Skip to main content

ecr_store/
address.rs

1use ecr_core::message::Address;
2use std::collections::BTreeMap;
3
4/// Builds an address book from `notmuch address` output.
5///
6/// Addresses seen as recipients of your own mail rank above ones merely seen
7/// as senders: you are far more likely to write to someone you have written to
8/// before than to a newsletter that has written to you.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum Source {
11    /// Someone you have sent mail to.
12    Recipient,
13    /// Someone who has sent you mail.
14    Sender,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct BookEntry {
19    pub address: Address,
20    pub source: Source,
21    pub count: usize,
22}
23
24#[derive(Debug, Clone, Default)]
25pub struct AddressBook {
26    entries: BTreeMap<String, BookEntry>,
27}
28
29impl AddressBook {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    pub fn len(&self) -> usize {
35        self.entries.len()
36    }
37
38    pub fn is_empty(&self) -> bool {
39        self.entries.is_empty()
40    }
41
42    /// Adds every line of `notmuch address` output.
43    pub fn add_lines(&mut self, output: &str, source: Source) {
44        for line in output.lines() {
45            self.add(line, source);
46        }
47    }
48
49    pub fn add(&mut self, line: &str, source: Source) {
50        let Some(address) = parse_line(line) else {
51            return;
52        };
53        let key = address.email.to_ascii_lowercase();
54
55        self.entries
56            .entry(key)
57            .and_modify(|existing| {
58                existing.count += 1;
59                // A name is better than none, and a recipient beats a sender.
60                if existing.address.name.is_none() && address.name.is_some() {
61                    existing.address.name = address.name.clone();
62                }
63                if source < existing.source {
64                    existing.source = source;
65                }
66            })
67            .or_insert(BookEntry {
68                address,
69                source,
70                count: 1,
71            });
72    }
73
74    /// Most useful first: recipients before senders, then by how often seen.
75    pub fn ranked(&self) -> Vec<BookEntry> {
76        let mut out: Vec<BookEntry> = self.entries.values().cloned().collect();
77        out.sort_by(|a, b| {
78            a.source
79                .cmp(&b.source)
80                .then(b.count.cmp(&a.count))
81                .then(a.address.email.cmp(&b.address.email))
82        });
83        out
84    }
85}
86
87fn parse_line(line: &str) -> Option<Address> {
88    let value = line.trim();
89    if value.is_empty() {
90        return None;
91    }
92
93    if let Some(open) = value.rfind('<') {
94        let close = value.rfind('>')?;
95        if close <= open {
96            return None;
97        }
98        let email = value[open + 1..close].trim();
99        if !email.contains('@') {
100            return None;
101        }
102        let name = value[..open].trim().trim_matches('"').trim();
103        return Some(Address::new(
104            (!name.is_empty()).then(|| name.to_string()),
105            email,
106        ));
107    }
108
109    (value.contains('@') && !value.contains(' ')).then(|| Address::new(None, value))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn parses_a_named_address() {
118        let address = parse_line("Alice Smith <alice@example.com>").unwrap();
119        assert_eq!(address.name.as_deref(), Some("Alice Smith"));
120        assert_eq!(address.email, "alice@example.com");
121    }
122
123    #[test]
124    fn parses_a_bare_address() {
125        let address = parse_line("bob@example.com").unwrap();
126        assert_eq!(address.name, None);
127        assert_eq!(address.email, "bob@example.com");
128    }
129
130    #[test]
131    fn strips_quotes_from_a_name() {
132        let address = parse_line("\"Doe, Jane\" <l@x.com>").unwrap();
133        assert_eq!(address.name.as_deref(), Some("Doe, Jane"));
134    }
135
136    #[test]
137    fn rejects_lines_that_are_not_addresses() {
138        assert!(parse_line("").is_none());
139        assert!(parse_line("   ").is_none());
140        assert!(parse_line("Just A Name").is_none());
141        assert!(parse_line("Name <not-an-address>").is_none());
142    }
143
144    #[test]
145    fn deduplicates_by_address_case_insensitively() {
146        let mut book = AddressBook::new();
147        book.add("Alice <alice@example.com>", Source::Sender);
148        book.add("alice@EXAMPLE.com", Source::Sender);
149
150        assert_eq!(book.len(), 1);
151        assert_eq!(book.ranked()[0].count, 2);
152    }
153
154    #[test]
155    fn keeps_the_first_name_it_learns() {
156        let mut book = AddressBook::new();
157        book.add("alice@example.com", Source::Sender);
158        book.add("Alice Smith <alice@example.com>", Source::Sender);
159
160        assert_eq!(
161            book.ranked()[0].address.name.as_deref(),
162            Some("Alice Smith")
163        );
164    }
165
166    #[test]
167    fn a_recipient_outranks_a_sender() {
168        let mut book = AddressBook::new();
169        book.add("newsletter@corp.com", Source::Sender);
170        book.add("newsletter@corp.com", Source::Sender);
171        book.add("colleague@work.com", Source::Recipient);
172
173        assert_eq!(book.ranked()[0].address.email, "colleague@work.com");
174    }
175
176    #[test]
177    fn seeing_someone_as_a_recipient_upgrades_them() {
178        let mut book = AddressBook::new();
179        book.add("person@x.com", Source::Sender);
180        book.add("person@x.com", Source::Recipient);
181
182        assert_eq!(book.ranked()[0].source, Source::Recipient);
183    }
184
185    #[test]
186    fn frequency_breaks_ties_within_a_source() {
187        let mut book = AddressBook::new();
188        book.add("rare@x.com", Source::Sender);
189        book.add("often@x.com", Source::Sender);
190        book.add("often@x.com", Source::Sender);
191
192        assert_eq!(book.ranked()[0].address.email, "often@x.com");
193    }
194
195    #[test]
196    fn ingests_multiline_output_and_skips_junk() {
197        let mut book = AddressBook::new();
198        book.add_lines(
199            "Alice <alice@x.com>\n\nnot an address\nbob@y.com\n",
200            Source::Sender,
201        );
202
203        assert_eq!(book.len(), 2);
204    }
205
206    #[test]
207    fn an_empty_book_is_empty() {
208        let book = AddressBook::new();
209        assert!(book.is_empty());
210        assert!(book.ranked().is_empty());
211    }
212}