use crate::i18n::{t, t_with};
#[cfg(test)]
const SMTP_CODES: &[&str] = &[
"5.7.0", "5.7.1", "5.7.3", "5.7.8", "5.7.57", "5.7.60", "5.7.64", "5.7.124", "5.7.135",
"5.7.139", "5.7.500", "5.7.501", "5.7.508", "5.7.511", "5.7.606", "5.7.708", "5.7.750",
"5.1.0", "5.1.1", "5.1.7", "5.1.8", "5.1.10", "5.4.1", "5.2.1", "5.2.2", "5.3.4",
"4.7.0", "4.4.2", "4.3.2",
];
const IMAP_NEEDLES: &[(&str, &str)] = &[
("AUTHENTICATIONFAILED", "AUTHENTICATIONFAILED"),
("LOGIN failed", "LOGIN_failed"),
("[ALERT]", "ALERT"),
("[UNAVAILABLE]", "UNAVAILABLE"),
("[PRIVACYREQUIRED]", "PRIVACYREQUIRED"),
("[CLIENTBUG]", "CLIENTBUG"),
("LOGINDISABLED", "LOGINDISABLED"),
];
const POP_NEEDLES: &[(&str, &str)] = &[
("authentication failed", "authentication_failed"),
("Logon failure", "logon_failure"),
("not implemented", "not_implemented"),
("disabled", "disabled"),
];
const BOUNCE_KEYS: &[&str] = &["gmail_send_as_en", "gmail_send_as_nl"];
pub fn smtp_hints_for(msg: &str) -> Vec<String> {
let mut out = Vec::new();
for esc in extract_enhanced_codes(msg) {
let safe = esc.replace('.', "_");
let what_key = format!("diagnostics.smtp.esc.{safe}.what");
let fix_key = format!("diagnostics.smtp.esc.{safe}.fix");
let what = t(&what_key);
let fix = t(&fix_key);
if what != what_key {
out.push(t_with(
"diagnostics.scaffold.esc_prefix",
&[("code", &esc), ("what", &what)],
));
out.push(t_with(
"diagnostics.scaffold.action_prefix",
&[("fix", &fix)],
));
}
}
let lower = msg.to_lowercase();
for key in BOUNCE_KEYS {
let needle_key = format!("diagnostics.bounce.{key}.needle");
let hint_key = format!("diagnostics.bounce.{key}.hint");
let needle = t(&needle_key);
if needle == needle_key {
continue; }
if lower.contains(&needle.to_lowercase()) {
out.push(t_with(
"diagnostics.scaffold.hint_prefix",
&[("hint", &t(&hint_key))],
));
}
}
out
}
pub fn imap_hints_for(msg: &str) -> Vec<String> {
let lower = msg.to_lowercase();
IMAP_NEEDLES
.iter()
.filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
.map(|(_, key)| {
t_with(
"diagnostics.scaffold.hint_prefix",
&[("hint", &t(&format!("diagnostics.imap.{key}.hint")))],
)
})
.collect()
}
pub fn pop_hints_for(msg: &str) -> Vec<String> {
let lower = msg.to_lowercase();
POP_NEEDLES
.iter()
.filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
.map(|(_, key)| {
t_with(
"diagnostics.scaffold.hint_prefix",
&[("hint", &t(&format!("diagnostics.pop.{key}.hint")))],
)
})
.collect()
}
fn extract_enhanced_codes(s: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let prev_is_boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
if prev_is_boundary && matches!(bytes[i], b'2' | b'4' | b'5') {
let mut j = i + 1;
if j < bytes.len() && bytes[j] == b'.' {
j += 1;
let mid_start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() && j - mid_start < 3 {
j += 1;
}
if j > mid_start && j < bytes.len() && bytes[j] == b'.' {
j += 1;
let tail_start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() && j - tail_start < 3 {
j += 1;
}
if j > tail_start && (j == bytes.len() || !bytes[j].is_ascii_alphanumeric()) {
out.push(s[i..j].to_string());
i = j;
continue;
}
}
}
}
i += 1;
}
out
}
#[cfg(test)]
fn smtp_code_count() -> usize {
SMTP_CODES.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::testing::LocaleTestGuard;
#[test]
fn extracts_codes_with_boundaries() {
let v = extract_enhanced_codes("535 5.7.139 Authentication unsuccessful; 4.7.0 throttle");
assert_eq!(v, vec!["5.7.139", "4.7.0"]);
}
#[test]
fn ignores_version_strings_and_partials() {
assert!(extract_enhanced_codes("running 1.2.3 release").is_empty());
assert!(extract_enhanced_codes("see 5.7 spec").is_empty());
assert!(extract_enhanced_codes("v5.7.60suffix").is_empty());
}
#[test]
fn extracts_at_start_and_end_of_string() {
assert_eq!(extract_enhanced_codes("5.1.1"), vec!["5.1.1"]);
assert_eq!(extract_enhanced_codes("foo 4.4.2"), vec!["4.4.2"]);
}
#[test]
fn we_track_every_documented_smtp_code() {
assert!(smtp_code_count() >= 25);
}
#[test]
fn hint_includes_send_as_for_5_7_60() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for("550 5.7.60 SendAsDenied");
let joined = h.join("\n");
assert!(joined.contains("SendAsDenied"));
assert!(joined.contains("Send As"));
assert!(joined.contains("ESC 5.7.60"));
}
#[test]
fn hint_includes_basic_auth_for_5_7_139() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for(
"535 5.7.139 Authentication unsuccessful, basic authentication is disabled",
);
let joined = h.join("\n");
assert!(joined.contains("5.7.139"));
assert!(joined.contains("Conditional Access"));
}
#[test]
fn hint_for_unknown_code_is_empty() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for("550 5.9.999 Made up code");
assert!(h.is_empty(), "expected no hints, got {h:?}");
}
#[test]
fn hint_collects_multiple_codes_in_one_reply() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for("550 5.7.60 SendAsDenied; also see 5.1.1 for the recipient");
let joined = h.join("\n");
assert!(joined.contains("5.7.60"));
assert!(joined.contains("5.1.1"));
}
#[test]
fn imap_hint_for_authenticationfailed() {
let _g = LocaleTestGuard::set("en");
let h = imap_hints_for("a1 NO [AUTHENTICATIONFAILED] LOGIN failed");
assert!(!h.is_empty());
assert!(h.iter().any(|s| s.contains("bad password")));
}
#[test]
fn imap_hint_for_logindisabled() {
let _g = LocaleTestGuard::set("en");
let h = imap_hints_for("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
assert!(h
.iter()
.any(|s| s.contains("STARTTLS") || s.contains("XOAUTH2")));
}
#[test]
fn pop_hint_for_authentication_failed() {
let _g = LocaleTestGuard::set("en");
let h = pop_hints_for("-ERR authentication failed");
assert!(!h.is_empty());
assert!(h
.iter()
.any(|s| s.contains("POP disabled") || s.contains("bad credentials")));
}
#[test]
fn pop_hint_for_disabled() {
let _g = LocaleTestGuard::set("en");
let h = pop_hints_for("-ERR POP is disabled for this account");
assert!(h.iter().any(|s| s.contains("disabled")));
}
#[test]
fn hint_recognises_gmail_send_as_bounce_english() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for(
"You're sending this message from a different address or alias using the 'Send mail as' feature.",
);
let joined = h.join("\n");
assert!(joined.contains("Send mail as"));
assert!(joined.contains("Accounts and Import"));
}
#[test]
fn hint_recognises_gmail_send_as_bounce_dutch() {
let _g = LocaleTestGuard::set("en");
let h = smtp_hints_for(
"Je verzendt dit bericht vanaf een ander adres of een alias met de functie 'Mail sturen als'. De instellingen voor het account dat je gebruikt voor 'Mail sturen als' zijn niet correct of zijn verouderd.",
);
let joined = h.join("\n");
assert!(joined.contains("Mail sturen als"));
assert!(joined.contains("Accounts and Import"));
}
#[test]
fn hint_text_switches_to_dutch_when_locale_changes() {
let _g = LocaleTestGuard::set("nl");
let h = smtp_hints_for("535 5.7.139 Authentication unsuccessful");
let joined = h.join("\n");
assert!(
joined.contains("Conditional Access-beleid") || joined.contains("Conditional Access"),
"expected Dutch hint, got:\n{joined}"
);
assert!(
joined.contains("Actie:"),
"expected Dutch action prefix, got:\n{joined}"
);
}
#[test]
fn unsupported_locale_falls_back_to_english_hints() {
let _g = LocaleTestGuard::set("xx-zz"); let h = smtp_hints_for("550 5.7.60 SendAsDenied");
let joined = h.join("\n");
assert!(joined.contains("Send As"));
assert!(joined.contains("Action:"));
}
}