Skip to main content

agent_first_mail/
mail.rs

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
243/// Collect RFC822 message-ids from an `In-Reply-To` / `References` header,
244/// which `mail-parser` exposes as a single `Text` or a `TextList` with the
245/// angle brackets already stripped. Stored bracket-less to match how
246/// `rfc822_message_id` is recorded; brackets are re-added when a header is
247/// emitted.
248fn 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
294/// Registrable domain (the user-visible address domain) for an authentication
295/// property value such as `user@domain`, `@domain`, or a bare `domain`.
296fn 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
306/// Last two labels of a domain, used as a coarse organizational-domain match for
307/// DMARC alignment. This intentionally over-matches multi-label public suffixes
308/// (e.g. `co.uk`); afmail does not bundle a Public Suffix List.
309fn 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
326/// Parse `Authentication-Results` header(s) into structured verdicts, the
327/// authenticated domain, and DMARC/`From` alignment.
328fn 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
354/// Split on top-level `;`, leaving parenthesised comments (which may contain
355/// `;` or `=`) intact within each segment.
356fn 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
380/// Remove parenthesised comments from a segment, returning the bare segment and
381/// the concatenated comment text (where DMARC carries its `p=` policy).
382fn 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    // The first segment is the authserv-id, and any token without `method=result`
408    // is ignored — only spf/dkim/dmarc results drive the structured fields.
409    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    // Only a passing mechanism contributes an authenticated domain.
426    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
476/// Combine the same mechanism seen across multiple headers/signatures: a `Pass`
477/// always wins, otherwise the first concrete verdict is kept.
478fn 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            // Stored bracket-less, matching rfc822_message_id storage.
712            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}