Skip to main content

pidge_core/
message.rs

1//! Normalized message representation, provider-agnostic.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// One message in a mailbox, as presented to commands and output formatters.
7///
8/// `account` is the email of the signed-in account this message belongs to,
9/// so multi-account merges can show provenance in a column.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Message {
12    pub account: String,
13    pub id: String,
14    pub from: MessageFrom,
15    pub subject: String,
16    pub received_at: DateTime<Utc>,
17    pub is_read: bool,
18    pub preview: String,
19    /// Outlook follow-up flag. Defaults to `NotFlagged` so old cache entries
20    /// and providers that don't expose flags deserialize cleanly.
21    #[serde(default)]
22    pub flag_status: FlagStatus,
23    /// Whether the message carries one or more attachments. Surfaced in
24    /// list views as a πŸ“Ž indicator so the user can scan for "which mail
25    /// had that file." Defaults to `false` for old cache entries.
26    #[serde(default)]
27    pub has_attachments: bool,
28    /// Full body content as returned by Graph. For HTML emails this is the
29    /// raw markup; for plain-text emails it's the original text. The
30    /// renderer uses [`body_content_type`] to decide how to convert it
31    /// (html2text β†’ text+OSC8 links for HTML; word-wrap for text).
32    #[serde(default)]
33    pub body: String,
34    #[serde(default)]
35    pub body_content_type: BodyContentType,
36}
37
38/// Outlook's follow-up flag state on a message. Three values to match what
39/// Graph returns; the CLI currently only sets `Flagged` / `NotFlagged`, but
40/// it reads and displays all three so a flag a user completed in Outlook
41/// shows up correctly here.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub enum FlagStatus {
45    #[default]
46    NotFlagged,
47    Flagged,
48    Complete,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct MessageFrom {
53    pub name: String,
54    pub address: String,
55}
56
57/// Full message content as returned by `GraphClient::get_message`.
58/// Compared to `Message` (the list-row shape), this carries full body,
59/// all recipient lists, sent/received timestamps, and a `has_attachments`
60/// flag for triggering the attachment fetch.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct FullMessage {
63    pub account: String,
64    pub id: String,
65    pub from: MessageFrom,
66    pub to: Vec<MessageFrom>,
67    pub cc: Vec<MessageFrom>,
68    pub bcc: Vec<MessageFrom>,
69    pub subject: String,
70    pub received_at: chrono::DateTime<chrono::Utc>,
71    pub sent_at: chrono::DateTime<chrono::Utc>,
72    pub is_read: bool,
73    pub body_content_type: BodyContentType,
74    pub body_content: String,
75    pub has_attachments: bool,
76    #[serde(default)]
77    pub flag_status: FlagStatus,
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum BodyContentType {
83    #[default]
84    Text,
85    Html,
86}
87
88/// An attachment listed on a message. Bytes are NOT included β€” fetch separately
89/// via `GraphClient::get_attachment_bytes(account, message_id, attachment.id)`.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Attachment {
92    /// Provider-specific identifier (Microsoft Graph attachment id).
93    pub id: String,
94    pub name: String,
95    pub content_type: String,
96    pub size_bytes: u64,
97    pub is_inline: bool,
98    pub content_id: Option<String>,
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn message_roundtrips_through_json() {
107        let m = Message {
108            account: "a@b.com".into(),
109            id: "id-1".into(),
110            from: MessageFrom {
111                name: "Maria Lindberg".into(),
112                address: "maria@mklab.se".into(),
113            },
114            subject: "Quarterly numbers".into(),
115            received_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
116                .unwrap()
117                .to_utc(),
118            is_read: false,
119            preview: "Hi, attached are…".into(),
120            flag_status: FlagStatus::NotFlagged,
121            has_attachments: false,
122            body: String::new(),
123            body_content_type: BodyContentType::Text,
124        };
125        let json = serde_json::to_string(&m).unwrap();
126        let m2: Message = serde_json::from_str(&json).unwrap();
127        assert_eq!(m, m2);
128    }
129
130    #[test]
131    fn full_message_roundtrips_through_json() {
132        let m = FullMessage {
133            account: "u@e.com".into(),
134            id: "graph-id".into(),
135            from: MessageFrom {
136                name: "Maria".into(),
137                address: "maria@mklab.se".into(),
138            },
139            to: vec![MessageFrom {
140                name: "Kristofer".into(),
141                address: "kristofer@mklab.se".into(),
142            }],
143            cc: vec![],
144            bcc: vec![],
145            subject: "Hi".into(),
146            received_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T22:00:00Z")
147                .unwrap()
148                .to_utc(),
149            sent_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T21:59:30Z")
150                .unwrap()
151                .to_utc(),
152            is_read: false,
153            body_content_type: BodyContentType::Html,
154            body_content: "<p>Hello</p>".into(),
155            has_attachments: true,
156            flag_status: FlagStatus::Flagged,
157        };
158        let json = serde_json::to_string(&m).unwrap();
159        let m2: FullMessage = serde_json::from_str(&json).unwrap();
160        assert_eq!(m, m2);
161    }
162
163    #[test]
164    fn attachment_roundtrips_through_json() {
165        let a = Attachment {
166            id: "att-1".into(),
167            name: "report.pdf".into(),
168            content_type: "application/pdf".into(),
169            size_bytes: 12345,
170            is_inline: false,
171            content_id: None,
172        };
173        let json = serde_json::to_string(&a).unwrap();
174        let a2: Attachment = serde_json::from_str(&json).unwrap();
175        assert_eq!(a, a2);
176    }
177
178    #[test]
179    fn body_content_type_serializes_lowercase() {
180        assert_eq!(
181            serde_json::to_string(&BodyContentType::Html).unwrap(),
182            "\"html\""
183        );
184        assert_eq!(
185            serde_json::to_string(&BodyContentType::Text).unwrap(),
186            "\"text\""
187        );
188    }
189}