Skip to main content

postvak_core/
unsubscribe.rs

1//! Leaving a mailing list: RFC 8058's one-click POST, or else the List-Unsubscribe page or address the reader opens.
2use std::time::Duration;
3
4use serde::Serialize;
5
6use crate::message::header_value;
7use crate::net::{public, public_agent};
8
9const TIMEOUT: Duration = Duration::from_secs(15);
10
11/// How a message offers unsubscribing, best first.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Method {
14    /// An https URL that takes the RFC 8058 POST, so Postvak can unsubscribe by itself.
15    OneClick(String),
16    /// A web page the reader opens.
17    Link(String),
18    /// A `mailto:` address the reader sends a mail to.
19    Mail(String),
20}
21
22impl Method {
23    pub fn url(&self) -> &str {
24        match self {
25            Method::OneClick(url) | Method::Link(url) | Method::Mail(url) => url,
26        }
27    }
28
29    pub fn label(&self) -> &'static str {
30        match self {
31            Method::OneClick(_) => "one_click",
32            Method::Link(_) => "link",
33            Method::Mail(_) => "mail",
34        }
35    }
36}
37
38/// What `unsubscribe` did: `done` once the sender accepted the one-click POST; otherwise `url` is for the reader.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct Report {
41    pub method: &'static str,
42    pub url: String,
43    pub done: bool,
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum UnsubscribeError {
48    #[error("the message has no List-Unsubscribe header")]
49    NotOffered,
50    #[error("the unsubscribe address is not a public web host")]
51    NotPublic,
52    /// Says nothing about the URL: it identifies the reader.
53    #[error("the sender's server did not accept the unsubscribe ({0})")]
54    Refused(String),
55}
56
57/// How `headers` offers unsubscribing: one-click when an https URL comes with `List-Unsubscribe-Post`, else a web page,
58/// else a mail; None without a usable List-Unsubscribe header.
59pub fn method(headers: &[u8]) -> Option<Method> {
60    let list = header_value(headers, "List-Unsubscribe")?;
61    // Folding may put whitespace inside the angle brackets, which no URL contains.
62    let uris: Vec<String> = list
63        .split(',')
64        .filter_map(|part| {
65            let uri = part.trim().strip_prefix('<')?.strip_suffix('>')?;
66            Some(uri.split_whitespace().collect())
67        })
68        .collect();
69    let scheme = |uri: &String, wanted: &str| {
70        uri.split_once(':')
71            .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case(wanted))
72    };
73    let post = header_value(headers, "List-Unsubscribe-Post").is_some_and(|value| {
74        let value: String = value.split_whitespace().collect();
75        value.eq_ignore_ascii_case("List-Unsubscribe=One-Click")
76    });
77    if post && let Some(url) = uris.iter().find(|uri| scheme(uri, "https")) {
78        return Some(Method::OneClick(url.clone()));
79    }
80    if let Some(url) = uris
81        .iter()
82        .find(|uri| scheme(uri, "https") || scheme(uri, "http"))
83    {
84        return Some(Method::Link(url.clone()));
85    }
86    uris.iter()
87        .find(|uri| scheme(uri, "mailto"))
88        .map(|uri| Method::Mail(uri.clone()))
89}
90
91/// Unsubscribes by one click where `headers` allow it; otherwise reports the page or address for the reader to open.
92/// A dry run only reports.
93pub fn unsubscribe(headers: &[u8], dry_run: bool) -> Result<Report, UnsubscribeError> {
94    let method = method(headers).ok_or(UnsubscribeError::NotOffered)?;
95    let done = match &method {
96        Method::OneClick(url) if !dry_run => {
97            one_click(url)?;
98            true
99        }
100        _ => false,
101    };
102    Ok(Report {
103        method: method.label(),
104        url: method.url().to_string(),
105        done,
106    })
107}
108
109/// The RFC 8058 POST to a public host.
110pub fn one_click(url: &str) -> Result<(), UnsubscribeError> {
111    post_to(url, public, public_agent(config()))
112}
113
114fn config() -> ureq::config::ConfigBuilder<ureq::typestate::AgentScope> {
115    ureq::Agent::config_builder()
116        .max_redirects(0)
117        .http_status_as_error(false)
118        .timeout_global(Some(TIMEOUT))
119}
120
121/// The RFC 8058 POST. The RFC forbids a redirect in answer, so only a 2xx counts; the host check and agent are given so
122/// tests can serve from this machine.
123fn post_to(
124    url: &str,
125    allowed: impl Fn(&str) -> bool,
126    agent: ureq::Agent,
127) -> Result<(), UnsubscribeError> {
128    if !allowed(url) {
129        return Err(UnsubscribeError::NotPublic);
130    }
131    let response = agent
132        .post(url)
133        .header("Content-Type", "application/x-www-form-urlencoded")
134        .send("List-Unsubscribe=One-Click")
135        .map_err(|_| UnsubscribeError::Refused("no answer".into()))?;
136    let status = response.status();
137    if status.is_success() {
138        Ok(())
139    } else {
140        Err(UnsubscribeError::Refused(format!(
141            "HTTP {}",
142            status.as_u16()
143        )))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use std::io::{Read, Write};
150    use std::net::TcpListener;
151    use std::sync::mpsc;
152
153    use super::*;
154
155    fn headers(lines: &str) -> Vec<u8> {
156        format!("Subject: news\r\n{lines}\r\n\r\n").into_bytes()
157    }
158
159    #[test]
160    fn one_click_wins_then_a_page_then_a_mail() {
161        let both = "List-Unsubscribe: <mailto:leave@list.example>, <https://list.example/u?id=1>";
162        let post = "List-Unsubscribe-Post: List-Unsubscribe=One-Click";
163        assert_eq!(
164            method(&headers(&format!("{both}\r\n{post}"))),
165            Some(Method::OneClick("https://list.example/u?id=1".into()))
166        );
167        assert_eq!(
168            method(&headers(both)),
169            Some(Method::Link("https://list.example/u?id=1".into()))
170        );
171        let http_post = "List-Unsubscribe: <http://list.example/u>\r\nList-Unsubscribe-Post: List-Unsubscribe=One-Click";
172        assert_eq!(
173            method(&headers(http_post)),
174            Some(Method::Link("http://list.example/u".into())),
175            "one-click needs https"
176        );
177        assert_eq!(
178            method(&headers(
179                "List-Unsubscribe: <MAILTO:leave@list.example?subject=bye>"
180            )),
181            Some(Method::Mail("MAILTO:leave@list.example?subject=bye".into()))
182        );
183        let folded = "List-Unsubscribe: <https://list.example/\r\n u?id=2>";
184        assert_eq!(
185            method(&headers(folded)),
186            Some(Method::Link("https://list.example/u?id=2".into()))
187        );
188        assert_eq!(
189            method(&headers("List-Unsubscribe: <javascript:alert(1)>")),
190            None
191        );
192        assert_eq!(method(&headers("X-Other: 1")), None);
193    }
194
195    #[test]
196    fn a_dry_run_or_a_link_only_reports() {
197        let one_click = headers(
198            "List-Unsubscribe: <https://list.example/u>\r\nList-Unsubscribe-Post: List-Unsubscribe=One-Click",
199        );
200        let report = unsubscribe(&one_click, true).unwrap();
201        assert_eq!(
202            report,
203            Report {
204                method: "one_click",
205                url: "https://list.example/u".into(),
206                done: false
207            }
208        );
209        let link = unsubscribe(
210            &headers("List-Unsubscribe: <https://list.example/u>"),
211            false,
212        )
213        .unwrap();
214        assert_eq!((link.method, link.done), ("link", false));
215        assert!(matches!(
216            unsubscribe(&headers("X-Other: 1"), false),
217            Err(UnsubscribeError::NotOffered)
218        ));
219    }
220
221    /// A server on a free local port that answers one request with `status` and hands the request back. The body can
222    /// arrive in a later segment than the headers, so it reads until the body is in or the client stops.
223    fn serve_once(status: &'static str) -> (String, mpsc::Receiver<String>) {
224        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
225        let port = listener.local_addr().unwrap().port();
226        let (send, request) = mpsc::channel();
227        std::thread::spawn(move || {
228            let (mut stream, _) = listener.accept().unwrap();
229            stream
230                .set_read_timeout(Some(Duration::from_secs(2)))
231                .unwrap();
232            let mut received = Vec::new();
233            let mut buffer = [0; 2048];
234            while !received.ends_with(b"One-Click") {
235                match stream.read(&mut buffer) {
236                    Ok(0) | Err(_) => break,
237                    Ok(read) => received.extend_from_slice(&buffer[..read]),
238                }
239            }
240            let _ = send.send(String::from_utf8_lossy(&received).into_owned());
241            let reply =
242                format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
243            let _ = stream.write_all(reply.as_bytes());
244        });
245        (format!("http://127.0.0.1:{port}/u?id=7"), request)
246    }
247
248    #[test]
249    fn the_post_says_one_click_and_only_a_success_counts() {
250        let anywhere = |_: &str| true;
251        let (url, request) = serve_once("200 OK");
252        post_to(&url, anywhere, config().build().into()).unwrap();
253        let request = request.recv().unwrap();
254        assert!(request.starts_with("POST /u?id=7 "), "{request}");
255        assert!(request.ends_with("List-Unsubscribe=One-Click"), "{request}");
256        let (url, _) = serve_once("302 Found");
257        assert!(
258            matches!(post_to(&url, anywhere, config().build().into()), Err(UnsubscribeError::Refused(e)) if e == "HTTP 302")
259        );
260        let (url, _) = serve_once("200 OK");
261        assert!(matches!(
262            post_to(&url, public, public_agent(config())),
263            Err(UnsubscribeError::NotPublic)
264        ));
265    }
266}