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 = Some(entry.to_string());
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 strip_brackets(s: &str) -> &str {
132 let s = s.trim();
133 let s = s.strip_prefix('<').unwrap_or(s);
134 let s = s.strip_suffix('>').unwrap_or(s);
135 s.trim()
136}
137
138fn parse_mailto(rest: &str) -> Option<(String, Option<String>, Option<String>)> {
139 let full = format!("mailto:{rest}");
141 let url = Url::parse(&full).ok()?;
142 if url.scheme() != "mailto" {
143 return None;
144 }
145 let address = url.path().to_string();
146 if address.is_empty() {
147 return None;
148 }
149 let mut subject = None;
150 let mut body = None;
151 for (k, v) in url.query_pairs() {
152 match k.as_ref() {
153 "subject" => subject = Some(v.into_owned()),
154 "body" => body = Some(v.into_owned()),
155 _ => {}
156 }
157 }
158 Some((address, subject, body))
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn mailto_subject_is_one_capped_line_defaulting_to_unsubscribe() {
167 assert_eq!(mailto_subject(None), "unsubscribe");
168 assert_eq!(mailto_subject(Some(" ")), "unsubscribe");
169 assert_eq!(
170 mailto_subject(Some("unsubscribe\nX-Injected: yes\x1b[2K list-42")),
171 "unsubscribe X-Injected: yes [2K list-42"
172 );
173 assert_eq!(mailto_subject(Some(&"s".repeat(300))).chars().count(), 100);
174 }
175
176 fn hdr(name: &str, value: &str) -> (String, String) {
177 (name.to_string(), value.to_string())
178 }
179
180 #[test]
181 fn no_header_returns_none() {
182 assert_eq!(parse_unsubscribe(&[]), UnsubscribeMethod::None);
183 }
184
185 #[test]
186 fn only_mailto_picks_mailto() {
187 let h = vec![hdr(
188 "List-Unsubscribe",
189 "<mailto:unsub-abc@news.example.com>",
190 )];
191 assert_eq!(
192 parse_unsubscribe(&h),
193 UnsubscribeMethod::Mailto {
194 address: "unsub-abc@news.example.com".into(),
195 subject: None,
196 body: None,
197 }
198 );
199 }
200
201 #[test]
202 fn only_https_without_one_click_returns_https_only() {
203 let h = vec![hdr("List-Unsubscribe", "<https://example.com/u?token=abc>")];
204 assert_eq!(
205 parse_unsubscribe(&h),
206 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=abc".into())
207 );
208 }
209
210 #[test]
211 fn https_with_one_click_picks_post() {
212 let h = vec![
213 hdr("List-Unsubscribe", "<https://example.com/u?token=abc>"),
214 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
215 ];
216 assert_eq!(
217 parse_unsubscribe(&h),
218 UnsubscribeMethod::OneClickPost("https://example.com/u?token=abc".into())
219 );
220 }
221
222 #[test]
223 fn both_mailto_and_one_click_prefers_one_click() {
224 let h = vec![
225 hdr(
226 "List-Unsubscribe",
227 "<mailto:unsub@example.com>, <https://example.com/u?t=a>",
228 ),
229 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
230 ];
231 assert_eq!(
232 parse_unsubscribe(&h),
233 UnsubscribeMethod::OneClickPost("https://example.com/u?t=a".into())
234 );
235 }
236
237 #[test]
238 fn mailto_with_subject_and_body_query_params() {
239 let h = vec![hdr(
240 "List-Unsubscribe",
241 "<mailto:unsub@example.com?subject=unsub&body=Please%20remove%20me>",
242 )];
243 assert_eq!(
244 parse_unsubscribe(&h),
245 UnsubscribeMethod::Mailto {
246 address: "unsub@example.com".into(),
247 subject: Some("unsub".into()),
248 body: Some("Please remove me".into()),
249 }
250 );
251 }
252
253 #[test]
254 fn header_name_is_case_insensitive() {
255 let h = vec![
256 hdr("list-unsubscribe", "<https://x/u>"),
257 hdr("LIST-UNSUBSCRIBE-POST", "List-Unsubscribe=One-Click"),
258 ];
259 assert_eq!(
260 parse_unsubscribe(&h),
261 UnsubscribeMethod::OneClickPost("https://x/u".into())
262 );
263 }
264
265 #[test]
266 fn commas_inside_url_brackets_do_not_split_entries() {
267 let h = vec![hdr(
268 "List-Unsubscribe",
269 "<https://example.com/u?token=a,b,c>",
270 )];
271 assert_eq!(
272 parse_unsubscribe(&h),
273 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=a,b,c".into())
274 );
275 }
276
277 #[test]
278 fn one_click_marker_is_case_insensitive() {
279 let h = vec![
280 hdr("List-Unsubscribe", "<https://x/u>"),
281 hdr("List-Unsubscribe-Post", "list-unsubscribe=one-click"),
282 ];
283 assert_eq!(
284 parse_unsubscribe(&h),
285 UnsubscribeMethod::OneClickPost("https://x/u".into())
286 );
287 }
288
289 #[test]
290 fn mailto_with_no_address_is_rejected() {
291 let h = vec![hdr("List-Unsubscribe", "<mailto:>")];
292 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
293 }
294
295 #[test]
296 fn malformed_header_with_only_whitespace_is_none() {
297 let h = vec![hdr("List-Unsubscribe", " ")];
298 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
299 }
300
301 #[test]
302 fn one_click_marker_without_https_falls_back_to_mailto() {
303 let h = vec![
307 hdr("List-Unsubscribe", "<mailto:unsub@example.com>"),
308 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
309 ];
310 assert_eq!(
311 parse_unsubscribe(&h),
312 UnsubscribeMethod::Mailto {
313 address: "unsub@example.com".into(),
314 subject: None,
315 body: None,
316 }
317 );
318 }
319}