1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Method {
14 OneClick(String),
16 Link(String),
18 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#[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 #[error("the sender's server did not accept the unsubscribe ({0})")]
54 Refused(String),
55}
56
57pub fn method(headers: &[u8]) -> Option<Method> {
60 let list = header_value(headers, "List-Unsubscribe")?;
61 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
91pub 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
109pub 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
121fn 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 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}