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 draft, as returned by `drafts.list`.
108///
109/// `message` carries only `{id, threadId}` — `drafts.list` never returns
110/// more, so a useful row costs one `messages.get` per draft (see
111/// [`crate::gmail::drafts_api::DraftsApi::list_summaries`]). `id` is the
112/// **draft** id every other drafts endpoint is addressed by; it is distinct
113/// from `message.id`, which changes each time the draft is updated.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
115pub struct Draft {
116    /// Gmail draft id.
117    pub id: String,
118    /// The draft's current message.
119    #[serde(default)]
120    pub message: MessageRef,
121}
122
123/// A Gmail draft with its full message, as returned by `drafts.get`.
124///
125/// Unlike [`Draft`], whose message is only `{id, threadId}`, this carries
126/// the whole [`Message`] at whichever `format` was requested. `id` is the
127/// draft id, which stays the same across saves; `message.id` changes on
128/// every save.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
130pub struct DraftDetail {
131    /// Gmail draft id.
132    pub id: String,
133    /// The draft's current message.
134    #[serde(default)]
135    pub message: Message,
136}
137
138/// Response envelope for `GET /gmail/v1/users/{userId}/drafts`.
139#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
140pub struct DraftListResponse {
141    /// Drafts on this page. Gmail omits the key entirely when there are none.
142    #[serde(default)]
143    pub drafts: Vec<Draft>,
144    /// Cursor for the next page, when more results are available.
145    #[serde(
146        default,
147        skip_serializing_if = "Option::is_none",
148        rename = "nextPageToken"
149    )]
150    pub next_page_token: Option<String>,
151    /// Gmail's estimate of the total number of drafts.
152    #[serde(
153        default,
154        skip_serializing_if = "Option::is_none",
155        rename = "resultSizeEstimate"
156    )]
157    pub result_size_estimate: Option<i64>,
158}
159
160/// A Gmail thread.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
162pub struct Thread {
163    /// Gmail thread id.
164    pub id: String,
165    /// The mailbox history id at the time this thread last changed.
166    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
167    pub history_id: Option<String>,
168    /// A short, plain-text snippet of the thread's most relevant message.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub snippet: Option<String>,
171    /// Messages in the thread.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub messages: Vec<Message>,
174}
175
176/// A thread reference, as returned by `threads.list`.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
178pub struct ThreadRef {
179    /// Gmail thread id.
180    pub id: String,
181    /// A short, plain-text snippet of the thread's most relevant message.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub snippet: Option<String>,
184    /// The mailbox history id at the time this thread last changed.
185    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
186    pub history_id: Option<String>,
187}
188
189/// Response envelope for `GET /gmail/v1/users/{userId}/threads`.
190#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
191pub struct ThreadListResponse {
192    /// Matching threads on this page.
193    #[serde(default)]
194    pub threads: Vec<ThreadRef>,
195    /// Cursor for the next page, when more results are available.
196    #[serde(
197        default,
198        skip_serializing_if = "Option::is_none",
199        rename = "nextPageToken"
200    )]
201    pub next_page_token: Option<String>,
202    /// Gmail's estimate of the total number of matches.
203    #[serde(
204        default,
205        skip_serializing_if = "Option::is_none",
206        rename = "resultSizeEstimate"
207    )]
208    pub result_size_estimate: Option<i64>,
209}
210
211/// A label's display colour.
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
213pub struct LabelColor {
214    /// Text colour, as a hex string (e.g. `#000000`).
215    #[serde(rename = "textColor")]
216    pub text_color: String,
217    /// Background colour, as a hex string.
218    #[serde(rename = "backgroundColor")]
219    pub background_color: String,
220}
221
222/// A Gmail label.
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
224pub struct Label {
225    /// Gmail label id.
226    pub id: String,
227    /// Display name.
228    pub name: String,
229    /// `"system"` (Gmail-provided, e.g. `INBOX`) or `"user"` (user-created).
230    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
231    pub label_type: Option<String>,
232    /// Whether messages with this label appear in the message list.
233    #[serde(
234        default,
235        skip_serializing_if = "Option::is_none",
236        rename = "messageListVisibility"
237    )]
238    pub message_list_visibility: Option<String>,
239    /// Whether this label appears in the label list.
240    #[serde(
241        default,
242        skip_serializing_if = "Option::is_none",
243        rename = "labelListVisibility"
244    )]
245    pub label_list_visibility: Option<String>,
246    /// Total number of messages with this label.
247    #[serde(
248        default,
249        skip_serializing_if = "Option::is_none",
250        rename = "messagesTotal"
251    )]
252    pub messages_total: Option<i64>,
253    /// Number of unread messages with this label.
254    #[serde(
255        default,
256        skip_serializing_if = "Option::is_none",
257        rename = "messagesUnread"
258    )]
259    pub messages_unread: Option<i64>,
260    /// Total number of threads with this label.
261    #[serde(
262        default,
263        skip_serializing_if = "Option::is_none",
264        rename = "threadsTotal"
265    )]
266    pub threads_total: Option<i64>,
267    /// Number of unread threads with this label.
268    #[serde(
269        default,
270        skip_serializing_if = "Option::is_none",
271        rename = "threadsUnread"
272    )]
273    pub threads_unread: Option<i64>,
274    /// Display colour, when set.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub color: Option<LabelColor>,
277}
278
279impl Label {
280    /// Whether this is a Gmail-provided system label (e.g. `INBOX`,
281    /// `TRASH`) rather than a user-created one.
282    #[must_use]
283    pub fn is_system(&self) -> bool {
284        self.label_type.as_deref() == Some("system")
285    }
286}
287
288/// Response envelope for `GET /gmail/v1/users/{userId}/labels`.
289///
290/// Unpaginated — Gmail returns every label in one call.
291#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
292pub struct LabelListResponse {
293    /// All labels on the mailbox.
294    #[serde(default)]
295    pub labels: Vec<Label>,
296}
297
298/// A `(id, threadId, labelIds)` triple, as embedded in
299/// `messagesAdded`/`messagesDeleted` history events.
300///
301/// Distinct from [`MessageRef`] (which `messages.list` returns and which
302/// never carries `labelIds`) because history events need the label set to
303/// seed a new message's archived record without a second fetch — see
304/// `src/cli/gmail/sync/engine.rs`.
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
306pub struct HistoryMessageRef {
307    /// Gmail message id.
308    pub id: String,
309    /// Id of the thread this message belongs to.
310    #[serde(default, rename = "threadId")]
311    pub thread_id: String,
312    /// Labels applied to the message at the time of this history event.
313    #[serde(default, rename = "labelIds")]
314    pub label_ids: Vec<String>,
315}
316
317/// A message added in a history record.
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
319pub struct HistoryMessageAdded {
320    /// The message that was added.
321    pub message: HistoryMessageRef,
322}
323
324/// A message deleted in a history record.
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
326pub struct HistoryMessageDeleted {
327    /// The message that was deleted.
328    pub message: MessageRef,
329}
330
331/// A label-change event in a history record.
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
333pub struct HistoryLabelChanged {
334    /// The message whose labels changed.
335    pub message: MessageRef,
336    /// The label ids that were added or removed.
337    #[serde(default, rename = "labelIds")]
338    pub label_ids: Vec<String>,
339}
340
341/// One entry in the mailbox's change history.
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
343pub struct HistoryRecord {
344    /// This history record's id.
345    pub id: String,
346    /// Messages added since the previous record.
347    #[serde(
348        default,
349        skip_serializing_if = "Vec::is_empty",
350        rename = "messagesAdded"
351    )]
352    pub messages_added: Vec<HistoryMessageAdded>,
353    /// Messages deleted since the previous record.
354    #[serde(
355        default,
356        skip_serializing_if = "Vec::is_empty",
357        rename = "messagesDeleted"
358    )]
359    pub messages_deleted: Vec<HistoryMessageDeleted>,
360    /// Labels added to messages since the previous record.
361    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelsAdded")]
362    pub labels_added: Vec<HistoryLabelChanged>,
363    /// Labels removed from messages since the previous record.
364    #[serde(
365        default,
366        skip_serializing_if = "Vec::is_empty",
367        rename = "labelsRemoved"
368    )]
369    pub labels_removed: Vec<HistoryLabelChanged>,
370}
371
372/// Response envelope for `GET /gmail/v1/users/{userId}/history`.
373#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
374pub struct HistoryListResponse {
375    /// History records since the requested `startHistoryId`.
376    #[serde(default)]
377    pub history: Vec<HistoryRecord>,
378    /// Cursor for the next page, when more results are available.
379    #[serde(
380        default,
381        skip_serializing_if = "Option::is_none",
382        rename = "nextPageToken"
383    )]
384    pub next_page_token: Option<String>,
385    /// The mailbox's current `historyId`. Present only on the *last* page
386    /// (when [`Self::next_page_token`] is absent) — `gmail sync`'s next
387    /// watermark.
388    #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
389    pub history_id: Option<String>,
390}
391
392impl JsonlSerialize for HistoryListResponse {
393    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
394        crate::cli::gmail::format::write_items_jsonl(self.history.iter(), out)
395    }
396}
397
398impl JsonlSerialize for Message {
399    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
400        write_scalar_jsonl(self, out)
401    }
402}
403
404impl JsonlSerialize for DraftDetail {
405    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
406        write_scalar_jsonl(self, out)
407    }
408}
409
410impl JsonlSerialize for Thread {
411    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
412        write_scalar_jsonl(self, out)
413    }
414}
415
416impl JsonlSerialize for Label {
417    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
418        write_scalar_jsonl(self, out)
419    }
420}
421
422#[cfg(test)]
423#[allow(clippy::unwrap_used, clippy::expect_used)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn message_list_response_deserializes_a_realistic_fixture() {
429        let json = serde_json::json!({
430            "messages": [
431                {"id": "msg1", "threadId": "thread1"},
432                {"id": "msg2", "threadId": "thread1"},
433            ],
434            "nextPageToken": "page2",
435            "resultSizeEstimate": 2,
436        });
437        let response: MessageListResponse = serde_json::from_value(json).unwrap();
438        assert_eq!(response.messages.len(), 2);
439        assert_eq!(response.messages[0].id, "msg1");
440        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
441    }
442
443    #[test]
444    fn message_deserializes_nested_mime_payload_and_round_trips() {
445        let json = serde_json::json!({
446            "id": "msg1",
447            "threadId": "thread1",
448            "labelIds": ["INBOX", "UNREAD"],
449            "snippet": "Hello there",
450            "internalDate": "1700000000000",
451            "payload": {
452                "mimeType": "multipart/mixed",
453                "headers": [{"name": "Subject", "value": "Hi"}],
454                "parts": [
455                    {
456                        "mimeType": "multipart/alternative",
457                        "parts": [
458                            {"mimeType": "text/plain", "body": {"data": "aGVsbG8"}},
459                            {"mimeType": "text/html", "body": {"data": "PGI+aGVsbG88L2I+"}},
460                        ]
461                    }
462                ]
463            }
464        });
465        let message: Message = serde_json::from_value(json.clone()).unwrap();
466        assert_eq!(message.id, "msg1");
467        assert_eq!(message.label_ids, vec!["INBOX", "UNREAD"]);
468        assert_eq!(message.payload, Some(json["payload"].clone()));
469
470        // Round-trips the nested structure losslessly.
471        let reserialized = serde_json::to_value(&message).unwrap();
472        assert_eq!(reserialized["payload"], json["payload"]);
473    }
474
475    #[test]
476    fn message_with_format_minimal_has_no_payload() {
477        let json = serde_json::json!({"id": "msg1", "labelIds": ["INBOX"]});
478        let message: Message = serde_json::from_value(json).unwrap();
479        assert_eq!(message.payload, None);
480    }
481
482    #[test]
483    fn message_internal_date_utc_parses_valid_epoch_ms() {
484        let message = Message {
485            internal_date: Some("1700000000000".to_string()),
486            ..Default::default()
487        };
488        let dt = message.internal_date_utc().unwrap();
489        assert_eq!(dt.timestamp_millis(), 1_700_000_000_000);
490    }
491
492    #[test]
493    fn message_internal_date_utc_is_none_for_missing_or_invalid() {
494        assert_eq!(Message::default().internal_date_utc(), None);
495        let message = Message {
496            internal_date: Some("not-a-number".to_string()),
497            ..Default::default()
498        };
499        assert_eq!(message.internal_date_utc(), None);
500    }
501
502    #[test]
503    fn thread_deserializes_embedded_messages() {
504        let json = serde_json::json!({
505            "id": "thread1",
506            "historyId": "1000",
507            "messages": [
508                {"id": "msg1", "threadId": "thread1"},
509                {"id": "msg2", "threadId": "thread1"},
510            ]
511        });
512        let thread: Thread = serde_json::from_value(json).unwrap();
513        assert_eq!(thread.messages.len(), 2);
514        assert_eq!(thread.history_id.as_deref(), Some("1000"));
515    }
516
517    #[test]
518    fn label_deserializes_with_and_without_color() {
519        let with_color = serde_json::json!({
520            "id": "Label_1",
521            "name": "Finance",
522            "type": "user",
523            "color": {"textColor": "#000000", "backgroundColor": "#ffffff"},
524        });
525        let label: Label = serde_json::from_value(with_color).unwrap();
526        assert!(label.color.is_some());
527        assert!(!label.is_system());
528
529        let without_color = serde_json::json!({"id": "INBOX", "name": "INBOX", "type": "system"});
530        let label: Label = serde_json::from_value(without_color).unwrap();
531        assert!(label.color.is_none());
532        assert!(label.is_system());
533    }
534
535    #[test]
536    fn message_write_jsonl_emits_exactly_one_line() {
537        let message = Message {
538            id: "msg1".to_string(),
539            ..Default::default()
540        };
541        let mut buf = Vec::new();
542        message.write_jsonl(&mut buf).unwrap();
543        let text = String::from_utf8(buf).unwrap();
544        assert_eq!(text.lines().count(), 1);
545        assert!(text.contains("msg1"));
546    }
547
548    #[test]
549    fn thread_write_jsonl_emits_exactly_one_line() {
550        let thread = Thread {
551            id: "t1".to_string(),
552            ..Default::default()
553        };
554        let mut buf = Vec::new();
555        thread.write_jsonl(&mut buf).unwrap();
556        let text = String::from_utf8(buf).unwrap();
557        assert_eq!(text.lines().count(), 1);
558        assert!(text.contains("t1"));
559    }
560
561    #[test]
562    fn label_write_jsonl_emits_exactly_one_line() {
563        let label = Label {
564            id: "INBOX".to_string(),
565            name: "INBOX".to_string(),
566            ..Default::default()
567        };
568        let mut buf = Vec::new();
569        label.write_jsonl(&mut buf).unwrap();
570        let text = String::from_utf8(buf).unwrap();
571        assert_eq!(text.lines().count(), 1);
572        assert!(text.contains("INBOX"));
573    }
574
575    #[test]
576    fn deserializing_unmodeled_extra_field_still_succeeds() {
577        // Forward-compatible with Google adding fields: confirms no
578        // accidental `#[serde(deny_unknown_fields)]`.
579        let json = serde_json::json!({
580            "id": "msg1",
581            "threadId": "t1",
582            "driveDetails": {"somethingNew": true},
583        });
584        let message: Message = serde_json::from_value(json).unwrap();
585        assert_eq!(message.id, "msg1");
586    }
587
588    #[test]
589    fn message_list_response_empty_page_with_valid_next_page_token_deserializes() {
590        // The Gmail-specific pagination quirk: a filtered list can return
591        // zero results alongside a valid nextPageToken.
592        let json = serde_json::json!({"messages": [], "nextPageToken": "page2"});
593        let response: MessageListResponse = serde_json::from_value(json).unwrap();
594        assert!(response.messages.is_empty());
595        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
596    }
597}