1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
22pub struct MessageRef {
23 pub id: String,
25 #[serde(default, rename = "threadId")]
27 pub thread_id: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
32pub struct Message {
33 pub id: String,
35 #[serde(default, skip_serializing_if = "Option::is_none", rename = "threadId")]
37 pub thread_id: Option<String>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelIds")]
40 pub label_ids: Vec<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub snippet: Option<String>,
44 #[serde(
46 default,
47 skip_serializing_if = "Option::is_none",
48 rename = "internalDate"
49 )]
50 pub internal_date: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
53 pub history_id: Option<String>,
54 #[serde(
56 default,
57 skip_serializing_if = "Option::is_none",
58 rename = "sizeEstimate"
59 )]
60 pub size_estimate: Option<i64>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub payload: Option<serde_json::Value>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub raw: Option<String>,
70}
71
72impl Message {
73 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
87pub struct MessageListResponse {
88 #[serde(default)]
90 pub messages: Vec<MessageRef>,
91 #[serde(
93 default,
94 skip_serializing_if = "Option::is_none",
95 rename = "nextPageToken"
96 )]
97 pub next_page_token: Option<String>,
98 #[serde(
100 default,
101 skip_serializing_if = "Option::is_none",
102 rename = "resultSizeEstimate"
103 )]
104 pub result_size_estimate: Option<i64>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
115pub struct Draft {
116 pub id: String,
118 #[serde(default)]
120 pub message: MessageRef,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
130pub struct DraftDetail {
131 pub id: String,
133 #[serde(default)]
135 pub message: Message,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
140pub struct DraftListResponse {
141 #[serde(default)]
143 pub drafts: Vec<Draft>,
144 #[serde(
146 default,
147 skip_serializing_if = "Option::is_none",
148 rename = "nextPageToken"
149 )]
150 pub next_page_token: Option<String>,
151 #[serde(
153 default,
154 skip_serializing_if = "Option::is_none",
155 rename = "resultSizeEstimate"
156 )]
157 pub result_size_estimate: Option<i64>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
162pub struct Thread {
163 pub id: String,
165 #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
167 pub history_id: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub snippet: Option<String>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub messages: Vec<Message>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
178pub struct ThreadRef {
179 pub id: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub snippet: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
186 pub history_id: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
191pub struct ThreadListResponse {
192 #[serde(default)]
194 pub threads: Vec<ThreadRef>,
195 #[serde(
197 default,
198 skip_serializing_if = "Option::is_none",
199 rename = "nextPageToken"
200 )]
201 pub next_page_token: Option<String>,
202 #[serde(
204 default,
205 skip_serializing_if = "Option::is_none",
206 rename = "resultSizeEstimate"
207 )]
208 pub result_size_estimate: Option<i64>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
213pub struct LabelColor {
214 #[serde(rename = "textColor")]
216 pub text_color: String,
217 #[serde(rename = "backgroundColor")]
219 pub background_color: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
224pub struct Label {
225 pub id: String,
227 pub name: String,
229 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
231 pub label_type: Option<String>,
232 #[serde(
234 default,
235 skip_serializing_if = "Option::is_none",
236 rename = "messageListVisibility"
237 )]
238 pub message_list_visibility: Option<String>,
239 #[serde(
241 default,
242 skip_serializing_if = "Option::is_none",
243 rename = "labelListVisibility"
244 )]
245 pub label_list_visibility: Option<String>,
246 #[serde(
248 default,
249 skip_serializing_if = "Option::is_none",
250 rename = "messagesTotal"
251 )]
252 pub messages_total: Option<i64>,
253 #[serde(
255 default,
256 skip_serializing_if = "Option::is_none",
257 rename = "messagesUnread"
258 )]
259 pub messages_unread: Option<i64>,
260 #[serde(
262 default,
263 skip_serializing_if = "Option::is_none",
264 rename = "threadsTotal"
265 )]
266 pub threads_total: Option<i64>,
267 #[serde(
269 default,
270 skip_serializing_if = "Option::is_none",
271 rename = "threadsUnread"
272 )]
273 pub threads_unread: Option<i64>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub color: Option<LabelColor>,
277}
278
279impl Label {
280 #[must_use]
283 pub fn is_system(&self) -> bool {
284 self.label_type.as_deref() == Some("system")
285 }
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
292pub struct LabelListResponse {
293 #[serde(default)]
295 pub labels: Vec<Label>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
306pub struct HistoryMessageRef {
307 pub id: String,
309 #[serde(default, rename = "threadId")]
311 pub thread_id: String,
312 #[serde(default, rename = "labelIds")]
314 pub label_ids: Vec<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
319pub struct HistoryMessageAdded {
320 pub message: HistoryMessageRef,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
326pub struct HistoryMessageDeleted {
327 pub message: MessageRef,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
333pub struct HistoryLabelChanged {
334 pub message: MessageRef,
336 #[serde(default, rename = "labelIds")]
338 pub label_ids: Vec<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
343pub struct HistoryRecord {
344 pub id: String,
346 #[serde(
348 default,
349 skip_serializing_if = "Vec::is_empty",
350 rename = "messagesAdded"
351 )]
352 pub messages_added: Vec<HistoryMessageAdded>,
353 #[serde(
355 default,
356 skip_serializing_if = "Vec::is_empty",
357 rename = "messagesDeleted"
358 )]
359 pub messages_deleted: Vec<HistoryMessageDeleted>,
360 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelsAdded")]
362 pub labels_added: Vec<HistoryLabelChanged>,
363 #[serde(
365 default,
366 skip_serializing_if = "Vec::is_empty",
367 rename = "labelsRemoved"
368 )]
369 pub labels_removed: Vec<HistoryLabelChanged>,
370}
371
372#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
374pub struct HistoryListResponse {
375 #[serde(default)]
377 pub history: Vec<HistoryRecord>,
378 #[serde(
380 default,
381 skip_serializing_if = "Option::is_none",
382 rename = "nextPageToken"
383 )]
384 pub next_page_token: Option<String>,
385 #[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 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 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 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}