1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4const 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
33const TRACKING_PATHS: &[&str] = &[
35 "/track", "/pixel", "/beacon", "/open", "/wf/open", "/o/", "/t/",
36 "/imp", "/ci/", "/e/o/", "tracking", "1x1",
37];
38
39const 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
51pub struct CleanResult {
55 pub clean_text: String,
58 pub has_tracking_pixel: bool,
60 pub is_template_heavy: bool,
63 pub link_count: usize,
65 #[allow(dead_code)]
67 pub image_count: usize,
68 #[allow(dead_code)]
71 pub text_to_html_ratio: f32,
72}
73
74pub 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 let stage1 = remove_unsafe_elements(html);
82
83 let stage2 = remove_tracking_and_hidden(&stage1);
85
86 let (stage3, footer_removed) = remove_template_chrome(&stage2);
88
89 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 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
111fn detect_tracking_pixels(html: &str) -> bool {
113 let lower = html.to_lowercase();
114
115 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 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 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 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
158fn remove_unsafe_elements(html: &str) -> String {
160 let mut result = html.to_string();
161
162 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 for tag in &["script", "style", "iframe", "noscript", "svg"] {
173 result = remove_tag_block(&result, tag);
174 }
175
176 result
177}
178
179fn 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 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 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 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 result = remove_hidden_blocks(&result);
225
226 result
227}
228
229fn 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 if let Some(tag_start) = html[..check_pos].rfind('<') {
240 let tag_lower = &lower[tag_start..];
241 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
260fn remove_template_chrome(html: &str) -> (String, bool) {
262 let lower = html.to_lowercase();
263 let mut footer_removed = false;
264
265 let mut earliest_footer = html.len();
267 for keyword in FOOTER_KEYWORDS {
268 if let Some(pos) = lower.rfind(keyword) {
269 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 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
293fn 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 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 while lines.last() == Some(&"") {
319 lines.pop();
320 }
321
322 lines.join("\n")
323}
324
325pub fn detect_bulk_sender(raw_headers: &str) -> bool {
327 let lower = raw_headers.to_lowercase();
328
329 if lower.contains("list-unsubscribe:") {
331 return true;
332 }
333
334 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 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 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
361pub 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
372pub 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 for (i, line) in lines.iter().enumerate() {
381 let trimmed = line.trim();
382
383 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 if trimmed.contains("年") && trimmed.contains("月") && trimmed.contains("日")
393 && trimmed.ends_with(':')
394 && trimmed.contains('@')
395 {
396 split_point = i;
397 break;
398 }
399
400 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 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 if (trimmed.starts_with("----") || trimmed.starts_with("____") || trimmed.starts_with("===="))
428 && trimmed.len() >= 4
429 && i > 0
430 {
431 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
452fn 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 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 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 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 #[test]
704 fn detect_tracking_pixel_singled_quotes() {
705 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 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 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 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 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 assert!(!is_automated_sender("alice@x.com"));
761 assert!(!is_automated_sender("not-noreply@x.com")); }
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 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 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 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 assert!(result.contains("unsubscribe"));
796 }
797}