Skip to main content

nexo_auth/
email.rs

1//! [`CredentialStore`] impl for IMAP/SMTP email accounts.
2//!
3//! Three auth modes are supported, each emitting opaque [`SecretString`]
4//! material to the plugin so a stray `tracing::debug!("{account:?}")`
5//! cannot leak it:
6//!
7//! - **Password** — user + app-password (Outlook, iCloud, custom IMAP).
8//! - **OAuth2Static** — pre-issued bearer; caller treats `expires_at`
9//!   as advisory and refreshes externally if needed.
10//! - **OAuth2Google** — username + an `id` that points at an account in
11//!   the [`GoogleCredentialStore`]. Refresh + token rotation already
12//!   live there — this variant simply delegates and reuses the
13//!   per-account refresh mutex so concurrent IMAP IDLE workers do not
14//!   trip Google's "concurrent refresh" 400.
15//!
16//! The TOML loader at `secrets/email/<instance>.toml` is the only
17//! supported on-disk format; YAML mixes too many polymorphic shapes to
18//! keep the secret-handling clear.
19
20use std::collections::HashMap;
21use std::path::Path;
22use std::sync::Arc;
23
24use base64::Engine as _;
25use secrecy::{ExposeSecret, SecretString};
26use serde::Deserialize;
27
28use crate::error::{BuildError, CredentialError};
29use crate::handle::{Channel, CredentialHandle, EMAIL};
30use crate::store::{CredentialStore, ValidationReport};
31
32#[derive(Clone)]
33pub struct EmailAccount {
34    pub instance: String,
35    pub address: String,
36    pub auth: EmailAuth,
37    pub allow_agents: Vec<String>,
38}
39
40#[derive(Clone)]
41pub enum EmailAuth {
42    Password {
43        username: String,
44        password: SecretString,
45    },
46    OAuth2Static {
47        username: String,
48        access_token: SecretString,
49        refresh_token: Option<SecretString>,
50        /// Unix seconds when the access token expires. `None` = unknown
51        /// or no-expiry; the plugin treats it as advisory.
52        expires_at: Option<i64>,
53    },
54    OAuth2Google {
55        username: String,
56        /// Looks up token via [`GoogleCredentialStore`] at use-time.
57        google_account_id: String,
58    },
59}
60
61impl std::fmt::Debug for EmailAuth {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::Password { username, .. } => f
65                .debug_struct("Password")
66                .field("username", username)
67                .field("password", &"<redacted>")
68                .finish(),
69            Self::OAuth2Static {
70                username,
71                refresh_token,
72                expires_at,
73                ..
74            } => f
75                .debug_struct("OAuth2Static")
76                .field("username", username)
77                .field("access_token", &"<redacted>")
78                .field(
79                    "refresh_token",
80                    &refresh_token.as_ref().map(|_| "<redacted>"),
81                )
82                .field("expires_at", expires_at)
83                .finish(),
84            Self::OAuth2Google {
85                username,
86                google_account_id,
87            } => f
88                .debug_struct("OAuth2Google")
89                .field("username", username)
90                .field("google_account_id", google_account_id)
91                .finish(),
92        }
93    }
94}
95
96impl std::fmt::Debug for EmailAccount {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("EmailAccount")
99            .field("instance", &self.instance)
100            .field("address", &self.address)
101            .field("auth", &self.auth)
102            .field("allow_agents", &self.allow_agents)
103            .finish()
104    }
105}
106
107impl EmailAccount {
108    /// SASL `XOAUTH2` payload as specified by RFC 7628 §3.2.1, base64'd.
109    /// Caller passes the freshly-resolved access token so this stays a
110    /// pure function — easy to unit-test against vendor fixtures.
111    pub fn xoauth2_sasl(username: &str, access_token: &str) -> String {
112        let raw = format!("user={username}\x01auth=Bearer {access_token}\x01\x01");
113        base64::engine::general_purpose::STANDARD.encode(raw)
114    }
115
116    /// Resolve a usable bearer token. For `Password` returns the
117    /// password material itself (caller picks AUTH=PLAIN/LOGIN). For
118    /// `OAuth2Static` returns the local access_token. For
119    /// `OAuth2Google` delegates to the [`GoogleCredentialStore`] under
120    /// its per-account refresh mutex; the caller is responsible for
121    /// holding the returned `SecretString` no longer than necessary.
122    pub async fn resolve_access_token(
123        &self,
124        google: &crate::google::GoogleCredentialStore,
125    ) -> Result<SecretString, CredentialError> {
126        match &self.auth {
127            EmailAuth::Password { password, .. } => Ok(password.clone()),
128            EmailAuth::OAuth2Static { access_token, .. } => Ok(access_token.clone()),
129            EmailAuth::OAuth2Google {
130                google_account_id, ..
131            } => {
132                let account = google.account(google_account_id).cloned().ok_or_else(|| {
133                    CredentialError::NotFound {
134                        channel: crate::handle::GOOGLE,
135                        account: google_account_id.clone(),
136                    }
137                })?;
138                // Acquire the per-account refresh mutex so two IDLE
139                // workers do not race a token rotation on disk. The
140                // lock is held only across the read; the actual rotate
141                // is the Google plugin's job.
142                let handle =
143                    CredentialHandle::new(crate::handle::GOOGLE, &account.id, "<email-resolve>");
144                let lock = google
145                    .refresh_lock(&handle)
146                    .ok_or(CredentialError::NotFound {
147                        channel: crate::handle::GOOGLE,
148                        account: google_account_id.clone(),
149                    })?;
150                let _guard = lock.lock().await;
151                let token = std::fs::read_to_string(&account.token_path).map_err(|e| {
152                    CredentialError::Unreadable {
153                        path: account.token_path.clone(),
154                        source: e,
155                    }
156                })?;
157                Ok(SecretString::new(token.trim().to_string()))
158            }
159        }
160    }
161
162    fn auth_warnings(&self) -> Vec<String> {
163        let mut out = Vec::new();
164        match &self.auth {
165            EmailAuth::Password { username, password } => {
166                if username.trim().is_empty() {
167                    out.push(format!(
168                        "email instance '{}': password auth has empty username",
169                        self.instance
170                    ));
171                }
172                if password.expose_secret().is_empty() {
173                    out.push(format!(
174                        "email instance '{}': password auth has empty password",
175                        self.instance
176                    ));
177                }
178            }
179            EmailAuth::OAuth2Static {
180                username,
181                access_token,
182                ..
183            } => {
184                if username.trim().is_empty() {
185                    out.push(format!(
186                        "email instance '{}': oauth2_static auth has empty username",
187                        self.instance
188                    ));
189                }
190                if access_token.expose_secret().is_empty() {
191                    out.push(format!(
192                        "email instance '{}': oauth2_static auth has empty access_token",
193                        self.instance
194                    ));
195                }
196            }
197            EmailAuth::OAuth2Google {
198                username,
199                google_account_id,
200            } => {
201                if username.trim().is_empty() {
202                    out.push(format!(
203                        "email instance '{}': oauth2_google auth has empty username",
204                        self.instance
205                    ));
206                }
207                if google_account_id.trim().is_empty() {
208                    out.push(format!(
209                        "email instance '{}': oauth2_google auth has empty google_account_id",
210                        self.instance
211                    ));
212                }
213            }
214        }
215        out
216    }
217}
218
219#[derive(Debug, Clone)]
220pub struct EmailCredentialStore {
221    accounts: Arc<HashMap<String, EmailAccount>>,
222}
223
224impl EmailCredentialStore {
225    pub fn new(accounts: Vec<EmailAccount>) -> Self {
226        let mut map = HashMap::with_capacity(accounts.len());
227        for a in accounts {
228            map.insert(a.instance.clone(), a);
229        }
230        Self {
231            accounts: Arc::new(map),
232        }
233    }
234
235    pub fn empty() -> Self {
236        Self {
237            accounts: Arc::new(HashMap::new()),
238        }
239    }
240
241    pub fn account(&self, instance: &str) -> Option<&EmailAccount> {
242        self.accounts.get(instance)
243    }
244}
245
246impl CredentialStore for EmailCredentialStore {
247    type Account = EmailAccount;
248
249    fn channel(&self) -> Channel {
250        EMAIL
251    }
252
253    fn get(&self, handle: &CredentialHandle) -> Result<Self::Account, CredentialError> {
254        let id = handle.account_id_raw();
255        self.accounts
256            .get(id)
257            .cloned()
258            .ok_or_else(|| CredentialError::NotFound {
259                channel: EMAIL,
260                account: id.to_string(),
261            })
262    }
263
264    fn issue(&self, account_id: &str, agent_id: &str) -> Result<CredentialHandle, CredentialError> {
265        let account = self
266            .accounts
267            .get(account_id)
268            .ok_or_else(|| CredentialError::NotFound {
269                channel: EMAIL,
270                account: account_id.to_string(),
271            })?;
272        if !account.allow_agents.is_empty() && !account.allow_agents.iter().any(|a| a == agent_id) {
273            let handle = CredentialHandle::new(EMAIL, account_id, agent_id);
274            return Err(CredentialError::NotPermitted {
275                channel: EMAIL,
276                agent: agent_id.to_string(),
277                fp: handle.fingerprint(),
278            });
279        }
280        Ok(CredentialHandle::new(EMAIL, account_id, agent_id))
281    }
282
283    fn list(&self) -> Vec<String> {
284        let mut ids: Vec<_> = self.accounts.keys().cloned().collect();
285        ids.sort();
286        ids
287    }
288
289    fn allow_agents(&self, account_id: &str) -> Vec<String> {
290        self.accounts
291            .get(account_id)
292            .map(|a| a.allow_agents.clone())
293            .unwrap_or_default()
294    }
295
296    fn validate(&self) -> ValidationReport {
297        let mut report = ValidationReport::default();
298        for (id, a) in self.accounts.iter() {
299            let warnings = a.auth_warnings();
300            if warnings.is_empty() {
301                report.accounts_ok += 1;
302            } else {
303                let _ = id;
304                for w in warnings {
305                    report.warnings.push(w);
306                }
307            }
308        }
309        report
310    }
311}
312
313// ── TOML loader ───────────────────────────────────────────────────────────────
314
315#[derive(Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317struct EmailSecretFile {
318    auth: EmailAuthFile,
319    #[serde(default)]
320    allow_agents: Vec<String>,
321}
322
323#[derive(Debug, Deserialize)]
324#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
325enum EmailAuthFile {
326    Password {
327        username: String,
328        password: String,
329    },
330    Oauth2Static {
331        username: String,
332        access_token: String,
333        #[serde(default)]
334        refresh_token: Option<String>,
335        #[serde(default)]
336        expires_at: Option<i64>,
337    },
338    Oauth2Google {
339        username: String,
340        google_account_id: String,
341    },
342}
343
344impl From<EmailAuthFile> for EmailAuth {
345    fn from(f: EmailAuthFile) -> Self {
346        match f {
347            EmailAuthFile::Password { username, password } => EmailAuth::Password {
348                username,
349                password: SecretString::new(password),
350            },
351            EmailAuthFile::Oauth2Static {
352                username,
353                access_token,
354                refresh_token,
355                expires_at,
356            } => EmailAuth::OAuth2Static {
357                username,
358                access_token: SecretString::new(access_token),
359                refresh_token: refresh_token.map(SecretString::new),
360                expires_at,
361            },
362            EmailAuthFile::Oauth2Google {
363                username,
364                google_account_id,
365            } => EmailAuth::OAuth2Google {
366                username,
367                google_account_id,
368            },
369        }
370    }
371}
372
373/// Load `<secrets_dir>/email/<instance>.toml` for every declared
374/// account. Caller passes `(instance, address)` pairs from the plugin
375/// config so the address ends up on the resulting [`EmailAccount`]
376/// without re-parsing YAML inside this crate.
377///
378/// Returns the list plus warnings (non-fatal: empty fields, missing
379/// optional values). Fatal problems (file missing, malformed TOML,
380/// unknown kind) come back as [`BuildError::Credential`] entries —
381/// boot accumulates them rather than failing fast.
382pub fn load_email_secrets(
383    secrets_dir: &Path,
384    declared: &[(String, String)],
385) -> (Vec<EmailAccount>, Vec<String>, Vec<BuildError>) {
386    let mut accounts = Vec::with_capacity(declared.len());
387    let mut warnings = Vec::new();
388    let mut errors = Vec::new();
389
390    for (instance, address) in declared {
391        let path = secrets_dir.join("email").join(format!("{instance}.toml"));
392        if !path.exists() {
393            errors.push(BuildError::Credential {
394                channel: EMAIL,
395                instance: instance.clone(),
396                source: CredentialError::FileMissing { path: path.clone() },
397            });
398            continue;
399        }
400        let raw = match std::fs::read_to_string(&path) {
401            Ok(s) => s,
402            Err(e) => {
403                errors.push(BuildError::Credential {
404                    channel: EMAIL,
405                    instance: instance.clone(),
406                    source: CredentialError::Unreadable {
407                        path: path.clone(),
408                        source: e,
409                    },
410                });
411                continue;
412            }
413        };
414        let resolved =
415            match nexo_config::env::resolve_placeholders(&raw, &format!("email/{instance}.toml")) {
416                Ok(s) => s,
417                Err(e) => {
418                    errors.push(BuildError::Credential {
419                        channel: EMAIL,
420                        instance: instance.clone(),
421                        source: CredentialError::InvalidSecret {
422                            path: path.clone(),
423                            message: e.to_string(),
424                        },
425                    });
426                    continue;
427                }
428            };
429        let parsed: EmailSecretFile = match toml::from_str(&resolved) {
430            Ok(p) => p,
431            Err(e) => {
432                errors.push(BuildError::Credential {
433                    channel: EMAIL,
434                    instance: instance.clone(),
435                    source: CredentialError::InvalidSecret {
436                        path: path.clone(),
437                        message: e.message().to_string(),
438                    },
439                });
440                continue;
441            }
442        };
443        let auth: EmailAuth = parsed.auth.into();
444        let account = EmailAccount {
445            instance: instance.clone(),
446            address: address.clone(),
447            auth,
448            allow_agents: parsed.allow_agents,
449        };
450        warnings.extend(account.auth_warnings());
451        accounts.push(account);
452    }
453
454    (accounts, warnings, errors)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::io::Write;
461
462    fn pwd_account(instance: &str, allow: &[&str]) -> EmailAccount {
463        EmailAccount {
464            instance: instance.into(),
465            address: format!("{instance}@example.com"),
466            auth: EmailAuth::Password {
467                username: format!("{instance}@example.com"),
468                password: SecretString::new("hunter2".into()),
469            },
470            allow_agents: allow.iter().map(|s| s.to_string()).collect(),
471        }
472    }
473
474    #[test]
475    fn auth_debug_does_not_leak_password() {
476        let auth = EmailAuth::Password {
477            username: "u@x".into(),
478            password: SecretString::new("super-secret-pw".into()),
479        };
480        let rendered = format!("{auth:?}");
481        assert!(!rendered.contains("super-secret-pw"));
482        assert!(rendered.contains("<redacted>"));
483    }
484
485    #[test]
486    fn auth_debug_does_not_leak_access_token() {
487        let auth = EmailAuth::OAuth2Static {
488            username: "u@x".into(),
489            access_token: SecretString::new("ya29.tk".into()),
490            refresh_token: Some(SecretString::new("rt-1".into())),
491            expires_at: Some(1_700_000_000),
492        };
493        let rendered = format!("{auth:?}");
494        assert!(!rendered.contains("ya29.tk"));
495        assert!(!rendered.contains("rt-1"));
496        assert!(rendered.contains("<redacted>"));
497    }
498
499    #[test]
500    fn account_debug_does_not_leak_secrets() {
501        let acct = pwd_account("ops", &[]);
502        let rendered = format!("{acct:?}");
503        assert!(!rendered.contains("hunter2"));
504        assert!(rendered.contains("<redacted>"));
505        assert!(rendered.contains("ops"));
506    }
507
508    #[test]
509    fn xoauth2_sasl_matches_rfc7628_fixture() {
510        // RFC 7628 §3.2.1 example: user=someuser@example.com\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==\x01\x01
511        let out = EmailAccount::xoauth2_sasl(
512            "someuser@example.com",
513            "vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==",
514        );
515        // Decode and verify the inner format rather than hard-coding the
516        // base64 string — encoders can pick different alphabets.
517        let decoded = base64::engine::general_purpose::STANDARD
518            .decode(out)
519            .unwrap();
520        let s = String::from_utf8(decoded).unwrap();
521        assert_eq!(
522            s,
523            "user=someuser@example.com\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==\x01\x01"
524        );
525    }
526
527    #[test]
528    fn store_issue_returns_handle_when_permitted() {
529        let s = EmailCredentialStore::new(vec![pwd_account("ops", &["ana"])]);
530        let h = s.issue("ops", "ana").unwrap();
531        assert_eq!(h.channel(), EMAIL);
532        assert_eq!(h.agent_id(), "ana");
533    }
534
535    #[test]
536    fn store_issue_rejects_non_allowed_agent() {
537        let s = EmailCredentialStore::new(vec![pwd_account("ops", &["ana"])]);
538        let err = s.issue("ops", "kate").unwrap_err();
539        assert!(matches!(err, CredentialError::NotPermitted { .. }));
540    }
541
542    #[test]
543    fn store_empty_allow_list_accepts_anyone() {
544        let s = EmailCredentialStore::new(vec![pwd_account("ops", &[])]);
545        assert!(s.issue("ops", "ana").is_ok());
546        assert!(s.issue("ops", "kate").is_ok());
547    }
548
549    #[test]
550    fn store_list_is_sorted() {
551        let s = EmailCredentialStore::new(vec![
552            pwd_account("b", &[]),
553            pwd_account("a", &[]),
554            pwd_account("c", &[]),
555        ]);
556        assert_eq!(s.list(), vec!["a", "b", "c"]);
557    }
558
559    #[test]
560    fn store_validate_warns_empty_password() {
561        let acct = EmailAccount {
562            auth: EmailAuth::Password {
563                username: "u@x".into(),
564                password: SecretString::new(String::new()),
565            },
566            ..pwd_account("ops", &[])
567        };
568        let s = EmailCredentialStore::new(vec![acct]);
569        let r = s.validate();
570        assert_eq!(r.accounts_ok, 0);
571        assert!(r.warnings.iter().any(|w| w.contains("empty password")));
572    }
573
574    #[test]
575    fn store_validate_warns_empty_oauth_token() {
576        let acct = EmailAccount {
577            auth: EmailAuth::OAuth2Static {
578                username: "u@x".into(),
579                access_token: SecretString::new(String::new()),
580                refresh_token: None,
581                expires_at: None,
582            },
583            ..pwd_account("ops", &[])
584        };
585        let s = EmailCredentialStore::new(vec![acct]);
586        let r = s.validate();
587        assert_eq!(r.accounts_ok, 0);
588        assert!(r.warnings.iter().any(|w| w.contains("empty access_token")));
589    }
590
591    #[test]
592    fn store_missing_instance_errors() {
593        let s = EmailCredentialStore::empty();
594        let err = s.issue("nope", "ana").unwrap_err();
595        assert!(matches!(err, CredentialError::NotFound { .. }));
596    }
597
598    fn write_secret(dir: &Path, instance: &str, body: &str) {
599        let inst_dir = dir.join("email");
600        std::fs::create_dir_all(&inst_dir).unwrap();
601        let path = inst_dir.join(format!("{instance}.toml"));
602        let mut f = std::fs::File::create(&path).unwrap();
603        f.write_all(body.as_bytes()).unwrap();
604    }
605
606    #[test]
607    fn loader_password_account() {
608        let tmp = tempfile::tempdir().unwrap();
609        write_secret(
610            tmp.path(),
611            "ops",
612            r#"
613[auth]
614kind = "password"
615username = "ops@example.com"
616password = "hunter2"
617"#,
618        );
619        let (accs, warns, errs) =
620            load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
621        assert!(errs.is_empty(), "errs={errs:?}");
622        assert!(warns.is_empty());
623        assert_eq!(accs.len(), 1);
624        match &accs[0].auth {
625            EmailAuth::Password { username, password } => {
626                assert_eq!(username, "ops@example.com");
627                assert_eq!(password.expose_secret(), "hunter2");
628            }
629            _ => panic!("expected Password variant"),
630        }
631    }
632
633    #[test]
634    fn loader_oauth2_static_account() {
635        let tmp = tempfile::tempdir().unwrap();
636        write_secret(
637            tmp.path(),
638            "ops",
639            r#"
640[auth]
641kind = "oauth2_static"
642username = "ops@gmail.com"
643access_token = "ya29.fresh"
644refresh_token = "1//rt"
645expires_at = 1735689600
646"#,
647        );
648        let (accs, warns, errs) =
649            load_email_secrets(tmp.path(), &[("ops".into(), "ops@gmail.com".into())]);
650        assert!(errs.is_empty());
651        assert!(warns.is_empty());
652        match &accs[0].auth {
653            EmailAuth::OAuth2Static {
654                access_token,
655                refresh_token,
656                expires_at,
657                ..
658            } => {
659                assert_eq!(access_token.expose_secret(), "ya29.fresh");
660                assert_eq!(refresh_token.as_ref().unwrap().expose_secret(), "1//rt");
661                assert_eq!(*expires_at, Some(1_735_689_600));
662            }
663            _ => panic!("expected OAuth2Static"),
664        }
665    }
666
667    #[test]
668    fn loader_oauth2_google_account() {
669        let tmp = tempfile::tempdir().unwrap();
670        write_secret(
671            tmp.path(),
672            "ops",
673            r#"
674[auth]
675kind = "oauth2_google"
676username = "ops@gmail.com"
677google_account_id = "ops"
678"#,
679        );
680        let (accs, _, errs) =
681            load_email_secrets(tmp.path(), &[("ops".into(), "ops@gmail.com".into())]);
682        assert!(errs.is_empty());
683        assert!(matches!(accs[0].auth, EmailAuth::OAuth2Google { .. }));
684    }
685
686    #[test]
687    fn loader_missing_file_yields_build_error() {
688        let tmp = tempfile::tempdir().unwrap();
689        let (accs, _, errs) =
690            load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
691        assert!(accs.is_empty());
692        assert_eq!(errs.len(), 1);
693        match &errs[0] {
694            BuildError::Credential {
695                channel,
696                source: CredentialError::FileMissing { .. },
697                ..
698            } => assert_eq!(*channel, EMAIL),
699            other => panic!("unexpected error: {other:?}"),
700        }
701    }
702
703    #[test]
704    fn loader_malformed_toml_yields_build_error() {
705        let tmp = tempfile::tempdir().unwrap();
706        write_secret(tmp.path(), "ops", "this is not toml @@@");
707        let (_, _, errs) =
708            load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
709        assert_eq!(errs.len(), 1);
710        match &errs[0] {
711            BuildError::Credential {
712                source: CredentialError::InvalidSecret { .. },
713                ..
714            } => {}
715            other => panic!("unexpected: {other:?}"),
716        }
717    }
718
719    #[test]
720    fn loader_unknown_kind_yields_build_error() {
721        let tmp = tempfile::tempdir().unwrap();
722        write_secret(
723            tmp.path(),
724            "ops",
725            r#"
726[auth]
727kind = "totally_made_up"
728username = "x"
729"#,
730        );
731        let (_, _, errs) =
732            load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
733        assert_eq!(errs.len(), 1);
734        match &errs[0] {
735            BuildError::Credential {
736                source: CredentialError::InvalidSecret { .. },
737                ..
738            } => {}
739            other => panic!("unexpected: {other:?}"),
740        }
741    }
742
743    #[test]
744    fn loader_resolves_env_placeholder() {
745        let tmp = tempfile::tempdir().unwrap();
746        // SAFETY: test is single-threaded for env var; nexo_config's env
747        // resolver reads the live env, so we rely on the standard
748        // ENV_LOCK convention used in load_test.rs. Use a unique name
749        // to avoid collisions with other tests.
750        std::env::set_var("EMAIL_TEST_PASS_48_2", "from-env");
751        write_secret(
752            tmp.path(),
753            "ops",
754            r#"
755[auth]
756kind = "password"
757username = "ops@example.com"
758password = "${EMAIL_TEST_PASS_48_2}"
759"#,
760        );
761        let (accs, _, errs) =
762            load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
763        std::env::remove_var("EMAIL_TEST_PASS_48_2");
764        assert!(errs.is_empty(), "errs={errs:?}");
765        match &accs[0].auth {
766            EmailAuth::Password { password, .. } => {
767                assert_eq!(password.expose_secret(), "from-env");
768            }
769            _ => panic!("expected Password"),
770        }
771    }
772
773    #[tokio::test]
774    async fn resolve_token_password_returns_inline() {
775        let acct = pwd_account("ops", &[]);
776        let google = crate::google::GoogleCredentialStore::empty();
777        let tok = acct.resolve_access_token(&google).await.unwrap();
778        assert_eq!(tok.expose_secret(), "hunter2");
779    }
780
781    #[tokio::test]
782    async fn resolve_token_oauth2_google_unknown_errors() {
783        let acct = EmailAccount {
784            instance: "ops".into(),
785            address: "ops@gmail.com".into(),
786            auth: EmailAuth::OAuth2Google {
787                username: "ops@gmail.com".into(),
788                google_account_id: "missing".into(),
789            },
790            allow_agents: vec![],
791        };
792        let google = crate::google::GoogleCredentialStore::empty();
793        let err = acct.resolve_access_token(&google).await.unwrap_err();
794        assert!(matches!(err, CredentialError::NotFound { .. }));
795    }
796}