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