Skip to main content

sioul_core/
card.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A card: what the Porch knows about one message before you open it.
5
6use crate::headers::RawHeaders;
7use mail_parser::{MessageParser, MimeHeaders};
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// Characters of the body kept for the detectors and the preview.
12const EXCERPT_LIMIT: usize = 6000;
13
14/// Where a message sits on its server: the folder's UIDVALIDITY and the message's
15/// UID, which together name it for good (RFC 9051 §2.3.1.1).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct ImapOrigin {
18    pub validity: u32,
19    pub uid: u32,
20}
21
22/// One message, read once, kept small.
23#[derive(Debug, Clone)]
24pub struct Card {
25    /// Where the message is stored, when it came from disk.
26    pub path: Option<PathBuf>,
27    /// The account it came through, when it was read from one.
28    pub account: Option<String>,
29    /// Where it sits on the server, when Sioul fetched it.
30    pub origin: Option<ImapOrigin>,
31    pub from_name: Option<String>,
32    pub from_address: Option<String>,
33    /// To whom it went: names, else addresses.
34    pub to: Vec<String>,
35    pub subject: String,
36    /// The Date header, as Unix seconds.
37    pub date: Option<i64>,
38    pub message_id: Option<String>,
39    /// The start of the text body (HTML-only mail is converted by mail-parser).
40    pub excerpt: String,
41    pub attachments: Vec<String>,
42    /// A newsletter or a mailing list (List-Id, List-Unsubscribe, Precedence: bulk/list).
43    pub is_list: bool,
44    pub headers: RawHeaders,
45    /// What it is made of: its text and HTML versions, its other parts (the spam filter's features).
46    pub shape: Shape,
47}
48
49impl Card {
50    /// Reads a raw RFC 5322 message; none if it cannot be parsed at all.
51    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    /// The domain of the sender's address.
75    pub fn sender_domain(&self) -> Option<&str> {
76        self.from_address.as_deref()?.rsplit_once('@').map(|(_, d)| d)
77    }
78
79    /// The name to show: the display name, else the address.
80    pub fn sender(&self) -> &str {
81        self.from_name.as_deref().or(self.from_address.as_deref()).unwrap_or("unknown sender")
82    }
83
84    /// The whole text of the message, read again from disk: plain text only,
85    /// HTML converted by mail-parser, so nothing remote loads and nothing runs.
86    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
93/// Below this many characters shown (web addresses and spaces aside), a text
94/// version may be a stand-in for the HTML one.
95const STUB: usize = 160;
96
97/// The start of the text: the text version; when it is a stand-in that says
98/// much less than the HTML version ("View this email in your browser", or
99/// nothing but links), the HTML read as text after it, so that the code or
100/// the link a site sent is read where it is.
101fn excerpt_of(message: &mail_parser::Message<'_>) -> String {
102    let text = message.body_text(0).unwrap_or_default();
103    // Its start is enough to tell: a stand-in is short.
104    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
118/// An OpenPGP signature or encrypted payload: part of the message's protection, not something attached.
119fn 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/// What a message is made of, for the spam filter (`spam::features`): whether
129/// it has a text version and an HTML version to read, and the name and type
130/// of each of its other parts, attached or inline (an OpenPGP part is none).
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Shape {
133    /// A text/plain part to read.
134    pub text: bool,
135    /// A text/html part to read.
136    pub html: bool,
137    /// Every other part: its file name ("" when it has none) and its MIME type, lowercase.
138    pub parts: Vec<(String, String)>,
139}
140
141fn shape_of(message: &mail_parser::Message<'_>) -> Shape {
142    use mail_parser::PartType;
143    // mail-parser lends an HTML-only message's HTML as its text, and the reverse: the parts' own types say.
144    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}