use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnsubscribeMethod {
OneClickPost(String),
Mailto {
address: String,
subject: Option<String>,
body: Option<String>,
},
HttpsOnly(String),
None,
}
const MAILTO_SUBJECT_CAP: usize = 100;
pub fn mailto_subject(subject: Option<&str>) -> String {
let subject: String = subject
.unwrap_or_default()
.split(|c: char| c.is_whitespace() || c.is_control())
.filter(|w| !w.is_empty())
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(MAILTO_SUBJECT_CAP)
.collect();
let subject = subject.trim();
if subject.is_empty() {
"unsubscribe".into()
} else {
subject.to_string()
}
}
pub fn parse_unsubscribe(headers: &[(String, String)]) -> UnsubscribeMethod {
let Some(raw) = find_header(headers, "List-Unsubscribe") else {
return UnsubscribeMethod::None;
};
let post = find_header(headers, "List-Unsubscribe-Post");
let mut https_url: Option<String> = None;
let mut mailto_entry: Option<(String, Option<String>, Option<String>)> = None;
for entry in split_entries(raw) {
if let Some(rest) = entry.strip_prefix("mailto:") {
if mailto_entry.is_none() {
mailto_entry = parse_mailto(rest);
}
} else if entry.starts_with("https://") && https_url.is_none() {
https_url = Some(entry.to_string());
}
}
let one_click = post
.map(|v| v.trim().eq_ignore_ascii_case("List-Unsubscribe=One-Click"))
.unwrap_or(false);
match (one_click, https_url, mailto_entry) {
(true, Some(url), _) => UnsubscribeMethod::OneClickPost(url),
(_, _, Some((address, subject, body))) => UnsubscribeMethod::Mailto {
address,
subject,
body,
},
(_, Some(url), _) => UnsubscribeMethod::HttpsOnly(url),
_ => UnsubscribeMethod::None,
}
}
fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn split_entries(raw: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut start = 0usize;
let bytes = raw.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
match b {
b'<' => depth += 1,
b'>' => depth -= 1,
b',' if depth == 0 => {
out.push(strip_brackets(&raw[start..i]));
start = i + 1;
}
_ => {}
}
}
if start < raw.len() {
out.push(strip_brackets(&raw[start..]));
}
out.into_iter().filter(|e| !e.is_empty()).collect()
}
fn strip_brackets(s: &str) -> &str {
let s = s.trim();
let s = s.strip_prefix('<').unwrap_or(s);
let s = s.strip_suffix('>').unwrap_or(s);
s.trim()
}
fn parse_mailto(rest: &str) -> Option<(String, Option<String>, Option<String>)> {
let full = format!("mailto:{rest}");
let url = Url::parse(&full).ok()?;
if url.scheme() != "mailto" {
return None;
}
let address = url.path().to_string();
if address.is_empty() {
return None;
}
let mut subject = None;
let mut body = None;
for (k, v) in url.query_pairs() {
match k.as_ref() {
"subject" => subject = Some(v.into_owned()),
"body" => body = Some(v.into_owned()),
_ => {}
}
}
Some((address, subject, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mailto_subject_is_one_capped_line_defaulting_to_unsubscribe() {
assert_eq!(mailto_subject(None), "unsubscribe");
assert_eq!(mailto_subject(Some(" ")), "unsubscribe");
assert_eq!(
mailto_subject(Some("unsubscribe\nX-Injected: yes\x1b[2K list-42")),
"unsubscribe X-Injected: yes [2K list-42"
);
assert_eq!(mailto_subject(Some(&"s".repeat(300))).chars().count(), 100);
}
fn hdr(name: &str, value: &str) -> (String, String) {
(name.to_string(), value.to_string())
}
#[test]
fn no_header_returns_none() {
assert_eq!(parse_unsubscribe(&[]), UnsubscribeMethod::None);
}
#[test]
fn only_mailto_picks_mailto() {
let h = vec![hdr(
"List-Unsubscribe",
"<mailto:unsub-abc@news.example.com>",
)];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::Mailto {
address: "unsub-abc@news.example.com".into(),
subject: None,
body: None,
}
);
}
#[test]
fn only_https_without_one_click_returns_https_only() {
let h = vec![hdr("List-Unsubscribe", "<https://example.com/u?token=abc>")];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::HttpsOnly("https://example.com/u?token=abc".into())
);
}
#[test]
fn https_with_one_click_picks_post() {
let h = vec![
hdr("List-Unsubscribe", "<https://example.com/u?token=abc>"),
hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::OneClickPost("https://example.com/u?token=abc".into())
);
}
#[test]
fn both_mailto_and_one_click_prefers_one_click() {
let h = vec![
hdr(
"List-Unsubscribe",
"<mailto:unsub@example.com>, <https://example.com/u?t=a>",
),
hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::OneClickPost("https://example.com/u?t=a".into())
);
}
#[test]
fn mailto_with_subject_and_body_query_params() {
let h = vec![hdr(
"List-Unsubscribe",
"<mailto:unsub@example.com?subject=unsub&body=Please%20remove%20me>",
)];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::Mailto {
address: "unsub@example.com".into(),
subject: Some("unsub".into()),
body: Some("Please remove me".into()),
}
);
}
#[test]
fn header_name_is_case_insensitive() {
let h = vec![
hdr("list-unsubscribe", "<https://x/u>"),
hdr("LIST-UNSUBSCRIBE-POST", "List-Unsubscribe=One-Click"),
];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::OneClickPost("https://x/u".into())
);
}
#[test]
fn commas_inside_url_brackets_do_not_split_entries() {
let h = vec![hdr(
"List-Unsubscribe",
"<https://example.com/u?token=a,b,c>",
)];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::HttpsOnly("https://example.com/u?token=a,b,c".into())
);
}
#[test]
fn one_click_marker_is_case_insensitive() {
let h = vec![
hdr("List-Unsubscribe", "<https://x/u>"),
hdr("List-Unsubscribe-Post", "list-unsubscribe=one-click"),
];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::OneClickPost("https://x/u".into())
);
}
#[test]
fn mailto_with_no_address_is_rejected() {
let h = vec![hdr("List-Unsubscribe", "<mailto:>")];
assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
}
#[test]
fn malformed_header_with_only_whitespace_is_none() {
let h = vec![hdr("List-Unsubscribe", " ")];
assert_eq!(parse_unsubscribe(&h), UnsubscribeMethod::None);
}
#[test]
fn one_click_marker_without_https_falls_back_to_mailto() {
let h = vec![
hdr("List-Unsubscribe", "<mailto:unsub@example.com>"),
hdr("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
];
assert_eq!(
parse_unsubscribe(&h),
UnsubscribeMethod::Mailto {
address: "unsub@example.com".into(),
subject: None,
body: None,
}
);
}
}