1use 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 expires_at: Option<i64>,
53 },
54 OAuth2Google {
55 username: String,
56 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 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 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 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#[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
373pub 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 let out = EmailAccount::xoauth2_sasl(
512 "someuser@example.com",
513 "vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==",
514 );
515 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 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}