Skip to main content

sioul_core/
unsubscribe.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Leaving a mailing list in one click (docs/client.md, "Unsubscribing").
5//!
6//! A list's message says how to leave it in `List-Unsubscribe` (RFC 2369): web
7//! addresses, mail addresses. When it also carries `List-Unsubscribe-Post:
8//! List-Unsubscribe=One-Click` and a valid DKIM signature covers both headers
9//! (RFC 8058 §3.1), an HTTPS POST does it, nothing else asked. Else a message
10//! to the list's address does it, sent as any message is; else only a web page
11//! can, and it opens in the browser.
12//!
13//! Nothing is contacted for mail set aside (forged, spam, a borrowed name, a
14//! blocked sender) or hostile, nor when nothing proves the address it names
15//! is the list's own: a valid signature of the domain shown in From (the
16//! message verified), or a valid signature covering `List-Unsubscribe` by the
17//! domain the address belongs to. A forged message could otherwise make Sioul
18//! post to, or write to, anyone. What was done is kept on this device
19//! (`Record`), so that a list is not asked twice and you can see it.
20
21use 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
29/// The value of `List-Unsubscribe-Post` that asks for one click (RFC 8058 §3.1).
30pub const ONE_CLICK: &str = "List-Unsubscribe=One-Click";
31
32/// What one click does.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Way {
35    /// An HTTPS POST of `List-Unsubscribe=One-Click` to this address (RFC 8058).
36    OneClick { url: String },
37    /// A message to the list's address, with the subject and text its link asks for.
38    Mail { to: String, subject: String, body: String },
39    /// Only a web page: it opens in the browser, where you finish.
40    Page { url: String },
41}
42
43impl Way {
44    /// "one-click", "mail", "page": as the record and the window name it.
45    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/// Why one click does nothing.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Refusal {
57    /// Forged: DMARC failed under a policy that says so.
58    Forged,
59    /// Flagged as spam: answering tells its sender your address is read.
60    Spam,
61    /// It borrows a brand's name, or one of your own domains.
62    Borrowed,
63    /// You blocked its sender.
64    Blocked,
65    /// Hostile mail to a shielded address.
66    Hostile,
67    /// Nothing proves the address it names is its list's own.
68    Unproven,
69    /// Two `List-Unsubscribe` headers, or two `List-Unsubscribe-Post`: Sioul does not choose.
70    Ambiguous,
71    /// No web or mail address Sioul can use (an IP address, a local name, several recipients).
72    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/// The list a message comes from.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct List {
93    /// What names it for good, lower case: its List-Id (RFC 2919), else the sender's address.
94    pub key: String,
95    /// What to call it: List-Id's name, else the sender's name, else the address.
96    pub name: String,
97}
98
99/// What a message offers to leave its list.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Offer {
102    pub list: List,
103    pub way: Result<Way, Refusal>,
104}
105
106/// How to leave the message's list, or why one click does nothing; none when
107/// it names no way out (`List-Unsubscribe`). `trust`, `lane` and `reasons`
108/// are the Porch's verdict on it (`porch::triage`); `trusted_ids` the
109/// authserv-ids of its account (Sioul's own, then the provider's).
110pub 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    // What the Porch set aside is never answered.
121    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    // The address it names is its list's: the message is verified, or the
144    // domain the address belongs to signed `List-Unsubscribe`.
145    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    // RFC 8058: one POST, when the sender's domain, or the address's, signed both headers.
149    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
176/// The domain a `Way::Mail` writes to.
177fn domain_of_address(way: &Way) -> &str {
178    match way {
179        Way::Mail { to, .. } => to.rsplit_once('@').map_or("", |(_, d)| d),
180        _ => "",
181    }
182}
183
184/// The message a `mailto:` address asks for: one recipient (a list's own
185/// address), its subject and text, "unsubscribe" where it says none. Copies
186/// it asks for (`cc`, `bcc`) are not sent: one click writes to the list alone.
187fn 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
199/// The addresses of `List-Unsubscribe`, in order: each between angle brackets,
200/// spaces inside taken out (RFC 2369 §2), comments left aside.
201pub 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
215/// The host of a web address, lower case; none when it is not a name on the
216/// Internet (an IP address, "localhost", a local network's name): one click
217/// reaches nothing on your own network.
218pub fn host_of(url: &str) -> Option<String> {
219    let rest = url.split_once("://")?.1;
220    let authority = rest.split(['/', '?', '#']).next()?;
221    // Credentials put before the host to mislead are refused: "https://bank.example@evil.example".
222    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
229/// A name on the Internet: letters, digits, hyphens and dots, two labels at
230/// least, the last of letters; never an IP address nor a local name.
231fn 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
242/// The list a message comes from: its List-Id ("Type & Pixels
243/// <letter.typeandpixels.example>"), else its sender.
244pub 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    // A List-Id's name is often a raw label ("newsletter"): the sender's name reads better.
256    let name = if name.is_empty() { sender } else { name };
257    List { key: format!("list-id:{key}"), name }
258}
259
260/// A DKIM signature that passed, with the headers it covers (its `h=` tag).
261#[derive(Debug, Clone, PartialEq, Eq)]
262struct Signed {
263    domain: String,
264    /// Lower case; only the headers every signature matching the result covers.
265    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
274/// The signatures that passed, as the trusted results header records them
275/// (`trust::dkim_passes`), each with what it covers, read from the
276/// DKIM-Signature header it names (domain, selector, start of the signature).
277/// When several headers match one result (a copy, altered), a header counts as
278/// covered only if all of them cover it: a copy can only take away.
279fn 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
299/// A DKIM-Signature header's tags (RFC 6376 §3.2), names lower case, the
300/// spaces of each value taken out (they fold a long `b=` or `h=`).
301fn 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/// The lists you left, kept on this device (`$XDG_STATE_HOME/sioul/unsubscribed.toml`).
312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313pub struct Record {
314    #[serde(default, rename = "list")]
315    pub lists: Vec<Left>,
316}
317
318/// One list left, or whose page was opened.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct Left {
321    /// The list's `key` (`List`).
322    pub key: String,
323    pub name: String,
324    /// The sender's address.
325    #[serde(default)]
326    pub address: String,
327    /// "one-click", "mail" (done), "page" (its page opened: you finish there).
328    pub way: String,
329    /// When, as Unix seconds.
330    pub at: i64,
331}
332
333impl Left {
334    /// Done by Sioul (one click, a message), not only a page opened.
335    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    /// The record; empty when the file does not exist or cannot be read.
346    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    /// The newest entry for a list.
351    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    /// Adds an entry, the list's older ones replaced; written beside, then moved.
356    pub fn add(path: &Path, left: Left) -> Result<(), String> {
357        crate::filelock::with_lock(path, || {
358            let mut record = Record::load(path);
359            // A page opened later does not undo a list left.
360            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/// The Reader's button, as the window shows it.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct UnsubscribeView {
379    /// One click acts now; else it only says why it does not (`tip`).
380    pub offered: bool,
381    /// "one-click", "mail", "page", or "" when nothing is offered.
382    pub way: &'static str,
383    /// The button's name.
384    pub label: String,
385    /// What one click does, why it does nothing, or when it was done.
386    pub tip: String,
387    /// Left already: the button rests.
388    pub done: bool,
389}
390
391/// The button for a message's offer. `sends_from` is the address a message
392/// goes out from (the account the message came to), none when it cannot send.
393pub 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
428/// The message that asks a list to stop, plain text, from your address.
429pub 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    /// A newsletter as a sending service writes it, signed by `signer` over `signed`, Sioul's checks on top.
451    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        // Signed by the sending service, the address its own: one click too.
479        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        // List-Unsubscribe-Post left out of the signature: the message instead (RFC 8058 §3.1).
483        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        // A copy of the signature that covers less: the headers count as not covered.
486        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        // A second List-Unsubscribe added above the signed one: Sioul does not choose.
490        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        // Not verified (DMARC fails, no signature of the sender's domain): no address of it is used.
498        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        // A mailing list (DMARC broken by the list) whose own domain signed the header: its address is proven.
504        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        // Forged, spam, a borrowed name, blocked, hostile: never.
509        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        // No List-Unsubscribe: no button at all.
520        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        // An address on your own network, or with a name put before the host: nothing.
529        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        // Several recipients, or copies: not by mail.
534        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        // A page opened later does not undo a list left.
550        Record::add(&path, left("page", 300)).unwrap();
551        let record = Record::load(&path);
552        assert_eq!(record.lists, vec![left("one-click", 200)]);
553        // The button rests, and says when.
554        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}