1use crate::error::{AppError, Result};
2use crate::store::{clean_body_text, render_message_section};
3use crate::types::{
4 AttachmentRef, AuthAlignment, AuthVerdict, ImapRef, MessageAuthentication, MessageFile,
5 RemoteLocation, RemoteState, WorkspaceState,
6};
7use mail_parser::{Address, HeaderValue, MessageParser, MimeHeaders};
8
9#[derive(Clone, Debug)]
10pub struct ParsedMail {
11 pub message: MessageFile,
12 pub body_text: String,
13 pub conversation: String,
14}
15
16#[derive(Clone, Debug)]
17pub struct MessageParseOptions {
18 pub direction: Option<String>,
19 pub workspace: WorkspaceState,
20 pub remote: Option<RemoteState>,
21 pub received_rfc3339: Option<String>,
22 pub sent_rfc3339: Option<String>,
23 pub attachments: Vec<AttachmentRef>,
24}
25
26pub fn parse_inbound_message(
27 message_id: String,
28 raw_eml: &[u8],
29 imap: ImapRef,
30) -> Result<ParsedMail> {
31 let remote = Some(RemoteState {
32 locations: vec![RemoteLocation {
33 mailbox_id: None,
34 mailbox_name: imap.mailbox_name.clone(),
35 uid_validity: Some(imap.uid_validity),
36 uid: Some(imap.uid),
37 flags: Vec::new(),
38 observed_rfc3339: crate::store::now_rfc3339(),
39 missing_rfc3339: None,
40 }],
41 });
42 parse_message_with_options(
43 message_id,
44 raw_eml,
45 MessageParseOptions {
46 direction: Some("inbound".to_string()),
47 workspace: WorkspaceState {
48 status: "triage".to_string(),
49 archive_uid: None,
50 archived_rfc3339: None,
51 origin: None,
52 remote_sync: None,
53 push: None,
54 },
55 remote,
56 received_rfc3339: None,
57 sent_rfc3339: None,
58 attachments: Vec::new(),
59 },
60 )
61}
62
63pub fn parse_message_with_options(
64 message_id: String,
65 raw_eml: &[u8],
66 options: MessageParseOptions,
67) -> Result<ParsedMail> {
68 let parsed = MessageParser::default()
69 .parse(raw_eml)
70 .ok_or_else(|| AppError::new("mime_parse_failed", "failed to parse RFC822 message"))?;
71 let body = parsed
72 .body_text(0)
73 .or_else(|| parsed.body_html(0))
74 .map(|s| s.into_owned())
75 .unwrap_or_default();
76 let body_text = clean_body_text(&body);
77 let mut attachments = parsed
78 .attachments
79 .iter()
80 .filter_map(|part_id| parsed.part(*part_id).map(|part| (*part_id, part)))
81 .map(|(part_id, part)| AttachmentRef {
82 part_id: part_id.to_string(),
83 filename: part
84 .attachment_name()
85 .map(ToString::to_string)
86 .unwrap_or_else(|| format!("part-{part_id}")),
87 content_type: content_type_string(part.content_type()),
88 size_bytes: part.len() as u64,
89 fetched: false,
90 file_path: None,
91 source_path: None,
92 })
93 .collect::<Vec<_>>();
94 merge_attachment_state(&mut attachments, &options.attachments);
95 let parsed_date_rfc3339 = parsed.date().map(|d| d.to_rfc3339());
96 let rfc822_message_id = parsed.message_id().map(ToString::to_string);
97 let in_reply_to = header_id_list(parsed.in_reply_to()).into_iter().next_back();
98 let references = header_id_list(parsed.references());
99 let direction = options.direction.unwrap_or_else(|| "inbound".to_string());
100 let is_outbound = direction.eq_ignore_ascii_case("outbound")
101 || (direction.eq_ignore_ascii_case("sent")
102 && options.received_rfc3339.is_none()
103 && options.sent_rfc3339.is_some());
104 let (received_rfc3339, sent_rfc3339) = if is_outbound {
105 (
106 None,
107 options.sent_rfc3339.or_else(|| parsed_date_rfc3339.clone()),
108 )
109 } else {
110 (
111 options
112 .received_rfc3339
113 .or_else(|| parsed_date_rfc3339.clone()),
114 options.sent_rfc3339,
115 )
116 };
117 let message = MessageFile {
118 schema_name: "message".to_string(),
119 schema_version: 1,
120 message_id: message_id.clone(),
121 rfc822_message_id,
122 in_reply_to,
123 references,
124 remote: options.remote,
125 direction: Some(direction),
126 subject: parsed.subject().map(ToString::to_string),
127 from: parsed.from().and_then(format_first_address),
128 to: parsed.to().map(format_addresses).unwrap_or_default(),
129 cc: parsed.cc().map(format_addresses).unwrap_or_default(),
130 bcc: parsed.bcc().map(format_addresses).unwrap_or_default(),
131 reply_to: parsed.reply_to().map(format_addresses).unwrap_or_default(),
132 sender: parsed.sender().and_then(format_first_address),
133 delivered_to: raw_header_values(&parsed, "Delivered-To"),
134 x_original_to: raw_header_values(&parsed, "X-Original-To"),
135 envelope_to: raw_header_values(&parsed, "Envelope-To"),
136 list_id: raw_header_values(&parsed, "List-ID").into_iter().next(),
137 mailing_list_headers: mailing_list_headers(&parsed),
138 authentication: parse_authentication(
139 raw_header_values(&parsed, "Authentication-Results"),
140 parsed.from().and_then(first_address_domain),
141 ),
142 received_rfc3339,
143 sent_rfc3339,
144 body_text: body_text.clone(),
145 eml_path: Some(format!(".afmail/messages/{message_id}.eml")),
146 attachments,
147 contact: None,
148 identity: None,
149 identity_email: None,
150 identity_match: None,
151 identity_candidates: Vec::new(),
152 observed_recipient_emails: Vec::new(),
153 workspace: options.workspace,
154 };
155 let conversation = render_message_section(&message, &body_text)?;
156 Ok(ParsedMail {
157 message,
158 body_text,
159 conversation,
160 })
161}
162
163pub fn parse_outbound_message(
164 message_id: String,
165 raw_eml: &[u8],
166 case_uid: String,
167) -> Result<ParsedMail> {
168 parse_outbound_message_with_status(
169 message_id,
170 raw_eml,
171 case_uid,
172 "case".to_string(),
173 Some(crate::store::now_rfc3339()),
174 )
175}
176
177pub fn parse_outbound_message_with_status(
178 message_id: String,
179 raw_eml: &[u8],
180 _case_uid: String,
181 workspace_status: String,
182 sent_rfc3339: Option<String>,
183) -> Result<ParsedMail> {
184 parse_message_with_options(
185 message_id,
186 raw_eml,
187 MessageParseOptions {
188 direction: Some("outbound".to_string()),
189 workspace: WorkspaceState {
190 status: workspace_status,
191 archive_uid: None,
192 archived_rfc3339: None,
193 origin: None,
194 remote_sync: None,
195 push: None,
196 },
197 remote: None,
198 received_rfc3339: None,
199 sent_rfc3339,
200 attachments: Vec::new(),
201 },
202 )
203}
204
205fn merge_attachment_state(attachments: &mut [AttachmentRef], previous: &[AttachmentRef]) {
206 for attachment in attachments {
207 let Some(prior) = previous.iter().find(|prior| {
208 prior.part_id == attachment.part_id
209 || (prior.filename == attachment.filename
210 && prior.content_type == attachment.content_type)
211 }) else {
212 continue;
213 };
214 attachment.fetched = prior.fetched;
215 attachment.file_path = prior.file_path.clone();
216 attachment.source_path = prior.source_path.clone();
217 }
218}
219
220pub fn attachment_bytes(raw_eml: &[u8], part_id: &str) -> Result<Vec<u8>> {
221 let parsed = MessageParser::default()
222 .parse(raw_eml)
223 .ok_or_else(|| AppError::new("mime_parse_failed", "failed to parse RFC822 message"))?;
224 let id = part_id.parse::<u32>().map_err(|_| {
225 AppError::new(
226 "attachment_not_found",
227 format!("invalid part id: {part_id}"),
228 )
229 })?;
230 let part = parsed.part(id).ok_or_else(|| {
231 AppError::new(
232 "attachment_not_found",
233 format!("attachment not found: part {part_id}"),
234 )
235 })?;
236 Ok(part.contents().to_vec())
237}
238
239fn content_type_string(content_type: Option<&mail_parser::ContentType<'_>>) -> String {
240 match content_type {
241 Some(ct) => match ct.subtype() {
242 Some(subtype) => format!("{}/{}", ct.ctype(), subtype),
243 None => ct.ctype().to_string(),
244 },
245 None => "application/octet-stream".to_string(),
246 }
247}
248
249fn header_id_list(value: &HeaderValue<'_>) -> Vec<String> {
255 match value {
256 HeaderValue::Text(s) => vec![s.trim().to_string()],
257 HeaderValue::TextList(list) => list.iter().map(|s| s.trim().to_string()).collect(),
258 _ => Vec::new(),
259 }
260}
261
262fn format_first_address(address: &Address<'_>) -> Option<String> {
263 address.first().and_then(|addr| {
264 let email = addr.address()?;
265 Some(match addr.name() {
266 Some(name) if !name.is_empty() => format!("{name} <{email}>"),
267 _ => email.to_string(),
268 })
269 })
270}
271
272fn format_addresses(address: &Address<'_>) -> Vec<String> {
273 address
274 .iter()
275 .filter_map(|addr| {
276 let email = addr.address()?;
277 Some(match addr.name() {
278 Some(name) if !name.is_empty() => format!("{name} <{email}>"),
279 _ => email.to_string(),
280 })
281 })
282 .collect()
283}
284
285fn raw_header_values(message: &mail_parser::Message<'_>, name: &str) -> Vec<String> {
286 message
287 .headers_raw()
288 .filter(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
289 .map(|(_, value)| value.trim().to_string())
290 .collect()
291}
292
293fn first_address_domain(address: &Address<'_>) -> Option<String> {
294 address
295 .first()
296 .and_then(|addr| addr.address())
297 .and_then(address_domain)
298}
299
300fn address_domain(value: &str) -> Option<String> {
303 let value = value.trim().trim_matches(|c| c == '<' || c == '>').trim();
304 let candidate = value.rsplit('@').next().unwrap_or(value);
305 let candidate = candidate.trim().trim_end_matches('.').to_ascii_lowercase();
306 if candidate.is_empty() || !candidate.contains('.') {
307 return None;
308 }
309 Some(candidate)
310}
311
312fn registrable_suffix(domain: &str) -> String {
316 let labels: Vec<&str> = domain.split('.').filter(|s| !s.is_empty()).collect();
317 let n = labels.len();
318 if n >= 2 {
319 format!("{}.{}", labels[n - 2], labels[n - 1])
320 } else {
321 domain.to_string()
322 }
323}
324
325#[derive(Default)]
326struct DomainCandidates {
327 dmarc: Option<String>,
328 dkim: Option<String>,
329 spf: Option<String>,
330}
331
332fn parse_authentication(raw: Vec<String>, from_domain: Option<String>) -> MessageAuthentication {
335 let mut auth = MessageAuthentication {
336 from_domain: from_domain.clone(),
337 ..MessageAuthentication::default()
338 };
339 let mut domains = DomainCandidates::default();
340 for header in &raw {
341 for segment in split_top_level_semicolons(header) {
342 apply_authentication_segment(&mut auth, &mut domains, &segment);
343 }
344 }
345 auth.authenticated_domain = domains.dmarc.or(domains.dkim).or(domains.spf);
346 auth.alignment = match (&auth.authenticated_domain, &from_domain) {
347 (Some(authenticated), Some(from)) => {
348 if registrable_suffix(authenticated) == registrable_suffix(from) {
349 AuthAlignment::Aligned
350 } else {
351 AuthAlignment::Mismatch
352 }
353 }
354 _ => AuthAlignment::Unknown,
355 };
356 auth.raw = raw;
357 auth
358}
359
360fn split_top_level_semicolons(header: &str) -> Vec<String> {
363 let mut out = Vec::new();
364 let mut current = String::new();
365 let mut depth = 0usize;
366 for ch in header.chars() {
367 match ch {
368 '(' => {
369 depth += 1;
370 current.push(ch);
371 }
372 ')' => {
373 depth = depth.saturating_sub(1);
374 current.push(ch);
375 }
376 ';' if depth == 0 => out.push(std::mem::take(&mut current)),
377 _ => current.push(ch),
378 }
379 }
380 if !current.trim().is_empty() {
381 out.push(current);
382 }
383 out
384}
385
386fn strip_comments(segment: &str) -> (String, String) {
389 let mut bare = String::new();
390 let mut comment = String::new();
391 let mut depth = 0usize;
392 for ch in segment.chars() {
393 match ch {
394 '(' => depth += 1,
395 ')' => depth = depth.saturating_sub(1),
396 _ if depth > 0 => comment.push(ch),
397 _ => bare.push(ch),
398 }
399 }
400 (bare, comment)
401}
402
403fn apply_authentication_segment(
404 auth: &mut MessageAuthentication,
405 domains: &mut DomainCandidates,
406 segment: &str,
407) {
408 let (bare, comment) = strip_comments(segment);
409 let mut tokens = bare.split_whitespace();
410 let Some(first) = tokens.next() else {
411 return;
412 };
413 let Some((method_raw, result)) = split_kv(first) else {
416 return;
417 };
418 let method = method_raw.to_ascii_lowercase();
419 let verdict = parse_verdict(result);
420 match method.as_str() {
421 "spf" => set_verdict(&mut auth.spf, verdict),
422 "dkim" => set_verdict(&mut auth.dkim, verdict),
423 "dmarc" => {
424 set_verdict(&mut auth.dmarc, verdict);
425 if let Some(policy) = extract_policy(&comment) {
426 auth.dmarc_policy.get_or_insert(policy);
427 }
428 }
429 _ => return,
430 }
431 if verdict != AuthVerdict::Pass {
433 return;
434 }
435 for token in tokens {
436 let Some((key, value)) = split_kv(token) else {
437 continue;
438 };
439 let key = key.to_ascii_lowercase();
440 let domain = match method.as_str() {
441 "spf" if matches!(key.as_str(), "smtp.mailfrom" | "envelope-from") => {
442 address_domain(value)
443 }
444 "dkim" if matches!(key.as_str(), "header.i" | "header.d") => address_domain(value),
445 "dmarc" if key == "header.from" => address_domain(value),
446 _ => None,
447 };
448 if let Some(domain) = domain {
449 match method.as_str() {
450 "spf" => domains.spf.get_or_insert(domain),
451 "dkim" => domains.dkim.get_or_insert(domain),
452 "dmarc" => domains.dmarc.get_or_insert(domain),
453 _ => continue,
454 };
455 }
456 }
457}
458
459fn split_kv(token: &str) -> Option<(&str, &str)> {
460 let (key, value) = token.split_once('=')?;
461 let key = key.trim();
462 let value = value.trim();
463 if key.is_empty() || value.is_empty() {
464 return None;
465 }
466 Some((key, value))
467}
468
469fn parse_verdict(value: &str) -> AuthVerdict {
470 match value.trim().to_ascii_lowercase().as_str() {
471 "pass" => AuthVerdict::Pass,
472 "fail" | "hardfail" => AuthVerdict::Fail,
473 "softfail" => AuthVerdict::SoftFail,
474 "neutral" => AuthVerdict::Neutral,
475 "none" => AuthVerdict::None,
476 "temperror" => AuthVerdict::TempError,
477 "permerror" => AuthVerdict::PermError,
478 _ => AuthVerdict::Neutral,
479 }
480}
481
482fn set_verdict(slot: &mut AuthVerdict, new: AuthVerdict) {
485 if *slot == AuthVerdict::Missing || new == AuthVerdict::Pass {
486 *slot = new;
487 }
488}
489
490fn extract_policy(comment: &str) -> Option<String> {
491 for token in comment.split(|c: char| c.is_whitespace() || c == ';') {
492 let lower = token.to_ascii_lowercase();
493 if let Some(rest) = lower.strip_prefix("p=") {
494 let policy = rest.trim().to_string();
495 if !policy.is_empty() {
496 return Some(policy);
497 }
498 }
499 }
500 None
501}
502
503fn mailing_list_headers(message: &mail_parser::Message<'_>) -> Vec<String> {
504 let names = [
505 "List-Unsubscribe",
506 "List-Post",
507 "List-Help",
508 "Mailing-List",
509 "X-Mailing-List",
510 "Precedence",
511 ];
512 let mut out = Vec::new();
513 for name in names {
514 for value in raw_header_values(message, name) {
515 let clear_list_header = !name.eq_ignore_ascii_case("Precedence")
516 || matches!(value.to_ascii_lowercase().as_str(), "bulk" | "list");
517 if clear_list_header {
518 out.push(format!("{name}: {value}"));
519 }
520 }
521 }
522 out
523}
524
525pub fn slugify(value: &str) -> String {
526 let mut out = String::new();
527 let mut last_dash = false;
528 for ch in value.chars() {
529 if ch.is_ascii_alphanumeric() {
530 out.push(ch.to_ascii_lowercase());
531 last_dash = false;
532 } else if !last_dash {
533 out.push('-');
534 last_dash = true;
535 }
536 }
537 let trimmed = out.trim_matches('-').to_string();
538 if trimmed.is_empty() {
539 "message".to_string()
540 } else {
541 trimmed
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn parses_plain_message_and_attachment_metadata() {
551 let raw = concat!(
552 "Message-ID: <m1@example.com>\r\n",
553 "From: Alice <alice@example.com>\r\n",
554 "To: Me <me@example.com>\r\n",
555 "Date: Thu, 21 May 2026 10:00:00 +0000\r\n",
556 "Subject: Contract renewal\r\n",
557 "Content-Type: multipart/mixed; boundary=abc\r\n\r\n",
558 "--abc\r\nContent-Type: text/plain\r\n\r\nHello\r\n",
559 "--abc\r\nContent-Type: text/plain; name=note.txt\r\nContent-Disposition: attachment; filename=note.txt\r\n\r\nAttached\r\n",
560 "--abc--\r\n"
561 );
562 let parsed = parse_inbound_message(
563 "message_inbox_1_1".to_string(),
564 raw.as_bytes(),
565 ImapRef {
566 mailbox_name: "INBOX".to_string(),
567 uid_validity: 1,
568 uid: 1,
569 },
570 );
571 assert!(parsed.is_ok());
572 let parsed = parsed.ok();
573 assert_eq!(parsed.as_ref().map(|p| p.body_text.as_str()), Some("Hello"));
574 assert_eq!(
575 parsed.as_ref().map(|p| p.message.attachments.len()),
576 Some(1)
577 );
578 assert!(parsed
579 .as_ref()
580 .map(|p| p.conversation.contains("```text"))
581 .unwrap_or(false));
582 }
583
584 #[test]
585 fn parses_passing_authentication_with_alignment() {
586 let raw = vec![concat!(
587 "purelymail.com; spf=pass (domain of email.apple.com designates 17.111.110.110 ",
588 "as permitted sender) smtp.mailfrom=email.apple.com; dkim=pass ",
589 "header.i=email.apple.com; dmarc=pass (p=reject) header.from=no_reply@email.apple.com"
590 )
591 .to_string()];
592 let auth = parse_authentication(raw, Some("apple.com".to_string()));
593 assert_eq!(auth.spf, AuthVerdict::Pass);
594 assert_eq!(auth.dkim, AuthVerdict::Pass);
595 assert_eq!(auth.dmarc, AuthVerdict::Pass);
596 assert_eq!(auth.dmarc_policy.as_deref(), Some("reject"));
597 assert_eq!(
598 auth.authenticated_domain.as_deref(),
599 Some("email.apple.com")
600 );
601 assert_eq!(auth.alignment, AuthAlignment::Aligned);
602 assert!(auth.has_results());
603 assert!(!auth.is_warning());
604 }
605
606 #[test]
607 fn flags_spf_failure_as_warning() {
608 let auth = parse_authentication(
609 vec!["mx.example.com; spf=fail smtp.mailfrom=bad.test".to_string()],
610 Some("example.com".to_string()),
611 );
612 assert_eq!(auth.spf, AuthVerdict::Fail);
613 assert!(auth.is_warning());
614 }
615
616 #[test]
617 fn missing_header_is_missing_not_warning() {
618 let auth = parse_authentication(Vec::new(), Some("example.com".to_string()));
619 assert_eq!(auth.spf, AuthVerdict::Missing);
620 assert_eq!(auth.dkim, AuthVerdict::Missing);
621 assert_eq!(auth.dmarc, AuthVerdict::Missing);
622 assert!(!auth.has_results());
623 assert!(!auth.is_warning());
624 }
625
626 #[test]
627 fn soft_results_are_shown_but_not_warnings() {
628 let auth = parse_authentication(
629 vec![
630 "mx.example.com; spf=softfail smtp.mailfrom=x.test; dmarc=none header.from=x.test"
631 .to_string(),
632 ],
633 Some("x.test".to_string()),
634 );
635 assert_eq!(auth.spf, AuthVerdict::SoftFail);
636 assert_eq!(auth.dmarc, AuthVerdict::None);
637 assert!(auth.has_results());
638 assert!(!auth.is_warning());
639 }
640
641 #[test]
642 fn passing_dmarc_on_lookalike_domain_is_mismatch_and_warning() {
643 let auth = parse_authentication(
644 vec!["mx; dmarc=pass (p=reject) header.from=billing@apple-billing.net".to_string()],
645 Some("apple.com".to_string()),
646 );
647 assert_eq!(auth.dmarc, AuthVerdict::Pass);
648 assert_eq!(
649 auth.authenticated_domain.as_deref(),
650 Some("apple-billing.net")
651 );
652 assert_eq!(auth.alignment, AuthAlignment::Mismatch);
653 assert!(auth.is_warning());
654 }
655
656 #[test]
657 fn comment_semicolons_do_not_split_segments() {
658 let auth = parse_authentication(
659 vec![
660 "mx; spf=pass (uses ; and = inside) smtp.mailfrom=ok.test; dkim=pass header.d=ok.test"
661 .to_string(),
662 ],
663 Some("ok.test".to_string()),
664 );
665 assert_eq!(auth.spf, AuthVerdict::Pass);
666 assert_eq!(auth.dkim, AuthVerdict::Pass);
667 assert_eq!(auth.authenticated_domain.as_deref(), Some("ok.test"));
668 }
669
670 #[test]
671 fn decodes_gb2312_encoded_subject() {
672 let raw = concat!(
673 "Message-ID: <gb2312@example.com>\r\n",
674 "From: Apple Developer <developer@insideapple.apple.com>\r\n",
675 "To: Me <me@example.com>\r\n",
676 "Subject: =?gb2312?B?0rvW3LW5vMbKsQ==?=\r\n",
677 "Content-Type: text/plain; charset=utf-8\r\n\r\n",
678 "Body\r\n"
679 );
680 let parsed = parse_inbound_message(
681 "message_inbox_1_gb2312".to_string(),
682 raw.as_bytes(),
683 ImapRef {
684 mailbox_name: "INBOX".to_string(),
685 uid_validity: 1,
686 uid: 1,
687 },
688 );
689 assert!(parsed.is_ok());
690 assert_eq!(
691 parsed.ok().and_then(|p| p.message.subject),
692 Some("一周倒计时".to_string())
693 );
694 }
695
696 #[test]
697 fn extracts_reply_threading_headers() {
698 let raw = concat!(
699 "Message-ID: <child@example.com>\r\n",
700 "In-Reply-To: <parent@example.com>\r\n",
701 "References: <root@example.com> <parent@example.com>\r\n",
702 "From: Alice <alice@example.com>\r\n",
703 "To: Me <me@example.com>\r\n",
704 "Subject: Re: Hi\r\n\r\nBody\r\n"
705 );
706 let parsed = parse_inbound_message(
707 "message_inbox_1_2".to_string(),
708 raw.as_bytes(),
709 ImapRef {
710 mailbox_name: "INBOX".to_string(),
711 uid_validity: 1,
712 uid: 2,
713 },
714 );
715 assert!(parsed.is_ok());
716 if let Ok(parsed) = parsed {
717 assert_eq!(
719 parsed.message.in_reply_to.as_deref(),
720 Some("parent@example.com")
721 );
722 assert_eq!(
723 parsed.message.references,
724 vec![
725 "root@example.com".to_string(),
726 "parent@example.com".to_string()
727 ]
728 );
729 }
730 }
731
732 #[test]
733 fn extracts_attachment_bytes_from_raw_eml() {
734 let raw = concat!(
735 "Message-ID: <m2@example.com>\r\n",
736 "From: Alice <alice@example.com>\r\n",
737 "To: Me <me@example.com>\r\n",
738 "Subject: Attachment\r\n",
739 "Content-Type: multipart/mixed; boundary=abc\r\n\r\n",
740 "--abc\r\nContent-Type: text/plain\r\n\r\nBody\r\n",
741 "--abc\r\nContent-Type: text/plain; name=note.txt\r\nContent-Disposition: attachment; filename=note.txt\r\n\r\nAttached\r\n",
742 "--abc--\r\n"
743 );
744 let parsed = parse_inbound_message(
745 "message_inbox_1_2".to_string(),
746 raw.as_bytes(),
747 ImapRef {
748 mailbox_name: "INBOX".to_string(),
749 uid_validity: 1,
750 uid: 2,
751 },
752 );
753 assert!(parsed.is_ok());
754 let part_id = parsed
755 .ok()
756 .and_then(|mail| mail.message.attachments.first().map(|a| a.part_id.clone()))
757 .unwrap_or_default();
758 let bytes = attachment_bytes(raw.as_bytes(), &part_id);
759 assert_eq!(bytes, Ok(b"Attached".to_vec()));
760 }
761
762 #[test]
763 fn html_only_body_contains_no_html_tags() {
764 let raw = concat!(
765 "Message-ID: <html-only@example.com>\r\n",
766 "From: Sender <sender@example.com>\r\n",
767 "To: Me <me@example.com>\r\n",
768 "Date: Thu, 21 May 2026 10:00:00 +0000\r\n",
769 "Subject: HTML only\r\n",
770 "Content-Type: text/html; charset=utf-8\r\n",
771 "\r\n",
772 "<html><body><p>Hello <b>world</b>!</p></body></html>\r\n"
773 );
774 let parsed = parse_inbound_message(
775 "message_inbox_1_3".to_string(),
776 raw.as_bytes(),
777 ImapRef {
778 mailbox_name: "INBOX".to_string(),
779 uid_validity: 1,
780 uid: 3,
781 },
782 );
783 assert!(parsed.is_ok());
784 let body_text = parsed.map(|p| p.body_text).unwrap_or_default();
785 assert!(
786 !body_text.contains('<'),
787 "html-only body should not contain raw HTML tags, got: {body_text:?}"
788 );
789 assert!(
790 body_text.contains("Hello") || body_text.contains("world"),
791 "body should contain text content, got: {body_text:?}"
792 );
793 }
794}