1use crate::headers::RawHeaders;
7use mail_parser::{MessageParser, MimeHeaders};
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11const EXCERPT_LIMIT: usize = 6000;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct ImapOrigin {
18 pub validity: u32,
19 pub uid: u32,
20}
21
22#[derive(Debug, Clone)]
24pub struct Card {
25 pub path: Option<PathBuf>,
27 pub account: Option<String>,
29 pub origin: Option<ImapOrigin>,
31 pub from_name: Option<String>,
32 pub from_address: Option<String>,
33 pub to: Vec<String>,
35 pub subject: String,
36 pub date: Option<i64>,
38 pub message_id: Option<String>,
39 pub excerpt: String,
41 pub attachments: Vec<String>,
42 pub is_list: bool,
44 pub headers: RawHeaders,
45 pub shape: Shape,
47}
48
49impl Card {
50 pub fn from_bytes(raw: &[u8]) -> Option<Card> {
52 let message = MessageParser::default().parse(raw)?;
53 let from = message.from().and_then(|a| a.first());
54 let headers = RawHeaders::parse(raw);
55 let excerpt = excerpt_of(&message);
56 Some(Card {
57 path: None,
58 account: None,
59 origin: None,
60 from_name: from.and_then(|a| a.name()).map(str::to_string),
61 from_address: from.and_then(|a| a.address()).map(|a| a.to_ascii_lowercase()),
62 to: message.to().map(|a| a.iter().filter_map(|p| p.name().or(p.address()).map(str::to_string)).collect()).unwrap_or_default(),
63 subject: message.subject().unwrap_or("").to_string(),
64 date: message.date().map(|d| d.to_timestamp()),
65 message_id: message.message_id().map(str::to_string),
66 excerpt,
67 attachments: message.attachments().filter(|p| !is_pgp_part(p)).filter_map(|p| p.attachment_name()).map(str::to_string).collect(),
68 is_list: is_list(&headers),
69 headers,
70 shape: shape_of(&message),
71 })
72 }
73
74 pub fn sender_domain(&self) -> Option<&str> {
76 self.from_address.as_deref()?.rsplit_once('@').map(|(_, d)| d)
77 }
78
79 pub fn sender(&self) -> &str {
81 self.from_name.as_deref().or(self.from_address.as_deref()).unwrap_or("unknown sender")
82 }
83
84 pub fn full_text(&self) -> Option<String> {
87 let raw = std::fs::read(self.path.as_ref()?).ok()?;
88 let message = MessageParser::default().parse(&raw)?;
89 Some(message.body_text(0).map(|b| b.into_owned()).unwrap_or_default())
90 }
91}
92
93const STUB: usize = 160;
96
97fn excerpt_of(message: &mail_parser::Message<'_>) -> String {
102 let text = message.body_text(0).unwrap_or_default();
103 let start: String = text.chars().take(EXCERPT_LIMIT).collect();
105 let shown = crate::text::visible_len(&start);
106 let html = (shown < STUB)
107 .then(|| message.body_html(0))
108 .flatten()
109 .map(|html| mail_parser::decoders::html::html_to_text(&html))
110 .filter(|html| crate::text::visible_len(html) > 2 * shown.max(1));
111 match html {
112 Some(html) if shown == 0 => html.chars().take(EXCERPT_LIMIT).collect(),
113 Some(html) => format!("{}\n\n{html}", text.trim_end()).chars().take(EXCERPT_LIMIT).collect(),
114 None => start,
115 }
116}
117
118fn is_pgp_part(part: &mail_parser::MessagePart) -> bool {
120 use mail_parser::MimeHeaders;
121 part.content_type().is_some_and(|t| {
122 let kind = format!("{}/{}", t.ctype(), t.subtype().unwrap_or("")).to_ascii_lowercase();
123 matches!(kind.as_str(), "application/pgp-signature" | "application/pgp-encrypted" | "application/pgp-keys")
124 || (kind == "application/octet-stream" && t.attribute("name").is_some_and(|n| n.eq_ignore_ascii_case("encrypted.asc")))
125 })
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Shape {
133 pub text: bool,
135 pub html: bool,
137 pub parts: Vec<(String, String)>,
139}
140
141fn shape_of(message: &mail_parser::Message<'_>) -> Shape {
142 use mail_parser::PartType;
143 let text = message.text_bodies().any(|p| matches!(p.body, PartType::Text(_)));
145 let html = message.html_bodies().any(|p| matches!(p.body, PartType::Html(_)));
146 let parts = message
147 .attachments()
148 .filter(|p| !is_pgp_part(p))
149 .map(|p| {
150 let kind = p.content_type().map_or_else(String::new, |t| format!("{}/{}", t.ctype(), t.subtype().unwrap_or("")).to_ascii_lowercase());
151 (p.attachment_name().unwrap_or("").to_string(), kind)
152 })
153 .collect();
154 Shape { text, html, parts }
155}
156
157fn is_list(headers: &RawHeaders) -> bool {
158 let bulk = headers
159 .first("Precedence")
160 .is_some_and(|p| matches!(p.trim().to_ascii_lowercase().as_str(), "bulk" | "list"));
161 bulk || headers.has("List-Id") || headers.has("List-Unsubscribe")
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn reads_a_simple_message() {
170 let raw = b"From: \"Jane Doe\" <Jane@Example.org>\r\nSubject: =?UTF-8?Q?R=C3=A9union?=\r\nList-Id: <news.example.org>\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nHello.\r\n";
171 let card = Card::from_bytes(raw).unwrap();
172 assert_eq!(card.sender(), "Jane Doe");
173 assert_eq!(card.from_address.as_deref(), Some("jane@example.org"));
174 assert_eq!(card.sender_domain(), Some("example.org"));
175 assert_eq!(card.subject, "Réunion");
176 assert!(card.is_list);
177 assert!(card.date.is_some());
178 }
179}