- JavaScript 79.6%
- HTML 9.6%
- CSS 7.7%
- Shell 1.9%
- Dockerfile 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| assets | ||
| ghost-admin | ||
| .env-example | ||
| .gitignore | ||
| demo.html | ||
| docker-compose.yml | ||
| Dockerfile | ||
| ghost-contact-svc.js | ||
| ghost-contact.service | ||
| install.sh | ||
| LICENSE | ||
| README.md | ||
| README.md.bak | ||
| test.sh | ||
This is a fork of ghost-contact-form by styxlab. Extended with CiviCRM integration, newsletter double opt-in, and Discourse support.
Note: this script was vibe coded. If you notice any issues or have suggestions for improvements, let me know!
Contents
- Features
- Hosting considerations
- Prerequisites
- Quickstart (local)
- Docker deployment
- Environment variables
- Configuring intents
- Editing user-facing text
- Nginx reverse proxy
- CiviCRM setup
- Frontend integration
- API endpoints
- Newsletter double opt-in
- Discourse email-in
- Testing end to end
- Troubleshooting
- License
contact-form
A self-hosted contact form backend for static websites. Accepts POST requests from your site, sends email notifications, and optionally integrates with CiviCRM for lead tracking and newsletter double opt-in.
Powers the contact form on tobiaseigen.org.
Features
- Checkbox-based intent form — visitors select what they're interested in (newsletter, consulting, project — all configurable) rather than filling a blank email form
- Notification email to you on every submission, formatted for easy reading and threaded replies
- Newsletter double opt-in — confirmation email with a token link; clicking confirms the subscription. Tokens are file-backed and survive container restarts.
- Optional CiviCRM integration — upserts contact, adds to per-intent groups, applies a tag, logs an activity on every submission
- Works great with Discourse email-in — creates a conversation thread you can reply to directly; optionally creates a staged user for the visitor
- Configurable intents — add, remove or rename checkbox options in one place
- All user-facing text in one place — easy to find and edit
Hosting considerations
The service runs anywhere Node.js and Docker are available. Running it on the same server as your mailcow installation has a key advantage: the container joins the mailcow Docker network and sends email via postfix as a trusted internal sender. This means:
- No external SMTP credentials needed for delivery to your own mailbox
- Notifications can be sent from the visitor's email address (not just Reply-To), so Discourse automatically creates a staged user for them and threads any reply directly back to them
- Set
EMAIL_FROM_SENDER=truein.envto enable this
If hosting elsewhere, leave EMAIL_FROM_SENDER=false and standard SMTP auth
is used with Reply-To set to the visitor.
Prerequisites
- Node.js v18 or later (uses built-in
fetchandcrypto) - Docker and Docker Compose
- An SMTP server
- A DNS record for your contact form subdomain (e.g.
contact.example.org) - Optional: a CiviCRM instance
Quickstart (local)
git clone https://git.digitallysovereign.org/tobias/contact-form.git
cd contact-form
npm install
cp .env-example .env
# edit .env
node ghost-contact-svc.js
Visit http://localhost:7000/v1/demo to test.
Docker deployment (recommended)
cp .env-example .env
nano .env # fill in your values
mkdir -p data # token store — persists across restarts
docker compose up -d
docker compose logs -f
You should see:
contact-form | Listening on http://localhost:7000
contact-form | [tokens] loaded 0 active token(s) from file
Running on the same server as mailcow
Add the mailcow network to docker-compose.yml so the container can send email
internally without external SMTP:
services:
contact-form:
...
networks:
contact-form-net:
mailcow-network:
ipv4_address: 172.22.1.243 # pick an unused IP on the mailcow subnet
networks:
contact-form-net:
driver: bridge
mailcow-network:
external: true
name: mailcowdockerized_mailcow-network
To find an available IP on the mailcow subnet:
docker network inspect mailcowdockerized_mailcow-network | grep -A2 '"IPv4Address"'
Environment variables
Required
| Variable | Description |
|---|---|
SMTP_HOST |
Hostname of your mail server |
SMTP_USER |
SMTP login / sending address |
SMTP_PASS |
SMTP password |
EMAIL_TO |
Address where your notifications are delivered |
Optional — behaviour
| Variable | Default | Description |
|---|---|---|
PORT |
7000 |
Port the service listens on |
EMAIL_FROM_SENDER |
false |
true = send notifications FROM the visitor's email. Only safe on a trusted internal mail network (e.g. mailcow Docker network). Creates staged Discourse users automatically. false = send from SMTP_USER with Reply-To set to the visitor. |
TOKENS_FILE |
./data/tokens.json |
Path to the newsletter confirmation token store |
Optional — CiviCRM
| Variable | Default | Description |
|---|---|---|
CIVICRM_ENABLED |
true |
Set false to disable CiviCRM entirely |
CIVICRM_BASE |
(none) | Base URL of your CiviCRM instance |
CIVICRM_API_KEY |
(none) | CiviCRM user API key |
CIVICRM_SITE_KEY |
(none) | CiviCRM site key |
CIVICRM_TAG_WEBSITE_LEAD |
8 |
Tag ID applied to every contact |
CIVICRM_ACTIVITY_TYPE_ID |
56 |
Activity type ID for form submission log |
CIVICRM_GROUP_NEWSLETTER |
2 |
Group ID for newsletter subscribers |
CIVICRM_GROUP_CONSULTING |
3 |
Group ID for consulting leads |
CIVICRM_GROUP_PROJECT |
4 |
Group ID for project leads |
Configuring intents (checkboxes)
Edit the INTENTS array near the top of ghost-contact-svc.js:
const INTENTS = [
{
value: 'newsletter', // sent in POST body from browser
label: 'Receive periodic newsletter', // shown in emails and CiviCRM
civiGroup: 2, // CiviCRM group ID (or null to skip)
newsletter: true, // true = triggers double opt-in email
},
{
value: 'consulting',
label: 'Learn about your tech consulting services',
civiGroup: 3,
newsletter: false,
},
{
value: 'project',
label: 'Help with a specific project',
civiGroup: 4,
newsletter: false,
},
];
Add, remove or rename entries here to change the form options.
Editing user-facing text
All copy — notification email, confirmation email, confirmation page messages —
is in the TEXTS object near the top of ghost-contact-svc.js. Each entry is
a string or a small function. Easy to find, easy to edit.
Nginx reverse proxy
On mailcow
Create /opt/mailcow-dockerized/data/conf/nginx/contact.example.org.conf:
server {
listen 80;
listen [::]:80;
server_name contact.example.org;
location ^~ /.well-known/acme-challenge/ {
allow all;
default_type "text/plain";
root /web;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name contact.example.org;
ssl_certificate /etc/ssl/mail/cert.pem;
ssl_certificate_key /etc/ssl/mail/key.pem;
limit_req zone=contact_limit burst=5 nodelay;
location / {
proxy_pass http://contact-form:7000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 10s;
}
}
Add the rate limit zone to your mailcow nginx global config if not already present:
limit_req_zone $binary_remote_addr zone=contact_limit:10m rate=10r/m;
Reload nginx:
cd /opt/mailcow-dockerized
docker compose exec nginx-mailcow nginx -t && \
docker compose exec nginx-mailcow nginx -s reload
SSL certificate (mailcow): add the subdomain to ADDITIONAL_SAN in
mailcow.conf then force-recreate the acme container:
docker compose up -d --force-recreate acme-mailcow
docker compose logs -f acme-mailcow
# watch for: contact.example.org verified!
Standalone nginx
server {
listen 443 ssl;
server_name contact.example.org;
ssl_certificate /etc/letsencrypt/live/contact.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/contact.example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:7000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Caddy
contact.example.org {
reverse_proxy localhost:7000
}
CiviCRM setup
Skip this section if CIVICRM_ENABLED=false.
1. Create groups
Contacts → Manage Groups → New Group — create one group per intent:
| Group | Type | Notes |
|---|---|---|
| Newsletter subscribers | Mailing List | Enables double opt-in |
| Consulting leads | Regular | Lead tracking |
| Project leads | Regular | Lead tracking |
Note each group ID (visible in the URL when editing).
2. Create a tag
Contacts → Tags → New Tag — create Website Lead.
Find its ID via the API4 Explorer at crm.example.org/civicrm/api4:
Entity: Tag → Action: Get → WHERE name = Website Lead → note id.
3. Create an activity type
Administer → Option Lists → Activity Types → Add — create Website Inquiry, note its ID.
4. Get your API credentials
User API key — install the API Keys extension, open your contact record in CiviCRM and set an API key. Generate a value with:
openssl rand -hex 16
Site key — find it in your CiviCRM settings:
grep CIVICRM_SITE_KEY /path/to/civicrm.settings.php
Test authentication:
curl -s \
-H "X-Civi-Auth: Bearer YOUR_API_KEY" \
-H "X-Civi-Key: YOUR_SITE_KEY" \
-H "X-Requested-With: XMLHttpRequest" \
--data-urlencode 'params={"limit":1}' \
"https://crm.example.org/civicrm/ajax/api4/Contact/get"
A JSON response (not a 401) means it's working.
Note: CiviCRM Standalone requires both
X-Civi-AuthandX-Civi-Keyheaders, and params must be form-encoded (not raw JSON body). The service handles this correctly.
Frontend integration
Add this to any static site. Adjust CONTACT_URL and the checkbox labels to
match your setup.
HTML
<form id="contactForm" novalidate>
<div class="form-group">
<label for="email">Email <span style="color:#2e9e7d">*</span></label>
<input type="email" id="email" name="email"
placeholder="Your email address" required>
</div>
<div class="form-group">
<label for="name">
Name <span style="font-weight:400;color:#888">(optional)</span>
</label>
<input type="text" id="name" name="name"
placeholder="Your name" minlength="2" maxlength="100">
</div>
<div class="form-group">
<fieldset style="border:none;padding:0;margin:0">
<legend style="font-weight:600;margin-bottom:0.6rem">I'd like to:</legend>
<label style="display:flex;gap:0.5rem;margin-bottom:0.5rem;cursor:pointer">
<input type="checkbox" name="intent" value="newsletter" id="chkNewsletter">
Receive your periodic newsletter
</label>
<label style="display:flex;gap:0.5rem;margin-bottom:0.5rem;cursor:pointer">
<input type="checkbox" name="intent" value="consulting" id="chkConsulting">
Learn about your tech consulting services
</label>
<label style="display:flex;gap:0.5rem;margin-bottom:0.5rem;cursor:pointer">
<input type="checkbox" name="intent" value="project" id="chkProject">
See if you can help with a specific project
</label>
</fieldset>
<p id="intentError" hidden
style="color:#c0392b;font-size:0.85rem;margin-top:0.4rem">
Please select at least one option.
</p>
</div>
<div class="form-group" id="messageGroup" hidden>
<label for="message">Tell me more</label>
<textarea id="message" name="message"
placeholder="Tell me about your situation."
maxlength="5000"></textarea>
</div>
<button type="submit" id="submitBtn">Send</button>
<div id="formMessage"></div>
</form>
JavaScript
(function () {
'use strict';
var CONTACT_URL = 'https://contact.example.org/v1/contact';
var chkNewsletter = document.getElementById('chkNewsletter');
var chkConsulting = document.getElementById('chkConsulting');
var chkProject = document.getElementById('chkProject');
var messageGroup = document.getElementById('messageGroup');
var messageField = document.getElementById('message');
var intentError = document.getElementById('intentError');
var params = new URLSearchParams(window.location.search);
// Pre-fill from URL params: ?intent=project&re=Email+migration
var intentParam = params.get('intent');
var reParam = params.get('re');
if (intentParam) {
intentParam.split(',').forEach(function (val) {
var chk = document.querySelector(
'input[name="intent"][value="' + val.trim() + '"]'
);
if (chk) chk.checked = true;
});
}
if (reParam) {
messageField.value = 'Re: ' + reParam + '\n\n';
}
function updateMessageVisibility() {
var needsMessage = chkConsulting.checked || chkProject.checked;
messageGroup.hidden = !needsMessage;
messageField.required = needsMessage;
messageField.minLength = needsMessage ? 10 : 0;
}
updateMessageVisibility();
[chkNewsletter, chkConsulting, chkProject].forEach(function (chk) {
chk.addEventListener('change', function () {
intentError.hidden = true;
updateMessageVisibility();
});
});
document.getElementById('contactForm').addEventListener('submit', async function (e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var msg = document.getElementById('formMessage');
var intents = Array.from(
document.querySelectorAll('input[name="intent"]:checked')
).map(function (el) { return el.value; });
if (!intents.length) {
intentError.hidden = false;
return;
}
if (!messageGroup.hidden && messageField.value.trim().length < 10) {
messageField.focus();
return;
}
btn.disabled = true;
btn.textContent = 'Sending…';
msg.style.display = 'none';
msg.className = '';
try {
var res = await fetch(CONTACT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: document.getElementById('email').value.trim(),
name: document.getElementById('name').value.trim(),
intents: intents,
message: messageField.value.trim(),
topic: params.get('re') || '',
}),
});
if (res.ok) {
msg.textContent = "Thanks for reaching out! I'll be in touch.";
msg.className = 'success';
this.reset();
updateMessageVisibility();
} else {
throw new Error('server error');
}
} catch (err) {
msg.textContent = 'Sorry, something went wrong. Please try again or email me directly.';
msg.className = 'error';
}
msg.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Send';
});
})();
URL params for pre-filling from service links
<!-- Pre-checks "project", pre-fills textarea with "Re: Email migration" -->
<a href="/?intent=project&re=Email+migration#contact">Migrate my email →</a>
<!-- Pre-checks "newsletter" only -->
<a href="/?intent=newsletter#contact">Subscribe to newsletter →</a>
<!-- Pre-checks two boxes -->
<a href="/?intent=project,consulting#contact">Get in touch →</a>
API endpoints
| Method | Path | Description |
|---|---|---|
POST |
/v1/contact |
Submit the contact form |
GET |
/v1/confirm?token=XXX |
Confirm newsletter subscription |
GET |
/v1/demo |
Demo form for local testing |
POST /v1/contact — request body
{
"email": "user@example.com",
"name": "Jane Smith",
"intents": ["newsletter", "consulting"],
"message": "Tell me more about your services.",
"topic": "Email migration"
}
topic is optional — the value of the re= URL param forwarded by the frontend
so it appears in the notification email subject.
Newsletter double opt-in
When a visitor checks the newsletter box:
- Service adds them to the CiviCRM newsletter group as Pending
- Confirmation email is sent with a unique token link (valid 24 hours)
- Clicking the link calls
GET /v1/confirm?token=XXX - Service flips their CiviCRM status to Added and shows a success page
Tokens are stored in data/tokens.json and survive container restarts. If a
visitor does not click within 24 hours the token expires — they remain as
Pending in CiviCRM and can resubmit the form to receive a fresh confirmation.
Discourse email-in
If you route notifications to a Discourse instance via email-in:
- Set
EMAIL_TOto your Discourse incoming email address - Set
EMAIL_FROM_SENDER=trueif on the mailcow internal network — sends the notification from the visitor's email, so Discourse creates a staged user for them automatically - When you reply in Discourse, the reply goes directly to the visitor
- If the visitor later joins Discourse with the same email, they gain access to the full conversation history
Testing end to end
curl -s -X POST https://contact.example.org/v1/contact \
-H "Content-Type: application/json" \
-d '{
"email": "you+test@example.org",
"name": "Test Person",
"intents": ["newsletter", "consulting"],
"message": "This is a test submission."
}'
Expected: {"ok":true}
Then check:
docker compose logs contact-form | tail -20
Expected:
[CiviCRM] contact upserted: id=92
[CiviCRM] contact 92 → group 2 (Pending)
[newsletter] confirmation email sent to you+test@example.org
[CiviCRM] contact 92 → group 3 (Added)
[CiviCRM] tag 8 applied to contact 92
[CiviCRM] activity logged for contact 92
[mail] notification sent for you+test@example.org
- Notification email arrived in your inbox
- Newsletter confirmation email arrived
- Clicking confirmation link shows success page and flips CiviCRM status
- Contact appears in CiviCRM with tag, groups and activity
- Deleting the test contact from CiviCRM
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
{"ok":true} but no notification email |
SMTP misconfiguration | Check logs for [mail] notify failed |
| No newsletter confirmation email | Token generation or SMTP failed | Check logs for [newsletter] opt-in flow failed |
| Confirmation link shows "Link expired" immediately | Container restarted, old token file missing | Check data/tokens.json exists and is writable |
| CiviCRM upsert failed | API or site key wrong | Test auth with curl — see CiviCRM setup section |
EntityTag already exists in logs |
Contact submitted the form before | Non-fatal — tag already applied, safe to ignore |
| Form checkboxes not working | Old and new JS submit handlers both present | Check for duplicate contactForm event listeners |
| Container env vars missing after edit | .env updated but container not recreated |
Run docker compose up -d --force-recreate |
License
MIT