Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

128 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

form-handler

CI release image reference scorecard licence

Turns a contact form submission into an email. That is the whole job.

It was written for the contact form on andthensome.nl, because Cloudflare Workers cannot open an SMTP connection. Workers can send mail through an HTTP email API, but the mail for that domain is self-hosted, so a small service that speaks SMTP was the simpler answer than routing the site's contact form through a third party.

It holds any number of forms across any number of sites. One endpoint per form, each with its own allowed origins, its own recipient and its own subject line — so a careers form on one domain and a contact form on another share a deployment without sharing an inbox, and neither can post to the other.

Requirements

  • Go 1.25 or newer, or Docker if you would rather run the image.
  • An SMTP server it may send through. In production that is a mail bridge on the same host; locally it is a throwaway container, below.
  • A sending address the mail server will accept, and somewhere to deliver to. There are no defaults and the service refuses to start without them.
  • The origins that may post to it. Also no default — there is no origin that is right for everybody, and the consequence of guessing is somebody else's page using your mailbox.

Installation

git clone https://github.com/alrayyes/form-handler.git
cd form-handler
go build ./cmd/form-handler

Or take the image CI builds, which is what actually runs in production:

docker pull ghcr.io/alrayyes/form-handler:latest

Every image is built by ko from a digest-pinned distroless base and carries a build provenance attestation, so you can check what built it and from which commit:

gh attestation verify oci://ghcr.io/alrayyes/form-handler:latest --repo alrayyes/form-handler

Running it

go run ./cmd/form-handler

It listens on :8080. How you configure it depends on how many forms you have.

One form

Set three variables and post to /contact. This is what a single-site deployment wants, and what the service did before it could hold more than one:

MAIL_FROM=site@example.com \
MAIL_TO=info@example.com \
ALLOWED_ORIGINS=https://www.example.com \
  go run ./cmd/form-handler
Variable Default What it does
MAIL_FROM required Envelope and header sender. Must be an address the mail server will accept.
MAIL_TO required Where submissions land.
ALLOWED_ORIGINS required Comma-separated. A request from anywhere else is refused.
SMTP_ADDR localhost:1025 host:port of the mail server.
SMTP_USERNAME empty Omit for a local bridge that does not authenticate.
SMTP_PASSWORD empty Required if SMTP_USERNAME is set.
RATE_LIMIT_PER_HOUR 5 Submissions per client address. 0 disables it.
FORMS_FILE unset Path to a forms file. Setting it replaces the four variables above — see below.
TRUSTED_PROXIES unset Comma-separated IPs or CIDRs of proxies in front. Unset ignores X-Forwarded-For.
ADDR :8080 Listen address.
LOG_LEVEL info debug, info, warn or error. Health checks are logged at debug.

That form is called default, which is what makes both /contact and /contact/default reach it.

Several forms

Point FORMS_FILE at a YAML file. There is a commented example in forms.example.yaml:

smtp:
  addr: smtp.eu.mailgun.org:587

forms:
  - id: marketing
    origins:
      - https://www.example.com
      - https://example.com
    from: postmaster@mg.example.com
    to: info@example.com
    subject: "Contact form: {{ .Name }}"
    rate_limit_per_hour: 5
    smtp:
      username: postmaster@mg.example.com
      password_env: MAILGUN_EXAMPLE_COM

  - id: careers
    origins:
      - https://careers.example.org
    from: postmaster@mg.example.org
    to: jobs@example.org
    subject: "Application from {{ .Name }}"
    smtp:
      addr: smtp.mailgun.org:587
      username: postmaster@mg.example.org
      password_env: MAILGUN_EXAMPLE_ORG

That serves POST /contact/marketing and POST /contact/careers. The id is the last path segment, so it has to survive being in a URL — lowercase letters, digits, - and _.

origins is per form on purpose. A site being allowed to use its own form must not let it use somebody else's, and the integration test asserts exactly that. Each entry is scheme://host[:port] and nothing after it, which is all a browser ever sends in the Origin header — www.example.com and example.com are different origins, so list both if you serve both.

subject is a Go text/template over .Name, .Email and .Form. Leave it out for Contact form: {{ .Name }}. Line breaks are stripped from whatever it renders, because the name feeding it is whatever the visitor typed.

rate_limit_per_hour defaults to 5. An explicit 0 turns the limit off, which is deliberately not the same as leaving the key out.

A login per domain

smtp appears twice on purpose: once at the top of the file as the default, and once inside a form to override it field by field. A form that only sets a username keeps the shared address.

It is per form because it has to be. Mailgun — and any provider that authenticates per sending domain — issues a separate login for each domain, so a service holding two domains holds two logins and cannot share one between them. The integration test proves this end to end by pointing two forms at two different mail servers and asserting that a submission to one never appears on the other.

The password is the one thing that is not in the file. password_env names an environment variable to read it from, which is what lets the forms file live in git next to the site it serves:

MAILGUN_EXAMPLE_COM=... MAILGUN_EXAMPLE_ORG=... FORMS_FILE=/etc/form-handler/forms.yaml form-handler

If that variable is empty or unset, the service refuses to start. A missing secret should be a failed deploy while the old container is still running, not a form that silently stops sending. Use password: inline instead if you mount the whole file as a secret and would rather keep it self-contained — setting both is an error, because it is a question about which one wins.

Mailgun instead of SMTP

A form sends through SMTP or through Mailgun, and names one or the other — never both. Setting both is refused at startup, because it is a question about which one wins and any answer is somebody's surprise.

forms:
  - id: marketing
    origins: ["https://www.example.com"]
    from: postmaster@mg.example.com
    to: info@example.com
    mailgun:
      domain: mg.example.com
      region: eu
      api_key_env: MAILGUN_EXAMPLE_COM

domain is the sending domain as Mailgun knows it — usually mg.example.com rather than example.com. region is us (the default) or eu; which one you get is decided when the domain is created, and sending to the wrong one fails authentication rather than redirecting. api_key_env follows the same rule as the SMTP password: the file names the secret, the deployment supplies it, and an unset variable refuses to start.

A top-level mailgun: block sets defaults for every form, exactly as smtp: does — useful when several sending domains share one Mailgun account and only domain differs.

Forms can disagree about this. One domain on Mailgun and another on a self-hosted SMTP bridge is a supported arrangement, not a workaround, and there is a test asserting it.

Which wins

The forms file is the whole story once it exists: MAIL_FROM, MAIL_TO, ALLOWED_ORIGINS, RATE_LIMIT_PER_HOUR and the SMTP_* variables are all ignored, because a half-file-half-environment configuration is the kind of thing that works locally and surprises you in production.

Everything in that file is checked at startup. An unknown key, a duplicate id, an origin with a path on it, an address that will not parse, a subject template with an unclosed brace: all of them refuse to start, rather than waiting for the first submission to find out.

Locally

Run a throwaway mail server and point at it:

docker run --rm -p 1025:1025 -p 8025:8025 \
  axllent/mailpit:v1.21.8@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a

Mailpit's web interface is on http://localhost:8025, and anything the service sends appears there instead of on the internet.

Three flags, all for asking the binary about itself rather than for running it:

form-handler --version      # the tag it was built from, or "dev"
form-handler --healthcheck  # probe the local /healthz and exit non-zero if it fails
form-handler --help         # everything it takes, generated from the command

--healthcheck exists because the image is distroless. There is no shell and no curl in there for a container healthcheck to run, so the binary has to be able to probe itself.

The single-dash spellings — -version and -healthcheck — still work, and will. They are what this took before the arguments were parsed by cobra, and a container healthcheck configured back then is baked into compose files that are already deployed. pflag would otherwise read -healthcheck as a cluster of shorthands and refuse it with unknown shorthand flag: 'e' in -ealthcheck, marking every running container unhealthy the moment it pulled a new image. Only those two exact arguments are translated, so a mistyped -nonsence is still an error rather than something quietly promoted into a flag.

The endpoint

POST /contact/{form}, JSON in, JSON out. /contact on its own is an alias for the form called default.

{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "message": "Please get in touch about an awkward system.",
  "website": ""
}

website is the honeypot: it is hidden from people, so anything that fills it in is automated. Unknown fields are rejected outright.

Status Meaning
202 Accepted. Also what a honeypot submission gets — see below.
204 A preflight was answered. OPTIONS only, and the body is empty.
400 The body could not be read, or contained fields we do not know.
403 The Origin is not one of this form's.
404 No form by that name.
405 Anything other than POST or OPTIONS.
422 A field is wrong. The body names which one and why.
429 Too many submissions from this address this hour.
500 The form's subject template would not render.
502 The mail server would not take it.

GET /healthz answers {"status":"ok"} and deliberately does not test SMTP: a mail server being briefly unreachable is not a reason for an orchestrator to restart a process that is answering perfectly well.

The whole contract is written down in api/openapi.yaml: every status in that table, what the request body accepts, and the headers a preflight gets back. It is worth trusting because nothing about it is hand-maintained on the honour system. Both halves are held to the service — every documented response is provoked out of the real handler, and every limit the schema states is posted at the endpoint one character inside it and one character past. Change a limit in the code without changing the document and a test goes red. That table spent its whole life missing three codes, which is the argument for having a description a test can read.

Behind a proxy

Set TRUSTED_PROXIES to the addresses or CIDRs of whatever sits in front — Traefik, nginx, Cloudflare Tunnel, a load balancer:

TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12

If you do not, X-Forwarded-For is ignored entirely and the rate limit counts by the address that actually connected. Behind a proxy that means every visitor looks like the proxy, so the whole internet shares one bucket and the first five submissions an hour are the only ones that get through.

The reason it is not simply believed is that the header is set by whoever sent the request. Trusting it unconditionally means anyone can send a different address on every request and get a fresh bucket each time, which is a rate limit that stops accidents and nothing else. So it is believed exactly as far as the proxies that added it are trusted: the address used is the rightmost entry in the chain that is not itself one of yours, and the header is ignored outright when the connection did not come from a trusted proxy.

Cloudflare's ranges are published and change; if you proxy through it, take them from https://www.cloudflare.com/ips/ rather than copying a list from here that will be wrong by the time you read it.

Logs

JSON on stdout, and every request gets a line — including health checks, preflights and requests for paths nobody serves. Anything the service answered is in there:

{
  "level": "INFO",
  "msg": "request",
  "method": "POST",
  "path": "/contact/marketing",
  "status": 202,
  "duration_ms": 84
}

A submission that was refused gets two lines: that one, saying what happened, and one from the form saying why. One is for counting, the other for acting on.

The level follows the outcome — 5xx is ERROR, 4xx is WARN, the rest INFO — so "show me what is going wrong" is a filter rather than a read-through:

docker logs form-handler | jq 'select(.status >= 400)'

Health checks are at debug. An orchestrator probes /healthz every few seconds, and at info that would be the only thing anybody ever saw. Set LOG_LEVEL=debug to see them:

LOG_LEVEL=debug form-handler

A refusal says why, and names the origin it saw:

{
  "time": "2026-08-11T18:04:11Z",
  "level": "WARN",
  "msg": "refused submission",
  "form": "marketing",
  "status": 403,
  "reason": "origin not allowed",
  "ip": "203.0.113.7",
  "origin": "https://staging.example.com",
  "cf_ray": "8f2b1c4d5e6f7a8b-AMS"
}

403 and 429 are WARN — somebody else's page posting here, or one address flooding the form, is worth noticing. A visitor mistyping their address is INFO.

This is how you tell whether a missing submission was refused here or never arrived. A form that stops working has two quite different causes with identical symptoms: something in front of this service — Cloudflare, a proxy — turning the request away, or this service refusing it on the origin check. Post a test submission and look:

  • A line with status: 403 — it reached the service and the origin field says what it presented. Add that origin to the form, or fix what the site is sending.
  • No line at all — it never got here. The problem is in front: a Cloudflare rule, a WAF, DNS, or the proxy.

cf_ray is Cloudflare's request id, copied from CF-Ray when present, so a line here can be matched against the same request in Cloudflare's own logs.

Headers are truncated before they reach a log line — origin and cf_ray are whatever the sender chose to put in them.

Deploying

CI builds and pushes an image to ghcr.io/alrayyes/form-handler on every release, for linux/amd64 and linux/arm64, tagged with the version and with latest. A branch build runs too, but stops after building — enough to prove the image still builds before you merge, without publishing anything.

There is no Dockerfile. The image is assembled by ko, which compiles the binary and writes the layers itself over the registry API. That started as a workaround for a runner that would allow neither a Docker daemon nor unshare(CLONE_NEWUSER), and stayed because it is faster and needs no qemu to build both architectures. What a Dockerfile would say now lives in .ko.yaml: the same distroless base, still pinned by digest rather than following the tag.

To build it yourself:

VERSION=dev KO_DOCKER_REPO=ko.local ko build --bare --local ./cmd/form-handler

The entrypoint is /ko-app/form-handler, not /form-handler. ko puts the binary under /ko-app, so a compose healthcheck that shells the old path will fail with "no such file" and the container will sit there unhealthy. Use form-handler --healthcheck instead; that is what it is for.

Pin the digest in your compose file, not the tag. A tag can be moved; a digest cannot, which is the difference between knowing what is running and assuming it. form-handler --version inside the container tells you which release a digest is, so pinning a digest no longer means losing the version.

Contributing

Everything about working on this — how it is put together, how to run the tests, the decisions worth knowing before you change one, and how a release is cut — is in CONTRIBUTING.md. Short version: write the outer test first, branch, push, open a pull request, and let someone else merge it. Commit messages follow Conventional Commits, and they are what pick the next version number.

Found a security problem? Report it privately rather than as an issue. See SECURITY.md.

Licence

GPL-3.0-or-later. Every source file carries the SPDX identifier.

About

Turns a contact form submission into an email. Any number of forms, any number of domains, one endpoint each.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages