diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 18:47:40 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 18:47:40 -0300 |
| commit | d9738b4be2add82ff955f061c79316b0dc91768f (patch) | |
| tree | e0f31bb9f72dff315fc475f9e9f30322b69602ff /src/message.rs | |
| download | posta-main.tar.gz posta-main.zip | |
Diffstat (limited to 'src/message.rs')
| -rw-r--r-- | src/message.rs | 168 |
1 files changed, 168 insertions, 0 deletions
diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..57d50d0 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,168 @@ +//! Message summaries and body extraction. + +use mailparse::{MailHeaderMap, ParsedMail}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct MsgSummary { + pub uid: u32, + pub flags: Vec<String>, + pub size: Option<u32>, + pub date: Option<String>, + pub from: Option<String>, + pub to: Option<String>, + pub subject: Option<String>, +} + +/// The header fields shown in listings, decoded (RFC 2047) by mailparse. +#[derive(Debug, Default, PartialEq)] +pub struct HeaderSummary { + pub date: Option<String>, + pub from: Option<String>, + pub to: Option<String>, + pub subject: Option<String>, +} + +pub fn parse_header_summary(raw: &[u8]) -> HeaderSummary { + match mailparse::parse_headers(raw) { + Ok((headers, _)) => HeaderSummary { + date: headers.get_first_value("Date"), + from: headers.get_first_value("From"), + to: headers.get_first_value("To"), + subject: headers.get_first_value("Subject"), + }, + Err(_) => HeaderSummary::default(), + } +} + +pub fn flag_to_string(f: &imap::types::Flag) -> String { + use imap::types::Flag::*; + match f { + Seen => "\\Seen".into(), + Answered => "\\Answered".into(), + Flagged => "\\Flagged".into(), + Deleted => "\\Deleted".into(), + Draft => "\\Draft".into(), + Recent => "\\Recent".into(), + Custom(c) => c.to_string(), + other => format!("{other:?}"), + } +} + +pub fn summarize(f: &imap::types::Fetch) -> Option<MsgSummary> { + let uid = f.uid?; + let h = f.header().map(parse_header_summary).unwrap_or_default(); + Some(MsgSummary { + uid, + flags: f.flags().iter().map(flag_to_string).collect(), + size: f.size, + date: h.date, + from: h.from, + to: h.to, + subject: h.subject, + }) +} + +fn find_part(mail: &ParsedMail, mimetype: &str) -> Option<String> { + if mail.ctype.mimetype.eq_ignore_ascii_case(mimetype) { + return mail.get_body().ok(); + } + mail.subparts.iter().find_map(|sp| find_part(sp, mimetype)) +} + +/// Picks the best displayable body: text/plain by default, text/html on request, +/// falling back to the other one when the preferred part is missing. +pub fn extract_body(mail: &ParsedMail, prefer_html: bool) -> Option<String> { + let order: [&str; 2] = if prefer_html { + ["text/html", "text/plain"] + } else { + ["text/plain", "text/html"] + }; + order.iter().find_map(|mt| find_part(mail, mt)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PLAIN: &[u8] = b"From: Alice <alice@example.com>\r\n\ +To: bob@example.com\r\n\ +Date: Fri, 17 Jul 2026 10:00:00 -0300\r\n\ +Subject: Hello\r\n\ +Content-Type: text/plain; charset=utf-8\r\n\ +\r\n\ +Just checking in.\r\n"; + + // Subject uses RFC 2047 encoded words; body parts use quoted-printable/base64. + const MULTIPART: &[u8] = b"From: =?utf-8?Q?Mar=C3=ADa?= <maria@example.com>\r\n\ +To: bob@example.com\r\n\ +Subject: =?utf-8?B?SG9sYSDwn5qA?=\r\n\ +MIME-Version: 1.0\r\n\ +Content-Type: multipart/alternative; boundary=XYZ\r\n\ +\r\n\ +--XYZ\r\n\ +Content-Type: text/plain; charset=utf-8\r\n\ +Content-Transfer-Encoding: quoted-printable\r\n\ +\r\n\ +Hola, =C2=BFtodo bien?\r\n\ +--XYZ\r\n\ +Content-Type: text/html; charset=utf-8\r\n\ +Content-Transfer-Encoding: base64\r\n\ +\r\n\ +PGI+SG9sYTwvYj4=\r\n\ +--XYZ--\r\n"; + + #[test] + fn header_summary_reads_basic_fields() { + let h = parse_header_summary(PLAIN); + assert_eq!(h.from.as_deref(), Some("Alice <alice@example.com>")); + assert_eq!(h.subject.as_deref(), Some("Hello")); + assert_eq!(h.date.as_deref(), Some("Fri, 17 Jul 2026 10:00:00 -0300")); + } + + #[test] + fn header_summary_decodes_rfc2047() { + let h = parse_header_summary(MULTIPART); + assert_eq!(h.from.as_deref(), Some("María <maria@example.com>")); + assert_eq!(h.subject.as_deref(), Some("Hola 🚀")); + } + + #[test] + fn garbage_headers_yield_empty_summary() { + assert_eq!( + parse_header_summary(b"\xff\xfe\x00"), + HeaderSummary::default() + ); + } + + #[test] + fn plain_body_is_extracted() { + let mail = mailparse::parse_mail(PLAIN).unwrap(); + assert_eq!( + extract_body(&mail, false).unwrap().trim(), + "Just checking in." + ); + } + + #[test] + fn multipart_prefers_plain_and_decodes_qp() { + let mail = mailparse::parse_mail(MULTIPART).unwrap(); + assert_eq!( + extract_body(&mail, false).unwrap().trim(), + "Hola, ¿todo bien?" + ); + } + + #[test] + fn html_flag_prefers_html_and_decodes_base64() { + let mail = mailparse::parse_mail(MULTIPART).unwrap(); + assert_eq!(extract_body(&mail, true).unwrap().trim(), "<b>Hola</b>"); + } + + #[test] + fn html_only_message_falls_back_when_plain_requested() { + let raw = b"Content-Type: text/html\r\n\r\n<p>hi</p>\r\n"; + let mail = mailparse::parse_mail(raw).unwrap(); + assert_eq!(extract_body(&mail, false).unwrap().trim(), "<p>hi</p>"); + } +} |