Ghost is a great publishing platform, but it has no contact form. There's no form handler, and a Ghost theme can't run server-side code, so a theme can't send an email on its own. Most guides stop at "use a third-party form service" or "link to your email address". We wanted neither, so for ty1er.com's contact page we built a small relay that sends the form through Brevo, keeps our address out of the page, and shrugs off spam bots.

This post is the full, step-by-step version of that build, so you can add the same thing to your own self-hosted Ghost site. The code is what runs this site's contact form on Ghost 6, generalised so it drops into any server: our details are swapped for example.com, and the paths use a standard Ubuntu layout.

Illustration: the form before and after sending Left: the contact form with name, email, reason, message and a Send message button. Right: after a successful send, the form is replaced by a green panel with a check mark, the words Message sent!, and a Send another message button, with confetti around it. BEFORE Name * Alex Rivera Email * alex@example.org Reason Project or consulting inquiry Message * Hi! Could you help us move our blog to a self-hosted Ghost? Send message AFTER Message sent! Thanks, Alex. Your message is on its way, and I'll get back to you soon. Send another message Illustration, not a screenshot
What visitors get: a normal form, then a clear “Message sent!” panel. No mail app, no page reload.

Why Ghost has no contact form

Ghost only sends two kinds of email: newsletters, and member sign-in links. Neither can be triggered by a visitor filling in a form. Themes are Handlebars templates that Ghost renders, with no hook for running your own code when someone submits something. So your options are:

  • A mailto: link. Simple, but it opens the visitor's mail app (many people don't have one set up), you can lose the message, and your address sits in the page for scrapers to harvest. We started here and quickly moved on.
  • A hosted form service (Formspree, Tally, Google Forms and the like). This is the only option on Ghost(Pro), and it works fine. The costs are another account, message limits on free plans, and your visitors' data passing through a third party.
  • Your own tiny endpoint on the server that already runs Ghost. That's this guide. It's about 250 lines of PHP, and you control everything: where mail goes, how spam is handled, and what gets logged.

How the pieces fit together

How the contact form fits around Ghost The visitor's browser talks only to Nginx. Nginx sends normal pages to Ghost and sends the one path /contact-relay to a PHP script, which reads its private config, logs the submission, and sends the email through Brevo's SMTP relay to your inbox. YOUR SERVER Visitor /contact/ page + form script Nginx HTTPS :443 / → Ghost /contact-relay → PHP Ghost Node on 127.0.0.1:2368 renders the page send.php PHP-FPM · spam checks builds the email /etc/contact-relay.env SMTP key · inbox · secret submission log + spool every post · failed sends Brevo SMTP relay :587 STARTTLS Your inbox Reply goes to visitor GET/POST JSON reads writes SMTP
Nginx sends one exact path, /contact-relay, to PHP. Everything else still goes to Ghost.
  • Nginx already sits in front of Ghost. We add one exact-match route, location = /contact-relay, that goes to PHP-FPM instead of Ghost.
  • send.php is the relay. GET hands out a signed start token. POST runs the spam checks, builds the email, and sends it through Brevo over SMTP with STARTTLS.
  • /etc/contact-relay.env holds the secrets: the Brevo SMTP key, your inbox address, and a signing secret. It lives outside the theme and outside the web root, so nothing secret ships with your theme.
  • A custom Ghost template (custom-contact.hbs) renders the form and a few lines of JavaScript that talk to the relay.

Before you start

  • Self-hosted Ghost behind Nginx, with root or sudo SSH access. (On Ghost(Pro) you can't add server code, so use a hosted form service instead.)
  • PHP-FPM 8.x on the same server. Check with ls /run/php/ or ls /tmp/*.sock. Control panels like aaPanel often have it installed already. On plain Ubuntu, run apt install php8.3-fpm.
  • A free Brevo account. Its free plan covers far more contact-form mail than a personal site sends.
  • DNS access for your domain, so Brevo can authenticate it.

Read and verify the files before you use them

These files end up running on your server, and contact-report is installed as a root command. Don't take any script from the internet on trust, ours included. Download them into one folder, check they match what we published, and read them before installing anything. They're short, and nothing in them should surprise you.

mkdir -p ~/ghost-contact-form && cd ~/ghost-contact-form
for f in send.php custom-contact.hbs contact-relay.env.example contact-report SHA256SUMS; do
  curl -fsSLO https://www.ty1er.com/content/files/2026/09/ghost-contact-form/$f
done
sha256sum -c SHA256SUMS      # every line should end in: OK
less send.php contact-report # read them before installing

If any line says FAILED, the file isn't the one we published, so don't use it. These are the expected SHA-256 hashes:

FileSHA-256
send.php78828e7c5f5f071499955376ecbecb50861371ae8823c4ac164b8c7162a5fc66
custom-contact.hbs3cd8d595670e77e4bece2b493fae13abdc59636a52e8150a2de82fecc205a336
contact-relay.env.example220d4950733354850adf670e85054000a65275f646ae3a786fd635c9c5d2a369
contact-reportde57fa13e597c5f1303d7d9e32f1cbc08ffd520e7a73d0af2688da76bd880203

The steps below install from this verified folder. If you later edit a file, for example to change its paths, its hash will change. That's expected.

Step 1: Set up Brevo

  1. In Brevo, go to Senders, Domains & Dedicated IPs → Domains and add your domain. Brevo shows you a few DNS records (a Brevo code, DKIM and DMARC). Add them at your DNS host and wait for Brevo to show the domain as authenticated.
  2. Pick the address the form will send from, on that domain, for example contactform@example.com. It doesn't need a real mailbox. It only has to be on the authenticated domain.
  3. Go to SMTP & API → SMTP and generate an SMTP key. Copy the key and the login shown on that page.

The gotcha that cost us an evening: the SMTP login is the generated address that looks like 8a1b2c001@smtp-brevo.com, not the email you signed up to Brevo with. Using your account email gets you 535 Authentication failed. The key must also be an SMTP key, not a v3 API key.

If your Ghost install already sends newsletters or sign-in emails through Brevo, you can reuse that same SMTP login and key. A separate key just lets you revoke the contact form on its own later.

Step 2: Install the relay script

Put send.php in its own folder, owned by root and readable by the PHP-FPM user. That's www-data on Ubuntu and www on aaPanel. Check yours with ps -eo user,args | grep "php-fpm: pool".

mkdir -p /var/www/contact-relay
install -o root -g www-data -m 644 ~/ghost-contact-form/send.php /var/www/contact-relay/send.php
php -l /var/www/contact-relay/send.php

What it does, top to bottom:

  • Loads /etc/contact-relay.env. It refuses to run (503 not_configured) while any required value is missing or still says CHANGE_ME.
  • GET returns {"t": <unix time>, "sig": <HMAC-SHA256 of t>}. The form fetches this when the page loads.
  • POST runs six checks in order, then sends. See step 6.
  • Sends with a minimal built-in SMTP client (EHLO, STARTTLS, AUTH LOGIN, DATA), so there's no Composer or PHPMailer to install. Every header value is stripped of line breaks, which blocks header injection. Reply-To is the visitor, so hitting Reply answers them.
  • Never loses a message. If Brevo rejects a send, the whole email is saved as an .eml file in a spool folder, and the visitor still sees success.
  • Logs every POST, real or bot, as one JSON line per submission, for the report in step 7.
View the full send.php
<?php
/**
 * contact-relay — sends a Ghost contact form through Brevo SMTP.
 * From "How to Add a Real Contact Form to Ghost CMS" on ty1er.com.
 *
 * Served by PHP-FPM at the exact path /contact-relay (an nginx location).
 *   GET  → {"t": <unix time>, "sig": <hmac>}   start token, fetched on page load
 *   POST → {"ok": true} | {"ok": false, "error": "<code>"}
 *
 * Credentials, sender and the destination inbox live only in
 * /etc/contact-relay.env on the server (never in your theme, never in page HTML).
 */

declare(strict_types=1);

const ENV_FILE      = '/etc/contact-relay.env';
const MIN_FILL_SECS = 5;        // faster than this is a bot
const MAX_TOKEN_AGE = 7200;     // start token valid for 2 hours
const RATE_MAX      = 5;        // messages per IP…
const RATE_WINDOW   = 3600;     // …per hour
const LOG_FILE      = '/var/log/contact-relay.log';
const SPOOL_DIR     = '/var/log/contact-relay-spool';   // unsent messages, php-user-only 700
const SUBMIT_DIR    = '/var/log/contact-relay-submissions'; // every POST, one JSONL file per month, php-user-only 700

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');

function reply(int $code, array $body): void {
    http_response_code($code);
    echo json_encode($body);
    exit;
}

/**
 * Record every POST (real or bot) as one JSON line in SUBMIT_DIR/YYYY-MM.jsonl,
 * with the outcome and what was submitted, then reply. Read it with
 * `contact-report` (see README). Contains visitor data: server-only, 600.
 */
function finish(string $outcome, int $code, array $body, array $extra = []): void {
    $f   = fn(string $k, int $max): string => is_string($_POST[$k] ?? null) ? mb_substr(trim($_POST[$k]), 0, $max) : '';
    $row = [
        'time'     => gmdate('c'),
        'outcome'  => $outcome,
        'ip'       => $_SERVER['REMOTE_ADDR'] ?? '',
        'ua'       => mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 300),
        'origin'   => $_SERVER['HTTP_ORIGIN'] ?? '',
        'name'     => $f('name', 200),
        'email'    => $f('email', 200),
        'reason'   => $f('reason', 80),
        'company'  => $f('company', 200),
        'message'  => $f('message', 5000),
        'honeypot' => $f('website', 200),
    ] + $extra;
    if (is_dir(SUBMIT_DIR) || @mkdir(SUBMIT_DIR, 0700, true)) {
        $file = SUBMIT_DIR . '/' . gmdate('Y-m') . '.jsonl';
        $new  = !file_exists($file);
        @file_put_contents($file, json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND | LOCK_EX);
        if ($new) @chmod($file, 0600);
    }
    reply($code, $body);
}

function logline(string $msg): void {
    @file_put_contents(LOG_FILE, gmdate('c') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}

function load_config(): ?array {
    // KEY=VALUE lines; blank lines and # comments ignored (parse_ini_file
    // would choke on # comments in PHP 7+).
    $lines = @file(ENV_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if ($lines === false) return null;
    $cfg = [];
    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) continue;
        [$k, $v] = explode('=', $line, 2);
        $cfg[trim($k)] = trim($v);
    }
    foreach (['SITE_URL', 'SITE_ORIGINS', 'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'MAIL_FROM', 'MAIL_TO', 'FORM_SECRET'] as $k) {
        if (empty($cfg[$k]) || str_starts_with((string) $cfg[$k], 'CHANGE_ME')) return null;
    }
    return $cfg;
}

// Strip CR/LF and control chars from anything that lands in a mail header.
function hdr(string $s, int $max = 200): string {
    return mb_substr(trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $s) ?? ''), 0, $max);
}

// RFC 2047 encode a header value if it isn't plain ASCII.
function enc(string $s): string {
    return preg_match('/[^\x20-\x7E]/', $s) ? '=?UTF-8?B?' . base64_encode($s) . '?=' : $s;
}

function rate_limited(string $ip): bool {
    $file = sys_get_temp_dir() . '/contact-relay-' . hash('sha256', $ip);
    $now  = time();
    $hits = array_filter(
        array_map('intval', @file($file, FILE_IGNORE_NEW_LINES) ?: []),
        fn($t) => $t > $now - RATE_WINDOW
    );
    if (count($hits) >= RATE_MAX) return true;
    $hits[] = $now;
    @file_put_contents($file, implode("\n", $hits), LOCK_EX);
    return false;
}

/** Minimal SMTP client: EHLO → STARTTLS → AUTH LOGIN → one message. */
function smtp_send(array $cfg, string $to, string $data): void {
    $sock = @stream_socket_client('tcp://' . $cfg['SMTP_HOST'] . ':' . (int) $cfg['SMTP_PORT'], $errno, $errstr, 15);
    if (!$sock) throw new RuntimeException("connect failed: $errstr");
    stream_set_timeout($sock, 15);

    $read = function () use ($sock): string {
        $resp = '';
        while (($line = fgets($sock, 1024)) !== false) {
            $resp .= $line;
            if (strlen($line) < 4 || $line[3] === ' ') break;
        }
        return $resp;
    };
    $cmd = function (string $line, int $expect) use ($sock, $read): void {
        if ($line !== '') fwrite($sock, $line . "\r\n");
        $resp = $read();
        if ((int) substr($resp, 0, 3) !== $expect) {
            // Never log AUTH payloads.
            $shown = str_starts_with($line, 'AUTH') || $line === '' || !ctype_print($line) ? '[redacted]' : $line;
            throw new RuntimeException("SMTP $shown → " . trim($resp));
        }
    };

    $host = parse_url($cfg['SITE_URL'] ?? 'https://localhost', PHP_URL_HOST) ?: 'localhost';
    $cmd('', 220);
    $cmd("EHLO $host", 250);
    $cmd('STARTTLS', 220);
    if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT)) {
        throw new RuntimeException('STARTTLS negotiation failed');
    }
    $cmd("EHLO $host", 250);
    $cmd('AUTH LOGIN', 334);
    fwrite($sock, base64_encode($cfg['SMTP_USER']) . "\r\n"); $r = $read();
    if ((int) substr($r, 0, 3) !== 334) throw new RuntimeException('SMTP AUTH user rejected → ' . trim($r));
    fwrite($sock, base64_encode($cfg['SMTP_PASS']) . "\r\n"); $r = $read();
    if ((int) substr($r, 0, 3) !== 235) throw new RuntimeException('SMTP AUTH rejected → ' . trim($r));
    $cmd('MAIL FROM:<' . $cfg['MAIL_FROM'] . '>', 250);
    $cmd("RCPT TO:<$to>", 250);
    $cmd('DATA', 354);
    // Dot-stuff lines that start with "." and terminate.
    fwrite($sock, preg_replace('/^\./m', '..', $data) . "\r\n.\r\n");
    $cmd('', 250);
    fwrite($sock, "QUIT\r\n");
    fclose($sock);
}

// ── Request handling ─────────────────────────────────────────────────────

$cfg = load_config();
if ($cfg === null) reply(503, ['ok' => false, 'error' => 'not_configured']);

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($method === 'GET') {
    $t = time();
    reply(200, ['t' => $t, 'sig' => hash_hmac('sha256', (string) $t, $cfg['FORM_SECRET'])]);
}

if ($method !== 'POST') {
    header('Allow: GET, POST');
    reply(405, ['ok' => false, 'error' => 'method']);
}

// Same-site only: browsers always send Origin on a cross-site POST.
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_map('trim', explode(',', $cfg['SITE_ORIGINS'] ?? ''));
if ($origin !== '' && !in_array($origin, $allowed, true)) {
    logline('bad-origin ip=' . ($_SERVER['REMOTE_ADDR'] ?? '') . ' origin=' . hdr($origin));
    finish('bad-origin', 403, ['ok' => false, 'error' => 'origin']);
}

$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$in = fn(string $k): string => is_string($_POST[$k] ?? null) ? trim($_POST[$k]) : '';

// Honeypot: pretend success so bots don't learn anything.
if ($in('website') !== '') { logline("honeypot ip=$ip"); finish('honeypot', 200, ['ok' => true]); }

// Signed start token: proves the form was loaded here, and not too fast.
$t   = (int) $in('started_at');
$sig = $in('started_sig');
$age = time() - $t;
if (!hash_equals(hash_hmac('sha256', (string) $t, $cfg['FORM_SECRET']), $sig) || $age > MAX_TOKEN_AGE) {
    $bad = hash_equals(hash_hmac('sha256', (string) $t, $cfg['FORM_SECRET']), $sig) ? 'expired-token' : 'bad-token';
    logline("$bad ip=$ip");
    finish($bad, 400, ['ok' => false, 'error' => 'expired']);
}
if ($age < MIN_FILL_SECS) { logline("too-fast ip=$ip age=$age"); finish('too-fast', 200, ['ok' => true], ['fill_secs' => $age]); }

$name    = hdr($in('name'), 120);
$email   = hdr($in('email'), 200);
$reason  = hdr($in('reason'), 60) ?: 'General inquiry';
$company = hdr($in('company'), 200);
$message = mb_substr(str_replace("\r\n", "\n", $in('message')), 0, 5000);

if ($name === '' || $message === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    finish('invalid', 400, ['ok' => false, 'error' => 'invalid'], ['fill_secs' => $age]);
}
$reasons = ['General inquiry', 'Project or consulting inquiry', 'Partnership', 'Something else'];
if (!in_array($reason, $reasons, true)) $reason = 'Something else';

if (rate_limited($ip)) { logline("rate-limited ip=$ip"); finish('rate-limited', 429, ['ok' => false, 'error' => 'rate'], ['fill_secs' => $age]); }

$site     = parse_url($cfg['SITE_URL'] ?? '', PHP_URL_HOST) ?: 'website';
$fromName = hdr($cfg['MAIL_FROM_NAME'] ?? "$site contact form", 80);
$subject  = "[$site] $reason — $name";
$body = "Name: $name\nEmail: $email\nReason: $reason\n"
      . ($company !== '' ? "Company/Website: $company\n" : '')
      . "\nMessage:\n$message\n\n--\nSent from " . ($cfg['SITE_URL'] ?? '') . "/contact/ (IP $ip)\n";

$data = implode("\r\n", [
    'Date: ' . date(DATE_RFC2822),
    'From: ' . enc($fromName) . ' <' . $cfg['MAIL_FROM'] . '>',
    'To: <' . $cfg['MAIL_TO'] . '>',
    'Reply-To: ' . enc($name) . " <$email>",
    'Subject: ' . enc($subject),
    'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $site . '>',
    'MIME-Version: 1.0',
    'Content-Type: text/plain; charset=UTF-8',
    'Content-Transfer-Encoding: base64',
    '',
    rtrim(chunk_split(base64_encode($body), 76, "\r\n")),
]);

try {
    smtp_send($cfg, $cfg['MAIL_TO'], $data);
    logline("sent ip=$ip reason=\"$reason\"");
    finish('sent', 200, ['ok' => true], ['fill_secs' => $age]);
} catch (Throwable $e) {
    logline('error ip=' . $ip . ' ' . $e->getMessage());
    // Don't lose the message: keep the full .eml so it can be read or re-sent.
    $file = SPOOL_DIR . '/' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.eml';
    if ((is_dir(SPOOL_DIR) || @mkdir(SPOOL_DIR, 0700, true)) && @file_put_contents($file, $data, LOCK_EX) !== false) {
        @chmod($file, 0600);
        logline('spooled ' . basename($file));
        finish('spooled', 200, ['ok' => true, 'queued' => true], ['fill_secs' => $age, 'error' => $e->getMessage()]);
    }
    finish('send-failed', 502, ['ok' => false, 'error' => 'send_failed'], ['fill_secs' => $age, 'error' => $e->getMessage()]);
}

Step 3: Create the config file

The config lives in /etc, readable only by root and the PHP user. It never goes in your theme or a git repo.

install -o root -g www-data -m 640 ~/ghost-contact-form/contact-relay.env.example /etc/contact-relay.env
sed -i "s/^FORM_SECRET=.*/FORM_SECRET=$(openssl rand -hex 32)/" /etc/contact-relay.env
nano /etc/contact-relay.env

Fill in each CHANGE_ME:

SITE_URL=https://www.example.com
SITE_ORIGINS=https://www.example.com,https://example.com
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=8a1b2c001@smtp-brevo.com      # the generated login, not your account email
SMTP_PASS=xsmtpsib-…                     # the SMTP key
MAIL_FROM=contactform@example.com       # on your Brevo-authenticated domain
MAIL_FROM_NAME=Example.com contact form
MAIL_TO=you@example.com                 # where messages arrive; only ever lives here
FORM_SECRET=…                           # already generated by the sed line above

Don't paste the key into a chat, a ticket, or a shell history you share. If the SMTP values are already in Ghost's config.production.json, you can copy them over on the server without ever displaying them. We did exactly that with a short Python one-liner that reads the mail block and rewrites only the matching lines of the env file.

Step 4: Add the Nginx route

Open the Nginx server block for your Ghost site, the one that has listen 443 ssl and proxy_pass http://127.0.0.1:2368. Add this above the location / block:

# Contact form relay: this one path goes to PHP, everything else to Ghost.
location = /contact-relay {
    client_max_body_size 32k;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /var/www/contact-relay/send.php;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;   # your PHP-FPM socket
}

Test and reload, and read the output. A failed test means the reload never happened:

nginx -t && systemctl reload nginx

Then check from anywhere:

curl -s https://www.example.com/contact-relay

You want JSON: {"t":1790445890,"sig":"…"}. If you get {"ok":false,"error":"not_configured"}, the route works but the env file still has a CHANGE_ME in it.

If you get a redirect or an HTML page instead, Ghost is still answering that path, so Nginx isn't using your route. Look for x-powered-by: Express in curl -sI, which means Ghost answered. It happened to us several times: the edit went into a backup copy of the config, into the port-80 block, or into a file the control panel later regenerated. This command shows the config Nginx actually loaded:

nginx -T 2>/dev/null | grep -n "listen 443\|contact-relay"

Step 5: Add the contact page template

Copy custom-contact.hbs from ~/ghost-contact-form into the root of your theme folder, next to page.hbs. Then zip and upload the theme in Settings → Design → Change theme, or copy the file over if you deploy themes by SSH (restart Ghost afterwards). In Ghost Admin, create a page called “Contact”. In its settings panel, pick Contact under Template, and publish it.

The template is intentionally plain, so it drops into any theme. Its short inline <style> block is easy to replace with your theme's own styles. The parts that matter:

  • The honeypot: a text field named website, positioned off-screen and hidden from screen readers. People never fill it. Bots fill every field they find.
  • The token fetch: on page load, fetch('/contact-relay') gets the signed start time. The form sends it back with the message.
  • The success panel: after {"ok": true}, the form hides and a “Message sent!” panel takes its place, with the visitor's first name.
  • The reason dropdown must match the $reasons list in send.php. Anything else is recorded as “Something else”.
// The heart of the form script: token on load, token back on submit.
function fetchToken() {
    return fetch('/contact-relay', { credentials: 'same-origin', cache: 'no-store' })
        .then(function (r) { return r.ok ? r.json() : null; })
        .then(function (j) { token = (j && j.sig) ? j : null; return token; });
}

function send(tok) {
    var data = new URLSearchParams(new FormData(form));
    data.set('started_at', tok.t);
    data.set('started_sig', tok.sig);
    return fetch('/contact-relay', { method: 'POST', credentials: 'same-origin', body: data })
        .then(function (r) { return r.json(); });
}
View the full custom-contact.hbs
{{!< default}}
{{!--
  custom-contact.hbs — a contact page for any Ghost theme.
  From "How to Add a Real Contact Form to Ghost CMS" on ty1er.com.

  1. Save this file in your theme's root folder, upload the theme.
  2. In Ghost Admin, create a page and pick "Contact" under Template.
  3. The form posts to /contact-relay (send.php behind nginx).

  The <option> values below must match the $reasons list in send.php.
--}}

{{#post}}
<article class="contact-page">
    <header class="contact-header">
        <h1>{{title}}</h1>
        {{#if custom_excerpt}}<p>{{custom_excerpt}}</p>{{/if}}
    </header>

    {{content}}

    <div class="contact-card">
        <div class="contact-success" id="contact-success" role="status" aria-live="polite" tabindex="-1" hidden>
            <svg class="contact-success-check" viewBox="0 0 52 52" aria-hidden="true">
                <circle cx="26" cy="26" r="24" fill="none" stroke="currentColor" stroke-width="2.5"/>
                <path d="M15 27.5l7.5 7.5L38 19" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
            <h2>Message sent!</h2>
            <p>Thanks<span id="contact-success-name"></span>. Your message is on its way, and I'll get back to you soon.</p>
            <button type="button" id="contact-again">Send another message</button>
        </div>

        <form class="contact-form" id="contact-form" novalidate>
            <label for="cf-name">Name *</label>
            <input id="cf-name" name="name" type="text" autocomplete="name" required>

            <label for="cf-email">Email *</label>
            <input id="cf-email" name="email" type="email" autocomplete="email" required>

            <label for="cf-reason">Reason</label>
            <select id="cf-reason" name="reason">
                <option>General inquiry</option>
                <option>Project or consulting inquiry</option>
                <option>Partnership</option>
                <option>Something else</option>
            </select>

            <label for="cf-company">Company or website (optional)</label>
            <input id="cf-company" name="company" type="text">

            <label for="cf-message">Message *</label>
            <textarea id="cf-message" name="message" rows="6" required></textarea>

            {{!-- Honeypot: people never see or fill this; bots fill everything. --}}
            <div class="contact-hp" aria-hidden="true">
                <label for="cf-website">Leave this field empty</label>
                <input id="cf-website" name="website" type="text" tabindex="-1" autocomplete="off">
            </div>

            <button class="contact-submit" type="submit">Send message</button>
            <p class="contact-status" id="contact-status" role="status" aria-live="polite" hidden></p>
        </form>
    </div>
</article>
{{/post}}

<style>
    .contact-page { max-width: 680px; margin: 0 auto; padding: 48px 20px 80px; }
    .contact-form { display: grid; gap: 8px; }
    .contact-form input, .contact-form select, .contact-form textarea { width: 100%; box-sizing: border-box; padding: 11px 13px; font: inherit; border: 1px solid #ccd; border-radius: 8px; margin-bottom: 10px; }
    .contact-hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
    .contact-submit { justify-self: start; padding: 12px 24px; font: inherit; font-weight: 600; border: 0; border-radius: 8px; background: #111; color: #fff; cursor: pointer; }
    .contact-submit[disabled] { opacity: .6; cursor: wait; }
    .contact-status { margin: 8px 0 0; }
    .contact-status.is-err { color: #c62828; }
    .contact-success { text-align: center; padding: 32px 20px; border: 1.5px solid #16a34a; border-radius: 14px; background: #f0fdf4; color: #14532d; }
    .contact-success[hidden] { display: none; }
    .contact-success-check { width: 72px; height: 72px; color: #16a34a; }
    .contact-success button { padding: 10px 18px; font: inherit; border: 1px solid #16a34a; border-radius: 8px; background: transparent; cursor: pointer; }
</style>

<script>
(function () {
    var RELAY   = '/contact-relay';
    var form    = document.getElementById('contact-form');
    if (!form) return;
    var status  = document.getElementById('contact-status');
    var button  = form.querySelector('.contact-submit');
    var success = document.getElementById('contact-success');
    var token   = null;   // { t, sig }: the relay's signed "page loaded at" time

    function show(msg, ok) {
        status.hidden = false;
        status.textContent = msg;
        status.className = 'contact-status ' + (ok ? 'is-ok' : 'is-err');
    }

    // Ask the relay for a signed start time as soon as the page loads.
    function fetchToken() {
        return fetch(RELAY, { credentials: 'same-origin', cache: 'no-store' })
            .then(function (r) { return r.ok ? r.json() : null; })
            .then(function (j) { token = (j && j.sig) ? j : null; return token; })
            .catch(function () { token = null; return null; });
    }
    fetchToken();

    var ERRORS = {
        invalid:     'Please check your name, email, and message and try again.',
        expired:     'This form has been open a while. Please send it again.',
        rate:        'Too many messages from this connection. Please try again later.',
        unavailable: 'The form can’t be reached right now. Your message is still here, so please try again in a few minutes.'
    };

    function showSuccess(name) {
        document.getElementById('contact-success-name').textContent = name ? ', ' + name.split(/\s+/)[0] : '';
        status.hidden = true;
        form.hidden = true;
        success.hidden = false;
        success.scrollIntoView({ behavior: 'smooth', block: 'center' });
        success.focus({ preventScroll: true });
    }
    document.getElementById('contact-again').addEventListener('click', function () {
        success.hidden = true;
        form.hidden = false;
        form.reset();
        document.getElementById('cf-name').focus();
    });

    function send(tok) {
        var data = new URLSearchParams(new FormData(form));
        data.set('started_at', tok.t);
        data.set('started_sig', tok.sig);
        return fetch(RELAY, { method: 'POST', credentials: 'same-origin', body: data })
            .then(function (r) { return r.json().catch(function () { return {}; }); });
    }

    function handle(res, retried, name) {
        if (res.ok) { showSuccess(name); return; }
        if (res.error === 'expired' && !retried) {        // stale tab: new token, one retry
            return fetchToken().then(function (t) {
                return t ? send(t).then(function (r2) { return handle(r2, true, name); }) : show(ERRORS.unavailable, false);
            });
        }
        show(ERRORS[res.error] || ERRORS.unavailable, false);
    }

    form.addEventListener('submit', function (e) {
        e.preventDefault();
        if (form.website.value) { showSuccess(''); return; }   // honeypot: act normal, send nothing

        var name    = form.name.value.trim();
        var email   = form.email.value.trim();
        var message = form.message.value.trim();
        if (!name || !email || !message) { show('Please fill in your name, email, and a message.', false); return; }
        if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { show('That email address doesn’t look right. Please check it.', false); return; }

        button.disabled = true;
        show('Sending…', true);
        (token ? Promise.resolve(token) : fetchToken())
            .then(function (tok) { return tok ? send(tok).then(function (res) { return handle(res, false, name); }) : show(ERRORS.unavailable, false); })
            .catch(function () { show(ERRORS.unavailable, false); })
            .then(function () { button.disabled = false; fetchToken(); });
    });
})();
</script>

On ty1er.com we added one extra flourish: a short confetti burst on success, drawn on a temporary <canvas> that removes itself after three seconds. It's about 40 lines with no library. Skip it for anyone whose system has prefers-reduced-motion turned on.

Step 6: Understand the spam defences

Six checks before anything is sent Every POST passes through six checks in order: same-site origin, honeypot field, signed start token, five-second minimum fill time, field validation, and a per-IP rate limit. Anything that fails is logged with the reason. Only messages that pass all six are sent. POST 1 · Origin same site only 2 · Honeypot hidden field must be empty 3 · Token HMAC-signed start time 4 · Timer at least 5 seconds 5 · Fields real email, no header tricks 6 · Rate 5 per IP per hour sent ✓ bad-origin honeypot bad-token too-fast invalid rate-limited Every rejection is logged with its reason. Bots caught by the honeypot or the timer still get a normal “success” reply, so they never learn what tripped them. SPAM DEFENCE, IN ORDER
Cheap checks first. Mail is only sent after all six pass.
  1. Origin. Browsers always send an Origin header on a cross-site POST, so the relay refuses any that isn't in SITE_ORIGINS. This stops other websites from using your form as a mail cannon.
  2. Honeypot. If the hidden website field has anything in it, the relay replies “ok” and sends nothing.
  3. Signed token. started_sig must be the HMAC of started_at under your FORM_SECRET. Bots that POST straight to the endpoint without loading the page can't produce one, and they're logged as bad-token. Genuine tokens expire after two hours, and the page quietly fetches a fresh one and retries once.
  4. Five-second timer. The signed start time also proves how long the form was open. Anything submitted within five seconds of page load is a bot. It gets a fake success and is logged as too-fast. A person can't fill in a name, an email and a message that quickly, and five seconds is still short enough that nobody real waits on it.
  5. Fields. A name, a message and a valid email are required. Every value is length-capped and stripped of control characters before it touches an email header.
  6. Rate limit. Five messages per IP per hour, tracked in a tiny file per (hashed) IP.

There's no CAPTCHA, because none of this ever asks a real visitor to do anything.

One message, start to finish When the page loads, the browser asks the relay for a signed start time. When the visitor submits, the browser posts the form with that token. The relay checks it, sends the email through Brevo, logs it, and returns ok, and the page shows the success panel. Browser send.php Brevo 1 page loads → GET /contact-relay {"t": 1790445890, "sig": "hmac…"} 2 visitor types (≥ 5 s) 3 POST form + started_at + started_sig origin · honeypot · signature 5-second timer · fields · rate 4 SMTP: STARTTLS → AUTH → DATA 250 OK: queued 5 {"ok": true} → “Message sent!” + confetti
The whole round trip. The token is issued at step 1 and checked at step 3.

Step 7: See what's coming in

Every POST is appended to /var/log/contact-relay-submissions/YYYY-MM.jsonl (the path is a constant at the top of send.php). Each line records the outcome, time, IP, user agent, how many seconds the form took, and what was typed. It's a record of real messages, a backup if an email ever goes missing, and a clear view of what the bots are up to. Create the folders once:

install -d -o www-data -g www-data -m 700 /var/log/contact-relay-submissions /var/log/contact-relay-spool
touch /var/log/contact-relay.log && chown www-data:www-data /var/log/contact-relay.log

Change the LOG_FILE, SPOOL_DIR and SUBMIT_DIR constants in send.php (and SUBMIT_DIR in contact-report) if you use different paths. The log holds visitors' names and emails, so keep it at 700/600 and never inside a web root.

Then install the report command:

install -o root -g root -m 700 ~/ghost-contact-form/contact-report /usr/local/bin/contact-report

contact-report                  # last 7 days
contact-report 30 --all         # 30 days, including every blocked bot post
contact-report --csv 30 > contact.csv

Here's what it printed after we sent three fake bots at our own form (IP shortened):

Contact form: last 1 day(s)  (2026-09-25 → 2026-09-26 UTC)
────────────────────────────────────────────────────────────────────────
Total posts: 3   Real messages: 0   Blocked as bots/invalid: 3

  too-fast           1
  bad-token          1
  honeypot           1

Per day (real / blocked):
  2026-09-26     0 / 3   ▇▇▇

All posts (newest first):
────────────────────────────────────────────────────────────────────────
2026-09-26 18:37:30Z  honeypot        -  [203.0.113.7]
  Honeypot Bot <bot@example.com>  · -
  "filled every field"
  honeypot: "http://spam.example"

2026-09-26 18:37:30Z  bad-token       -  [203.0.113.7]
  Forged Token <bot@example.com>  · -
  "no token here"

2026-09-26 18:37:30Z  too-fast        1s  [203.0.113.7]
  Instant Bot <bot@example.com>  · -
  "Buy cheap SEO now"

Step 8: Test it

  1. Endpoint: curl -s https://www.example.com/contact-relay returns a token.
  2. A real send: open /contact/, wait at least five seconds, fill it in, and send. The success panel appears and tail /var/log/contact-relay.log shows sent. Check the inbox, and the spam folder the first time.
  3. Reply: hit Reply on the email. It should be addressed to whatever email you typed in the form.
  4. A bot: send a POST with no token and check that the log records bad-token:
    curl -s -X POST -H "Origin: https://www.example.com" \
      -d name=Bot -d email=bot@example.com -d message=spam \
      https://www.example.com/contact-relay

Troubleshooting

What you seeWhat it meansFix
curl gets a 301 or HTML, x-powered-by: ExpressGhost is answering. Nginx isn't using the route.Check nginx -T | grep contact-relay. Put the block in the 443 server, above location /, then run nginx -t and reload.
503 not_configuredThe env file is missing, unreadable, or still has CHANGE_ME.Run grep -c "=CHANGE_ME" /etc/contact-relay.env and expect 0. Check that the owner is root:<php user> and the mode is 640.
502 Bad GatewayNginx can't reach PHP-FPM.Fix the socket path in fastcgi_pass, and check that PHP-FPM is running.
Log: SMTP AUTH rejected → 535Wrong SMTP login or key.Use the …@smtp-brevo.com login and an SMTP key, not your account email or an API key.
Log says sent, but nothing arrivesBrevo accepted it and then dropped or filtered it.Check that MAIL_FROM is on an authenticated domain, look in Brevo's transactional logs, and check your spam folder.
Success panel shows, log says too-fastYou submitted within 5 seconds (autofill makes this easy).Wait a few seconds before sending. This is the timer working.
“Too many messages from this connection”You hit 5 sends in an hour while testing.Wait an hour. The limit resets on its own.

Wrapping up

That's the whole thing: one PHP file, one config file, one Nginx route, and one Ghost template. Visitors get a form that just works, with no mail app and a clear success message. You get mail through a properly authenticated sender, spam stopped without a CAPTCHA, and a log of everything that came in. Nothing depends on a third-party form service, and your email address never appears on the page.

Want to see it working? Try the form on our contact page. It's the same code.