Skip to main content
A freshly installed mail server sends from a cold IP. Large receivers throttle or junk mail from an IP with no history, and most clouds and ISPs block outbound port 25 entirely — so a new deployment often cannot deliver at all on day one. The answer is the same one Postfix has offered for twenty-five years: keep receiving and storing your own mail, but route outbound through a relay (a smarthost) whose IP is already warm. OxiMail treats this as two roles, and one server can play either:
  • The leaf relays its outbound through a smarthost. It is still a normal Primary mail server — only how it sends changes.
  • The smarthost accepts relayed mail from leaves. Accepting relay is something any Primary can do once relay accounts exist; a dedicated hub runs as [mode] role = "smarthost", a role of its own: submission stays open, mailbox surfaces and client autoconfiguration are closed, and its outbound mail leaves through the ordinary delivery queue.
Relaying is entirely opt-in. An instance that sends direct is unaffected by anything on this page — no configuration, no behaviour change.

Part 1 — Sending through a relay (the leaf)

Choosing the mode at install

oximail setup asks, after network topology, for your outbound send mode. It probes outbound port 25 and recommends relay when direct delivery is impossible. The choice is neutral — the relay can be a managed smarthost service, an ESP (Amazon SES, SMTP2GO…), or an internal company smarthost; OxiMail asks for its connection details generically and never pushes a particular service. You configure the relay one of two ways:
  • Enter the details manually — the relay host, port, SMTP AUTH username and password, and the SPF term to publish. Works for any relay.
  • Redeem an enrolment token — for a managed smarthost that hands out onboarding tokens. You paste the smarthost’s URL and a one-time token; the wizard fetches the credentials automatically. See Part 2.

What it writes

A relay is a catch-all [[smtp.transport_maps]] route. The AUTH password is written to a sidecar file (mode 0600), never inline in the TOML:
Delivery to the relay is TLS-mandatory by construction: an authenticated route has no plaintext fallback, the relay’s certificate is WebPKI-verified, and credentials are never sent over an unencrypted connection.
The wizard refuses to activate a relay route until your SPF and DKIM checks pass. A relay whose SPF story is not published will send mail that receivers mark as spam — the gate exists to stop exactly that silent failure. Publish the DNS below first, or pass --force while DNS propagates.

The DNS you must publish

Two records on each sender domain: That is the leaf-signs model. If the client cannot sign (an application that only submits mail), the relay can sign for it instead: see delegated signing keys. A managed smarthost additionally asks you to publish a relay-domain proof — _oximail-relay.yourdomain TXT "<token>" — which proves you control the domain before the smarthost will emit mail for it. The onboarding provides the token.

Going direct later

Relaying is a supported permanent setup. If you want to cut over to direct sending once your own IP is warm, check readiness first:
It verifies outbound port 25 is reachable, that your reverse DNS is forward-confirmed (the PTR resolves back to the IP), and that the IP is not on a public DNSBL. Exit 0 means ready — remove the catch-all [[smtp.transport_maps]] route and send direct. The cutover is reversible.

Changing the route later

Routes are read at boot: the TOML is the configuration baseline, and no route is reloaded while the service runs. Any change to a [[smtp.transport_maps]] route — host, port, credentials, an exemption — is oximail check-config on the edited file, then a restart of the service. The queue holds your mail meanwhile; nothing is lost.
  • Rotating the relay password without a cut. A smarthost running OxiMail accepts several secrets for one sending flow, so ask its operator for a second password before changing yours: write it into the auth_password_file sidecar, oximail check-config, restart, and only then let the operator revoke the old one. There is no moment where the leaf cannot send.
  • A smarthost with a private certificate authority. An authenticated route verifies the relay’s certificate against the public WebPKI roots and nothing else — there is no ca_file and no pinning. An internal smarthost must present a publicly valid certificate for its relay_host name (the certificate OxiMail obtains for its own hostname through ACME qualifies). A self-signed or private-CA certificate is accepted only on an unauthenticated route, which carries no credentials and may fall back to plaintext: not a profile for your outbound mail.
  • One receiver at a time, or your own servers. A route with action = "direct" is an exemption: its domains are delivered by MX even when a later catch-all names the relay — to move one large receiver off the relay once your address is warm there, or to keep the mail between your organization’s own servers off the relay.
First match wins, as for every route. A direct route names no relay: relay_host, credentials, fallback_hosts, implicit_tls, bind_addr and spf_mechanism are refused on it by check-config.

Part 2 — Running a smarthost

A smarthost accepts authenticated relay from leaf instances. On a Primary, enabling it is a matter of creating the relay accounts that are allowed to relay. For a dedicated hub, the setup wizard has a Smarthost relay (dedicated hub) deploy-mode entry (first boot): egress IPs collected, the hub SPF record published, and [mode] role = "smarthost" written. The role decides: it closes the mailbox surfaces and client autoconfiguration while keeping submission open, and every start warns when [legacy] enabled = true puts IMAP/CalDAV/CardDAV back on or when [metrics] dnsbl_self_ips is empty (the egress reputation is the product of such a box). The former [mode] profile = "smarthost" declaration was removed and now refuses the boot, naming the role to set instead. A combined mailbox-plus-hub server stays role = "primary".

Relay accounts

A relay account is an account with role service. Unlike a mailbox user, it owns no mail; it exists to authenticate an SMTP relay session. Create one and mark it a service principal:

Proven declared domains

A relay account may only send from domains that have been proven. This is the entire security boundary — without it, one client could send as another’s domain from your shared IP. The proof belongs to the tenant’s own domain, not to the account. Register the domain on the tenant first (oximail tenant add-domain --kind send: a domain has one tenant only, and a proof never claims a domain), then declare it; every relay account of that tenant uses the tenant’s proven domains.
The client publishes that TXT record; you then verify it:
A background worker re-verifies every declared domain two minutes after the server starts, then every six hours, and suspends it fail-closed if the proof disappears. A transient DNS failure never suspends — only a definitive “no such record” answer does, so a resolver blip cannot knock a client offline. But a proof that has not been confirmed for 72 hours stops authorizing too: name servers that stopped answering are the usual prelude to an expired, then re-registered, domain. A suspended or lapsed domain is a reversible state, so its mail is deferred 451 4.7.1 at end of DATA and the client’s queue retries while the proof comes back; a domain never declared or never verified is refused 550 5.7.1. oximail account relay remove-domain --email … --domain … --yes withdraws a domain: the tenant’s relay accounts lose the authority to send for it at once. account relay list shows the lapsed state as stale (--json adds last_verified_ok_at and proof_stale), and oximail doctor checks every proof under sender_domain_proofs: a failure names the suspended or stale domains, a warning the ones that missed two re-verifications, each with the verify verb to run.
An account with no verified domain relays nothing — the ingress is deny-by-default. The null sender (<>) is allowed so bounce notices can transit.

One-time enrolment

Instead of handing a client raw credentials, mint a single-use token. The client’s wizard redeems it and receives its credentials automatically.
The token is printed once (only its hash is stored). The client redeems it at POST /relay/enrol — the endpoint validates and consumes the token, provisions the service account, and returns the credentials one time. A leaked token is revoked with oximail account relay revoke-tokens.

Recipient quotas and warm-up

On a shared egress IP the reputation-critical unit is the recipient, not the message. Each relay account has a durable per-day recipient tally with a warm-up ramp: a new account starts at 200 recipients/day and doubles weekly toward [rate_limit] relay_max_rcpt_per_day (default 5000). Over the cap, a submission gets a transient 452 4.7.1 (a policy limit, like the tenant and account caps) and the sender’s queue retries — never a bounce.

The SPF term you publish

Publish an spf.your-smarthost.example TXT record listing your egress IP(s), and hand clients include:spf.your-smarthost.example. The indirection lets you renumber egress IPs without every client editing their zone. The dedicated-hub wizard flow publishes this record for you (through Cloudflare when a token is provided, printed for manual publication otherwise). The record belongs to the hub, and the wizard reads the name first: an identical record is left, a different v=spf1 record is replaced, and a second one is never added, since two SPF records at one name are a permerror for every domain that includes it (RFC 7208 §4.5). Other TXT records at the name are left alone.

Retention: the relay is not an archive

Mail relayed by a service account is ephemeral, and since ADR-149 the rule behind it is that the location of a byte is a property of its owner: a message in transit belongs to its queue row and lives and dies with it. Its body never enters the blob store. It is a file in the relay spool, {relay_spool_path}/{tenant}/_relay/{queue_id}, one file per queue row named by the row’s id, with no registry, no deduplication and no counter; the row is its only reference, and the outbound worker its only reader (JMAP and the blob reader cannot see the spool at all). At the destination’s 250, or on abandonment or expiry, the row is deleted and the file unlinked in the same terminal write under the claim fence; a failed unlink is a volume incident counted in relay_spool_unlink_failed, never a per-message error.
  • The guard is where the byte is written. oximail relay-spool init creates the root (0700) on the mounted volume and is the only place that creates it. A submission is refused 452 4.3.1 (transient, never 5xx) when the path is unset, missing, on the same filesystem as the blobs (st_dev), an ancestor or descendant of blob_path, not 0700 to the service user, or past the tenant’s watermark (per tenant, so one client cannot fill the spool and get everyone refused). check-config refuses the same cases before the service stops, so the mount point precedes the swap; a backup MX without a spool is refused there too.
  • doctor relay_spool measures the separation: a row without a file, an unreachable or read-only spool, or the same st_dev as the blobs is a failure; orphans past the age floor, an unlink failure, occupation near the watermark or wrong permissions a warning; a non-empty spool is never a warning. oximail relay-spool gc collects files no row owns any more (dry-run by default, --apply unlinks after re-reading each row, stands down while a backup marker is present). relay-spool reabsorb retires the spool by moving every body back into the blob store, server stopped, without touching the schema.
  • The failure report carries nothing of the message but its identifier. The non-delivery report of a relayed message contains no part of it (RET=FULL is not honoured, and the end-of-DATA 250 says so) except the original Message-ID: its third part is a text/rfc822-headers section holding that one header, the key a bounce processor such as MailWizz attributes the report by, and nothing else of the message (an identifier that would not make one clean header line gives no third part), its own queue row is ephemeral, local delivery of it is refused, and no delay notice is generated for a relay parent; it opens a second deadline, ten days cumulated. Every abandonment path ends in the same fenced purge.
  • The 250 is durable, the file included. When an SMTP door is listed in [storage] durable_doors (all three are by default), the spool file and its directory are synced before the 250, as the database’s log is: the barrier covers the row, not the file the row names (mail & SMTP).
  • What the contract promises is what the server measures. The content resides on a dedicated volume excluded from backups (oximail backup includes neither the spool nor the spool rows, with no flag to include them); the reference leaves the live database at 250; delivery logs, email trace and queue list carry the queue id only and redact the envelope of an ephemeral row ([ephemeral-redacted]); the service cannot search the spool by address. What it does not promise, named: the queue row lives in data.db on the root filesystem, which a hypervisor snapshot may carry. Per-day usage counters (relay_account_usage) live twelve months and the purge journal twenty-four, with a freshness gauge. Non-relay outbound is unaffected — it keeps its delivered record.

Verbatim forwarding and extension mediation

Two wire-level guarantees shape what a relay hop may and may not do to a message:
  • A relay account’s mail is never rewritten, and never re-signed. What a role = "service" account submits is the transit path: the message is forwarded byte-identical, with the one Received: line of the hop added above it — including when the smarthost happens to host the From: domain itself. No smarthost DKIM-Signature and no cosignature is added, so the client’s own signature remains the only one and stays verifiable end to end (tamper evidence: any modification in transit is detectable). The promise holds for that path alone: mail this server originates (a person’s, or an application’s through an ordinary account) is completed, signed and queued as the next section says.
  • ESMTP requirements are mediated, not bridged. Every outbound hop — leaf to relay, relay to destination — derives what the message needs from its content (a non-ASCII address or header requires SMTPUTF8, a high-bit body octet requires 8BITMIME, the size is declared when the peer accepts SIZE=) and checks the destination’s advertised capabilities. A gap that cannot be bridged bounces permanently (550 5.6.7 / 554 5.6.3 / 552 5.3.4) instead of delivering a mis-encoded message, with one exception on the origin path: toward a destination advertising neither 8BITMIME nor SMTPUTF8, an 8-bit body is re-encoded (quoted-printable for text, base64 otherwise, RFC 6152) so the mail is delivered rather than lost, as every MTA in use does; measured on one instance, every accented notification toward a support tool’s MX that advertised only STARTTLS had been refused by our own mediation and then lost without a bounce. The relay path stays verbatim (the leaf signed those bytes), an internationalized header stays an SMTPUTF8 need refused 5.6.7, and a body the parser cannot re-encode still bounces 5.6.3. For the same reason the EHLO advertises no onward promise it cannot keep. BINARYMIME is not advertised and BODY=BINARYMIME is rejected 555 5.5.4. DSN (RFC 3461) is advertised on a primary, where the queue row carries RET/ENVID and each recipient’s NOTIFY/ORCPT and re-emits them on the next hop only when that peer advertises DSN; a backup MX does not advertise it, since its forward path does not carry them. A malformed DSN parameter is refused at the command, 501 5.5.4, including an ENVID or ORCPT that decodes to a control or non-ASCII character and an ORCPT over 500 characters (RFC 3461 §4): such a value used to be accepted and then silently produce no report.

Who signs what

The outbound worker decides once per delivery attempt, from whose mail it is: The operator’s key wins over a delegated one. d= is always the exact domain of the From: (its A-label for an internationalized domain), never a parent domain. On top of that, a message leaving through an egress pool that names a cosign_domain gets a cosignature: a second DKIM-Signature above the first, d= the cosign domain, made with that domain’s [[smtp.dkim_keys]] key, with t= the signing time and x= 72 hours later. The reasons are the receivers’: some feedback loops attach complaints to the signing domain, so mail signed only as its client would leave the relay with no reputation signal of its own; and the expiry means a captured message replayed later cannot borrow the relay’s reputation past three days (a receiver may allow some slack after x=, so read it as a ceiling, not as a guarantee). The client’s own signature carries no expiry. A pool’s own mail (its From: under the cosign domain) carries one signature, not two. A relay account’s mail is never cosigned. Every signature the server writes, the client-domain one and the cosignature alike, covers the same headers: From, Reply-To, To, Cc, Subject, Date, Message-ID, In-Reply-To, References, MIME-Version, Content-Type, the list headers (List-Id, List-Help, List-Subscribe, List-Post, List-Owner, List-Archive) and List-Unsubscribe, List-Unsubscribe-Post, Feedback-ID and X-OxiMail-Flow. From, Reply-To, To, Cc, Subject, the two unsubscribe headers and X-OxiMail-Flow are oversigned: named once more than they occur, so nobody can add a second copy after the fact without breaking the signature. Never l=. A message the server signs leaves with CRLF line ends only: a bare CR or LF is normalised before the signature, so the signed octets are the transmitted ones (RFC 5321 §2.3.8).

Delegated signing keys

An application that submits through an ordinary account often cannot sign, and you should not ask its owner for their zone. Delegated signing lets the relay sign d=<the client's exact domain> while the client publishes two CNAMEs and nothing else. It is operator-provisioned, like every authorisation that widens what an account may send as: there is no client-side verb and no JMAP method. The domain must belong to the tenant already (oximail tenant add-domain, any kind), and --email names any account of that tenant: it only tells the CLI which tenant.
add --apply generates the first RSA 2048 key (smaller is refused), seals it, and prints what the client publishes, one CNAME per selector:
  • Where the keys are published. The targets live under dkim.<[server] hostname>, in the zone the [dns] provider already writes. With Cloudflare the relay writes the target records itself; with any other provider add, verify and doctor list the TXT records to publish by hand, and a hand-published record is recognised when read back. Each target answers v=DKIM1; k=rsa; n=<label>; p=<key>: the n= tag carries the label, so reading <selector>._domainkey.<domain> through the client’s name proves that the name leads to this relay and shows which key the world sees, without querying the CNAME.
  • Why two selectors, and random labels. Two selectors so a rotation always has a free one. Each target label is random, unique in the database and never reused: a withdrawn domain’s targets keep answering p= empty, so a CNAME a departed client forgot vouches for no one, the next tenant of that domain included.
  • When a key signs. A pass runs two minutes after the server starts, then hourly (verify runs it on demand). A key is prepared, then published once its target answers with it, then active once it has been visible through the client’s name for two TTLs of the record (3600 s, so two hours); the previous key is retiring until it goes back to empty. The domain’s dkim proof is confirmed when both selectors lead to this relay and suspended when one stops.
  • Rotation. Every 180 days a new key is generated behind the free selector and goes through the same steps; the previous key stays published for seven days, then answers p= empty. The client’s CNAMEs never change, so rotating needs nothing from the client.
  • Fail-safe, not fail-open. A key the world does not see (the published key differs from the signing key, or the target is gone) does not sign, and doctor fails. A key that cannot be read or unsealed holds the row back, deferred: it never lets a message leave unsigned. The private key is never exported (no verb and no method returns it); it is sealed under the tenant’s key, bound to its domain and selector. That tenant key is itself protected by the server key, which has its escrow, so a volume backup carries both: that is why a restore replaces them (next section).
  • Limits. One active key per domain; the two selectors are fixed (oximail1, oximail2); RSA only; automatic publication is Cloudflare only today; the key signs only the exact From: domain it was declared for (not a parent, not a subdomain), only for mail of the tenant that owns it, and only below the operator’s own keys.
  • doctor delegated_dkim fails when a signing key is no longer what the world sees; it warns when a declared domain still has no active key after three days (the client has not published its CNAMEs), when the active key is older than 190 days (rotation overdue), or when records wait to be published by hand.
  • Removing a domain (remove --yes) erases its keys and withdraws its dkim proof; its targets keep answering p= empty and are never reused.

Delegated signing keys after a restore

A backup carries the private keys of the client domains the relay signs for (oximail account signing-domain), together with the organization key that seals them, so after oximail restore every such key created before the restore is presumed exposed. The hourly pass reads the restore date from the marker restore writes beside the database and replaces them: a spare key (prepared, published or retiring) goes back to empty at once and can never sign; a new signing key is prepared in the same pass, and the old one keeps signing until the new one has been visible in DNS for two TTLs, then retires for two days instead of the usual seven. Nothing changes for the client: its CNAMEs stay the same. oximail doctor names under delegated_dkim the domains still carrying a key older than the restore; once none does, nothing more happens.

Which IP family mail leaves over

Reputation is kept per address family at the large providers and in the block lists: an IPv6 address that has never sent is cold, however established the IPv4 address of the same box is. So outgoing mail leaves over IPv4 whenever the destination has an IPv4 address.
  • auto (the default). Per domain: when any MX host has an IPv4 address, only those hosts are tried, so an IPv6-only MX of higher priority is skipped; a domain whose MX hosts all lack IPv4 is reached over IPv6. A domain whose IPv4 cannot be ruled out because a DNS answer was unreadable is deferred, never sent to over IPv6 on a guess (an empty answer is an answer, a failed one is not). Per host: its IPv4 addresses when it has one, its IPv6 addresses otherwise, never both, so a failing IPv4 defers the row instead of falling through to IPv6. The same per-host rule applies to a relay_host. Earlier versions followed the resolver’s order, which puts IPv6 first for a dual-stack MX: a box with a global IPv6 address already sent to the large providers over it, from its default address. Warm an IPv6 address deliberately or leave it unused.
  • v4 and v6 pin the family. A destination with no address of that family fails the attempt (logged) rather than silently using the other one: a guarantee that falls back is no guarantee at all.
  • Egress pools keep both families. A pool with an IPv4 and an IPv6 address hands both sources to the SMTP client, which binds, for each address of the destination, the pool’s source of that address’s family: IPv4 whenever the destination has one, and the pool’s IPv6 only for a destination without IPv4, which it could not reach otherwise. A destination in a family the pool lacks is never tried from the box’s default address: the attempt fails naming the families resolved and the families the pool can bind, and the row is deferred at the normal pace until its unchanged expiry. Pinned to v4 or v6, a pool without that family holds the row (no attempt counted).
  • oximail doctor --reachability runs the egress_pools check in full: for each public address of each pool that can serve under the configured family (so both, in auto), it checks that its PTR is the pool’s HELO name, that the HELO resolves back to it, and that the HELO’s own SPF passes for it: a null-sender report is judged on the HELO (RFC 7208 §2.3), so an IPv6 address the SPF does not name fails there. Without --reachability the check only confirms that each local address of each pool is on an interface.

Reputation monitoring

A listed shared egress IP degrades delivery for every client at once. Turn on self-monitoring:
Each egress pool’s public IPv4 address is watched as well, without being listed twice (an IPv6 address is skipped: public lists are IPv4 in practice). A worker checks each IP against Spamhaus ZEN, Barracuda, and SpamCop every fifteen minutes and publishes oximail_smarthost_ip_listed{ip,list}. It reads the answers as the inbound filter does (anti-spam): only NXDOMAIN is “not listed”, and a list that refuses the box’s resolver or does not answer is counted in oximail_dnsbl_answers_total{source="self"} rather than read as clean. Alert on > 0 and enrol your IPs in receiver feedback programs (Google Postmaster Tools, Microsoft SNDS/JMRP, Yahoo CFL) before your first client.
A shared smarthost carries the abuse and reputation responsibility for everything it relays. Monitor the egress IP, rate-limit per account, and — for a managed service — put the data-processing terms in writing (a relay operator is a data processor for the mail it transmits).