Skip to main content

smtp_test_tool/
diagnostics.rs

1//! Translates raw server replies into human-readable, IT-actionable hints.
2//!
3//! As of v0.1.3 the actual strings live in `locales/<code>.toml` and are
4//! looked up via the [`crate::i18n`] module.  This file is now the
5//! *router* — it knows which keys exist and which substrings to match
6//! — but says nothing in any particular language.  Translators add a
7//! new locale by adding a TOML file under `locales/`; no Rust change
8//! is needed unless we want to add a whole new diagnostic code.
9
10use crate::i18n::{t, t_with};
11
12// ============================================================================
13// SMTP enhanced status codes (5.x.x permanent, 4.x.x temporary)
14//
15// Translations are looked up dynamically at runtime via the i18n module,
16// so production code does NOT need a Rust-side list of which codes are
17// recognised - smtp_hints_for() just tries the key and treats the
18// literal-key fallback as 'no translation, skip this code'.  The list
19// below is kept solely for the 'we know about every documented code'
20// regression test and as documentation of what ships translated; the
21// `#[cfg(test)]` gate keeps it out of release binaries.
22// Keep in sync with `locales/en.toml`'s `[diagnostics.smtp.esc.*]`.
23// ============================================================================
24#[cfg(test)]
25const SMTP_CODES: &[&str] = &[
26    // 5.x.x permanent failures
27    "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",
28    "5.7.139", "5.7.500", "5.7.501", "5.7.508", "5.7.511", "5.7.606", "5.7.708", "5.7.750",
29    "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",
30    // 4.x.x temporary failures
31    "4.7.0", "4.4.2", "4.3.2",
32];
33
34// IMAP needle -> `diagnostics.imap.<KEY>.hint`.  Substring match against
35// the server reply, case-insensitive.  Order matters only for documentation
36// readability; the matcher runs through all of them.
37const IMAP_NEEDLES: &[(&str, &str)] = &[
38    ("AUTHENTICATIONFAILED", "AUTHENTICATIONFAILED"),
39    ("LOGIN failed", "LOGIN_failed"),
40    ("[ALERT]", "ALERT"),
41    ("[UNAVAILABLE]", "UNAVAILABLE"),
42    ("[PRIVACYREQUIRED]", "PRIVACYREQUIRED"),
43    ("[CLIENTBUG]", "CLIENTBUG"),
44    ("LOGINDISABLED", "LOGINDISABLED"),
45];
46
47// POP3 needle -> `diagnostics.pop.<KEY>.hint`.
48const POP_NEEDLES: &[(&str, &str)] = &[
49    ("authentication failed", "authentication_failed"),
50    ("Logon failure", "logon_failure"),
51    ("not implemented", "not_implemented"),
52    ("disabled", "disabled"),
53];
54
55// Client-side bounce fixtures (Gmail "Send mail as", ...).  Each entry's
56// `needle` is the substring to match in the bounce body, looked up at
57// `diagnostics.bounce.<KEY>.needle`; the corresponding hint is
58// `diagnostics.bounce.<KEY>.hint`.  Listing them by key here means the
59// scanner stays language-agnostic - whatever languages the active locale
60// shipped, that's what we look for.
61const BOUNCE_KEYS: &[&str] = &["gmail_send_as_en", "gmail_send_as_nl"];
62
63/// Given any SMTP error message, return matched enhanced-status hints
64/// plus any client-side bounce-body hints.
65pub fn smtp_hints_for(msg: &str) -> Vec<String> {
66    let mut out = Vec::new();
67
68    // Enhanced status codes appear as the second token in a reply
69    // ('535 5.7.139 ...').  Scan everything because some servers
70    // re-quote them in long-form text.
71    for esc in extract_enhanced_codes(msg) {
72        let safe = esc.replace('.', "_");
73        let what_key = format!("diagnostics.smtp.esc.{safe}.what");
74        let fix_key = format!("diagnostics.smtp.esc.{safe}.fix");
75        let what = t(&what_key);
76        let fix = t(&fix_key);
77        // If the key fell back to the literal dotted string, the code
78        // is not one we recognise; skip it instead of printing the
79        // dotted-key gibberish to the user.
80        if what != what_key {
81            out.push(t_with(
82                "diagnostics.scaffold.esc_prefix",
83                &[("code", &esc), ("what", &what)],
84            ));
85            out.push(t_with(
86                "diagnostics.scaffold.action_prefix",
87                &[("fix", &fix)],
88            ));
89        }
90    }
91
92    // Client-side bounce body scan (Gmail Send-mail-as, etc.).
93    // We pull the needle from the ACTIVE locale's TOML, but it falls
94    // back to en, so a Dutch user pasting a Dutch Gmail bounce still
95    // gets the hint even when running under a different locale.
96    let lower = msg.to_lowercase();
97    for key in BOUNCE_KEYS {
98        let needle_key = format!("diagnostics.bounce.{key}.needle");
99        let hint_key = format!("diagnostics.bounce.{key}.hint");
100        let needle = t(&needle_key);
101        if needle == needle_key {
102            continue; // bounce key not configured in any locale
103        }
104        if lower.contains(&needle.to_lowercase()) {
105            out.push(t_with(
106                "diagnostics.scaffold.hint_prefix",
107                &[("hint", &t(&hint_key))],
108            ));
109        }
110    }
111
112    out
113}
114
115pub fn imap_hints_for(msg: &str) -> Vec<String> {
116    let lower = msg.to_lowercase();
117    IMAP_NEEDLES
118        .iter()
119        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
120        .map(|(_, key)| {
121            t_with(
122                "diagnostics.scaffold.hint_prefix",
123                &[("hint", &t(&format!("diagnostics.imap.{key}.hint")))],
124            )
125        })
126        .collect()
127}
128
129pub fn pop_hints_for(msg: &str) -> Vec<String> {
130    let lower = msg.to_lowercase();
131    POP_NEEDLES
132        .iter()
133        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
134        .map(|(_, key)| {
135            t_with(
136                "diagnostics.scaffold.hint_prefix",
137                &[("hint", &t(&format!("diagnostics.pop.{key}.hint")))],
138            )
139        })
140        .collect()
141}
142
143/// Find enhanced status codes like "5.7.139" or "4.7.0" inside an arbitrary
144/// server reply.  Tiny regex-free scanner.
145fn extract_enhanced_codes(s: &str) -> Vec<String> {
146    let mut out = Vec::new();
147    let bytes = s.as_bytes();
148    let mut i = 0;
149    while i < bytes.len() {
150        let prev_is_boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
151        if prev_is_boundary && matches!(bytes[i], b'2' | b'4' | b'5') {
152            let mut j = i + 1;
153            if j < bytes.len() && bytes[j] == b'.' {
154                j += 1;
155                let mid_start = j;
156                while j < bytes.len() && bytes[j].is_ascii_digit() && j - mid_start < 3 {
157                    j += 1;
158                }
159                if j > mid_start && j < bytes.len() && bytes[j] == b'.' {
160                    j += 1;
161                    let tail_start = j;
162                    while j < bytes.len() && bytes[j].is_ascii_digit() && j - tail_start < 3 {
163                        j += 1;
164                    }
165                    if j > tail_start && (j == bytes.len() || !bytes[j].is_ascii_alphanumeric()) {
166                        out.push(s[i..j].to_string());
167                        i = j;
168                        continue;
169                    }
170                }
171            }
172        }
173        i += 1;
174    }
175    out
176}
177
178/// Exposed for tests: number of SMTP codes we know how to translate.
179#[cfg(test)]
180fn smtp_code_count() -> usize {
181    SMTP_CODES.len()
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::i18n::testing::LocaleTestGuard;
188
189    // ---- enhanced-code scanner ------------------------------------
190
191    #[test]
192    fn extracts_codes_with_boundaries() {
193        let v = extract_enhanced_codes("535 5.7.139 Authentication unsuccessful; 4.7.0 throttle");
194        assert_eq!(v, vec!["5.7.139", "4.7.0"]);
195    }
196
197    #[test]
198    fn ignores_version_strings_and_partials() {
199        assert!(extract_enhanced_codes("running 1.2.3 release").is_empty());
200        assert!(extract_enhanced_codes("see 5.7 spec").is_empty());
201        assert!(extract_enhanced_codes("v5.7.60suffix").is_empty());
202    }
203
204    #[test]
205    fn extracts_at_start_and_end_of_string() {
206        assert_eq!(extract_enhanced_codes("5.1.1"), vec!["5.1.1"]);
207        assert_eq!(extract_enhanced_codes("foo 4.4.2"), vec!["4.4.2"]);
208    }
209
210    #[test]
211    fn we_track_every_documented_smtp_code() {
212        // Quick sanity: if we add an enhanced code to en.toml, also add
213        // it to SMTP_CODES (so the scanner notices it in real replies).
214        // 29 currently - bump if you legitimately add more.
215        assert!(smtp_code_count() >= 25);
216    }
217
218    // ---- SMTP hint mapping (English locale) -----------------------
219
220    #[test]
221    fn hint_includes_send_as_for_5_7_60() {
222        let _g = LocaleTestGuard::set("en");
223        let h = smtp_hints_for("550 5.7.60 SendAsDenied");
224        let joined = h.join("\n");
225        assert!(joined.contains("SendAsDenied"));
226        assert!(joined.contains("Send As"));
227        assert!(joined.contains("ESC 5.7.60"));
228    }
229
230    #[test]
231    fn hint_includes_basic_auth_for_5_7_139() {
232        let _g = LocaleTestGuard::set("en");
233        let h = smtp_hints_for(
234            "535 5.7.139 Authentication unsuccessful, basic authentication is disabled",
235        );
236        let joined = h.join("\n");
237        assert!(joined.contains("5.7.139"));
238        assert!(joined.contains("Conditional Access"));
239    }
240
241    #[test]
242    fn hint_for_unknown_code_is_empty() {
243        let _g = LocaleTestGuard::set("en");
244        let h = smtp_hints_for("550 5.9.999 Made up code");
245        assert!(h.is_empty(), "expected no hints, got {h:?}");
246    }
247
248    #[test]
249    fn hint_collects_multiple_codes_in_one_reply() {
250        let _g = LocaleTestGuard::set("en");
251        let h = smtp_hints_for("550 5.7.60 SendAsDenied; also see 5.1.1 for the recipient");
252        let joined = h.join("\n");
253        assert!(joined.contains("5.7.60"));
254        assert!(joined.contains("5.1.1"));
255    }
256
257    // ---- IMAP hint mapping ---------------------------------------
258
259    #[test]
260    fn imap_hint_for_authenticationfailed() {
261        let _g = LocaleTestGuard::set("en");
262        let h = imap_hints_for("a1 NO [AUTHENTICATIONFAILED] LOGIN failed");
263        assert!(!h.is_empty());
264        assert!(h.iter().any(|s| s.contains("bad password")));
265    }
266
267    #[test]
268    fn imap_hint_for_logindisabled() {
269        let _g = LocaleTestGuard::set("en");
270        let h = imap_hints_for("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
271        assert!(h
272            .iter()
273            .any(|s| s.contains("STARTTLS") || s.contains("XOAUTH2")));
274    }
275
276    // ---- POP hint mapping ----------------------------------------
277
278    #[test]
279    fn pop_hint_for_authentication_failed() {
280        let _g = LocaleTestGuard::set("en");
281        let h = pop_hints_for("-ERR authentication failed");
282        assert!(!h.is_empty());
283        assert!(h
284            .iter()
285            .any(|s| s.contains("POP disabled") || s.contains("bad credentials")));
286    }
287
288    #[test]
289    fn pop_hint_for_disabled() {
290        let _g = LocaleTestGuard::set("en");
291        let h = pop_hints_for("-ERR POP is disabled for this account");
292        assert!(h.iter().any(|s| s.contains("disabled")));
293    }
294
295    // ---- Client-side bounce signatures ---------------------------
296
297    #[test]
298    fn hint_recognises_gmail_send_as_bounce_english() {
299        let _g = LocaleTestGuard::set("en");
300        let h = smtp_hints_for(
301            "You're sending this message from a different address or alias using the 'Send mail as' feature.",
302        );
303        let joined = h.join("\n");
304        assert!(joined.contains("Send mail as"));
305        assert!(joined.contains("Accounts and Import"));
306    }
307
308    #[test]
309    fn hint_recognises_gmail_send_as_bounce_dutch() {
310        let _g = LocaleTestGuard::set("en");
311        let h = smtp_hints_for(
312            "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.",
313        );
314        let joined = h.join("\n");
315        assert!(joined.contains("Mail sturen als"));
316        assert!(joined.contains("Accounts and Import"));
317    }
318
319    // ---- Locale-switching ----------------------------------------
320    // Same code, different language - proves the i18n integration is
321    // live and not a no-op.
322
323    #[test]
324    fn hint_text_switches_to_dutch_when_locale_changes() {
325        let _g = LocaleTestGuard::set("nl");
326        let h = smtp_hints_for("535 5.7.139 Authentication unsuccessful");
327        let joined = h.join("\n");
328        // Dutch nl.toml's 5.7.139.fix mentions "Conditional Access-beleid".
329        assert!(
330            joined.contains("Conditional Access-beleid") || joined.contains("Conditional Access"),
331            "expected Dutch hint, got:\n{joined}"
332        );
333        // And the prefix is in Dutch too: "Actie:" not "Action:".
334        assert!(
335            joined.contains("Actie:"),
336            "expected Dutch action prefix, got:\n{joined}"
337        );
338    }
339
340    #[test]
341    fn unsupported_locale_falls_back_to_english_hints() {
342        let _g = LocaleTestGuard::set("xx-zz"); // not shipped
343        let h = smtp_hints_for("550 5.7.60 SendAsDenied");
344        let joined = h.join("\n");
345        // i18n::set_locale silently switches to BASE on unsupported.
346        assert!(joined.contains("Send As"));
347        assert!(joined.contains("Action:"));
348    }
349}