Skip to main content

omni_dev/gmail/
types.rs

1//! Wire types for the Gmail v1 REST API.
2//!
3//! Field naming follows Gmail's camelCase JSON via per-field
4//! `#[serde(rename = "...")]` (matching the Jira precedent for a
5//! camelCase upstream API, not Datadog's snake_case-native one).
6//! `Message::payload`/`raw` are the MIME-tree escape hatch: the per-part
7//! MIME structure is deeply recursive and heterogeneous, so it round-trips
8//! as raw `serde_json::Value` rather than being modelled — the same
9//! precedent as `Dashboard.widgets` in `src/datadog/types.rs`.
10
11use std::io::Write;
12
13use anyhow::Result;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17use crate::cli::gmail::format::{write_scalar_jsonl, JsonlSerialize};
18
19/// A bare `(id, threadId)` pair, as returned by `messages.list` without
20/// fetching each message's full content.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
22pub struct MessageRef {
23    /// Gmail message id.
24    pub id: String,
25    /// Id of the thread this message belongs to.
26    #[serde(default, rename = "threadId")]
27    pub thread_id: String,
28}
29
30/// A Gmail message.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
32pub struct Message {
33    /// Gmail message id.
34    pub id: String,
35    /// Id of the thread this message belongs to.
36    #[serde(default, skip_serializing_if = "Option::is_none", rename = "threadId")]
37    pub thread_id: Option<String>,
38    /// Labels currently applied to this message.
39    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelIds")]
40    pub label_ids: Vec<String>,
41    /// A short, plain-text snippet of the message body.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub snippet: Option<String>,
44    /// Epoch milliseconds as a decimal string — Gmail's own wire format.
45    #[serde(
46        default,
47        skip_serializing_if = "Option::is_none",
48        rename = "internalDate"
49    )]
50    pub internal_date: Option<String>,
51    /// The mailbox history id at the time this message last changed.
52    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
53    pub history_id: Option<String>,
54    /// Estimated size of the message in bytes.
55    #[serde(
56        default,
57        skip_serializing_if = "Option::is_none",
58        rename = "sizeEstimate"
59    )]
60    pub size_estimate: Option<i64>,
61    /// The parsed MIME structure (headers, body parts). Preserved as raw
62    /// JSON — see the module doc for why. Present when `format` is
63    /// `full`, `metadata`, or `minimal` with parts; absent otherwise.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub payload: Option<serde_json::Value>,
66    /// The full RFC 2822 message, base64url-encoded. Present only when
67    /// `format=raw` was requested.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub raw: Option<String>,
70}
71
72impl Message {
73    /// Parses [`Self::internal_date`] into a UTC timestamp.
74    ///
75    /// Returns `None` when absent or unparsable — Gmail's field is a
76    /// decimal-string epoch-millisecond value; malformed input degrades
77    /// gracefully rather than erroring.
78    #[must_use]
79    pub fn internal_date_utc(&self) -> Option<DateTime<Utc>> {
80        let ms: i64 = self.internal_date.as_deref()?.parse().ok()?;
81        DateTime::from_timestamp_millis(ms)
82    }
83}
84
85/// Response envelope for `GET /gmail/v1/users/{userId}/messages`.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
87pub struct MessageListResponse {
88    /// Matching messages on this page.
89    #[serde(default)]
90    pub messages: Vec<MessageRef>,
91    /// Cursor for the next page, when more results are available.
92    #[serde(
93        default,
94        skip_serializing_if = "Option::is_none",
95        rename = "nextPageToken"
96    )]
97    pub next_page_token: Option<String>,
98    /// Gmail's estimate of the total number of matches.
99    #[serde(
100        default,
101        skip_serializing_if = "Option::is_none",
102        rename = "resultSizeEstimate"
103    )]
104    pub result_size_estimate: Option<i64>,
105}
106
107/// A Gmail thread.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
109pub struct Thread {
110    /// Gmail thread id.
111    pub id: String,
112    /// The mailbox history id at the time this thread last changed.
113    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
114    pub history_id: Option<String>,
115    /// A short, plain-text snippet of the thread's most relevant message.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub snippet: Option<String>,
118    /// Messages in the thread.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub messages: Vec<Message>,
121}
122
123/// A thread reference, as returned by `threads.list`.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
125pub struct ThreadRef {
126    /// Gmail thread id.
127    pub id: String,
128    /// A short, plain-text snippet of the thread's most relevant message.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub snippet: Option<String>,
131    /// The mailbox history id at the time this thread last changed.
132    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
133    pub history_id: Option<String>,
134}
135
136/// Response envelope for `GET /gmail/v1/users/{userId}/threads`.
137#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
138pub struct ThreadListResponse {
139    /// Matching threads on this page.
140    #[serde(default)]
141    pub threads: Vec<ThreadRef>,
142    /// Cursor for the next page, when more results are available.
143    #[serde(
144        default,
145        skip_serializing_if = "Option::is_none",
146        rename = "nextPageToken"
147    )]
148    pub next_page_token: Option<String>,
149    /// Gmail's estimate of the total number of matches.
150    #[serde(
151        default,
152        skip_serializing_if = "Option::is_none",
153        rename = "resultSizeEstimate"
154    )]
155    pub result_size_estimate: Option<i64>,
156}
157
158/// A label's display colour.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
160pub struct LabelColor {
161    /// Text colour, as a hex string (e.g. `#000000`).
162    #[serde(rename = "textColor")]
163    pub text_color: String,
164    /// Background colour, as a hex string.
165    #[serde(rename = "backgroundColor")]
166    pub background_color: String,
167}
168
169/// A Gmail label.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
171pub struct Label {
172    /// Gmail label id.
173    pub id: String,
174    /// Display name.
175    pub name: String,
176    /// `"system"` (Gmail-provided, e.g. `INBOX`) or `"user"` (user-created).
177    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
178    pub label_type: Option<String>,
179    /// Whether messages with this label appear in the message list.
180    #[serde(
181        default,
182        skip_serializing_if = "Option::is_none",
183        rename = "messageListVisibility"
184    )]
185    pub message_list_visibility: Option<String>,
186    /// Whether this label appears in the label list.
187    #[serde(
188        default,
189        skip_serializing_if = "Option::is_none",
190        rename = "labelListVisibility"
191    )]
192    pub label_list_visibility: Option<String>,
193    /// Total number of messages with this label.
194    #[serde(
195        default,
196        skip_serializing_if = "Option::is_none",
197        rename = "messagesTotal"
198    )]
199    pub messages_total: Option<i64>,
200    /// Number of unread messages with this label.
201    #[serde(
202        default,
203        skip_serializing_if = "Option::is_none",
204        rename = "messagesUnread"
205    )]
206    pub messages_unread: Option<i64>,
207    /// Total number of threads with this label.
208    #[serde(
209        default,
210        skip_serializing_if = "Option::is_none",
211        rename = "threadsTotal"
212    )]
213    pub threads_total: Option<i64>,
214    /// Number of unread threads with this label.
215    #[serde(
216        default,
217        skip_serializing_if = "Option::is_none",
218        rename = "threadsUnread"
219    )]
220    pub threads_unread: Option<i64>,
221    /// Display colour, when set.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub color: Option<LabelColor>,
224}
225
226impl Label {
227    /// Whether this is a Gmail-provided system label (e.g. `INBOX`,
228    /// `TRASH`) rather than a user-created one.
229    #[must_use]
230    pub fn is_system(&self) -> bool {
231        self.label_type.as_deref() == Some("system")
232    }
233}
234
235/// Response envelope for `GET /gmail/v1/users/{userId}/labels`.
236///
237/// Unpaginated — Gmail returns every label in one call.
238#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
239pub struct LabelListResponse {
240    /// All labels on the mailbox.
241    #[serde(default)]
242    pub labels: Vec<Label>,
243}
244
245/// A `(id, threadId, labelIds)` triple, as embedded in
246/// `messagesAdded`/`messagesDeleted` history events.
247///
248/// Distinct from [`MessageRef`] (which `messages.list` returns and which
249/// never carries `labelIds`) because history events need the label set to
250/// seed a new message's archived record without a second fetch — see
251/// `src/cli/gmail/sync/engine.rs`.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
253pub struct HistoryMessageRef {
254    /// Gmail message id.
255    pub id: String,
256    /// Id of the thread this message belongs to.
257    #[serde(default, rename = "threadId")]
258    pub thread_id: String,
259    /// Labels applied to the message at the time of this history event.
260    #[serde(default, rename = "labelIds")]
261    pub label_ids: Vec<String>,
262}
263
264/// A message added in a history record.
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
266pub struct HistoryMessageAdded {
267    /// The message that was added.
268    pub message: HistoryMessageRef,
269}
270
271/// A message deleted in a history record.
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
273pub struct HistoryMessageDeleted {
274    /// The message that was deleted.
275    pub message: MessageRef,
276}
277
278/// A label-change event in a history record.
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
280pub struct HistoryLabelChanged {
281    /// The message whose labels changed.
282    pub message: MessageRef,
283    /// The label ids that were added or removed.
284    #[serde(default, rename = "labelIds")]
285    pub label_ids: Vec<String>,
286}
287
288/// One entry in the mailbox's change history.
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
290pub struct HistoryRecord {
291    /// This history record's id.
292    pub id: String,
293    /// Messages added since the previous record.
294    #[serde(
295        default,
296        skip_serializing_if = "Vec::is_empty",
297        rename = "messagesAdded"
298    )]
299    pub messages_added: Vec<HistoryMessageAdded>,
300    /// Messages deleted since the previous record.
301    #[serde(
302        default,
303        skip_serializing_if = "Vec::is_empty",
304        rename = "messagesDeleted"
305    )]
306    pub messages_deleted: Vec<HistoryMessageDeleted>,
307    /// Labels added to messages since the previous record.
308    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelsAdded")]
309    pub labels_added: Vec<HistoryLabelChanged>,
310    /// Labels removed from messages since the previous record.
311    #[serde(
312        default,
313        skip_serializing_if = "Vec::is_empty",
314        rename = "labelsRemoved"
315    )]
316    pub labels_removed: Vec<HistoryLabelChanged>,
317}
318
319/// Response envelope for `GET /gmail/v1/users/{userId}/history`.
320#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
321pub struct HistoryListResponse {
322    /// History records since the requested `startHistoryId`.
323    #[serde(default)]
324    pub history: Vec<HistoryRecord>,
325    /// Cursor for the next page, when more results are available.
326    #[serde(
327        default,
328        skip_serializing_if = "Option::is_none",
329        rename = "nextPageToken"
330    )]
331    pub next_page_token: Option<String>,
332    /// The mailbox's current `historyId`. Present only on the *last* page
333    /// (when [`Self::next_page_token`] is absent) — `gmail sync`'s next
334    /// watermark.
335    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
336    pub history_id: Option<String>,
337}
338
339impl JsonlSerialize for HistoryListResponse {
340    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
341        crate::cli::gmail::format::write_items_jsonl(self.history.iter(), out)
342    }
343}
344
345impl JsonlSerialize for Message {
346    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
347        write_scalar_jsonl(self, out)
348    }
349}
350
351impl JsonlSerialize for Thread {
352    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
353        write_scalar_jsonl(self, out)
354    }
355}
356
357impl JsonlSerialize for Label {
358    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
359        write_scalar_jsonl(self, out)
360    }
361}
362
363#[cfg(test)]
364#[allow(clippy::unwrap_used, clippy::expect_used)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn message_list_response_deserializes_a_realistic_fixture() {
370        let json = serde_json::json!({
371            "messages": [
372                {"id": "msg1", "threadId": "thread1"},
373                {"id": "msg2", "threadId": "thread1"},
374            ],
375            "nextPageToken": "page2",
376            "resultSizeEstimate": 2,
377        });
378        let response: MessageListResponse = serde_json::from_value(json).unwrap();
379        assert_eq!(response.messages.len(), 2);
380        assert_eq!(response.messages[0].id, "msg1");
381        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
382    }
383
384    #[test]
385    fn message_deserializes_nested_mime_payload_and_round_trips() {
386        let json = serde_json::json!({
387            "id": "msg1",
388            "threadId": "thread1",
389            "labelIds": ["INBOX", "UNREAD"],
390            "snippet": "Hello there",
391            "internalDate": "1700000000000",
392            "payload": {
393                "mimeType": "multipart/mixed",
394                "headers": [{"name": "Subject", "value": "Hi"}],
395                "parts": [
396                    {
397                        "mimeType": "multipart/alternative",
398                        "parts": [
399                            {"mimeType": "text/plain", "body": {"data": "aGVsbG8"}},
400                            {"mimeType": "text/html", "body": {"data": "PGI+aGVsbG88L2I+"}},
401                        ]
402                    }
403                ]
404            }
405        });
406        let message: Message = serde_json::from_value(json.clone()).unwrap();
407        assert_eq!(message.id, "msg1");
408        assert_eq!(message.label_ids, vec!["INBOX", "UNREAD"]);
409        assert_eq!(message.payload, Some(json["payload"].clone()));
410
411        // Round-trips the nested structure losslessly.
412        let reserialized = serde_json::to_value(&message).unwrap();
413        assert_eq!(reserialized["payload"], json["payload"]);
414    }
415
416    #[test]
417    fn message_with_format_minimal_has_no_payload() {
418        let json = serde_json::json!({"id": "msg1", "labelIds": ["INBOX"]});
419        let message: Message = serde_json::from_value(json).unwrap();
420        assert_eq!(message.payload, None);
421    }
422
423    #[test]
424    fn message_internal_date_utc_parses_valid_epoch_ms() {
425        let message = Message {
426            internal_date: Some("1700000000000".to_string()),
427            ..Default::default()
428        };
429        let dt = message.internal_date_utc().unwrap();
430        assert_eq!(dt.timestamp_millis(), 1_700_000_000_000);
431    }
432
433    #[test]
434    fn message_internal_date_utc_is_none_for_missing_or_invalid() {
435        assert_eq!(Message::default().internal_date_utc(), None);
436        let message = Message {
437            internal_date: Some("not-a-number".to_string()),
438            ..Default::default()
439        };
440        assert_eq!(message.internal_date_utc(), None);
441    }
442
443    #[test]
444    fn thread_deserializes_embedded_messages() {
445        let json = serde_json::json!({
446            "id": "thread1",
447            "historyId": "1000",
448            "messages": [
449                {"id": "msg1", "threadId": "thread1"},
450                {"id": "msg2", "threadId": "thread1"},
451            ]
452        });
453        let thread: Thread = serde_json::from_value(json).unwrap();
454        assert_eq!(thread.messages.len(), 2);
455        assert_eq!(thread.history_id.as_deref(), Some("1000"));
456    }
457
458    #[test]
459    fn label_deserializes_with_and_without_color() {
460        let with_color = serde_json::json!({
461            "id": "Label_1",
462            "name": "Finance",
463            "type": "user",
464            "color": {"textColor": "#000000", "backgroundColor": "#ffffff"},
465        });
466        let label: Label = serde_json::from_value(with_color).unwrap();
467        assert!(label.color.is_some());
468        assert!(!label.is_system());
469
470        let without_color = serde_json::json!({"id": "INBOX", "name": "INBOX", "type": "system"});
471        let label: Label = serde_json::from_value(without_color).unwrap();
472        assert!(label.color.is_none());
473        assert!(label.is_system());
474    }
475
476    #[test]
477    fn message_write_jsonl_emits_exactly_one_line() {
478        let message = Message {
479            id: "msg1".to_string(),
480            ..Default::default()
481        };
482        let mut buf = Vec::new();
483        message.write_jsonl(&mut buf).unwrap();
484        let text = String::from_utf8(buf).unwrap();
485        assert_eq!(text.lines().count(), 1);
486        assert!(text.contains("msg1"));
487    }
488
489    #[test]
490    fn thread_write_jsonl_emits_exactly_one_line() {
491        let thread = Thread {
492            id: "t1".to_string(),
493            ..Default::default()
494        };
495        let mut buf = Vec::new();
496        thread.write_jsonl(&mut buf).unwrap();
497        let text = String::from_utf8(buf).unwrap();
498        assert_eq!(text.lines().count(), 1);
499        assert!(text.contains("t1"));
500    }
501
502    #[test]
503    fn label_write_jsonl_emits_exactly_one_line() {
504        let label = Label {
505            id: "INBOX".to_string(),
506            name: "INBOX".to_string(),
507            ..Default::default()
508        };
509        let mut buf = Vec::new();
510        label.write_jsonl(&mut buf).unwrap();
511        let text = String::from_utf8(buf).unwrap();
512        assert_eq!(text.lines().count(), 1);
513        assert!(text.contains("INBOX"));
514    }
515
516    #[test]
517    fn deserializing_unmodeled_extra_field_still_succeeds() {
518        // Forward-compatible with Google adding fields: confirms no
519        // accidental `#[serde(deny_unknown_fields)]`.
520        let json = serde_json::json!({
521            "id": "msg1",
522            "threadId": "t1",
523            "driveDetails": {"somethingNew": true},
524        });
525        let message: Message = serde_json::from_value(json).unwrap();
526        assert_eq!(message.id, "msg1");
527    }
528
529    #[test]
530    fn message_list_response_empty_page_with_valid_next_page_token_deserializes() {
531        // The Gmail-specific pagination quirk: a filtered list can return
532        // zero results alongside a valid nextPageToken.
533        let json = serde_json::json!({"messages": [], "nextPageToken": "page2"});
534        let response: MessageListResponse = serde_json::from_value(json).unwrap();
535        assert!(response.messages.is_empty());
536        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
537    }
538}