Skip to content

Outbound SMTP

[endpoints.transport]
type = "smtp"
[endpoints.transport.settings]
host = "smtp.mailgun.org"
port = 587
username = "postmaster@mg.yourdomain.com"
password = "${env.SMTP_PASSWORD}"
SettingRequiredDefaultDescription
hostyesSMTP server hostname.
portyesSMTP server port. Typically 587 (STARTTLS) or 2525 (alternative STARTTLS port). Port 25 is blocked outbound on most cloud providers.
usernameyesSMTP AUTH PLAIN username.
passwordyesSMTP AUTH PLAIN password. Never logged in any code path.
require_tlsnotrueRequire STARTTLS before sending credentials. Setting to false is not recommended outside of local testing.
tls_insecure_skip_verifynofalseSkip TLS certificate validation. Test-only escape hatch for self-signed certs in local dev.
AspectBehavior
Protocol flowEHLO → STARTTLS → AUTH PLAIN → MAIL FROM → RCPT TO → DATA → QUIT
Per-request timeout30s (longer than HTTP transports because SMTP has slower greeting + DATA round trips)
Connection reuseNone — one TCP connection per Send. (Connection pooling is a v2 optimization.)
Message bodyHand-constructed RFC 5322 message (From/To/Subject/Reply-To headers + plain-text body, UTF-8)
Subject encodingRFC 2047 B-encoding for non-ASCII subjects; ASCII passes through unchanged
transport_message_idEmpty — stdlib net/smtp doesn’t expose the server’s queued as <id> response. Operators must correlate by timestamp + recipient in the relay’s own logs.

The SMTP transport is for operators who want Posthorn to relay to an SMTP server they already trust rather than calling an HTTP API. Common shapes:

  • Mailgun’s SMTP gateway (smtp.mailgun.org:587) — same Mailgun account, SMTP wire instead of HTTP
  • SendGrid’s SMTP relay (smtp.sendgrid.net:587)
  • A self-hosted Postfix smarthost that handles the actual delivery to recipients
  • Mailtrap or MailHog for testing in a local development environment
  • An internal mail server for org-internal-only notifications that never leave the network

If you’re not sure which to pick, prefer one of the HTTP API transports (Postmark, Resend, Mailgun) — they have better operational ergonomics (per-request error responses, structured rate-limit headers, dashboards). SMTP is the “I need SMTP wire” fallback.

ConditionError classRetry?
Connect / TCP timeout / DNS failureErrTransientyes, after 1s
STARTTLS not advertised when require_tls=trueErrTerminalno — operator must fix config or pick a different server
TLS handshake failureErrTransientyes, after 1s
AUTH rejected (typically 535 5.7.8)ErrTerminal (bad credentials)no
MAIL FROM / RCPT TO 4xx (greylisting, 450 4.7.1)ErrTransientyes, after 1s
MAIL FROM / RCPT TO 5xx (relay-rejected, bad address, 550 5.1.1)ErrTerminalno
DATA initial command 5xx (e.g., 552 5.3.4 Message too big)ErrTerminalno
Carriage-return / line-feed (CRLF) in From / To / Subject / Reply-To (header injection attempt)ErrTerminalno — rejected at Posthorn’s edge before dialing

SMTP is the only Posthorn transport that hand-writes the RFC 5322 message envelope (because there’s no JSON struct to lean on). The structural header-injection defense is pre-write validation: before the wire conversation starts, Posthorn checks that no submitter-controlled header value contains CR or LF. Any CRLF in From/To/Subject/Reply-To is rejected with ErrTerminal and the dial never happens.

Additionally:

  • MAIL FROM / RCPT TO envelopes go through stdlib net/smtp.Client which has its own CRLF validation
  • DATA body is wrapped in net/textproto.DotWriter which handles dot-stuffing automatically — a body line starting with . cannot prematurely terminate the DATA section
SymptomLikely causeFix
smtp: server does not advertise STARTTLS but require_tls=trueServer’s EHLO response omits STARTTLS (rare for legitimate relays)Verify server hostname/port; if intentional (local-network plaintext), set require_tls = false explicitly
535 5.7.8 Authentication failedWrong username or password, or AUTH PLAIN not supported by serverCheck creds; some servers require username to be the literal email address, not a username
Connection times out on connectCloud provider blocking outbound port 25 (and sometimes 587)Use port 2525 if your provider blocks 587, or switch to an HTTP-API transport
450 4.7.1 errors retry but then succeedGreylisting — server delays first-time-from-this-sender mailWorking as intended; Posthorn’s retry policy handles this
Mail accepted but never deliveredThe SMTP relay accepted but failed downstream — Posthorn has no visibilityCheck the relay’s own logs and bounce notifications