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