Skip to main content

sioul_core/
codes.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! One-time codes, passwords, password resets, sign-in links and addresses
5//! to confirm, sent by mail.
6//!
7//! You asked for them a moment ago, on a site, and they expire within
8//! minutes or hours: they are the one exception to the admin windows, shown
9//! at once and quietly, from automatic addresses too (docs/porch.md, "Right
10//! now"). Fake "your code" messages are a common phishing trick: a forged one
11//! was set aside before, and one whose sender is not verified comes with a
12//! warning. The words it looks for are the `codes` lists of the word packs
13//! in use, with your changes (`words::Codes`, docs/words.md).
14//!
15//! Many sites send these through the same services as their newsletters, with
16//! the same headers (`List-Unsubscribe`, `Precedence: bulk`): those headers
17//! never decide that a message is not a code (`detect_message`). They only
18//! narrow where a code is looked for: in the subject and the top of the text,
19//! where a site puts what you asked for, and not in a newsletter's articles,
20//! which talk about passwords without handing one over.
21
22use crate::text::{find_word, fold};
23use crate::words::Codes;
24
25/// What kind of short-lived secret a message carries.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CodeKind {
28    Code,
29    Password,
30    PasswordReset,
31    SignInLink,
32    /// An address or an account to confirm: "confirm your email".
33    Confirmation,
34}
35
36impl CodeKind {
37    /// How long one stays "right now" when its message does not say, in
38    /// minutes: codes live 5 to 15 minutes, sign-in links and resets an hour
39    /// or two, an address to confirm a day; a temporary password lasts until
40    /// you have used it: a week.
41    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/// A short-lived secret found in a message.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct OneTimeCode {
55    pub kind: CodeKind,
56    /// The code itself, digits only or as printed ("G-482913"); none for links and resets.
57    pub code: Option<String>,
58    /// How long the message says it stays valid.
59    pub expires_minutes: Option<u32>,
60}
61
62/// The longest a message stays "right now", whatever it says: a week, as a
63/// password waiting for its first use. "Valid for 90 days", written by anyone,
64/// would otherwise keep it on top for three months.
65const LONGEST_MINUTES: u32 = 7 * 24 * 60;
66
67impl OneTimeCode {
68    /// How long it stays "right now": what its message says, else what its kind usually lasts.
69    pub fn lasts_minutes(&self) -> u32 {
70        self.expires_minutes.unwrap_or_else(|| self.kind.usual_minutes()).min(LONGEST_MINUTES)
71    }
72}
73
74/// Characters of subject and body looked at: codes sit at the top of these messages.
75const SCAN_LIMIT: usize = 6000;
76/// Characters of a bulk message's text read, its web addresses left out
77/// (`detect_message`): the top, where a site puts the code you asked for.
78const LEAD: usize = 1000;
79/// Characters of the text, after the subject, where a sign-in link's message
80/// says what it is about ("Click the link below to sign in").
81const INTENT_LEAD: usize = 300;
82/// How far around the phrase a code is looked for, in characters.
83const AFTER_WINDOW: usize = 160;
84const BEFORE_WINDOW: usize = 100;
85/// How far after "link" its validity is said, in characters.
86const VALIDITY_WINDOW: usize = 80;
87
88/// Finds a short-lived secret in a message, if it carries one. Phrases are
89/// compared folded: lowercase, without accents (see `text`).
90pub fn detect(words: &Codes, subject: &str, body: &str) -> Option<OneTimeCode> {
91    read(words, subject, body, false)
92}
93
94/// Finds a short-lived secret in a message read from mail. Bulk headers
95/// (`Card::is_list`: `List-Unsubscribe`, `List-Id`, `Precedence: bulk`) are
96/// how many sites send codes and links too, so they never rule one out: they
97/// only narrow the reading to the subject and the top of the text (`LEAD`),
98/// and a phrase that hands nothing over by itself (a reset, a link, an
99/// address to confirm, a password), when it is not in the subject, counts only
100/// beside a line that answers a request ("if you did not ask for this…",
101/// `Codes::request`) or a link said to expire. A newsletter's article on
102/// passwords is none.
103pub 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    // A phrase broken over two lines is still the phrase.
116    let folded: Vec<char> = fold(&text).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
117    // Positions before this one are the subject's.
118    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    // A code phrase without a code nearby ("never share your security
123    // code", a heading) is no code: the next phrase is read, and so on.
124    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    // In bulk mail, a phrase without a code beside it counts in the subject,
129    // or beside a cue: "your password is" is also an article's sentence.
130    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    // "Sign in to Medium" above, "this link expires in 15 minutes" below: a
138    // link to sign in. A shop's "log in to see your offers" is none.
139    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
146/// The longest a link said to expire may last to be one you just asked for:
147/// two days ("valid for 48 hours"); a sale's "valid until Sunday" is none.
148const BRIEF_MINUTES: u32 = 2 * 24 * 60;
149
150/// Whether a link, its button or a code is said to expire soon or to work
151/// once: "This link expires in 15 minutes", "ce lien n'est valable qu'une fois".
152fn 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
161/// The earliest of the phrases in the text, as (start, end) positions.
162fn 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
169/// Every place of each phrase in the text, as (start, end) positions.
170fn 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
183/// The code beside the first phrase, in the text's order, that has one; else
184/// a code said to be yours right after it ("123456 is your Instagram code").
185fn 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    // "Enter the code below", only in a message about signing in or checking who you are.
191    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
198/// Whether a folded word is one of a list's, the list's words folded too.
199fn is_in(list: &[String], folded_word: &str) -> bool {
200    list.iter().any(|w| crate::words::folded(w) == folded_word)
201}
202
203/// The words of a text, as (start, end) positions: runs of letters and digits,
204/// with the hyphens and apostrophes inside them ("sign-in", "d'accès").
205fn 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
222/// "Your Instagram code", "your Uber login code", "votre code Instagram":
223/// the owner, up to two words, "code", and in French a name after it,
224/// written with a capital ("Instagram", never "postal"); none of the words
225/// one that makes it no secret (`Codes::not_yours`), nothing but spaces between
226/// them. As (start, end): what must be followed by the code itself.
227fn 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        // Back: the owner one to three words before, only spaces between.
236        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        // Words and spaces only: "your account. Code: …" is two sentences.
240        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        // A name after "code", with its capital: "votre code Instagram : 123456".
246        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
257/// A code followed by what says it is yours: "123456 is your Instagram code",
258/// "482913 est votre code de vérification". Up to three words between "your"
259/// and "code", none that makes it no secret.
260fn 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
278/// The code next to a phrase: right after it, whatever its shape ("votre code
279/// est : 482 913", "your sign-in code: K7Q-2M9"); else, for a phrase of
280/// `Codes::code`, digits further after it, or the nearest before it
281/// ("G-482913 is your Google verification code").
282fn 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
298/// Whether what stands between a phrase and a code ties them: spaces, a
299/// colon, "is" or "est". After "your code", a colon or one of these words
300/// at least: "votre code postal : 54390" gives no code.
301fn 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
308/// A code-shaped stretch of the text: where it is, the code as kept, and
309/// whether it mixes letters and digits (then read only right after its phrase).
310struct Found {
311    start: usize,
312    end: usize,
313    code: String,
314    mixed: bool,
315}
316
317/// Every code-shaped stretch of the text, in order. Groups of digits of the
318/// same size one space apart, a non-breaking one too ("482 913", "48 29 13"),
319/// are read together, a code or none: a phone number or an amount is read
320/// whole, never in parts. Groups of other sizes are read one by one.
321fn 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
352/// A space between groups of digits: plain, non-breaking, narrow, thin or figure.
353fn is_space(c: char) -> bool {
354    matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}')
355}
356
357/// A token and what surrounds it, enough to tell a code from a price or a phone number.
358struct Token {
359    text: String,
360    /// Where it starts and ends in the text, in characters.
361    start: usize,
362    end: usize,
363    before: char,
364    after: String,
365}
366
367/// Maximal runs of letters, digits and hyphens.
368fn 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
388/// Whether a token alone is a code: 4 to 8 digits (not a year, a price or a
389/// phone number), a prefixed code ("G-482913"), or two groups of three or
390/// four digits joined by a hyphen ("123-456"), kept as digits.
391fn 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
399/// "123-456", "4829-1357": two groups of digits of the same size, three or
400/// four, joined by a hyphen; a date ("2026-10") or a phone number is none.
401fn 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
413/// Digits printed in groups of the same size, 4 to 8 in all: "482 913",
414/// "48 29 13", "4 8 2 9 1 3", "4829 1357". A phone number ("06 12 34 56 78"),
415/// a card's number or an amount ("120 450 €") is none.
416fn 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
427/// A code of letters and digits ("K7Q-2M9", "AB12CD", "F7G2K"): 5 to 10
428/// characters, uppercase, two letters and two digits at least, so that no
429/// word is one; read only right after its phrase (`code_beside`).
430fn 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
445/// "€", "%" or "euros" right after: an amount.
446fn 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
451/// "G-482913": letters, a hyphen, 4 to 8 digits.
452fn 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
461/// "valable 10 minutes", "expires in 15 minutes", "valid for 1 hour", "for 7 days".
462fn 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    /// The detectors with the packs built in (French and English).
500    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    /// The words come from the configuration: one added is read, one taken away is not.
509    #[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        // A shop's code is not yours to rush for.
564        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        // Whatever the message says, a week at most on top.
575        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        // The first phrase has no code nearby: the next ones are read.
582        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        // A phone number, an amount, a year: no code, whole or in part.
601        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        // Words, an order number, a postcode, a shop's code: none.
612        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    /// A message as a site's mailing service sends it: its bulk headers, then the text.
629    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        // A sign-in link, worded as newsletter services word them.
644        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        // A code from an account service, a shop, a bank.
654        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(&microsoft).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        // A tracking link and a padded preview line before the code take nothing from the reading.
663        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        // A password reset from a no-reply address, its link said to expire.
667        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        // A store's newsletter with a promotion code and a link to sign in.
680        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        // A security notice: a new sign-in, nothing to use.
683        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(&notice), 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        // Codes that are no secret of yours.
700        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        // Its text version only points to the HTML one, which holds the code.
709        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}