Skip to main content

hypermail/
link.rs

1use crate::message::BodyChain;
2use crate::structs::EmailStore;
3use std::collections::HashMap;
4
5/// Links replies by scanning message bodies for quoted Message-IDs.
6///
7/// # Performance
8///
9/// Builds a single msgid → msgnum lookup table, then for each message scans
10/// its body once for `<...>`-delimited tokens and checks each against the
11/// table. This is O(total body bytes) rather than the naive O(messages² ×
12/// body bytes) of re-running `contains()` against every other message's
13/// msgid for every message.
14pub fn link_quotes(store: &mut EmailStore, ignore_types: bool, linkquotes: bool) {
15    if !linkquotes || ignore_types {
16        return;
17    }
18
19    let msgid_to_msgnum: HashMap<&str, i32> = store
20        .emails
21        .iter()
22        .filter_map(|e| e.msgid.as_deref().map(|mid| (mid, e.msgnum)))
23        .collect();
24
25    let mut new_replies = Vec::new();
26    for email in &store.emails {
27        let to_msgnum = email.msgnum;
28        let from_text = collect_body_text(&email.bodylist);
29        for candidate in extract_angle_bracket_tokens(&from_text) {
30            if let Some(&from_msgnum) = msgid_to_msgnum.get(candidate) {
31                if from_msgnum != to_msgnum {
32                    new_replies.push(crate::message::Reply {
33                        from_msgnum,
34                        msgnum: to_msgnum,
35                        data: None,
36                        maybe_reply: 1,
37                    });
38                }
39            }
40        }
41    }
42    store.replylist.extend(new_replies);
43}
44
45/// Yields each `<...>`-delimited token in `text` (e.g. `<a@b.com>`), without allocating.
46fn extract_angle_bracket_tokens(text: &str) -> impl Iterator<Item = &str> {
47    let mut rest = text;
48    std::iter::from_fn(move || {
49        let start = rest.find('<')?;
50        let after_start = &rest[start + 1..];
51        match after_start.find('>') {
52            Some(end) => {
53                let consumed = start + 1 + end + 1;
54                let token = &rest[start..consumed];
55                rest = &rest[consumed..];
56                Some(token)
57            },
58            None => None,
59        }
60    })
61}
62
63fn collect_body_text(body_chain: &BodyChain) -> String {
64    let mut text = String::new();
65    for body in &body_chain.bodies {
66        if !body.attached && !body.header {
67            text.push_str(&body.line);
68            text.push(' ');
69        }
70    }
71    text
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::message::{Body, BodyChain, EmailInfo};
78
79    #[test]
80    fn test_link_quotes_empty_store() {
81        let mut store = EmailStore::new();
82        link_quotes(&mut store, false, true);
83        assert!(store.replylist.is_empty());
84    }
85
86    #[test]
87    fn test_link_quotes_disabled() {
88        let mut store = EmailStore::new();
89        let e1 = EmailInfo {
90            msgnum: 1,
91            msgid: Some("<a@b>".to_string()),
92            bodylist: BodyChain {
93                bodies: vec![Body {
94                    line: "reply to <a@b>".to_string(),
95                    html: false,
96                    header: false,
97                    parsed_header: false,
98                    attached: false,
99                    demimed: false,
100                    msgnum: 0,
101                }],
102            },
103            ..Default::default()
104        };
105        let e2 = EmailInfo {
106            msgnum: 2,
107            msgid: Some("<b@c>".to_string()),
108            bodylist: BodyChain { bodies: Vec::new() },
109            ..Default::default()
110        };
111        store.add_email(e1);
112        store.add_email(e2);
113        link_quotes(&mut store, false, false);
114        assert!(store.replylist.is_empty());
115    }
116
117    #[test]
118    fn test_link_quotes_finds_quoted_msgid() {
119        let mut store = EmailStore::new();
120        let e1 = EmailInfo {
121            msgnum: 1,
122            msgid: Some("<a@b>".to_string()),
123            bodylist: BodyChain { bodies: Vec::new() },
124            ..Default::default()
125        };
126        let e2 = EmailInfo {
127            msgnum: 2,
128            msgid: Some("<c@d>".to_string()),
129            bodylist: BodyChain {
130                bodies: vec![Body {
131                    line: "On Mon, someone wrote: <a@b>".to_string(),
132                    html: false,
133                    header: false,
134                    parsed_header: false,
135                    attached: false,
136                    demimed: false,
137                    msgnum: 0,
138                }],
139            },
140            ..Default::default()
141        };
142        store.add_email(e1);
143        store.add_email(e2);
144        link_quotes(&mut store, false, true);
145        assert_eq!(store.replylist.len(), 1);
146        assert_eq!(store.replylist[0].from_msgnum, 1);
147        assert_eq!(store.replylist[0].msgnum, 2);
148    }
149
150    #[test]
151    fn test_extract_angle_bracket_tokens() {
152        let text = "reply to <a@b.com> and also <c@d.com> end";
153        let tokens: Vec<&str> = extract_angle_bracket_tokens(text).collect();
154        assert_eq!(tokens, vec!["<a@b.com>", "<c@d.com>"]);
155    }
156
157    #[test]
158    fn test_extract_angle_bracket_tokens_unclosed() {
159        let text = "no closing bracket <a@b.com";
160        let tokens: Vec<&str> = extract_angle_bracket_tokens(text).collect();
161        assert!(tokens.is_empty());
162    }
163
164    #[test]
165    fn test_collect_body_text() {
166        let chain = BodyChain {
167            bodies: vec![
168                Body {
169                    line: "hello".to_string(),
170                    html: false,
171                    header: false,
172                    parsed_header: false,
173                    attached: false,
174                    demimed: false,
175                    msgnum: 0,
176                },
177                Body {
178                    line: "world".to_string(),
179                    html: false,
180                    header: false,
181                    parsed_header: false,
182                    attached: true,
183                    demimed: false,
184                    msgnum: 0,
185                },
186            ],
187        };
188        let text = collect_body_text(&chain);
189        assert!(!text.contains("world"));
190        assert!(text.contains("hello"));
191    }
192}