From fc2923fd4830f33fd53aa4842cb948cd10bccef5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 09:34:46 -0700 Subject: [PATCH] relay: send REQUIRETLS mail through relays The downstream hands REQUIRETLS mail to a relay with the parameter instead of keeping it off relays. The relay treats it as the server's own delivery does: TLS required with a WebPKI certificate unless DANE applies, never retried in the clear, and REQUIRETLS passed on where the next server offers it. Its TLS failures report no-policy-found unless an MTA-STS policy applied. --- crates/relay/src/deliver/client.rs | 28 ++++++++++++++- crates/relay/src/deliver/mod.rs | 14 ++++++-- crates/relay/src/deliver/tests.rs | 34 +++++++++++++++++- crates/smtp/src/outbound/delivery.rs | 5 ++- crates/smtp/src/outbound/relay.rs | 10 ++++-- docs/spec/features/relay.md | 15 +++++--- tests/src/system/relay.rs | 53 +++++++++++++++++++++++++++- 7 files changed, 144 insertions(+), 15 deletions(-) diff --git a/crates/relay/src/deliver/client.rs b/crates/relay/src/deliver/client.rs index 85d4bf32..67aa4392 100644 --- a/crates/relay/src/deliver/client.rs +++ b/crates/relay/src/deliver/client.rs @@ -284,6 +284,7 @@ pub struct Ehlo { pub eight_bit: bool, pub smtputf8: bool, pub dsn: bool, + pub requiretls: bool, } impl Ehlo { @@ -301,6 +302,7 @@ impl Ehlo { "8BITMIME" => ehlo.eight_bit = true, "SMTPUTF8" => ehlo.smtputf8 = true, "DSN" => ehlo.dsn = true, + "REQUIRETLS" => ehlo.requiretls = true, _ => {} } } @@ -308,6 +310,11 @@ impl Ehlo { } } +/// Whether the downstream sent the message with REQUIRETLS (RFC 8689). +pub fn requires_tls(from: &Address) -> bool { + parameter(from, "REQUIRETLS").is_some() +} + /// The value of an SMTP parameter, if the downstream gave it. fn parameter<'a>(address: &'a Address, name: &str) -> Option> { address @@ -319,7 +326,7 @@ fn parameter<'a>(address: &'a Address, name: &str) -> Option> { } /// MAIL FROM, with only the parameters this server takes (RFC 1870, 6152, -/// 6531, 3461). The size is the relay's own count. +/// 6531, 3461, 8689). The size is the relay's own count. fn mail_from(from: &Address, ehlo: &Ehlo, size: u64, utf8: bool) -> String { let mut line = format!("MAIL FROM:<{}>", from.email); if ehlo.size.is_some() { @@ -331,6 +338,10 @@ fn mail_from(from: &Address, ehlo: &Ehlo, size: u64, utf8: bool) -> String { { line.push_str(" BODY=8BITMIME"); } + // Only ever on a session where TLS was required + if ehlo.requiretls && requires_tls(from) { + line.push_str(" REQUIRETLS"); + } if utf8 { line.push_str(" SMTPUTF8"); } @@ -575,12 +586,27 @@ mod tests { size: Some(0), eight_bit: true, dsn: true, + requiretls: true, ..Default::default() }; assert_eq!( mail_from(&from, &full, 10, true), "MAIL FROM: SIZE=10 BODY=8BITMIME SMTPUTF8 RET=HDRS\r\n" ); + let mut strict = from.clone(); + strict + .parameters + .as_mut() + .unwrap() + .insert("REQUIRETLS".to_string(), None); + assert_eq!( + mail_from(&strict, &bare, 10, false), + "MAIL FROM:\r\n" + ); + assert_eq!( + mail_from(&strict, &full, 10, false), + "MAIL FROM: SIZE=10 BODY=8BITMIME REQUIRETLS RET=HDRS\r\n" + ); let mut rcpt = Address::new("b@example.org"); rcpt.parameters = Some([("NOTIFY".to_string(), Some("NEVER".to_string()))].into()); assert_eq!(rcpt_to(&rcpt, &bare), "RCPT TO:\r\n"); diff --git a/crates/relay/src/deliver/mod.rs b/crates/relay/src/deliver/mod.rs index d73b323e..5cc19a47 100644 --- a/crates/relay/src/deliver/mod.rs +++ b/crates/relay/src/deliver/mod.rs @@ -189,6 +189,7 @@ impl Target<'_, R> { .await .filter(|p| p.mode != Mode::None); let enforce = sts.as_ref().is_some_and(|p| p.enforce()); + let requiretls = client::requires_tls(&self.meta.envelope.mail_from); let mut last = format!("451 4.4.0 No MX host of {domain} could be reached"); for host in hosts.iter().take(MX_MAX) { @@ -218,7 +219,9 @@ impl Target<'_, R> { continue; } }; - let fallback = if enforce { + // REQUIRETLS asks what the server's own delivery gives it: TLS + // with a WebPKI certificate, unless DANE applies (RFC 8689 ยง4.2.1) + let fallback = if enforce || requiretls { TlsMode::Verified } else if self.settings.outbound_tls == OutboundTls::Required { TlsMode::Required @@ -345,7 +348,14 @@ impl Target<'_, R> { let mut data = crate::events::data([ ("domain", json!(self.domain)), ("mx", json!(mx)), - ("policyType", json!(mode.policy_type())), + ( + "policyType", + // Verified for REQUIRETLS alone isn't an MTA-STS policy + json!(match (mode, sts) { + (TlsMode::Verified, None) => "no-policy-found", + _ => mode.policy_type(), + }), + ), ("policyStrings", json!(policy_strings)), ( "result", diff --git a/crates/relay/src/deliver/tests.rs b/crates/relay/src/deliver/tests.rs index 2115128f..b40ee0e7 100644 --- a/crates/relay/src/deliver/tests.rs +++ b/crates/relay/src/deliver/tests.rs @@ -216,6 +216,10 @@ async fn relay(name: &str) -> (Arc, std::path::PathBuf) { } fn hand_over(app: &App, to: &[&str], body: &[u8]) -> String { + hand_over_from(app, Address::new("jane@example.com"), to, body) +} + +fn hand_over_from(app: &App, from: Address, to: &[&str], body: &[u8]) -> String { let blob = app.paths.blobs().join(crate::spool::new_id()); std::fs::write(&blob, body).unwrap(); let settings = app.settings().unwrap(); @@ -226,7 +230,7 @@ fn hand_over(app: &App, to: &[&str], body: &[u8]) -> String { &blob, "b", Envelope { - mail_from: Address::new("jane@example.com"), + mail_from: from, rcpt_to: to.iter().map(|t| Address::new(*t)).collect(), }, &settings, @@ -475,6 +479,34 @@ async fn mta_sts_enforce_is_enforced() { assert!(seen.lock().unwrap().messages.is_empty()); } +#[tokio::test] +async fn requiretls_needs_a_verified_certificate() { + // The scripted MX's certificate is self-signed: fine for opportunistic + // TLS, not for REQUIRETLS (RFC 8689) + let (port, seen, _) = scripted_mx().await; + let (app, dir) = relay("requiretls").await; + let mut from = Address::new("jane@example.com"); + from.parameters = Some([("REQUIRETLS".to_string(), None)].into()); + let id = hand_over_from( + &app, + from, + &["bob@example.org"], + b"Subject: r\r\n\r\nhi\r\n", + ); + tokio::spawn(run(app.clone(), Arc::new(hosts(port)))); + app.outbound_wake.notify_one(); + let out = tried(&app, &id).await; + let status = &out.delivery_status["bob@example.org"]; + assert_eq!(status.delivered, Delivered::Queued); + assert!(status.smtp_reply.starts_with("451 4."), "{status:?}"); + let tls = events(&app, "outbound.tls"); + assert_eq!(tls[0]["policyType"], "no-policy-found"); + assert_ne!(tls[0]["result"], "success"); + // Not tried again in the clear either + assert!(seen.lock().unwrap().messages.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} + #[tokio::test] async fn expiry_refuses_what_is_still_queued() { let (port, _, _) = scripted_mx().await; diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index 197e5968..8eaa0f29 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -239,14 +239,13 @@ impl QueuedMessage { } } // inbuxa: with a relay's sendThrough on, every non-local recipient - // goes through a relay (relay spec, RL-24). REQUIRETLS mail doesn't - // yet: the relay can't keep that promise for it. + // goes through a relay (relay spec, RL-24) static THROUGH_RELAY: RoutingStrategy = RoutingStrategy::InbuxaRelay; let senders = &server.inner.data.relay_senders; if let Err(err) = senders.load(server.store()).await { trc::error!(err.details("Failed to read the relays").span_id(span_id)); } - let through_relay = senders.sending() && message.message.flags & MAIL_REQUIRETLS == 0; + let through_relay = senders.sending(); let mut relayed_waiting = Vec::new(); for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { if matches!( diff --git a/crates/smtp/src/outbound/relay.rs b/crates/smtp/src/outbound/relay.rs index e93221bd..9d242b63 100644 --- a/crates/smtp/src/outbound/relay.rs +++ b/crates/smtp/src/outbound/relay.rs @@ -23,8 +23,8 @@ use common::{Server, config::smtp::queue::QueueName, ipc::QueueEvent}; use inbuxa_features::relay::outbound::{Handed, Refused, record_handed}; use inbuxa_relay_proto::types::{Address, Envelope}; use smtp_proto::{ - MAIL_BODY_8BITMIME, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, - RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response, + MAIL_BODY_8BITMIME, MAIL_REQUIRETLS, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, + RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response, }; use std::collections::BTreeMap; use store::write::now; @@ -153,7 +153,8 @@ impl MessageWrapper { } /// The envelope as the relay takes it: the DSN parameters this server - /// would send itself, and 8BITMIME (RFC 8621 `Address` parameters). + /// would send itself, 8BITMIME, and REQUIRETLS, which the relay keeps + /// as this server would (RFC 8621 `Address` parameters). fn relay_envelope(&self, rcpt_idxs: &[usize]) -> Envelope { let flags = self.message.flags; let mut from = BTreeMap::new(); @@ -165,6 +166,9 @@ impl MessageWrapper { } else if flags & MAIL_RET_HDRS != 0 { from.insert("RET".to_string(), Some("HDRS".to_string())); } + if flags & MAIL_REQUIRETLS != 0 { + from.insert("REQUIRETLS".to_string(), None); + } if let Some(env_id) = &self.message.env_id { from.insert("ENVID".to_string(), Some(env_id.to_string())); } diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index b8343c9b..add4a4ae 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -569,8 +569,12 @@ allowing the edge's IP and disabling SPF, DMARC and reverse IP for it. relay, for every message handed to it from any node. A final status is recorded in the downstream's queue, which sends any DSN as for its own deliveries, and the message is then destroyed on the relay. -- Mail sent with REQUIRETLS (RFC 8689) isn't handed to a relay yet: it - takes the route it would without relays. +- Mail sent with REQUIRETLS (RFC 8689) is handed over with the parameter, + and the relay treats it as the server's own delivery does: TLS is + required, with a WebPKI certificate valid for the MX host unless DANE + applies, never in the clear, and `REQUIRETLS` is passed on where the + next server offers it. Its TLS failures are reported as + `no-policy-found` unless an MTA-STS policy applied. ### DNS and certificates @@ -827,8 +831,11 @@ In progress. Done so far: the relay sending to a scripted MX (RL-T11): a test build of the relay, with the `test-mx` feature, sends every domain to one local port. `outboundTls` is set like any other setting, `settings/outboundTls`. -- Still to come in step 4: the named `inbuxaRelay` route, TLS-RPT reports - from the relay's `outbound.tls` events, and REQUIRETLS through relays. +- REQUIRETLS through relays: handed over with the parameter, delivered by + the relay only over TLS with a verified certificate (tested against a + scripted MX with a self-signed one). +- Still to come in step 4: the named `inbuxaRelay` route, and TLS-RPT + reports from the relay's `outbound.tls` events. - `crates/outbound-tls` (RL-30): the TLSA and MTA-STS types, the DANE certificate check, MTA-STS policy parsing and matching, and the DNSSEC lookups (with the bogus-answer recheck), moved out of `crates/smtp` and diff --git a/tests/src/system/relay.rs b/tests/src/system/relay.rs index ab91ce6e..72dfc262 100644 --- a/tests/src/system/relay.rs +++ b/tests/src/system/relay.rs @@ -546,6 +546,18 @@ async fn wait_for_mail(account: &Account, subject: &str, count: usize) { /// Sends a message from `sender` to `to` by JMAP submission. async fn submit(sender: &Account, identity: &str, mailbox: &str, to: &[&str], subject: &str) { + submit_with(sender, identity, mailbox, to, subject, None).await +} + +/// The same, with the submission's `envelope`, if given. +async fn submit_with( + sender: &Account, + identity: &str, + mailbox: &str, + to: &[&str], + subject: &str, + envelope: Option, +) { let (_, response) = call( sender, "Email/set", @@ -563,10 +575,14 @@ async fn submit(sender: &Account, identity: &str, mailbox: &str, to: &[&str], su .as_str() .unwrap_or_else(|| panic!("draft: {response}")) .to_string(); + let mut create = json!({"emailId": email, "identityId": identity}); + if let Some(envelope) = envelope { + create["envelope"] = envelope; + } let (_, response) = call( sender, "EmailSubmission/set", - json!({"create": {"s": {"emailId": email, "identityId": identity}}}), + json!({"create": {"s": create}}), ) .await; assert!(response["created"].get("s").is_some(), "{response}"); @@ -946,6 +962,41 @@ pub async fn link(test: &mut TestServer) { ); tokio::time::sleep(std::time::Duration::from_millis(500)).await; } + // REQUIRETLS (RFC 8689): never sent in the clear, and this MX has no + // STARTTLS, so it waits, saying why + submit_with( + &jane, + &identity, + &drafts, + &["strict@remote.test"], + "Strict one", + Some(json!({ + "mailFrom": {"email": "jane@example.com", "parameters": {"REQUIRETLS": null}}, + "rcptTo": [{"email": "strict@remote.test"}] + })), + ) + .await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let (_, queued) = call(admin, "x:QueuedMessage/get", json!({"ids": null})).await; + let text = queued.to_string(); + if text.contains("strict@remote.test") && text.contains("doesn't offer STARTTLS") { + break; + } + assert!( + std::time::Instant::now() < deadline, + "REQUIRETLS held back: {queued}" + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + assert!( + !mx_seen + .lock() + .unwrap() + .iter() + .any(|m| m.contains("Subject: Strict one")), + "sent in the clear" + ); tokio::time::sleep(std::time::Duration::from_secs(2)).await; assert_eq!( subjects(&jane) -- 2.54.0