mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
fix: send one-click unsubscribes only when the sender signed them
This commit is contained in:
1 parent
d71ef7422a
commit
31d854f20e
3 files changed
+274
-41
No files matched your search
@@ -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<UnsubscribeOutcome> {
|
||||
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 } => {
|
||||
|
||||
@@ -1747,9 +1747,15 @@ fn authentication_results(results: &str) -> Vec<AuthenticationResult<'_>> {
|
||||
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<DkimSignature> = 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<String>,
|
||||
/// The signature, without folding whitespace.
|
||||
signature: String,
|
||||
}
|
||||
|
||||
impl DkimSignature {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
"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<String> = 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 <news@news.example>\r\n",
|
||||
"List-Unsubscribe: <https://news.example/u/1>\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: <https://elsewhere.example/u>\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!(
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
//!
|
||||
//! One-click (RFC 8058) requires the sender to publish BOTH
|
||||
//! `List-Unsubscribe: <https://…>` 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<UnsubscribePlan> {
|
||||
/// 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<UnsubscribePlan> {
|
||||
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<UnsubscribePlan> {
|
||||
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(
|
||||
"<https://x.example/u?t=1>, <mailto:u@x.example>",
|
||||
Some("List-Unsubscribe=One-Click"),
|
||||
);
|
||||
fn one_click_needs_a_signed_request_and_https() {
|
||||
let p = plan("<https://x.example/u?t=1>, <mailto:u@x.example>", 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("<https://x.example/u?t=1>, <mailto:u@x.example>", None);
|
||||
// The same header without a signed request: mailto wins over browser.
|
||||
let p = plan("<https://x.example/u?t=1>, <mailto:u@x.example>", false);
|
||||
assert!(matches!(p, Some(UnsubscribePlan::Mailto { .. })));
|
||||
|
||||
// One-click marker on a plain http URL is ignored.
|
||||
let p = plan("<http://x.example/u>", Some("List-Unsubscribe=One-Click"));
|
||||
let p = plan("<http://x.example/u>", true);
|
||||
assert_eq!(
|
||||
p,
|
||||
Some(UnsubscribePlan::Browser {
|
||||
@@ -249,7 +249,7 @@ mod tests {
|
||||
fn mailto_parses_subject_and_body() {
|
||||
let p = plan(
|
||||
"<mailto:leave@list.example?subject=unsub%20me&body=please%20go>",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
p,
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mailto_defaults() {
|
||||
let p = plan("<mailto:leave@list.example>", None);
|
||||
let p = plan("<mailto:leave@list.example>", false);
|
||||
assert_eq!(
|
||||
p,
|
||||
Some(UnsubscribePlan::Mailto {
|
||||
@@ -278,7 +278,7 @@ mod tests {
|
||||
fn mailto_keeps_plus_addresses() {
|
||||
let p = plan(
|
||||
"<mailto:googlegroups-manage+123+unsubscribe@googlegroups.com?subject=Unsubscribe+me>",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
p,
|
||||
@@ -292,9 +292,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mailto_without_an_address_is_not_usable() {
|
||||
assert_eq!(plan("<mailto:?subject=unsubscribe>", None), None);
|
||||
assert_eq!(plan("<mailto:?subject=unsubscribe>", false), None);
|
||||
assert_eq!(
|
||||
plan("<mailto:?subject=x>, <https://x.example/u>", None),
|
||||
plan("<mailto:?subject=x>, <https://x.example/u>", 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("<https://x.example/u>", None),
|
||||
plan("<https://x.example/u>", 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 { .. })
|
||||
));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user