Skip to main content

list_unsubscribe/
lib.rs

1//! Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058)
2//! email headers into a typed action enum.
3//!
4//! See the [README] for the rationale and coverage matrix.
5//!
6//! [README]: https://github.com/planetaryescape/list-unsubscribe#readme
7//!
8//! ```
9//! use list_unsubscribe::{parse_with_post, UnsubscribeMethod};
10//!
11//! let header = "<mailto:u@example.com>, <https://example.com/unsub?u=abc>";
12//! let post = Some("List-Unsubscribe=One-Click");
13//!
14//! match parse_with_post(header, post) {
15//!     UnsubscribeMethod::OneClick { url } => {
16//!         // POST to `url` with body `List-Unsubscribe=One-Click`
17//!         let _ = url;
18//!     }
19//!     UnsubscribeMethod::Mailto { address, subject } => {
20//!         let _ = (address, subject);
21//!     }
22//!     UnsubscribeMethod::HttpLink { url } => {
23//!         let _ = url;
24//!     }
25//!     UnsubscribeMethod::None => {}
26//! }
27//! ```
28
29#![cfg_attr(docsrs, feature(doc_cfg))]
30#![deny(unsafe_code, unused_must_use)]
31
32use url::Url;
33
34/// The unsubscribe action a message exposes via its headers.
35///
36/// Returned by [`parse`] and [`parse_with_post`]. The `OneClick` variant is
37/// only produced when an RFC 8058 `List-Unsubscribe-Post` header is present
38/// and an HTTPS or HTTP URL is available in the main header.
39#[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    /// RFC 8058 one-click. The caller should POST to `url` with body
44    /// `List-Unsubscribe=One-Click` and `Content-Type: application/x-www-form-urlencoded`.
45    OneClick { url: Url },
46    /// A plain HTTP(S) link the user should open in a browser. May require
47    /// further interaction (preference page, confirmation click).
48    HttpLink { url: Url },
49    /// Send an email to `address` with optional `subject`. The body, if any
50    /// was specified in the `mailto:` query, is intentionally dropped — see
51    /// the crate README for the rationale.
52    Mailto {
53        address: String,
54        subject: Option<String>,
55    },
56    /// No `List-Unsubscribe` header was found, or every candidate was
57    /// unparseable.
58    None,
59}
60
61/// Parse a raw `List-Unsubscribe` header value into an [`UnsubscribeMethod`].
62///
63/// Equivalent to [`parse_with_post`] with `post_header_value = None`. Use
64/// this when you have not been able to retrieve `List-Unsubscribe-Post` —
65/// for example because the message store discarded it. RFC 8058 one-click
66/// detection requires both headers, so this entry point will never return
67/// [`UnsubscribeMethod::OneClick`].
68pub fn parse(header_value: &str) -> UnsubscribeMethod {
69    parse_with_post(header_value, None)
70}
71
72/// Parse `List-Unsubscribe` together with the optional `List-Unsubscribe-Post`
73/// header.
74///
75/// `post_header_value` is matched case-insensitively against the substring
76/// `list-unsubscribe=one-click`, per [RFC 8058 §3.2][rfc8058-3-2]. When
77/// present and accompanied by a valid HTTP(S) URL in the main header, the
78/// returned variant is [`UnsubscribeMethod::OneClick`].
79///
80/// Preference order when multiple methods are present:
81///
82/// 1. RFC 8058 one-click (Post header + HTTP(S) URL)
83/// 2. `mailto:` (first encountered)
84/// 3. HTTP(S) link (first encountered)
85///
86/// Malformed URLs are skipped silently and fall through to the next
87/// candidate. If no candidate parses, [`UnsubscribeMethod::None`] is
88/// returned.
89///
90/// [rfc8058-3-2]: https://www.rfc-editor.org/rfc/rfc8058#section-3.2
91pub 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/// Parse `List-Unsubscribe` from a [`mail_parser::Message`] in one call.
133///
134/// Reads both `List-Unsubscribe` and `List-Unsubscribe-Post` from the
135/// message and delegates to [`parse_with_post`]. Available only with the
136/// `mail-parser` feature enabled.
137#[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        // Producers in the wild sometimes pad inside the angle brackets.
331        // We strip the < > envelope then trim, so both entries are
332        // recoverable. Mailto wins per preference order.
333        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}