1use crate::text::{find_word, fold};
23use crate::words::Codes;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CodeKind {
28 Code,
29 Password,
30 PasswordReset,
31 SignInLink,
32 Confirmation,
34}
35
36impl CodeKind {
37 pub fn usual_minutes(self) -> u32 {
42 match self {
43 CodeKind::Code => 30,
44 CodeKind::SignInLink => 60,
45 CodeKind::PasswordReset => 120,
46 CodeKind::Confirmation => 24 * 60,
47 CodeKind::Password => 7 * 24 * 60,
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct OneTimeCode {
55 pub kind: CodeKind,
56 pub code: Option<String>,
58 pub expires_minutes: Option<u32>,
60}
61
62const LONGEST_MINUTES: u32 = 7 * 24 * 60;
66
67impl OneTimeCode {
68 pub fn lasts_minutes(&self) -> u32 {
70 self.expires_minutes.unwrap_or_else(|| self.kind.usual_minutes()).min(LONGEST_MINUTES)
71 }
72}
73
74const SCAN_LIMIT: usize = 6000;
76const LEAD: usize = 1000;
79const INTENT_LEAD: usize = 300;
82const AFTER_WINDOW: usize = 160;
84const BEFORE_WINDOW: usize = 100;
85const VALIDITY_WINDOW: usize = 80;
87
88pub fn detect(words: &Codes, subject: &str, body: &str) -> Option<OneTimeCode> {
91 read(words, subject, body, false)
92}
93
94pub fn detect_message(words: &Codes, card: &crate::card::Card) -> Option<OneTimeCode> {
104 read(words, &card.subject, &card.excerpt, card.is_list)
105}
106
107fn read(w: &Codes, subject: &str, body: &str, bulk: bool) -> Option<OneTimeCode> {
108 let subject = crate::text::readable(subject).replace('\n', " ");
109 let mut body = crate::text::readable(body);
110 if bulk {
111 body = body.chars().take(LEAD).collect();
112 }
113 let text: String = format!("{subject}\n{body}").chars().take(SCAN_LIMIT).collect();
114 let original: Vec<char> = text.chars().collect();
115 let folded: Vec<char> = fold(&text).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
117 let in_subject = subject.chars().count();
119 let expires_minutes = expiry_minutes(w, &folded);
120 let promotion = first_hit(&folded, &w.promo).is_some();
121 let secret = |kind, code| Some(OneTimeCode { kind, code, expires_minutes });
122 if !promotion && let Some(code) = code_in(w, &original, &folded) {
125 return secret(CodeKind::Code, Some(code));
126 }
127 let brief = lasts_briefly(w, &folded, expires_minutes);
128 let cued = !bulk || first_hit(&folded, &w.request).is_some() || brief;
131 let counts = |hit: Option<(usize, usize)>| hit.is_some_and(|(start, _)| start < in_subject || cued);
132 for (kind, phrases) in [(CodeKind::Password, &w.password), (CodeKind::PasswordReset, &w.reset), (CodeKind::SignInLink, &w.link), (CodeKind::Confirmation, &w.confirm)] {
133 if counts(first_hit(&folded, phrases)) {
134 return secret(kind, None);
135 }
136 }
137 let intent = first_hit(&folded, &w.intents).filter(|&(start, _)| start <= in_subject + INTENT_LEAD);
140 if intent.is_some() && brief && !promotion {
141 return secret(CodeKind::SignInLink, None);
142 }
143 None
144}
145
146const BRIEF_MINUTES: u32 = 2 * 24 * 60;
149
150fn lasts_briefly(w: &Codes, folded: &[char], expires_minutes: Option<u32>) -> bool {
153 let soon = expires_minutes.is_some_and(|m| m <= BRIEF_MINUTES);
154 every_hit(folded, &w.link_words).into_iter().any(|(_, end)| {
155 let window = &folded[end..(end + VALIDITY_WINDOW).min(folded.len())];
156 let said = |words: &[String]| words.iter().any(|w| find_word(window, w, 0).is_some());
157 said(&w.once) || (soon && said(&w.validity))
158 })
159}
160
161fn first_hit(folded: &[char], phrases: &[String]) -> Option<(usize, usize)> {
163 phrases
164 .iter()
165 .filter_map(|p| find_word(folded, p, 0).map(|start| (start, start + fold(p).len())))
166 .min_by_key(|&(start, _)| start)
167}
168
169fn every_hit(folded: &[char], phrases: &[String]) -> Vec<(usize, usize)> {
171 let mut hits = Vec::new();
172 for phrase in phrases {
173 let length = fold(phrase).len();
174 let mut from = 0;
175 while let Some(start) = find_word(folded, phrase, from) {
176 hits.push((start, start + length));
177 from = start + 1;
178 }
179 }
180 hits
181}
182
183fn code_in(w: &Codes, original: &[char], folded: &[char]) -> Option<String> {
186 let found = codes(original);
187 let mut hits: Vec<(usize, usize, bool)> = every_hit(folded, &w.code).into_iter().map(|(start, end)| (start, end, false)).collect();
188 hits.extend(every_hit(folded, &w.giving).into_iter().map(|(start, end)| (start, end, true)));
189 hits.extend(owned_codes(w, original, folded).into_iter().map(|(start, end)| (start, end, true)));
190 if first_hit(folded, &w.sign_in).is_some() {
192 hits.extend(every_hit(folded, &w.pointing).into_iter().map(|(start, end)| (start, end, false)));
193 }
194 hits.sort_unstable();
195 hits.into_iter().find_map(|(start, end, giving)| code_beside(w, folded, &found, (start, end), giving)).or_else(|| named_after(w, folded, &found))
196}
197
198fn is_in(list: &[String], folded_word: &str) -> bool {
200 list.iter().any(|w| crate::words::folded(w) == folded_word)
201}
202
203fn words(folded: &[char]) -> Vec<(usize, usize)> {
206 let mut out = Vec::new();
207 let mut i = 0;
208 while i < folded.len() {
209 if !folded[i].is_alphanumeric() {
210 i += 1;
211 continue;
212 }
213 let start = i;
214 while i < folded.len() && (folded[i].is_alphanumeric() || (matches!(folded[i], '-' | '\'') && folded.get(i + 1).is_some_and(|c| c.is_alphanumeric()))) {
215 i += 1;
216 }
217 out.push((start, i));
218 }
219 out
220}
221
222fn owned_codes(w: &Codes, original: &[char], folded: &[char]) -> Vec<(usize, usize)> {
228 let words = words(folded);
229 let text = |(start, end): (usize, usize)| folded[start..end].iter().collect::<String>();
230 let mut out = Vec::new();
231 for (index, &code) in words.iter().enumerate() {
232 if !is_in(&w.noun, &text(code)) {
233 continue;
234 }
235 let owner = (1..=3).filter_map(|back| index.checked_sub(back)).find(|&at| is_in(&w.owners, &text(words[at])));
237 let Some(owner) = owner else { continue };
238 let between = &words[owner + 1..index];
239 let spaced = folded[words[owner].1..code.0].iter().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '\''));
241 if !spaced || between.iter().any(|&between| is_in(&w.not_yours, &text(between))) {
242 continue;
243 }
244 out.push((words[owner].0, code.1));
245 if let Some(&name) = words.get(index + 1)
247 && folded[code.1..name.0].iter().all(|c| *c == ' ')
248 && original[name.0].is_uppercase()
249 && !is_in(&w.not_yours, &text(name))
250 {
251 out.push((words[owner].0, name.1));
252 }
253 }
254 out
255}
256
257fn named_after(w: &Codes, folded: &[char], found: &[Found]) -> Option<String> {
261 found.iter().find_map(|f| {
262 let rest = &folded[f.end..(f.end + 60).min(folded.len())];
263 let lead = rest.iter().take_while(|c| **c == ' ').count();
264 let owned = w.owned_after.iter().find(|p| {
265 let p = fold(p);
266 rest[lead..].starts_with(&p) && rest.get(lead + p.len()).is_none_or(|c| !c.is_alphanumeric())
267 })?;
268 let after = &rest[lead + owned.chars().count()..];
269 let words = words(after);
270 let text = |(start, end): (usize, usize)| after[start..end].iter().collect::<String>();
271 let code = words.iter().take(4).position(|&word| is_in(&w.noun, &text(word)))?;
272 let clean = words[..code].iter().all(|&word| !is_in(&w.not_yours, &text(word)));
273 let spaced = after[..words[code].0].iter().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '\''));
274 (clean && spaced).then(|| f.code.clone())
275 })
276}
277
278fn code_beside(w: &Codes, folded: &[char], found: &[Found], (start, end): (usize, usize), giving: bool) -> Option<String> {
283 if let Some(next) = found.iter().find(|f| f.start >= end)
284 && ties(w, &folded[end..next.start], giving)
285 {
286 return Some(next.code.clone());
287 }
288 if giving {
289 return None;
290 }
291 let plain = || found.iter().filter(|f| !f.mixed);
292 plain()
293 .find(|f| f.start >= end && f.start < end + AFTER_WINDOW)
294 .or_else(|| plain().filter(|f| f.start + BEFORE_WINDOW >= start && f.end <= start).last())
295 .map(|f| f.code.clone())
296}
297
298fn ties(w: &Codes, gap: &[char], giving: bool) -> bool {
302 let gap: String = gap.iter().collect();
303 let words: Vec<&str> = gap.split(|c: char| c.is_whitespace() || c == ':' || c == '=').filter(|word| !word.is_empty()).collect();
304 let linked = gap.contains([':', '=']) || !words.is_empty();
305 words.len() <= 1 && words.iter().all(|word| is_in(&w.linking, word)) && (linked || !giving)
306}
307
308struct Found {
311 start: usize,
312 end: usize,
313 code: String,
314 mixed: bool,
315}
316
317fn codes(chars: &[char]) -> Vec<Found> {
322 let tokens = tokens(chars);
323 let mut found = Vec::new();
324 let mut i = 0;
325 while i < tokens.len() {
326 let mut j = i + 1;
327 if is_digits(&tokens[i].text) {
328 while j < tokens.len() && is_digits(&tokens[j].text) && tokens[j].start == tokens[j - 1].end + 1 && is_space(chars[tokens[j - 1].end]) {
329 j += 1;
330 }
331 }
332 let run = &tokens[i..j];
333 if run.len() > 1 && run.iter().all(|t| t.text.len() == run[0].text.len()) {
334 found.extend(grouped_code(run).map(|code| Found { start: run[0].start, end: run[run.len() - 1].end, code, mixed: false }));
335 } else {
336 for token in run {
337 let code = code_from(token).map(|c| (c, false)).or_else(|| mixed_code(&token.text).map(|c| (c, true)));
338 if let Some((code, mixed)) = code {
339 found.push(Found { start: token.start, end: token.end, code, mixed });
340 }
341 }
342 }
343 i = j;
344 }
345 found
346}
347
348fn is_digits(text: &str) -> bool {
349 !text.is_empty() && text.chars().all(|c| c.is_ascii_digit())
350}
351
352fn is_space(c: char) -> bool {
354 matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}')
355}
356
357struct Token {
359 text: String,
360 start: usize,
362 end: usize,
363 before: char,
364 after: String,
365}
366
367fn tokens(chars: &[char]) -> Vec<Token> {
369 let mut tokens = Vec::new();
370 let mut i = 0;
371 while i < chars.len() {
372 if !(chars[i].is_ascii_alphanumeric()) {
373 i += 1;
374 continue;
375 }
376 let start = i;
377 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '-') {
378 i += 1;
379 }
380 let text: String = chars[start..i].iter().collect::<String>().trim_end_matches('-').to_string();
381 let before = if start > 0 { chars[start - 1] } else { ' ' };
382 let after: String = chars[i..(i + 4).min(chars.len())].iter().collect();
383 tokens.push(Token { end: start + text.len(), text, start, before, after });
384 }
385 tokens
386}
387
388fn code_from(token: &Token) -> Option<String> {
392 let t = &token.text;
393 if is_digits(t) && (4..=8).contains(&t.len()) && !looks_like_amount_or_year(token) {
394 return Some(t.clone());
395 }
396 prefixed_code(t).or_else(|| hyphened_code(token))
397}
398
399fn hyphened_code(token: &Token) -> Option<String> {
402 let (first, second) = token.text.split_once('-')?;
403 let ok = is_digits(first)
404 && is_digits(second)
405 && first.len() == second.len()
406 && (3..=4).contains(&first.len())
407 && !is_year(first)
408 && !is_year(second)
409 && !looks_like_amount_or_year(token);
410 ok.then(|| format!("{first}{second}"))
411}
412
413fn grouped_code(run: &[Token]) -> Option<String> {
417 let (first, last) = (&run[0], &run[run.len() - 1]);
418 let joined: String = run.iter().map(|t| t.text.as_str()).collect();
419 let ok = (1..=4).contains(&first.text.len())
420 && (4..=8).contains(&joined.len())
421 && !run.iter().any(|t| is_year(&t.text))
422 && first.before != '+'
423 && !amount_follows(last);
424 ok.then_some(joined)
425}
426
427fn mixed_code(text: &str) -> Option<String> {
431 let letters = text.chars().filter(char::is_ascii_uppercase).count();
432 let digits = text.chars().filter(char::is_ascii_digit).count();
433 let shaped = text.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') && !text.contains("--");
434 (shaped && (5..=10).contains(&text.len()) && letters >= 2 && digits >= 2).then(|| text.to_string())
435}
436
437fn looks_like_amount_or_year(token: &Token) -> bool {
438 is_year(&token.text) || token.before == '+' || amount_follows(token)
439}
440
441fn is_year(text: &str) -> bool {
442 text.len() == 4 && text.parse::<u32>().is_ok_and(|y| (1900..=2099).contains(&y))
443}
444
445fn amount_follows(token: &Token) -> bool {
447 let after = token.after.trim_start().to_ascii_lowercase();
448 after.starts_with('€') || after.starts_with('%') || after.starts_with("eur")
449}
450
451fn prefixed_code(text: &str) -> Option<String> {
453 let (prefix, digits) = text.split_once('-')?;
454 let ok = (1..=3).contains(&prefix.len())
455 && prefix.chars().all(|c| c.is_ascii_uppercase())
456 && (4..=8).contains(&digits.len())
457 && digits.chars().all(|c| c.is_ascii_digit());
458 ok.then(|| text.to_string())
459}
460
461fn expiry_minutes(w: &Codes, folded: &[char]) -> Option<u32> {
463 let starts = |unit: &str, list: &[String]| list.iter().map(|u| crate::words::folded(u)).any(|u| !u.is_empty() && unit.starts_with(&u));
464 let mut i = 0;
465 while i < folded.len() {
466 if !folded[i].is_ascii_digit() {
467 i += 1;
468 continue;
469 }
470 let start = i;
471 while i < folded.len() && folded[i].is_ascii_digit() {
472 i += 1;
473 }
474 let number: u32 = folded[start..i].iter().collect::<String>().parse().unwrap_or(0);
475 let unit: String = folded[i..(i + 8).min(folded.len())].iter().collect::<String>().trim_start().to_string();
476 let before: String = folded[start.saturating_sub(40)..start].iter().collect();
477 let in_context = w.expiry_context.iter().map(|c| crate::words::folded(c)).any(|c| !c.is_empty() && before.contains(&c));
478 let minutes = if starts(&unit, &w.unit_minutes) {
479 Some(number)
480 } else if starts(&unit, &w.unit_hours) {
481 Some(number.saturating_mul(60))
482 } else if starts(&unit, &w.unit_days) {
483 Some(number.saturating_mul(24 * 60))
484 } else {
485 None
486 };
487 if let (true, Some(m)) = (in_context, minutes) {
488 return Some(m);
489 }
490 }
491 None
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::words::Words;
498
499 fn detect(subject: &str, body: &str) -> Option<OneTimeCode> {
501 super::detect(&Words::builtin().codes, subject, body)
502 }
503
504 fn detect_message(card: &crate::card::Card) -> Option<OneTimeCode> {
505 super::detect_message(&Words::builtin().codes, card)
506 }
507
508 #[test]
510 fn the_words_you_add_and_take_away() {
511 let config: crate::config::Config = toml::from_str(
512 "[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.codes.code]\nadd = [\"Bestätigungscode\"]\nremove = [\"code de connexion\"]\n",
513 )
514 .unwrap();
515 let yours = Words::of(&config);
516 let german = ("Ihr Bestätigungscode", "Bestätigungscode: 482913");
517 assert_eq!(detect(german.0, german.1), None, "not with the packs built in");
518 assert_eq!(super::detect(&yours.codes, german.0, german.1).and_then(|c| c.code).as_deref(), Some("482913"));
519 let french = ("Connexion", "Votre code de connexion : 551204");
520 assert_eq!(detect(french.0, french.1).and_then(|c| c.code).as_deref(), Some("551204"));
521 assert_eq!(super::detect(&yours.codes, french.0, french.1), None, "taken away");
522 }
523
524 #[test]
525 fn french_bank_code() {
526 let found = detect(
527 "Votre code de sécurité",
528 "Bonjour,\nVotre code de sécurité est : 482 913.\nIl est valable 10 minutes.",
529 )
530 .unwrap();
531 assert_eq!(found.kind, CodeKind::Code);
532 assert_eq!(found.code.as_deref(), Some("482913"));
533 assert_eq!(found.expires_minutes, Some(10));
534 }
535
536 #[test]
537 fn code_before_the_phrase() {
538 let found = detect("G-482913 is your Google verification code", "").unwrap();
539 assert_eq!(found.code.as_deref(), Some("G-482913"));
540 let found = detect("123456 est votre code de vérification", "Ce code expire dans 5 min.").unwrap();
541 assert_eq!(found.code.as_deref(), Some("123456"));
542 assert_eq!(found.expires_minutes, Some(5));
543 }
544
545 #[test]
546 fn years_prices_and_postcodes_are_not_codes() {
547 let found = detect("Your verification code", "Your verification code for 2026: 4821").unwrap();
548 assert_eq!(found.code.as_deref(), Some("4821"));
549 assert!(detect("Votre code postal", "Votre nouveau code postal : 54390").is_none());
550 assert!(detect("Security", "Never share your security code with anyone.").is_none());
551 }
552
553 #[test]
554 fn what_you_just_asked_for() {
555 let found = detect("Welcome", "Here is your password: Xk82-pq. Change it after you sign in.").unwrap();
556 assert_eq!((found.kind, found.lasts_minutes()), (CodeKind::Password, 7 * 24 * 60));
557 let found = detect("Confirmez votre adresse e-mail", "Cliquez sur le lien ci-dessous.").unwrap();
558 assert_eq!((found.kind, found.lasts_minutes()), (CodeKind::Confirmation, 24 * 60));
559 let found = detect("Forgot your password?", "Choose a new password with this link. It is valid for 2 days.").unwrap();
560 assert_eq!((found.kind, found.lasts_minutes()), (CodeKind::PasswordReset, 2 * 24 * 60));
561 let found = detect("Your 2FA code", "Use 551 204 to sign in.").unwrap();
562 assert_eq!(found.code.as_deref(), Some("551204"));
563 assert!(detect("Your access code", "Use access code 2468 for 20 % off this week.").is_none());
565 }
566
567 #[test]
568 fn notices_that_name_a_password_carry_none() {
569 assert!(detect("Nouveau document", "Un document est disponible. Connectez-vous à votre espace avec vos identifiants.").is_none());
570 assert!(detect("Mot de passe modifié", "Votre nouveau mot de passe a bien été enregistré.").is_none());
571 assert!(detect("Your statement", "Sign in with your login details to read it.").is_none());
572 let found = detect("Bienvenue", "Vos identifiants\u{a0}: jdupont / Xk82pq").unwrap();
573 assert_eq!(found.kind, CodeKind::Password);
574 let long = detect("Confirm your email", "This link is valid for 90 days.").unwrap();
576 assert_eq!((long.expires_minutes, long.lasts_minutes()), (Some(90 * 24 * 60), 7 * 24 * 60));
577 }
578
579 #[test]
580 fn the_code_after_a_warning_or_a_heading() {
581 let filler = "We will never ask for it by phone or by mail. ".repeat(4);
583 let body = format!("Never share your security code with anyone.\n{filler}\nYour security code: 482913");
584 assert_eq!(detect("Security", &body).unwrap().code.as_deref(), Some("482913"));
585 let body = format!("Code de vérification\n{filler}\nVotre code de vérification est : 551204");
586 assert_eq!(detect("Votre code de vérification", &body).unwrap().code.as_deref(), Some("551204"));
587 }
588
589 #[test]
590 fn codes_printed_in_groups() {
591 for (body, code) in [
592 ("Votre code de vérification : 48 29 13.", "482913"),
593 ("Your verification code is 482\u{a0}913.", "482913"),
594 ("Votre code de sécurité\u{202f}: 482\u{202f}913", "482913"),
595 ("Your verification code: 4 8 2 9 1 3", "482913"),
596 ("Votre code : 482913", "482913"),
597 ] {
598 assert_eq!(detect("Code", body).and_then(|c| c.code).as_deref(), Some(code), "{body}");
599 }
600 assert!(detect("Votre code de vérification", "Il vous sera envoyé au 06 12 34 56 78.").is_none());
602 assert!(detect("Your verification code", "It goes to +33 6 12 34 56 78 by text.").is_none());
603 assert!(detect("Votre code de vérification", "Montant : 120 450 €").is_none());
604 assert!(detect("Bienvenue", "Votre code : 2026").is_none());
605 }
606
607 #[test]
608 fn codes_of_letters_and_digits_only_where_given() {
609 assert_eq!(detect("Your sign-in code", "Your sign-in code is K7Q-2M9.").unwrap().code.as_deref(), Some("K7Q-2M9"));
610 assert_eq!(detect("Bienvenue", "Votre code\u{a0}: AB12CD").unwrap().code.as_deref(), Some("AB12CD"));
611 assert!(detect("Your code", "Your code is ready: open the app.").is_none());
613 assert!(detect("Your code", "Your code is ab12cd").is_none());
614 assert!(detect("Votre commande FR000000", "Votre code de confirmation vous sera envoyé par SMS.\nCommande FR000000 : 2 articles.").is_none());
615 assert!(detect("Votre code postal", "Votre code postal : AB12CD").is_none());
616 assert!(detect("Votre code", "Votre code : SPRING20 pour 20 % de réduction.").is_none());
617 }
618
619 #[test]
620 fn resets_and_links() {
621 let found = detect("Réinitialisation de votre mot de passe", "Cliquez sur le lien.").unwrap();
622 assert_eq!(found.kind, CodeKind::PasswordReset);
623 let found = detect("Your sign-in link", "Valid for 1 hour.").unwrap();
624 assert_eq!(found.kind, CodeKind::SignInLink);
625 assert_eq!(found.expires_minutes, Some(60));
626 }
627
628 fn bulk(subject: &str, body: &str) -> crate::card::Card {
630 let raw = format!(
631 "From: Service <no-reply@service.example>\r\nTo: you@example.org\r\nSubject: {subject}\r\n\
632 List-Unsubscribe: <https://mail.service.example/u/a1b2c3>, <mailto:unsubscribe@service.example>\r\n\
633 List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\nPrecedence: bulk\r\nX-Mailer: Mailing Service 4.2\r\n\
634 Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}\r\n"
635 );
636 let card = crate::card::Card::from_bytes(raw.as_bytes()).unwrap();
637 assert!(card.is_list);
638 card
639 }
640
641 #[test]
642 fn codes_and_links_sent_with_bulk_headers() {
643 let link = bulk(
645 "Sign in to Readwell",
646 "Hi,\n\nClick the link below to sign in to your Readwell account.\n\nSign in to Readwell <https://click.service.example/ls/click?upn=u001.Zm9vYmFyYmF6cXV4-2FZm9vYmFyYmF6cXV4-2B&token=4829137715>\n\n\
647 This link will expire in 15 minutes and can only be used once. If you didn't request this, you can safely ignore this email.",
648 );
649 let found = detect_message(&link).unwrap();
650 assert_eq!((found.kind, found.expires_minutes), (CodeKind::SignInLink, Some(15)));
651 let lien = bulk("Connexion à votre espace", "Bonjour,\n\nPour vous connecter, cliquez sur le bouton ci-dessous.\n\nMe connecter\n\nCe lien est valable 30 minutes et ne fonctionne qu'une fois.");
652 assert_eq!(detect_message(&lien).map(|c| c.kind), Some(CodeKind::SignInLink));
653 let microsoft = bulk("Microsoft account security code", "Please use the following security code for the Microsoft account jo***@example.org.\n\nSecurity code: 482913\n\nIf you don't recognise the account, you can click a link to remove your email address from that account.");
655 assert_eq!(detect_message(µsoft).and_then(|c| c.code).as_deref(), Some("482913"));
656 let shop = bulk("Your Picshare code", "Your Picshare code: 551204\n\nEnter it in the app to finish signing in. It expires in 10 minutes.");
657 let found = detect_message(&shop).unwrap();
658 assert_eq!((found.code.as_deref(), found.expires_minutes), (Some("551204"), Some(10)));
659 let bank = bulk("Validation de votre opération", "Bonjour,\n\nVotre code de validation\u{a0}: 77 41 08\n\nIl expire dans 5 minutes. Ne le communiquez à personne.");
660 let found = detect_message(&bank).unwrap();
661 assert_eq!((found.code.as_deref(), found.expires_minutes), (Some("774108"), Some(5)));
662 let padding = "\u{200c}\u{a0}".repeat(300);
664 let tracked = bulk("Verify your email", &format!("{padding}\nView in browser: https://click.service.example/v/{}\n\nYour verification code\n\nhttps://click.service.example/c/{}\n\n482913", "x".repeat(900), "y".repeat(400)));
665 assert_eq!(detect_message(&tracked).and_then(|c| c.code).as_deref(), Some("482913"));
666 let reset = bulk("Your account", "Someone asked to change the password of your account.\nUse this link to choose a new password: https://service.example/r/9f8e7d\nThe link expires in 1 hour.");
668 assert_eq!(detect_message(&reset).map(|c| c.kind), Some(CodeKind::PasswordReset));
669 }
670
671 #[test]
672 fn a_newsletter_about_passwords_hands_none_over() {
673 let article = "This week: a large provider asked millions of people to reset your password after a breach. \
674 We explain what a password reset is for, why a sign-in link beats a reused password, and why you should \
675 confirm your email only on the site you signed up to. Your security code should never travel by mail: \
676 banks send theirs by text message instead. Remember: your password is yours alone.";
677 let news = bulk("The Weekly Byte #87: passwords, passkeys and you", &format!("{article}\n\n{}", "More stories below.\n".repeat(20)));
678 assert_eq!(detect_message(&news), None, "{news:?}");
679 let store = bulk("Autumn sale", "Use the code AUTUMN25 at checkout for 25 % off. Log in to see your offers: this link is valid until Sunday.");
681 assert_eq!(detect_message(&store), None);
682 let notice = bulk("New sign-in to your account", "We noticed a new sign-in to your account from Firefox on Linux. If this was you, there is nothing to do. If this wasn't you, change your password.");
684 assert_eq!(detect_message(¬ice), None);
685 }
686
687 #[test]
688 fn codes_said_in_other_words() {
689 for (subject, body, code) in [
690 ("Your login", "123456 is your Picshare code. Don't share it.", "123456"),
691 ("Code", "482913 est votre code Picshare.", "482913"),
692 ("Votre code", "Votre code Picshare\u{202f}: 551204", "551204"),
693 ("Sign in", "Use this code to sign in: 123-456", "123456"),
694 ("Vérification", "Saisissez le code suivant pour vérifier votre identité :\n\n4829 1357", "48291357"),
695 ("Steam", "Login Code: F7G2K", "F7G2K"),
696 ] {
697 assert_eq!(detect(subject, body).and_then(|c| c.code).as_deref(), Some(code), "{body}");
698 }
699 assert!(detect("Your order", "Your zip code: 54390").is_none());
701 assert!(detect("Votre compte", "Votre code Client : 123456").is_none());
702 assert!(detect("Thanks", "Use this code at checkout: 4821").is_none());
703 assert!(detect("Order", "Your order 2026-1004 shipped on 06-10.").is_none());
704 }
705
706 #[test]
707 fn a_text_version_that_says_nothing() {
708 let raw = "From: Service <no-reply@service.example>\r\nSubject: Your code\r\nList-Id: <accounts.service.example>\r\n\
710 MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"b\"\r\n\r\n\
711 --b\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nView this email in your browser: https://service.example/view/a1b2c3\r\n\
712 --b\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<html><body><p>Hello,</p><p>Your verification code is <b>482913</b>.</p><p>It expires in 10 minutes.</p></body></html>\r\n--b--\r\n";
713 let card = crate::card::Card::from_bytes(raw.as_bytes()).unwrap();
714 assert_eq!(detect_message(&card).and_then(|c| c.code).as_deref(), Some("482913"), "{:?}", card.excerpt);
715 }
716}