diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c301ec..d250703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Gmail** — `void gmail search` and `void gmail thread` read from the local INBOX store when a usable body is already synced. `--live` forces the Gmail API (`in:sent`, drafts, and unsynced mail still go to the network). + ### Fixed - **Gmail** — Retry transient API failures (429, 5xx, and 403 `rateLimitExceeded`) with exponential backoff, honouring `Retry-After` and the retry timestamp in Google's error body. diff --git a/crates/void-cli/src/commands/gmail/args.rs b/crates/void-cli/src/commands/gmail/args.rs index a0b5d95..066fcd6 100644 --- a/crates/void-cli/src/commands/gmail/args.rs +++ b/crates/void-cli/src/commands/gmail/args.rs @@ -37,6 +37,12 @@ pub struct SearchArgs { /// Max results to return #[arg(long, default_value = "20")] pub max: u32, + /// Skip the local store and hit the Gmail API for every message. + /// + /// Default reads hydrate from the INBOX mirror when a usable body is already + /// stored (`in:sent`, drafts, and unsynced mail still go to the API). + #[arg(long)] + pub live: bool, /// Gmail connection to use #[arg(long)] pub connection: Option, @@ -46,6 +52,9 @@ pub struct SearchArgs { pub struct ThreadArgs { /// Thread ID pub thread_id: String, + /// Skip the local store and fetch the live Gmail thread. + #[arg(long)] + pub live: bool, /// Gmail connection to use #[arg(long)] pub connection: Option, diff --git a/crates/void-cli/src/commands/gmail/handlers.rs b/crates/void-cli/src/commands/gmail/handlers.rs index 7074e9a..75a8af0 100644 --- a/crates/void-cli/src/commands/gmail/handlers.rs +++ b/crates/void-cli/src/commands/gmail/handlers.rs @@ -25,7 +25,9 @@ pub(super) async fn dispatch(args: &GmailArgs) -> anyhow::Result<()> { async fn run_search(args: &SearchArgs) -> anyhow::Result<()> { let connector = build_gmail_connector(args.connection.as_deref())?; - let messages = connector.search_api(&args.query, args.max).await?; + let messages = connector + .search_api(&args.query, args.max, args.live) + .await?; let items: Vec = messages .iter() @@ -57,7 +59,7 @@ async fn run_search(args: &SearchArgs) -> anyhow::Result<()> { async fn run_thread(args: &ThreadArgs) -> anyhow::Result<()> { let connector = build_gmail_connector(args.connection.as_deref())?; - let thread = connector.get_thread(&args.thread_id).await?; + let thread = connector.get_thread(&args.thread_id, args.live).await?; let msgs: Vec = thread .messages diff --git a/crates/void-cli/tests/cli_contract.rs b/crates/void-cli/tests/cli_contract.rs index 7ae0f2c..9d86d62 100644 --- a/crates/void-cli/tests/cli_contract.rs +++ b/crates/void-cli/tests/cli_contract.rs @@ -170,3 +170,17 @@ fn inbox_with_bogus_connector_fails_with_message() { .stderr(predicate::str::contains("Unknown connector")) .stderr(predicate::str::contains("bogus")); } + +#[test] +fn gmail_search_and_thread_help_include_live() { + void() + .args(["gmail", "search", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--live")); + void() + .args(["gmail", "thread", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--live")); +} diff --git a/crates/void-core/src/db/conversations.rs b/crates/void-core/src/db/conversations.rs index 8bd468f..e441838 100644 --- a/crates/void-core/src/db/conversations.rs +++ b/crates/void-core/src/db/conversations.rs @@ -151,6 +151,21 @@ pub(super) fn find_by_name_contains( rows.collect::>().map_err(Into::into) } +pub(super) fn find_by_connector_external_id( + conn: &Connection, + connector: &str, + external_id: &str, +) -> Result, DbError> { + conn.query_row( + "SELECT id, connection_id, connector, external_id, name, kind, last_message_at, unread_count, is_muted, metadata + FROM conversations WHERE connector = ?1 AND external_id = ?2 LIMIT 1", + params![connector, external_id], + row::row_to_conversation, + ) + .optional() + .map_err(Into::into) +} + pub(super) fn get(conn: &Connection, id: &str) -> Result, DbError> { conn.query_row( "SELECT id, connection_id, connector, external_id, name, kind, last_message_at, unread_count, is_muted, metadata diff --git a/crates/void-core/src/db/database_access.rs b/crates/void-core/src/db/database_access.rs index 76f10a5..a963875 100644 --- a/crates/void-core/src/db/database_access.rs +++ b/crates/void-core/src/db/database_access.rs @@ -280,6 +280,27 @@ impl Database { messages::find_by_external_id(&*self.conn()?, connection_id, external_id) } + /// Look up a message by connector + native id, across connection ids. + /// + /// Gmail stores `connection_id` as the account email after the first sync, + /// while CLI commands may still key off the config id. Routing by + /// `(connector, external_id)` finds the row either way. + pub fn find_message_by_connector_external_id( + &self, + connector: &str, + external_id: &str, + ) -> Result, DbError> { + messages::find_by_connector_external_id(&*self.conn()?, connector, external_id) + } + + pub fn find_conversation_by_connector_external_id( + &self, + connector: &str, + external_id: &str, + ) -> Result, DbError> { + conversations::find_by_connector_external_id(&*self.conn()?, connector, external_id) + } + /// Resolve a Slack permalink to a stored message. /// /// Looks up by the Slack-native `(channel external_id, message ts)` pair, diff --git a/crates/void-core/src/db/messages/lookup.rs b/crates/void-core/src/db/messages/lookup.rs index dabf559..8f84630 100644 --- a/crates/void-core/src/db/messages/lookup.rs +++ b/crates/void-core/src/db/messages/lookup.rs @@ -4,6 +4,21 @@ use super::super::row; use crate::error::DbError; use crate::models::Message; +pub fn find_by_connector_external_id( + conn: &Connection, + connector: &str, + external_id: &str, +) -> Result, DbError> { + conn.query_row( + "SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved + FROM messages WHERE connector = ?1 AND external_id = ?2 LIMIT 1", + params![connector, external_id], + row::row_to_message, + ) + .optional() + .map_err(Into::into) +} + pub fn find_by_external_id( conn: &Connection, connection_id: &str, diff --git a/crates/void-core/src/db/messages/mod.rs b/crates/void-core/src/db/messages/mod.rs index 6c23894..7fed841 100644 --- a/crates/void-core/src/db/messages/mod.rs +++ b/crates/void-core/src/db/messages/mod.rs @@ -31,8 +31,8 @@ pub use inbox::{ senders_missing_avatar, }; pub use lookup::{ - find_by_external_id, find_by_slack_link, find_slack_conversation_by_external_id, - last_in_conversation, + find_by_connector_external_id, find_by_external_id, find_by_slack_link, + find_slack_conversation_by_external_id, last_in_conversation, }; pub use read::{ count_for_conversation, count_recent, get, latest_timestamp, list_for_conversation, list_recent, diff --git a/crates/void-core/src/db/tests/crud.rs b/crates/void-core/src/db/tests/crud.rs index 4b069eb..7aaea33 100644 --- a/crates/void-core/src/db/tests/crud.rs +++ b/crates/void-core/src/db/tests/crud.rs @@ -679,3 +679,29 @@ fn migrations_preserve_existing_data() { .unwrap(); assert_eq!(sync_conn, "legacy-acct"); } + +#[test] +fn find_by_connector_external_id_ignores_connection_id() { + let db = test_db(); + let conv = make_conversation_with_connector("c1", "me@gmail.com", "t1", "gmail"); + db.upsert_conversation(&conv).unwrap(); + let mut msg = make_message_with_connector("m1", "c1", "me@gmail.com", "hello", 1, "gmail"); + msg.external_id = "gmail-msg-1".into(); + db.upsert_message(&msg).unwrap(); + + let found = db + .find_message_by_connector_external_id("gmail", "gmail-msg-1") + .unwrap() + .expect("row"); + assert_eq!(found.id, "m1"); + assert_eq!(found.connection_id, "me@gmail.com"); + + assert!(db + .find_conversation_by_connector_external_id("gmail", "t1") + .unwrap() + .is_some()); + assert!(db + .find_message_by_connector_external_id("gmail", "missing") + .unwrap() + .is_none()); +} diff --git a/crates/void-gmail/src/api/types.rs b/crates/void-gmail/src/api/types.rs index 09dbb19..5f664ae 100644 --- a/crates/void-gmail/src/api/types.rs +++ b/crates/void-gmail/src/api/types.rs @@ -65,7 +65,7 @@ pub struct MessageHeader { pub value: String, } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FileAttachment { pub filename: String, pub mime_type: Option, diff --git a/crates/void-gmail/src/connector/api_methods.rs b/crates/void-gmail/src/connector/api_methods.rs index 3dd04dd..c9f6c30 100644 --- a/crates/void-gmail/src/connector/api_methods.rs +++ b/crates/void-gmail/src/connector/api_methods.rs @@ -5,6 +5,7 @@ use tracing::warn; use crate::api::GmailApiClient; use crate::auth; +use void_core::db::Database; use super::GmailConnector; @@ -39,24 +40,29 @@ impl GmailConnector { &self, query: &str, max_results: u32, + live: bool, ) -> anyhow::Result> { let api = self.get_client().await?; - let resp = api - .list_messages(max_results, None, None, Some(query)) - .await?; - let mut messages = Vec::new(); - if let Some(refs) = resp.messages { - for r in &refs { - match api.get_message(&r.id).await { - Ok(msg) => messages.push(msg), - Err(e) => warn!(message_id = %r.id, "failed to fetch: {e}"), + let db = if live { + None + } else { + super::store::open_store(&self.store_path)? + }; + search_with_api(&api, db.as_ref(), query, max_results).await + } + + pub async fn get_thread( + &self, + thread_id: &str, + live: bool, + ) -> anyhow::Result { + if !live { + if let Some(db) = super::store::open_store(&self.store_path)? { + if let Some(thread) = super::store::load_stored_thread(&db, thread_id) { + return Ok(thread); } } } - Ok(messages) - } - - pub async fn get_thread(&self, thread_id: &str) -> anyhow::Result { let api = self.get_client().await?; api.get_thread(thread_id).await.map_err(Into::into) } @@ -374,3 +380,31 @@ pub(super) fn build_reply_all_recipients( recipients.join(", ") } + +pub(super) async fn search_with_api( + api: &GmailApiClient, + db: Option<&Database>, + query: &str, + max_results: u32, +) -> anyhow::Result> { + let resp = api + .list_messages(max_results, None, None, Some(query)) + .await?; + let mut messages = Vec::new(); + if let Some(refs) = resp.messages { + for r in &refs { + if let Some(db) = db { + if let Some(stored) = super::store::load_stored_message(db, &r.id) { + debug!(message_id = %r.id, "gmail: serving message from local store"); + messages.push(stored); + continue; + } + } + match api.get_message(&r.id).await { + Ok(msg) => messages.push(msg), + Err(e) => warn!(message_id = %r.id, "failed to fetch: {e}"), + } + } + } + Ok(messages) +} diff --git a/crates/void-gmail/src/connector/mod.rs b/crates/void-gmail/src/connector/mod.rs index 5101598..0d496a6 100644 --- a/crates/void-gmail/src/connector/mod.rs +++ b/crates/void-gmail/src/connector/mod.rs @@ -1,6 +1,7 @@ mod api_methods; mod compose; mod connector_trait; +mod store; mod sync; #[cfg(test)] diff --git a/crates/void-gmail/src/connector/store.rs b/crates/void-gmail/src/connector/store.rs new file mode 100644 index 0000000..76d4b27 --- /dev/null +++ b/crates/void-gmail/src/connector/store.rs @@ -0,0 +1,259 @@ +//! Read Gmail messages/threads from the local store when a usable body is there. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use tracing::debug; +use void_core::db::Database; +use void_core::models::Message; + +use crate::api::{ + FileAttachment, GmailMessage, GmailThread, MessageHeader, MessagePart, MessagePartBody, + MessagePayload, +}; +use crate::CONNECTOR_ID; + +/// Bodies shorter than this are treated as snippets, not a full read. +pub(super) const MIN_STORED_BODY_CHARS: usize = 200; + +pub(super) fn open_store(store_path: &std::path::Path) -> anyhow::Result> { + let path = store_path.join("void.db"); + if !path.exists() { + return Ok(None); + } + Ok(Some(Database::open(&path)?)) +} + +pub(super) fn stored_body_is_complete(msg: &Message) -> bool { + let Some(body) = msg.body.as_deref() else { + return false; + }; + if body.is_empty() { + return false; + } + let meta = msg.metadata.as_ref(); + let has_html = meta + .and_then(|m| m.get("has_html")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + has_html || body.chars().count() >= MIN_STORED_BODY_CHARS +} + +pub(super) fn load_stored_message(db: &Database, message_id: &str) -> Option { + let msg = db + .find_message_by_connector_external_id(CONNECTOR_ID, message_id) + .ok() + .flatten()?; + if !stored_body_is_complete(&msg) { + return None; + } + let thread_id = db + .get_conversation(&msg.conversation_id) + .ok() + .flatten() + .map(|c| c.external_id) + .unwrap_or_else(|| msg.conversation_id.clone()); + Some(gmail_message_from_store(&msg, &thread_id)) +} + +pub(super) fn load_stored_thread(db: &Database, thread_id: &str) -> Option { + let conv = db + .find_conversation_by_connector_external_id(CONNECTOR_ID, thread_id) + .ok() + .flatten()?; + let stored = db.list_messages(&conv.id, 500, None, None).ok()?; + if stored.is_empty() { + return None; + } + let mut messages = Vec::with_capacity(stored.len()); + for msg in stored { + if !stored_body_is_complete(&msg) { + return None; + } + messages.push(gmail_message_from_store(&msg, thread_id)); + } + debug!( + thread_id, + count = messages.len(), + "gmail: serving thread from local store" + ); + Some(GmailThread { + id: Some(thread_id.to_string()), + snippet: messages.first().and_then(|m| m.snippet.clone()), + messages: Some(messages), + }) +} + +pub(super) fn gmail_message_from_store(msg: &Message, thread_id: &str) -> GmailMessage { + let subject = msg + .metadata + .as_ref() + .and_then(|m| m.get("subject")) + .and_then(|v| v.as_str()) + .unwrap_or("(no subject)") + .to_string(); + let snippet = msg + .metadata + .as_ref() + .and_then(|m| m.get("snippet")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + msg.body + .as_ref() + .map(|b| b.chars().take(120).collect::()) + }); + let from = match &msg.sender_name { + Some(name) if !name.is_empty() => format!("{name} <{}>", msg.sender), + _ => msg.sender.clone(), + }; + let body_text = msg.body.clone().unwrap_or_default(); + let encoded = URL_SAFE_NO_PAD.encode(body_text.as_bytes()); + + let mut parts = vec![MessagePart { + mime_type: Some("text/plain".into()), + filename: None, + headers: None, + body: Some(MessagePartBody { + data: Some(encoded), + size: Some(body_text.len() as u64), + attachment_id: None, + }), + parts: None, + }]; + if let Some(atts) = msg + .metadata + .as_ref() + .and_then(|m| m.get("attachments")) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + { + for att in atts { + parts.push(MessagePart { + mime_type: att.mime_type, + filename: Some(att.filename), + headers: None, + body: Some(MessagePartBody { + data: None, + size: att.size, + attachment_id: Some(att.attachment_id), + }), + parts: None, + }); + } + } + + let labels = if msg.is_archived { + None + } else { + Some(vec!["INBOX".into()]) + }; + + GmailMessage { + id: Some(msg.external_id.clone()), + thread_id: Some(thread_id.to_string()), + snippet, + internal_date: Some((msg.timestamp * 1000).to_string()), + label_ids: labels, + payload: Some(MessagePayload { + mime_type: Some("multipart/mixed".into()), + headers: Some(vec![ + MessageHeader { + name: "From".into(), + value: from, + }, + MessageHeader { + name: "Subject".into(), + value: subject, + }, + ]), + body: None, + parts: Some(parts), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use void_core::models::{Conversation, ConversationKind}; + + fn seed(body: &str, has_html: bool) -> Message { + let mut metadata = serde_json::Map::new(); + metadata.insert("subject".into(), serde_json::json!("Hello")); + if has_html { + metadata.insert("has_html".into(), serde_json::json!(true)); + } + Message { + id: "id".into(), + conversation_id: "c".into(), + connection_id: "acct".into(), + connector: "gmail".into(), + external_id: "m1".into(), + sender: "a@b.com".into(), + sender_name: Some("Ann".into()), + sender_avatar_url: None, + body: Some(body.into()), + timestamp: 1_700_000_000, + synced_at: None, + is_archived: false, + is_saved: false, + reply_to_id: None, + media_type: None, + metadata: Some(serde_json::Value::Object(metadata)), + context_id: None, + context: None, + } + } + + #[test] + fn short_body_is_not_complete_unless_html() { + assert!(!stored_body_is_complete(&seed("short snippet", false))); + assert!(stored_body_is_complete(&seed("short snippet", true))); + let long = "x".repeat(MIN_STORED_BODY_CHARS); + assert!(stored_body_is_complete(&seed(&long, false))); + assert!(!stored_body_is_complete(&seed("", false))); + } + + #[test] + fn from_store_round_trips_headers_and_body() { + let body = "x".repeat(MIN_STORED_BODY_CHARS); + let gm = gmail_message_from_store(&seed(&body, false), "t1"); + assert_eq!(gm.id.as_deref(), Some("m1")); + assert_eq!(gm.thread_id.as_deref(), Some("t1")); + assert_eq!(gm.get_header("From").as_deref(), Some("Ann ")); + assert_eq!(gm.get_header("Subject").as_deref(), Some("Hello")); + assert_eq!(gm.text_body().as_deref(), Some(body.as_str())); + assert_eq!(gm.label_ids, Some(vec!["INBOX".into()])); + } + + #[test] + fn load_thread_requires_every_message_complete() { + let db = Database::open_in_memory().unwrap(); + db.upsert_conversation(&Conversation { + id: "c1".into(), + connection_id: "acct".into(), + connector: "gmail".into(), + external_id: "t1".into(), + name: Some("Hello".into()), + kind: ConversationKind::Thread, + last_message_at: None, + unread_count: 0, + is_muted: false, + metadata: None, + }) + .unwrap(); + let mut complete = seed(&"y".repeat(MIN_STORED_BODY_CHARS), false); + complete.id = "c1-m1".into(); + complete.conversation_id = "c1".into(); + complete.external_id = "m1".into(); + db.upsert_message(&complete).unwrap(); + let thread = load_stored_thread(&db, "t1").expect("complete thread"); + assert_eq!(thread.messages.as_ref().map(|m| m.len()), Some(1)); + + let mut stub = seed("tiny", false); + stub.id = "c1-m2".into(); + stub.conversation_id = "c1".into(); + stub.external_id = "m2".into(); + db.upsert_message(&stub).unwrap(); + assert!(load_stored_thread(&db, "t1").is_none()); + } +} diff --git a/crates/void-gmail/src/connector/tests.rs b/crates/void-gmail/src/connector/tests.rs index 14291c5..db21939 100644 --- a/crates/void-gmail/src/connector/tests.rs +++ b/crates/void-gmail/src/connector/tests.rs @@ -1,4 +1,4 @@ -use super::api_methods::{build_reply_all_recipients, create_draft_with_api}; +use super::api_methods::{build_reply_all_recipients, create_draft_with_api, search_with_api}; use super::*; use crate::api::{GmailApiClient, GmailMessage}; use base64::Engine; @@ -1342,3 +1342,145 @@ async fn create_draft_errors_without_to_and_reply_to() { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("--to is required")); } + +fn seed_gmail_store(db: &Database, body: &str) { + db.upsert_conversation(&Conversation { + id: "c1".into(), + connection_id: "acct@gmail.com".into(), + connector: "gmail".into(), + external_id: "t1".into(), + name: Some("Hello".into()), + kind: ConversationKind::Thread, + last_message_at: None, + unread_count: 0, + is_muted: false, + metadata: None, + }) + .unwrap(); + let mut metadata = serde_json::Map::new(); + metadata.insert("subject".into(), serde_json::json!("Hello")); + db.upsert_message(&Message { + id: "c1-m1".into(), + conversation_id: "c1".into(), + connection_id: "acct@gmail.com".into(), + connector: "gmail".into(), + external_id: "m1".into(), + sender: "a@b.com".into(), + sender_name: Some("Ann".into()), + sender_avatar_url: None, + body: Some(body.into()), + timestamp: 1_700_000_000, + synced_at: None, + is_archived: false, + is_saved: false, + reply_to_id: None, + media_type: None, + metadata: Some(serde_json::Value::Object(metadata)), + context_id: None, + context: None, + }) + .unwrap(); +} + +fn list_one_message_mock() -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "messages": [{"id": "m1", "threadId": "t1"}] + })) +} + +fn api_full_message() -> serde_json::Value { + serde_json::json!({ + "id": "m1", + "threadId": "t1", + "snippet": "Hello", + "internalDate": "1741700000000", + "labelIds": ["INBOX"], + "payload": { + "mimeType": "text/plain", + "headers": [ + {"name": "From", "value": "sender@example.com"}, + {"name": "Subject", "value": "Live"} + ], + "body": {"data": "SGVsbG8gV29ybGQ", "size": 11} + } + }) +} + +#[tokio::test] +async fn search_serves_complete_store_body_without_get_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages")) + .respond_with(list_one_message_mock()) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages/m1")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + + let api = GmailApiClient::with_base_url("test-token", &server.uri()); + let db = Database::open_in_memory().unwrap(); + let body = "x".repeat(200); + seed_gmail_store(&db, &body); + + let msgs = search_with_api(&api, Some(&db), "in:inbox", 10) + .await + .unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].id.as_deref(), Some("m1")); + assert_eq!(msgs[0].get_header("Subject").as_deref(), Some("Hello")); + assert_eq!(msgs[0].text_body().as_deref(), Some(body.as_str())); +} + +#[tokio::test] +async fn search_fetches_when_store_body_is_snippet() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages")) + .respond_with(list_one_message_mock()) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages/m1")) + .respond_with(ResponseTemplate::new(200).set_body_json(api_full_message())) + .expect(1) + .mount(&server) + .await; + + let api = GmailApiClient::with_base_url("test-token", &server.uri()); + let db = Database::open_in_memory().unwrap(); + seed_gmail_store(&db, "tiny snippet"); + + let msgs = search_with_api(&api, Some(&db), "in:inbox", 10) + .await + .unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].get_header("Subject").as_deref(), Some("Live")); +} + +#[tokio::test] +async fn search_live_skips_store() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages")) + .respond_with(list_one_message_mock()) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages/m1")) + .respond_with(ResponseTemplate::new(200).set_body_json(api_full_message())) + .expect(1) + .mount(&server) + .await; + + let api = GmailApiClient::with_base_url("test-token", &server.uri()); + let db = Database::open_in_memory().unwrap(); + seed_gmail_store(&db, &"x".repeat(200)); + + let msgs = search_with_api(&api, None, "in:inbox", 10).await.unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].get_header("Subject").as_deref(), Some("Live")); +} diff --git a/docs/commands.md b/docs/commands.md index 302cc9a..4ee0944 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -88,8 +88,8 @@ Outgoing Gmail compose (`send`, `reply`, `forward`, and draft create/update) acc | Command | Description | |---------|-------------| -| `void gmail search ` | Search with Gmail query syntax (`from:`, `newer_than:7d`, …). `--max ` | -| `void gmail thread ` | View a full email thread | +| `void gmail search ` | Search with Gmail query syntax (`from:`, `newer_than:7d`, …). `--max `. Reads the local INBOX store when a usable body is already synced; `--live` forces the Gmail API | +| `void gmail thread ` | View a full email thread. Serves the local INBOX mirror when every message has a usable stored body; `--live` fetches from Gmail | | `void gmail url ` | Generate the Gmail web URL for a thread | | `void gmail labels` | List labels | | `void gmail label --add --remove ` | Modify labels on a thread |