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 https://tobiaseigen.org.
  • JavaScript 79.6%
  • HTML 9.6%
  • CSS 7.7%
  • Shell 1.9%
  • Dockerfile 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-15 13:51:14 +00:00
assets minor fixes 2020-02-01 18:51:37 +01:00
ghost-admin first drop 2020-02-01 18:40:02 +01:00
.env-example Extend with CiviCRM, newsletter double opt-in, configurable intents 2026-10-14 23:05:48 +00:00
.gitignore Extend with CiviCRM, newsletter double opt-in, configurable intents 2026-10-14 23:05:48 +00:00
demo.html first drop 2020-02-01 18:40:02 +01:00
docker-compose.yml Extend with CiviCRM, newsletter double opt-in, configurable intents 2026-10-14 23:05:48 +00:00
Dockerfile Initial commit: contact-form 2026-06-23 20:27:45 +00:00
ghost-contact-svc.js Extend with CiviCRM, newsletter double opt-in, configurable intents 2026-10-14 23:05:48 +00:00
ghost-contact.service first drop 2020-02-01 18:40:02 +01:00
install.sh first drop 2020-02-01 18:40:02 +01:00
LICENSE Initial commit 2020-02-01 15:47:13 +01:00
README.md added vibe coded note 2026-10-15 13:51:14 +00:00
README.md.bak spell check passed 2020-02-02 13:41:33 +01:00
test.sh first drop 2020-02-01 18:40:02 +01:00

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

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=true in .env to 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 fetch and crypto)
  • 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.

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-Auth and X-Civi-Key headers, 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';
  });

})();
<!-- 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:

  1. Service adds them to the CiviCRM newsletter group as Pending
  2. Confirmation email is sent with a unique token link (valid 24 hours)
  3. Clicking the link calls GET /v1/confirm?token=XXX
  4. 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_TO to your Discourse incoming email address
  • Set EMAIL_FROM_SENDER=true if 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