SMTP ingress
The SMTP ingress lets internal apps that speak SMTP — Ghost’s admin login, Gitea’s notifications, anything that natively talks to a mail server — point at Posthorn instead of a real SMTP server. Posthorn authenticates the client, parses the message, and forwards it via the same outbound transport you already configured (Postmark, Resend, Mailgun, SES, outbound-SMTP). Posthorn is not a mail server — it doesn’t host mailboxes, doesn’t act as an MX, doesn’t do inbound spam filtering. It’s an authenticated relay for known internal clients only.
The canonical use case: a self-hosted app on a homelab can’t reach the public SMTP ports (25/465/587 blocked by the cloud provider), but it can reach a Posthorn instance you also run. Posthorn relays the message out via Postmark’s HTTP API. The app keeps speaking SMTP — it never knows the difference.
Configuration
Section titled “Configuration”[smtp_listener]listen = ":2525"require_tls = truetls_cert = "/etc/posthorn/cert.pem"tls_key = "/etc/posthorn/key.pem"auth_required = "smtp-auth" # smtp-auth | client-cert | eitherallowed_senders = ["*@yourdomain.com"]max_recipients_per_session = 10max_message_size = "1MB"idle_timeout = "60s"
[[smtp_listener.smtp_users]]username = "ghost"password = "${env.GHOST_SMTP_PASSWORD}"
[[smtp_listener.smtp_users]]username = "gitea"password = "${env.GITEA_SMTP_PASSWORD}"
[smtp_listener.transport]type = "postmark"
[smtp_listener.transport.settings]api_key = "${env.POSTMARK_API_KEY}"The SMTP listener has its own [smtp_listener.transport] block, separate from [[endpoints]]. One SMTP listener has one outbound transport; per-recipient routing through different transports is a future feature.
Open-relay prevention
Section titled “Open-relay prevention”Posthorn refuses to relay arbitrary mail. Every accepted message must clear all of:
- Authentication — SMTP AUTH PLAIN against
smtp_users, or a TLS client certificate signed byclient_cert_ca.auth_required = "either"accepts both. For internal-network-only deployments where network access already implies trust,auth_required = "none"skips this layer entirely (see the internal-SMTP-relay recipe). - STARTTLS — when
require_tls = true(default), clients can’t send AUTH or MAIL until they’ve upgraded to TLS. - Sender allowlist —
allowed_sendersis required. Entries can be exact addresses or*@domainwildcards.*allows any sender (only do this with strong auth or a tightly-firewalled internal listener). - Recipient bound — either
allowed_recipients(allowlist) ormax_recipients_per_session(cap, default 10). Both protect against RCPT bombing. - Size cap —
max_message_size(default 1MB). DATA blobs exceeding this return552 5.3.4.
| Defense | Failure response |
|---|---|
| AUTH not yet completed | 530 5.7.0 Authentication required |
| AUTH failed | 535 5.7.8 Authentication failed |
| STARTTLS required but not done | 530 5.7.0 Must issue STARTTLS first |
| Sender not in allowlist | 550 5.7.1 Sender not authorized |
| Recipient not in allowlist | 550 5.7.1 Recipient not authorized |
| Recipient cap exceeded | 452 4.5.3 Too many recipients |
| Message too big | 552 5.3.4 Message too big |
Recipients come from the envelope, never the MIME
Section titled “Recipients come from the envelope, never the MIME”A subtle but important invariant: the outbound message’s recipients come from the SMTP RCPT TO commands, never from the Multipurpose Internet Mail Extensions (MIME) To:/Cc:/Bcc: headers in the message body. A malicious client that sent:
RCPT TO:<alice@example.com>DATAFrom: legitimate@yourdomain.comTo: alice@example.comBcc: attacker@target.comSubject: hi...would have attacker@target.com ignored entirely. Posthorn relays only to alice@example.com because that’s the only RCPT TO. The smuggled Bcc header lands in the parsed headers map and is discarded.
Wiring up Ghost
Section titled “Wiring up Ghost”Ghost (and most modern web apps) configure SMTP via four environment variables or config keys:
mail__from='"Ghost" <noreply@yourdomain.com>'mail__transport=SMTPmail__options__host=posthorn.yourdomain.commail__options__port=2525mail__options__secure=false # using STARTTLS, not implicit TLSmail__options__auth__user=ghostmail__options__auth__pass="$GHOST_SMTP_PASSWORD"Match mail__options__auth__user to a [[smtp_listener.smtp_users]] username, mail__options__auth__pass to its password, and Ghost’s mail__from address must match the Posthorn allowed_senders allowlist.
What doesn’t work yet
Section titled “What doesn’t work yet”- HTML-only message bodies. Posthorn requires a
text/plainpart (either standalone or inside a multipart/alternative). HTML-only messages are rejected with550 5.6.0. HTML body support is v2 scope. - File attachments. Multipart messages with attachments parse successfully, but only the text/plain part reaches the outbound transport — attachments are dropped. Attachment support is v2 scope.
- Per-RCPT routing. One SMTP listener has one outbound transport. Routing different recipients to different upstream providers is v2 territory.
Observability
Section titled “Observability”The SMTP listener emits structured log lines for the full session lifecycle:
smtp_session_open — connection acceptedsmtp_tls_established — STARTTLS upgrade completedsmtp_auth_ok — AUTH PLAIN succeeded (includes `user` field)smtp_auth_failed — AUTH PLAIN rejected (includes `user`; password never logged)smtp_sender_rejected — MAIL FROM not in allowlistsmtp_recipient_rejected — RCPT TO not in allowlistsmtp_submission_sent — DATA delivered to upstream transport (with submission_id, transport_message_id, size_bytes)smtp_submission_failed — upstream transport failedsmtp_session_close — connection closedEach line carries a per-session session_id UUID so operators can trace a single connection through the protocol exchange.
The /metrics endpoint exposes the same posthorn_submissions_sent_total / posthorn_submissions_failed_total counters with endpoint = "smtp_listener" so operators can split inbound-via-HTTP from inbound-via-SMTP traffic in Prometheus.