Skip to main content

sioul_core/
voicemail.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Voicemail the operator sends by e-mail (docs/android.md, "Calls";
5//! docs/research/call-screening.md, 5.4). Some operators, set to, mail each
6//! message left on your voicemail: the caller's number, the date and the
7//! length of the message, and the sound as an attachment (research 5.4
8//! quotes one operator's help pages). Their exact words, senders and file
9//! names are not published: read tolerantly. Only the operators' domains you
10//! name are read (`words::VoicemailWords::operators`; none in the packs, so
11//! nothing is read until you name yours), with the words of the languages in
12//! use. A message is one when it comes from those domains and either carries
13//! a sound, or says voicemail with a caller (or a hidden one) and a length;
14//! the caller's number, when the message was left and how long it lasts are
15//! read from its subject and text, the time from its Date when the text
16//! gives none.
17//!
18//! Each is then linked to the call Sioul refused (`link`): the same number,
19//! left within half an hour after it. Sioul plays its sound on demand
20//! (`reading::attachment`); the mail itself stays where the Porch puts it.
21//! Nothing is transcribed (a later option: research 5.3, CS18).
22
23use crate::calls::Held;
24use crate::phones::{self, Region};
25use crate::words::VoicemailWords;
26use mail_parser::{MessageParser, MimeHeaders};
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30/// A voicemail is linked to a call refused at most this long before it (seconds).
31pub const LINK_BEFORE: i64 = 30 * 60;
32/// … or this long after it: clocks differ (seconds).
33pub const LINK_AFTER: i64 = 3 * 60;
34
35/// A voicemail, as its mail says it.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Voicemail {
38    pub path: PathBuf,
39    /// The caller's number as written ("01 99 00 12 34"); "" when hidden or not said.
40    pub number: String,
41    /// `phones::key` of it; "" when hidden or not said.
42    pub key: String,
43    /// The caller hid their number ("numéro masqué").
44    pub hidden: bool,
45    /// When the message was left (Unix seconds): the mail's words, else its Date.
46    pub at: i64,
47    /// How long it lasts, when said.
48    pub seconds: Option<u32>,
49    /// The sound's place among the mail's attachments; none in a notice without it.
50    pub sound: Option<u32>,
51}
52
53/// The operators' domains, lowercase, without an "@" before them.
54fn domains(operators: &[String]) -> Vec<String> {
55    operators.iter().map(|d| d.trim().trim_start_matches('@').to_ascii_lowercase()).filter(|d| !d.is_empty()).collect()
56}
57
58/// Whether an address is one of the operators' (`operators`: their domains; a subdomain too).
59pub fn from_operator(operators: &[String], address: &str) -> bool {
60    let address = address.trim().to_ascii_lowercase();
61    let Some((_, domain)) = address.rsplit_once('@') else { return false };
62    domains(operators).iter().any(|d| domain == d || domain.ends_with(&format!(".{d}")))
63}
64
65/// A quick look at a mail's head before reading it whole: a "From" with one
66/// of the operators' domains in it, on its line or the lines that continue
67/// it. Voicemail mails carry their sound: most mail never is.
68pub fn may_be(operators: &[String], raw: &[u8]) -> bool {
69    let domains = domains(operators);
70    if domains.is_empty() {
71        return false;
72    }
73    let end = raw.windows(4).position(|w| w == b"\r\n\r\n").or_else(|| raw.windows(2).position(|w| w == b"\n\n")).unwrap_or(raw.len().min(16 * 1024));
74    let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
75    let mut in_from = false;
76    for line in head.lines() {
77        if !line.starts_with([' ', '\t']) {
78            in_from = line.starts_with("from:");
79        }
80        if in_from && domains.iter().any(|d| line.contains(d.as_str())) {
81            return true;
82        }
83    }
84    false
85}
86
87/// Accents and case aside, for the words looked for.
88fn folded(text: &str) -> String {
89    crate::text::fold(text).into_iter().collect::<String>().to_lowercase()
90}
91
92/// Whether a folded text holds one of a list's words, folded.
93fn holds(list: &[String], text: &str) -> bool {
94    list.iter().map(|w| folded(w)).any(|w| !w.trim().is_empty() && text.contains(&w))
95}
96
97/// Whether a folded text holds one of a list's words as a whole word
98/// (`words::holds_word`): the callers' leads are read so, yours as the
99/// shipped ones.
100fn holds_word(list: &[String], text: &str) -> bool {
101    crate::words::holds_word(list, text)
102}
103
104/// The voicemail a mail is, if it is one, read with `words` (the operators'
105/// domains, the words of the languages in use). `zone` reads the time its
106/// text gives (the operator's local time); `region`, numbers without a
107/// country; `trusted`, the authserv-ids of the provider that delivered it
108/// (`config::Source::trusted_ids`): a mail its own checks find forged in an
109/// operator's name is never one.
110pub fn read(words: &VoicemailWords, raw: &[u8], path: &Path, region: Option<&Region>, zone: &jiff::tz::TimeZone, trusted: &[String]) -> Option<Voicemail> {
111    let message = MessageParser::default().parse(raw)?;
112    let from = message.from()?.first()?.address()?.to_ascii_lowercase();
113    if !from_operator(&words.operators, &from) {
114        return None;
115    }
116    let auth = crate::trust::read_auth_results(&crate::headers::RawHeaders::parse(raw), trusted);
117    let (trust, _) = crate::trust::judge_sender(auth.as_ref(), false, from.rsplit_once('@').map(|(_, d)| d));
118    if trust == crate::trust::Trust::Forged {
119        return None;
120    }
121    let subject = message.subject().unwrap_or("").to_string();
122    let mut body = message.body_text(0).map(|b| b.into_owned()).unwrap_or_default();
123    if body.trim().is_empty()
124        && let Some(html) = message.body_html(0)
125    {
126        body = mail_parser::decoders::html::html_to_text(&html);
127    }
128    let found = message.attachments().enumerate().find(|(_, part)| is_sound(part)).map(|(i, part)| (i as u32, part.attachment_name().unwrap_or("").to_string()));
129    let sound = found.as_ref().map(|(i, _)| *i);
130    let text = folded(&format!("{subject}\n{body}"));
131    let says_voicemail = holds(&words.words, &text);
132    let hidden = holds(&words.hidden, &text);
133    // The subject, the text, else the sound's own name ("0199001234_20261006.wav").
134    let number = caller(words, &subject, region).or_else(|| caller(words, &body, region)).or_else(|| found.as_ref().and_then(|(_, name)| caller(words, name, region)));
135    let seconds = duration(&words.duration, &text);
136    // A notice without its sound counts when it says voicemail, who, and how long.
137    if sound.is_none() && !(says_voicemail && (number.is_some() || hidden) && seconds.is_some()) {
138        return None;
139    }
140    if sound.is_some() && !says_voicemail && number.is_none() && !hidden {
141        return None;
142    }
143    let at = left_at(&body, zone).or_else(|| left_at(&subject, zone)).or_else(|| message.date().map(|d| d.to_timestamp()))?;
144    let (number, key) = match number {
145        Some((written, key)) if !hidden || !key.is_empty() => (written, key),
146        _ => (String::new(), String::new()),
147    };
148    Some(Voicemail { path: path.to_path_buf(), hidden: key.is_empty() && hidden, number, key, at, seconds, sound })
149}
150
151/// An attachment that is a sound: an audio type, or a sound file's name.
152fn is_sound(part: &mail_parser::MessagePart) -> bool {
153    let by_type = part.content_type().is_some_and(|t| t.ctype().eq_ignore_ascii_case("audio"));
154    let by_name = part.attachment_name().is_some_and(|n| {
155        let n = n.to_ascii_lowercase();
156        [".wav", ".mp3", ".ogg", ".amr", ".m4a", ".opus"].iter().any(|e| n.ends_with(e))
157    });
158    by_type || by_name
159}
160
161/// The caller's number in a text: the numbers written in it, the one after
162/// words that name a caller first (`caller_leads`: "de la part du",
163/// "appelant", "from"), never one after words that name your own line
164/// (`own_line`: "votre ligne", "your number").
165fn caller(words: &VoicemailWords, text: &str, region: Option<&Region>) -> Option<(String, String)> {
166    let mut found: Vec<(i32, String, String)> = Vec::new();
167    let chars: Vec<char> = text.chars().collect();
168    let mut i = 0;
169    while i < chars.len() {
170        let c = chars[i];
171        let starts = c.is_ascii_digit() || (c == '+' && chars.get(i + 1).is_some_and(char::is_ascii_digit));
172        if !starts {
173            i += 1;
174            continue;
175        }
176        let begin = i;
177        let mut digits = 0;
178        let mut end = i;
179        while i < chars.len() && (chars[i].is_ascii_digit() || (i == begin && chars[i] == '+') || matches!(chars[i], ' ' | '.' | '-' | '\u{a0}' | '\u{202f}')) {
180            if chars[i].is_ascii_digit() {
181                digits += 1;
182                end = i + 1;
183            }
184            i += 1;
185        }
186        if !(9..=15).contains(&digits) {
187            continue;
188        }
189        let written: String = chars[begin..end].iter().collect::<String>().trim().to_string();
190        // A date, a time, a length: never a number (they hold "/" or ":", which end the run).
191        let key = phones::key(&written, region);
192        // A number as a country writes it (its international form), never a run of
193        // digits no plan reads (a file's time stamp); without a country known, a number's length.
194        if !phones::is_whole(&key) || !(key.starts_with('+') || (region.is_none() && (9..=12).contains(&digits))) {
195            continue;
196        }
197        // The 32 characters before it, less a word cut at their start ("…ade " is no "de").
198        let mut start = begin.saturating_sub(32);
199        while start > 0 && start < begin && chars[start].is_alphanumeric() && chars[start - 1].is_alphanumeric() {
200            start += 1;
201        }
202        let before: String = folded(&chars[start..begin].iter().collect::<String>());
203        let score = if holds_word(&words.caller_leads, &before) { 2 } else { 1 };
204        let score = if holds(&words.own_line, &before) { 0 } else { score };
205        found.push((score, written, key));
206    }
207    found.into_iter().enumerate().max_by_key(|(order, (score, _, _))| (*score, -(*order as i64))).filter(|(_, (score, _, _))| *score > 0).map(|(_, (_, written, key))| (written, key))
208}
209
210/// When the message was left, as the text writes it: "06/10/2026 à 09:31",
211/// "le 06/10/26 à 9h31", "2026-10-06 09:31:05". In `zone`.
212fn left_at(text: &str, zone: &jiff::tz::TimeZone) -> Option<i64> {
213    let chars: Vec<char> = text.chars().collect();
214    let number_at = |from: usize| -> Option<(i64, usize)> {
215        let mut end = from;
216        while end < chars.len() && chars[end].is_ascii_digit() && end - from < 4 {
217            end += 1;
218        }
219        (end > from).then(|| (chars[from..end].iter().collect::<String>().parse::<i64>().unwrap_or(-1), end))
220    };
221    let skip = |mut at: usize, words: &[&str]| -> usize {
222        loop {
223            while at < chars.len() && matches!(chars[at], ' ' | ',' | '\u{a0}' | '\u{202f}') {
224                at += 1;
225            }
226            let rest: String = folded(&chars[at..chars.len().min(at + 6)].iter().collect::<String>());
227            match words.iter().find(|w| rest.starts_with(**w)) {
228                Some(w) => at += w.chars().count(),
229                None => return at,
230            }
231        }
232    };
233    for start in 0..chars.len() {
234        if !chars[start].is_ascii_digit() || (start > 0 && chars[start - 1].is_ascii_digit()) {
235            continue;
236        }
237        let Some((first, after_first)) = number_at(start) else { continue };
238        let sep = chars.get(after_first).copied();
239        let (year, month, day, after_date) = match sep {
240            Some('/') => {
241                let Some((month, after_month)) = number_at(after_first + 1) else { continue };
242                if chars.get(after_month) != Some(&'/') {
243                    continue;
244                }
245                let Some((year, after_year)) = number_at(after_month + 1) else { continue };
246                (if year < 100 { 2000 + year } else { year }, month, first, after_year)
247            }
248            Some('-') if first > 1900 => {
249                let Some((month, after_month)) = number_at(after_first + 1) else { continue };
250                if chars.get(after_month) != Some(&'-') {
251                    continue;
252                }
253                let Some((day, after_day)) = number_at(after_month + 1) else { continue };
254                (first, month, day, after_day)
255            }
256            _ => continue,
257        };
258        let at_time = skip(after_date, &["a ", "at ", "-", "t"]);
259        let Some((hour, after_hour)) = number_at(at_time) else { continue };
260        let sep = chars.get(after_hour).copied();
261        if !matches!(sep, Some(':') | Some('h') | Some('H')) {
262            continue;
263        }
264        let (minute, after_minute) = number_at(after_hour + 1).unwrap_or((0, after_hour + 1));
265        let second = if chars.get(after_minute) == Some(&':') { number_at(after_minute + 1).map_or(0, |(s, _)| s) } else { 0 };
266        let (Ok(year), Ok(month), Ok(day), Ok(hour), Ok(minute), Ok(second)) = (i16::try_from(year), i8::try_from(month), i8::try_from(day), i8::try_from(hour), i8::try_from(minute), i8::try_from(second)) else { continue };
267        let (Ok(civil), Ok(time)) = (jiff::civil::Date::new(year, month, day), jiff::civil::Time::new(hour, minute, second, 0)) else { continue };
268        if let Ok(zoned) = civil.to_datetime(time).to_zoned(zone.clone()) {
269            return Some(zoned.timestamp().as_second());
270        }
271    }
272    None
273}
274
275/// How long the message lasts: "Durée : 42 secondes", "durée du message :
276/// 0:42", "1 min 05 s", "duration: 42 s". Read after the word that says it
277/// (`said`, its first in the folded text `words`).
278fn duration(said: &[String], words: &str) -> Option<u32> {
279    let at = said.iter().map(|w| folded(w)).filter(|w| !w.trim().is_empty()).filter_map(|w| words.find(&w).map(|i| i + w.len())).min()?;
280    let rest: String = words[at..].chars().take(48).collect();
281    let rest = rest.trim_start_matches(|c: char| !c.is_ascii_digit());
282    let mut numbers: Vec<(u32, String)> = Vec::new();
283    let mut chars = rest.chars().peekable();
284    while numbers.len() < 3 {
285        let mut digits = String::new();
286        while let Some(c) = chars.peek().copied().filter(char::is_ascii_digit) {
287            digits.push(c);
288            chars.next();
289        }
290        if digits.is_empty() {
291            break;
292        }
293        let mut unit = String::new();
294        while let Some(c) = chars.peek().copied() {
295            if c.is_ascii_digit() {
296                break;
297            }
298            unit.push(c);
299            chars.next();
300            if unit.trim().len() > 10 {
301                break;
302            }
303        }
304        numbers.push((digits.parse().ok()?, unit.trim().to_string()));
305        if !(unit.trim().is_empty() || unit.trim() == ":" || unit.trim().starts_with("min") || unit.trim().starts_with('h') || unit.trim() == "m") {
306            break;
307        }
308    }
309    match numbers.as_slice() {
310        [(minutes, sep), (seconds, _), ..] if sep == ":" => Some(minutes * 60 + seconds),
311        [(minutes, unit), (seconds, _), ..] if unit.starts_with("min") || unit == "m" => Some(minutes * 60 + seconds),
312        [(minutes, unit), ..] if unit.starts_with("min") => Some(minutes * 60),
313        [(seconds, unit), ..] if unit.starts_with('s') || unit.is_empty() => Some(*seconds),
314        _ => None,
315    }
316}
317
318/// Each voicemail linked to the call it followed: the same number (or both
319/// hidden), left at most half an hour after the call refused (a few minutes
320/// before it too: clocks differ); the nearest first, one call each.
321pub fn link(held: &[Held], mails: &[Voicemail]) -> BTreeMap<String, usize> {
322    let mut linked: BTreeMap<String, usize> = BTreeMap::new();
323    let mut order: Vec<usize> = (0..mails.len()).collect();
324    order.sort_by_key(|i| mails[*i].at);
325    for i in order {
326        let mail = &mails[i];
327        let candidate = held
328            .iter()
329            .filter(|h| !linked.contains_key(&h.id()))
330            .filter(|h| if mail.key.is_empty() { h.hidden } else { !h.hidden && h.key == mail.key })
331            .filter(|h| {
332                let call = h.at / 1000;
333                mail.at >= call - LINK_AFTER && mail.at - call <= LINK_BEFORE
334            })
335            .min_by_key(|h| (mail.at - h.at / 1000).abs());
336        if let Some(h) = candidate {
337            linked.insert(h.id(), i);
338        }
339    }
340    linked
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const WAV: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA=";
348
349    /// The operator of these tests, invented, added as you would add yours.
350    fn words() -> VoicemailWords {
351        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.voicemail.operators]\nadd = [\"operator-mobile.example\", \"operator.example\"]\n").unwrap();
352        crate::words::Words::of(&config).voicemail.clone()
353    }
354
355    /// A mail as an operator might send it: `from`, `subject`, a text body, a sound or none.
356    fn mail(from: &str, subject: &str, body: &str, sound: Option<(&str, &str)>) -> Vec<u8> {
357        let mut raw = format!("From: {from}\r\nTo: someone@example.org\r\nSubject: {subject}\r\nDate: Tue, 06 Oct 2026 09:33:00 +0200\r\nMIME-Version: 1.0\r\n");
358        match sound {
359            Some((kind, name)) => {
360                raw.push_str("Content-Type: multipart/mixed; boundary=\"b1\"\r\n\r\n--b1\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n");
361                raw.push_str(body);
362                raw.push_str(&format!("\r\n--b1\r\nContent-Type: {kind}; name=\"{name}\"\r\nContent-Disposition: attachment; filename=\"{name}\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{WAV}\r\n--b1--\r\n"));
363            }
364            None => {
365                raw.push_str("Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n");
366                raw.push_str(body);
367                raw.push_str("\r\n");
368            }
369        }
370        raw.into_bytes()
371    }
372
373    fn paris() -> jiff::tz::TimeZone {
374        jiff::tz::TimeZone::get("Europe/Paris").unwrap()
375    }
376
377    fn at(text: &str) -> i64 {
378        text.parse::<jiff::Zoned>().unwrap().timestamp().as_second()
379    }
380
381    fn read_it(raw: &[u8]) -> Option<Voicemail> {
382        read(&words(), raw, Path::new("/mail/cur/1"), phones::region_named("FR"), &paris(), &["mx.example.net".to_string()])
383    }
384
385    fn may_be(raw: &[u8]) -> bool {
386        super::may_be(&words().operators, raw)
387    }
388
389    #[test]
390    fn an_operator_s_voicemail_with_its_sound() {
391        let raw = mail(
392            "Operator Mobile <messagerie@operator-mobile.example>",
393            "Nouveau message vocal de 01 99 00 12 34",
394            "Bonjour,\r\nVous avez reçu un nouveau message vocal sur votre ligne 06 39 98 00 01 de la part du 01 99 00 12 34 le 06/10/2026 à 09:31.\r\nDurée du message : 42 secondes.\r\n",
395            Some(("audio/x-wav", "message.wav")),
396        );
397        assert!(may_be(&raw));
398        let v = read_it(&raw).unwrap();
399        assert_eq!((v.number.as_str(), v.key.as_str(), v.hidden), ("01 99 00 12 34", "+33199001234", false));
400        assert_eq!(v.at, at("2026-10-06T09:31[Europe/Paris]"));
401        assert_eq!((v.seconds, v.sound), (Some(42), Some(0)));
402    }
403
404    #[test]
405    fn read_tolerantly_whatever_the_words() {
406        // The number in the text only, after the subscriber's own line; the length as 0:42; the time with seconds.
407        let raw = mail(
408            "Messagerie Vocale <voicemail@mobile.operator.example>",
409            "Vous avez un nouveau message",
410            "Votre ligne : 06 39 98 00 01\nAppelant : +33 1 99 00 56 78\nDate : 06/10/2026 09:40:05\nDurée : 0:42\n",
411            Some(("application/octet-stream", "0199005678_20261006094005.wav")),
412        );
413        let v = read_it(&raw).unwrap();
414        assert_eq!(v.key, "+33199005678");
415        assert_eq!(v.at, at("2026-10-06T09:40:05[Europe/Paris]"));
416        assert_eq!((v.seconds, v.sound), (Some(42), Some(0)));
417        // A hidden caller, minutes and seconds, the time as "9h05", the date in the subject's year form.
418        let raw = mail("Operator <noreply@operator.example>", "Message vocal", "Un message vocal de numéro masqué le 07/10/26 à 9h05. Durée : 1 min 05 s.", Some(("audio/wav", "message.wav")));
419        let v = read_it(&raw).unwrap();
420        assert!(v.hidden && v.key.is_empty() && v.number.is_empty());
421        assert_eq!(v.at, at("2026-10-07T09:05[Europe/Paris]"));
422        assert_eq!(v.seconds, Some(65));
423        // No time in the words: the mail's Date.
424        let raw = mail("Operator Mobile <messagerie@operator-mobile.example>", "Message vocal de 0199004321", "Écoutez la pièce jointe.", Some(("audio/x-wav", "m.wav")));
425        let v = read_it(&raw).unwrap();
426        assert_eq!((v.key.as_str(), v.at, v.seconds), ("+33199004321", at("2026-10-06T09:33[Europe/Paris]"), None));
427        // A notice without its sound ("simple" mode): who, when, how long; nothing to play.
428        let raw = mail("Operator Mobile <messagerie@operator-mobile.example>", "Nouveau message vocal", "Nouveau message vocal de la part du 01 99 00 12 34 le 06/10/2026 à 09:31. Durée : 12 s.", None);
429        let v = read_it(&raw).unwrap();
430        assert_eq!((v.key.as_str(), v.seconds, v.sound), ("+33199001234", Some(12), None));
431        // The number in the sound's name only, after its time stamp: the stamp is no number.
432        let raw = mail("Operator Mobile <messagerie@operator-mobile.example>", "Nouveau message vocal", "Vous avez un nouveau message.", Some(("audio/x-wav", "20261006093100_0199005555.wav")));
433        assert_eq!(read_it(&raw).unwrap().key, "+33199005555");
434        // English words, a numeric date the other way round.
435        let raw = mail("Operator Mobile <voicemail@operator-mobile.example>", "New voicemail", "You have a new voice message from +33 4 65 71 12 34 on 2026-10-06 at 09:31. Duration: 42 s", Some(("audio/x-wav", "vm.wav")));
436        let v = read_it(&raw).unwrap();
437        assert_eq!((v.key.as_str(), v.at, v.seconds), ("+33465711234", at("2026-10-06T09:31[Europe/Paris]"), Some(42)));
438    }
439
440    #[test]
441    fn other_mail_is_not_voicemail() {
442        // Forged in the operator's name, as the provider's own checks say: never.
443        let forged = mail(
444            "Operator Mobile <messagerie@operator-mobile.example>\r\nAuthentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=operator-mobile.example",
445            "Nouveau message vocal de 01 99 00 12 34",
446            "Durée : 42 secondes.",
447            Some(("audio/x-wav", "message.wav")),
448        );
449        assert!(read_it(&forged).is_none());
450        // Not the operator's: never, whatever it says or carries.
451        let raw = mail("Operator Mobile <messagerie@operator-mobile.example.net>", "Nouveau message vocal de 01 99 00 12 34", "Durée : 42 secondes.", Some(("audio/x-wav", "message.wav")));
452        assert!(read_it(&raw).is_none());
453        let ops = words().operators;
454        assert!(!from_operator(&ops, "x@operatormobile.example") && from_operator(&ops, "a@operator-mobile.example") && from_operator(&ops, "a@smtp.operator.example") && !from_operator(&ops, "a@notoperator.example"));
455        // No operator named (the packs name none): nothing is voicemail.
456        let raw = mail("Operator Mobile <messagerie@operator-mobile.example>", "Nouveau message vocal de 01 99 00 12 34", "Durée : 42 secondes.", Some(("audio/x-wav", "message.wav")));
457        let builtin = crate::words::Words::builtin();
458        assert!(super::read(&builtin.voicemail, &raw, Path::new("/m"), phones::region_named("FR"), &paris(), &[]).is_none());
459        assert!(!super::may_be(&builtin.voicemail.operators, &raw));
460        // The operator's invoice, its code changed: no sound, no caller with a length.
461        let raw = mail("Operator Mobile <noreply@operator-mobile.example>", "Votre facture mobile", "Votre facture de 19,99 € est disponible. Ligne 06 39 98 00 01.", None);
462        assert!(read_it(&raw).is_none());
463        let raw = mail("Operator Mobile <noreply@operator-mobile.example>", "Votre code de messagerie vocale a été modifié", "Le code de votre messagerie vocale a été modifié le 06/10/2026 à 09:31.", None);
464        assert!(read_it(&raw).is_none());
465        // The quick look reads the head only; a From folded on two lines is one.
466        assert!(!may_be(b"From: Jane <jane@example.org>\r\nSubject: free tickets\r\n\r\nfree\r\n"));
467        assert!(may_be(b"From: =?UTF-8?Q?Messagerie_vocale?=\r\n <messagerie@operator-mobile.example>\r\nSubject: x\r\n\r\nbody\r\n"));
468        assert!(!may_be(b"From: Jane\r\n <jane@example.org>\r\nX-Note: free\r\n\r\nbody\r\n"));
469    }
470
471    fn held(at: i64, key: &str) -> Held {
472        Held { at: at * 1000, key: key.into(), hidden: key.is_empty(), who: if key.is_empty() { "hidden".into() } else { "stranger".into() }, ..Held::default() }
473    }
474
475    fn voicemail(at: i64, key: &str) -> Voicemail {
476        Voicemail { path: PathBuf::from("/m"), number: key.into(), key: key.into(), hidden: key.is_empty(), at, seconds: Some(42), sound: Some(0) }
477    }
478
479    #[test]
480    fn each_voicemail_goes_with_the_call_it_followed() {
481        let t = at("2026-10-06T09:30[Europe/Paris]");
482        let calls = vec![held(t, "+33199001234"), held(t + 600, "+33199001234"), held(t + 60, ""), held(t + 120, "+33465711234")];
483        let mails = vec![voicemail(t + 30, "+33199001234"), voicemail(t + 700, "+33199001234"), voicemail(t + 90, ""), voicemail(t + 4 * 3600, "+33465711234")];
484        let linked = link(&calls, &mails);
485        assert_eq!(linked.get(&calls[0].id()), Some(&0));
486        assert_eq!(linked.get(&calls[1].id()), Some(&1), "the second call has the second message");
487        assert_eq!(linked.get(&calls[2].id()), Some(&2), "hidden with hidden");
488        assert_eq!(linked.get(&calls[3].id()), None, "four hours later is another call's");
489        // A number that never called: nothing linked.
490        assert!(link(&calls, &[voicemail(t, "+33199009999")]).is_empty());
491    }
492
493    /// The callers' leads are whole words: the shipped ones ("du", "de :")
494    /// and yours, added in `[words]` without the spaces a trimmed word loses.
495    #[test]
496    fn the_callers_leads_are_whole_words() {
497        let leads = |words: &[&str]| words.iter().map(|w| w.to_string()).collect::<Vec<String>>();
498        assert!(holds_word(&leads(&["du"]), "de la part du "));
499        assert!(!holds_word(&leads(&["du"]), "duree "), "inside a word");
500        assert!(holds_word(&leads(&["de :"]), "numero de : "));
501        assert!(!holds_word(&leads(&["de :"]), "monde : "), "its end is no letter, its start is");
502        assert!(holds_word(&leads(&[" von "]), "anruf von "), "trimmed as yours are");
503        assert!(!holds_word(&leads(&["von"]), "hinweis davon "));
504        // A lead of yours: the number after it is the caller's, not the one after a word that holds it.
505        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.voicemail.caller_leads]\nadd = [\" von \"]\n").unwrap();
506        let words = crate::words::Words::of(&config).voicemail.clone();
507        let found = caller(&words, "Hinweis davon 06 39 98 00 01. Anruf von 01 99 00 12 34.", phones::region_named("FR"));
508        assert_eq!(found.map(|(_, key)| key).as_deref(), Some("+33199001234"));
509        // A word cut at the window's start is no lead: the 32 characters before the
510        // first number begin with the "de" of "ade", which would rank it with the caller's.
511        let text = format!("Il ade {} 06 39 98 00 01, appelant 01 99 00 12 34.", "x".repeat(28));
512        let found = caller(&words, &text, phones::region_named("FR"));
513        assert_eq!(found.map(|(_, key)| key).as_deref(), Some("+33199001234"));
514    }
515}