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        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
249/// Collect RFC822 message-ids from an `In-Reply-To` / `References` header,
250/// which `mail-parser` exposes as a single `Text` or a `TextList` with the
251/// angle brackets already stripped. Stored bracket-less to match how
252/// `rfc822_message_id` is recorded; brackets are re-added when a header is
253/// emitted.
254fn 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
300/// Registrable domain (the user-visible address domain) for an authentication
301/// property value such as `user@domain`, `@domain`, or a bare `domain`.
302fn 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
312/// Last two labels of a domain, used as a coarse organizational-domain match for
313/// DMARC alignment. This intentionally over-matches multi-label public suffixes
314/// (e.g. `co.uk`); afmail does not bundle a Public Suffix List.
315fn 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
332/// Parse `Authentication-Results` header(s) into structured verdicts, the
333/// authenticated domain, and DMARC/`From` alignment.
334fn 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
360/// Split on top-level `;`, leaving parenthesised comments (which may contain
361/// `;` or `=`) intact within each segment.
362fn 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
386/// Remove parenthesised comments from a segment, returning the bare segment and
387/// the concatenated comment text (where DMARC carries its `p=` policy).
388fn 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    // The first segment is the authserv-id, and any token without `method=result`
414    // is ignored — only spf/dkim/dmarc results drive the structured fields.
415    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    // Only a passing mechanism contributes an authenticated domain.
432    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
482/// Combine the same mechanism seen across multiple headers/signatures: a `Pass`
483/// always wins, otherwise the first concrete verdict is kept.
484fn 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            // Stored bracket-less, matching rfc822_message_id storage.
718            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}