Skip to main content

pidge_client/
unsubscribe.rs

1//! Parsing of RFC 2369 `List-Unsubscribe` and RFC 8058
2//! `List-Unsubscribe-Post` headers — no I/O.
3//!
4//! See:
5//! - <https://www.rfc-editor.org/rfc/rfc2369> (List-Unsubscribe)
6//! - <https://www.rfc-editor.org/rfc/rfc8058> (one-click POST)
7//! - <https://www.rfc-editor.org/rfc/rfc6068> (mailto: URI)
8
9use url::Url;
10
11/// The opt-out method picked from a message's unsubscribe headers, in
12/// preference order: `OneClickPost` → `Mailto` → `HttpsOnly` → `None`.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum UnsubscribeMethod {
15    /// RFC 8058 one-click: POST `List-Unsubscribe=One-Click`
16    /// (`application/x-www-form-urlencoded`) to this URL. No browser
17    /// interaction needed.
18    OneClickPost(String),
19
20    /// RFC 2369 `mailto:` — send an e-mail to this address. Per RFC 6068
21    /// the URL may carry `?subject=` / `?body=` that override our defaults.
22    Mailto {
23        address: String,
24        subject: Option<String>,
25        body: Option<String>,
26    },
27
28    /// HTTPS URL exists but no one-click marker. Won't auto-drive; the
29    /// caller should surface the URL for a manual click.
30    /// HTTPS only — plaintext `http://` entries are ignored on purpose.
31    HttpsOnly(String),
32
33    /// No `List-Unsubscribe` header at all.
34    None,
35}
36
37/// Pick the best `UnsubscribeMethod` for the given message headers.
38///
39/// Header name comparison is case-insensitive (RFC 5322).
40pub 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
82/// Split a comma-separated `List-Unsubscribe` value, respecting `<>` so URLs
83/// with commas in their query string survive intact. Strips the brackets.
84fn 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    // Prepend the scheme back and let `url` handle percent-decoding for us.
115    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        // Sender includes `List-Unsubscribe-Post` but only a mailto URL.
268        // Per RFC 8058 the marker only applies to HTTPS; we must fall
269        // back to the mailto rather than picking nothing.
270        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}