Skip to main content

omni_dev/cli/
gmail.rs

1//! Gmail CLI commands.
2
3pub(crate) mod account;
4pub(crate) mod auth;
5pub(crate) mod extract_attachments;
6pub(crate) mod format;
7pub(crate) mod helpers;
8pub(crate) mod label;
9pub(crate) mod read;
10pub(crate) mod render;
11pub(crate) mod search;
12pub(crate) mod sync;
13pub(crate) mod sync_all;
14pub(crate) mod thread;
15
16use anyhow::Result;
17use clap::{Parser, Subcommand};
18
19use crate::gmail::account::GMAIL_ACCOUNT_ENV;
20use crate::gmail::client::GmailClient;
21
22/// Gmail: read Gmail messages, threads, and labels (and, with `gmail.modify`, mutate labels).
23#[derive(Parser)]
24pub struct GmailCommand {
25    /// Selects a named Gmail account configured in
26    /// `~/.omni-dev/settings.json` (AWS-CLI style, mirrors the top-level
27    /// `--profile`) for this invocation.
28    ///
29    /// Orthogonal to `--profile`: switching the Gmail account never changes
30    /// which profile is active, and vice versa (see
31    /// [ADR-0066](../../../docs/adrs/adr-0066.md)). Overrides
32    /// `OMNI_DEV_GMAIL_ACCOUNT`. Scoped to the `gmail` subtree — unlike
33    /// `--profile`/`--instance` it is not usable before the `gmail`
34    /// subcommand name, only after it (`gmail --account NAME <cmd>` or
35    /// `gmail <cmd> --account NAME`), so it can't collide with an unrelated
36    /// subcommand's own `--account` flag elsewhere in the CLI (e.g.
37    /// `snowflake query --account`).
38    #[arg(long, global = true, value_name = "NAME")]
39    pub account: Option<String>,
40    /// The Gmail subcommand to execute.
41    #[command(subcommand)]
42    pub command: GmailSubcommands,
43}
44
45/// Gmail subcommands.
46#[derive(Subcommand)]
47pub enum GmailSubcommands {
48    /// Manages Gmail OAuth2 credentials (mirrors the `gmail_auth_status` MCP tool for `status`).
49    Auth(auth::AuthCommand),
50    /// Manages named Gmail accounts (mirrors the `gmail_account_list` MCP tool for `list`).
51    Account(account::AccountCommand),
52    /// Searches Gmail messages (mirrors the `gmail_search` MCP tool).
53    Search(search::SearchCommand),
54    /// Reads a single Gmail message (mirrors the `gmail_message_read` MCP tool).
55    Read(read::ReadCommand),
56    /// Reads a Gmail thread (mirrors the `gmail_thread_read` MCP tool).
57    Thread(thread::ThreadCommand),
58    /// Manages Gmail labels (mirrors the `gmail_label_list` MCP tool; `add`/`remove` are CLI-only in Phase 1).
59    Label(label::LabelCommand),
60    /// Maintains a durable local archive of a mailbox (CLI-only; no MCP equivalent).
61    Sync(sync::SyncCommand),
62    /// Maintains durable local archives for every account in
63    /// `.omni-dev/gmail-sync.yaml`, concurrently (CLI-only; no MCP
64    /// equivalent; ADR-0068).
65    SyncAll(sync_all::SyncAllCommand),
66    /// Retroactively extracts attachments for already-archived messages,
67    /// without re-fetching from Gmail (CLI-only; no MCP equivalent; purely
68    /// local, no client/credentials needed; #1510).
69    ExtractAttachments(extract_attachments::ExtractAttachmentsCommand),
70    /// Renders one or more archived `.eml` files as human-readable Markdown
71    /// (CLI-only; no MCP equivalent; purely local, no client/credentials
72    /// needed; #1513).
73    Render(render::RenderCommand),
74}
75
76impl GmailCommand {
77    /// Executes the Gmail command.
78    ///
79    /// `auth` manages credentials and must run without them; `account`
80    /// manages which named account is selected and must equally run
81    /// without a resolved client (`import-legacy`'s whole point is working
82    /// in a pre-migration state). `sync-all` also runs without the shared
83    /// client: it resolves one client per configured account itself
84    /// (`helpers::create_client_for`, never the env var below, which is
85    /// unsafe across its concurrent tasks — ADR-0068). `extract-attachments`
86    /// also runs without a client — it never contacts Gmail at all, only
87    /// the local archive under `--archive-dir`, so `--account` has no
88    /// meaning for it either (#1510). Every other subcommand needs an
89    /// authenticated client, which is resolved **once** here and threaded
90    /// down so each leaf takes `&GmailClient` and stays free of process
91    /// env. `render` also runs without a client — like
92    /// `extract-attachments`, it only ever reads `.eml` files already on
93    /// disk (#1513).
94    pub async fn execute(self) -> Result<()> {
95        let account = self.account;
96        match self.command {
97            GmailSubcommands::SyncAll(cmd) => {
98                anyhow::ensure!(
99                    account.is_none(),
100                    "--account is not compatible with sync-all; configure accounts in \
101                     .omni-dev/gmail-sync.yaml instead"
102                );
103                cmd.execute().await
104            }
105            GmailSubcommands::ExtractAttachments(cmd) => {
106                anyhow::ensure!(
107                    account.is_none(),
108                    "--account is not compatible with extract-attachments; it operates on a \
109                     local archive directory only"
110                );
111                cmd.execute()
112            }
113            GmailSubcommands::Render(cmd) => {
114                anyhow::ensure!(
115                    account.is_none(),
116                    "--account is not compatible with render; it operates on local .eml files \
117                     only"
118                );
119                cmd.execute()
120            }
121            command => {
122                // Propagates --account to the env var for the duration of
123                // this call only (`gmail::account::resolve_account` reads
124                // it, issue #1500), mirroring `Cli::propagate_global_flags`'s
125                // pattern: only set when present, so an existing ambient
126                // OMNI_DEV_GMAIL_ACCOUNT still works when the flag is
127                // omitted. The guard restores/removes it on drop at the end
128                // of this scope, so execute() is safe to call more than once
129                // per process (#1538).
130                let _account_guard = account.as_ref().map(|account| {
131                    crate::utils::env::ScopedEnvVar::set(GMAIL_ACCOUNT_ENV, account)
132                });
133
134                match command {
135                    GmailSubcommands::Auth(cmd) => cmd.execute().await,
136                    GmailSubcommands::Account(cmd) => cmd.execute(),
137                    data => {
138                        let client = helpers::create_client()?;
139                        data.dispatch(&client).await
140                    }
141                }
142            }
143        }
144    }
145}
146
147impl GmailSubcommands {
148    /// Routes a non-`Auth`/`Account`/`SyncAll`/`ExtractAttachments`/`Render`
149    /// subcommand against the shared client. Kept separate from credential
150    /// resolution so it is testable without env (tests pass a client
151    /// pointed at an unreachable URL). Those five arms are unreachable
152    /// because all five are handled before client resolution in
153    /// [`GmailCommand::execute`] — `SyncAll` builds its own per-account
154    /// clients instead of using the shared one (ADR-0068), and
155    /// `ExtractAttachments`/`Render` need no client at all (#1510, #1513).
156    async fn dispatch(self, client: &GmailClient) -> Result<()> {
157        match self {
158            Self::Auth(_) => {
159                unreachable!("Auth is dispatched before client resolution")
160            }
161            Self::Account(_) => {
162                unreachable!("Account is dispatched before client resolution")
163            }
164            Self::SyncAll(_) => {
165                unreachable!("SyncAll is dispatched before client resolution")
166            }
167            Self::ExtractAttachments(_) => {
168                unreachable!("ExtractAttachments is dispatched before client resolution")
169            }
170            Self::Render(_) => {
171                unreachable!("Render is dispatched before client resolution")
172            }
173            Self::Search(cmd) => cmd.execute(client).await,
174            Self::Read(cmd) => cmd.execute(client).await,
175            Self::Thread(cmd) => cmd.execute(client).await,
176            Self::Label(cmd) => cmd.execute(client).await,
177            Self::Sync(cmd) => cmd.execute(client).await,
178        }
179    }
180}
181
182#[cfg(test)]
183#[allow(clippy::unwrap_used)]
184mod tests {
185    use super::*;
186    use crate::cli::gmail::format::OutputFormat;
187    use crate::gmail::auth::{GmailCredentials, GmailScope};
188    use crate::gmail::client::GmailClient;
189    use crate::utils::secret::Secret;
190
191    fn dead_credentials() -> GmailCredentials {
192        GmailCredentials {
193            client_id: "client".to_string(),
194            client_secret: Secret::new("secret"),
195            refresh_token: Secret::new("refresh"),
196            scope: GmailScope::ReadOnly,
197        }
198    }
199
200    /// A client pointed at an unreachable URL. Routing tests use it so a
201    /// command runs through dispatch -> leaf -> the HTTP layer and fails
202    /// with a connection error — exercising the routing without touching
203    /// credentials, the process environment, or a mock server.
204    fn dead_client() -> GmailClient {
205        GmailClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
206    }
207
208    // ── GmailCommand::execute glue ──────────────────────────────────
209    //
210    // The success path (`helpers::create_client()` succeeding, then
211    // `data.dispatch(&client)` actually issuing a request) needs real
212    // credentials and would hit the real Gmail API host — there's no
213    // env-var override to redirect it at a mock server (see the
214    // `mcp::gmail_tools` module doc). These tests cover the
215    // credentials-missing error path, which is deterministic and
216    // network-free.
217
218    #[tokio::test]
219    async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
220        let guard = crate::gmail::test_support::EnvGuard::take();
221        let _dir = guard.clear_credentials();
222
223        let cmd = GmailCommand {
224            account: None,
225            command: GmailSubcommands::Auth(auth::AuthCommand {
226                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
227            }),
228        };
229        let err = cmd.execute().await.unwrap_err();
230        assert!(err.to_string().contains("not configured"));
231    }
232
233    #[tokio::test]
234    async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
235        let guard = crate::gmail::test_support::EnvGuard::take();
236        let _dir = guard.clear_credentials();
237
238        let cmd = GmailCommand {
239            account: None,
240            command: GmailSubcommands::Search(search::SearchCommand {
241                query: "label:finance".to_string(),
242                limit: 10,
243                enrich: false,
244                concurrency: 4,
245                output: OutputFormat::Table,
246            }),
247        };
248        let err = cmd.execute().await.unwrap_err();
249        assert!(err.to_string().contains("not configured"));
250    }
251
252    #[test]
253    fn gmail_subcommands_auth_variant() {
254        let cmd = GmailCommand {
255            account: None,
256            command: GmailSubcommands::Auth(auth::AuthCommand {
257                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
258            }),
259        };
260        assert!(matches!(cmd.command, GmailSubcommands::Auth(_)));
261    }
262
263    #[test]
264    fn gmail_subcommands_account_variant() {
265        let cmd = GmailCommand {
266            account: None,
267            command: GmailSubcommands::Account(account::AccountCommand {
268                command: account::AccountSubcommands::List(account::list::ListCommand {
269                    output: OutputFormat::Table,
270                }),
271            }),
272        };
273        assert!(matches!(cmd.command, GmailSubcommands::Account(_)));
274    }
275
276    #[tokio::test]
277    async fn execute_restores_account_env_var_after_return() {
278        let guard = crate::gmail::test_support::EnvGuard::take();
279        let _dir = guard.clear_credentials();
280
281        let cmd = GmailCommand {
282            account: Some("work".to_string()),
283            command: GmailSubcommands::Account(account::AccountCommand {
284                command: account::AccountSubcommands::List(account::list::ListCommand {
285                    output: OutputFormat::Table,
286                }),
287            }),
288        };
289        cmd.execute().await.unwrap();
290        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
291    }
292
293    #[tokio::test]
294    async fn execute_restores_previous_account_env_var_after_return() {
295        let guard = crate::gmail::test_support::EnvGuard::take();
296        let _dir = guard.clear_credentials();
297        std::env::set_var(GMAIL_ACCOUNT_ENV, "personal");
298
299        let cmd = GmailCommand {
300            account: Some("work".to_string()),
301            command: GmailSubcommands::Account(account::AccountCommand {
302                command: account::AccountSubcommands::List(account::list::ListCommand {
303                    output: OutputFormat::Table,
304                }),
305            }),
306        };
307        cmd.execute().await.unwrap();
308        assert_eq!(
309            std::env::var(GMAIL_ACCOUNT_ENV).ok().as_deref(),
310            Some("personal")
311        );
312    }
313
314    #[tokio::test]
315    async fn execute_does_not_leak_account_across_sequential_calls() {
316        let guard = crate::gmail::test_support::EnvGuard::take();
317        let _dir = guard.clear_credentials();
318
319        let account_list_cmd = || GmailCommand {
320            account: None,
321            command: GmailSubcommands::Account(account::AccountCommand {
322                command: account::AccountSubcommands::List(account::list::ListCommand {
323                    output: OutputFormat::Table,
324                }),
325            }),
326        };
327
328        let first = GmailCommand {
329            account: Some("alpha".to_string()),
330            ..account_list_cmd()
331        };
332        first.execute().await.unwrap();
333        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
334
335        // If the first call's value had leaked, this second call — which
336        // omits --account entirely — would still see it via the env var.
337        account_list_cmd().execute().await.unwrap();
338        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
339    }
340
341    #[tokio::test]
342    async fn execute_absent_account_leaves_ambient_env_var_untouched() {
343        let guard = crate::gmail::test_support::EnvGuard::take();
344        let _dir = guard.clear_credentials();
345        std::env::set_var(GMAIL_ACCOUNT_ENV, "personal");
346
347        let cmd = GmailCommand {
348            account: None,
349            command: GmailSubcommands::Account(account::AccountCommand {
350                command: account::AccountSubcommands::List(account::list::ListCommand {
351                    output: OutputFormat::Table,
352                }),
353            }),
354        };
355        cmd.execute().await.unwrap();
356        assert_eq!(
357            std::env::var(GMAIL_ACCOUNT_ENV).ok().as_deref(),
358            Some("personal")
359        );
360    }
361
362    #[tokio::test]
363    async fn execute_routes_account_list_without_client_resolution() {
364        let guard = crate::gmail::test_support::EnvGuard::take();
365        let _dir = guard.clear_credentials();
366
367        let cmd = GmailCommand {
368            account: None,
369            command: GmailSubcommands::Account(account::AccountCommand {
370                command: account::AccountSubcommands::List(account::list::ListCommand {
371                    output: OutputFormat::Table,
372                }),
373            }),
374        };
375        // Succeeds even with zero credentials configured — proving Account
376        // subcommands never resolve a client (unlike every other
377        // subcommand, which errors on missing credentials).
378        cmd.execute().await.unwrap();
379    }
380
381    #[tokio::test]
382    async fn dispatch_routes_search() {
383        let cmd = GmailSubcommands::Search(search::SearchCommand {
384            query: "label:finance".to_string(),
385            limit: 10,
386            enrich: false,
387            concurrency: 4,
388            output: OutputFormat::Table,
389        });
390        assert!(cmd.dispatch(&dead_client()).await.is_err());
391    }
392
393    #[tokio::test]
394    async fn dispatch_routes_read() {
395        let cmd = GmailSubcommands::Read(read::ReadCommand {
396            message_id: "msg1".to_string(),
397            out_file: None,
398            detail: read::ReadDetail::Full,
399            output: read::ReadOutputFormat::Table,
400            fold_quotes: false,
401        });
402        assert!(cmd.dispatch(&dead_client()).await.is_err());
403    }
404
405    #[tokio::test]
406    async fn dispatch_routes_thread() {
407        let cmd = GmailSubcommands::Thread(thread::ThreadCommand {
408            thread_id: "t1".to_string(),
409            output: OutputFormat::Table,
410        });
411        assert!(cmd.dispatch(&dead_client()).await.is_err());
412    }
413
414    #[tokio::test]
415    async fn dispatch_routes_label_list() {
416        let cmd = GmailSubcommands::Label(label::LabelCommand {
417            command: label::LabelSubcommands::List(label::list::ListCommand {
418                output: OutputFormat::Table,
419            }),
420        });
421        assert!(cmd.dispatch(&dead_client()).await.is_err());
422    }
423
424    #[tokio::test]
425    async fn dispatch_routes_label_add() {
426        let cmd = GmailSubcommands::Label(label::LabelCommand {
427            command: label::LabelSubcommands::Add(label::add::AddCommand {
428                message_ids: vec!["m1".to_string()],
429                label: "IMPORTANT".to_string(),
430            }),
431        });
432        assert!(cmd.dispatch(&dead_client()).await.is_err());
433    }
434
435    #[tokio::test]
436    async fn dispatch_routes_label_remove() {
437        let cmd = GmailSubcommands::Label(label::LabelCommand {
438            command: label::LabelSubcommands::Remove(label::remove::RemoveCommand {
439                message_ids: vec!["m1".to_string()],
440                label: "IMPORTANT".to_string(),
441                force: true,
442                dry_run: false,
443            }),
444        });
445        assert!(cmd.dispatch(&dead_client()).await.is_err());
446    }
447
448    #[tokio::test]
449    async fn dispatch_routes_sync() {
450        let cmd = GmailSubcommands::Sync(sync::SyncCommand {
451            output_dir: std::path::PathBuf::from("/tmp/does-not-matter"),
452            query: None,
453            full: false,
454            concurrency: 4,
455            dry_run: false,
456            extract_attachments: false,
457            quiet: false,
458            output: OutputFormat::Table,
459        });
460        assert!(cmd.dispatch(&dead_client()).await.is_err());
461    }
462
463    fn sync_all_command() -> sync_all::SyncAllCommand {
464        sync_all::SyncAllCommand {
465            context_dir: None,
466            concurrency: None,
467            full: false,
468            dry_run: false,
469            quiet: false,
470            output: OutputFormat::Table,
471        }
472    }
473
474    #[tokio::test]
475    async fn execute_rejects_account_flag_with_sync_all() {
476        let guard = crate::gmail::test_support::EnvGuard::take();
477        let _dir = guard.clear_credentials();
478
479        let cmd = GmailCommand {
480            account: Some("work".to_string()),
481            command: GmailSubcommands::SyncAll(sync_all_command()),
482        };
483        let err = cmd.execute().await.unwrap_err();
484        assert!(err
485            .to_string()
486            .contains("--account is not compatible with sync-all"));
487    }
488
489    #[tokio::test]
490    async fn execute_sync_all_never_sets_the_account_env_var() {
491        let guard = crate::gmail::test_support::EnvGuard::take();
492        let dir = guard.clear_credentials();
493
494        let cmd = GmailCommand {
495            account: None,
496            command: GmailSubcommands::SyncAll(sync_all::SyncAllCommand {
497                context_dir: Some(dir.path().to_path_buf()),
498                ..sync_all_command()
499            }),
500        };
501        // No gmail-sync.yaml exists under `dir` — this is expected to
502        // fail with a config-loading error, never a network/dispatch
503        // error, proving `SyncAll` never reached the shared-client
504        // `dispatch` path (which would panic on `unreachable!()`).
505        let err = cmd.execute().await.unwrap_err();
506        assert!(err.to_string().contains("no gmail-sync.yaml found"));
507        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
508    }
509
510    #[tokio::test]
511    async fn execute_routes_extract_attachments_without_client_resolution() {
512        let guard = crate::gmail::test_support::EnvGuard::take();
513        let _dir = guard.clear_credentials();
514        let archive_dir = tempfile::tempdir().unwrap();
515
516        let cmd = GmailCommand {
517            account: None,
518            command: GmailSubcommands::ExtractAttachments(
519                extract_attachments::ExtractAttachmentsCommand {
520                    archive_dir: archive_dir.path().to_path_buf(),
521                    dry_run: false,
522                    quiet: true,
523                    output: OutputFormat::Table,
524                },
525            ),
526        };
527        // Succeeds even with zero credentials configured (an empty
528        // archive is simply "nothing to do") — proving
529        // `ExtractAttachments` never resolves a client, unlike every
530        // subcommand routed through `dispatch`.
531        cmd.execute().await.unwrap();
532    }
533
534    #[tokio::test]
535    async fn execute_rejects_account_flag_with_extract_attachments() {
536        let guard = crate::gmail::test_support::EnvGuard::take();
537        let _dir = guard.clear_credentials();
538        let archive_dir = tempfile::tempdir().unwrap();
539
540        let cmd = GmailCommand {
541            account: Some("work".to_string()),
542            command: GmailSubcommands::ExtractAttachments(
543                extract_attachments::ExtractAttachmentsCommand {
544                    archive_dir: archive_dir.path().to_path_buf(),
545                    dry_run: false,
546                    quiet: true,
547                    output: OutputFormat::Table,
548                },
549            ),
550        };
551        let err = cmd.execute().await.unwrap_err();
552        assert!(err
553            .to_string()
554            .contains("--account is not compatible with extract-attachments"));
555        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
556    }
557
558    #[tokio::test]
559    async fn execute_routes_render_without_client_resolution() {
560        let guard = crate::gmail::test_support::EnvGuard::take();
561        let _dir = guard.clear_credentials();
562        let dir = tempfile::tempdir().unwrap();
563        let path = dir.path().join("m1.eml");
564        std::fs::write(&path, "Subject: Hi\r\n\r\nBody.").unwrap();
565
566        let cmd = GmailCommand {
567            account: None,
568            command: GmailSubcommands::Render(render::RenderCommand {
569                paths: vec![path],
570                archive_dir: None,
571                all: false,
572                out_dir: None,
573                output: OutputFormat::Table,
574                fold_quotes: false,
575            }),
576        };
577        // Succeeds even with zero credentials configured — proving `Render`
578        // never resolves a client, unlike every subcommand routed through
579        // `dispatch`.
580        cmd.execute().await.unwrap();
581    }
582
583    #[tokio::test]
584    async fn execute_routes_render_archive_dir_without_client_resolution() {
585        let guard = crate::gmail::test_support::EnvGuard::take();
586        let _dir = guard.clear_credentials();
587        let archive_dir = tempfile::tempdir().unwrap();
588
589        let cmd = GmailCommand {
590            account: None,
591            command: GmailSubcommands::Render(render::RenderCommand {
592                paths: Vec::new(),
593                archive_dir: Some(archive_dir.path().to_path_buf()),
594                all: true,
595                out_dir: None,
596                output: OutputFormat::Table,
597                fold_quotes: false,
598            }),
599        };
600        // Succeeds even with zero credentials configured (an empty archive
601        // is simply "nothing to render") — proving `Render`'s
602        // `--archive-dir --all` mode never resolves a client either.
603        cmd.execute().await.unwrap();
604    }
605
606    #[tokio::test]
607    async fn execute_rejects_account_flag_with_render() {
608        let guard = crate::gmail::test_support::EnvGuard::take();
609        let _dir = guard.clear_credentials();
610        let dir = tempfile::tempdir().unwrap();
611        let path = dir.path().join("m1.eml");
612        std::fs::write(&path, "Subject: Hi\r\n\r\nBody.").unwrap();
613
614        let cmd = GmailCommand {
615            account: Some("work".to_string()),
616            command: GmailSubcommands::Render(render::RenderCommand {
617                paths: vec![path],
618                archive_dir: None,
619                all: false,
620                out_dir: None,
621                output: OutputFormat::Table,
622                fold_quotes: false,
623            }),
624        };
625        let err = cmd.execute().await.unwrap_err();
626        assert!(err
627            .to_string()
628            .contains("--account is not compatible with render"));
629        assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
630    }
631}