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
37pub fn parse_unsubscribe(headers: &[(String, String)]) -> UnsubscribeMethod {
41 let Some(raw) = find_header(headers, "List-Unsubscribe") else {
42 return UnsubscribeMethod::None;
43 };
44 let post = find_header(headers, "List-Unsubscribe-Post");
45
46 let mut https_url: Option<String> = None;
47 let mut mailto_entry: Option<(String, Option<String>, Option<String>)> = None;
48
49 for entry in split_entries(raw) {
50 if let Some(rest) = entry.strip_prefix("mailto:") {
51 if mailto_entry.is_none() {
52 mailto_entry = parse_mailto(rest);
53 }
54 } else if entry.starts_with("https://") && https_url.is_none() {
55 https_url = Some(entry.to_string());
56 }
57 }
58
59 let one_click = post
60 .map(|v| v.trim().eq_ignore_ascii_case("List-Unsubscribe=One-Click"))
61 .unwrap_or(false);
62
63 match (one_click, https_url, mailto_entry) {
64 (true, Some(url), _) => UnsubscribeMethod::OneClickPost(url),
65 (_, _, Some((address, subject, body))) => UnsubscribeMethod::Mailto {
66 address,
67 subject,
68 body,
69 },
70 (_, Some(url), _) => UnsubscribeMethod::HttpsOnly(url),
71 _ => UnsubscribeMethod::None,
72 }
73}
74
75fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
76 headers
77 .iter()
78 .find(|(n, _)| n.eq_ignore_ascii_case(name))
79 .map(|(_, v)| v.as_str())
80}
81
82fn split_entries(raw: &str) -> Vec<&str> {
85 let mut out = Vec::new();
86 let mut depth = 0i32;
87 let mut start = 0usize;
88 let bytes = raw.as_bytes();
89 for (i, &b) in bytes.iter().enumerate() {
90 match b {
91 b'<' => depth += 1,
92 b'>' => depth -= 1,
93 b',' if depth == 0 => {
94 out.push(strip_brackets(&raw[start..i]));
95 start = i + 1;
96 }
97 _ => {}
98 }
99 }
100 if start < raw.len() {
101 out.push(strip_brackets(&raw[start..]));
102 }
103 out.into_iter().filter(|e| !e.is_empty()).collect()
104}
105
106fn strip_brackets(s: &str) -> &str {
107 let s = s.trim();
108 let s = s.strip_prefix('<').unwrap_or(s);
109 let s = s.strip_suffix('>').unwrap_or(s);
110 s.trim()
111}
112
113fn parse_mailto(rest: &str) -> Option<(String, Option<String>, Option<String>)> {
114 let full = format!("mailto:{rest}");
116 let url = Url::parse(&full).ok()?;
117 if url.scheme() != "mailto" {
118 return None;
119 }
120 let address = url.path().to_string();
121 if address.is_empty() {
122 return None;
123 }
124 let mut subject = None;
125 let mut body = None;
126 for (k, v) in url.query_pairs() {
127 match k.as_ref() {
128 "subject" => subject = Some(v.into_owned()),
129 "body" => body = Some(v.into_owned()),
130 _ => {}
131 }
132 }
133 Some((address, subject, body))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn hdr(name: &str, value: &str) -> (String, String) {
141 (name.to_string(), value.to_string())
142 }
143
144 #[test]
145 fn no_header_returns_none() {
146 assert_eq!(parse_unsubscribe(&[]), UnsubscribeMethod::None);
147 }
148
149 #[test]
150 fn only_mailto_picks_mailto() {
151 let h = vec![hdr(
152 "List-Unsubscribe",
153 "<mailto:unsub-abc@news.example.com>",
154 )];
155 assert_eq!(
156 parse_unsubscribe(&h),
157 UnsubscribeMethod::Mailto {
158 address: "unsub-abc@news.example.com".into(),
159 subject: None,
160 body: None,
161 }
162 );
163 }
164
165 #[test]
166 fn only_https_without_one_click_returns_https_only() {
167 let h = vec![hdr("List-Unsubscribe", "<https://example.com/u?token=abc>")];
168 assert_eq!(
169 parse_unsubscribe(&h),
170 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=abc".into())
171 );
172 }
173
174 #[test]
175 fn https_with_one_click_picks_post() {
176 let h = vec![
177 hdr("List-Unsubscribe", "<https://example.com/u?token=abc>"),
178 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
179 ];
180 assert_eq!(
181 parse_unsubscribe(&h),
182 UnsubscribeMethod::OneClickPost("https://example.com/u?token=abc".into())
183 );
184 }
185
186 #[test]
187 fn both_mailto_and_one_click_prefers_one_click() {
188 let h = vec![
189 hdr(
190 "List-Unsubscribe",
191 "<mailto:unsub@example.com>, <https://example.com/u?t=a>",
192 ),
193 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
194 ];
195 assert_eq!(
196 parse_unsubscribe(&h),
197 UnsubscribeMethod::OneClickPost("https://example.com/u?t=a".into())
198 );
199 }
200
201 #[test]
202 fn mailto_with_subject_and_body_query_params() {
203 let h = vec![hdr(
204 "List-Unsubscribe",
205 "<mailto:unsub@example.com?subject=unsub&body=Please%20remove%20me>",
206 )];
207 assert_eq!(
208 parse_unsubscribe(&h),
209 UnsubscribeMethod::Mailto {
210 address: "unsub@example.com".into(),
211 subject: Some("unsub".into()),
212 body: Some("Please remove me".into()),
213 }
214 );
215 }
216
217 #[test]
218 fn header_name_is_case_insensitive() {
219 let h = vec![
220 hdr("list-unsubscribe", "<https://x/u>"),
221 hdr("LIST-UNSUBSCRIBE-POST", "List-Unsubscribe=One-Click"),
222 ];
223 assert_eq!(
224 parse_unsubscribe(&h),
225 UnsubscribeMethod::OneClickPost("https://x/u".into())
226 );
227 }
228
229 #[test]
230 fn commas_inside_url_brackets_do_not_split_entries() {
231 let h = vec![hdr(
232 "List-Unsubscribe",
233 "<https://example.com/u?token=a,b,c>",
234 )];
235 assert_eq!(
236 parse_unsubscribe(&h),
237 UnsubscribeMethod::HttpsOnly("https://example.com/u?token=a,b,c".into())
238 );
239 }
240
241 #[test]
242 fn one_click_marker_is_case_insensitive() {
243 let h = vec![
244 hdr("List-Unsubscribe", "<https://x/u>"),
245 hdr("List-Unsubscribe-Post", "list-unsubscribe=one-click"),
246 ];
247 assert_eq!(
248 parse_unsubscribe(&h),
249 UnsubscribeMethod::OneClickPost("https://x/u".into())
250 );
251 }
252
253 #[test]
254 fn mailto_with_no_address_is_rejected() {
255 let h = vec![hdr("List-Unsubscribe", "<mailto:>")];
256 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
257 }
258
259 #[test]
260 fn malformed_header_with_only_whitespace_is_none() {
261 let h = vec![hdr("List-Unsubscribe", " ")];
262 assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
263 }
264
265 #[test]
266 fn one_click_marker_without_https_falls_back_to_mailto() {
267 let h = vec![
271 hdr("List-Unsubscribe", "<mailto:unsub@example.com>"),
272 hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
273 ];
274 assert_eq!(
275 parse_unsubscribe(&h),
276 UnsubscribeMethod::Mailto {
277 address: "unsub@example.com".into(),
278 subject: None,
279 body: None,
280 }
281 );
282 }
283}