smtp-test-tool 0.1.2

Cross-platform SMTP / IMAP / POP3 connectivity tester with IT-actionable diagnostics.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! Translates raw server replies into human-readable, IT-actionable hints.
//!
//! Tables ported verbatim from the Python `email_tester.py` so output stays
//! interchangeable between the two implementations.

use once_cell::sync::Lazy;
use std::collections::HashMap;

/// (short_explanation, remediation_hint)
pub type Hint = (&'static str, &'static str);

/// SMTP enhanced status codes ("5.7.139", "5.7.60", ...) -> Hint.
pub static SMTP_ENHANCED: Lazy<HashMap<&'static str, Hint>> = Lazy::new(|| {
    let mut m = HashMap::new();
    // 5.x.x permanent failures
    m.insert(
        "5.7.0",
        (
            "Authentication required, or chosen AUTH mechanism not permitted.",
            "Enable SMTP AUTH for the mailbox; verify LOGIN / PLAIN / XOAUTH2 is allowed.",
        ),
    );
    m.insert("5.7.1",   ("Relay access denied - server refuses to forward this message.",
                          "Either the sender is not authenticated, the recipient is external and relaying is disabled, or a transport rule is blocking the message."));
    m.insert("5.7.3",   ("Authentication unsuccessful.",
                          "Bad password, MFA enabled without app-password, or Basic/Legacy auth disabled on the tenant (O365: 'SMTP AUTH disabled')."));
    m.insert(
        "5.7.8",
        (
            "Authentication credentials invalid.",
            "Username/password rejected by the SASL layer.",
        ),
    );
    m.insert("5.7.57",  ("Client was not authenticated to send anonymous mail during MAIL FROM.",
                          "Force STARTTLS + AUTH before MAIL FROM. In O365 this is the typical error when the client connects without AUTH."));
    m.insert("5.7.60",  ("SendAsDenied - authenticated user is not allowed to send AS this From: address.",
                          "Grant the mailbox 'Send As' (or 'Send on Behalf') rights to the authenticated account, or change the From: header to match the login."));
    m.insert(
        "5.7.64",
        (
            "TenantAttribution; Relay Access Denied.",
            "Authenticated SMTP submission requires a licensed mailbox in O365.",
        ),
    );
    m.insert(
        "5.7.124",
        (
            "The user is not authorised to send mail.",
            "Disabled mailbox, blocked by Conditional Access, or licence missing.",
        ),
    );
    m.insert(
        "5.7.135",
        (
            "Authentication unsuccessful, the user credentials have expired.",
            "Reset the password / refresh the OAuth token.",
        ),
    );
    m.insert(
        "5.7.139",
        (
            "Authentication unsuccessful, the request did not meet the criteria.",
            "Conditional Access policy denied the login (location, device, MFA).",
        ),
    );
    m.insert(
        "5.7.500",
        (
            "Access denied, sending domain disabled.",
            "The sender's domain is blocked for outbound mail on this tenant.",
        ),
    );
    m.insert(
        "5.7.501",
        (
            "Access denied, banned sender.",
            "Sender address is on a tenant blocklist.",
        ),
    );
    m.insert(
        "5.7.508",
        (
            "Access denied, rate or traffic threshold exceeded.",
            "Throttled; wait and retry, or ask admin to raise the limit.",
        ),
    );
    m.insert(
        "5.7.511",
        (
            "Access denied, banned sender.",
            "Sender flagged as spam source.",
        ),
    );
    m.insert(
        "5.7.606",
        (
            "Access denied, banned sending IP.",
            "The submitting IP is on a Microsoft blocklist.",
        ),
    );
    m.insert(
        "5.7.708",
        (
            "Service refused. Source IP has bad reputation.",
            "Submit from a different IP or request delisting.",
        ),
    );
    m.insert(
        "5.7.750",
        (
            "Client blocked from sending from unregistered domains.",
            "Verify the sender domain in the tenant or use an accepted domain.",
        ),
    );
    m.insert(
        "5.1.0",
        (
            "Sender address rejected.",
            "From/MAIL FROM not accepted; usually format or domain policy.",
        ),
    );
    m.insert(
        "5.1.1",
        (
            "Bad destination mailbox - recipient does not exist.",
            "Check the recipient address.",
        ),
    );
    m.insert(
        "5.1.7",
        (
            "Invalid sender address (malformed).",
            "Fix the MAIL FROM syntax.",
        ),
    );
    m.insert(
        "5.1.8",
        (
            "Sender domain not allowed.",
            "Domain not accepted by the server.",
        ),
    );
    m.insert(
        "5.1.10",
        (
            "Recipient address rejected - user unknown.",
            "Typo or non-existent recipient.",
        ),
    );
    m.insert(
        "5.4.1",
        (
            "Recipient address rejected: access denied.",
            "Recipient mailbox refuses messages from this sender / domain.",
        ),
    );
    m.insert(
        "5.2.1",
        (
            "Mailbox disabled, not accepting messages.",
            "Recipient mailbox suspended.",
        ),
    );
    m.insert("5.2.2", ("Mailbox full.", "Recipient quota exceeded."));
    m.insert(
        "5.3.4",
        ("Message too big for system.", "Reduce message size."),
    );
    // 4.x.x temporary failures
    m.insert(
        "4.7.0",
        (
            "Temporary authentication failure / throttling.",
            "Try again later; could also be tarpit for repeated bad logins.",
        ),
    );
    m.insert(
        "4.4.2",
        (
            "Connection dropped.",
            "Network glitch or server restart; retry.",
        ),
    );
    m.insert(
        "4.3.2",
        (
            "System not accepting network messages.",
            "Server maintenance.",
        ),
    );
    m
});

/// IMAP error-string substring -> hint.
pub const IMAP_HINTS: &[(&str, &str)] = &[
    (
        "AUTHENTICATIONFAILED",
        "IMAP login rejected - bad password, MFA without app-password, or Basic Auth disabled.",
    ),
    ("LOGIN failed", "IMAP login rejected by server."),
    (
        "[ALERT]",
        "Server returned an ALERT response - read it; admin-defined message.",
    ),
    ("[UNAVAILABLE]", "Mailbox/server temporarily unavailable."),
    (
        "[PRIVACYREQUIRED]",
        "Server requires TLS before LOGIN - use STARTTLS or implicit SSL.",
    ),
    (
        "[CLIENTBUG]",
        "Client did something the server considers wrong; usually missing STARTTLS or wrong state.",
    ),
    (
        "LOGINDISABLED",
        "Plain LOGIN is disabled on this server - use STARTTLS/SSL or XOAUTH2.",
    ),
];

/// Client-side bounce signatures: text that appears in bodies of
/// delivery-failure notifications generated by webmail providers rather
/// than in raw SMTP server replies.  We carry these so a user who pastes
/// a bounce body into `smtp_hints_for` still gets actionable advice.
///
/// Each entry is a (substring, hint) pair.  Substring matching is
/// case-insensitive and language-aware (English + Dutch tested).
pub const CLIENT_BOUNCE_HINTS: &[(&str, &str)] = &[
    // Gmail's "Send mail as" failure - both the English original and the
    // Dutch translation that actually triggered this fixture.  Gmail logs
    // into the *other* server with the SMTP creds you stored under
    // Settings > Accounts and Import > Send mail as; when that fails the
    // bounce blames the stored credentials rather than your inbox.
    (
        "Send mail as",
        "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).",
    ),
    (
        "Mail sturen als",
        "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).",
    ),
];

pub const POP_HINTS: &[(&str, &str)] = &[
    (
        "authentication failed",
        "POP3 login rejected - bad credentials or POP disabled for this mailbox.",
    ),
    ("Logon failure", "POP3 login rejected."),
    (
        "not implemented",
        "Server does not support the issued command.",
    ),
    (
        "disabled",
        "POP3 access is disabled for this account / tenant.",
    ),
];

/// Given any SMTP error message, return the matched enhanced-status hints.
pub fn smtp_hints_for(msg: &str) -> Vec<String> {
    let mut out = Vec::new();
    for esc in extract_enhanced_codes(msg) {
        if let Some((what, fix)) = SMTP_ENHANCED.get(esc.as_str()) {
            out.push(format!("  ESC {esc}: {what}"));
            out.push(format!("  -> Action: {fix}"));
        }
    }
    // Also scan for client-side bounce signatures (Gmail's 'Send mail as'
    // failure, etc.) - matters when a user pastes a bounce body rather
    // than a raw server reply.
    let lower = msg.to_lowercase();
    for (needle, hint) in CLIENT_BOUNCE_HINTS {
        if lower.contains(&needle.to_lowercase()) {
            out.push(format!("  -> {hint}"));
        }
    }
    out
}

pub fn imap_hints_for(msg: &str) -> Vec<String> {
    let lower = msg.to_lowercase();
    IMAP_HINTS
        .iter()
        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
        .map(|(_, hint)| format!("  -> {hint}"))
        .collect()
}

pub fn pop_hints_for(msg: &str) -> Vec<String> {
    let lower = msg.to_lowercase();
    POP_HINTS
        .iter()
        .filter(|(needle, _)| lower.contains(&needle.to_lowercase()))
        .map(|(_, hint)| format!("  -> {hint}"))
        .collect()
}

/// Find enhanced status codes like "5.7.139" or "4.7.0" inside an arbitrary
/// server reply.  Tiny regex-free scanner.
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() {
        // First char must be '2', '4' or '5' preceded by a word boundary.
        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') {
            // Pattern: D '.' D{1,3} '.' D{1,3} not followed by digit/alnum.
            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)]
mod tests {
    use super::*;

    // ---- enhanced-code scanner ------------------------------------

    #[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() {
        // 1.x.y is not an enhanced status code (1 is not 2/4/5).
        assert!(extract_enhanced_codes("running 1.2.3 release").is_empty());
        // 5.7 alone (no third octet) must not match.
        assert!(extract_enhanced_codes("see 5.7 spec").is_empty());
        // Embedded inside a word should not match (must be word-bounded).
        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"]);
    }

    // ---- SMTP hint mapping ---------------------------------------

    #[test]
    fn hint_includes_send_as_for_5_7_60() {
        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 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() {
        // A code we don't have a mapping for (yet) should produce no
        // spurious lines, only the unmatched scanner result.
        let h = smtp_hints_for("550 5.9.999 Made up code");
        assert!(h.is_empty(), "expected no hints, got {h:?}");
    }

    #[test]
    fn hint_recognises_gmail_send_as_bounce_english() {
        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() {
        // Verbatim text from a real Dutch-locale Gmail bounce.
        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 en import"));
    }

    #[test]
    fn hint_collects_multiple_codes_in_one_reply() {
        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"));
    }

    // ---- IMAP hint mapping ---------------------------------------

    #[test]
    fn imap_hint_for_authenticationfailed() {
        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 h = imap_hints_for("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
        assert!(h
            .iter()
            .any(|s| s.contains("STARTTLS") || s.contains("XOAUTH2")));
    }

    // ---- POP hint mapping ----------------------------------------

    #[test]
    fn pop_hint_for_authentication_failed() {
        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 h = pop_hints_for("-ERR POP is disabled for this account");
        assert!(h.iter().any(|s| s.contains("disabled")));
    }
}