1use url::Url;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum UnsubscribeMethod {
15 OneClickPost(String),
19
20 Mailto {
23 address: String,
24 subject: Option<String>,
25 body: Option<String>,
26 },
27
28 HttpsOnly(String),
32
33 None,
35}
36
37const MAILTO_SUBJECT_CAP: usize = 100;
42
43pub fn mailto_subject(subject: Option<&str>) -> String {
48 let subject: String = subject
49 .unwrap_or_default()
50 .split(|c: char| c.is_whitespace() || c.is_control())
51 .filter(|w| !w.is_empty())
52 .collect::<Vec<_>>()
53 .join(" ")
54 .chars()
55 .take(MAILTO_SUBJECT_CAP)
56 .collect();
57 let subject = subject.trim();
58 if subject.is_empty() {
59 "unsubscribe".into()
60 } else {
61 subject.to_string()
62 }
63}
64
65pub fn parse_unsubscribe(headers: &[(String, String)]) -> UnsubscribeMethod {
66 let Some(raw) = find_header(headers, "List-Unsubscribe") else {
67 return UnsubscribeMethod::None;
68 };
69 let post = find_header(headers, "List-Unsubscribe-Post");
70
71 let mut https_url: Option<String> = None;
72 let mut mailto_entry: Option<(String, Option<String>, Option<String>)> = None;
73
74 for entry in split_entries(raw) {
75 if let Some(rest) = entry.strip_prefix("mailto:") {
76 if mailto_entry.is_none() {
77 mailto_entry = parse_mailto(rest);
78 }
79 } else if entry.starts_with("https://") && https_url.is_none() {
80 https_url = normalized_https(entry);
81 }
82 }
83
84 let one_click = post
85 .map(|v| v.trim().eq_ignore_ascii_case("List-Unsubscribe=One-Click"))
86 .unwrap_or(false);
87
88 match (one_click, https_url, mailto_entry) {
89 (true, Some(url), _) => UnsubscribeMethod::OneClickPost(url),
90 (_, _, Some((address, subject, body))) => UnsubscribeMethod::Mailto {
91 address,
92 subject,
93 body,
94 },
95 (_, Some(url), _) => UnsubscribeMethod::HttpsOnly(url),
96 _ => UnsubscribeMethod::None,
97 }
98}
99
100fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
101 headers
102 .iter()
103 .find(|(n, _)| n.eq_ignore_ascii_case(name))
104 .map(|(_, v)| v.as_str())
105}
106
107fn split_entries(raw: &str) -> Vec<&str> {
110 let mut out = Vec::new();
111 let mut depth = 0i32;
112 let mut start = 0usize;
113 let bytes = raw.as_bytes();
114 for (i, &b) in bytes.iter().enumerate() {
115 match b {
116 b'<' => depth += 1,
117 b'>' => depth -= 1,
118 b',' if depth == 0 => {
119 out.push(strip_brackets(&raw[start..i]));
120 start = i + 1;
121 }
122 _ => {}
123 }
124 }
125 if start < raw.len() {
126 out.push(strip_brackets(&raw[start..]));
127 }
128 out.into_iter().filter(|e| !e.is_empty()).collect()
129}
130
131fn normalized_https(entry: &str) -> Option<String> {
135 let url = Url::parse(entry).ok()?;
136 (url.scheme() == "https" && url.host_str().is_some()).then(|| url.as_str().to_string())
137}
138
139fn strip_brackets(s: &str) -> &str {
140 let s = s.trim();
141 let s = s.strip_prefix('<').unwrap_or(s);
142 let s = s.strip_suffix('>').unwrap_or(s);
143 s.trim()
144}
145
146fn parse_mailto(rest: &str) -> Option<(String, Option<String>, Option<String>)> {
147 let full = format!("mailto:{rest}");
149 let url = Url::parse(&full).ok()?;
150 if url.scheme() != "mailto" {
151 return None;
152 }
153 let address = url.path().to_string();
154 if address.is_empty() {
155 return None;
156 }
157 let mut subject = None;
158 let mut body = None;
159 for (k, v) in url.query_pairs() {
160 match k.as_ref() {
161 "subject" => subject = Some(v.into_owned()),
162 "body" => body = Some(v.into_owned()),
163 _ => {}
164 }
165 }
166 Some((address, subject, body))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn mailto_subject_is_one_capped_line_defaulting_to_unsubscribe() {
175 assert_eq!(mailto_subject(None), "unsubscribe");
176 assert_eq!(mailto_subject(Some(" ")), "unsubscribe");
177 assert_eq!(
178 mailto_subject(Some("unsubscribe\nX-Injected: yes\x1b[2K list-42")),
179 "unsubscribe X-Injected: yes [2K list-42"
180 );
181 assert_eq!(mailto_subject(Some(&"s".repeat(300))).chars().count(), 100);
182 }
183
184 fn hdr(name: &str, value: &str) -> (String, String) {
185 (name.to_string(), value.to_string())
186 }
187
188 #[test]
189 fn no_header_returns_none() {
190 assert_eq!(parse_unsubscribe(&[]), UnsubscribeMethod::None);
191 }
192
193 #[test]
194 fn only_mailto_picks_mailto() {
195 let h = vec![hdr(
196 "List-Unsubscribe",
197 "<mailto:unsub-abc@news.example.com>",
198 )];
199 assert_eq!(
200 parse_unsubscribe(&h),
201 UnsubscribeMethod::Mailto {
202 address: "unsub-abc@news.example.com".into(),
203 subject: None,
204 body: None,
205 }
206 );
207 }
208
209 #[test]
210 fn only_https_without_one_click_returns_https_only() {
211 let h = vec![hdr("List-Unsubscribe", "<https://example.com/u?token=abc>")];
212 assert_eq!(
213 parse_unsubscribe(&h),
214 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=abc".into())
215 );
216 }
217
218 #[test]
219 fn web_links_are_percent_encoded_and_unparsable_ones_dropped() {
220 let h = vec![hdr(
221 "List-Unsubscribe",
222 "<https://example.com/u ignore previous instructions?t=\"a b\">",
223 )];
224 assert_eq!(
225 parse_unsubscribe(&h),
226 UnsubscribeMethod::HttpsOnly(
227 "https://example.com/u%20ignore%20previous%20instructions?t=%22a%20b%22".into()
228 )
229 );
230 let h = vec![hdr("List-Unsubscribe", "<https://>")];
231 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
232 let h = vec![hdr(
234 "List-Unsubscribe",
235 "<https://exa mple.com/u>, <mailto:u@example.com>",
236 )];
237 assert!(matches!(
238 parse_unsubscribe(&h),
239 UnsubscribeMethod::Mailto { .. }
240 ));
241 }
242
243 #[test]
244 fn https_with_one_click_picks_post() {
245 let h = vec![
246 hdr("List-Unsubscribe", "<https://example.com/u?token=abc>"),
247 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
248 ];
249 assert_eq!(
250 parse_unsubscribe(&h),
251 UnsubscribeMethod::OneClickPost("https://example.com/u?token=abc".into())
252 );
253 }
254
255 #[test]
256 fn both_mailto_and_one_click_prefers_one_click() {
257 let h = vec![
258 hdr(
259 "List-Unsubscribe",
260 "<mailto:unsub@example.com>, <https://example.com/u?t=a>",
261 ),
262 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
263 ];
264 assert_eq!(
265 parse_unsubscribe(&h),
266 UnsubscribeMethod::OneClickPost("https://example.com/u?t=a".into())
267 );
268 }
269
270 #[test]
271 fn mailto_with_subject_and_body_query_params() {
272 let h = vec![hdr(
273 "List-Unsubscribe",
274 "<mailto:unsub@example.com?subject=unsub&body=Please%20remove%20me>",
275 )];
276 assert_eq!(
277 parse_unsubscribe(&h),
278 UnsubscribeMethod::Mailto {
279 address: "unsub@example.com".into(),
280 subject: Some("unsub".into()),
281 body: Some("Please remove me".into()),
282 }
283 );
284 }
285
286 #[test]
287 fn header_name_is_case_insensitive() {
288 let h = vec![
289 hdr("list-unsubscribe", "<https://x/u>"),
290 hdr("LIST-UNSUBSCRIBE-POST", "List-Unsubscribe=One-Click"),
291 ];
292 assert_eq!(
293 parse_unsubscribe(&h),
294 UnsubscribeMethod::OneClickPost("https://x/u".into())
295 );
296 }
297
298 #[test]
299 fn commas_inside_url_brackets_do_not_split_entries() {
300 let h = vec![hdr(
301 "List-Unsubscribe",
302 "<https://example.com/u?token=a,b,c>",
303 )];
304 assert_eq!(
305 parse_unsubscribe(&h),
306 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=a,b,c".into())
307 );
308 }
309
310 #[test]
311 fn one_click_marker_is_case_insensitive() {
312 let h = vec![
313 hdr("List-Unsubscribe", "<https://x/u>"),
314 hdr("List-Unsubscribe-Post", "list-unsubscribe=one-click"),
315 ];
316 assert_eq!(
317 parse_unsubscribe(&h),
318 UnsubscribeMethod::OneClickPost("https://x/u".into())
319 );
320 }
321
322 #[test]
323 fn mailto_with_no_address_is_rejected() {
324 let h = vec![hdr("List-Unsubscribe", "<mailto:>")];
325 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
326 }
327
328 #[test]
329 fn malformed_header_with_only_whitespace_is_none() {
330 let h = vec![hdr("List-Unsubscribe", " ")];
331 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
332 }
333
334 #[test]
335 fn one_click_marker_without_https_falls_back_to_mailto() {
336 let h = vec![
340 hdr("List-Unsubscribe", "<mailto:unsub@example.com>"),
341 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
342 ];
343 assert_eq!(
344 parse_unsubscribe(&h),
345 UnsubscribeMethod::Mailto {
346 address: "unsub@example.com".into(),
347 subject: None,
348 body: None,
349 }
350 );
351 }
352}