Introducing Posthorn: the missing layer in self-hosted email
If you self-host on a cloud VPS, you’ve probably noticed by now that some of your services can’t send email. DigitalOcean, AWS Lightsail, Linode, and Vultr all block outbound SMTP on ports 25, 465, and 587. It’s an anti-abuse measure: compromised VMs make great spam relays, and one bad actor can burn the reputation of an entire IP range. The official recommendation is to use a transactional HTTP API like Postmark. Or, if all you care about is a contact form, a SaaS service like Formspree. Both work for the apps that can speak HTTP. Some can’t.
For the HTTP-capable apps the standard fix is fine. I use Resend for a contact form on this Hugo site, comment notifications from Comentario, and some monitoring scripts. The token goes in environment variables, the integration is a few lines per app, you move on.
Except now I have three integrations against the same provider, each doing something slightly different when a send fails. Each has its own logging shape. Each has its own model of “what happens when the provider returns a 429?” Each has its own defaults I chose months ago and have since forgotten. The credential’s fine. The integration logic is fragmented across the stack.
And it doesn’t fix the apps that can’t speak HTTP in the first place. Ghost only speaks SMTP. So do Gitea, Mastodon, Matrix, NextCloud, Authentik. On a host that blocks outbound SMTP, those apps just don’t send mail. The available workarounds are all bad: stand up a Postfix-to-HTTP relay shim nobody actively maintains, migrate to a different host, or accept that some of your apps can’t email you.
None of these felt right. So I built Posthorn, a self-hosted email gateway, launching today as an open source project: posthorn.dev, github.com/craigmccaskill/posthorn, Apache-2.0.
Why no one’s built this
Self-hosted email today is roughly three categories of project.
Mail servers (Stalwart, Mailcow, iRedMail) let you run your own MTA. Host mailboxes, handle inbound, manage DKIM keys yourself. These projects are excellent and the people who run them are deeply unimpressed with anyone outsourcing to a hosted provider. They’re also doing about twenty things I deliberately stopped wanting to do years ago, including the most thankless work in computing: keeping a single IP out of every spam blocklist on the internet.
Outbound platforms (Postal, Hyvor Relay) let you become your own transactional provider. Run an SMTP fleet, warm up IPs, deal with reputation, handle bounces. Real category, real maintainers, but it’s the exact thing my hosted provider is being paid to do. Going back to it would undo the original decision.
Marketing platforms (Listmonk) are a different category entirely. Lists, campaigns, segmentation. Not a gateway.
The gap is in the middle. Not a mail server. Not a deliverability platform. Just a thin integration layer between the apps I run and the provider I picked.
The cost of the missing piece is real. It’s paid in fifteen-minute increments across N apps over months. It adds up to “you’ve integrated the same provider five times and you still don’t have a unified retry policy.” Or, if you’re on a cloud host that blocks outbound SMTP, “you’ve integrated it three times and the fourth app can’t even reach the provider.”
What it is
One Docker container, one TOML config file, one set of credentials. Apps point at Posthorn, Posthorn points at your provider.
It’s a single Go binary, distributed as a multi-arch Docker image (amd64, arm64) on the order of 10 MB. A few thousand lines, stdlib-first: net/http, log/slog, text/template, net/mail. Three external dependencies in the whole module: a TOML parser, a UUID library, and an LRU cache. Each provider transport is a bespoke 200-to-300-line HTTP client. No vendor SDKs. No transitive dependency tree to watch.
Three ingress shapes. HTTP form for contact forms on static sites: action pointed at Posthorn, submission becomes an email. JSON API with Bearer auth and idempotency keys for server-to-server callers, the shape cron jobs and backend services want. SMTP listener for apps that only speak SMTP. The SMTP listener is what unblocks Ghost on a DigitalOcean droplet (and Gitea, Mastodon, Matrix, NextCloud, Authentik). Posthorn looks like a mail server to those apps. It’s actually relaying via HTTP, so the outbound-port block never matters. All three ingresses share the same transport pipeline, the same logging, the same retry policy, the same defaults.
Five transports on the egress side. Postmark, Resend, Mailgun, AWS SES, and outbound-SMTP relay. The choice is a config field. Switching providers is one line and a container restart.
The security primitives that contact-form-in-an-afternoon implementations forget are in by default: structured-JSON header construction so submitter input can’t become headers, honeypot 200s that look identical to real successes so observant bots can’t fingerprint the failure path, fail-closed Origin checks, bounded-memory rate limiter, per-IP brute-force defense on the API endpoints, API keys that are header values only and never logged.
What’s missing
Posthorn doesn’t have persistent storage. A send that exhausts both retries is gone, except for the log line. Durable retry across restarts is on the roadmap, not in yet. If you need at-least-once outbound mail, this isn’t the tool for it.
I built this to solve my own problem. If you have a feature request or hit something missing, open an issue or send a PR. The whole thing is one Go module of a few thousand lines, Apache-2.0 licensed.
What my own stack looks like
This blog runs on Hugo, with Comentario for comments. Both need to send email: Hugo’s contact form for visitor messages, Comentario for moderation and reply notifications. Without Posthorn, that’s two integrations: a webhook out of the static site to my provider, plus Comentario configured with its own SMTP credentials and retry behavior. With Posthorn, both point at one instance.
The contact form POSTs across the public internet to a Posthorn HTTP endpoint behind my reverse proxy. Comentario runs in Docker on the same host as Posthorn and SMTPs through a private internal network. Both end up at the same transactional provider. One credential to rotate. One log stream to grep when something breaks.
The public-side defenses (allowed_origins fail-closed, honeypot with byte-identical 200, per-IP rate limit, body-size cap) are the ones I’d otherwise wire into every form integration separately. With Posthorn they happen one layer below every app pointing at it, which means Comentario sends mail without holding a credential at all, the Resend token lives in one .env file on one host, and rotating it is a single value change instead of an N-app coordination problem.
The full walkthrough is in the Hugo + Comentario recipe on the docs site.
Where to find it
posthorn.dev for the docs. github.com/craigmccaskill/posthorn for the source. Docker Compose snippet in the README, ten minutes from docker compose up to a working contact form (assuming your sending domain’s DNS is set up). If you self-host more than one thing and you’ve integrated Postmark or Resend or SES into more than one app, this was built for you. I’ve done a significant amount of testing where I could, but I’d love to hear what breaks.
Comments