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    /// `To` recipients; empty for old cache entries and providers that don't list them.
40    #[serde(default)]
41    pub to: Vec<MessageFrom>,
42    #[serde(default)]
43    pub cc: Vec<MessageFrom>,
44    /// A meeting request (Graph `eventMessageRequest`).
45    #[serde(default)]
46    pub is_invite: bool,
47}
48
49/// Outlook's follow-up flag state on a message. Three values to match what
50/// Graph returns; the CLI currently only sets `Flagged` / `NotFlagged`, but
51/// it reads and displays all three so a flag a user completed in Outlook
52/// shows up correctly here.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub enum FlagStatus {
56    #[default]
57    NotFlagged,
58    Flagged,
59    Complete,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MessageFrom {
64    pub name: String,
65    pub address: String,
66}
67
68/// Full message content as returned by `GraphClient::get_message`.
69/// Compared to `Message` (the list-row shape), this carries full body,
70/// all recipient lists, sent/received timestamps, and a `has_attachments`
71/// flag for triggering the attachment fetch.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct FullMessage {
74    pub account: String,
75    pub id: String,
76    /// Graph conversation (thread) id; empty when not requested.
77    #[serde(default)]
78    pub conversation_id: String,
79    pub from: MessageFrom,
80    pub to: Vec<MessageFrom>,
81    pub cc: Vec<MessageFrom>,
82    pub bcc: Vec<MessageFrom>,
83    pub subject: String,
84    pub received_at: chrono::DateTime<chrono::Utc>,
85    pub sent_at: chrono::DateTime<chrono::Utc>,
86    pub is_read: bool,
87    pub body_content_type: BodyContentType,
88    pub body_content: String,
89    pub has_attachments: bool,
90    #[serde(default)]
91    pub flag_status: FlagStatus,
92    /// A meeting request (Graph `eventMessageRequest`).
93    #[serde(default)]
94    pub is_invite: bool,
95    /// For a meeting request, the calendar event it invites to.
96    #[serde(default)]
97    pub event_id: Option<String>,
98    /// An unsent draft (Graph `isDraft`).
99    #[serde(default)]
100    pub is_draft: bool,
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum BodyContentType {
106    #[default]
107    Text,
108    Html,
109}
110
111/// An attachment listed on a message. Bytes are NOT included; fetch separately
112/// via `GraphClient::get_attachment_bytes(account, message_id, attachment.id)`.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Attachment {
115    /// Provider-specific identifier (Microsoft Graph attachment id).
116    pub id: String,
117    pub name: String,
118    pub content_type: String,
119    pub size_bytes: u64,
120    pub is_inline: bool,
121    pub content_id: Option<String>,
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn message_roundtrips_through_json() {
130        let m = Message {
131            conversation_id: String::new(),
132            account: "a@b.com".into(),
133            id: "id-1".into(),
134            from: MessageFrom {
135                name: "Maria Lindberg".into(),
136                address: "maria@mklab.se".into(),
137            },
138            subject: "Quarterly numbers".into(),
139            received_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
140                .unwrap()
141                .to_utc(),
142            is_read: false,
143            preview: "Hi, attached are…".into(),
144            flag_status: FlagStatus::NotFlagged,
145            has_attachments: false,
146            body: String::new(),
147            body_content_type: BodyContentType::Text,
148            to: vec![],
149            cc: vec![],
150            is_invite: false,
151        };
152        let json = serde_json::to_string(&m).unwrap();
153        let m2: Message = serde_json::from_str(&json).unwrap();
154        assert_eq!(m, m2);
155    }
156
157    #[test]
158    fn full_message_roundtrips_through_json() {
159        let m = FullMessage {
160            conversation_id: String::new(),
161            account: "u@e.com".into(),
162            id: "graph-id".into(),
163            from: MessageFrom {
164                name: "Maria".into(),
165                address: "maria@mklab.se".into(),
166            },
167            to: vec![MessageFrom {
168                name: "Kristofer".into(),
169                address: "kristofer@mklab.se".into(),
170            }],
171            cc: vec![],
172            bcc: vec![],
173            subject: "Hi".into(),
174            received_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T22:00:00Z")
175                .unwrap()
176                .to_utc(),
177            sent_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T21:59:30Z")
178                .unwrap()
179                .to_utc(),
180            is_read: false,
181            body_content_type: BodyContentType::Html,
182            body_content: "<p>Hello</p>".into(),
183            has_attachments: true,
184            flag_status: FlagStatus::Flagged,
185            is_invite: true,
186            event_id: Some("event-1".into()),
187            is_draft: false,
188        };
189        let json = serde_json::to_string(&m).unwrap();
190        let m2: FullMessage = serde_json::from_str(&json).unwrap();
191        assert_eq!(m, m2);
192    }
193
194    #[test]
195    fn attachment_roundtrips_through_json() {
196        let a = Attachment {
197            id: "att-1".into(),
198            name: "report.pdf".into(),
199            content_type: "application/pdf".into(),
200            size_bytes: 12345,
201            is_inline: false,
202            content_id: None,
203        };
204        let json = serde_json::to_string(&a).unwrap();
205        let a2: Attachment = serde_json::from_str(&json).unwrap();
206        assert_eq!(a, a2);
207    }
208
209    #[test]
210    fn body_content_type_serializes_lowercase() {
211        assert_eq!(
212            serde_json::to_string(&BodyContentType::Html).unwrap(),
213            "\"html\""
214        );
215        assert_eq!(
216            serde_json::to_string(&BodyContentType::Text).unwrap(),
217            "\"text\""
218        );
219    }
220}