aboutsummaryrefslogtreecommitdiffstats
path: root/src/message.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/message.rs')
-rw-r--r--src/message.rs168
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>");
+ }
+}