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}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct MessageFrom {
23    pub name: String,
24    pub address: String,
25}
26
27/// Full message content as returned by `GraphClient::get_message`.
28/// Compared to `Message` (the list-row shape), this carries full body,
29/// all recipient lists, sent/received timestamps, and a `has_attachments`
30/// flag for triggering the attachment fetch.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct FullMessage {
33    pub account: String,
34    pub id: String,
35    pub from: MessageFrom,
36    pub to: Vec<MessageFrom>,
37    pub cc: Vec<MessageFrom>,
38    pub bcc: Vec<MessageFrom>,
39    pub subject: String,
40    pub received_at: chrono::DateTime<chrono::Utc>,
41    pub sent_at: chrono::DateTime<chrono::Utc>,
42    pub is_read: bool,
43    pub body_content_type: BodyContentType,
44    pub body_content: String,
45    pub has_attachments: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum BodyContentType {
51    Text,
52    Html,
53}
54
55/// An attachment listed on a message. Bytes are NOT included — fetch separately
56/// via `GraphClient::get_attachment_bytes(account, message_id, attachment.id)`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Attachment {
59    /// Provider-specific identifier (Microsoft Graph attachment id).
60    pub id: String,
61    pub name: String,
62    pub content_type: String,
63    pub size_bytes: u64,
64    pub is_inline: bool,
65    pub content_id: Option<String>,
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn message_roundtrips_through_json() {
74        let m = Message {
75            account: "a@b.com".into(),
76            id: "id-1".into(),
77            from: MessageFrom {
78                name: "Maria Lindberg".into(),
79                address: "maria@mklab.se".into(),
80            },
81            subject: "Quarterly numbers".into(),
82            received_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
83                .unwrap()
84                .to_utc(),
85            is_read: false,
86            preview: "Hi, attached are…".into(),
87        };
88        let json = serde_json::to_string(&m).unwrap();
89        let m2: Message = serde_json::from_str(&json).unwrap();
90        assert_eq!(m, m2);
91    }
92
93    #[test]
94    fn full_message_roundtrips_through_json() {
95        let m = FullMessage {
96            account: "u@e.com".into(),
97            id: "graph-id".into(),
98            from: MessageFrom {
99                name: "Maria".into(),
100                address: "maria@mklab.se".into(),
101            },
102            to: vec![MessageFrom {
103                name: "Kristofer".into(),
104                address: "kristofer@mklab.se".into(),
105            }],
106            cc: vec![],
107            bcc: vec![],
108            subject: "Hi".into(),
109            received_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T22:00:00Z")
110                .unwrap()
111                .to_utc(),
112            sent_at: chrono::DateTime::parse_from_rfc3339("2026-05-14T21:59:30Z")
113                .unwrap()
114                .to_utc(),
115            is_read: false,
116            body_content_type: BodyContentType::Html,
117            body_content: "<p>Hello</p>".into(),
118            has_attachments: true,
119        };
120        let json = serde_json::to_string(&m).unwrap();
121        let m2: FullMessage = serde_json::from_str(&json).unwrap();
122        assert_eq!(m, m2);
123    }
124
125    #[test]
126    fn attachment_roundtrips_through_json() {
127        let a = Attachment {
128            id: "att-1".into(),
129            name: "report.pdf".into(),
130            content_type: "application/pdf".into(),
131            size_bytes: 12345,
132            is_inline: false,
133            content_id: None,
134        };
135        let json = serde_json::to_string(&a).unwrap();
136        let a2: Attachment = serde_json::from_str(&json).unwrap();
137        assert_eq!(a, a2);
138    }
139
140    #[test]
141    fn body_content_type_serializes_lowercase() {
142        assert_eq!(
143            serde_json::to_string(&BodyContentType::Html).unwrap(),
144            "\"html\""
145        );
146        assert_eq!(
147            serde_json::to_string(&BodyContentType::Text).unwrap(),
148            "\"text\""
149        );
150    }
151}