Skip to main content

omni_dev/gmail/
account.rs

1//! Named Gmail account resolution (issue #1500,
2//! [ADR-0066](../../docs/adrs/adr-0066.md)).
3//!
4//! A second, Gmail-specific multi-tenancy axis, orthogonal to `--profile`
5//! ([ADR-0045](../../docs/adrs/adr-0045.md)): named accounts in the `gmail`
6//! block of `settings.json`, selected per invocation via `--account` /
7//! [`GMAIL_ACCOUNT_ENV`]. [`resolve_account`] (reads) and
8//! [`resolve_account_for_write`] (account-creating writes: `login`,
9//! `import`) are the two entry points the credential CRUD layer
10//! (`crate::gmail::auth`, `crate::gmail::import`) and the CLI/MCP surfaces
11//! route through.
12
13use anyhow::Result;
14use serde::Serialize;
15
16use crate::gmail::auth::{GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN};
17use crate::utils::env::EnvSource;
18use crate::utils::secret_env::secret_var_is_set;
19use crate::utils::settings::GmailSettings;
20
21/// Selects the active Gmail account for every command, mirroring
22/// `OMNI_DEV_PROFILE`. Propagated by the `gmail`-scoped `--account` flag in
23/// `GmailCommand::execute`.
24pub const GMAIL_ACCOUNT_ENV: &str = "OMNI_DEV_GMAIL_ACCOUNT";
25
26/// Returns the account named by [`GMAIL_ACCOUNT_ENV`] in `raw`, or `None`
27/// when unset or empty.
28///
29/// Reads the **raw** env only, mirroring
30/// [`active_profile_from`](crate::utils::settings::active_profile_from) —
31/// pure over the injected source, never resolves through account fallback.
32pub fn active_gmail_account_from<E: EnvSource>(raw: &E) -> Option<String> {
33    raw.var(GMAIL_ACCOUNT_ENV).filter(|s| !s.is_empty())
34}
35
36/// Which credential source a Gmail command should read from, as decided by
37/// [`resolve_account`].
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ResolvedAccount {
40    /// A named account: `gmail.accounts.<name>`.
41    Named(String),
42    /// No named account applies — fall through to today's exact
43    /// process-env / active-`--profile`-env / base-env resolution,
44    /// unchanged (the zero-migration path).
45    Legacy,
46}
47
48/// Resolves which Gmail account a *read* command (credential load, status,
49/// client construction, MCP tools, …) should use.
50///
51/// `explicit` is the already-resolved `--account`/[`GMAIL_ACCOUNT_ENV`]
52/// value, if any (production callers pass the result of calling
53/// [`active_gmail_account_from`] on `&SystemEnv`, or an MCP per-call
54/// override).
55///
56/// Precedence:
57/// 1. A literal `GMAIL_CLIENT_ID`+`GMAIL_CLIENT_SECRET`+`GMAIL_REFRESH_TOKEN`
58///    all present in `raw` bypasses account resolution entirely — today's
59///    exact behavior, unchanged (the process-env-always-wins invariant
60///    `Settings::resolve_with_source` already applies elsewhere).
61/// 2. `explicit`, if set, selects `gmail.accounts.<name>`; an unknown name
62///    is a hard error listing the configured accounts.
63/// 3. No explicit account, `settings.accounts` non-empty: `default_account`
64///    if it still names a configured account, else the sole account if
65///    exactly one is configured, else a hard error.
66/// 4. `settings.accounts` empty: [`ResolvedAccount::Legacy`].
67///
68/// For `gmail auth login`/`gmail auth import`, which *create* an account
69/// rather than select an existing one, see [`resolve_account_for_write`] —
70/// this function's rule 2 would otherwise make it impossible to ever
71/// configure a brand-new named account, since it would reject the very name
72/// being created as "unknown".
73pub fn resolve_account<E: EnvSource>(
74    raw: &E,
75    settings: &GmailSettings,
76    explicit: Option<&str>,
77) -> Result<ResolvedAccount> {
78    if has_literal_credentials(raw) {
79        return Ok(ResolvedAccount::Legacy);
80    }
81    if let Some(name) = explicit {
82        validate_account(settings, name)?;
83        return Ok(ResolvedAccount::Named(name.to_string()));
84    }
85    if settings.accounts.is_empty() {
86        return Ok(ResolvedAccount::Legacy);
87    }
88    resolve_default_account(settings).map(ResolvedAccount::Named)
89}
90
91/// Resolves which Gmail account a *write* command that can create a new
92/// account (`gmail auth login`, `gmail auth import`) should target.
93///
94/// Identical to [`resolve_account`] except for rule 2: an explicit
95/// `--account`/[`GMAIL_ACCOUNT_ENV`] name need not already be configured —
96/// these commands are how an account comes into existence in the first
97/// place, so it is always accepted as the target rather than validated.
98/// Rules 1, 3, and 4 (literal-env bypass, default/sole-account fallback,
99/// empty-accounts legacy fallback) are unchanged, so a plain
100/// `gmail auth login` with no `--account` still resolves exactly like
101/// [`resolve_account`] would.
102pub fn resolve_account_for_write<E: EnvSource>(
103    raw: &E,
104    settings: &GmailSettings,
105    explicit: Option<&str>,
106) -> Result<ResolvedAccount> {
107    if has_literal_credentials(raw) {
108        return Ok(ResolvedAccount::Legacy);
109    }
110    if let Some(name) = explicit {
111        return Ok(ResolvedAccount::Named(name.to_string()));
112    }
113    if settings.accounts.is_empty() {
114        return Ok(ResolvedAccount::Legacy);
115    }
116    resolve_default_account(settings).map(ResolvedAccount::Named)
117}
118
119/// Whether `raw` carries a complete literal credential set directly in the
120/// process environment (rule 1 of [`resolve_account`]).
121fn has_literal_credentials<E: EnvSource>(raw: &E) -> bool {
122    raw.var(GMAIL_CLIENT_ID).is_some()
123        && raw.var(GMAIL_CLIENT_SECRET).is_some()
124        && secret_var_is_set(raw, GMAIL_REFRESH_TOKEN)
125}
126
127/// Validates that `name` is a known Gmail account.
128///
129/// Mirrors
130/// [`Settings::validate_profile`](crate::utils::settings::Settings::validate_profile)'s
131/// exact message convention (sorted key list, `"(none)"` sentinel). Exposed
132/// beyond this module so `gmail account set-default` can reuse the same
133/// error text rather than duplicating it.
134pub fn validate_account(settings: &GmailSettings, name: &str) -> Result<()> {
135    if settings.accounts.contains_key(name) {
136        return Ok(());
137    }
138    Err(anyhow::anyhow!(
139        "unknown Gmail account '{name}'; known accounts: {}",
140        known_accounts(settings)
141    ))
142}
143
144/// The configured account names, sorted and comma-joined, or `"(none)"` when
145/// none are configured — the shared error-message fragment.
146fn known_accounts(settings: &GmailSettings) -> String {
147    if settings.accounts.is_empty() {
148        "(none)".to_string()
149    } else {
150        let mut names: Vec<&str> = settings.accounts.keys().map(String::as_str).collect();
151        names.sort_unstable();
152        names.join(", ")
153    }
154}
155
156/// Resolves the account to use when no `--account`/[`GMAIL_ACCOUNT_ENV`] is
157/// given and at least one account is configured (rule 3 of
158/// [`resolve_account`]).
159fn resolve_default_account(settings: &GmailSettings) -> Result<String> {
160    if let Some(default) = &settings.default_account {
161        return if settings.accounts.contains_key(default) {
162            Ok(default.clone())
163        } else {
164            Err(anyhow::anyhow!(
165                "configured default Gmail account '{default}' no longer exists; known accounts: {}",
166                known_accounts(settings)
167            ))
168        };
169    }
170    let mut names = settings.accounts.keys();
171    if let (Some(sole), None) = (names.next(), names.next()) {
172        return Ok(sole.clone());
173    }
174    Err(anyhow::anyhow!(
175        "multiple Gmail accounts configured and no default set; pass --account or run \
176         `gmail account set-default <name>`"
177    ))
178}
179
180/// One row of `gmail account list` / the `gmail_account_list` MCP tool.
181/// Never carries a secret — only what's safe to render.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183pub struct AccountSummary {
184    /// The account name passed to `--account`.
185    pub name: String,
186    /// The display-only cached mailbox address, if known.
187    pub email_address: Option<String>,
188    /// The OAuth2 scope this account was authorized with, if known.
189    pub scope: Option<String>,
190    /// Whether this is `gmail.default_account`.
191    pub is_default: bool,
192}
193
194/// Lists configured Gmail accounts, sorted by name.
195pub fn list_accounts(settings: &GmailSettings) -> Vec<AccountSummary> {
196    let mut names: Vec<&String> = settings.accounts.keys().collect();
197    names.sort();
198    names
199        .into_iter()
200        .map(|name| {
201            let account = &settings.accounts[name];
202            AccountSummary {
203                name: name.clone(),
204                email_address: account.email_address.clone(),
205                scope: account.scope.clone(),
206                is_default: settings.default_account.as_deref() == Some(name.as_str()),
207            }
208        })
209        .collect()
210}
211
212/// Whether the write about to happen would be the **first** time a named
213/// Gmail account is configured while legacy (pre-migration) credentials
214/// still exist.
215///
216/// This is the moment un-migrated legacy credentials become shadowed for
217/// no-`--account` invocations (rule 4 of [`resolve_account`] stops
218/// applying). Callers (`gmail auth login --account`, `gmail account
219/// import-legacy`) print a one-time notice at this transition rather than
220/// auto-migrating.
221pub fn is_first_legacy_to_named_transition(
222    settings: &GmailSettings,
223    has_legacy_credentials: bool,
224) -> bool {
225    settings.accounts.is_empty() && has_legacy_credentials
226}
227
228#[cfg(test)]
229#[allow(clippy::unwrap_used)]
230mod tests {
231    use super::*;
232    use crate::test_support::env::MapEnv;
233    use crate::utils::settings::GmailAccountSettings;
234    use std::collections::HashMap;
235
236    fn account(email: Option<&str>, scope: Option<&str>) -> GmailAccountSettings {
237        GmailAccountSettings {
238            client_id: Some("id".to_string()),
239            client_secret: Some("secret".to_string()),
240            client_secret_file: None,
241            refresh_token: Some("token".to_string()),
242            refresh_token_file: None,
243            scope: scope.map(str::to_string),
244            email_address: email.map(str::to_string),
245            chrome_profile_from_email: false,
246            browser_command: None,
247        }
248    }
249
250    fn settings_with(
251        default_account: Option<&str>,
252        accounts: &[(&str, GmailAccountSettings)],
253    ) -> GmailSettings {
254        GmailSettings {
255            default_account: default_account.map(str::to_string),
256            accounts: accounts
257                .iter()
258                .map(|(name, acc)| {
259                    (
260                        (*name).to_string(),
261                        GmailAccountSettings {
262                            client_id: acc.client_id.clone(),
263                            client_secret: acc.client_secret.clone(),
264                            client_secret_file: acc.client_secret_file.clone(),
265                            refresh_token: acc.refresh_token.clone(),
266                            refresh_token_file: acc.refresh_token_file.clone(),
267                            scope: acc.scope.clone(),
268                            email_address: acc.email_address.clone(),
269                            chrome_profile_from_email: acc.chrome_profile_from_email,
270                            browser_command: acc.browser_command.clone(),
271                        },
272                    )
273                })
274                .collect::<HashMap<_, _>>(),
275        }
276    }
277
278    #[test]
279    fn active_gmail_account_from_reads_and_trims_empty() {
280        assert_eq!(
281            active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "work")),
282            Some("work".to_string())
283        );
284        assert_eq!(
285            active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "")),
286            None
287        );
288        assert_eq!(active_gmail_account_from(&MapEnv::new()), None);
289    }
290
291    #[test]
292    fn resolve_literal_env_bypasses_account_resolution() {
293        let raw = MapEnv::new()
294            .with(GMAIL_CLIENT_ID, "id")
295            .with(GMAIL_CLIENT_SECRET, "secret")
296            .with(GMAIL_REFRESH_TOKEN, "token");
297        // Even with an ambiguous multi-account store and no default, or an
298        // explicit account, literal env wins outright.
299        let settings = settings_with(
300            None,
301            &[("a", account(None, None)), ("b", account(None, None))],
302        );
303        assert_eq!(
304            resolve_account(&raw, &settings, Some("a")).unwrap(),
305            ResolvedAccount::Legacy
306        );
307        assert_eq!(
308            resolve_account(&raw, &settings, None).unwrap(),
309            ResolvedAccount::Legacy
310        );
311    }
312
313    #[test]
314    fn resolve_literal_env_requires_all_three_keys() {
315        let raw = MapEnv::new()
316            .with(GMAIL_CLIENT_ID, "id")
317            .with(GMAIL_CLIENT_SECRET, "secret");
318        let settings = settings_with(None, &[("work", account(None, None))]);
319        // Missing GMAIL_REFRESH_TOKEN: falls through to normal resolution
320        // (rule 3, sole account) rather than the literal-env bypass.
321        assert_eq!(
322            resolve_account(&raw, &settings, None).unwrap(),
323            ResolvedAccount::Named("work".to_string())
324        );
325    }
326
327    #[test]
328    fn resolve_explicit_account_selects_named() {
329        let settings = settings_with(None, &[("work", account(None, None))]);
330        assert_eq!(
331            resolve_account(&MapEnv::new(), &settings, Some("work")).unwrap(),
332            ResolvedAccount::Named("work".to_string())
333        );
334    }
335
336    #[test]
337    fn resolve_explicit_unknown_account_errors_with_sorted_list() {
338        let settings = settings_with(
339            None,
340            &[
341                ("work", account(None, None)),
342                ("personal", account(None, None)),
343            ],
344        );
345        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
346        assert_eq!(
347            err.to_string(),
348            "unknown Gmail account 'bogus'; known accounts: personal, work"
349        );
350    }
351
352    #[test]
353    fn resolve_explicit_unknown_account_reports_none_when_empty() {
354        let settings = settings_with(None, &[]);
355        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
356        assert_eq!(
357            err.to_string(),
358            "unknown Gmail account 'bogus'; known accounts: (none)"
359        );
360    }
361
362    // ── resolve_account_for_write (login/import bootstrap a new name) ──
363
364    #[test]
365    fn resolve_for_write_explicit_new_name_is_accepted_without_validation() {
366        // The whole point: an --account name that doesn't exist yet must
367        // succeed for login/import, unlike the read-side resolve_account.
368        let settings = settings_with(None, &[]);
369        assert_eq!(
370            resolve_account_for_write(&MapEnv::new(), &settings, Some("brand-new")).unwrap(),
371            ResolvedAccount::Named("brand-new".to_string())
372        );
373    }
374
375    #[test]
376    fn resolve_for_write_explicit_existing_name_still_selects_it() {
377        let settings = settings_with(None, &[("work", account(None, None))]);
378        assert_eq!(
379            resolve_account_for_write(&MapEnv::new(), &settings, Some("work")).unwrap(),
380            ResolvedAccount::Named("work".to_string())
381        );
382    }
383
384    #[test]
385    fn resolve_for_write_literal_env_still_bypasses_account_resolution() {
386        let raw = MapEnv::new()
387            .with(GMAIL_CLIENT_ID, "id")
388            .with(GMAIL_CLIENT_SECRET, "secret")
389            .with(GMAIL_REFRESH_TOKEN, "token");
390        let settings = settings_with(None, &[]);
391        assert_eq!(
392            resolve_account_for_write(&raw, &settings, Some("brand-new")).unwrap(),
393            ResolvedAccount::Legacy
394        );
395    }
396
397    #[test]
398    fn resolve_for_write_no_explicit_matches_read_side_fallback_rules() {
399        // No --account given: falls back to the same default/sole-account/
400        // empty-is-legacy rules as resolve_account.
401        let sole = settings_with(None, &[("work", account(None, None))]);
402        assert_eq!(
403            resolve_account_for_write(&MapEnv::new(), &sole, None).unwrap(),
404            ResolvedAccount::Named("work".to_string())
405        );
406
407        let empty = settings_with(None, &[]);
408        assert_eq!(
409            resolve_account_for_write(&MapEnv::new(), &empty, None).unwrap(),
410            ResolvedAccount::Legacy
411        );
412
413        let ambiguous = settings_with(
414            None,
415            &[
416                ("work", account(None, None)),
417                ("personal", account(None, None)),
418            ],
419        );
420        assert!(resolve_account_for_write(&MapEnv::new(), &ambiguous, None).is_err());
421    }
422
423    #[test]
424    fn resolve_empty_accounts_is_legacy() {
425        let settings = settings_with(None, &[]);
426        assert_eq!(
427            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
428            ResolvedAccount::Legacy
429        );
430    }
431
432    #[test]
433    fn resolve_no_explicit_uses_valid_default() {
434        let settings = settings_with(
435            Some("work"),
436            &[
437                ("work", account(None, None)),
438                ("personal", account(None, None)),
439            ],
440        );
441        assert_eq!(
442            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
443            ResolvedAccount::Named("work".to_string())
444        );
445    }
446
447    #[test]
448    fn resolve_no_explicit_stale_default_errors() {
449        let settings = settings_with(Some("gone"), &[("work", account(None, None))]);
450        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
451        assert_eq!(
452            err.to_string(),
453            "configured default Gmail account 'gone' no longer exists; known accounts: work"
454        );
455    }
456
457    #[test]
458    fn resolve_no_explicit_no_default_falls_back_to_sole_account() {
459        let settings = settings_with(None, &[("work", account(None, None))]);
460        assert_eq!(
461            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
462            ResolvedAccount::Named("work".to_string())
463        );
464    }
465
466    #[test]
467    fn resolve_no_explicit_no_default_multiple_accounts_errors() {
468        let settings = settings_with(
469            None,
470            &[
471                ("work", account(None, None)),
472                ("personal", account(None, None)),
473            ],
474        );
475        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
476        assert_eq!(
477            err.to_string(),
478            "multiple Gmail accounts configured and no default set; pass --account or run \
479             `gmail account set-default <name>`"
480        );
481    }
482
483    #[test]
484    fn list_accounts_sorted_and_marks_default() {
485        let settings = settings_with(
486            Some("work"),
487            &[
488                ("personal", account(Some("me@gmail.com"), Some("readonly"))),
489                ("work", account(Some("me@work.com"), Some("modify"))),
490            ],
491        );
492        let rows = list_accounts(&settings);
493        assert_eq!(rows.len(), 2);
494        assert_eq!(rows[0].name, "personal");
495        assert_eq!(rows[0].email_address.as_deref(), Some("me@gmail.com"));
496        assert_eq!(rows[0].scope.as_deref(), Some("readonly"));
497        assert!(!rows[0].is_default);
498        assert_eq!(rows[1].name, "work");
499        assert!(rows[1].is_default);
500    }
501
502    #[test]
503    fn list_accounts_empty_when_no_accounts() {
504        assert!(list_accounts(&settings_with(None, &[])).is_empty());
505    }
506
507    #[test]
508    fn is_first_legacy_to_named_transition_true_only_when_empty_and_legacy_present() {
509        let empty = settings_with(None, &[]);
510        let non_empty = settings_with(None, &[("work", account(None, None))]);
511        assert!(is_first_legacy_to_named_transition(&empty, true));
512        assert!(!is_first_legacy_to_named_transition(&empty, false));
513        assert!(!is_first_legacy_to_named_transition(&non_empty, true));
514    }
515}