1use crate::i18n::{t, t_with};
11
12#[cfg(test)]
25const SMTP_CODES: &[&str] = &[
26 "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.7.0", "4.4.2", "4.3.2",
32];
33
34const 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
47const POP_NEEDLES: &[(&str, &str)] = &[
49 ("authentication failed", "authentication_failed"),
50 ("Logon failure", "logon_failure"),
51 ("not implemented", "not_implemented"),
52 ("disabled", "disabled"),
53];
54
55const BOUNCE_KEYS: &[&str] = &["gmail_send_as_en", "gmail_send_as_nl"];
62
63pub fn smtp_hints_for(msg: &str) -> Vec<String> {
66 let mut out = Vec::new();
67
68 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 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 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; }
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
143fn 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#[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 #[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 assert!(smtp_code_count() >= 25);
216 }
217
218 #[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 #[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 #[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 #[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 #[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 assert!(
330 joined.contains("Conditional Access-beleid") || joined.contains("Conditional Access"),
331 "expected Dutch hint, got:\n{joined}"
332 );
333 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"); let h = smtp_hints_for("550 5.7.60 SendAsDenied");
344 let joined = h.join("\n");
345 assert!(joined.contains("Send As"));
347 assert!(joined.contains("Action:"));
348 }
349}