1#![cfg_attr(docsrs, feature(doc_cfg))]
30#![deny(unsafe_code, unused_must_use)]
31
32use url::Url;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[cfg_attr(feature = "serde", serde(tag = "kind"))]
42pub enum UnsubscribeMethod {
43 OneClick { url: Url },
46 HttpLink { url: Url },
49 Mailto {
53 address: String,
54 subject: Option<String>,
55 },
56 None,
59}
60
61pub fn parse(header_value: &str) -> UnsubscribeMethod {
69 parse_with_post(header_value, None)
70}
71
72pub fn parse_with_post(header_value: &str, post_header_value: Option<&str>) -> UnsubscribeMethod {
92 let entries = split_entries(header_value);
93 if entries.is_empty() {
94 return UnsubscribeMethod::None;
95 }
96
97 let one_click_requested = post_header_value
98 .map(|value| {
99 value
100 .to_ascii_lowercase()
101 .contains("list-unsubscribe=one-click")
102 })
103 .unwrap_or(false);
104
105 if one_click_requested {
106 for entry in &entries {
107 if is_http(entry) {
108 if let Ok(url) = Url::parse(entry) {
109 return UnsubscribeMethod::OneClick { url };
110 }
111 }
112 }
113 }
114
115 for entry in &entries {
116 if let Some(rest) = strip_mailto(entry) {
117 return parse_mailto(rest);
118 }
119 }
120
121 for entry in &entries {
122 if is_http(entry) {
123 if let Ok(url) = Url::parse(entry) {
124 return UnsubscribeMethod::HttpLink { url };
125 }
126 }
127 }
128
129 UnsubscribeMethod::None
130}
131
132#[cfg(feature = "mail-parser")]
138#[cfg_attr(docsrs, doc(cfg(feature = "mail-parser")))]
139pub fn parse_from_message(message: &mail_parser::Message<'_>) -> UnsubscribeMethod {
140 let header_value = message
141 .header_raw("List-Unsubscribe")
142 .unwrap_or("")
143 .to_string();
144 let post_value = message
145 .header_raw("List-Unsubscribe-Post")
146 .map(|value| value.to_string());
147 parse_with_post(&header_value, post_value.as_deref())
148}
149
150fn split_entries(header_value: &str) -> Vec<String> {
151 let mut out = Vec::new();
152 for raw in header_value.split(',') {
153 let trimmed = raw.trim();
154 if trimmed.is_empty() {
155 continue;
156 }
157 let stripped = trimmed
158 .strip_prefix('<')
159 .and_then(|s| s.strip_suffix('>'))
160 .unwrap_or(trimmed);
161 let stripped = stripped.trim();
162 if !stripped.is_empty() {
163 out.push(stripped.to_string());
164 }
165 }
166 out
167}
168
169fn is_http(entry: &str) -> bool {
170 let lower_prefix = entry.get(..8).map(str::to_ascii_lowercase);
171 matches!(
172 lower_prefix.as_deref(),
173 Some(p) if p.starts_with("https://") || p.starts_with("http://")
174 )
175}
176
177fn strip_mailto(entry: &str) -> Option<&str> {
178 let prefix = entry.get(..7)?;
179 if prefix.eq_ignore_ascii_case("mailto:") {
180 Some(&entry[7..])
181 } else {
182 None
183 }
184}
185
186fn parse_mailto(rest: &str) -> UnsubscribeMethod {
187 let (address_part, query) = match rest.split_once('?') {
188 Some((address, query)) => (address.to_string(), Some(query)),
189 None => (rest.to_string(), None),
190 };
191
192 let mut subject = None;
193 if let Some(query) = query {
194 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
195 if key.eq_ignore_ascii_case("subject") {
196 subject = Some(value.into_owned());
197 break;
198 }
199 }
200 }
201
202 if address_part.is_empty() {
203 UnsubscribeMethod::None
204 } else {
205 UnsubscribeMethod::Mailto {
206 address: address_part,
207 subject,
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 #![allow(clippy::panic, clippy::unwrap_used)]
215
216 use super::*;
217
218 #[test]
219 fn empty_header_returns_none() {
220 assert_eq!(parse(""), UnsubscribeMethod::None);
221 assert_eq!(parse(" "), UnsubscribeMethod::None);
222 }
223
224 #[test]
225 fn single_mailto_returns_mailto() {
226 match parse("<mailto:u@example.com>") {
227 UnsubscribeMethod::Mailto { address, subject } => {
228 assert_eq!(address, "u@example.com");
229 assert!(subject.is_none());
230 }
231 other => panic!("expected Mailto, got {other:?}"),
232 }
233 }
234
235 #[test]
236 fn mailto_with_subject_extracts_subject() {
237 match parse("<mailto:u@example.com?subject=Unsubscribe>") {
238 UnsubscribeMethod::Mailto { address, subject } => {
239 assert_eq!(address, "u@example.com");
240 assert_eq!(subject.as_deref(), Some("Unsubscribe"));
241 }
242 other => panic!("expected Mailto, got {other:?}"),
243 }
244 }
245
246 #[test]
247 fn mailto_with_subject_and_body_drops_body() {
248 match parse("<mailto:u@example.com?subject=Unsubscribe&body=please>") {
249 UnsubscribeMethod::Mailto { address, subject } => {
250 assert_eq!(address, "u@example.com");
251 assert_eq!(subject.as_deref(), Some("Unsubscribe"));
252 }
253 other => panic!("expected Mailto, got {other:?}"),
254 }
255 }
256
257 #[test]
258 fn single_https_returns_http_link() {
259 match parse("<https://example.com/unsub>") {
260 UnsubscribeMethod::HttpLink { url } => {
261 assert_eq!(url.as_str(), "https://example.com/unsub");
262 }
263 other => panic!("expected HttpLink, got {other:?}"),
264 }
265 }
266
267 #[test]
268 fn mailto_preferred_over_http_when_no_one_click() {
269 let header = "<mailto:u@example.com>, <https://example.com/unsub>";
270 match parse(header) {
271 UnsubscribeMethod::Mailto { address, .. } => {
272 assert_eq!(address, "u@example.com");
273 }
274 other => panic!("expected Mailto, got {other:?}"),
275 }
276 }
277
278 #[test]
279 fn one_click_picks_http_url() {
280 let header = "<mailto:u@example.com>, <https://example.com/unsub?u=abc>";
281 let post = Some("List-Unsubscribe=One-Click");
282 match parse_with_post(header, post) {
283 UnsubscribeMethod::OneClick { url } => {
284 assert_eq!(url.as_str(), "https://example.com/unsub?u=abc");
285 }
286 other => panic!("expected OneClick, got {other:?}"),
287 }
288 }
289
290 #[test]
291 fn one_click_is_case_insensitive() {
292 let header = "<https://example.com/unsub>";
293 let post = Some("LIST-UNSUBSCRIBE=ONE-CLICK");
294 assert!(matches!(
295 parse_with_post(header, post),
296 UnsubscribeMethod::OneClick { .. }
297 ));
298 }
299
300 #[test]
301 fn one_click_without_http_falls_back() {
302 let header = "<mailto:u@example.com>";
303 let post = Some("List-Unsubscribe=One-Click");
304 match parse_with_post(header, post) {
305 UnsubscribeMethod::Mailto { address, .. } => {
306 assert_eq!(address, "u@example.com");
307 }
308 other => panic!("expected Mailto fallback, got {other:?}"),
309 }
310 }
311
312 #[test]
313 fn multiple_https_returns_first() {
314 let header = "<https://example.com/desktop/unsub>, <https://example.com/mobile/unsub>";
315 match parse(header) {
316 UnsubscribeMethod::HttpLink { url } => {
317 assert_eq!(url.as_str(), "https://example.com/desktop/unsub");
318 }
319 other => panic!("expected HttpLink, got {other:?}"),
320 }
321 }
322
323 #[test]
324 fn malformed_url_returns_none_when_only_candidate() {
325 assert_eq!(parse("<https://>"), UnsubscribeMethod::None);
326 }
327
328 #[test]
329 fn whitespace_quirks_tolerated() {
330 let header = " < mailto:u@example.com > , < https://example.com/unsub > ";
334 match parse(header) {
335 UnsubscribeMethod::Mailto { address, subject } => {
336 assert_eq!(address, "u@example.com");
337 assert!(subject.is_none());
338 }
339 other => panic!("expected Mailto, got {other:?}"),
340 }
341 }
342
343 #[test]
344 fn http_scheme_case_insensitive() {
345 let header = "<HTTPS://example.com/unsub>";
346 match parse(header) {
347 UnsubscribeMethod::HttpLink { url } => {
348 assert_eq!(url.scheme(), "https");
349 }
350 other => panic!("expected HttpLink, got {other:?}"),
351 }
352 }
353}