Skip to main content

sioul_sync/
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, on the network: the HTTPS POST of
5//! RFC 8058 (docs/client.md, "Unsubscribing"). When it may be sent is the
6//! core's to say (`sioul_core::unsubscribe`); here it is sent as the RFC
7//! asks: `List-Unsubscribe=One-Click` as a form, no cookie, no authorisation,
8//! nothing of yours but what the list wrote in its own address. A redirect to
9//! another site is never followed; the wait is short.
10
11use crate::SyncError;
12use std::time::Duration;
13
14/// The longest the list's server may take to answer, all told.
15pub const WAIT: Duration = Duration::from_secs(15);
16/// Redirects followed within the same site (RFC 8058 asks senders for none).
17const HOPS: usize = 3;
18
19/// What the list's server made of the request.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Outcome {
22    /// It took it (2xx).
23    Done,
24    /// It answered with this status.
25    Refused(u16),
26    /// It sent Sioul to another site (its host), not followed.
27    Elsewhere(String),
28    /// No answer within `WAIT`.
29    TimedOut,
30}
31
32/// Posts `List-Unsubscribe=One-Click` to `url`, an HTTPS address of a name on
33/// the Internet (`sioul_core::unsubscribe::host_of`).
34pub fn one_click(url: &str) -> Result<Outcome, SyncError> {
35    let host = sioul_core::unsubscribe::host_of(url).filter(|_| url.get(..8).is_some_and(|s| s.eq_ignore_ascii_case("https://"))).ok_or_else(|| SyncError::Message(url.to_string()))?;
36    // A fresh agent each time: no cookie kept from anything (ureq is built without them anyway).
37    let agent: ureq::Agent = ureq::Agent::config_builder().https_only(true).max_redirects(0).http_status_as_error(false).timeout_global(Some(WAIT)).user_agent("Sioul").build().into();
38    post(&agent, url, &host)
39}
40
41/// The request, and the redirects within `host` (a browser's way: 303 and the
42/// older 301 and 302 continue as a GET, 307 and 308 post again).
43fn post(agent: &ureq::Agent, url: &str, host: &str) -> Result<Outcome, SyncError> {
44    let mut url = url.to_string();
45    let mut posting = true;
46    let mut last = 0;
47    for _ in 0..=HOPS {
48        let answer = if posting {
49            agent.post(&url).header("Content-Type", "application/x-www-form-urlencoded").send(sioul_core::unsubscribe::ONE_CLICK)
50        } else {
51            agent.get(&url).call()
52        };
53        let response = match answer {
54            Ok(response) => response,
55            Err(ureq::Error::Timeout(_)) => return Ok(Outcome::TimedOut),
56            Err(ureq::Error::Tls(e)) => return Err(SyncError::Tls(e.to_string())),
57            Err(e) => return Err(SyncError::Network(e.to_string())),
58        };
59        last = response.status().as_u16();
60        if (200..300).contains(&last) {
61            return Ok(Outcome::Done);
62        }
63        if !matches!(last, 301 | 302 | 303 | 307 | 308) {
64            return Ok(Outcome::Refused(last));
65        }
66        let location = response.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").trim().to_string();
67        let next = resolve(&url, &location);
68        let same = next.as_deref().and_then(authority_host).is_some_and(|h| h == host) && next.as_deref().is_some_and(|n| scheme(n) == scheme(&url));
69        let Some(next) = next.filter(|_| same) else {
70            return Ok(Outcome::Elsewhere(resolve(&url, &location).as_deref().and_then(authority_host).unwrap_or(location)));
71        };
72        posting = posting && matches!(last, 307 | 308);
73        url = next;
74    }
75    Ok(Outcome::Refused(last))
76}
77
78/// "https", "http": the scheme, lower case.
79fn scheme(url: &str) -> String {
80    url.split_once("://").map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default()
81}
82
83/// The host of an absolute address, lower case, without its port.
84fn authority_host(url: &str) -> Option<String> {
85    let rest = url.split_once("://")?.1;
86    let authority = rest.split(['/', '?', '#']).next()?;
87    let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
88    let host = authority.rsplit_once(':').filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())).map_or(authority, |(host, _)| host);
89    (!host.is_empty()).then(|| host.trim_end_matches('.').to_ascii_lowercase())
90}
91
92/// Where a `Location` points, from `base`: an address of its own, one
93/// without its scheme ("//host/x"), or a path on the same site.
94fn resolve(base: &str, location: &str) -> Option<String> {
95    if location.is_empty() {
96        return None;
97    }
98    if location.contains("://") {
99        return Some(location.to_string());
100    }
101    let (scheme, rest) = base.split_once("://")?;
102    if let Some(other) = location.strip_prefix("//") {
103        return Some(format!("{scheme}://{other}"));
104    }
105    let authority = rest.split(['/', '?', '#']).next()?;
106    if location.starts_with('/') {
107        return Some(format!("{scheme}://{authority}{location}"));
108    }
109    let path = rest[authority.len()..].split(['?', '#']).next().unwrap_or("");
110    let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
111    Some(format!("{scheme}://{authority}{folder}/{location}"))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::io::{BufRead, BufReader, Read, Write};
118    use std::net::TcpListener;
119
120    /// What a stand-in server received: the request line, the headers (lower case), the body.
121    #[derive(Debug, Default, Clone)]
122    struct Seen {
123        line: String,
124        headers: Vec<(String, String)>,
125        body: String,
126    }
127
128    /// A server on this computer that answers each request with the next of `answers`, and says what it got.
129    fn stand_in(answers: Vec<String>) -> (String, std::sync::mpsc::Receiver<Seen>) {
130        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
131        let address = format!("http://{}", listener.local_addr().unwrap());
132        let (tell, heard) = std::sync::mpsc::channel();
133        std::thread::spawn(move || {
134            for answer in answers {
135                let Ok((stream, _)) = listener.accept() else { return };
136                let mut reader = BufReader::new(stream.try_clone().unwrap());
137                let mut seen = Seen::default();
138                reader.read_line(&mut seen.line).unwrap();
139                let mut length = 0;
140                loop {
141                    let mut line = String::new();
142                    reader.read_line(&mut line).unwrap();
143                    let line = line.trim_end().to_string();
144                    if line.is_empty() {
145                        break;
146                    }
147                    if let Some((name, value)) = line.split_once(':') {
148                        let (name, value) = (name.trim().to_ascii_lowercase(), value.trim().to_string());
149                        if name == "content-length" {
150                            length = value.parse().unwrap_or(0);
151                        }
152                        seen.headers.push((name, value));
153                    }
154                }
155                let mut body = vec![0; length];
156                reader.read_exact(&mut body).unwrap();
157                seen.body = String::from_utf8_lossy(&body).into_owned();
158                let _ = tell.send(seen);
159                let mut stream = stream;
160                let _ = stream.write_all(answer.as_bytes());
161            }
162        });
163        (address, heard)
164    }
165
166    fn agent() -> ureq::Agent {
167        ureq::Agent::config_builder().max_redirects(0).http_status_as_error(false).timeout_global(Some(Duration::from_secs(5))).user_agent("Sioul").build().into()
168    }
169
170    const OK: &str = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
171
172    #[test]
173    fn posts_the_one_click_form_and_nothing_else() {
174        let (base, heard) = stand_in(vec![OK.to_string()]);
175        let outcome = post(&agent(), &format!("{base}/u/a1b2?l=112"), "127.0.0.1").unwrap();
176        assert_eq!(outcome, Outcome::Done);
177        let seen = heard.recv().unwrap();
178        assert_eq!(seen.line.trim_end(), "POST /u/a1b2?l=112 HTTP/1.1");
179        assert_eq!(seen.body, "List-Unsubscribe=One-Click");
180        let header = |name: &str| seen.headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str());
181        assert_eq!(header("content-type"), Some("application/x-www-form-urlencoded"));
182        // No cookie, no authorisation, nothing about you (RFC 8058 §4).
183        assert_eq!((header("cookie"), header("authorization"), header("referer")), (None, None, None));
184    }
185
186    #[test]
187    fn a_redirect_to_another_site_is_not_followed() {
188        let elsewhere = "HTTP/1.1 302 Found\r\nLocation: https://tracker.example/landing\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
189        let (base, heard) = stand_in(vec![elsewhere]);
190        assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Elsewhere("tracker.example".into()));
191        assert_eq!(heard.recv().unwrap().body, "List-Unsubscribe=One-Click");
192        // Within the same site: followed, a 307 posting again.
193        let again = "HTTP/1.1 307 Temporary Redirect\r\nLocation: /u/confirm\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
194        let (base, heard) = stand_in(vec![again, OK.to_string()]);
195        assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Done);
196        let (first, second) = (heard.recv().unwrap(), heard.recv().unwrap());
197        assert!(first.line.starts_with("POST /u ") && second.line.starts_with("POST /u/confirm "), "{first:?} {second:?}");
198        // A refusal is said with its status.
199        let gone = "HTTP/1.1 410 Gone\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
200        let (base, _heard) = stand_in(vec![gone]);
201        assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Refused(410));
202    }
203
204    #[test]
205    fn only_https_to_a_name_on_the_internet() {
206        for url in ["http://letters.example/u", "https://127.0.0.1/u", "https://router.lan/u", "https://letters.example@evil.example/u"] {
207            assert!(matches!(one_click(url), Err(SyncError::Message(_))), "{url}");
208        }
209        assert_eq!(resolve("https://a.example/u/x?y=1", "done"), Some("https://a.example/u/done".into()));
210        assert_eq!(resolve("https://a.example/u", "//b.example/z"), Some("https://b.example/z".into()));
211        assert_eq!(authority_host("https://user@A.Example:8443/x"), Some("a.example".into()));
212    }
213}