Skip to main content

smtp_test_tool/
diagnostics.rs

1//! Translates raw server replies into human-readable, IT-actionable hints.
2//!
3//! Tables ported verbatim from the Python `email_tester.py` so output stays
4//! interchangeable between the two implementations.
5
6use once_cell::sync::Lazy;
7use std::collections::HashMap;
8
9/// (short_explanation, remediation_hint)
10pub type Hint = (&'static str, &'static str);
11
12/// SMTP enhanced status codes ("5.7.139", "5.7.60", ...) -> Hint.
13pub static SMTP_ENHANCED: Lazy<HashMap<&'static str, Hint>> = Lazy::new(|| {
14    let mut m = HashMap::new();
15    // 5.x.x permanent failures
16    m.insert(
17        "5.7.0",
18        (
19            "Authentication required, or chosen AUTH mechanism not permitted.",
20            "Enable SMTP AUTH for the mailbox; verify LOGIN / PLAIN / XOAUTH2 is allowed.",
21        ),
22    );
23    m.insert("5.7.1",   ("Relay access denied - server refuses to forward this message.",
24                          "Either the sender is not authenticated, the recipient is external and relaying is disabled, or a transport rule is blocking the message."));
25    m.insert("5.7.3",   ("Authentication unsuccessful.",
26                          "Bad password, MFA enabled without app-password, or Basic/Legacy auth disabled on the tenant (O365: 'SMTP AUTH disabled')."));
27    m.insert(
28        "5.7.8",
29        (
30            "Authentication credentials invalid.",
31            "Username/password rejected by the SASL layer.",
32        ),
33    );
34    m.insert("5.7.57",  ("Client was not authenticated to send anonymous mail during MAIL FROM.",
35                          "Force STARTTLS + AUTH before MAIL FROM. In O365 this is the typical error when the client connects without AUTH."));
36    m.insert("5.7.60",  ("SendAsDenied - authenticated user is not allowed to send AS this From: address.",
37                          "Grant the mailbox 'Send As' (or 'Send on Behalf') rights to the authenticated account, or change the From: header to match the login."));
38    m.insert(
39        "5.7.64",
40        (
41            "TenantAttribution; Relay Access Denied.",
42            "Authenticated SMTP submission requires a licensed mailbox in O365.",
43        ),
44    );
45    m.insert(
46        "5.7.124",
47        (
48            "The user is not authorised to send mail.",
49            "Disabled mailbox, blocked by Conditional Access, or licence missing.",
50        ),
51    );
52    m.insert(
53        "5.7.135",
54        (
55            "Authentication unsuccessful, the user credentials have expired.",
56            "Reset the password / refresh the OAuth token.",
57        ),
58    );
59    m.insert(
60        "5.7.139",
61        (
62            "Authentication unsuccessful, the request did not meet the criteria.",
63            "Conditional Access policy denied the login (location, device, MFA).",
64        ),
65    );
66    m.insert(
67        "5.7.500",
68        (
69            "Access denied, sending domain disabled.",
70            "The sender's domain is blocked for outbound mail on this tenant.",
71        ),
72    );
73    m.insert(
74        "5.7.501",
75        (
76            "Access denied, banned sender.",
77            "Sender address is on a tenant blocklist.",
78        ),
79    );
80    m.insert(
81        "5.7.508",
82        (
83            "Access denied, rate or traffic threshold exceeded.",
84            "Throttled; wait and retry, or ask admin to raise the limit.",
85        ),
86    );
87    m.insert(
88        "5.7.511",
89        (
90            "Access denied, banned sender.",
91            "Sender flagged as spam source.",
92        ),
93    );
94    m.insert(
95        "5.7.606",
96        (
97            "Access denied, banned sending IP.",
98            "The submitting IP is on a Microsoft blocklist.",
99        ),
100    );
101    m.insert(
102        "5.7.708",
103        (
104            "Service refused. Source IP has bad reputation.",
105            "Submit from a different IP or request delisting.",
106        ),
107    );
108    m.insert(
109        "5.7.750",
110        (
111            "Client blocked from sending from unregistered domains.",
112            "Verify the sender domain in the tenant or use an accepted domain.",
113        ),
114    );
115    m.insert(
116        "5.1.0",
117        (
118            "Sender address rejected.",
119            "From/MAIL FROM not accepted; usually format or domain policy.",
120        ),
121    );
122    m.insert(
123        "5.1.1",
124        (
125            "Bad destination mailbox - recipient does not exist.",
126            "Check the recipient address.",
127        ),
128    );
129    m.insert(
130        "5.1.7",
131        (
132            "Invalid sender address (malformed).",
133            "Fix the MAIL FROM syntax.",
134        ),
135    );
136    m.insert(
137        "5.1.8",
138        (
139            "Sender domain not allowed.",
140            "Domain not accepted by the server.",
141        ),
142    );
143    m.insert(
144        "5.1.10",
145        (
146            "Recipient address rejected - user unknown.",
147            "Typo or non-existent recipient.",
148        ),
149    );
150    m.insert(
151        "5.4.1",
152        (
153            "Recipient address rejected: access denied.",
154            "Recipient mailbox refuses messages from this sender / domain.",
155        ),
156    );
157    m.insert(
158        "5.2.1",
159        (
160            "Mailbox disabled, not accepting messages.",
161            "Recipient mailbox suspended.",
162        ),
163    );
164    m.insert("5.2.2", ("Mailbox full.", "Recipient quota exceeded."));
165    m.insert(
166        "5.3.4",
167        ("Message too big for system.", "Reduce message size."),
168    );
169    // 4.x.x temporary failures
170    m.insert(
171        "4.7.0",
172        (
173            "Temporary authentication failure / throttling.",
174            "Try again later; could also be tarpit for repeated bad logins.",
175        ),
176    );
177    m.insert(
178        "4.4.2",
179        (
180            "Connection dropped.",
181            "Network glitch or server restart; retry.",
182        ),
183    );
184    m.insert(
185        "4.3.2",
186        (
187            "System not accepting network messages.",
188            "Server maintenance.",
189        ),
190    );
191    m
192});
193
194/// IMAP error-string substring -> hint.
195pub const IMAP_HINTS: &[(&str, &str)] = &[
196    (
197        "AUTHENTICATIONFAILED",
198        "IMAP login rejected - bad password, MFA without app-password, or Basic Auth disabled.",
199    ),
200    ("LOGIN failed", "IMAP login rejected by server."),
201    (
202        "[ALERT]",
203        "Server returned an ALERT response - read it; admin-defined message.",
204    ),
205    ("[UNAVAILABLE]", "Mailbox/server temporarily unavailable."),
206    (
207        "[PRIVACYREQUIRED]",
208        "Server requires TLS before LOGIN - use STARTTLS or implicit SSL.",
209    ),
210    (
211        "[CLIENTBUG]",
212        "Client did something the server considers wrong; usually missing STARTTLS or wrong state.",
213    ),
214    (
215        "LOGINDISABLED",
216        "Plain LOGIN is disabled on this server - use STARTTLS/SSL or XOAUTH2.",
217    ),
218];
219
220/// Client-side bounce signatures: text that appears in bodies of
221/// delivery-failure notifications generated by webmail providers rather
222/// than in raw SMTP server replies.  We carry these so a user who pastes
223/// a bounce body into `smtp_hints_for` still gets actionable advice.
224///
225/// Each entry is a (substring, hint) pair.  Substring matching is
226/// case-insensitive and language-aware (English + Dutch tested).
227pub const CLIENT_BOUNCE_HINTS: &[(&str, &str)] = &[
228    // Gmail's "Send mail as" failure - both the English original and the
229    // Dutch translation that actually triggered this fixture.  Gmail logs
230    // into the *other* server with the SMTP creds you stored under
231    // Settings > Accounts and Import > Send mail as; when that fails the
232    // bounce blames the stored credentials rather than your inbox.
233    (
234        "Send mail as",
235        "Gmail's 'Send mail as' upstream login failed.  Settings > See all settings > Accounts and Import > Send mail as > Edit info > re-enter the SMTP password (or an app-password if the source account has 2FA).",
236    ),
237    (
238        "Mail sturen als",
239        "Gmail 'Mail sturen als' upstream-login mislukt.  Instellingen > Alle instellingen weergeven > Accounts en import > Mail sturen als > Gegevens bewerken > voer het SMTP-wachtwoord opnieuw in (of een app-wachtwoord als het bronaccount 2FA gebruikt).",
240    ),
241];
242
243pub const POP_HINTS: &[(&str, &str)] = &[
244    (
245        "authentication failed",
246        "POP3 login rejected - bad credentials or POP disabled for this mailbox.",
247    ),
248    ("Logon failure", "POP3 login rejected."),
249    (
250        "not implemented",
251        "Server does not support the issued command.",
252    ),
253    (
254        "disabled",
255        "POP3 access is disabled for this account / tenant.",
256    ),
257];
258
259/// Given any SMTP error message, return the matched enhanced-status hints.
260pub fn smtp_hints_for(msg: &str) -> Vec<String> {
261    let mut out = Vec::new();
262    for esc in extract_enhanced_codes(msg) {
263        if let Some((what, fix)) = SMTP_ENHANCED.get(esc.as_str()) {
264            out.push(format!("  ESC {esc}: {what}"));
265            out.push(format!("  -> Action: {fix}"));
266        }
267    }
268    // Also scan for client-side bounce signatures (Gmail's 'Send mail as'
269    // failure, etc.) - matters when a user pastes a bounce body rather
270    // than a raw server reply.
271    let lower = msg.to_lowercase();
272    for (needle, hint) in CLIENT_BOUNCE_HINTS {
273        if lower.contains(&needle.to_lowercase()) {
274            out.push(format!("  -> {hint}"));
275        }
276    }
277    out
278}
279
280pub fn imap_hints_for(msg: &str) -> Vec<String> {
281    let lower = msg.to_lowercase();
282    IMAP_HINTS
283        .iter()
284        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
285        .map(|(_, hint)| format!("  -> {hint}"))
286        .collect()
287}
288
289pub fn pop_hints_for(msg: &str) -> Vec<String> {
290    let lower = msg.to_lowercase();
291    POP_HINTS
292        .iter()
293        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
294        .map(|(_, hint)| format!("  -> {hint}"))
295        .collect()
296}
297
298/// Find enhanced status codes like "5.7.139" or "4.7.0" inside an arbitrary
299/// server reply.  Tiny regex-free scanner.
300fn extract_enhanced_codes(s: &str) -> Vec<String> {
301    let mut out = Vec::new();
302    let bytes = s.as_bytes();
303    let mut i = 0;
304    while i < bytes.len() {
305        // First char must be '2', '4' or '5' preceded by a word boundary.
306        let prev_is_boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
307        if prev_is_boundary && matches!(bytes[i], b'2' | b'4' | b'5') {
308            // Pattern: D '.' D{1,3} '.' D{1,3} not followed by digit/alnum.
309            let mut j = i + 1;
310            if j < bytes.len() && bytes[j] == b'.' {
311                j += 1;
312                let mid_start = j;
313                while j < bytes.len() && bytes[j].is_ascii_digit() && j - mid_start < 3 {
314                    j += 1;
315                }
316                if j > mid_start && j < bytes.len() && bytes[j] == b'.' {
317                    j += 1;
318                    let tail_start = j;
319                    while j < bytes.len() && bytes[j].is_ascii_digit() && j - tail_start < 3 {
320                        j += 1;
321                    }
322                    if j > tail_start && (j == bytes.len() || !bytes[j].is_ascii_alphanumeric()) {
323                        out.push(s[i..j].to_string());
324                        i = j;
325                        continue;
326                    }
327                }
328            }
329        }
330        i += 1;
331    }
332    out
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    // ---- enhanced-code scanner ------------------------------------
340
341    #[test]
342    fn extracts_codes_with_boundaries() {
343        let v = extract_enhanced_codes("535 5.7.139 Authentication unsuccessful; 4.7.0 throttle");
344        assert_eq!(v, vec!["5.7.139", "4.7.0"]);
345    }
346
347    #[test]
348    fn ignores_version_strings_and_partials() {
349        // 1.x.y is not an enhanced status code (1 is not 2/4/5).
350        assert!(extract_enhanced_codes("running 1.2.3 release").is_empty());
351        // 5.7 alone (no third octet) must not match.
352        assert!(extract_enhanced_codes("see 5.7 spec").is_empty());
353        // Embedded inside a word should not match (must be word-bounded).
354        assert!(extract_enhanced_codes("v5.7.60suffix").is_empty());
355    }
356
357    #[test]
358    fn extracts_at_start_and_end_of_string() {
359        assert_eq!(extract_enhanced_codes("5.1.1"), vec!["5.1.1"]);
360        assert_eq!(extract_enhanced_codes("foo 4.4.2"), vec!["4.4.2"]);
361    }
362
363    // ---- SMTP hint mapping ---------------------------------------
364
365    #[test]
366    fn hint_includes_send_as_for_5_7_60() {
367        let h = smtp_hints_for("550 5.7.60 SendAsDenied");
368        let joined = h.join("\n");
369        assert!(joined.contains("SendAsDenied"));
370        assert!(joined.contains("Send As"));
371        assert!(joined.contains("ESC 5.7.60"));
372    }
373
374    #[test]
375    fn hint_includes_basic_auth_for_5_7_139() {
376        let h = smtp_hints_for(
377            "535 5.7.139 Authentication unsuccessful, basic authentication is disabled",
378        );
379        let joined = h.join("\n");
380        assert!(joined.contains("5.7.139"));
381        assert!(joined.contains("Conditional Access"));
382    }
383
384    #[test]
385    fn hint_for_unknown_code_is_empty() {
386        // A code we don't have a mapping for (yet) should produce no
387        // spurious lines, only the unmatched scanner result.
388        let h = smtp_hints_for("550 5.9.999 Made up code");
389        assert!(h.is_empty(), "expected no hints, got {h:?}");
390    }
391
392    #[test]
393    fn hint_recognises_gmail_send_as_bounce_english() {
394        let h = smtp_hints_for(
395            "You're sending this message from a different address or alias using the 'Send mail as' feature.",
396        );
397        let joined = h.join("\n");
398        assert!(joined.contains("Send mail as"));
399        assert!(joined.contains("Accounts and Import"));
400    }
401
402    #[test]
403    fn hint_recognises_gmail_send_as_bounce_dutch() {
404        // Verbatim text from a real Dutch-locale Gmail bounce.
405        let h = smtp_hints_for(
406            "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.",
407        );
408        let joined = h.join("\n");
409        assert!(joined.contains("Mail sturen als"));
410        assert!(joined.contains("Accounts en import"));
411    }
412
413    #[test]
414    fn hint_collects_multiple_codes_in_one_reply() {
415        let h = smtp_hints_for("550 5.7.60 SendAsDenied; also see 5.1.1 for the recipient");
416        let joined = h.join("\n");
417        assert!(joined.contains("5.7.60"));
418        assert!(joined.contains("5.1.1"));
419    }
420
421    // ---- IMAP hint mapping ---------------------------------------
422
423    #[test]
424    fn imap_hint_for_authenticationfailed() {
425        let h = imap_hints_for("a1 NO [AUTHENTICATIONFAILED] LOGIN failed");
426        assert!(!h.is_empty());
427        assert!(h.iter().any(|s| s.contains("bad password")));
428    }
429
430    #[test]
431    fn imap_hint_for_logindisabled() {
432        let h = imap_hints_for("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
433        assert!(h
434            .iter()
435            .any(|s| s.contains("STARTTLS") || s.contains("XOAUTH2")));
436    }
437
438    // ---- POP hint mapping ----------------------------------------
439
440    #[test]
441    fn pop_hint_for_authentication_failed() {
442        let h = pop_hints_for("-ERR authentication failed");
443        assert!(!h.is_empty());
444        assert!(h
445            .iter()
446            .any(|s| s.contains("POP disabled") || s.contains("bad credentials")));
447    }
448
449    #[test]
450    fn pop_hint_for_disabled() {
451        let h = pop_hints_for("-ERR POP is disabled for this account");
452        assert!(h.iter().any(|s| s.contains("disabled")));
453    }
454}