1use crate::card::Card;
22use crate::headers::RawHeaders;
23use crate::i18n::Translator;
24use crate::porch::{Lane, Reason};
25use crate::trust::{self, Trust};
26use serde::{Deserialize, Serialize};
27use std::path::{Path, PathBuf};
28
29pub const ONE_CLICK: &str = "List-Unsubscribe=One-Click";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Way {
35 OneClick { url: String },
37 Mail { to: String, subject: String, body: String },
39 Page { url: String },
41}
42
43impl Way {
44 pub fn id(&self) -> &'static str {
46 match self {
47 Way::OneClick { .. } => "one-click",
48 Way::Mail { .. } => "mail",
49 Way::Page { .. } => "page",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Refusal {
57 Forged,
59 Spam,
61 Borrowed,
63 Blocked,
65 Hostile,
67 Unproven,
69 Ambiguous,
71 Unusable,
73}
74
75impl Refusal {
76 fn id(self) -> &'static str {
77 match self {
78 Refusal::Forged => "forged",
79 Refusal::Spam => "spam",
80 Refusal::Borrowed => "borrowed",
81 Refusal::Blocked => "blocked",
82 Refusal::Hostile => "hostile",
83 Refusal::Unproven => "unproven",
84 Refusal::Ambiguous => "ambiguous",
85 Refusal::Unusable => "unusable",
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct List {
93 pub key: String,
95 pub name: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Offer {
102 pub list: List,
103 pub way: Result<Way, Refusal>,
104}
105
106pub fn offer(card: &Card, trusted_ids: &[String], trust: Trust, lane: &Lane, reasons: &[Reason]) -> Option<Offer> {
111 let headers = &card.headers;
112 let values: Vec<&str> = headers.all("List-Unsubscribe").collect();
113 let first = *values.first()?;
114 let list = list_of(card);
115 Some(Offer { list, way: way(card, headers, &values, first, trusted_ids, trust, lane, reasons) })
116}
117
118#[allow(clippy::too_many_arguments)]
119fn way(card: &Card, headers: &RawHeaders, values: &[&str], first: &str, trusted_ids: &[String], trust: Trust, lane: &Lane, reasons: &[Reason]) -> Result<Way, Refusal> {
120 if *lane == Lane::Hostile || reasons.contains(&Reason::Hostile) {
122 return Err(Refusal::Hostile);
123 }
124 if reasons.contains(&Reason::Blocked) {
125 return Err(Refusal::Blocked);
126 }
127 if trust == Trust::Forged || reasons.contains(&Reason::Forged) {
128 return Err(Refusal::Forged);
129 }
130 if reasons.iter().any(|r| matches!(r, Reason::Impersonation { .. })) {
131 return Err(Refusal::Borrowed);
132 }
133 if reasons.iter().any(Reason::is_spam) || *lane == Lane::SetAside {
134 return Err(Refusal::Spam);
135 }
136 if values.len() > 1 || headers.all("List-Unsubscribe-Post").count() > 1 {
137 return Err(Refusal::Ambiguous);
138 }
139 let uris = uris(first);
140 let signed = signatures(headers, trusted_ids);
141 let from = card.sender_domain().unwrap_or("").to_ascii_lowercase();
142 let verified = trust == Trust::Verified;
143 let proven = |domain: &str| verified || signed.iter().any(|s| s.covers("list-unsubscribe") && trust::aligned(&s.domain, domain));
146 let web = |scheme: &str| uris.iter().find(|u| u.get(..scheme.len()).is_some_and(|s| s.eq_ignore_ascii_case(scheme))).cloned();
147 let https = web("https://");
148 let one_click = headers.first("List-Unsubscribe-Post").is_some_and(|v| v.trim().eq_ignore_ascii_case(ONE_CLICK));
150 if one_click
151 && let Some(url) = https.as_deref()
152 && let Some(host) = host_of(url)
153 && signed.iter().any(|s| s.covers("list-unsubscribe") && s.covers("list-unsubscribe-post") && (trust::aligned(&s.domain, &from) || trust::aligned(&s.domain, &host)))
154 {
155 return Ok(Way::OneClick { url: url.to_string() });
156 }
157 let mut unproven = false;
158 if let Some(uri) = uris.iter().find(|u| crate::mailto::is_mailto(u)) {
159 match by_mail(uri) {
160 Some(way @ Way::Mail { .. }) if proven(domain_of_address(&way)) => return Ok(way),
161 Some(_) => unproven = true,
162 None => {}
163 }
164 }
165 if let Some(url) = https.or_else(|| web("http://"))
166 && let Some(host) = host_of(&url)
167 {
168 if proven(&host) {
169 return Ok(Way::Page { url });
170 }
171 unproven = true;
172 }
173 Err(if unproven { Refusal::Unproven } else { Refusal::Unusable })
174}
175
176fn domain_of_address(way: &Way) -> &str {
178 match way {
179 Way::Mail { to, .. } => to.rsplit_once('@').map_or("", |(_, d)| d),
180 _ => "",
181 }
182}
183
184fn by_mail(uri: &str) -> Option<Way> {
188 let asked = crate::mailto::parse(uri)?;
189 let [to] = asked.to.as_slice() else { return None };
190 let to = crate::compose::address_of(to)?.to_ascii_lowercase();
191 let (_, domain) = to.rsplit_once('@')?;
192 if !public_host(domain) {
193 return None;
194 }
195 let or_unsubscribe = |text: &str| if text.trim().is_empty() { "unsubscribe".to_string() } else { text.to_string() };
196 Some(Way::Mail { subject: or_unsubscribe(&asked.subject), body: or_unsubscribe(&asked.body), to })
197}
198
199pub fn uris(value: &str) -> Vec<String> {
202 let mut out = Vec::new();
203 let mut rest = value;
204 while let Some(open) = rest.find('<') {
205 let Some(close) = rest[open..].find('>') else { break };
206 let uri: String = rest[open + 1..open + close].chars().filter(|c| !c.is_whitespace()).collect();
207 if !uri.is_empty() {
208 out.push(uri);
209 }
210 rest = &rest[open + close + 1..];
211 }
212 out
213}
214
215pub fn host_of(url: &str) -> Option<String> {
219 let rest = url.split_once("://")?.1;
220 let authority = rest.split(['/', '?', '#']).next()?;
221 if authority.contains('@') {
223 return None;
224 }
225 let host = authority.rsplit_once(':').filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())).map_or(authority, |(host, _)| host).trim_end_matches('.').to_ascii_lowercase();
226 public_host(&host).then_some(host)
227}
228
229fn public_host(host: &str) -> bool {
232 let labels: Vec<&str> = host.split('.').collect();
233 let top = labels.last().copied().unwrap_or("");
234 let local = ["localhost", "local", "lan", "internal", "home", "arpa", "localdomain", "invalid", "test"].contains(&top);
235 labels.len() >= 2
236 && labels.iter().all(|l| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
237 && top.len() >= 2
238 && top.chars().all(|c| c.is_ascii_alphabetic())
239 && !local
240}
241
242pub fn list_of(card: &Card) -> List {
245 let address = card.from_address.clone().unwrap_or_default().to_ascii_lowercase();
246 let sender = card.from_name.clone().filter(|n| !n.trim().is_empty()).unwrap_or_else(|| address.clone());
247 let Some(id) = card.headers.first("List-Id") else { return List { key: address, name: sender } };
248 let (name, key) = match (id.rfind('<'), id.rfind('>')) {
249 (Some(open), Some(close)) if open < close => (id[..open].trim().trim_matches('"').trim().to_string(), id[open + 1..close].trim().to_ascii_lowercase()),
250 _ => (String::new(), id.trim().to_ascii_lowercase()),
251 };
252 if key.is_empty() {
253 return List { key: address, name: sender };
254 }
255 let name = if name.is_empty() { sender } else { name };
257 List { key: format!("list-id:{key}"), name }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262struct Signed {
263 domain: String,
264 covers: Vec<String>,
266}
267
268impl Signed {
269 fn covers(&self, header: &str) -> bool {
270 self.covers.iter().any(|h| h == header)
271 }
272}
273
274fn signatures(headers: &RawHeaders, trusted_ids: &[String]) -> Vec<Signed> {
280 let all: Vec<Vec<(String, String)>> = headers.all("DKIM-Signature").map(tags).collect();
281 let tag = |tags: &[(String, String)], name: &str| tags.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone()).unwrap_or_default();
282 trust::dkim_passes(headers, trusted_ids)
283 .into_iter()
284 .filter_map(|pass| {
285 let matching: Vec<&Vec<(String, String)>> = all
286 .iter()
287 .filter(|t| trust::aligned(&tag(t, "d"), &pass.domain))
288 .filter(|t| pass.selector.as_ref().is_none_or(|s| tag(t, "s").eq_ignore_ascii_case(s)))
289 .filter(|t| pass.b.as_ref().is_none_or(|b| tag(t, "b").starts_with(b.as_str())))
290 .collect();
291 let first = matching.first()?;
292 let covered = |t: &Vec<(String, String)>| tag(t, "h").split(':').map(|h| h.trim().to_ascii_lowercase()).filter(|h| !h.is_empty()).collect::<Vec<_>>();
293 let covers: Vec<String> = covered(first).into_iter().filter(|h| matching.iter().all(|t| covered(t).contains(h))).collect();
294 Some(Signed { domain: tag(first, "d").to_ascii_lowercase(), covers })
295 })
296 .collect()
297}
298
299fn tags(value: &str) -> Vec<(String, String)> {
302 value
303 .split(';')
304 .filter_map(|tag| {
305 let (name, value) = tag.split_once('=')?;
306 Some((name.trim().to_ascii_lowercase(), value.chars().filter(|c| !c.is_whitespace()).collect()))
307 })
308 .collect()
309}
310
311#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313pub struct Record {
314 #[serde(default, rename = "list")]
315 pub lists: Vec<Left>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct Left {
321 pub key: String,
323 pub name: String,
324 #[serde(default)]
326 pub address: String,
327 pub way: String,
329 pub at: i64,
331}
332
333impl Left {
334 pub fn done(&self) -> bool {
336 self.way != "page"
337 }
338}
339
340impl Record {
341 pub fn default_path() -> PathBuf {
342 crate::config::state_dir().join("unsubscribed.toml")
343 }
344
345 pub fn load(path: &Path) -> Record {
347 std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
348 }
349
350 pub fn find(&self, key: &str) -> Option<&Left> {
352 self.lists.iter().filter(|l| l.key == key).max_by_key(|l| l.at)
353 }
354
355 pub fn add(path: &Path, left: Left) -> Result<(), String> {
357 crate::filelock::with_lock(path, || {
358 let mut record = Record::load(path);
359 if !left.done() && record.find(&left.key).is_some_and(Left::done) {
361 return Ok(());
362 }
363 record.lists.retain(|l| l.key != left.key);
364 record.lists.push(left);
365 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
366 if let Some(parent) = path.parent() {
367 std::fs::create_dir_all(parent).map_err(fail)?;
368 }
369 let text = toml::to_string(&record).map_err(|e| e.to_string())?;
370 let temporary = path.with_extension("toml.new");
371 std::fs::write(&temporary, text).and_then(|()| std::fs::rename(&temporary, path)).map_err(fail)
372 })
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct UnsubscribeView {
379 pub offered: bool,
381 pub way: &'static str,
383 pub label: String,
385 pub tip: String,
387 pub done: bool,
389}
390
391pub fn view(offer: &Offer, record: &Record, sends_from: Option<&str>, tr: &Translator, today: jiff::civil::Date) -> UnsubscribeView {
394 let name = offer.list.name.clone();
395 let say = |id: &str, pairs: &[(&str, String)]| {
396 let mut args = crate::i18n::args();
397 args.set("list", name.clone());
398 for (key, value) in pairs {
399 args.set(key.to_string(), value.clone());
400 }
401 tr.text(id, Some(&args))
402 };
403 let day = |at: i64| jiff::Timestamp::from_second(at).map(|t| tr.day_in(t.to_zoned(jiff::tz::TimeZone::system()).date(), today)).unwrap_or_default();
404 let label = tr.text("unsubscribe-label", None);
405 let past = record.find(&offer.list.key);
406 if let Some(left) = past.filter(|l| l.done()) {
407 let way = tr.text(&format!("unsubscribe-way-{}", left.way), None);
408 return UnsubscribeView { offered: false, way: "", label: tr.text("unsubscribe-done-label", None), tip: say("unsubscribe-done", &[("date", day(left.at)), ("way", way)]), done: true };
409 }
410 let refused = |id: &str| UnsubscribeView { offered: false, way: "", label: label.clone(), tip: tr.text(&format!("unsubscribe-not-{id}"), None), done: false };
411 match &offer.way {
412 Err(refusal) => refused(refusal.id()),
413 Ok(Way::OneClick { url }) => UnsubscribeView { offered: true, way: "one-click", label: label.clone(), tip: say("unsubscribe-tip-one-click", &[("host", host_of(url).unwrap_or_default())]), done: false },
414 Ok(Way::Mail { to, .. }) => match sends_from {
415 Some(from) => UnsubscribeView { offered: true, way: "mail", label: label.clone(), tip: say("unsubscribe-tip-mail", &[("to", to.clone()), ("from", from.to_string())]), done: false },
416 None => refused("no-sender"),
417 },
418 Ok(Way::Page { url }) => {
419 let mut tip = say("unsubscribe-tip-page", &[("host", host_of(url).unwrap_or_default())]);
420 if let Some(opened) = past {
421 tip = format!("{tip} {}", say("unsubscribe-page-opened", &[("date", day(opened.at))]));
422 }
423 UnsubscribeView { offered: true, way: "page", label: label.clone(), tip, done: false }
424 }
425 }
426}
427
428pub fn request(from: (&str, &str), to: &str, subject: &str, body: &str, date: i64) -> Result<crate::compose::Outgoing, String> {
430 let domain = from.1.rsplit_once('@').map_or("localhost", |(_, d)| d);
431 let message_id = format!("{}@{domain}", crate::compose::new_message_id());
432 let raw = mail_builder::MessageBuilder::new()
433 .from(from)
434 .to(to)
435 .subject(subject)
436 .message_id(message_id.as_str())
437 .date(date)
438 .text_body(body)
439 .write_to_vec()
440 .map_err(|e| e.to_string())?;
441 Ok(crate::compose::Outgoing { raw, from: from.1.to_string(), recipients: vec![to.to_string()] })
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 const SIOUL: &str = "sioul-test.invalid";
449
450 fn letter(extra: &str, signer: &str, signed: &str, dmarc: &str) -> Card {
452 let raw = format!(
453 "Authentication-Results: {SIOUL};\r\n\tdkim=pass header.d={signer} header.s=s1 header.b=QUJDREVG;\r\n\tspf=pass smtp.mailfrom=bounce.{signer}; dmarc={dmarc} header.from=letters.example\r\n\
454 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d={signer}; s=s1;\r\n\th={signed};\r\n\tbh=Zm9v; b=QUJDREVG\r\n\t SElKS0xN\r\n\
455 From: Type & Pixels <letter@letters.example>\r\nTo: you@example.org\r\nSubject: Issue 112\r\n\
456 List-Id: Type & Pixels <issues.letters.example>\r\n{extra}Content-Type: text/plain\r\n\r\nThis week.\r\n"
457 );
458 Card::from_bytes(raw.as_bytes()).unwrap()
459 }
460
461 fn judged(card: &Card) -> Option<Offer> {
462 let ids = vec![SIOUL.to_string()];
463 let auth = trust::read_auth_results(&card.headers, &ids);
464 let (trust, _) = trust::judge_sender(auth.as_ref(), card.is_list, card.sender_domain());
465 offer(card, &ids, trust, &Lane::Filed, &[Reason::Newsletter])
466 }
467
468 const BOTH: &str = "from:to:subject:date:list-unsubscribe:list-unsubscribe-post";
469 const POST: &str = "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n";
470
471 #[test]
472 fn one_click_when_both_headers_are_signed() {
473 let links = "List-Unsubscribe: <https://letters.example/u/a1b2?l=112>, <mailto:leave@letters.example?subject=stop>\r\n";
474 let card = letter(&format!("{links}{POST}"), "letters.example", BOTH, "pass");
475 let found = judged(&card).unwrap();
476 assert_eq!(found.list, List { key: "list-id:issues.letters.example".into(), name: "Type & Pixels".into() });
477 assert_eq!(found.way, Ok(Way::OneClick { url: "https://letters.example/u/a1b2?l=112".into() }));
478 let service = "List-Unsubscribe: <https://unsub.mailer.example/u/a1b2>\r\n";
480 let card = letter(&format!("{service}{POST}"), "mailer.example", BOTH, "pass");
481 assert_eq!(judged(&card).unwrap().way, Ok(Way::OneClick { url: "https://unsub.mailer.example/u/a1b2".into() }));
482 let card = letter(&format!("{links}{POST}"), "letters.example", "from:to:subject:list-unsubscribe", "pass");
484 assert_eq!(judged(&card).unwrap().way, Ok(Way::Mail { to: "leave@letters.example".into(), subject: "stop".into(), body: "unsubscribe".into() }));
485 let copied = format!("{links}{POST}DKIM-Signature: v=1; d=letters.example; s=s1; h=from:to; bh=Zm9v; b=QUJDREVGSElKS0xN\r\n");
487 let card = letter(&copied, "letters.example", BOTH, "pass");
488 assert!(matches!(judged(&card).unwrap().way, Ok(Way::Mail { .. })));
489 let added = format!("List-Unsubscribe: <https://evil.example/collect>\r\n{links}{POST}");
491 let card = letter(&added, "letters.example", BOTH, "pass");
492 assert_eq!(judged(&card).unwrap().way, Err(Refusal::Ambiguous));
493 }
494
495 #[test]
496 fn nothing_for_what_nothing_proves() {
497 let links = "List-Unsubscribe: <https://elsewhere.example/u>, <mailto:leave@elsewhere.example>\r\n";
499 let card = letter(&format!("{links}{POST}"), "signer.example", "from:to", "fail");
500 let ids = vec![SIOUL.to_string()];
501 let found = offer(&card, &ids, Trust::Unverified, &Lane::Filed, &[Reason::Newsletter]).unwrap();
502 assert_eq!(found.way, Err(Refusal::Unproven));
503 let list = "List-Unsubscribe: <mailto:club-leave@lists.signer.example>\r\n";
505 let card = letter(list, "signer.example", "from:to:list-unsubscribe", "fail");
506 let found = offer(&card, &ids, Trust::Unverified, &Lane::Filed, &[Reason::Newsletter]).unwrap();
507 assert_eq!(found.way, Ok(Way::Mail { to: "club-leave@lists.signer.example".into(), subject: "unsubscribe".into(), body: "unsubscribe".into() }));
508 let card = letter(&format!("{links}{POST}"), "letters.example", BOTH, "pass");
510 for (lane, reasons, refusal) in [
511 (Lane::SetAside, vec![Reason::Forged], Refusal::Forged),
512 (Lane::SetAside, vec![Reason::Spam { source: "rspamd", score: None }], Refusal::Spam),
513 (Lane::SetAside, vec![Reason::Impersonation { brand: "Bank".into(), domain: "letters.example".into() }], Refusal::Borrowed),
514 (Lane::SetAside, vec![Reason::Blocked], Refusal::Blocked),
515 (Lane::Hostile, vec![Reason::Hostile], Refusal::Hostile),
516 ] {
517 assert_eq!(offer(&card, &ids, Trust::Verified, &lane, &reasons).unwrap().way, Err(refusal));
518 }
519 let card = letter("", "letters.example", BOTH, "pass");
521 assert!(judged(&card).is_none());
522 }
523
524 #[test]
525 fn a_page_when_only_a_page_is_named() {
526 let card = letter("List-Unsubscribe: <https://letters.example/preferences>\r\n", "letters.example", "from:to", "pass");
527 assert_eq!(judged(&card).unwrap().way, Ok(Way::Page { url: "https://letters.example/preferences".into() }));
528 for url in ["https://192.168.1.1/u", "https://router.lan/u", "https://letters.example@evil.example/u", "https://localhost/u", "ftp://letters.example/u"] {
530 let card = letter(&format!("List-Unsubscribe: <{url}>\r\n"), "letters.example", "from:to", "pass");
531 assert_eq!(judged(&card).unwrap().way, Err(Refusal::Unusable), "{url}");
532 }
533 let card = letter("List-Unsubscribe: <mailto:a@letters.example,b@letters.example>\r\n", "letters.example", "from:to", "pass");
535 assert_eq!(judged(&card).unwrap().way, Err(Refusal::Unusable));
536 let card = letter("List-Unsubscribe: <mailto:leave@letters.example?cc=someone@example.org&body=stop%20please>\r\n", "letters.example", "from:to", "pass");
537 assert_eq!(judged(&card).unwrap().way, Ok(Way::Mail { to: "leave@letters.example".into(), subject: "unsubscribe".into(), body: "stop please".into() }));
538 assert_eq!(uris(" <https://a.example/x> ,\r\n\t(comment) <mailto: leave@a.example>"), vec!["https://a.example/x".to_string(), "mailto:leave@a.example".into()]);
539 }
540
541 #[test]
542 fn the_record_keeps_what_was_done() {
543 let dir = std::env::temp_dir().join(format!("sioul-unsubscribed-{}", std::process::id()));
544 let _ = std::fs::remove_dir_all(&dir);
545 let path = dir.join("unsubscribed.toml");
546 let left = |way: &str, at: i64| Left { key: "list-id:issues.letters.example".into(), name: "Type & Pixels".into(), address: "letter@letters.example".into(), way: way.into(), at };
547 Record::add(&path, left("page", 100)).unwrap();
548 Record::add(&path, left("one-click", 200)).unwrap();
549 Record::add(&path, left("page", 300)).unwrap();
551 let record = Record::load(&path);
552 assert_eq!(record.lists, vec![left("one-click", 200)]);
553 let card = letter("List-Unsubscribe: <https://letters.example/preferences>\r\n", "letters.example", "from:to", "pass");
555 let found = judged(&card).unwrap();
556 let tr = Translator::new("en");
557 let today = jiff::civil::date(2026, 10, 6);
558 let shown = view(&found, &record, Some("you@example.org"), &tr, today);
559 assert!(shown.done && !shown.offered && shown.tip.contains("Type & Pixels"), "{shown:?}");
560 let shown = view(&found, &Record::default(), Some("you@example.org"), &tr, today);
561 assert!(shown.offered && shown.way == "page" && shown.tip.contains("letters.example"), "{shown:?}");
562 let _ = std::fs::remove_dir_all(&dir);
563 }
564
565 #[test]
566 fn the_request_is_plain_text_to_the_list_alone() {
567 let sent = request(("Noa", "noa@example.org"), "leave@letters.example", "unsubscribe", "unsubscribe", 1_790_000_000).unwrap();
568 let text = String::from_utf8_lossy(&sent.raw);
569 assert_eq!(sent.recipients, vec!["leave@letters.example".to_string()]);
570 assert!(text.contains("Subject: unsubscribe") && text.contains("leave@letters.example") && text.contains("text/plain") && !text.contains("text/html"), "{text}");
571 }
572}