use std::io::Write;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::cli::gmail::format::{write_scalar_jsonl, JsonlSerialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct MessageRef {
pub id: String,
#[serde(default, rename = "threadId")]
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Message {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "threadId")]
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelIds")]
pub label_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "internalDate"
)]
pub internal_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
pub history_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "sizeEstimate"
)]
pub size_estimate: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
}
impl Message {
#[must_use]
pub fn internal_date_utc(&self) -> Option<DateTime<Utc>> {
let ms: i64 = self.internal_date.as_deref()?.parse().ok()?;
DateTime::from_timestamp_millis(ms)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessageListResponse {
#[serde(default)]
pub messages: Vec<MessageRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "nextPageToken"
)]
pub next_page_token: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "resultSizeEstimate"
)]
pub result_size_estimate: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Thread {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
pub history_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<Message>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ThreadRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
pub history_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadListResponse {
#[serde(default)]
pub threads: Vec<ThreadRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "nextPageToken"
)]
pub next_page_token: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "resultSizeEstimate"
)]
pub result_size_estimate: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LabelColor {
#[serde(rename = "textColor")]
pub text_color: String,
#[serde(rename = "backgroundColor")]
pub background_color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Label {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
pub label_type: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "messageListVisibility"
)]
pub message_list_visibility: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "labelListVisibility"
)]
pub label_list_visibility: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "messagesTotal"
)]
pub messages_total: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "messagesUnread"
)]
pub messages_unread: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "threadsTotal"
)]
pub threads_total: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "threadsUnread"
)]
pub threads_unread: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<LabelColor>,
}
impl Label {
#[must_use]
pub fn is_system(&self) -> bool {
self.label_type.as_deref() == Some("system")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LabelListResponse {
#[serde(default)]
pub labels: Vec<Label>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HistoryMessageRef {
pub id: String,
#[serde(default, rename = "threadId")]
pub thread_id: String,
#[serde(default, rename = "labelIds")]
pub label_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HistoryMessageAdded {
pub message: HistoryMessageRef,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HistoryMessageDeleted {
pub message: MessageRef,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HistoryLabelChanged {
pub message: MessageRef,
#[serde(default, rename = "labelIds")]
pub label_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct HistoryRecord {
pub id: String,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "messagesAdded"
)]
pub messages_added: Vec<HistoryMessageAdded>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "messagesDeleted"
)]
pub messages_deleted: Vec<HistoryMessageDeleted>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "labelsAdded")]
pub labels_added: Vec<HistoryLabelChanged>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "labelsRemoved"
)]
pub labels_removed: Vec<HistoryLabelChanged>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryListResponse {
#[serde(default)]
pub history: Vec<HistoryRecord>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "nextPageToken"
)]
pub next_page_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "historyId")]
pub history_id: Option<String>,
}
impl JsonlSerialize for HistoryListResponse {
fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
crate::cli::gmail::format::write_items_jsonl(self.history.iter(), out)
}
}
impl JsonlSerialize for Message {
fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
impl JsonlSerialize for Thread {
fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
impl JsonlSerialize for Label {
fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn message_list_response_deserializes_a_realistic_fixture() {
let json = serde_json::json!({
"messages": [
{"id": "msg1", "threadId": "thread1"},
{"id": "msg2", "threadId": "thread1"},
],
"nextPageToken": "page2",
"resultSizeEstimate": 2,
});
let response: MessageListResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.messages.len(), 2);
assert_eq!(response.messages[0].id, "msg1");
assert_eq!(response.next_page_token.as_deref(), Some("page2"));
}
#[test]
fn message_deserializes_nested_mime_payload_and_round_trips() {
let json = serde_json::json!({
"id": "msg1",
"threadId": "thread1",
"labelIds": ["INBOX", "UNREAD"],
"snippet": "Hello there",
"internalDate": "1700000000000",
"payload": {
"mimeType": "multipart/mixed",
"headers": [{"name": "Subject", "value": "Hi"}],
"parts": [
{
"mimeType": "multipart/alternative",
"parts": [
{"mimeType": "text/plain", "body": {"data": "aGVsbG8"}},
{"mimeType": "text/html", "body": {"data": "PGI+aGVsbG88L2I+"}},
]
}
]
}
});
let message: Message = serde_json::from_value(json.clone()).unwrap();
assert_eq!(message.id, "msg1");
assert_eq!(message.label_ids, vec!["INBOX", "UNREAD"]);
assert_eq!(message.payload, Some(json["payload"].clone()));
let reserialized = serde_json::to_value(&message).unwrap();
assert_eq!(reserialized["payload"], json["payload"]);
}
#[test]
fn message_with_format_minimal_has_no_payload() {
let json = serde_json::json!({"id": "msg1", "labelIds": ["INBOX"]});
let message: Message = serde_json::from_value(json).unwrap();
assert_eq!(message.payload, None);
}
#[test]
fn message_internal_date_utc_parses_valid_epoch_ms() {
let message = Message {
internal_date: Some("1700000000000".to_string()),
..Default::default()
};
let dt = message.internal_date_utc().unwrap();
assert_eq!(dt.timestamp_millis(), 1_700_000_000_000);
}
#[test]
fn message_internal_date_utc_is_none_for_missing_or_invalid() {
assert_eq!(Message::default().internal_date_utc(), None);
let message = Message {
internal_date: Some("not-a-number".to_string()),
..Default::default()
};
assert_eq!(message.internal_date_utc(), None);
}
#[test]
fn thread_deserializes_embedded_messages() {
let json = serde_json::json!({
"id": "thread1",
"historyId": "1000",
"messages": [
{"id": "msg1", "threadId": "thread1"},
{"id": "msg2", "threadId": "thread1"},
]
});
let thread: Thread = serde_json::from_value(json).unwrap();
assert_eq!(thread.messages.len(), 2);
assert_eq!(thread.history_id.as_deref(), Some("1000"));
}
#[test]
fn label_deserializes_with_and_without_color() {
let with_color = serde_json::json!({
"id": "Label_1",
"name": "Finance",
"type": "user",
"color": {"textColor": "#000000", "backgroundColor": "#ffffff"},
});
let label: Label = serde_json::from_value(with_color).unwrap();
assert!(label.color.is_some());
assert!(!label.is_system());
let without_color = serde_json::json!({"id": "INBOX", "name": "INBOX", "type": "system"});
let label: Label = serde_json::from_value(without_color).unwrap();
assert!(label.color.is_none());
assert!(label.is_system());
}
#[test]
fn message_write_jsonl_emits_exactly_one_line() {
let message = Message {
id: "msg1".to_string(),
..Default::default()
};
let mut buf = Vec::new();
message.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.lines().count(), 1);
assert!(text.contains("msg1"));
}
#[test]
fn thread_write_jsonl_emits_exactly_one_line() {
let thread = Thread {
id: "t1".to_string(),
..Default::default()
};
let mut buf = Vec::new();
thread.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.lines().count(), 1);
assert!(text.contains("t1"));
}
#[test]
fn label_write_jsonl_emits_exactly_one_line() {
let label = Label {
id: "INBOX".to_string(),
name: "INBOX".to_string(),
..Default::default()
};
let mut buf = Vec::new();
label.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.lines().count(), 1);
assert!(text.contains("INBOX"));
}
#[test]
fn deserializing_unmodeled_extra_field_still_succeeds() {
let json = serde_json::json!({
"id": "msg1",
"threadId": "t1",
"driveDetails": {"somethingNew": true},
});
let message: Message = serde_json::from_value(json).unwrap();
assert_eq!(message.id, "msg1");
}
#[test]
fn message_list_response_empty_page_with_valid_next_page_token_deserializes() {
let json = serde_json::json!({"messages": [], "nextPageToken": "page2"});
let response: MessageListResponse = serde_json::from_value(json).unwrap();
assert!(response.messages.is_empty());
assert_eq!(response.next_page_token.as_deref(), Some("page2"));
}
}