Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Section titled “[Unreleased]”Nothing yet.
[1.2.0] — 2026-07-04
Section titled “[1.2.0] — 2026-07-04”Spam-protection minor. Adds three content-agnostic checks that target automated contact-form spam — the kind that fills valid-looking fields, varies its wording and language, and reuses throwaway sender addresses, so there’s no content signature to match. All are opt-in, form-mode only, and backwards-compatible; existing configs are unaffected.
- Reputation check (#44). Optional
[endpoints.reputation]— a StopForumSpam lookup on the submitter email and/or IP before send, blocking known form-spam identities regardless of message content or language. Bounded LRU result cache; fails open by default so a provider outage never blocks real mail (fail-open events incrementposthorn_check_failed_open_total{check="reputation"}). Sends email+IP to a third party — opt-in. - Proof-of-browser check (#45, ADR-18). Optional
[endpoints.proof_of_browser]— Posthorn serves a challenge token from aGETon the endpoint that only page JavaScript can fetch and inject as_pob_token, blocking bots that POST the form directly. An optionalmin_agetime-trap rejects impossibly-fast submissions. No third party; works on static sites. - Captcha check (#33). Optional
[endpoints.captcha]— Cloudflare Turnstile verification, the escalation tier for bots that render a real browser. Byte-identical silent 200 on a rejected token; fails closed by default on a provider outage. - New
posthorn_check_failed_open_total{endpoint,check}metric andspam_blockedkindsreputation/proof_of_browser/captcha.
Changed
Section titled “Changed”- The three spam checks (origin, honeypot, CSRF) were refactored into a two-phase pipeline (#60): header checks pre-parse, form checks post-parse, each producing a
spam.Verdictthe handler renders through one mapping. No behavior change — the extraction is what let the three new checks land as small additions rather than more inline blocks.
- The SMTP ingress was constructed with a nil metrics recorder, so
posthorn_submissions_sent_total{endpoint="smtp_listener"}and its failure counter never emitted. The registry and recorder are now shared with the HTTP mux, and the real send latency is recorded (was hardcoded 0). (#57) posthorn validatenow builds the SMTP ingress, so a listener config with a semantic error (e.g.require_tlswith notls_cert) is caught at validate time instead of failing at boot. (#58)
Recipes
Section titled “Recipes”- Added Authentik and Mastodon SMTP-listener recipes to the docs site.
[1.1.0] — 2026-07-04
Section titled “[1.1.0] — 2026-07-04”A security-hardening and operational-maturity release, from a full audit of the shipped v1.0. One breaking change (below) that closes an open-relay footgun; everything else is additive or a fix.
Breaking
Section titled “Breaking”auth_required = "none"on a public bind is now refused at config-parse time (#41). An SMTP listener withauth_required = "none"whoselistenaddress is not verifiably loopback/private (e.g.:2525,0.0.0.0:2525, a public IP, or an unresolvable hostname) is a startup error. Settrusted_network = trueto assert the listener is reachable only from a trusted network — a Docker bridge with noports:exposure, a VPN, or a firewall. Migration: deployments built from the pre-existing Ghost / Gitea / internal-relay recipes usedauth_required = "none"withlisten = ":2525"and must addtrusted_network = true(the updated recipes include it). This closes the open-relay footgun where the sender allowlist was the only gate on an unauthenticated public listener.
- SMTP listener brute-force and resource hardening (#50). Per-remote-IP AUTH-failure budget (10 failures/minute; further attempts get
421and the connection closes; successful auths never consume budget). Global (max_connections, default 100) and per-IP (max_connections_per_ip, default 16) concurrent-connection caps; over-cap connections get a421before any protocol work. - CSRF rejections now increment
posthorn_spam_blocked_total{kind="csrf"}— previously they were log-only and invisible on/metrics. spam_blocked(honeypot and origin) andcsrf_rejectedlog lines now carryclient_ipfor operator forensics, matchingrate_limited; omitted whenstrip_client_ip = true(FR59).
- An
[smtp_listener]-only config (no[[endpoints]]) is now accepted — the config validator required at least one HTTP endpoint, breaking the documented Ghost and Gitea recipes and every SMTP-listener-only deployment.posthorn validateoutput now names the listener so a listener-only config doesn’t report a bare “0 endpoint(s)”. (#37) - SMTP-listener sends now honor the FR19-22 retry policy — a transient provider error is retried once (as the HTTP ingress already did) instead of returning an immediate
451. Previously a provider blip could drop a transactional email on the SMTP path that the HTTP path would have retried. The retry policy is now shared between both ingresses. (#59) - The HTTP server now sets
ReadTimeout(15s) andWriteTimeout(30s, chosen to exceedReadTimeout+ the handler’s 10s request bound so a legitimate slow upload followed by a provider retry isn’t truncated after the mail was sent) plus an explicit 64 KBMaxHeaderBytes, so slow-body senders and slow-reading clients can’t hold connections indefinitely. (#42) - Raw stdlib parse errors are no longer echoed to clients on malformed bodies; both parse paths return a generic 400 and log the detail server-side as
body_parse_failed. Deliberate API-shape messages (e.g. “nested objects are not supported”) remain client-visible. (#42)
Security & supply chain
Section titled “Security & supply chain”- CI now runs a
gofmtgate,govulncheck, andgolangci-linton every push and pull request. (#38) - Docker base images are pinned by digest, and Dependabot keeps the base images, Go modules, and GitHub Actions current. (#39)
- Release images now ship SLSA provenance and SBOM attestations, are signed with cosign (keyless via GitHub OIDC), and are gated on a Trivy vulnerability scan. (#56)
Development
Section titled “Development”- New hermetic provider-testing harness (
core/providertest): ingress→egress end-to-end tests for all five transports and the SMTP listener, run on every PR with no credentials. Amake test-liverunner and a scheduled, protected workflow validate against real provider APIs (against non-delivering targets; SES via OIDC, no stored key). See #76. Posthorn deliberately does not test deliverability/DKIM — the provider owns signing and reputation.
[1.0.0] — 2026-05-26
Section titled “[1.0.0] — 2026-05-26”Initial public release. The v1.0 spec is in spec/. Four feature blocks ship in a single release: form-mode HTTP ingress, API-mode HTTP ingress, multi-transport + operational maturity, and an SMTP listener. Originally sequenced as v1.0 → v1.1 → v1.2 → v1.3 themed releases; consolidated into v1.0 before tag.
Block A — HTTP form ingress
Section titled “Block A — HTTP form ingress”- HTTP form ingress with multiple independent endpoints per config (FR1, FR2)
- Postmark HTTP API transport with bespoke ~80-line client (FR3, FR4, ADR-1)
- Honeypot field, Origin/Referer fail-closed check, max body size (1 MB safe default; configurable), token-bucket rate limit with LRU eviction at 10K IPs (FR5–FR9, NFR4, NFR6)
- Required-field and email-format validation returning structured 422 (FR10, FR11)
- Go
text/templaterendering for subject and body with custom-fields passthrough block (FR12, FR13) - JSON responses, content negotiation,
redirect_success/redirect_error(FR14–FR16) - Per-request UUIDv4
submission_idin the 200 JSON body on both real-success and silent-honeypot paths — byte-identical body shape so a bot inspecting the response cannot distinguish honeypot rejection from success (FR5, NFR5) - Retry policy: one retry on transient/5xx (1s), one retry on 429 (5s), no retry on 4xx config errors, 10s hard request timeout (FR19–FR22)
- Structured JSON logging with UUIDv4 submission IDs propagated through every log line;
submission_sentcarriestransport_message_idso operators jump straight from Posthorn logs to the provider’s UI (FR17, FR18, NFR7, NFR8) - Standalone binary
cmd/posthornwithserveandvalidatesubcommands, SIGTERM/SIGINT graceful shutdown (FR24–FR26) - Multi-stage Dockerfile producing a distroless static image for
linux/amd64andlinux/arm64, published toghcr.io/craigmccaskill/posthornon tag push (NFR12, NFR13) - GitHub Actions CI:
go vetandgo test -race -count=1 - Public documentation site at posthorn.dev (Astro + Starlight)
- Strict TOML field validation — unknown / misspelled config keys (e.g.,
starttlswhen the real field isrequire_tls) fail at parse time with a clear error naming the offending key, rather than being silently ignored and producing wrong runtime behavior.
Block B — API mode
Section titled “Block B — API mode”- API-mode endpoints via per-endpoint
auth = "api-key"config; defaultauth = "form"preserves form-mode behavior (FR31, FR45) - API-key authentication via
Authorization: Bearer <key>with constant-time comparison (crypto/subtle.ConstantTimeCompare); multiple keys per endpoint for rotation (FR33, FR34, NFR19) - Per-API-key rate limiting on API-mode endpoints — workers sharing egress IPs (Cloudflare Workers, etc.) get independent buckets (FR35)
- JSON content type on API-mode endpoints (
application/json); flat-object body shape with primitive type coercion to template variables; nested objects rejected with 400 (FR36, FR37, FR38, FR39) - Idempotency keys via standard
Idempotency-Keyheader; in-memory per-endpoint LRU cache, 24-hour TTL, byte-identical response replay; HTTP 409 on concurrent in-flight collisions (FR40–FR44, NFR20) - New
core/idempotency/package; newidempotency_cache_sizeendpoint config (default 10000) - Per-request
to_overridein api-mode JSON bodies — string or array of email addresses replaces the endpoint’stolist for the request. Each address validated as syntactic email; empty array or any invalid address returns 422.fromis intentionally not overridable to prevent spoofing via leaked keys (FR46, ADR-11) - API keys configured via
${env.VAR}are subject to NFR3’s “never appear in log output” invariant; tests verify with sentinel-key assertions (NFR21) - Per-IP brute-force defense for failed auth: 10 failed attempts from one IP within ~1 minute trip a bucket and subsequent failures return 429 (instead of 401) until the budget refills. Successful auths never consume the budget, so legitimate callers are unaffected. New
auth_rate_limitedlog event records the lockout for forensic follow-up.
Block C — Multi-transport + operational maturity
Section titled “Block C — Multi-transport + operational maturity”- Resend HTTP API transport — bespoke client (~150 LOC); Bearer auth; JSON body; ErrorClass mapping (FR47)
- Mailgun HTTP API transport — bespoke client (~180 LOC); HTTP Basic auth; multipart/form-data body via
mime/multipart.Writer; US + EU regions (FR48) - AWS SES transport — bespoke SigV4 implementation (~230 LOC) shared in
core/transport/awssigv4.gofor any future AWS-signed transport (S3, SNS) + SESv2SendEmailrequest shape (~265 LOC) (FR49, ADR-14) - Outbound SMTP transport — stdlib
net/smtp.PlainAuth+ STARTTLS; per-Send connect/AUTH/MAIL/RCPT/DATA/QUIT cycle; 30s per-Send timeout (FR50, ADR-17) - Transport registry pattern — new file in
core/transport/callsRegister()ininit(); config + cmd/posthorn dispatch throughLookup(). Adding a sixth transport requires zero edits to config or main (FR4, FR51, FR52, FR53) /healthzendpoint returning200 OKwith bodyok; auth-free, fixed path (FR54)/metricsendpoint with hand-rolled Prometheus text exposition (FR55, ADR-15) — counters for submissions received / sent / failed (witherror_class), rate-limit hits, auth failures, spam blocks, idempotent replays, validation failures; latency histogram with operator-meaningful buckets. Submitter content never enters the label space (NFR24)- New
core/metrics/package:Registry,Counter,Histogram, exposition writer; nil-safeRecorderfor gateway integration - Dry-run mode via per-endpoint
dry_run = true— runs full pipeline up to but not includingtransport.Send, returns 200 with{"status":"dry_run","submission_id":"...","prepared_message":{...}}(FR56) - CSRF tokens via HMAC-SHA256 over Unix timestamp — operator-issued at form-render time using
csrf_secret; Posthorn verifies on submit; form-mode only (api-mode rejectscsrf_secretat parse time) (FR57, ADR-16) - New
core/csrf/package withIssue/Verifyhelpers;csrf_secretandcsrf_token_ttlendpoint config;_csrf_tokenreserved form field name - Named
trusted_proxiespresets —cloudflareshipped in full;aws-elb,gcp-lb,azure-front-doorreserved as empty slots awaiting maintained ranges. Mix presets and explicit CIDRs in one list (FR58) strip_client_ipendpoint option — omits the resolved client IP from log lines (rate-limited, etc.) for GDPR-conscious deployments; rate-limit keying unaffected (FR59)
Block D — SMTP ingress
Section titled “Block D — SMTP ingress”- New
core/ingress/package: minimalIngressinterface (Start/Stop/Name);HTTPIngresswrapshttp.Serverfor the v1.0 lifecycle (FR60, FR61, ADR-12) - New
core/smtp/package: TCP listener accepting SMTP from internal clients; full state machine for EHLO/STARTTLS/AUTH/MAIL/RCPT/DATA/QUIT/RSET/NOOP (FR62) - SMTP AUTH PLAIN with constant-time password compare; client-cert auth as alternative (
auth_required = "either") (FR63) auth_required = "none"mode for internal-network deployments where network access already implies trust (Docker bridge, loopback). Disables the AUTH state entirely; the sender allowlist (allowed_senders) and recipient cap remain in force as the only ingress gates. Pairs withrequire_tls = falseto skip the TLS cert requirement.- STARTTLS required by default (
require_tls = true); rejected before AUTH/MAIL when plaintext (FR67) - Sender allowlist (
allowed_senders, exact-or-*@domainsyntax) — required, non-empty (FR64) - Recipient cap OR allowlist (default cap 10 per session) preventing RCPT bombing (FR65)
- Message size cap (
max_message_size, default 1MB) — DATA exceeding returns552 5.3.4(FR66) - MIME →
transport.Messageconversion (FR68): recipients come from SMTP envelope (RCPT TO), never from MIMETo/Cc/Bccheaders — a malicious DATA blob with smuggledBcc:cannot add recipients (NFR22) - Per-session structured logging with
session_idUUID; password never logged on auth failures (NFR23) cmd/posthornstarts both HTTP and SMTP ingresses in their own goroutines when[smtp_listener]is configured; SIGTERM drains both with a 15s deadline
Removed (before tag)
Section titled “Removed (before tag)”- The Caddy v2 adapter module (previously planned as a secondary deployment shape under FR27–FR30 / NFR10 / ADR-6 / ADR-7) was cut before tagging v1.0.0. The single-shape standalone-behind-any-reverse-proxy story keeps the product thesis cleaner and avoids ongoing per-feature carve-outs. Caddy users keep first-class support as a reverse proxy — see posthorn.dev/deployment/reverse-proxy.
- Batch send API (originally listed as a v1.1 feature) was dropped 2026-05-16 — see spec/01-project-brief.md §“Deliberately not on the roadmap” for the reasoning. Reconsidered if a concrete operator workload surfaces with the need.
Documentation
Section titled “Documentation”In addition to the per-feature pages, the site ships five recipes (contact form, newsletter signup, multi-form site, monitoring alerts, transactional email from a Cloudflare Worker, internal SMTP relay for Docker Compose), a Design principles page, a Reading the logs ops guide, and a Deploying API mode safely guide covering loopback bind, Cloudflare Tunnel + Cloudflare Access service tokens, IP allowlist, and mTLS deployment shapes for API-mode endpoints.
Dependencies
Section titled “Dependencies”Three external Go dependencies in the entire module: github.com/BurntSushi/toml, github.com/google/uuid, github.com/hashicorp/golang-lru/v2. Every transport (Postmark, Resend, Mailgun, SES, outbound-SMTP) is bespoke — no vendor SDK in transport code per ADR-1.