1pub(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#[derive(Parser)]
28pub struct GmailCommand {
29 #[arg(long, global = true, value_name = "NAME")]
43 pub account: Option<String>,
44 #[command(subcommand)]
46 pub command: GmailSubcommands,
47}
48
49#[derive(Subcommand)]
51pub enum GmailSubcommands {
52 Auth(auth::AuthCommand),
54 Account(account::AccountCommand),
56 Search(search::SearchCommand),
58 Read(read::ReadCommand),
60 Thread(thread::ThreadCommand),
62 Label(label::LabelCommand),
64 Draft(draft::DraftCommand),
68 Sync(sync::SyncCommand),
70 SyncAll(sync_all::SyncAllCommand),
74 ExtractAttachments(extract_attachments::ExtractAttachmentsCommand),
78 Render(render::RenderCommand),
82 Insert(insert::InsertCommand),
85}
86
87impl GmailCommand {
88 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 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 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 fn dead_client() -> GmailClient {
219 GmailClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
220 }
221
222 #[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 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 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 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 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 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 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}