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