- 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:
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: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_filesidecar,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_fileand no pinning. An internal smarthost must present a publicly valid certificate for itsrelay_hostname (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.
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 aSmarthost 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 roleservice. 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.
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.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 anspf.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 aservice 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 initcreates the root (0700) on the mounted volume and is the only place that creates it. A submission is refused452 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 ofblob_path, not0700to the service user, or past the tenant’s watermark (per tenant, so one client cannot fill the spool and get everyone refused).check-configrefuses 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_spoolmeasures the separation: a row without a file, an unreachable or read-only spool, or the samest_devas 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 gccollects files no row owns any more (dry-run by default,--applyunlinks after re-reading each row, stands down while a backup marker is present).relay-spool reabsorbretires 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=FULLis not honoured, and the end-of-DATA250says so) except the originalMessage-ID: its third part is atext/rfc822-headerssection 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
250is 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 the250, 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 backupincludes neither the spool nor the spool rows, with no flag to include them); the reference leaves the live database at250; delivery logs,email traceandqueue listcarry 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 indata.dbon 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 oneReceived:line of the hop added above it — including when the smarthost happens to host theFrom:domain itself. No smarthostDKIM-Signatureand 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 refused5.6.7, and a body the parser cannot re-encode still bounces5.6.3. For the same reason the EHLO advertises no onward promise it cannot keep. BINARYMIME is not advertised andBODY=BINARYMIMEis rejected555 5.5.4. DSN (RFC 3461) is advertised on a primary, where the queue row carriesRET/ENVIDand each recipient’sNOTIFY/ORCPTand 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 anENVIDorORCPTthat decodes to a control or non-ASCII character and anORCPTover 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 signd=<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 provideradd,verifyanddoctorlist the TXT records to publish by hand, and a hand-published record is recognised when read back. Each target answersv=DKIM1; k=rsa; n=<label>; p=<key>: then=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 (
verifyruns 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’sdkimproof 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
doctorfails. 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 exactFrom: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_dkimfails 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 itsdkimproof; its targets keep answeringp=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 arelay_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.v4andv6pin 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
v4orv6, a pool without that family holds the row (no attempt counted). oximail doctor --reachabilityruns theegress_poolscheck in full: for each public address of each pool that can serve under the configured family (so both, inauto), 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--reachabilitythe 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: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.