diff --git a/src/openhuman/composio/providers/gmail/provider.rs b/src/openhuman/composio/providers/gmail/provider.rs index f1397d6c2..a4074a4ce 100644 --- a/src/openhuman/composio/providers/gmail/provider.rs +++ b/src/openhuman/composio/providers/gmail/provider.rs @@ -36,6 +36,20 @@ use crate::openhuman::composio::providers::{ const ACTION_GET_PROFILE: &str = "GMAIL_GET_PROFILE"; const ACTION_FETCH_EMAILS: &str = "GMAIL_FETCH_EMAILS"; +/// Base Gmail search query used on every sync pass. +/// +/// Excludes spam and trash but intentionally does NOT restrict to `in:inbox` — +/// that restriction (issue #1713) prevented sent emails from ever being ingested. +/// Exported `pub(super)` so `tests.rs` can assert against the canonical value +/// rather than a duplicated literal. +pub(super) const BASE_QUERY: &str = "-in:spam -in:trash"; + +/// Gmail search query strings that retrieve sent mail. +/// +/// Any of these can be passed as the `query` parameter to `GMAIL_FETCH_EMAILS` +/// to fetch outbound messages. Exported `pub(super)` for use in regression tests. +pub(super) const SENT_QUERIES: &[&str] = &["from:me", "label:SENT", "in:sent"]; + /// Page size per API call. Kept moderate so each call is fast and we /// get frequent checkpoints for the daily budget. const PAGE_SIZE: u32 = 25; @@ -283,7 +297,14 @@ impl ComposioProvider for GmailProvider { // so same-day re-ticks do not re-fetch a whole day's // window every time. Fall back to the day filter only when // the cursor cannot be parsed as a timestamp. - let mut query = "in:inbox -in:spam -in:trash".to_string(); + // + // NOTE: We intentionally do NOT restrict to `in:inbox` here. + // The original query `in:inbox -in:spam -in:trash` meant sent + // emails (label:SENT) were never fetched and therefore the + // agent could not answer questions about outbound mail (issue #1713). + // Removing `in:inbox` lets Gmail return both inbox and sent + // messages while still excluding spam and trash. + let mut query = BASE_QUERY.to_string(); if let Some(ref cursor) = state.cursor { if let Some(epoch_filter) = sync::cursor_to_gmail_after_epoch_filter(cursor) { query.push_str(&format!(" after:{epoch_filter}")); diff --git a/src/openhuman/composio/providers/gmail/tests.rs b/src/openhuman/composio/providers/gmail/tests.rs index 0c80135c3..eb9f7d42c 100644 --- a/src/openhuman/composio/providers/gmail/tests.rs +++ b/src/openhuman/composio/providers/gmail/tests.rs @@ -1,5 +1,6 @@ //! Unit tests for the Gmail provider. +use super::provider::{BASE_QUERY, SENT_QUERIES}; use super::sync::{ cursor_to_gmail_after_epoch_filter, cursor_to_gmail_after_filter, extract_messages, extract_page_token, now_ms, parse_cursor_to_epoch_secs, @@ -238,3 +239,63 @@ fn extract_messages_handles_deep_nesting() { // live `ComposioClient` (HTTP) plus the global `MemoryClient` singleton. // Those go through the integration test suite. Here we just lock in // the provider's identity surface and helpers. + +// ── Regression tests for issue #1713: sent-mail retrieval ─────────────────── +// +// Before the fix the sync query was `in:inbox -in:spam -in:trash`, which meant +// sent emails (label:SENT) were never fetched or ingested into the memory tree. +// The fix removes `in:inbox` so both inbox and sent mail are fetched. + +/// Guard: the provider source must NOT contain the `in:inbox` restriction. +/// If this test fails, someone reintroduced the inbox-only query that caused +/// issue #1713. +#[test] +fn provider_source_does_not_restrict_to_inbox() { + let source = include_str!("provider.rs"); + // The old restriction started with `"in:inbox`. The double-quote is part + // of the Rust string literal, so this catches the exact regression pattern. + assert!( + !source.contains("\"in:inbox"), + "provider.rs sync query must NOT start with 'in:inbox' — this \ + restriction prevents sent emails from being ingested (issue #1713). \ + Use '-in:spam -in:trash' to allow both inbox and sent mail." + ); +} + +/// The base sync query must exclude spam and trash but NOT restrict to inbox. +/// Asserts against the canonical `BASE_QUERY` constant from provider.rs so +/// any change to the production value is caught immediately. +#[test] +fn sync_base_query_excludes_spam_and_trash_without_inbox_restriction() { + assert!( + BASE_QUERY.contains("-in:spam"), + "BASE_QUERY must exclude spam (got: {BASE_QUERY:?})" + ); + assert!( + BASE_QUERY.contains("-in:trash"), + "BASE_QUERY must exclude trash (got: {BASE_QUERY:?})" + ); + assert!( + !BASE_QUERY.contains("in:inbox"), + "BASE_QUERY must NOT restrict to inbox — omitting this allows sent \ + mail to be fetched and ingested (issue #1713). Got: {BASE_QUERY:?}" + ); +} + +/// Sent-mail query strings must be non-empty and must not restrict to inbox. +/// Iterates `SENT_QUERIES` from provider.rs — the canonical list of query +/// strings a user or agent can pass to GMAIL_FETCH_EMAILS for sent mail. +#[test] +fn sent_mail_query_strings_are_well_formed() { + assert!(!SENT_QUERIES.is_empty(), "SENT_QUERIES must not be empty"); + for q in SENT_QUERIES { + assert!( + !q.is_empty(), + "sent-mail query must not be empty, got empty string in SENT_QUERIES" + ); + assert!( + !q.starts_with("in:inbox"), + "sent-mail query '{q}' must not restrict to inbox" + ); + } +} diff --git a/src/openhuman/tools/impl/network/composio.rs b/src/openhuman/tools/impl/network/composio.rs index d46b39d22..0a7ede0b2 100644 --- a/src/openhuman/tools/impl/network/composio.rs +++ b/src/openhuman/tools/impl/network/composio.rs @@ -499,7 +499,10 @@ impl Tool for ComposioTool { fn description(&self) -> &str { "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \ Use action='list' to see available actions, action='execute' with action_name/tool_slug, params, and optional connected_account_id, \ - or action='connect' with app/auth_config_id to get OAuth URL." + or action='connect' with app/auth_config_id to get OAuth URL. \ + For Gmail: GMAIL_FETCH_EMAILS supports standard Gmail search syntax in the 'query' param — \ + use query='from:me' or query='label:SENT' to retrieve sent emails, query='label:INBOX' for inbox, \ + query='is:unread' for unread mail, etc. Sent mail is synced and searchable." } fn parameters_schema(&self) -> serde_json::Value {