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).
40/// Characters of a `List-Unsubscribe` mailto subject that are used.
41const MAILTO_SUBJECT_CAP: usize = 100;
42
43/// The subject of the unsubscribe e-mail for a `mailto:` method: the
44/// header's subject on one line, capped at [`MAILTO_SUBJECT_CAP`]
45/// characters, or `unsubscribe` when it gives none. The header's `body` is
46/// never used: the sender chooses it, and the e-mail goes out as the user.
47pub 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
107/// Split a comma-separated `List-Unsubscribe` value, respecting `<>` so URLs
108/// with commas in their query string survive intact. Strips the brackets.
109fn 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    // Prepend the scheme back and let `url` handle percent-decoding for us.
140    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        // Sender includes `List-Unsubscribe-Post` but only a mailto URL.
304        // Per RFC 8058 the marker only applies to HTTPS; we must fall
305        // back to the mailto rather than picking nothing.
306        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}