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)]
109pub struct Thread {
110 pub id: String,
112 #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
114 pub history_id: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub snippet: Option<String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub messages: Vec<Message>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
125pub struct ThreadRef {
126 pub id: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub snippet: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
133 pub history_id: Option<String>,
134}
135
136#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
138pub struct ThreadListResponse {
139 #[serde(default)]
141 pub threads: Vec<ThreadRef>,
142 #[serde(
144 default,
145 skip_serializing_if = "Option::is_none",
146 rename = "nextPageToken"
147 )]
148 pub next_page_token: Option<String>,
149 #[serde(
151 default,
152 skip_serializing_if = "Option::is_none",
153 rename = "resultSizeEstimate"
154 )]
155 pub result_size_estimate: Option<i64>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
160pub struct LabelColor {
161 #[serde(rename = "textColor")]
163 pub text_color: String,
164 #[serde(rename = "backgroundColor")]
166 pub background_color: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
171pub struct Label {
172 pub id: String,
174 pub name: String,
176 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
178 pub label_type: Option<String>,
179 #[serde(
181 default,
182 skip_serializing_if = "Option::is_none",
183 rename = "messageListVisibility"
184 )]
185 pub message_list_visibility: Option<String>,
186 #[serde(
188 default,
189 skip_serializing_if = "Option::is_none",
190 rename = "labelListVisibility"
191 )]
192 pub label_list_visibility: Option<String>,
193 #[serde(
195 default,
196 skip_serializing_if = "Option::is_none",
197 rename = "messagesTotal"
198 )]
199 pub messages_total: Option<i64>,
200 #[serde(
202 default,
203 skip_serializing_if = "Option::is_none",
204 rename = "messagesUnread"
205 )]
206 pub messages_unread: Option<i64>,
207 #[serde(
209 default,
210 skip_serializing_if = "Option::is_none",
211 rename = "threadsTotal"
212 )]
213 pub threads_total: Option<i64>,
214 #[serde(
216 default,
217 skip_serializing_if = "Option::is_none",
218 rename = "threadsUnread"
219 )]
220 pub threads_unread: Option<i64>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub color: Option<LabelColor>,
224}
225
226impl Label {
227 #[must_use]
230 pub fn is_system(&self) -> bool {
231 self.label_type.as_deref() == Some("system")
232 }
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
239pub struct LabelListResponse {
240 #[serde(default)]
242 pub labels: Vec<Label>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
253pub struct HistoryMessageRef {
254 pub id: String,
256 #[serde(default, rename = "threadId")]
258 pub thread_id: String,
259 #[serde(default, rename = "labelIds")]
261 pub label_ids: Vec<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
266pub struct HistoryMessageAdded {
267 pub message: HistoryMessageRef,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
273pub struct HistoryMessageDeleted {
274 pub message: MessageRef,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
280pub struct HistoryLabelChanged {
281 pub message: MessageRef,
283 #[serde(default, rename = "labelIds")]
285 pub label_ids: Vec<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
290pub struct HistoryRecord {
291 pub id: String,
293 #[serde(
295 default,
296 skip_serializing_if = "Vec::is_empty",
297 rename = "messagesAdded"
298 )]
299 pub messages_added: Vec<HistoryMessageAdded>,
300 #[serde(
302 default,
303 skip_serializing_if = "Vec::is_empty",
304 rename = "messagesDeleted"
305 )]
306 pub messages_deleted: Vec<HistoryMessageDeleted>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelsAdded")]
309 pub labels_added: Vec<HistoryLabelChanged>,
310 #[serde(
312 default,
313 skip_serializing_if = "Vec::is_empty",
314 rename = "labelsRemoved"
315 )]
316 pub labels_removed: Vec<HistoryLabelChanged>,
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
321pub struct HistoryListResponse {
322 #[serde(default)]
324 pub history: Vec<HistoryRecord>,
325 #[serde(
327 default,
328 skip_serializing_if = "Option::is_none",
329 rename = "nextPageToken"
330 )]
331 pub next_page_token: Option<String>,
332 #[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 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 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 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}