relay: send REQUIRETLS mail through relays #262

Merged
jcoffey-dev merged 1 commits from relay/outbound-requiretls into main 2026-10-30 17:08:30 +00:00
7 changed files with 144 additions and 15 deletions

No files matched your search

+27 -1
View File
@@ -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<Option<&'a str>> {
address
@@ -319,7 +326,7 @@ fn parameter<'a>(address: &'a Address, name: &str) -> Option<Option<&'a str>> {
}
/// 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:<[email protected]> 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:<[email protected]>\r\n"
);
assert_eq!(
mail_from(&strict, &full, 10, false),
"MAIL FROM:<[email protected]> SIZE=10 BODY=8BITMIME REQUIRETLS RET=HDRS\r\n"
);
let mut rcpt = Address::new("[email protected]");
rcpt.parameters = Some([("NOTIFY".to_string(), Some("NEVER".to_string()))].into());
assert_eq!(rcpt_to(&rcpt, &bare), "RCPT TO:<[email protected]>\r\n");
+12 -2
View File
@@ -189,6 +189,7 @@ impl<R: Resolve> 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<R: Resolve> 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<R: Resolve> 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",
+33 -1
View File
@@ -216,6 +216,10 @@ async fn relay(name: &str) -> (Arc<App>, std::path::PathBuf) {
}
fn hand_over(app: &App, to: &[&str], body: &[u8]) -> String {
hand_over_from(app, Address::new("[email protected]"), 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("[email protected]"),
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("[email protected]");
from.parameters = Some([("REQUIRETLS".to_string(), None)].into());
let id = hand_over_from(
&app,
from,
&["[email protected]"],
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["[email protected]"];
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;
+2 -3
View File
@@ -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!(
+7 -3
View File
@@ -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()));
}
+11 -4
View File
@@ -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
+52 -1
View File
@@ -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<Value>,
) {
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,
&["[email protected]"],
"Strict one",
Some(json!({
"mailFrom": {"email": "[email protected]", "parameters": {"REQUIRETLS": null}},
"rcptTo": [{"email": "[email protected]"}]
})),
)
.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("[email protected]") && 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)