relay: deliver outbound mail #260

Merged
jcoffey-dev merged 1 commits from relay/outbound-send into main 2026-10-30 15:53:42 +00:00
Owner

Part 4c of relay outbound: the relay now delivers what the downstream hands it through Outbound/set (#258), using the DANE, MTA-STS and DNSSEC code shared with the server (#259). The downstream doesn't hand anything over yet; that's 4d.

Delivery (crates/relay/src/deliver/)

  • One domain at a time:
    • MX hosts best first, at most five, two addresses each;
    • no MX means the domain itself;
    • a null MX (RFC 7505) refuses with 556 5.1.10;
    • a domain that doesn't exist (NXDOMAIN) is refused for good.
  • TLS, with the same rules as the server:
    • DANE applies when the MX answer, the host's addresses and its TLSA records are all DNSSEC-signed, and takes precedence over MTA-STS;
    • a signed zone with unusable TLSA records still requires encryption, and bogus TLSA records skip the host;
    • MTA-STS enforce skips unlisted hosts and needs a WebPKI certificate valid for the host; testing only reports;
    • otherwise the new outboundTls setting applies: opportunistic (the default) or required, with no certificate check;
    • opportunistic TLS that fails to set up is tried once more in the clear.
  • The SMTP client: EHLO, STARTTLS (nothing may be sent ahead of the handshake), MAIL FROM with only the parameters the server offers (SIZE, BODY, SMTPUTF8, RET/ENVID; NOTIFY/ORCPT on RCPT), and dot-stuffed DATA streamed from the spool with the bytes otherwise untouched. Replies are length-checked.
  • MTA-STS policies are fetched over HTTPS: WebPKI against the system roots, no redirects, 64 KiB max, cached for max_age. As RFC 8461 asks, a policy that can't be fetched leaves the cached one in force.

Status

  • Each recipient gets the remote server's own reply: refused is no, deferred stays queued, and after DATA it's yes.
  • When no host could take the attempt, recipients stay queued with the reason as a 4.4.x reply.
  • Retries follow outboundRetry. At expiresAt, what is still queued becomes no with the last reply seen.
  • destroy is refused while an attempt is under way. Finished messages the downstream never clears are deleted after 7 days.
  • Events: outbound.attempt, outbound.tls (TLS-RPT policy type, policy strings, and result type), and outbound.expired.

Footprint: the release binary goes from 5.4 MB to 8.1 MB (glibc), under the 15 MB budget. Idle memory not re-measured.

Tests

  • New delivery tests run against a scripted MX on localhost with fixed DNS answers:
    • per-recipient yes/no/queued with the real replies (RL-T11, relay side);
    • STARTTLS used when offered;
    • dot-stuffing;
    • null MX and nonexistent domains;
    • DANE with the right key delivers and with a wrong one doesn't, and MTA-STS enforce refuses an untrusted certificate and an unlisted MX (RL-T12);
    • expiry and cleanup.
  • Unit tests cover reply parsing, EHLO keywords, parameter filtering and the dot-stuffer.
  • All 25 relay tests pass, and system::relay::relay_link_tests still passes. fmt, clippy and the fork checks are clean.
  • Not tested: resolution through real DNS, including the startup check that the resolver validates DNSSEC; and real MTA-STS HTTPS fetches.
Part 4c of relay outbound: the relay now delivers what the downstream hands it through `Outbound/set` (#258), using the DANE, MTA-STS and DNSSEC code shared with the server (#259). The downstream doesn't hand anything over yet; that's 4d. **Delivery** (`crates/relay/src/deliver/`) - One domain at a time: - MX hosts best first, at most five, two addresses each; - no MX means the domain itself; - a null MX (RFC 7505) refuses with `556 5.1.10`; - a domain that doesn't exist (NXDOMAIN) is refused for good. - TLS, with the same rules as the server: - DANE applies when the MX answer, the host's addresses and its TLSA records are all DNSSEC-signed, and takes precedence over MTA-STS; - a signed zone with unusable TLSA records still requires encryption, and bogus TLSA records skip the host; - MTA-STS `enforce` skips unlisted hosts and needs a WebPKI certificate valid for the host; `testing` only reports; - otherwise the new `outboundTls` setting applies: `opportunistic` (the default) or `required`, with no certificate check; - opportunistic TLS that fails to set up is tried once more in the clear. - The SMTP client: EHLO, STARTTLS (nothing may be sent ahead of the handshake), MAIL FROM with only the parameters the server offers (SIZE, BODY, SMTPUTF8, RET/ENVID; NOTIFY/ORCPT on RCPT), and dot-stuffed DATA streamed from the spool with the bytes otherwise untouched. Replies are length-checked. - MTA-STS policies are fetched over HTTPS: WebPKI against the system roots, no redirects, 64 KiB max, cached for `max_age`. As RFC 8461 asks, a policy that can't be fetched leaves the cached one in force. **Status** - Each recipient gets the remote server's own reply: refused is `no`, deferred stays `queued`, and after DATA it's `yes`. - When no host could take the attempt, recipients stay queued with the reason as a `4.4.x` reply. - Retries follow `outboundRetry`. At `expiresAt`, what is still queued becomes `no` with the last reply seen. - `destroy` is refused while an attempt is under way. Finished messages the downstream never clears are deleted after 7 days. - Events: `outbound.attempt`, `outbound.tls` (TLS-RPT policy type, policy strings, and result type), and `outbound.expired`. **Footprint:** the release binary goes from 5.4 MB to 8.1 MB (glibc), under the 15 MB budget. Idle memory not re-measured. **Tests** - New delivery tests run against a scripted MX on localhost with fixed DNS answers: - per-recipient yes/no/queued with the real replies (RL-T11, relay side); - STARTTLS used when offered; - dot-stuffing; - null MX and nonexistent domains; - DANE with the right key delivers and with a wrong one doesn't, and MTA-STS `enforce` refuses an untrusted certificate and an unlisted MX (RL-T12); - expiry and cleanup. - Unit tests cover reply parsing, EHLO keywords, parameter filtering and the dot-stuffer. - All 25 relay tests pass, and `system::relay::relay_link_tests` still passes. fmt, clippy and the fork checks are clean. - Not tested: resolution through real DNS, including the startup check that the resolver validates DNSSEC; and real MTA-STS HTTPS fetches.
jcoffey-dev added 1 commit 2026-10-30 15:41:37 +00:00
relay: deliver outbound mail
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 11m25s
a41b840e5e
The relay now sends what the downstream hands it: by MX, one domain at a
time, with DANE where the MX and the host's addresses are signed and its
TLSA records are, MTA-STS where the domain enforces a policy, and
otherwise STARTTLS per the new outboundTls setting. Each recipient's
deliveryStatus is the remote server's own reply; retries follow
outboundRetry, and what is still queued at expiresAt is refused with the
last reply. Sessions report outbound.attempt and outbound.tls events, the
latter for TLS-RPT. DNS, DANE and MTA-STS come from crates/outbound-tls.
jcoffey-dev merged commit 5ea6c31d9f into main 2026-10-30 15:53:42 +00:00
jcoffey-dev deleted branch relay/outbound-send 2026-10-30 15:53:42 +00:00
Sign in to join this conversation.