Skip to content

TOML reference

Posthorn loads a single TOML file at startup. By default the standalone binary reads /etc/posthorn/config.toml, overridable via --config <path>.

The config file resolves ${env.VAR} placeholders against the process environment as a post-parse step. Missing env vars are config-validation errors, not runtime errors — posthorn validate will surface them before the listener starts.

This page is the canonical reference for every field. For an annotated example, see the Quick start.

# One or more endpoint blocks. Each is fully independent.
[[endpoints]]
# ...endpoint fields...
# Optional global logging block
[logging]
# ...
# Optional inbound SMTP listener (v1.0 block D)
[smtp_listener]
# ...

Each [[endpoints]] table defines a single ingress path with its own transport, recipients, templates, and protections.

FieldTypeRequiredDefaultDescription
pathstringyesURL path to match. Must start with /. Example: "/api/contact".
FieldTypeRequiredDefaultDescription
to[]stringyesOne or more default recipient addresses. Plain addr@host or "Name <addr@host>". Each entry must parse as a valid email. API-mode endpoints can override per request via the to_override JSON field.
fromstringyesSender address. Format: "Name <addr@host>" or plain addr@host. Per-request override is not supported — a leaked API key cannot be used to spoof other senders.
reply_to_email_fieldstringnothe value of email_fieldForm field whose value to use as the email’s Reply-To header. Set to a name that doesn’t exist in the submission to disable Reply-To.
FieldTypeRequiredDefaultDescription
transport.typestringyesOne of "postmark", "resend", "mailgun", "ses", "smtp".
transport.settingstableyesTransport-specific settings. See Transports.
[endpoints.transport]
type = "postmark"
[endpoints.transport.settings]
api_key = "${env.POSTMARK_API_KEY}"
FieldTypeRequiredDefaultDescription
required[]stringno[]Fields that must be present and non-empty in the submission. Missing → 422.
email_fieldstringno"email"Field name to validate as an email address.

API-mode endpoints reject these fields at config-parse time.

FieldTypeRequiredDefaultDescription
honeypotstringnounsetForm field name that bots will fill in. Any non-empty value triggers a silent 200 (no email sent).
allowed_origins[]stringnounsetIf set, Origin/Referer must match one of these. Missing both headers → 403 (fail-closed). Explicitly empty (= []) is rejected at parse time.
FieldTypeRequiredDefaultDescription
max_body_sizestringno"1MB"Maximum request body size. Format: "32KB", "1MB", "512KB". Exceeding → 413. The 1 MB default is safe-by-default; bump up for endpoints accepting large form uploads.
FieldTypeRequiredDefaultDescription
trusted_proxies[]stringno[]Classless Inter-Domain Routing (CIDR) ranges and/or preset names. When the request’s RemoteAddr is in one of these networks, the rate limiter reads the client IP from X-Forwarded-For (rightmost untrusted).
strip_client_ipboolnofalseWhen true, omit the resolved client IP from log lines for this endpoint. Rate-limit bucketing is unaffected — the IP is still computed; it just doesn’t reach logs.

Preset names accepted in trusted_proxiescloudflare is populated; the rest are reserved as empty slots to avoid name churn when they’re populated later:

PresetStatus
cloudflareShipped — 15 IPv4 + 7 IPv6 ranges, sourced from cloudflare.com/ips
aws-elbReserved (no built-in CIDRs in v1.0)
gcp-lbReserved (no built-in CIDRs in v1.0)
azure-front-doorReserved (no built-in CIDRs in v1.0)

Mix presets and explicit CIDRs:

trusted_proxies = ["cloudflare", "10.0.0.0/8"]
[endpoints.rate_limit]
count = 5
interval = "1m"
FieldTypeRequiredDefaultDescription
rate_limit.countintyes within blockToken bucket capacity. Per-IP in form mode; per-API-key in API mode. Must be positive.
rate_limit.intervaldurationyes within blockRefill window. Go duration string ("1m", "30s", "1h"). Must be positive.

When rate_limit is omitted entirely, no rate limit is applied to the endpoint.

API mode swaps form-mode browser defenses for Authorization: Bearer auth and JSON body parsing. See API mode.

FieldTypeRequiredDefaultDescription
authstringno"form""form" (default) or "api-key". The two modes are mutually exclusive per endpoint.
api_keys[]stringyes when auth = "api-key"unsetBearer tokens accepted on this endpoint. ${env.VAR} substitution honored. Empty list is rejected at parse time. Form-mode endpoints reject this field at parse time.
idempotency_cache_sizeintno10000Per-endpoint idempotency cache capacity. Least Recently Used (LRU) eviction, 24-hour TTL (time-to-live). API-mode only — form-mode endpoints reject at parse time.

Configuring honeypot, allowed_origins, redirect_success, redirect_error, or csrf_secret on an API-mode endpoint is a parse error — those defenses only make sense for browser-facing form endpoints.

FieldTypeRequiredDefaultDescription
csrf_secretstringnounset (CSRF disabled)HMAC key for token verification. Must be at least 16 bytes. When set, every form submission must carry a _csrf_token field issued at form-render time. API-mode endpoints reject at parse time.
csrf_token_ttldurationno"1h"Maximum age of a CSRF token. Tokens older than this are rejected with 403.

See Spam protection → CSRF tokens for the issuance flow.

FieldTypeRequiredDefaultDescription
dry_runboolnofalseWhen true, the endpoint runs the full pipeline (validation, template render, recipient resolution) but skips transport.Send and returns the prepared message in the 200 response body.
FieldTypeRequiredDefaultDescription
subjectstringyestext/template source for the email subject. Inline string.
bodystringyestext/template source for the email body. Inline string if it contains {{, otherwise treated as a file path.

Submission fields are available as template variables: {{.name}}, {{.email}}, {{.message}}. See Templating.

FieldTypeRequiredDefaultDescription
redirect_successstringnounsetURL to redirect to on success (303 See Other). Used when client prefers text/html.
redirect_errorstringnounsetURL to redirect to on validation/rate-limit failures.

When both are unset, all responses are JSON regardless of Accept header. API-mode endpoints reject these fields at parse time.

FieldTypeRequiredDefaultDescription
log_failed_submissionsboolnotrueWhen true, terminal failures log the full submission payload under the form field at ERROR level. When false, only field NAMES log (under form_fields).
FieldTypeRequiredDefaultDescription
levelstringno"info"One of debug, info, warn, error. (Posthorn doesn’t currently emit DEBUG events in v1.0; setting level = "debug" is forward-compatible.)
formatstringno"json"Only "json" is supported in v1.0. Any other value is a parse error.

Optional inbound SMTP ingress. When this block is present, posthorn serve starts a second listener alongside the HTTP one. See SMTP ingress.

FieldTypeRequiredDefaultDescription
listenstringyesTCP listen address. Example: ":2525".
require_tlsboolnotrueWhen true (default), AUTH / MAIL / RCPT are rejected until the client has issued STARTTLS. Set explicitly to false only for local development on a loopback listener.
tls_certstringconditionalPath to PEM cert. Required when require_tls = true or when auth_required involves client certs.
tls_keystringconditionalPath to PEM private key. Same conditions as tls_cert.
FieldTypeRequiredDefaultDescription
auth_requiredstringno"smtp-auth"One of "smtp-auth", "client-cert", "either", or "none". See the internal-SMTP-relay recipe for when "none" is appropriate (private Docker network only — the sender allowlist becomes the only ingress gate).
smtp_users[]tableyes for smtp-auth or eitherList of AUTH PLAIN credential pairs. Each entry has username (string) and password (string; ${env.VAR} honored). Not used in "none" mode.
client_cert_castringyes for client-cert or eitherPath to a PEM-encoded CA bundle. Client certs signed by this CA are accepted.
FieldTypeRequiredDefaultDescription
allowed_senders[]stringyes (non-empty)Exact addresses (noreply@example.com) or domain wildcards (*@example.com). MAIL FROM: outside this list is rejected.
allowed_recipients[]stringnounsetSame syntax. When set, only RCPT TO: entries matching this list are accepted. When unset, the recipient-count cap below applies.
max_recipients_per_sessionintno10Open-relay-prevention cap. Only applies when allowed_recipients is unset. Set to a very large value to effectively disable.
FieldTypeRequiredDefaultDescription
max_message_sizestringno"1MB"Maximum DATA blob size. Format matches max_body_size. Exceeding returns SMTP 552 5.3.4.
idle_timeoutdurationno"60s"Connection idle timeout.
[smtp_listener.transport]
type = "postmark"
[smtp_listener.transport.settings]
api_key = "${env.POSTMARK_API_KEY}"

Same shape as [endpoints.transport]. The listener forwards every accepted submission through this single transport — one listener has one outbound transport; per-recipient routing is a future feature.

# Two HTTP endpoints (a contact form + a transactional api-mode endpoint)
# plus an SMTP listener for Ghost/Gitea/etc.
[logging]
level = "info"
format = "json"
[[endpoints]]
path = "/api/contact"
to = ["alerts@example.com"]
from = "Contact Form <noreply@example.com>"
required = ["name", "email", "message"]
email_field = "email"
honeypot = "_gotcha"
allowed_origins = ["https://example.com", "https://www.example.com"]
max_body_size = "64KB"
trusted_proxies = ["cloudflare"]
csrf_secret = "${env.CSRF_SECRET}"
csrf_token_ttl = "30m"
subject = "Contact: {{.name}}"
body = """
From: {{.name}} <{{.email}}>
{{.message}}
"""
redirect_success = "/thank-you"
redirect_error = "/contact?error=1"
[endpoints.transport]
type = "postmark"
[endpoints.transport.settings]
api_key = "${env.POSTMARK_CONTACT_KEY}"
[endpoints.rate_limit]
count = 5
interval = "1m"
# ---
[[endpoints]]
path = "/api/transactional"
to = ["fallback@example.com"]
from = "App <noreply@example.com>"
auth = "api-key"
api_keys = ["${env.WORKER_KEY_PRIMARY}", "${env.WORKER_KEY_BACKUP}"]
idempotency_cache_size = 5000
required = ["subject_line", "body"]
subject = "{{.subject_line}}"
body = "{{.body}}"
[endpoints.transport]
type = "resend"
[endpoints.transport.settings]
api_key = "${env.RESEND_API_KEY}"
[endpoints.rate_limit]
count = 100
interval = "1m"
# ---
[smtp_listener]
listen = ":2525"
require_tls = true
tls_cert = "/etc/posthorn/cert.pem"
tls_key = "/etc/posthorn/key.pem"
auth_required = "smtp-auth"
allowed_senders = ["*@example.com"]
max_recipients_per_session = 10
max_message_size = "1MB"
[[smtp_listener.smtp_users]]
username = "ghost"
password = "${env.GHOST_SMTP_PASSWORD}"
[smtp_listener.transport]
type = "postmark"
[smtp_listener.transport.settings]
api_key = "${env.POSTMARK_API_KEY}"