Skip to main content

squeeze/
email.rs

1use super::Finder;
2use crate::domain::{glued_to_two_byte_char, is_label_byte, looks_like_tld};
3use std::ops::Range;
4
5// RFC 5322 atext, minus "=" and "`": in extracted text they almost always
6// delimit the address (`--author=a@b.com`, `` `a@b.com` ``).
7const LOCAL_CHARS: [bool; 256] = {
8    let mut table = [false; 256];
9    let mut i = 0u16;
10    while i < 256 {
11        let b = i as u8;
12        table[i as usize] = b.is_ascii_alphanumeric()
13            || matches!(
14                b,
15                b'.' | b'!'
16                    | b'#'
17                    | b'$'
18                    | b'%'
19                    | b'&'
20                    | b'\''
21                    | b'*'
22                    | b'+'
23                    | b'/'
24                    | b'?'
25                    | b'^'
26                    | b'_'
27                    | b'{'
28                    | b'|'
29                    | b'}'
30                    | b'~'
31                    | b'-'
32            );
33        i += 1;
34    }
35    table
36};
37
38#[derive(Default)]
39pub struct Email {}
40
41impl Finder for Email {
42    fn id(&self) -> &'static str {
43        "email"
44    }
45
46    fn triggerable(&self) -> bool {
47        true
48    }
49
50    fn line_agnostic(&self) -> bool {
51        // Every walk stops at whitespace, which includes line terminators.
52        true
53    }
54
55    fn could_trigger_at(&self, byte: u8) -> bool {
56        byte == b'@'
57    }
58
59    fn try_trigger_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
60        Self::try_at_at(input, pos)
61    }
62
63    fn find(&self, s: &str) -> Option<Range<usize>> {
64        let input = s.as_bytes();
65        let mut idx = 0;
66
67        while idx < input.len() {
68            let at_pos = idx + input[idx..].iter().position(|&b| b == b'@')?;
69            if let Some(range) = Self::try_at_at(input, at_pos) {
70                return Some(range);
71            }
72            idx = at_pos + 1;
73        }
74
75        None
76    }
77}
78
79impl Email {
80    fn try_at_at(input: &[u8], at_pos: usize) -> Option<Range<usize>> {
81        if input[at_pos] != b'@' {
82            return None;
83        }
84
85        let mut local_start = at_pos;
86        while local_start > 0 && LOCAL_CHARS[input[local_start - 1] as usize] {
87            local_start -= 1;
88        }
89        // Leading atext punctuation is valid RFC 5322 but in practice wraps
90        // the address: `'a@b.com'`, `{a@b.com}`, `**a@b.com**`.
91        while local_start < at_pos
92            && input[local_start] != b'.'
93            && !input[local_start].is_ascii_alphanumeric()
94        {
95            local_start += 1;
96        }
97
98        // Local part must be non-empty and not start/end with '.'
99        if local_start == at_pos || input[local_start] == b'.' || input[at_pos - 1] == b'.' {
100            return None;
101        }
102
103        // A fediverse mention, not an email: in `@alice@hachyderm.io` the
104        // whole span belongs to the handle finder (README's canonical
105        // `@user@example.social` example).
106        if local_start > 0 && input[local_start - 1] == b'@' {
107            return None;
108        }
109
110        // Glued to a truncated word: `müller@x.com` must not yield the
111        // corrupted `ller@x.com` (same policy as the domain finder).
112        if glued_to_two_byte_char(input, local_start) {
113            return None;
114        }
115
116        // RFC 5322 dot-atom: no consecutive dots inside the local part.
117        if input[local_start..at_pos].windows(2).any(|w| w == b"..") {
118            return None;
119        }
120
121        // Walk forwards for domain, validating in a single pass with the
122        // same label rules as the domain finder.
123        let domain_start = at_pos + 1;
124        let mut domain_end = domain_start;
125        let mut dot_count = 0u32;
126        let mut last_dot = 0usize;
127        let mut label_start = domain_start;
128
129        while domain_end < input.len() {
130            let b = input[domain_end];
131            if b == b'.' {
132                // A dot not followed by a label byte (ellipsis, end of
133                // sentence or token) ends the domain here instead of
134                // invalidating the candidate.
135                if domain_end + 1 >= input.len() || !is_label_byte(input[domain_end + 1]) {
136                    break;
137                }
138                let label_len = domain_end - label_start;
139                if label_len == 0
140                    || label_len > 63
141                    || input[label_start] == b'-'
142                    || input[domain_end - 1] == b'-'
143                {
144                    return None;
145                }
146                dot_count += 1;
147                last_dot = domain_end;
148                label_start = domain_end + 1;
149                domain_end += 1;
150            } else if is_label_byte(b) {
151                domain_end += 1;
152            } else {
153                break;
154            }
155        }
156
157        if domain_end == domain_start {
158            return None;
159        }
160
161        // Strip trailing dots/hyphens
162        while domain_end > domain_start && matches!(input[domain_end - 1], b'.' | b'-') {
163            if input[domain_end - 1] == b'.' && domain_end - 1 == last_dot {
164                dot_count -= 1;
165                if dot_count > 0 {
166                    last_dot = input[domain_start..domain_end - 1]
167                        .iter()
168                        .rposition(|&b| b == b'.')
169                        .map(|p| domain_start + p)
170                        .unwrap_or(0);
171                }
172            }
173            domain_end -= 1;
174        }
175
176        if dot_count == 0 {
177            return None;
178        }
179
180        // Validate final label (after last strip)
181        let final_label_start = if last_dot >= domain_start {
182            last_dot + 1
183        } else {
184            domain_start
185        };
186        let final_label_len = domain_end - final_label_start;
187        if final_label_len == 0 || input[final_label_start] == b'-' || input[domain_end - 1] == b'-'
188        {
189            return None;
190        }
191
192        // TLD rules shared with the domain finder: alphabetic, 2..=24 chars.
193        let tld = &input[last_dot + 1..domain_end];
194        if !looks_like_tld(tld) {
195            return None;
196        }
197
198        Some(local_start..domain_end)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn id_should_return_email() {
208        let finder = Email::default();
209        assert_eq!("email", finder.id());
210    }
211
212    #[test]
213    fn find_should_stop_the_local_part_at_an_assignment() {
214        let finder = Email::default();
215        for input in [
216            "git commit --author=ops@example.com",
217            "EMAIL=ops@example.com",
218        ] {
219            let range = finder.find(input).unwrap();
220            assert_eq!("ops@example.com", &input[range], "{input}");
221        }
222    }
223
224    #[test]
225    fn find_should_extract_simple_email() {
226        let finder = Email::default();
227        let input = "contact user@example.com for info";
228        let range = finder.find(input).unwrap();
229        assert_eq!("user@example.com", &input[range]);
230    }
231
232    #[test]
233    fn find_should_extract_email_at_start() {
234        let finder = Email::default();
235        let input = "alice@example.org is here";
236        let range = finder.find(input).unwrap();
237        assert_eq!("alice@example.org", &input[range]);
238    }
239
240    #[test]
241    fn find_should_extract_email_at_end() {
242        let finder = Email::default();
243        let input = "send to bob@test.co";
244        let range = finder.find(input).unwrap();
245        assert_eq!("bob@test.co", &input[range]);
246    }
247
248    #[test]
249    fn find_should_handle_plus_addressing() {
250        let finder = Email::default();
251        let input = "user+tag@example.com";
252        let range = finder.find(input).unwrap();
253        assert_eq!("user+tag@example.com", &input[range]);
254    }
255
256    #[test]
257    fn find_should_handle_dots_in_local_part() {
258        let finder = Email::default();
259        let input = "first.last@example.com";
260        let range = finder.find(input).unwrap();
261        assert_eq!("first.last@example.com", &input[range]);
262    }
263
264    #[test]
265    fn find_should_handle_subdomain() {
266        let finder = Email::default();
267        let input = "user@mail.example.co.uk";
268        let range = finder.find(input).unwrap();
269        assert_eq!("user@mail.example.co.uk", &input[range]);
270    }
271
272    #[test]
273    fn find_should_handle_hyphenated_domain() {
274        let finder = Email::default();
275        let input = "user@my-company.example.com";
276        let range = finder.find(input).unwrap();
277        assert_eq!("user@my-company.example.com", &input[range]);
278    }
279
280    #[test]
281    fn find_should_reject_no_at_sign() {
282        let finder = Email::default();
283        assert!(finder.find("not an email").is_none());
284    }
285
286    #[test]
287    fn find_should_reject_no_domain_dot() {
288        let finder = Email::default();
289        assert!(finder.find("user@localhost").is_none());
290    }
291
292    #[test]
293    fn find_should_reject_empty_local_part() {
294        let finder = Email::default();
295        assert!(finder.find("@example.com").is_none());
296    }
297
298    #[test]
299    fn find_should_reject_dot_start_local() {
300        let finder = Email::default();
301        assert!(finder.find(".user@example.com").is_none());
302    }
303
304    #[test]
305    fn find_should_reject_dot_end_local() {
306        let finder = Email::default();
307        assert!(finder.find("user.@example.com").is_none());
308    }
309
310    #[test]
311    fn find_should_reject_single_char_tld() {
312        let finder = Email::default();
313        assert!(finder.find("user@example.x").is_none());
314    }
315
316    #[test]
317    fn find_should_reject_numeric_tld() {
318        let finder = Email::default();
319        assert!(finder.find("user@example.123").is_none());
320    }
321
322    #[test]
323    fn find_should_reject_domain_starting_with_hyphen() {
324        let finder = Email::default();
325        assert!(finder.find("user@-example.com").is_none());
326    }
327
328    #[test]
329    fn find_should_reject_domain_ending_with_hyphen() {
330        let finder = Email::default();
331        assert!(finder.find("user@example-.com").is_none());
332    }
333
334    #[test]
335    fn find_should_extract_email_from_angle_brackets() {
336        let finder = Email::default();
337        let input = "Author <author@example.com>";
338        let range = finder.find(input).unwrap();
339        assert_eq!("author@example.com", &input[range]);
340    }
341
342    #[test]
343    fn find_should_extract_multiple_emails_iteratively() {
344        let finder = Email::default();
345        let input = "cc: alice@one.com and bob@two.org";
346
347        let mut results = Vec::new();
348        let mut idx = 0;
349        while idx < input.len() {
350            if let Some(range) = finder.find(&input[idx..]) {
351                results.push(&input[idx + range.start..idx + range.end]);
352                idx += range.end;
353            } else {
354                break;
355            }
356        }
357
358        assert_eq!(vec!["alice@one.com", "bob@two.org"], results);
359    }
360
361    #[test]
362    fn find_should_extract_email_in_parentheses() {
363        let finder = Email::default();
364        let input = "(support@example.com)";
365        let range = finder.find(input).unwrap();
366        assert_eq!("support@example.com", &input[range]);
367    }
368
369    #[test]
370    fn find_should_handle_empty_input() {
371        let finder = Email::default();
372        assert!(finder.find("").is_none());
373    }
374
375    #[test]
376    fn find_should_handle_bare_at() {
377        let finder = Email::default();
378        assert!(finder.find("@").is_none());
379    }
380
381    #[test]
382    fn find_should_skip_invalid_and_find_valid() {
383        let finder = Email::default();
384        let input = "@invalid then real@example.com";
385        let range = finder.find(input).unwrap();
386        assert_eq!("real@example.com", &input[range]);
387    }
388
389    // --- Regression: single-pass domain validation ---
390
391    #[test]
392    fn find_should_handle_domain_with_many_labels() {
393        let finder = Email::default();
394        let input = "user@a.b.c.d.example.com";
395        let range = finder.find(input).unwrap();
396        assert_eq!("user@a.b.c.d.example.com", &input[range]);
397    }
398
399    #[test]
400    fn find_should_reject_consecutive_dots_in_domain() {
401        let finder = Email::default();
402        assert!(finder.find("user@example..com").is_none());
403    }
404
405    #[test]
406    fn find_should_strip_trailing_dot_from_domain() {
407        let finder = Email::default();
408        let input = "user@example.com.";
409        let range = finder.find(input).unwrap();
410        assert_eq!("user@example.com", &input[range]);
411    }
412
413    #[test]
414    fn find_should_handle_trailing_dot_strip_and_still_validate() {
415        let finder = Email::default();
416        let input = "user@example.com. next";
417        let range = finder.find(input).unwrap();
418        assert_eq!("user@example.com", &input[range]);
419    }
420
421    #[test]
422    fn find_should_reject_label_starting_with_hyphen_deep() {
423        let finder = Email::default();
424        assert!(finder.find("user@sub.-example.com").is_none());
425    }
426
427    #[test]
428    fn find_should_reject_label_ending_with_hyphen_deep() {
429        let finder = Email::default();
430        assert!(finder.find("user@sub.example-.com").is_none());
431    }
432
433    #[test]
434    fn find_should_handle_max_length_tld() {
435        let finder = Email::default();
436        let input = "user@example.museum";
437        let range = finder.find(input).unwrap();
438        assert_eq!("user@example.museum", &input[range]);
439    }
440
441    #[test]
442    fn find_should_extract_email_adjacent_to_punctuation() {
443        let finder = Email::default();
444        let input = "email:user@example.com,next";
445        let range = finder.find(input).unwrap();
446        assert_eq!("user@example.com", &input[range]);
447    }
448}