use std::io::Read as _;
use std::path::Path;
use anyhow::{bail, Context as _, Result};
use icu_properties::{props::DefaultIgnorableCodePoint, CodePointSetData};
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 (mut file, _) = super::config::open_owned(path, "checks: deny_file")?
.with_context(|| format!("checks: the deny file {} does not exist", path.display()))?;
let mut text = String::new();
file.read_to_string(&mut text)
.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(sent_as_is(&message));
out.extend(plain_text_only(&headers, body));
let head = headers
.iter()
.map(|(n, v)| format!("{n}: {v}"))
.collect::<Vec<_>>()
.join("\n");
let views = [body.to_string(), rendered(body)].map(|b| fold(&format!("{head}\n{b}")));
let carries = |t: &String| views.iter().any(|v| has_word(v, t));
if deny
.terms
.iter()
.chain(&deny.addresses)
.chain(&deny.domains)
.chain(&deny.handles)
.any(carries)
{
out.push("checks: the draft carries a denied term".into());
}
if deny
.pairs
.iter()
.any(|(a, b)| views.iter().any(|v| has_word(v, a) && has_word(v, b)))
{
out.push("checks: the draft carries a denied pair".into());
}
if let Some(hit) = attribution.iter().find(|p| carries(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 sent_as_is(message: &str) -> Vec<String> {
let mut out = Vec::new();
if let Some(n) = message.split('\n').position(|line| line.len() > 998) {
out.push(format!(
"checks: line {} is over 998 octets, so the MTA would break it",
n + 1
));
}
if !message.is_ascii() {
out.push("checks: the draft is not ASCII, so an MTA may re-encode it; send 7bit".into());
}
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") {
out.push(
"checks: send 7bit; an encoded body cannot be scanned and an 8bit one may be re-encoded"
.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'<' => 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,
}
})
}
fn rendered(body: &str) -> String {
let mut text = String::with_capacity(body.len());
let mut rest = body;
while let Some(lt) = rest.find('<') {
text.push_str(&rest[..lt]);
rest = &rest[lt..];
let Some(gt) = rest.find('>') else { break };
rest = &rest[gt + 1..];
}
text.push_str(rest);
let mut out = String::with_capacity(text.len());
let mut rest = text.as_str();
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
rest = &rest[amp + 1..];
let (c, len) = reference(rest).unwrap_or(('&', 0));
out.push(c);
rest = &rest[len..];
}
out.push_str(rest);
out
}
fn reference(s: &str) -> Option<(char, usize)> {
const NAMES: &[(&str, char)] = &[
("amp", '&'),
("lt", '<'),
("gt", '>'),
("quot", '"'),
("apos", '\''),
("nbsp", '\u{a0}'),
];
let semi = |s: &str| usize::from(s.starts_with(';'));
let Some(n) = s.strip_prefix('#') else {
let (name, c) = NAMES.iter().find(|(name, _)| {
s.get(..name.len())
.is_some_and(|p| p.eq_ignore_ascii_case(name))
})?;
return Some((*c, name.len() + semi(&s[name.len()..])));
};
let (digits, radix) = match n.strip_prefix(['x', 'X']) {
Some(hex) => (hex, 16),
None => (n, 10),
};
let run = digits
.find(|c: char| !c.is_digit(radix))
.unwrap_or(digits.len());
if run == 0 {
return None;
}
let c = u32::from_str_radix(&digits[..run], radix)
.ok()
.and_then(char::from_u32)
.filter(|&c| c != '\0')
.unwrap_or('\u{FFFD}');
Some((c, s.len() - digits.len() + run + semi(&digits[run..])))
}
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 ignorable = CodePointSetData::new::<DefaultIgnorableCodePoint>();
let visible: String = text.chars().filter(|c| !ignorable.contains(*c)).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)
})
}