Skip to content

Posthorn

Self-hosted mail without the mail server. One outbound layer between every app you run and your transactional provider — Postmark, Resend, Mailgun, AWS SES, or outbound-SMTP. Three ingress shapes (HTTP form, HTTP API, SMTP).

Nobody wants to run a mail server in 2026. Self-hosted operators use Postmark, Resend, Mailgun, or AWS SES because they’re cheap, they handle deliverability properly, and somebody else can worry about sender authentication, bounce processing, and reputation management.

But every app you self-host has to integrate with that service independently. Your contact form. Your Ghost blog’s admin emails. Your Gitea magic links. Your Mastodon notifications. Your worker that fires a license-delivery email when someone pays. Each one needs its own copy of the API key, its own integration code, its own quirks around retry and bounce handling. The same outbound concern duplicated five times across your stack.

And on cloud hosts that block outbound SMTP — DigitalOcean, AWS Lightsail, Linode, Vultr — the SMTP-only apps don’t work at all without a workaround.

Posthorn is the bridge. One container, one config, one set of credentials. Your apps point at Posthorn. Posthorn talks to your transactional mail provider.

Your appsPosthornPostmark · ResendMailgun · AWS SESOutbound SMTPHugo contact formGhostGiteaMastodonPayment workerCron script HTTP / SMTPHTTPS(one credential)

Three ingress shapes. Pick the one that fits each of your apps; one Posthorn instance handles all of them.

HTTP form ingress

Contact forms, signup forms, alert webhooks. application/x-www-form-urlencoded and multipart/form-data POSTs on configured paths. Drop the form on your static site or behind your reverse proxy, point its action at Posthorn.

JSON API ingress

Server-to-server callers: workers, cron jobs, payment handlers, internal services. Standard Authorization: Bearer auth. Idempotency keys for safe retries. Per-request to_override for transactional sends.

SMTP ingress

For apps that only speak SMTP: Ghost, Gitea, Mastodon, Matrix, NextCloud, Authentik. Posthorn accepts SMTP on your local network (AUTH PLAIN or client-cert, STARTTLS-required) and forwards via your configured HTTP API transport.

Five transports, all shipping today. Swap providers without rewriting your apps’ integrations — they all talk to Posthorn the same way.

Postmark, Resend, Mailgun

Bespoke HTTP clients, ~200-250 lines each. No third-party SDK overhead. Headers pass through each upstream’s structured API — no string concatenation, no injection vector. API keys never appear in logs. Mailgun supports US + EU regions.

AWS SES, outbound SMTP

SES uses a bespoke Signature Version 4 (SigV4) implementation (no AWS SDK, no transitive dep tree). Outbound SMTP relays through any STARTTLS-capable upstream — your Postfix smarthost, Mailtrap, Mailgun SMTP. Swap transports without changing app code or operator runbooks; every endpoint config has the same shape.

Webhook delivery

v2 — later. Point Posthorn at any URL for non-email delivery (Slack, internal HTTP processors, analytics pipelines). Fan out one submission across email + webhook + log archive.

Built for self-hosted, built for production

Section titled “Built for self-hosted, built for production”

Defense in depth

Honeypot, Origin/Referer fail-closed, token-bucket rate limit with LRU eviction, max body size, required-field validation, email syntax check, signed CSRF tokens. API-key auth for server-to-server callers. SMTP listener gates open-relay abuse via sender allowlist, recipient cap, AUTH PLAIN, and required STARTTLS.

One deployment shape, every front door

Standalone Docker container (or Go binary) that runs as a sidecar to your apps. Reverse-proxy your form endpoints from whatever front door you already use — Caddy, nginx, Traefik, Cloudflare.

Structured for ops

JSON logs with UUIDv4 submission IDs. Hard 10-second request timeout. /healthz liveness probe and Prometheus /metrics exposition on the same listener. SQLite-backed retry queue across restarts in v2. Designed to drop into your existing logging pipeline, not replace it.

No mail-server skills required

You don’t run Postfix. You don’t manage DKIM rotation. You bring an API key from your transactional provider and a TOML config. Posthorn handles the gateway logic.

Retry that respects deadlines

One retry on transient/5xx (1s backoff), one retry on 429 (5s backoff), no retry on 4xx config errors, hard 10-second timeout including retries. Failed submissions log the full payload so you can recover.

Public roadmap, locked spec

Roadmap is public, milestones track in GitHub, every feature traces back to a versioned spec. v1.0 is the full surface; v2 (durable storage, suppression, lifecycle webhooks, attachments) is the next milestone.

The simplest path: a contact form on a static site, behind a reverse proxy.

docker-compose.yml
services:
posthorn:
image: ghcr.io/craigmccaskill/posthorn:latest
volumes:
- ./posthorn.toml:/etc/posthorn/config.toml:ro
environment:
POSTMARK_API_KEY: ${POSTMARK_API_KEY}
ports:
- "127.0.0.1:8080:8080"
posthorn.toml
[[endpoints]]
path = "/api/contact"
to = ["you@example.com"]
from = "Contact Form <noreply@example.com>"
honeypot = "_gotcha"
allowed_origins = ["https://example.com"]
required = ["name", "email", "message"]
subject = "Contact from {{.name}}"
body = """
From: {{.name}} <{{.email}}>
{{.message}}
"""
[endpoints.transport]
type = "postmark"
[endpoints.transport.settings]
api_key = "${env.POSTMARK_API_KEY}"
[endpoints.rate_limit]
count = 5
interval = "1m"

Reverse-proxy /api/contact from your front door (Caddy, nginx, Traefik) to http://posthorn:8080. Point your form’s action at /api/contact. Done.

Full walkthrough: posthorn.dev/getting-started/quick-start.

To save you a wrong turn. Three categories of self-hosted email infrastructure exist; Posthorn occupies exactly one.

What Posthorn doesn’t doLook at instead
Not a mail serverNo mailbox storage, no IMAP / JMAP, no DKIM key management.Stalwart, Mailcow, iRedMail
Not its own outbound infrastructurePosthorn relays through a provider you chose; it doesn’t run its own SMTP fleet, doesn’t manage IP reputation, doesn’t compete on feature count with Mailgun-class platforms.Postal, Hyvor Relay
Not a marketing email platformNo list management, no segmentation, no campaign dashboard.Listmonk
Not webmail / a mailbox UINo interface for reading mail.Roundcube, Snappymail (paired with a real mail server)

The wedge is the integration layer between your self-hosted apps and the transactional provider you’ve already picked. If you need a different tool above, those projects are better at their thing than Posthorn would ever be — and Posthorn is intentionally a different shape.

A few principles that shape every decision. See Design principles for the full set with reasoning.

  • Gateway, not infrastructure. Posthorn sits between your apps and a provider you already chose. It doesn’t run its own outbound mail server, doesn’t manage IP reputation, doesn’t host mailboxes.
  • Integration layer, not mail-receiving layer. Many ingress shapes converge on one transport surface — many inputs, one output. Receiving mail is somebody else’s job.
  • Config over admin UI. A single TOML file is the source of truth. Reviewing a config diff is reviewing the system’s behavior. No runtime mutation surface.
  • Bespoke transports, no SDKs. Three external Go dependencies in the whole module — a TOML parser, a UUID library, an LRU cache. Every transport is ~200-400 lines of focused Go, auditable in an afternoon.
  • Locked spec, public roadmap. Every feature traces back to a numbered requirement. No quiet drift between docs and behavior.

Apache-2.0. See the GitHub releases page for the current tag and open milestones for what’s queued next. The full v1.0 specification is in spec/; the v2 trajectory lives on the roadmap page.