Skip to main content

sioul_core/
threads.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Conversations: messages tied by their Message-ID and the ids they answer
5//! or cite (In-Reply-To, References; RFC 5322 §3.6.4), never by subject alone,
6//! which would join strangers who both wrote "Hello". A message tied to
7//! another through a third is in the same conversation.
8
9use crate::card::Card;
10use crate::mailindex::bare_id;
11use std::collections::HashMap;
12
13/// The ids a message is tied by: its own first, then those it answers or cites.
14pub fn ids_of(card: &Card) -> Vec<String> {
15    let mut ids: Vec<String> = card.message_id.iter().map(|id| bare_id(id)).collect();
16    for name in ["In-Reply-To", "References"] {
17        for value in card.headers.all(name) {
18            ids.extend(value.split(|c: char| c.is_whitespace() || c == ',').filter(|t| t.contains('@')).map(bare_id));
19        }
20    }
21    ids.retain(|id| !id.is_empty());
22    ids
23}
24
25/// The conversation of each card, as a number: equal for messages of the same conversation.
26pub fn group(cards: &[&Card]) -> Vec<usize> {
27    // Union-find over the cards, joined through the ids they share.
28    let mut parent: Vec<usize> = (0..cards.len()).collect();
29    fn root(parent: &mut [usize], mut i: usize) -> usize {
30        while parent[i] != i {
31            parent[i] = parent[parent[i]];
32            i = parent[i];
33        }
34        i
35    }
36    let mut first_with: HashMap<String, usize> = HashMap::new();
37    for (i, card) in cards.iter().enumerate() {
38        for id in ids_of(card) {
39            match first_with.get(&id) {
40                Some(&j) => {
41                    let (a, b) = (root(&mut parent, i), root(&mut parent, j));
42                    parent[a.max(b)] = a.min(b);
43                }
44                None => {
45                    first_with.insert(id, i);
46                }
47            }
48        }
49    }
50    (0..cards.len()).map(|i| root(&mut parent, i)).collect()
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    fn card(raw: &str) -> Card {
58        Card::from_bytes(raw.replace('\n', "\r\n").as_bytes()).unwrap()
59    }
60
61    #[test]
62    fn answers_join_their_conversation() {
63        let first = card("From: jane@example.org\nSubject: Hello\nMessage-ID: <a@example.org>\n\nHi.\n");
64        let answer = card("From: me@example.net\nSubject: Re: Hello\nMessage-ID: <b@example.net>\nIn-Reply-To: <a@example.org>\n\nHi Jane.\n");
65        // Tied to the first through the answer only.
66        let third = card("From: jane@example.org\nSubject: Re: Hello\nMessage-ID: <c@example.org>\nReferences: <b@example.net>\n\nThanks.\n");
67        let stranger = card("From: paul@example.com\nSubject: Hello\nMessage-ID: <d@example.com>\n\nHello too.\n");
68        let groups = group(&[&first, &answer, &third, &stranger]);
69        assert_eq!(groups[0], groups[1]);
70        assert_eq!(groups[0], groups[2]);
71        assert_ne!(groups[0], groups[3], "the same subject does not make a conversation");
72        assert_eq!(ids_of(&third), vec!["c@example.org", "b@example.net"]);
73    }
74}