diff --git a/crates/flectar-mail-core/src/lib.rs b/crates/flectar-mail-core/src/lib.rs index e612bd56..872fcf5f 100644 --- a/crates/flectar-mail-core/src/lib.rs +++ b/crates/flectar-mail-core/src/lib.rs @@ -3632,19 +3632,36 @@ impl Core { Ok(None) } - /// Unsubscribe from the list a message came from, preferring the RFC 8058 - /// one-click HTTPS POST, then the RFC 2369 mailto: (sent immediately over - /// SMTP), and finally handing the URL back for the browser. The returned - /// outcome states what actually happened so the UI can toast honestly. + /// Unsubscribe from the list a message came from, preferring a signed + /// RFC 8058 one-click HTTPS POST, then the RFC 2369 mailto: (sent + /// immediately over SMTP), and finally handing the URL back for the + /// browser. The returned outcome states what actually happened so the UI + /// can toast honestly. pub async fn unsubscribe_message(&self, message_id: i64) -> Result { - let detail = self + let (detail, raw_path) = self .db - .read(move |conn| repo::messages::detail(conn, message_id)) + .read(move |conn| { + let detail = repo::messages::detail(conn, message_id)?; + let raw_path = + repo::messages::get_row(conn, message_id)?.and_then(|row| row.raw_path); + Ok((detail, raw_path)) + }) .await?; let raw = detail .list_unsubscribe .ok_or_else(|| CoreError::NotFound("message has no List-Unsubscribe header".into()))?; - let plan = unsubscribe::plan(&raw, detail.list_unsubscribe_post.as_deref()) + // One-click needs the DKIM-Signature fields of the original, which + // the reader has downloaded; without them it falls back to the other + // ways. + let one_click = match &raw_path { + Some(raw_path) => { + crate::file_io::read_headers(raw_path, MAX_CACHED_HEADER_BYTES, "cached headers") + .await + .is_ok_and(|raw| crate::mime::one_click_signed(&raw)) + } + None => false, + }; + let plan = unsubscribe::plan(&raw, one_click) .ok_or_else(|| CoreError::Other("List-Unsubscribe header has no usable URI".into()))?; match plan { unsubscribe::UnsubscribePlan::OneClick { url } => { diff --git a/crates/flectar-mail-core/src/mime/mod.rs b/crates/flectar-mail-core/src/mime/mod.rs index 9c1cc8c2..48516635 100644 --- a/crates/flectar-mail-core/src/mime/mod.rs +++ b/crates/flectar-mail-core/src/mime/mod.rs @@ -1747,9 +1747,15 @@ fn authentication_results(results: &str) -> Vec> { let properties = tokens .filter_map(|token| token.split_once('=')) .filter(|(name, _)| { - ["policy.authority", "header.from", "header.d"] - .iter() - .any(|known| name.trim().eq_ignore_ascii_case(known)) + [ + "policy.authority", + "header.from", + "header.d", + "header.i", + "header.b", + ] + .iter() + .any(|known| name.trim().eq_ignore_ascii_case(known)) }) .map(|(name, value)| { ( @@ -1865,6 +1871,124 @@ pub fn is_one_click_post(raw: &str) -> bool { .eq_ignore_ascii_case("List-Unsubscribe=One-Click") } +/// Whether a message's header block asks for an RFC 8058 one-click +/// unsubscribe that its sender signed. Section 4 requires a valid DKIM +/// signature whose h= tag covers both List-Unsubscribe and +/// List-Unsubscribe-Post; without one, receivers should not offer one-click. +/// +/// Signatures are not verified here. As with the verified-sender badge, the +/// result the receiving server put in the topmost Authentication-Results +/// field (RFC 8601) decides, and its header.b (RFC 6008) tells which +/// signature passed. When a server reports only the signing domain, every +/// signature of that domain has to cover both fields. Each field has to +/// appear exactly once: RFC 2369 allows no more, and a signature that covers +/// one instance says nothing about another (RFC 6376 section 8.15). +pub fn one_click_signed(raw: &[u8]) -> bool { + let Some(msg) = mail_parser::MessageParser::default().parse_headers(raw) else { + return false; + }; + let fields = |name: &'static str| msg.header_as(name, mail_parser::HeaderForm::Raw); + let post = fields("List-Unsubscribe-Post"); + if fields("List-Unsubscribe").len() != 1 + || post.len() != 1 + || !post[0].as_text().is_some_and(is_one_click_post) + { + return false; + } + let signatures: Vec = fields("DKIM-Signature") + .iter() + .filter_map(|value| value.as_text().and_then(DkimSignature::parse)) + .collect(); + let authentication = first_raw_header(&msg, "Authentication-Results").unwrap_or_default(); + let authentication = without_comments(&authentication); + authentication_results(&authentication) + .iter() + .filter(|result| { + result.method.eq_ignore_ascii_case("dkim") && result.result.eq_ignore_ascii_case("pass") + }) + .any(|result| { + let mut reported = signatures + .iter() + .filter(|signature| signature.reported_by(result)) + .peekable(); + reported.peek().is_some() && reported.all(DkimSignature::covers_one_click) + }) +} + +/// The DKIM-Signature tags that say who signed which fields (RFC 6376 +/// section 3.5). +struct DkimSignature { + domain: String, + /// Signed field names, lowercase. + signed: Vec, + /// The signature, without folding whitespace. + signature: String, +} + +impl DkimSignature { + fn parse(value: &str) -> Option { + let (mut domain, mut signed, mut signature) = (None, None, None); + for tag in value.split(';') { + let Some((name, value)) = tag.split_once('=') else { + continue; + }; + let compact = || -> String { value.split_ascii_whitespace().collect() }; + // Tag names are case-sensitive. + match name.trim() { + "d" => domain = Some(compact()), + "h" => { + signed = Some( + compact() + .split(':') + .map(str::to_ascii_lowercase) + .collect::>(), + ) + } + "b" => signature = Some(compact()), + _ => {} + } + } + Some(Self { + domain: domain.filter(|domain| !domain.is_empty())?, + signed: signed?, + signature: signature.filter(|signature| !signature.is_empty())?, + }) + } + + /// Whether the receiver's result is about this signature: by the start of + /// its b= value (matched case-sensitively, RFC 6008), else by the domain + /// it reports. An i= identity names the signing domain or a subdomain. + fn reported_by(&self, result: &AuthenticationResult<'_>) -> bool { + if let Some(signature) = auth_property(result, "header.b") { + return !signature.is_empty() && self.signature.starts_with(signature); + } + if let Some(domain) = auth_property(result, "header.d") { + return self + .domain + .trim_end_matches('.') + .eq_ignore_ascii_case(domain.trim_end_matches('.')); + } + auth_property(result, "header.i").is_some_and(|identity| { + let identity = domain_of(identity).trim_end_matches('.'); + let domain = self.domain.trim_end_matches('.'); + identity.eq_ignore_ascii_case(domain) + || identity + .len() + .checked_sub(domain.len() + 1) + .is_some_and(|dot| { + identity.as_bytes()[dot] == b'.' + && identity[dot + 1..].eq_ignore_ascii_case(domain) + }) + }) + } + + fn covers_one_click(&self) -> bool { + ["list-unsubscribe", "list-unsubscribe-post"] + .iter() + .all(|name| self.signed.iter().any(|signed| signed == name)) + } +} + fn parse_headers(msg: &mail_parser::Message) -> ParsedHeaders { let mut refs: Vec = Vec::new(); if let Some(irt) = msg.in_reply_to().as_text_list() { @@ -2574,6 +2698,98 @@ mod tests { ); } + fn one_click_message(authentication: &str, signatures: &[&str]) -> String { + let mut raw = format!("Authentication-Results: {authentication}\r\n"); + for signature in signatures { + raw.push_str(&format!("DKIM-Signature: {signature}\r\n")); + } + raw.push_str(concat!( + "From: News \r\n", + "List-Unsubscribe: \r\n", + "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n", + "Subject: Weekly\r\n\r\nbody" + )); + raw + } + + const COVERING: &str = "v=1; a=rsa-sha256; d=news.example; s=s1;\r\n\th=From:Subject:\r\n List-Unsubscribe:List-Unsubscribe-Post; bh=x;\r\n\tb=AbCdEfGh12\r\n 345"; + const PARTIAL: &str = "v=1; a=rsa-sha256; d=news.example; s=s2; h=From:Subject:List-Unsubscribe; bh=x; b=ZyXwVuTs999"; + + #[test] + fn one_click_needs_the_passing_signature_to_cover_both_fields() { + // Gmail names the signature that passed by the start of its b= tag. + let gmail = "mx.google.com; dkim=pass header.i=@news.example header.s=s1 header.b=AbCdEfGh"; + assert!(one_click_signed( + one_click_message(gmail, &[PARTIAL, COVERING]).as_bytes() + )); + // The case of the b= prefix matters (RFC 6008). + let wrong_case = "mx.google.com; dkim=pass header.b=abcdefgh"; + assert!(!one_click_signed( + one_click_message(wrong_case, &[COVERING]).as_bytes() + )); + // Only the signature that passed counts, not one that covers both. + let partial_passed = "mx.example; dkim=pass header.b=ZyXwVuTs; dkim=fail header.b=AbCdEfGh"; + assert!(!one_click_signed( + one_click_message(partial_passed, &[PARTIAL, COVERING]).as_bytes() + )); + // A claimed pass inside a comment is not a result. + let comment = "mx.example; dkim=none (dkim=pass header.b=AbCdEfGh)"; + assert!(!one_click_signed( + one_click_message(comment, &[COVERING]).as_bytes() + )); + assert!(!one_click_signed( + one_click_message("mx.example; dkim=pass header.b=AbCdEfGh", &[]).as_bytes() + )); + } + + #[test] + fn one_click_by_domain_needs_every_signature_of_that_domain() { + // Microsoft reports only the signing domain. + let microsoft = "spf=pass (sender IP is 192.0.2.1) smtp.mailfrom=news.example; dkim=pass (signature was verified) header.d=news.example;dmarc=pass action=none header.from=news.example;compauth=pass reason=100"; + assert!(one_click_signed( + one_click_message(microsoft, &[COVERING]).as_bytes() + )); + assert!(!one_click_signed( + one_click_message(microsoft, &[COVERING, PARTIAL]).as_bytes() + )); + let other_domain = "mx.example; dkim=pass header.d=esp.example"; + assert!(!one_click_signed( + one_click_message(other_domain, &[COVERING]).as_bytes() + )); + // An identity in a subdomain still names the signing domain. + let identity = "mx.example; dkim=pass header.i=@mail.news.example"; + assert!(one_click_signed( + one_click_message(identity, &[COVERING]).as_bytes() + )); + let lookalike = "mx.example; dkim=pass header.i=@evilnews.example"; + assert!(!one_click_signed( + one_click_message(lookalike, &[COVERING]).as_bytes() + )); + } + + #[test] + fn one_click_needs_exactly_one_of_each_field() { + let gmail = "mx.google.com; dkim=pass header.b=AbCdEfGh"; + let signed = one_click_message(gmail, &[COVERING]); + let added = signed.replacen( + "List-Unsubscribe:", + "List-Unsubscribe: \r\nList-Unsubscribe:", + 1, + ); + assert!(!one_click_signed(added.as_bytes())); + let twice = signed.replacen( + "List-Unsubscribe-Post:", + "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\nList-Unsubscribe-Post:", + 1, + ); + assert!(!one_click_signed(twice.as_bytes())); + let other_value = signed.replace( + "List-Unsubscribe-Post: List-Unsubscribe=One-Click", + "List-Unsubscribe-Post: List-Unsubscribe=Later", + ); + assert!(!one_click_signed(other_value.as_bytes())); + } + #[test] fn accepts_receiver_stamped_bimi_pass() { let raw = concat!( diff --git a/crates/flectar-mail-core/src/unsubscribe.rs b/crates/flectar-mail-core/src/unsubscribe.rs index db91f317..930154f9 100644 --- a/crates/flectar-mail-core/src/unsubscribe.rs +++ b/crates/flectar-mail-core/src/unsubscribe.rs @@ -3,14 +3,14 @@ //! //! One-click (RFC 8058) requires the sender to publish BOTH //! `List-Unsubscribe: ` and -//! `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; the receiver then -//! POSTs the literal body `List-Unsubscribe=One-Click` to the HTTPS URI. -//! The endpoint must complete the unsubscribe without cookies, logins or -//! redirects, so the client here carries no cookie store and treats any -//! non-2xx (including 3xx) as failure. +//! `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, signed by DKIM +//! ([`crate::mime::one_click_signed`]); the receiver then POSTs the field +//! `List-Unsubscribe=One-Click` to the HTTPS URI. The endpoint must complete +//! the unsubscribe without cookies, logins or redirects, so the client here +//! carries no cookie store and treats any non-2xx (including 3xx) as failure. use crate::error::{CoreError, Result}; -use crate::mime::{is_one_click_post, unsubscribe_uris}; +use crate::mime::unsubscribe_uris; use std::{ net::{IpAddr, SocketAddr}, time::Duration, @@ -33,17 +33,15 @@ pub enum UnsubscribePlan { Browser { url: String }, } -/// Decide how to unsubscribe from the raw header values. None when the header -/// contains no usable URI. -pub fn plan( - list_unsubscribe: &str, - list_unsubscribe_post: Option<&str>, -) -> Option { +/// Decide how to unsubscribe from the raw List-Unsubscribe value. `one_click` +/// says the sender signed a one-click request +/// ([`crate::mime::one_click_signed`]). None when the header contains no +/// usable URI. +pub fn plan(list_unsubscribe: &str, one_click: bool) -> Option { let mut uris = unsubscribe_uris(list_unsubscribe); let web = uris .clone() .find(|u| has_scheme_prefix(u, "https://") || has_scheme_prefix(u, "http://")); - let one_click = list_unsubscribe_post.is_some_and(is_one_click_post); // RFC 8058 is https-only; a one-click marker on an http:// URI is ignored. if one_click && let Some(url) = web.filter(|u| has_scheme_prefix(u, "https://")) { @@ -84,6 +82,11 @@ fn parse_mailto(uri: &str) -> Option { Some(UnsubscribePlan::Mailto { to, subject, body }) } +/// The form field the POST carries, as multipart/form-data: RFC 8058 section +/// 3.2 says it SHOULD be sent that way, and MAY be urlencoded. +const ONE_CLICK_BOUNDARY: &str = "flectar-one-click"; +const ONE_CLICK_FORM: &str = "--flectar-one-click\r\nContent-Disposition: form-data; name=\"List-Unsubscribe\"\r\n\r\nOne-Click\r\n--flectar-one-click--\r\n"; + /// Perform the RFC 8058 one-click POST. Success is a 2xx only: no redirects /// are followed (the endpoint must not require them) and no cookies are sent. pub async fn post_one_click(url: &str) -> Result<()> { @@ -120,9 +123,9 @@ pub async fn post_one_click(url: &str) -> Result<()> { .post(url) .header( reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", + format!("multipart/form-data; boundary={ONE_CLICK_BOUNDARY}"), ) - .body("List-Unsubscribe=One-Click") + .body(ONE_CLICK_FORM) .send() .await .map_err(|e| CoreError::Network(e.to_string()))?; @@ -203,11 +206,8 @@ mod tests { use super::*; #[test] - fn one_click_needs_post_header_and_https() { - let p = plan( - ", ", - Some("List-Unsubscribe=One-Click"), - ); + fn one_click_needs_a_signed_request_and_https() { + let p = plan(", ", true); assert_eq!( p, Some(UnsubscribePlan::OneClick { @@ -215,12 +215,12 @@ mod tests { }) ); - // Same header without the POST marker: mailto wins over browser. - let p = plan(", ", None); + // The same header without a signed request: mailto wins over browser. + let p = plan(", ", false); assert!(matches!(p, Some(UnsubscribePlan::Mailto { .. }))); // One-click marker on a plain http URL is ignored. - let p = plan("", Some("List-Unsubscribe=One-Click")); + let p = plan("", true); assert_eq!( p, Some(UnsubscribePlan::Browser { @@ -249,7 +249,7 @@ mod tests { fn mailto_parses_subject_and_body() { let p = plan( "", - None, + false, ); assert_eq!( p, @@ -263,7 +263,7 @@ mod tests { #[test] fn mailto_defaults() { - let p = plan("", None); + let p = plan("", false); assert_eq!( p, Some(UnsubscribePlan::Mailto { @@ -278,7 +278,7 @@ mod tests { fn mailto_keeps_plus_addresses() { let p = plan( "", - None, + false, ); assert_eq!( p, @@ -292,9 +292,9 @@ mod tests { #[test] fn mailto_without_an_address_is_not_usable() { - assert_eq!(plan("", None), None); + assert_eq!(plan("", false), None); assert_eq!( - plan(", ", None), + plan(", ", false), Some(UnsubscribePlan::Browser { url: "https://x.example/u".into() }) @@ -304,15 +304,15 @@ mod tests { #[test] fn browser_fallback_and_empty() { assert_eq!( - plan("", None), + plan("", false), Some(UnsubscribePlan::Browser { url: "https://x.example/u".into() }) ); - assert_eq!(plan("nothing useful", None), None); + assert_eq!(plan("nothing useful", false), None); // Bare (unbracketed) value from a sloppy sender still parses. assert!(matches!( - plan("https://x.example/u", Some("List-Unsubscribe=One-Click")), + plan("https://x.example/u", true), Some(UnsubscribePlan::OneClick { .. }) )); }