1use 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
30pub const LINK_BEFORE: i64 = 30 * 60;
32pub const LINK_AFTER: i64 = 3 * 60;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Voicemail {
38 pub path: PathBuf,
39 pub number: String,
41 pub key: String,
43 pub hidden: bool,
45 pub at: i64,
47 pub seconds: Option<u32>,
49 pub sound: Option<u32>,
51}
52
53fn 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
58pub 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
65pub 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
87fn folded(text: &str) -> String {
89 crate::text::fold(text).into_iter().collect::<String>().to_lowercase()
90}
91
92fn holds(list: &[String], text: &str) -> bool {
94 list.iter().map(|w| folded(w)).any(|w| !w.trim().is_empty() && text.contains(&w))
95}
96
97fn holds_word(list: &[String], text: &str) -> bool {
101 crate::words::holds_word(list, text)
102}
103
104pub 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 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 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
151fn 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
161fn 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 let key = phones::key(&written, region);
192 if !phones::is_whole(&key) || !(key.starts_with('+') || (region.is_none() && (9..=12).contains(&digits))) {
195 continue;
196 }
197 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
210fn 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
275fn 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
318pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(link(&calls, &[voicemail(t, "+33199009999")]).is_empty());
491 }
492
493 #[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 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 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}