use std::path::Path;
use anyhow::{bail, Context as _, Result};
use unicode_normalization::UnicodeNormalization as _;
use super::config::MailConfig;
const ATTRIBUTION: &[&str] = &[
"generated by",
"generated with",
"generated using",
"co-authored-by",
"as an ai",
"as a language model",
"large language model",
"ai assistant",
"ai-generated",
"ai generated",
"chatgpt",
"openai",
"anthropic",
"claude",
"gpt-4",
"gpt-4o",
"gpt-5",
"copilot",
"gemini",
];
const AGENT_MAILER: &[&str] = &["agent", "bot", "ai", "llm", "assistant", "automated"];
#[derive(Debug, Clone, Default)]
pub struct DenyList {
addresses: Vec<String>,
domains: Vec<String>,
handles: Vec<String>,
terms: Vec<String>,
pairs: Vec<(String, String)>,
}
impl DenyList {
pub fn read(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("checks: cannot read the deny file {}", path.display()))?;
Self::parse(&text)
}
pub fn parse(text: &str) -> Result<Self> {
let mut deny = Self::default();
for (n, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((kind, value)) = line.split_once(':') else {
bail!("checks: deny file line {} has no kind", n + 1);
};
let value = fold(value.trim());
if value.is_empty() {
bail!("checks: deny file line {} has no value", n + 1);
}
match kind.trim() {
"address" => deny.addresses.push(value),
"domain" => deny.domains.push(value.trim_start_matches('@').to_string()),
"handle" => deny.handles.push(value),
"term" => deny.terms.push(value),
"pair" => match value.split_once('+') {
Some((a, b)) if !a.trim().is_empty() && !b.trim().is_empty() => {
deny.pairs
.push((a.trim().to_string(), b.trim().to_string()));
}
_ => bail!("checks: deny file line {} pair is not `a + b`", n + 1),
},
other => bail!("checks: deny file line {} has unknown kind {other}", n + 1),
}
}
Ok(deny)
}
fn denies_address(&self, address: &str) -> bool {
let address = address.trim_end_matches('.');
let domain = address.rsplit_once('@').map_or("", |(_, d)| d);
self.addresses.iter().any(|a| a == address)
|| self
.domains
.iter()
.any(|d| domain == d || domain.ends_with(&format!(".{d}")))
}
}
#[must_use]
pub fn refusals(message: &[u8], config: &MailConfig) -> Vec<String> {
let Some(identity) = config.identity.as_deref() else {
return vec!["checks: no [mail] identity is configured".into()];
};
let Some(deny_file) = config.deny_file.as_deref() else {
return vec!["checks: no [mail] deny_file is configured".into()];
};
let deny = match DenyList::read(deny_file) {
Ok(deny) => deny,
Err(e) => return vec![format!("{e:#}")],
};
let attribution: Vec<String> = ATTRIBUTION
.iter()
.map(|p| fold(p))
.chain(config.attribution.iter().map(|p| fold(p)))
.collect();
check(message, &fold(identity), &deny, &attribution)
}
fn check(message: &[u8], identity: &str, deny: &DenyList, attribution: &[String]) -> Vec<String> {
let Ok(message) = std::str::from_utf8(message) else {
return vec!["checks: the draft is not UTF-8".into()];
};
let message = message.replace("\r\n", "\n");
let (head, body) = message.split_once("\n\n").unwrap_or((&message, ""));
let Some(headers) = unfold(head) else {
return vec!["checks: a header line is neither a field nor a continuation".into()];
};
let mut out = Vec::new();
let values = |name: &str| -> Vec<&str> {
headers
.iter()
.filter(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
.collect()
};
let from: Vec<String> = values("from").iter().flat_map(|v| addresses(v)).collect();
if from.len() != 1 || from[0] != identity {
out.push(format!("checks: From must be exactly {identity}"));
}
for name in ["reply-to", "sender", "return-path"] {
for value in values(name) {
let folded = fold(value);
if addresses(value).iter().any(|a| deny.denies_address(a))
|| deny.handles.iter().any(|h| has_word(&folded, h))
{
out.push(format!("checks: {name} names a denied identity"));
}
}
}
for name in ["to", "cc", "bcc"] {
for address in values(name).iter().flat_map(|v| addresses(v)) {
if deny.denies_address(&address) {
out.push(format!("checks: {name} has a denied recipient"));
}
}
}
out.extend(plain_text_only(&headers, body));
let text = fold(&format!(
"{}\n{body}",
headers
.iter()
.map(|(n, v)| format!("{n}: {v}"))
.collect::<Vec<_>>()
.join("\n")
));
let identities = deny
.addresses
.iter()
.chain(&deny.domains)
.chain(&deny.handles);
if deny
.terms
.iter()
.chain(identities)
.any(|t| has_word(&text, t))
{
out.push("checks: the draft carries a denied term".into());
}
if deny
.pairs
.iter()
.any(|(a, b)| has_word(&text, a) && has_word(&text, b))
{
out.push("checks: the draft carries a denied pair".into());
}
if let Some(hit) = attribution.iter().find(|p| has_word(&text, p)) {
out.push(format!("checks: the draft carries attribution ({hit})"));
}
for name in ["x-mailer", "user-agent"] {
for value in values(name) {
let folded = fold(value);
if AGENT_MAILER.iter().any(|w| has_word(&folded, w)) {
out.push(format!(
"checks: {name} names a tool, not a person's mailer"
));
}
}
}
out
}
fn plain_text_only(headers: &[(String, String)], body: &str) -> Vec<String> {
let mut out = Vec::new();
let compact = |v: &str| -> String {
v.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_lowercase()
};
let all = |name: &str| -> Vec<String> {
headers
.iter()
.filter(|(n, _)| n == name)
.map(|(_, v)| compact(v))
.collect()
};
let types = all("content-type");
if types.len() != 1
|| !matches!(
types[0].as_str(),
"text/plain;charset=utf-8" | "text/plain;charset=\"utf-8\""
)
{
out.push("checks: the draft must be one text/plain; charset=utf-8 part".into());
}
let encodings = all("content-transfer-encoding");
if encodings.len() > 1 || encodings.iter().any(|e| e != "7bit" && e != "8bit") {
out.push("checks: encoded text cannot be scanned; send it as 7bit or 8bit UTF-8".into());
}
if body.contains("=?") || headers.iter().any(|(_, v)| v.contains("=?")) {
out.push("checks: encoded words cannot be scanned; send plain UTF-8".into());
}
if has_markup(body) {
out.push("checks: the body carries HTML tags or entities".into());
}
out
}
fn has_markup(body: &str) -> bool {
let bytes = body.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
let next = bytes.get(i + 1).copied().unwrap_or(0);
match b {
b'<' => next.is_ascii_alphabetic() || matches!(next, b'/' | b'!' | b'?'),
b'&' => {
let name: Vec<u8> = bytes[i + 1..]
.iter()
.copied()
.take_while(|c| c.is_ascii_alphanumeric() || *c == b'#')
.collect();
!name.is_empty() && bytes.get(i + 1 + name.len()) == Some(&b';')
}
_ => false,
}
})
}
pub(crate) fn unfold(head: &str) -> Option<Vec<(String, String)>> {
let mut headers: Vec<(String, String)> = Vec::new();
for line in head.lines() {
if line.starts_with([' ', '\t']) {
headers.last_mut()?.1.push_str(line);
} else {
let (name, value) = line.split_once(':')?;
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_graphic()) {
return None;
}
headers.push((name.to_ascii_lowercase(), value.to_string()));
}
}
for (_, value) in &mut headers {
*value = value.trim().to_string();
}
Some(headers)
}
fn addresses(value: &str) -> Vec<String> {
let mut out = Vec::new();
let (mut quoted, mut angle, mut item) = (false, false, String::new());
for c in value.chars().chain(std::iter::once(',')) {
match c {
'"' if !angle => quoted = !quoted,
'<' if !quoted => angle = true,
'>' if !quoted => angle = false,
',' if !quoted && !angle => {
let raw = std::mem::take(&mut item);
let address = match (raw.find('<'), raw.rfind('>')) {
(Some(a), Some(b)) if a < b => raw[a + 1..b].to_string(),
_ => raw.trim().to_string(),
};
let address = fold(address.trim());
if !address.is_empty() {
out.push(address);
}
continue;
}
_ => {}
}
item.push(c);
}
out
}
fn fold(text: &str) -> String {
let visible: String = text
.chars()
.filter(|c| {
!matches!(c,
'\u{00AD}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
})
.collect();
visible.nfkc().collect::<String>().to_lowercase()
}
fn has_word(hay: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
hay.match_indices(needle).any(|(at, _)| {
let before = hay[..at].chars().next_back();
let after = hay[at + needle.len()..].chars().next();
!before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric)
})
}