Part of the formgate series.
What
formgate is around 1300 lines of Go across nine files, nothing outside the standard library. Part 1 covered the design decisions: why the emailed OTP had to go, why the sender and recipient come only from config, and how each pipeline step picks a failure direction. This part is the implementation. The whole pipeline lives in one HTTP handler, and the failure directions from Part 1 show up as concrete branches inside it.
Why
Three bets from Part 1, restated as rules the code must obey:
- A failed Turnstile token is dropped before anything costs money or a connection.
- A broken bad-email service never drops a real enquiry.
- A failed acknowledgement never fails a delivered enquiry.
The handler below is those three rules, plus the open-relay closure, written as control flow.
How
Step 3: Turnstile, and expiry as its own code
The siteverify call returns a result with an error-codes array. The interesting branch is not success or failure; it is what kind of failure.
if !result.Success {
if result.Expired() {
writeJSON(w, http.StatusForbidden, apiResponse{codeTokenExpired, "verification expired, please try again"})
return
}
writeJSON(w, http.StatusForbidden, apiResponse{codeVerifyFailed, "verification failed"})
return
}
Expired is one error-code check:
func (r TurnstileResult) Expired() bool {
for _, c := range r.ErrorCodes {
if c == "timeout-or-duplicate" {
return true
}
}
return false
}
Cloudflare reports timeout-or-duplicate for a token older than 300 seconds or already redeemed. That is a human with a stale tab, not a bot, so it gets 400007 and the frontend fetches a fresh token and resubmits once, silently. A genuinely failed challenge gets 400006. The distinction is one string comparison, and it is the difference between a person retyping a form and nothing happening.
Step 4: the hostname the token was solved on
The siteverify response includes the domain the challenge was actually solved on. It comes from Cloudflare, not from the client, so it cannot be spoofed.
if !site.AllowsHostname(result.Hostname) {
writeJSON(w, http.StatusForbidden, apiResponse{codeVerifyFailed, "verification failed"})
return
}
This closes the replay path where a token minted on one site is replayed against another. Each site config lists its allowed hostnames, and the check runs before anything with a cost.
Steps 5 and 6: the sender address
ValidateFrom does two things. It strips CR and LF, and it requires a bare address; “Display Name a@b.com” forms are rejected. The submitter’s address never becomes the envelope sender, only the Reply-To, but it lands in a header, so it gets the same treatment.
Then the bad-email lookup, with the fail-open decision from Part 1 as code:
allowed, err := g.badEmail.Allowed(r.Context(), from)
if err != nil {
allowed = true
}
The service behind that call holds the disposable-domain lists, the bounce history from ESP webhooks, and live MX verification. formgate reimplements none of it. A blocked verdict is honoured; only an unreachable service is waved through, and it is logged at warn level.
Step 7: the body, and the injection that matters
The mail goes out as text/plain to an internal inbox, so markup is inert and formgate does not scrub HTML. What it enforces is valid UTF-8, no control characters, a hard size cap, and the block patterns from config.
The header-safe part is separate, because headers are where injection actually bites:
func SanitizeHeaderValue(v string) string {
v = strings.ReplaceAll(v, "\r", "")
v = strings.ReplaceAll(v, "\n", "")
return strings.TrimSpace(v)
}
A newline in a header field lets the sender append headers of their own, Bcc included. Stripping CR and LF kills that path.
Truncation has its own subtlety. Header length is measured in bytes, so the cap is in bytes, and a plain slice can land in the middle of a multi-byte rune. The result would be invalid UTF-8 that gets encoded without complaint into a subject line no mail client can render back:
v = v[:maxBytes]
for len(v) > 0 && !utf8.ValidString(v) {
v = v[:len(v)-1]
}
Backing off to the last valid boundary drops at most three bytes and keeps the subject readable.
Step 8: the send, and where the relay closes
msg := Message{
FromAddress: site.FromAddress,
ToAddress: site.ToAddress,
ReplyTo: from,
Subject: subject,
Body: body,
}
This struct is the open-relay closure from Part 1 in one place. The request contributes exactly two things: the body text and the Reply-To. The envelope sender, the recipient, and the subject come from server-side config. Bounces and vacation responders go to the noreply address; only a person pressing reply reaches the team.
One delivery detail worth knowing: a Cc header alone delivers nothing. The address gets its own RCPT TO in the SMTP conversation, or it does not get the mail.
Step 9: the acknowledgement that cannot fail
sendAck returns nothing. On failure it logs and returns; the request is already a success by then, because the enquiry was delivered in step 8. Failing the request over a failed ack would report failure for a job that succeeded.
The ack’s Reply-To is the site’s own enquiry recipient, and this one is load-bearing: every ack body tells the submitter “replying will reach us”. Without that header the reply goes to the noreply address the mail was sent from, and the sentence was false. The envelope sender stays noreply, so only a human reply is affected.
Per-site transport: the 553
The envelope sender is always the site’s from address, so the SMTP account must be verified for that domain. A shared account sending as a domain it does not own gets refused by the relay:
553 "Sender is not allowed to relay emails"
A site in that position gets its own smtp block in config and its own mailer, built once at startup rather than per request. DutyTick’s wishlist form is the case this was built for: it sends through DutyTick’s own SES identity, because a second sending provider on that domain would work against the reputation the product is selling.
The process: systemd and a clean shutdown
The binary is a long-lived process. It binds the port at startup, serves every request in the same process, and shuts down on SIGTERM within a 20 second window:
[Unit]
Description=formgate contact form gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=formgate
WorkingDirectory=/opt/formgate
ExecStart=/opt/formgate/formgate -config /opt/formgate/config.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Logs go to stdout as slog text, which journald picks up. /healthz answers for uptime checks.
CORS is handled in the same service: the sites live on different origins and the request is application/json, so browsers send an OPTIONS preflight. The gateway answers it and echoes the Origin back only when its hostname appears in some site’s allowed hostnames.
What this does not do
There is no rate limiting at any layer. Turnstile does not provide it, and nothing here stops repeated submissions from one person or a token farm. Volumetric abuse is dropped at the edge, in the WAF in front of the CloudFront distributions, before it reaches this service.
The old system had an email usage log table intended to cap one send per address per week. It was only ever written by one of twelve workflows, so the cap never applied. Nothing writes it now. The cap belongs in this pipeline someday, and until it lands, the WAF is doing the job.