1pub(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#[derive(Parser)]
24pub struct GmailCommand {
25 #[arg(long, global = true, value_name = "NAME")]
39 pub account: Option<String>,
40 #[command(subcommand)]
42 pub command: GmailSubcommands,
43}
44
45#[derive(Subcommand)]
47pub enum GmailSubcommands {
48 Auth(auth::AuthCommand),
50 Account(account::AccountCommand),
52 Search(search::SearchCommand),
54 Read(read::ReadCommand),
56 Thread(thread::ThreadCommand),
58 Label(label::LabelCommand),
60 Sync(sync::SyncCommand),
62 SyncAll(sync_all::SyncAllCommand),
66 ExtractAttachments(extract_attachments::ExtractAttachmentsCommand),
70 Render(render::RenderCommand),
74}
75
76impl GmailCommand {
77 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 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 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 fn dead_client() -> GmailClient {
205 GmailClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
206 }
207
208 #[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 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 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 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 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 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 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}