Skip to content

Core concepts

A short tour of the model so the rest of the docs read naturally.

Posthorn is configured as a list of endpoints. Each endpoint is a (path, transport, recipients, templates, protections) bundle. An endpoint is independent — it has its own rate-limit counter, its own templates, its own honeypot field name.

You can configure as many endpoints as you want in a single config file. They share nothing operationally — submissions to /api/contact don’t count against the rate limit on /api/newsletter.

[[endpoints]]
path = "/api/contact"
# ...
[[endpoints]]
path = "/api/newsletter"
# ...

A transport is the egress side — the thing that actually delivers the email. Posthorn ships five transports: Postmark, Resend, Mailgun, AWS SES, and an outbound-SMTP relay. The Transport interface is identical across them, so adding a new one doesn’t require changes to the request pipeline.

Each endpoint has its own transport configuration:

[endpoints.transport]
type = "postmark"
[endpoints.transport.settings]
api_key = "${env.POSTMARK_API_KEY}"

Different endpoints can use different transports — useful for routing transactional vs. marketing mail to separate Postmark servers, for example.

Every form submission flows through this pipeline, in this order:

1. body size cap → http.MaxBytesReader wraps r.Body (413)
2. method check → POST only (405)
3. content-type check → form-encoded only (400)
4. origin/referer check → fail-closed if allowed_origins set (403)
5. rate limit check → token bucket, proxy-aware IP (429)
6. parse form → r.ParseForm() reads body (413/400)
7. honeypot check → silent 200 if field non-empty (200)
8. required fields → all listed fields present + non-empty (422)
9. email format → submitter email field syntactic (422)
10. generate submission ID (UUIDv4), log "submission_received"
11. render subject template
12. render body template + custom-fields passthrough
13. transport.Send() with retry policy
14. log outcome, write response (JSON or redirect) (200/502)

The order is intentional: cheaper checks before expensive ones, header-only checks before parsing the body, security checks before processing.

Every request gets a fresh UUIDv4 submission ID at step 10. That ID is included in every log line for that request — from “received” through “sent” or “failed” — and is returned in the JSON response. Save the ID in your logs and you can correlate every event for a single submission.

{"submission_id":"7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f", ...}

The required-fields list, the honeypot field, and the email field together define what Posthorn calls the named fields. Anything else in the form submission is passed through verbatim into an “Additional fields” block at the bottom of the rendered email body:

From: Alice <alice@example.com>
This is the message body the template rendered.
Additional fields:
company: Acme Corp
source: HN
newsletter_opt_in: yes

This means you can add new form fields without touching your config — they show up in the email automatically. Great for A/B testing form variations.

The retry policy is deliberately simple and bounded:

OutcomeAction
Transport returns 2xxLog success, return 200
Transport returns 5xx or network errorWait 1s, retry once
Transport returns 429 Too Many RequestsWait 5s, retry once
Transport returns 4xx (other than 429)Don’t retry — log error, return 502
10s elapses on the entire requestCancel retry, log error, return 502

The 10-second cap is hard. Even with retries in flight, the request returns by the 10-second mark. No unbounded waits.

On terminal failure, Posthorn logs the full submission payload at ERROR level by default. The thinking: the submission has already failed; the operator’s primary recovery path is reading the form contents out of the logs and sending the email manually. You can flip this off with log_failed_submissions = false for GDPR-sensitive contexts.

Posthorn is a single Go binary, distributed as a Docker container or a standalone executable. Run it as a sidecar to your apps and reverse-proxy your form endpoints to it from whatever front door you already use — Caddy, nginx, Traefik, Cloudflare. The reverse proxy page has worked examples.