Skip to main content

mail_parser/fields/
thread.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use std::str;
8
9const MAX_PREFIX_LEN: usize = 40;
10const MAX_GENERIC_PREFIX_CHARS: usize = 4;
11const LOWERCASE_BUFFER_LEN: usize = 64;
12const URL_SCHEME_SEPARATOR: &str = "://";
13
14fn is_re_prefix(prefix: &str) -> bool {
15    hashify::set! {prefix.as_bytes(),
16        "re",
17        "res",
18        "resp",
19        "resposta",
20        "respuesta",
21        "rsp",
22        "ref",
23        "réf",
24        "rép",
25        "rep",
26        "réponse",
27        "reponse",
28        "aw",
29        "antw",
30        "antwort",
31        "antwoord",
32        "sv",
33        "svar",
34        "svara",
35        "vast",
36        "vastaus",
37        "vastus",
38        "vá",
39        "va",
40        "válasz",
41        "valasz",
42        "r",
43        "ri",
44        "rif",
45        "odp",
46        "odpowiedź",
47        "odpowiedz",
48        "odpov",
49        "odg",
50        "odgovor",
51        "απ",
52        "σχετ",
53        "απάντηση",
54        "ynt",
55        "yn",
56        "yan",
57        "yanıt",
58        "yanit",
59        "atb",
60        "atb.",
61        "atbilde",
62        "ateb",
63        "ats",
64        "ats.",
65        "atsakymas",
66        "bls",
67        "balas",
68        "השב",
69        "תשובה",
70        "رد",
71        "پاسخ",
72        "отв",
73        "ответ",
74        "відп",
75        "відповідь",
76        "отг",
77        "отговор",
78        "回复",
79        "回覆",
80        "答复",
81        "答覆",
82        "返信",
83        "회신",
84        "답장",
85        "ตอบกลับ",
86        "பதில்",
87        "trả lời",
88    }
89}
90
91fn is_fwd_prefix(prefix: &str) -> bool {
92    hashify::set! {prefix.as_bytes(),
93        "fwd",
94        "fw",
95        "rv",
96        "reenviado",
97        "enc",
98        "encaminhado",
99        "tr",
100        "transfert",
101        "wg",
102        "weitergeleitet",
103        "doorst",
104        "doorgestuurd",
105        "vs",
106        "videresendt",
107        "vb",
108        "vidarebefordrat",
109        "vl",
110        "välitetty",
111        "ed",
112        "edastatud",
113        "fs",
114        "framsenda",
115        "i",
116        "inoltro",
117        "inoltrato",
118        "pd",
119        "podaj dalej",
120        "przekazane",
121        "prosl",
122        "proslijeđeno",
123        "πρθ",
124        "προωθ",
125        "προωθημένο",
126        "i\u{307}lt",
127        "ilt",
128        "i\u{307}let",
129        "ilet",
130        "i\u{307}letilen",
131        "iletilen",
132        "yml",
133        "ymlaen",
134        "pārs",
135        "pārs.",
136        "pārsūtīts",
137        "persiųsta",
138        "trs",
139        "terusan",
140        "teruskan",
141        "továbbítás",
142        "הועבר",
143        "העברה",
144        "إعادة توجيه",
145        "перес",
146        "переслано",
147        "пересылка",
148        "пре",
149        "препратено",
150        "转发",
151        "转寄",
152        "轉寄",
153        "転送",
154        "전달",
155        "ส่งต่อ",
156        "முன்னனுப்பு",
157        "chuyển tiếp",
158    }
159}
160
161fn is_known_prefix(prefix: &str) -> bool {
162    is_re_prefix(prefix) || is_fwd_prefix(prefix)
163}
164
165fn is_generic_prefix(prefix: &str) -> bool {
166    let mut char_count = 0;
167
168    for ch in prefix.chars() {
169        if !ch.is_alphabetic() {
170            return false;
171        }
172        char_count += 1;
173        if char_count > MAX_GENERIC_PREFIX_CHARS {
174            return false;
175        }
176    }
177
178    char_count > 0
179}
180
181fn with_lowercase<T>(text: &str, f: impl FnOnce(&str) -> T) -> T {
182    let mut buffer = [0u8; LOWERCASE_BUFFER_LEN];
183    match buffer.get_mut(..text.len()) {
184        Some(lower) if text.is_ascii() => {
185            lower.copy_from_slice(text.as_bytes());
186            lower.make_ascii_lowercase();
187            f(str::from_utf8(lower).unwrap_or_default())
188        }
189        _ => f(&text.to_lowercase()),
190    }
191}
192
193fn is_prefix(text: &str, token_start: usize, token_end: usize, separator: usize) -> bool {
194    let token = text.get(token_start..token_end).unwrap_or_default();
195    let is_token_prefix = with_lowercase(token, |token| {
196        if is_known_prefix(token) {
197            Some(true)
198        } else if token_end == separator {
199            Some(
200                !text
201                    .get(separator..)
202                    .unwrap_or_default()
203                    .starts_with(URL_SCHEME_SEPARATOR)
204                    && is_generic_prefix(token),
205            )
206        } else {
207            None
208        }
209    });
210    is_token_prefix.unwrap_or_else(|| {
211        let span = text
212            .get(token_start..separator)
213            .unwrap_or_default()
214            .trim_end();
215        span.len() <= MAX_PREFIX_LEN && with_lowercase(span, is_known_prefix)
216    })
217}
218
219/// The thread name of a subject: the subject without reply and forward
220/// prefixes (`Re:`, `Fwd:` and their translations, with counters such as
221/// `Re[2]:`), bracketed list tags and trailing `(fwd)` markers, as used to
222/// group messages into threads. Borrows from `text`.
223///
224/// ```
225/// use mail_parser::thread_name;
226///
227/// assert_eq!(thread_name("Re: Fwd: [list] Lunch on Friday"), "Lunch on Friday");
228/// ```
229pub fn thread_name(text: &str) -> &str {
230    let mut token_start = 0;
231    let mut token_end = 0;
232
233    let mut thread_name_start = 0;
234    let mut fwd_start = 0;
235    let mut fwd_end = 0;
236    let mut last_blob_end = 0;
237
238    let mut in_blob = false;
239    let mut in_blob_ignore = false;
240    let mut seen_header = false;
241    let mut seen_blob_header = false;
242    let mut token_found = false;
243
244    for (pos, ch) in text.char_indices() {
245        match ch {
246            '[' => {
247                if !in_blob {
248                    if token_found {
249                        if token_end == 0 {
250                            token_end = pos;
251                        }
252                        if is_prefix(text, token_start, token_end, pos) {
253                            seen_header = true;
254                        } else {
255                            break;
256                        }
257                    }
258                    token_found = false;
259                    in_blob = true;
260                } else {
261                    break;
262                }
263            }
264            ']' if in_blob => {
265                if seen_blob_header && token_found {
266                    fwd_start = token_start;
267                    fwd_end = pos;
268                }
269                if !seen_header {
270                    last_blob_end = pos + 1;
271                }
272                in_blob = false;
273                token_found = false;
274                seen_blob_header = false;
275                in_blob_ignore = false;
276            }
277            ':' if !in_blob => {
278                if (seen_header && token_found) || (!seen_header && !token_found) {
279                    break;
280                } else if !seen_header {
281                    if token_end == 0 {
282                        token_end = pos;
283                    }
284                    if !is_prefix(text, token_start, token_end, pos) {
285                        break;
286                    }
287                } else {
288                    seen_header = false;
289                }
290                thread_name_start = pos + 1;
291                token_found = false;
292            }
293            ':' if in_blob && !in_blob_ignore => {
294                if token_end == 0 {
295                    token_end = pos;
296                }
297
298                let token = text.get(token_start..token_end).unwrap_or_default();
299                with_lowercase(token, |prefix| {
300                    if is_fwd_prefix(prefix) {
301                        token_found = false;
302                        seen_blob_header = true;
303                    } else if seen_blob_header && is_re_prefix(prefix) {
304                        token_found = false;
305                    } else {
306                        in_blob_ignore = true;
307                    }
308                });
309            }
310            _ if ch.is_whitespace() => {
311                if token_end == 0 {
312                    token_end = pos;
313                }
314            }
315            _ => {
316                if !token_found {
317                    token_start = pos;
318                    token_end = 0;
319                    token_found = true;
320                } else if !in_blob && pos - token_start > MAX_PREFIX_LEN {
321                    break;
322                }
323            }
324        }
325    }
326
327    if last_blob_end > thread_name_start
328        || (fwd_start > 0 && last_blob_end > fwd_start && fwd_start > thread_name_start)
329    {
330        let result = trim_trailing_fwd(text.get(last_blob_end..).unwrap_or_default());
331        if !result.is_empty() {
332            return result;
333        }
334    }
335
336    if fwd_start > 0 && thread_name_start < fwd_start {
337        let result = trim_trailing_fwd(text.get(fwd_start..fwd_end).unwrap_or_default());
338        if !result.is_empty() {
339            return result;
340        }
341    }
342
343    trim_trailing_fwd(text.get(thread_name_start..).unwrap_or_default())
344}
345
346fn trim_trailing_fwd(text: &str) -> &str {
347    let mut in_parentheses = false;
348    let mut trim_end = true;
349
350    let mut text_start = 0;
351    let mut text_end = text.len();
352    let mut fwd_end = 0;
353
354    for (pos, ch) in text.char_indices().rev() {
355        let end_found = match ch {
356            '(' => {
357                if in_parentheses {
358                    in_parentheses = false;
359                    if fwd_end - pos > 2
360                        && with_lowercase(
361                            text.get(pos + 1..fwd_end).unwrap_or_default(),
362                            is_fwd_prefix,
363                        )
364                    {
365                        text_end = pos;
366                        trim_end = true;
367                        continue;
368                    }
369                }
370                true
371            }
372            ')' => {
373                if in_parentheses {
374                    true
375                } else {
376                    in_parentheses = true;
377                    fwd_end = pos;
378                    false
379                }
380            }
381            _ if ch.is_whitespace() => {
382                if trim_end {
383                    text_end = pos;
384                }
385                continue;
386            }
387            _ => !in_parentheses,
388        };
389
390        if end_found {
391            let start = text
392                .char_indices()
393                .find(|(_, ch)| !ch.is_whitespace())
394                .map_or(pos, |(start, _)| start);
395            return text.get(start..text_end).unwrap_or_default();
396        }
397
398        trim_end = false;
399        text_start = pos;
400    }
401
402    text.get(text_start..text_end).unwrap_or_default()
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{thread_name, trim_trailing_fwd};
408    use crate::fields::tests::load_text_pairs;
409
410    #[test]
411    fn thread_name_fixtures() {
412        let tests = load_text_pairs("thread_name.json");
413        assert_eq!(tests.len(), 180);
414        for (input, expected) in tests {
415            assert_eq!(thread_name(&input), expected, "{input:?}");
416        }
417    }
418
419    #[test]
420    fn trailing_fwd_fixtures() {
421        let tests = load_text_pairs("trailing_fwd.json");
422        assert_eq!(tests.len(), 18);
423        for (input, expected) in tests {
424            assert_eq!(trim_trailing_fwd(&input), expected, "{input:?}");
425        }
426    }
427}