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