Skip to main content

mailrs_clean/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4//! Email content cleanup primitives — HTML → readable text + sender heuristics.
5//!
6//! Four entry points cover what a mail client / inbound pipeline
7//! typically needs after parsing an RFC 5322 message:
8//!
9//! - [`clean_email_html`] — multi-stage HTML pipeline that strips
10//!   tracking pixels, hidden blocks, marketing-template chrome, and
11//!   unsafe elements, then converts what's left to a paragraph-aware
12//!   plain-text view. Returns [`CleanResult`] with the cleaned text
13//!   plus boolean flags the caller can fold into an importance /
14//!   spam score.
15//! - [`detect_bulk_sender`] — RFC 2369 `List-*` header heuristic, used
16//!   to demote mailing-list traffic in inbox sorting.
17//! - [`is_automated_sender`] — local-part pattern check for
18//!   `no-reply@`, `notification@`, etc.
19//! - [`split_quoted_content`] — separate a fresh reply from its quoted
20//!   ancestry so UIs can collapse old context.
21//!
22//! Zero I/O, no async runtime — give it strings, get strings back.
23
24/// known tracking pixel domains
25const TRACKING_DOMAINS: &[&str] = &[
26    "mailchimp.com", "sendgrid.net", "hubspot.com", "mailgun.org",
27    "constantcontact.com", "campaign-archive.com", "list-manage.com",
28    "exacttarget.com", "sailthru.com", "marketo.com", "pardot.com",
29    "braze.com", "iterable.com", "customer.io", "intercom-mail.com",
30    "mandrillapp.com", "amazonses.com", "postmarkapp.com",
31];
32
33/// tracking pixel url path keywords
34const TRACKING_PATHS: &[&str] = &[
35    "/track", "/pixel", "/beacon", "/open", "/wf/open", "/o/", "/t/",
36    "/imp", "/ci/", "/e/o/", "tracking", "1x1",
37];
38
39/// footer keywords (multi-language)
40const FOOTER_KEYWORDS: &[&str] = &[
41    "unsubscribe", "opt-out", "opt out", "manage preferences",
42    "email preferences", "update preferences", "subscription",
43    "配信停止", "退订", "取消订阅", "メール配信", "購読解除",
44    "view in browser", "view this email", "ブラウザで表示",
45    "privacy policy", "terms of service", "all rights reserved",
46    "©", "you are receiving this",
47    "this email was sent to", "no longer wish to receive",
48    "if you no longer", "to stop receiving",
49];
50
51/// Result of [`clean_email_html`].
52///
53/// The cleaned text plus signals the caller can fold into a spam / importance score.
54pub struct CleanResult {
55    /// Plain text rendering of the cleaned HTML (tracking removed, footer
56    /// chrome stripped, paragraphs preserved).
57    pub clean_text: String,
58    /// `true` when at least one tracking pixel was detected and stripped.
59    pub has_tracking_pixel: bool,
60    /// `true` when the HTML looked like a marketing template (heavy on
61    /// inline styles, hidden divs, table-based layout).
62    pub is_template_heavy: bool,
63    /// Number of `<a>` tags in the original HTML.
64    pub link_count: usize,
65    /// Number of `<img>` tags in the original HTML.
66    #[allow(dead_code)]
67    pub image_count: usize,
68    /// Ratio of plain-text bytes to total HTML bytes — useful for spotting
69    /// emails that are mostly chrome.
70    #[allow(dead_code)]
71    pub text_to_html_ratio: f32,
72}
73
74/// clean html email content through multi-stage pipeline
75pub fn clean_email_html(html: &str) -> CleanResult {
76    let has_tracking_pixel = detect_tracking_pixels(html);
77    let link_count = count_pattern(html, "<a ");
78    let image_count = count_pattern(html, "<img ");
79
80    // stage 1: remove script, style, comments
81    let stage1 = remove_unsafe_elements(html);
82
83    // stage 2: remove tracking pixels and hidden elements
84    let stage2 = remove_tracking_and_hidden(&stage1);
85
86    // stage 3: remove template header/footer
87    let (stage3, footer_removed) = remove_template_chrome(&stage2);
88
89    // stage 4: convert to clean text
90    let clean_text = html_to_clean_text(&stage3);
91
92    let text_to_html_ratio = if html.is_empty() {
93        1.0
94    } else {
95        clean_text.len() as f32 / html.len() as f32
96    };
97
98    // template-heavy: low text ratio + many images or footer removed
99    let is_template_heavy = text_to_html_ratio < 0.05 || (footer_removed && text_to_html_ratio < 0.15);
100
101    CleanResult {
102        clean_text,
103        has_tracking_pixel,
104        is_template_heavy,
105        link_count,
106        image_count,
107        text_to_html_ratio,
108    }
109}
110
111/// detect tracking pixels in raw html
112fn detect_tracking_pixels(html: &str) -> bool {
113    let lower = html.to_lowercase();
114
115    // check for 1x1 or 0x0 images
116    for img_start in find_all_positions(&lower, "<img") {
117        let img_end = match lower[img_start..].find('>') {
118            Some(p) => img_start + p + 1,
119            None => continue,
120        };
121        let tag = &lower[img_start..img_end];
122
123        // size-based detection
124        let is_tiny = (tag.contains("width=\"1\"") || tag.contains("width='1'")
125            || tag.contains("width:1") || tag.contains("width: 1")
126            || tag.contains("width=\"0\"") || tag.contains("width='0'"))
127            && (tag.contains("height=\"1\"") || tag.contains("height='1'")
128                || tag.contains("height:1") || tag.contains("height: 1")
129                || tag.contains("height=\"0\"") || tag.contains("height='0'"));
130
131        // hidden via css
132        let is_hidden = tag.contains("display:none") || tag.contains("display: none")
133            || tag.contains("visibility:hidden") || tag.contains("visibility: hidden")
134            || tag.contains("opacity:0") || tag.contains("opacity: 0");
135
136        if is_tiny || is_hidden {
137            return true;
138        }
139
140        // domain-based detection
141        if let Some(src) = extract_attr(tag, "src") {
142            for domain in TRACKING_DOMAINS {
143                if src.contains(domain) {
144                    return true;
145                }
146            }
147            for path in TRACKING_PATHS {
148                if src.contains(path) {
149                    return true;
150                }
151            }
152        }
153    }
154
155    false
156}
157
158/// remove <script>, <style>, <iframe>, html comments
159fn remove_unsafe_elements(html: &str) -> String {
160    let mut result = html.to_string();
161
162    // remove html comments <!-- ... -->
163    while let Some(start) = result.find("<!--") {
164        if let Some(end) = result[start..].find("-->") {
165            result.replace_range(start..start + end + 3, "");
166        } else {
167            break;
168        }
169    }
170
171    // remove block elements by tag name
172    for tag in &["script", "style", "iframe", "noscript", "svg"] {
173        result = remove_tag_block(&result, tag);
174    }
175
176    result
177}
178
179/// remove tracking pixels and hidden elements
180fn remove_tracking_and_hidden(html: &str) -> String {
181    let mut result = String::with_capacity(html.len());
182    let lower = html.to_lowercase();
183    let mut pos = 0;
184
185    while pos < html.len() {
186        if let Some(img_offset) = lower[pos..].find("<img") {
187            let img_start = pos + img_offset;
188            // copy text before this img
189            result.push_str(&html[pos..img_start]);
190
191            let img_end = match lower[img_start..].find('>') {
192                Some(p) => img_start + p + 1,
193                None => {
194                    pos = img_start + 4;
195                    continue;
196                }
197            };
198
199            let tag_lower = &lower[img_start..img_end];
200
201            // check if tracking pixel
202            let is_tiny = (tag_lower.contains("width=\"1\"") || tag_lower.contains("width='1'")
203                || tag_lower.contains("width=\"0\"") || tag_lower.contains("width='0'"))
204                && (tag_lower.contains("height=\"1\"") || tag_lower.contains("height='1'")
205                    || tag_lower.contains("height=\"0\"") || tag_lower.contains("height='0'"));
206
207            let is_hidden = tag_lower.contains("display:none") || tag_lower.contains("display: none")
208                || tag_lower.contains("visibility:hidden");
209
210            if is_tiny || is_hidden {
211                // skip this img tag
212                pos = img_end;
213            } else {
214                result.push_str(&html[img_start..img_end]);
215                pos = img_end;
216            }
217        } else {
218            result.push_str(&html[pos..]);
219            break;
220        }
221    }
222
223    // remove any elements with display:none style (divs, spans, etc.)
224    result = remove_hidden_blocks(&result);
225
226    result
227}
228
229/// remove elements with display:none
230fn remove_hidden_blocks(html: &str) -> String {
231    let lower = html.to_lowercase();
232    let mut result = String::with_capacity(html.len());
233    let mut pos = 0;
234
235    while pos < html.len() {
236        if let Some(offset) = lower[pos..].find("display:none") {
237            let check_pos = pos + offset;
238            // find the enclosing tag
239            if let Some(tag_start) = html[..check_pos].rfind('<') {
240                let tag_lower = &lower[tag_start..];
241                // find the tag name
242                if let Some(tag_name) = extract_tag_name(tag_lower)
243                    && let Some(end) = find_closing_tag(&lower[tag_start..], &tag_name) {
244                        result.push_str(&html[pos..tag_start]);
245                        pos = tag_start + end;
246                        continue;
247                    }
248            }
249            result.push_str(&html[pos..check_pos + 12]);
250            pos = check_pos + 12;
251        } else {
252            result.push_str(&html[pos..]);
253            break;
254        }
255    }
256
257    result
258}
259
260/// remove template header and footer regions
261fn remove_template_chrome(html: &str) -> (String, bool) {
262    let lower = html.to_lowercase();
263    let mut footer_removed = false;
264
265    // find the last occurrence of footer keywords
266    let mut earliest_footer = html.len();
267    for keyword in FOOTER_KEYWORDS {
268        if let Some(pos) = lower.rfind(keyword) {
269            // find the enclosing block element (td, div, tr)
270            if let Some(block_start) = find_enclosing_block_start(&lower[..pos])
271                && block_start < earliest_footer {
272                    earliest_footer = block_start;
273                    footer_removed = true;
274                }
275        }
276    }
277
278    // don't remove more than 40% of the content
279    if footer_removed && earliest_footer < html.len() * 60 / 100 {
280        footer_removed = false;
281        earliest_footer = html.len();
282    }
283
284    let trimmed = if footer_removed {
285        &html[..earliest_footer]
286    } else {
287        html
288    };
289
290    (trimmed.to_string(), footer_removed)
291}
292
293/// convert cleaned html to plain text
294fn html_to_clean_text(html: &str) -> String {
295    let text = match html2text::from_read(html.as_bytes(), 80) {
296        Ok(t) => t,
297        Err(_) => return String::new(),
298    };
299
300    // post-process: collapse excessive whitespace
301    let mut lines: Vec<&str> = Vec::new();
302    let mut blank_count = 0;
303
304    for line in text.lines() {
305        let trimmed = line.trim();
306        if trimmed.is_empty() {
307            blank_count += 1;
308            if blank_count <= 2 {
309                lines.push("");
310            }
311        } else {
312            blank_count = 0;
313            lines.push(trimmed);
314        }
315    }
316
317    // trim trailing blank lines
318    while lines.last() == Some(&"") {
319        lines.pop();
320    }
321
322    lines.join("\n")
323}
324
325/// detect if sender is a bulk/automated sender based on email headers
326pub fn detect_bulk_sender(raw_headers: &str) -> bool {
327    let lower = raw_headers.to_lowercase();
328
329    // list-unsubscribe header
330    if lower.contains("list-unsubscribe:") {
331        return true;
332    }
333
334    // precedence: bulk or list
335    if lower.contains("precedence: bulk") || lower.contains("precedence: list")
336        || lower.contains("precedence:bulk") || lower.contains("precedence:list")
337    {
338        return true;
339    }
340
341    // x-mailer headers from known ESPs
342    if lower.contains("x-sg-id") || lower.contains("x-mailgun-") || lower.contains("x-mandrill-")
343        || lower.contains("x-mc-") || lower.contains("x-ses-")
344        || lower.contains("x-campaign") || lower.contains("x-mailer: mailchimp")
345    {
346        return true;
347    }
348
349    // auto-submitted header
350    if lower.contains("auto-submitted:") {
351        let auto_val = lower.split("auto-submitted:").nth(1).unwrap_or("");
352        let auto_val = auto_val.split('\n').next().unwrap_or("").trim();
353        if auto_val != "no" {
354            return true;
355        }
356    }
357
358    false
359}
360
361/// detect automated/noreply senders
362pub fn is_automated_sender(email: &str) -> bool {
363    let lower = email.to_lowercase();
364    let local = lower.split('@').next().unwrap_or("");
365
366    local == "noreply" || local == "no-reply" || local == "do-not-reply"
367        || local == "donotreply" || local == "mailer-daemon"
368        || local == "postmaster" || local.starts_with("bounce")
369        || local.starts_with("notification") || local == "auto"
370}
371
372/// extract quoted text boundary from email text
373/// returns (new_content, quoted_parts) where new_content is the original reply text
374pub fn split_quoted_content(text: &str) -> (String, Vec<String>) {
375    let lines: Vec<&str> = text.lines().collect();
376    let mut split_point = lines.len();
377    let mut quoted = Vec::new();
378
379    // find "On ... wrote:" pattern (supports multiple languages)
380    for (i, line) in lines.iter().enumerate() {
381        let trimmed = line.trim();
382
383        // english: "On Mon, Jan 1, 2025 at 10:00 AM Alice <alice@x.com> wrote:"
384        if (trimmed.starts_with("On ") && trimmed.ends_with("wrote:"))
385            || (trimmed.starts_with("On ") && trimmed.contains(" wrote:"))
386        {
387            split_point = i;
388            break;
389        }
390
391        // japanese: "2025年1月1日 10:00 Alice <alice@x.com>:"
392        if trimmed.contains("年") && trimmed.contains("月") && trimmed.contains("日")
393            && trimmed.ends_with(':')
394            && trimmed.contains('@')
395        {
396            split_point = i;
397            break;
398        }
399
400        // outlook style: "From: Alice" followed by "Sent:" or "Date:"
401        if trimmed.starts_with("From:") && i + 1 < lines.len() {
402            let next = lines[i + 1].trim();
403            if next.starts_with("Sent:") || next.starts_with("Date:") || next.starts_with("日時:") {
404                split_point = i;
405                break;
406            }
407        }
408
409        // simple quote prefix: line starting with ">"
410        // only if it's a block (3+ consecutive lines)
411        if trimmed.starts_with('>') {
412            let mut count = 1;
413            for line in lines.iter().skip(i + 1) {
414                if line.trim().starts_with('>') {
415                    count += 1;
416                } else {
417                    break;
418                }
419            }
420            if count >= 3 {
421                split_point = i;
422                break;
423            }
424        }
425
426        // separator line: "----" or "____" or "====" (at least 4 chars)
427        if (trimmed.starts_with("----") || trimmed.starts_with("____") || trimmed.starts_with("===="))
428            && trimmed.len() >= 4
429            && i > 0
430        {
431            // check if next line looks like quoted header
432            if i + 1 < lines.len() {
433                let next = lines[i + 1].trim();
434                if next.starts_with("From:") || next.starts_with("Subject:") || next.starts_with("Date:") {
435                    split_point = i;
436                    break;
437                }
438            }
439        }
440    }
441
442    if split_point < lines.len() {
443        let new_content = lines[..split_point].join("\n").trim_end().to_string();
444        let quoted_text = lines[split_point..].join("\n");
445        quoted.push(quoted_text);
446        (new_content, quoted)
447    } else {
448        (text.to_string(), quoted)
449    }
450}
451
452// ---- helper functions ----
453
454fn remove_tag_block(html: &str, tag: &str) -> String {
455    let lower = html.to_lowercase();
456    let open = format!("<{tag}");
457    let close = format!("</{tag}>");
458    let mut result = String::with_capacity(html.len());
459    let mut pos = 0;
460
461    while pos < html.len() {
462        if let Some(offset) = lower[pos..].find(&open) {
463            let start = pos + offset;
464            result.push_str(&html[pos..start]);
465
466            if let Some(end_offset) = lower[start..].find(&close) {
467                pos = start + end_offset + close.len();
468            } else {
469                // no closing tag, skip to end of opening tag
470                if let Some(gt) = html[start..].find('>') {
471                    pos = start + gt + 1;
472                } else {
473                    pos = html.len();
474                }
475            }
476        } else {
477            result.push_str(&html[pos..]);
478            break;
479        }
480    }
481
482    result
483}
484
485fn find_all_positions(haystack: &str, needle: &str) -> Vec<usize> {
486    let mut positions = Vec::new();
487    let mut start = 0;
488    while let Some(pos) = haystack[start..].find(needle) {
489        positions.push(start + pos);
490        start = start + pos + needle.len();
491    }
492    positions
493}
494
495fn extract_attr<'a>(tag: &'a str, attr: &str) -> Option<&'a str> {
496    let search = format!("{attr}=\"");
497    if let Some(start) = tag.find(&search) {
498        let val_start = start + search.len();
499        if let Some(end) = tag[val_start..].find('"') {
500            return Some(&tag[val_start..val_start + end]);
501        }
502    }
503    let search2 = format!("{attr}='");
504    if let Some(start) = tag.find(&search2) {
505        let val_start = start + search2.len();
506        if let Some(end) = tag[val_start..].find('\'') {
507            return Some(&tag[val_start..val_start + end]);
508        }
509    }
510    None
511}
512
513fn extract_tag_name(lower_html: &str) -> Option<String> {
514    if !lower_html.starts_with('<') {
515        return None;
516    }
517    let after = &lower_html[1..];
518    let end = after.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?;
519    let name = after[..end].to_string();
520    if name.is_empty() || name.starts_with('/') {
521        None
522    } else {
523        Some(name)
524    }
525}
526
527fn find_closing_tag(lower_html: &str, tag_name: &str) -> Option<usize> {
528    let close = format!("</{tag_name}>");
529    lower_html.find(&close).map(|p| p + close.len())
530}
531
532fn find_enclosing_block_start(html_before: &str) -> Option<usize> {
533    // look backward for <td, <tr, <div, <table
534    let block_tags = ["<td", "<tr", "<div", "<table"];
535    let mut best = None;
536
537    for tag in &block_tags {
538        if let Some(pos) = html_before.rfind(tag) {
539            match best {
540                None => best = Some(pos),
541                Some(current) => {
542                    if pos > current {
543                        best = Some(pos);
544                    }
545                }
546            }
547        }
548    }
549
550    best
551}
552
553fn count_pattern(html: &str, pattern: &str) -> usize {
554    let lower = html.to_lowercase();
555    lower.matches(pattern).count()
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn clean_simple_html() {
564        let html = "<html><body><p>Hello world</p></body></html>";
565        let result = clean_email_html(html);
566        assert!(result.clean_text.contains("Hello world"));
567        assert!(!result.has_tracking_pixel);
568    }
569
570    #[test]
571    fn detect_1x1_tracking_pixel() {
572        let html = r#"<img src="https://track.mailchimp.com/open?u=123" width="1" height="1">"#;
573        assert!(detect_tracking_pixels(html));
574    }
575
576    #[test]
577    fn detect_hidden_pixel() {
578        let html = r#"<img src="https://example.com/pixel" style="display:none">"#;
579        assert!(detect_tracking_pixels(html));
580    }
581
582    #[test]
583    fn no_tracking_pixel_normal_image() {
584        let html = r#"<img src="https://example.com/photo.jpg" width="600" height="400">"#;
585        assert!(!detect_tracking_pixels(html));
586    }
587
588    #[test]
589    fn remove_script_tags() {
590        let html = "<p>Before</p><script>alert('xss')</script><p>After</p>";
591        let cleaned = remove_unsafe_elements(html);
592        assert!(!cleaned.contains("alert"));
593        assert!(cleaned.contains("Before"));
594        assert!(cleaned.contains("After"));
595    }
596
597    #[test]
598    fn remove_style_tags() {
599        let html = "<style>.foo{color:red}</style><p>Content</p>";
600        let cleaned = remove_unsafe_elements(html);
601        assert!(!cleaned.contains("color:red"));
602        assert!(cleaned.contains("Content"));
603    }
604
605    #[test]
606    fn remove_html_comments() {
607        let html = "<p>A</p><!--[if mso]><table><tr><td>Outlook junk</td></tr></table><![endif]--><p>B</p>";
608        let cleaned = remove_unsafe_elements(html);
609        assert!(!cleaned.contains("Outlook junk"));
610        assert!(cleaned.contains("A"));
611        assert!(cleaned.contains("B"));
612    }
613
614    #[test]
615    fn detect_bulk_sender_list_unsubscribe() {
616        let headers = "From: news@example.com\r\nList-Unsubscribe: <mailto:unsub@example.com>\r\n";
617        assert!(detect_bulk_sender(headers));
618    }
619
620    #[test]
621    fn detect_bulk_sender_precedence() {
622        let headers = "From: news@example.com\r\nPrecedence: bulk\r\n";
623        assert!(detect_bulk_sender(headers));
624    }
625
626    #[test]
627    fn not_bulk_sender_personal() {
628        let headers = "From: alice@example.com\r\nTo: bob@example.com\r\n";
629        assert!(!detect_bulk_sender(headers));
630    }
631
632    #[test]
633    fn is_automated_sender_noreply() {
634        assert!(is_automated_sender("noreply@example.com"));
635        assert!(is_automated_sender("no-reply@example.com"));
636        assert!(is_automated_sender("NOREPLY@Example.com"));
637    }
638
639    #[test]
640    fn is_not_automated_sender() {
641        assert!(!is_automated_sender("alice@example.com"));
642        assert!(!is_automated_sender("support@example.com"));
643    }
644
645    #[test]
646    fn split_quoted_on_wrote() {
647        let text = "Thanks for the update.\n\nOn Mon, Jan 1, 2025 at 10:00 AM Alice <alice@x.com> wrote:\n> Original message";
648        let (new_content, quoted) = split_quoted_content(text);
649        assert_eq!(new_content, "Thanks for the update.");
650        assert_eq!(quoted.len(), 1);
651        assert!(quoted[0].contains("Alice"));
652    }
653
654    #[test]
655    fn split_quoted_no_quote() {
656        let text = "Just a simple message with no quotes.";
657        let (new_content, quoted) = split_quoted_content(text);
658        assert_eq!(new_content, text);
659        assert!(quoted.is_empty());
660    }
661
662    #[test]
663    fn split_quoted_outlook_style() {
664        let text = "My reply.\n\nFrom: Bob\nSent: Monday\nTo: Alice\nSubject: Re: test\n\nOriginal";
665        let (new_content, quoted) = split_quoted_content(text);
666        assert_eq!(new_content, "My reply.");
667        assert_eq!(quoted.len(), 1);
668    }
669
670    #[test]
671    fn split_quoted_angle_bracket() {
672        let text = "My reply.\n\n> line 1\n> line 2\n> line 3\n> line 4";
673        let (new_content, quoted) = split_quoted_content(text);
674        assert_eq!(new_content, "My reply.");
675        assert_eq!(quoted.len(), 1);
676    }
677
678    #[test]
679    fn text_to_html_ratio_pure_text() {
680        let html = "Hello world";
681        let result = clean_email_html(html);
682        assert!(result.text_to_html_ratio > 0.5);
683    }
684
685    #[test]
686    fn text_to_html_ratio_heavy_template() {
687        let html = "<html><head><style>.a{}.b{}.c{}</style></head><body><table><tr><td><table><tr><td><img src='logo.png' width='600'></td></tr></table></td></tr><tr><td>Hi</td></tr><tr><td><a href='#'>Unsubscribe</a></td></tr></table></body></html>";
688        let result = clean_email_html(html);
689        // template-heavy emails have low ratio
690        assert!(result.text_to_html_ratio < 0.3);
691    }
692
693    #[test]
694    fn footer_removal() {
695        let html = "<div>Important content here with lots of text that forms the main body of the email message.</div><div>More important content in the second paragraph.</div><div><a href='#'>unsubscribe</a> | <a href='#'>manage preferences</a></div>";
696        let (result, removed) = remove_template_chrome(html);
697        assert!(removed);
698        assert!(result.contains("Important content"));
699    }
700
701    // ===== Additional corner-case tests =====
702
703    #[test]
704    fn detect_tracking_pixel_singled_quotes() {
705        // tracking pixels often use single quotes — detection must cover both styles
706        let html = "<img src='https://track.example.com/o/' width='1' height='1'>";
707        assert!(detect_tracking_pixels(html));
708    }
709
710    #[test]
711    fn detect_tracking_by_known_domain_even_full_size() {
712        // A full-size image from a known tracking domain still counts as tracking.
713        let html = r#"<img src="https://list-manage.com/wf/open?u=x" width="600" height="400">"#;
714        assert!(detect_tracking_pixels(html));
715    }
716
717    #[test]
718    fn detect_tracking_by_path_keyword() {
719        let html = r#"<img src="https://example.com/track?id=1" width="600">"#;
720        assert!(detect_tracking_pixels(html));
721    }
722
723    #[test]
724    fn empty_html_returns_empty_result() {
725        let result = clean_email_html("");
726        assert_eq!(result.clean_text, "");
727        assert!(!result.has_tracking_pixel);
728        assert_eq!(result.link_count, 0);
729        assert_eq!(result.image_count, 0);
730        // ratio is 1.0 when html is empty
731        assert!((result.text_to_html_ratio - 1.0).abs() < f32::EPSILON);
732    }
733
734    #[test]
735    fn detect_bulk_sender_auto_submitted_no_is_not_bulk() {
736        // RFC 3834: auto-submitted: no means a human-sent reply, not bulk.
737        let headers = "From: alice@example.com\r\nAuto-Submitted: no\r\n";
738        assert!(!detect_bulk_sender(headers));
739    }
740
741    #[test]
742    fn detect_bulk_sender_auto_submitted_yes() {
743        // anything other than "no" is treated as auto / bulk
744        let headers = "From: x@y\r\nAuto-Submitted: auto-replied\r\n";
745        assert!(detect_bulk_sender(headers));
746    }
747
748    #[test]
749    fn is_automated_sender_postmaster_bounce_variants() {
750        assert!(is_automated_sender("postmaster@x.com"));
751        assert!(is_automated_sender("mailer-daemon@x.com"));
752        assert!(is_automated_sender("bounce-12345@x.com"));
753        assert!(is_automated_sender("bounces@x.com"));
754        assert!(is_automated_sender("notification-system@x.com"));
755        assert!(is_automated_sender("notifications@x.com"));
756        assert!(is_automated_sender("do-not-reply@x.com"));
757        assert!(is_automated_sender("donotreply@x.com"));
758        assert!(is_automated_sender("auto@x.com"));
759        // Negative cases
760        assert!(!is_automated_sender("alice@x.com"));
761        assert!(!is_automated_sender("not-noreply@x.com"));  // doesn't match exact pattern
762    }
763
764    #[test]
765    fn split_quoted_japanese_style_header() {
766        let text = "私の返事です。\n\n2025年1月1日 10:00 Alice <alice@x.com>:\n> 元のメッセージ";
767        let (new_content, quoted) = split_quoted_content(text);
768        assert_eq!(new_content, "私の返事です。");
769        assert_eq!(quoted.len(), 1);
770    }
771
772    #[test]
773    fn split_quoted_short_block_of_quotes_not_split() {
774        // Only 1-2 quoted lines is not enough to trigger split.
775        let text = "Reply.\n\n> one quoted line\n\nAnother line.";
776        let (new_content, _) = split_quoted_content(text);
777        assert!(new_content.contains("Another line"));
778    }
779
780    #[test]
781    fn count_pattern_case_insensitive() {
782        // count_pattern lowercases — uppercase tags should still count
783        let html = "<A href='1'>x</A><a href='2'>y</a>";
784        assert_eq!(count_pattern(html, "<a "), 2);
785    }
786
787    #[test]
788    fn footer_too_early_not_removed() {
789        // If footer keyword appears in the first 40% of html, removal is suppressed
790        // (otherwise we'd nuke most of the message).
791        let html = "<div>unsubscribe</div><div>actual content here that constitutes most of the email body text</div>";
792        let (result, removed) = remove_template_chrome(html);
793        assert!(!removed, "removal suppressed when it would consume too much content");
794        // unsubscribe word kept since not at the end
795        assert!(result.contains("unsubscribe"));
796    }
797}