Skip to content

Transports

A transport is the egress side — the thing that turns a parsed submission into a delivered email. Posthorn ships five transports out of the box; each endpoint picks exactly one.

[endpoints.transport]
type = "postmark" # or "resend", "mailgun", "ses", "smtp"
[endpoints.transport.settings]
# Per-transport settings, see the matching page below.

Different endpoints in one Posthorn instance can use different transports, or different settings (different API keys) on the same transport.

TransportBest fitAuth shapeBody format
PostmarkTransactional email at low-to-medium volume. Strong deliverability defaults.X-Postmark-Server-Token headerJSON
ResendModern HTTP API; developer-friendly dashboard. Smaller deliverability track record than Postmark.Authorization: BearerJSON
MailgunHigher-volume transactional. EU region available.HTTP Basic (api:<key>)multipart/form-data
AWS SESAWS-native deployments. Cheapest per-message at volume. Operator owns deliverability.AWS SigV4JSON
Outbound SMTPOperator-controlled relay (self-hosted Postfix, Mailgun’s SMTP gateway, Mailtrap for testing). The “I want SMTP” fallback.SMTP AUTH PLAINSMTP DATA

Every transport in the registry honors these invariants. They’re tested per-transport — adding a new one requires passing the same suite.

InvariantWhat it means
No header injectionSubmitter-controlled fields (From, To, Subject, Reply-To, recipients) reach the provider through structural API constructs — JSON struct marshaling, multipart writers, or pre-write CRLF (carriage-return / line-feed) validation. CRLF in any field cannot construct a sibling header.
API keys never loggedThe transport sets credentials at request-construction time and never passes them to the logger. Test suites include sentinel-token assertions that the configured key string does not appear in any captured log output, even on failure paths.
Consistent error classesEvery transport classifies failures as ErrTransient (network, 5xx, transient SMTP 4xx → retry), ErrRateLimited (429 → retry with longer backoff), ErrTerminal (auth failures, 4xx other than 429, permanent SMTP 5xx → no retry). The retry policy is ingress-agnostic.
Message-ID surfacingWhere the provider returns one, transport_message_id lands in the submission_sent log line so operators can correlate Posthorn logs with the provider’s dashboard.

Every transport in Posthorn is a hand-rolled HTTP (or SMTP) client. No postmark-go, no aws-sdk-go-v2, no mailgun-go:

  • Each provider’s surface area is small (typically one or two endpoints we care about). Bespoke runs ~150–250 lines per transport.
  • SDKs come with transitive dependency trees we don’t want to track for security updates. The smallest mainstream email SDK still pulls in 20+ packages.
  • Bespoke gives us complete control over header construction — the structural header-injection defense lives in the transport code, not in some SDK we’d have to audit on every release.

The marginal case is AWS SES, which needs Signature Version 4 (SigV4) signing. We implement that bespoke too (~230 lines), with a documented threshold: if bespoke ever grows past 500 lines of implementation for a single transport, we revisit the SDK question.

Adding a sixth (or sixteenth) transport is a single new file in core/transport/. The file calls Register in an init() that names the type and provides a validator + builder pair. No edits to the gateway handler, the config loader, the rate limiter, or the response writer.