#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(unsafe_code, unused_must_use)]
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind"))]
pub enum UnsubscribeMethod {
OneClick { url: Url },
HttpLink { url: Url },
Mailto {
address: String,
subject: Option<String>,
},
None,
}
pub fn parse(header_value: &str) -> UnsubscribeMethod {
parse_with_post(header_value, None)
}
pub fn parse_with_post(header_value: &str, post_header_value: Option<&str>) -> UnsubscribeMethod {
let entries = split_entries(header_value);
if entries.is_empty() {
return UnsubscribeMethod::None;
}
let one_click_requested = post_header_value
.map(|value| {
value
.to_ascii_lowercase()
.contains("list-unsubscribe=one-click")
})
.unwrap_or(false);
if one_click_requested {
for entry in &entries {
if is_http(entry) {
if let Ok(url) = Url::parse(entry) {
return UnsubscribeMethod::OneClick { url };
}
}
}
}
for entry in &entries {
if let Some(rest) = strip_mailto(entry) {
return parse_mailto(rest);
}
}
for entry in &entries {
if is_http(entry) {
if let Ok(url) = Url::parse(entry) {
return UnsubscribeMethod::HttpLink { url };
}
}
}
UnsubscribeMethod::None
}
#[cfg(feature = "mail-parser")]
#[cfg_attr(docsrs, doc(cfg(feature = "mail-parser")))]
pub fn parse_from_message(message: &mail_parser::Message<'_>) -> UnsubscribeMethod {
let header_value = message
.header_raw("List-Unsubscribe")
.unwrap_or("")
.to_string();
let post_value = message
.header_raw("List-Unsubscribe-Post")
.map(|value| value.to_string());
parse_with_post(&header_value, post_value.as_deref())
}
fn split_entries(header_value: &str) -> Vec<String> {
let mut out = Vec::new();
for raw in header_value.split(',') {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let stripped = trimmed
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(trimmed);
let stripped = stripped.trim();
if !stripped.is_empty() {
out.push(stripped.to_string());
}
}
out
}
fn is_http(entry: &str) -> bool {
let lower_prefix = entry.get(..8).map(str::to_ascii_lowercase);
matches!(
lower_prefix.as_deref(),
Some(p) if p.starts_with("https://") || p.starts_with("http://")
)
}
fn strip_mailto(entry: &str) -> Option<&str> {
let prefix = entry.get(..7)?;
if prefix.eq_ignore_ascii_case("mailto:") {
Some(&entry[7..])
} else {
None
}
}
fn parse_mailto(rest: &str) -> UnsubscribeMethod {
let (address_part, query) = match rest.split_once('?') {
Some((address, query)) => (address.to_string(), Some(query)),
None => (rest.to_string(), None),
};
let mut subject = None;
if let Some(query) = query {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
if key.eq_ignore_ascii_case("subject") {
subject = Some(value.into_owned());
break;
}
}
}
if address_part.is_empty() {
UnsubscribeMethod::None
} else {
UnsubscribeMethod::Mailto {
address: address_part,
subject,
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic, clippy::unwrap_used)]
use super::*;
#[test]
fn empty_header_returns_none() {
assert_eq!(parse(""), UnsubscribeMethod::None);
assert_eq!(parse(" "), UnsubscribeMethod::None);
}
#[test]
fn single_mailto_returns_mailto() {
match parse("<mailto:u@example.com>") {
UnsubscribeMethod::Mailto { address, subject } => {
assert_eq!(address, "u@example.com");
assert!(subject.is_none());
}
other => panic!("expected Mailto, got {other:?}"),
}
}
#[test]
fn mailto_with_subject_extracts_subject() {
match parse("<mailto:u@example.com?subject=Unsubscribe>") {
UnsubscribeMethod::Mailto { address, subject } => {
assert_eq!(address, "u@example.com");
assert_eq!(subject.as_deref(), Some("Unsubscribe"));
}
other => panic!("expected Mailto, got {other:?}"),
}
}
#[test]
fn mailto_with_subject_and_body_drops_body() {
match parse("<mailto:u@example.com?subject=Unsubscribe&body=please>") {
UnsubscribeMethod::Mailto { address, subject } => {
assert_eq!(address, "u@example.com");
assert_eq!(subject.as_deref(), Some("Unsubscribe"));
}
other => panic!("expected Mailto, got {other:?}"),
}
}
#[test]
fn single_https_returns_http_link() {
match parse("<https://example.com/unsub>") {
UnsubscribeMethod::HttpLink { url } => {
assert_eq!(url.as_str(), "https://example.com/unsub");
}
other => panic!("expected HttpLink, got {other:?}"),
}
}
#[test]
fn mailto_preferred_over_http_when_no_one_click() {
let header = "<mailto:u@example.com>, <https://example.com/unsub>";
match parse(header) {
UnsubscribeMethod::Mailto { address, .. } => {
assert_eq!(address, "u@example.com");
}
other => panic!("expected Mailto, got {other:?}"),
}
}
#[test]
fn one_click_picks_http_url() {
let header = "<mailto:u@example.com>, <https://example.com/unsub?u=abc>";
let post = Some("List-Unsubscribe=One-Click");
match parse_with_post(header, post) {
UnsubscribeMethod::OneClick { url } => {
assert_eq!(url.as_str(), "https://example.com/unsub?u=abc");
}
other => panic!("expected OneClick, got {other:?}"),
}
}
#[test]
fn one_click_is_case_insensitive() {
let header = "<https://example.com/unsub>";
let post = Some("LIST-UNSUBSCRIBE=ONE-CLICK");
assert!(matches!(
parse_with_post(header, post),
UnsubscribeMethod::OneClick { .. }
));
}
#[test]
fn one_click_without_http_falls_back() {
let header = "<mailto:u@example.com>";
let post = Some("List-Unsubscribe=One-Click");
match parse_with_post(header, post) {
UnsubscribeMethod::Mailto { address, .. } => {
assert_eq!(address, "u@example.com");
}
other => panic!("expected Mailto fallback, got {other:?}"),
}
}
#[test]
fn multiple_https_returns_first() {
let header = "<https://example.com/desktop/unsub>, <https://example.com/mobile/unsub>";
match parse(header) {
UnsubscribeMethod::HttpLink { url } => {
assert_eq!(url.as_str(), "https://example.com/desktop/unsub");
}
other => panic!("expected HttpLink, got {other:?}"),
}
}
#[test]
fn malformed_url_returns_none_when_only_candidate() {
assert_eq!(parse("<https://>"), UnsubscribeMethod::None);
}
#[test]
fn whitespace_quirks_tolerated() {
let header = " < mailto:u@example.com > , < https://example.com/unsub > ";
match parse(header) {
UnsubscribeMethod::Mailto { address, subject } => {
assert_eq!(address, "u@example.com");
assert!(subject.is_none());
}
other => panic!("expected Mailto, got {other:?}"),
}
}
#[test]
fn http_scheme_case_insensitive() {
let header = "<HTTPS://example.com/unsub>";
match parse(header) {
UnsubscribeMethod::HttpLink { url } => {
assert_eq!(url.scheme(), "https");
}
other => panic!("expected HttpLink, got {other:?}"),
}
}
}