pub(crate) mod account;
pub(crate) mod auth;
pub(crate) mod extract_attachments;
pub(crate) mod format;
pub(crate) mod helpers;
pub(crate) mod label;
pub(crate) mod read;
pub(crate) mod render;
pub(crate) mod search;
pub(crate) mod sync;
pub(crate) mod sync_all;
pub(crate) mod thread;
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::gmail::account::GMAIL_ACCOUNT_ENV;
use crate::gmail::client::GmailClient;
#[derive(Parser)]
pub struct GmailCommand {
#[arg(long, global = true, value_name = "NAME")]
pub account: Option<String>,
#[command(subcommand)]
pub command: GmailSubcommands,
}
#[derive(Subcommand)]
pub enum GmailSubcommands {
Auth(auth::AuthCommand),
Account(account::AccountCommand),
Search(search::SearchCommand),
Read(read::ReadCommand),
Thread(thread::ThreadCommand),
Label(label::LabelCommand),
Sync(sync::SyncCommand),
SyncAll(sync_all::SyncAllCommand),
ExtractAttachments(extract_attachments::ExtractAttachmentsCommand),
Render(render::RenderCommand),
}
impl GmailCommand {
pub async fn execute(self) -> Result<()> {
let account = self.account;
match self.command {
GmailSubcommands::SyncAll(cmd) => {
anyhow::ensure!(
account.is_none(),
"--account is not compatible with sync-all; configure accounts in \
.omni-dev/gmail-sync.yaml instead"
);
cmd.execute().await
}
GmailSubcommands::ExtractAttachments(cmd) => {
anyhow::ensure!(
account.is_none(),
"--account is not compatible with extract-attachments; it operates on a \
local archive directory only"
);
cmd.execute()
}
GmailSubcommands::Render(cmd) => {
anyhow::ensure!(
account.is_none(),
"--account is not compatible with render; it operates on local .eml files \
only"
);
cmd.execute()
}
command => {
let _account_guard = account.as_ref().map(|account| {
crate::utils::env::ScopedEnvVar::set(GMAIL_ACCOUNT_ENV, account)
});
match command {
GmailSubcommands::Auth(cmd) => cmd.execute().await,
GmailSubcommands::Account(cmd) => cmd.execute(),
data => {
let client = helpers::create_client()?;
data.dispatch(&client).await
}
}
}
}
}
}
impl GmailSubcommands {
async fn dispatch(self, client: &GmailClient) -> Result<()> {
match self {
Self::Auth(_) => {
unreachable!("Auth is dispatched before client resolution")
}
Self::Account(_) => {
unreachable!("Account is dispatched before client resolution")
}
Self::SyncAll(_) => {
unreachable!("SyncAll is dispatched before client resolution")
}
Self::ExtractAttachments(_) => {
unreachable!("ExtractAttachments is dispatched before client resolution")
}
Self::Render(_) => {
unreachable!("Render is dispatched before client resolution")
}
Self::Search(cmd) => cmd.execute(client).await,
Self::Read(cmd) => cmd.execute(client).await,
Self::Thread(cmd) => cmd.execute(client).await,
Self::Label(cmd) => cmd.execute(client).await,
Self::Sync(cmd) => cmd.execute(client).await,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::cli::gmail::format::OutputFormat;
use crate::gmail::auth::{GmailCredentials, GmailScope};
use crate::gmail::client::GmailClient;
use crate::utils::secret::Secret;
fn dead_credentials() -> GmailCredentials {
GmailCredentials {
client_id: "client".to_string(),
client_secret: Secret::new("secret"),
refresh_token: Secret::new("refresh"),
scope: GmailScope::ReadOnly,
}
}
fn dead_client() -> GmailClient {
GmailClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
}
#[tokio::test]
async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Auth(auth::AuthCommand {
command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
}),
};
let err = cmd.execute().await.unwrap_err();
assert!(err.to_string().contains("not configured"));
}
#[tokio::test]
async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Search(search::SearchCommand {
query: "label:finance".to_string(),
limit: 10,
enrich: false,
concurrency: 4,
output: OutputFormat::Table,
}),
};
let err = cmd.execute().await.unwrap_err();
assert!(err.to_string().contains("not configured"));
}
#[test]
fn gmail_subcommands_auth_variant() {
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Auth(auth::AuthCommand {
command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
}),
};
assert!(matches!(cmd.command, GmailSubcommands::Auth(_)));
}
#[test]
fn gmail_subcommands_account_variant() {
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
assert!(matches!(cmd.command, GmailSubcommands::Account(_)));
}
#[tokio::test]
async fn execute_restores_account_env_var_after_return() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let cmd = GmailCommand {
account: Some("work".to_string()),
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
cmd.execute().await.unwrap();
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
}
#[tokio::test]
async fn execute_restores_previous_account_env_var_after_return() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
std::env::set_var(GMAIL_ACCOUNT_ENV, "personal");
let cmd = GmailCommand {
account: Some("work".to_string()),
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
cmd.execute().await.unwrap();
assert_eq!(
std::env::var(GMAIL_ACCOUNT_ENV).ok().as_deref(),
Some("personal")
);
}
#[tokio::test]
async fn execute_does_not_leak_account_across_sequential_calls() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let account_list_cmd = || GmailCommand {
account: None,
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
let first = GmailCommand {
account: Some("alpha".to_string()),
..account_list_cmd()
};
first.execute().await.unwrap();
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
account_list_cmd().execute().await.unwrap();
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
}
#[tokio::test]
async fn execute_absent_account_leaves_ambient_env_var_untouched() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
std::env::set_var(GMAIL_ACCOUNT_ENV, "personal");
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
cmd.execute().await.unwrap();
assert_eq!(
std::env::var(GMAIL_ACCOUNT_ENV).ok().as_deref(),
Some("personal")
);
}
#[tokio::test]
async fn execute_routes_account_list_without_client_resolution() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Account(account::AccountCommand {
command: account::AccountSubcommands::List(account::list::ListCommand {
output: OutputFormat::Table,
}),
}),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn dispatch_routes_search() {
let cmd = GmailSubcommands::Search(search::SearchCommand {
query: "label:finance".to_string(),
limit: 10,
enrich: false,
concurrency: 4,
output: OutputFormat::Table,
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_read() {
let cmd = GmailSubcommands::Read(read::ReadCommand {
message_id: "msg1".to_string(),
out_file: None,
detail: read::ReadDetail::Full,
output: read::ReadOutputFormat::Table,
fold_quotes: false,
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_thread() {
let cmd = GmailSubcommands::Thread(thread::ThreadCommand {
thread_id: "t1".to_string(),
output: OutputFormat::Table,
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_label_list() {
let cmd = GmailSubcommands::Label(label::LabelCommand {
command: label::LabelSubcommands::List(label::list::ListCommand {
output: OutputFormat::Table,
}),
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_label_add() {
let cmd = GmailSubcommands::Label(label::LabelCommand {
command: label::LabelSubcommands::Add(label::add::AddCommand {
message_ids: vec!["m1".to_string()],
label: "IMPORTANT".to_string(),
}),
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_label_remove() {
let cmd = GmailSubcommands::Label(label::LabelCommand {
command: label::LabelSubcommands::Remove(label::remove::RemoveCommand {
message_ids: vec!["m1".to_string()],
label: "IMPORTANT".to_string(),
force: true,
dry_run: false,
}),
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
#[tokio::test]
async fn dispatch_routes_sync() {
let cmd = GmailSubcommands::Sync(sync::SyncCommand {
output_dir: std::path::PathBuf::from("/tmp/does-not-matter"),
query: None,
full: false,
concurrency: 4,
dry_run: false,
extract_attachments: false,
quiet: false,
output: OutputFormat::Table,
});
assert!(cmd.dispatch(&dead_client()).await.is_err());
}
fn sync_all_command() -> sync_all::SyncAllCommand {
sync_all::SyncAllCommand {
context_dir: None,
concurrency: None,
full: false,
dry_run: false,
quiet: false,
output: OutputFormat::Table,
}
}
#[tokio::test]
async fn execute_rejects_account_flag_with_sync_all() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let cmd = GmailCommand {
account: Some("work".to_string()),
command: GmailSubcommands::SyncAll(sync_all_command()),
};
let err = cmd.execute().await.unwrap_err();
assert!(err
.to_string()
.contains("--account is not compatible with sync-all"));
}
#[tokio::test]
async fn execute_sync_all_never_sets_the_account_env_var() {
let guard = crate::gmail::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::SyncAll(sync_all::SyncAllCommand {
context_dir: Some(dir.path().to_path_buf()),
..sync_all_command()
}),
};
let err = cmd.execute().await.unwrap_err();
assert!(err.to_string().contains("no gmail-sync.yaml found"));
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
}
#[tokio::test]
async fn execute_routes_extract_attachments_without_client_resolution() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let archive_dir = tempfile::tempdir().unwrap();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::ExtractAttachments(
extract_attachments::ExtractAttachmentsCommand {
archive_dir: archive_dir.path().to_path_buf(),
dry_run: false,
quiet: true,
output: OutputFormat::Table,
},
),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn execute_rejects_account_flag_with_extract_attachments() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let archive_dir = tempfile::tempdir().unwrap();
let cmd = GmailCommand {
account: Some("work".to_string()),
command: GmailSubcommands::ExtractAttachments(
extract_attachments::ExtractAttachmentsCommand {
archive_dir: archive_dir.path().to_path_buf(),
dry_run: false,
quiet: true,
output: OutputFormat::Table,
},
),
};
let err = cmd.execute().await.unwrap_err();
assert!(err
.to_string()
.contains("--account is not compatible with extract-attachments"));
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
}
#[tokio::test]
async fn execute_routes_render_without_client_resolution() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("m1.eml");
std::fs::write(&path, "Subject: Hi\r\n\r\nBody.").unwrap();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Render(render::RenderCommand {
paths: vec![path],
archive_dir: None,
all: false,
out_dir: None,
output: OutputFormat::Table,
fold_quotes: false,
}),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn execute_routes_render_archive_dir_without_client_resolution() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let archive_dir = tempfile::tempdir().unwrap();
let cmd = GmailCommand {
account: None,
command: GmailSubcommands::Render(render::RenderCommand {
paths: Vec::new(),
archive_dir: Some(archive_dir.path().to_path_buf()),
all: true,
out_dir: None,
output: OutputFormat::Table,
fold_quotes: false,
}),
};
cmd.execute().await.unwrap();
}
#[tokio::test]
async fn execute_rejects_account_flag_with_render() {
let guard = crate::gmail::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("m1.eml");
std::fs::write(&path, "Subject: Hi\r\n\r\nBody.").unwrap();
let cmd = GmailCommand {
account: Some("work".to_string()),
command: GmailSubcommands::Render(render::RenderCommand {
paths: vec![path],
archive_dir: None,
all: false,
out_dir: None,
output: OutputFormat::Table,
fold_quotes: false,
}),
};
let err = cmd.execute().await.unwrap_err();
assert!(err
.to_string()
.contains("--account is not compatible with render"));
assert_eq!(std::env::var(GMAIL_ACCOUNT_ENV).ok(), None);
}
}