1use ecr_core::message::Address;
2use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum Source {
11 Recipient,
13 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 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 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 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}