use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use base64::Engine as _;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use crate::error::{BuildError, CredentialError};
use crate::handle::{Channel, CredentialHandle, EMAIL};
use crate::store::{CredentialStore, ValidationReport};
#[derive(Clone)]
pub struct EmailAccount {
pub instance: String,
pub address: String,
pub auth: EmailAuth,
pub allow_agents: Vec<String>,
}
#[derive(Clone)]
pub enum EmailAuth {
Password {
username: String,
password: SecretString,
},
OAuth2Static {
username: String,
access_token: SecretString,
refresh_token: Option<SecretString>,
expires_at: Option<i64>,
},
OAuth2Google {
username: String,
google_account_id: String,
},
}
impl std::fmt::Debug for EmailAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Password { username, .. } => f
.debug_struct("Password")
.field("username", username)
.field("password", &"<redacted>")
.finish(),
Self::OAuth2Static {
username,
refresh_token,
expires_at,
..
} => f
.debug_struct("OAuth2Static")
.field("username", username)
.field("access_token", &"<redacted>")
.field(
"refresh_token",
&refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("expires_at", expires_at)
.finish(),
Self::OAuth2Google {
username,
google_account_id,
} => f
.debug_struct("OAuth2Google")
.field("username", username)
.field("google_account_id", google_account_id)
.finish(),
}
}
}
impl std::fmt::Debug for EmailAccount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailAccount")
.field("instance", &self.instance)
.field("address", &self.address)
.field("auth", &self.auth)
.field("allow_agents", &self.allow_agents)
.finish()
}
}
impl EmailAccount {
pub fn xoauth2_sasl(username: &str, access_token: &str) -> String {
let raw = format!("user={username}\x01auth=Bearer {access_token}\x01\x01");
base64::engine::general_purpose::STANDARD.encode(raw)
}
pub async fn resolve_access_token(
&self,
google: &crate::google::GoogleCredentialStore,
) -> Result<SecretString, CredentialError> {
match &self.auth {
EmailAuth::Password { password, .. } => Ok(password.clone()),
EmailAuth::OAuth2Static { access_token, .. } => Ok(access_token.clone()),
EmailAuth::OAuth2Google {
google_account_id, ..
} => {
let account = google.account(google_account_id).cloned().ok_or_else(|| {
CredentialError::NotFound {
channel: crate::handle::GOOGLE,
account: google_account_id.clone(),
}
})?;
let handle =
CredentialHandle::new(crate::handle::GOOGLE, &account.id, "<email-resolve>");
let lock = google
.refresh_lock(&handle)
.ok_or(CredentialError::NotFound {
channel: crate::handle::GOOGLE,
account: google_account_id.clone(),
})?;
let _guard = lock.lock().await;
let token = std::fs::read_to_string(&account.token_path).map_err(|e| {
CredentialError::Unreadable {
path: account.token_path.clone(),
source: e,
}
})?;
Ok(SecretString::new(token.trim().to_string()))
}
}
}
fn auth_warnings(&self) -> Vec<String> {
let mut out = Vec::new();
match &self.auth {
EmailAuth::Password { username, password } => {
if username.trim().is_empty() {
out.push(format!(
"email instance '{}': password auth has empty username",
self.instance
));
}
if password.expose_secret().is_empty() {
out.push(format!(
"email instance '{}': password auth has empty password",
self.instance
));
}
}
EmailAuth::OAuth2Static {
username,
access_token,
..
} => {
if username.trim().is_empty() {
out.push(format!(
"email instance '{}': oauth2_static auth has empty username",
self.instance
));
}
if access_token.expose_secret().is_empty() {
out.push(format!(
"email instance '{}': oauth2_static auth has empty access_token",
self.instance
));
}
}
EmailAuth::OAuth2Google {
username,
google_account_id,
} => {
if username.trim().is_empty() {
out.push(format!(
"email instance '{}': oauth2_google auth has empty username",
self.instance
));
}
if google_account_id.trim().is_empty() {
out.push(format!(
"email instance '{}': oauth2_google auth has empty google_account_id",
self.instance
));
}
}
}
out
}
}
#[derive(Debug, Clone)]
pub struct EmailCredentialStore {
accounts: Arc<HashMap<String, EmailAccount>>,
}
impl EmailCredentialStore {
pub fn new(accounts: Vec<EmailAccount>) -> Self {
let mut map = HashMap::with_capacity(accounts.len());
for a in accounts {
map.insert(a.instance.clone(), a);
}
Self {
accounts: Arc::new(map),
}
}
pub fn empty() -> Self {
Self {
accounts: Arc::new(HashMap::new()),
}
}
pub fn account(&self, instance: &str) -> Option<&EmailAccount> {
self.accounts.get(instance)
}
}
impl CredentialStore for EmailCredentialStore {
type Account = EmailAccount;
fn channel(&self) -> Channel {
EMAIL
}
fn get(&self, handle: &CredentialHandle) -> Result<Self::Account, CredentialError> {
let id = handle.account_id_raw();
self.accounts
.get(id)
.cloned()
.ok_or_else(|| CredentialError::NotFound {
channel: EMAIL,
account: id.to_string(),
})
}
fn issue(&self, account_id: &str, agent_id: &str) -> Result<CredentialHandle, CredentialError> {
let account = self
.accounts
.get(account_id)
.ok_or_else(|| CredentialError::NotFound {
channel: EMAIL,
account: account_id.to_string(),
})?;
if !account.allow_agents.is_empty() && !account.allow_agents.iter().any(|a| a == agent_id) {
let handle = CredentialHandle::new(EMAIL, account_id, agent_id);
return Err(CredentialError::NotPermitted {
channel: EMAIL,
agent: agent_id.to_string(),
fp: handle.fingerprint(),
});
}
Ok(CredentialHandle::new(EMAIL, account_id, agent_id))
}
fn list(&self) -> Vec<String> {
let mut ids: Vec<_> = self.accounts.keys().cloned().collect();
ids.sort();
ids
}
fn allow_agents(&self, account_id: &str) -> Vec<String> {
self.accounts
.get(account_id)
.map(|a| a.allow_agents.clone())
.unwrap_or_default()
}
fn validate(&self) -> ValidationReport {
let mut report = ValidationReport::default();
for (id, a) in self.accounts.iter() {
let warnings = a.auth_warnings();
if warnings.is_empty() {
report.accounts_ok += 1;
} else {
let _ = id;
for w in warnings {
report.warnings.push(w);
}
}
}
report
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct EmailSecretFile {
auth: EmailAuthFile,
#[serde(default)]
allow_agents: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum EmailAuthFile {
Password {
username: String,
password: String,
},
Oauth2Static {
username: String,
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_at: Option<i64>,
},
Oauth2Google {
username: String,
google_account_id: String,
},
}
impl From<EmailAuthFile> for EmailAuth {
fn from(f: EmailAuthFile) -> Self {
match f {
EmailAuthFile::Password { username, password } => EmailAuth::Password {
username,
password: SecretString::new(password),
},
EmailAuthFile::Oauth2Static {
username,
access_token,
refresh_token,
expires_at,
} => EmailAuth::OAuth2Static {
username,
access_token: SecretString::new(access_token),
refresh_token: refresh_token.map(SecretString::new),
expires_at,
},
EmailAuthFile::Oauth2Google {
username,
google_account_id,
} => EmailAuth::OAuth2Google {
username,
google_account_id,
},
}
}
}
pub fn load_email_secrets(
secrets_dir: &Path,
declared: &[(String, String)],
) -> (Vec<EmailAccount>, Vec<String>, Vec<BuildError>) {
let mut accounts = Vec::with_capacity(declared.len());
let mut warnings = Vec::new();
let mut errors = Vec::new();
for (instance, address) in declared {
let path = secrets_dir.join("email").join(format!("{instance}.toml"));
if !path.exists() {
errors.push(BuildError::Credential {
channel: EMAIL,
instance: instance.clone(),
source: CredentialError::FileMissing { path: path.clone() },
});
continue;
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
errors.push(BuildError::Credential {
channel: EMAIL,
instance: instance.clone(),
source: CredentialError::Unreadable {
path: path.clone(),
source: e,
},
});
continue;
}
};
let resolved =
match nexo_config::env::resolve_placeholders(&raw, &format!("email/{instance}.toml")) {
Ok(s) => s,
Err(e) => {
errors.push(BuildError::Credential {
channel: EMAIL,
instance: instance.clone(),
source: CredentialError::InvalidSecret {
path: path.clone(),
message: e.to_string(),
},
});
continue;
}
};
let parsed: EmailSecretFile = match toml::from_str(&resolved) {
Ok(p) => p,
Err(e) => {
errors.push(BuildError::Credential {
channel: EMAIL,
instance: instance.clone(),
source: CredentialError::InvalidSecret {
path: path.clone(),
message: e.message().to_string(),
},
});
continue;
}
};
let auth: EmailAuth = parsed.auth.into();
let account = EmailAccount {
instance: instance.clone(),
address: address.clone(),
auth,
allow_agents: parsed.allow_agents,
};
warnings.extend(account.auth_warnings());
accounts.push(account);
}
(accounts, warnings, errors)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn pwd_account(instance: &str, allow: &[&str]) -> EmailAccount {
EmailAccount {
instance: instance.into(),
address: format!("{instance}@example.com"),
auth: EmailAuth::Password {
username: format!("{instance}@example.com"),
password: SecretString::new("hunter2".into()),
},
allow_agents: allow.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn auth_debug_does_not_leak_password() {
let auth = EmailAuth::Password {
username: "u@x".into(),
password: SecretString::new("super-secret-pw".into()),
};
let rendered = format!("{auth:?}");
assert!(!rendered.contains("super-secret-pw"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn auth_debug_does_not_leak_access_token() {
let auth = EmailAuth::OAuth2Static {
username: "u@x".into(),
access_token: SecretString::new("ya29.tk".into()),
refresh_token: Some(SecretString::new("rt-1".into())),
expires_at: Some(1_700_000_000),
};
let rendered = format!("{auth:?}");
assert!(!rendered.contains("ya29.tk"));
assert!(!rendered.contains("rt-1"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn account_debug_does_not_leak_secrets() {
let acct = pwd_account("ops", &[]);
let rendered = format!("{acct:?}");
assert!(!rendered.contains("hunter2"));
assert!(rendered.contains("<redacted>"));
assert!(rendered.contains("ops"));
}
#[test]
fn xoauth2_sasl_matches_rfc7628_fixture() {
let out = EmailAccount::xoauth2_sasl(
"someuser@example.com",
"vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==",
);
let decoded = base64::engine::general_purpose::STANDARD
.decode(out)
.unwrap();
let s = String::from_utf8(decoded).unwrap();
assert_eq!(
s,
"user=someuser@example.com\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==\x01\x01"
);
}
#[test]
fn store_issue_returns_handle_when_permitted() {
let s = EmailCredentialStore::new(vec![pwd_account("ops", &["ana"])]);
let h = s.issue("ops", "ana").unwrap();
assert_eq!(h.channel(), EMAIL);
assert_eq!(h.agent_id(), "ana");
}
#[test]
fn store_issue_rejects_non_allowed_agent() {
let s = EmailCredentialStore::new(vec![pwd_account("ops", &["ana"])]);
let err = s.issue("ops", "kate").unwrap_err();
assert!(matches!(err, CredentialError::NotPermitted { .. }));
}
#[test]
fn store_empty_allow_list_accepts_anyone() {
let s = EmailCredentialStore::new(vec![pwd_account("ops", &[])]);
assert!(s.issue("ops", "ana").is_ok());
assert!(s.issue("ops", "kate").is_ok());
}
#[test]
fn store_list_is_sorted() {
let s = EmailCredentialStore::new(vec![
pwd_account("b", &[]),
pwd_account("a", &[]),
pwd_account("c", &[]),
]);
assert_eq!(s.list(), vec!["a", "b", "c"]);
}
#[test]
fn store_validate_warns_empty_password() {
let acct = EmailAccount {
auth: EmailAuth::Password {
username: "u@x".into(),
password: SecretString::new(String::new()),
},
..pwd_account("ops", &[])
};
let s = EmailCredentialStore::new(vec![acct]);
let r = s.validate();
assert_eq!(r.accounts_ok, 0);
assert!(r.warnings.iter().any(|w| w.contains("empty password")));
}
#[test]
fn store_validate_warns_empty_oauth_token() {
let acct = EmailAccount {
auth: EmailAuth::OAuth2Static {
username: "u@x".into(),
access_token: SecretString::new(String::new()),
refresh_token: None,
expires_at: None,
},
..pwd_account("ops", &[])
};
let s = EmailCredentialStore::new(vec![acct]);
let r = s.validate();
assert_eq!(r.accounts_ok, 0);
assert!(r.warnings.iter().any(|w| w.contains("empty access_token")));
}
#[test]
fn store_missing_instance_errors() {
let s = EmailCredentialStore::empty();
let err = s.issue("nope", "ana").unwrap_err();
assert!(matches!(err, CredentialError::NotFound { .. }));
}
fn write_secret(dir: &Path, instance: &str, body: &str) {
let inst_dir = dir.join("email");
std::fs::create_dir_all(&inst_dir).unwrap();
let path = inst_dir.join(format!("{instance}.toml"));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
}
#[test]
fn loader_password_account() {
let tmp = tempfile::tempdir().unwrap();
write_secret(
tmp.path(),
"ops",
r#"
[auth]
kind = "password"
username = "ops@example.com"
password = "hunter2"
"#,
);
let (accs, warns, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
assert!(errs.is_empty(), "errs={errs:?}");
assert!(warns.is_empty());
assert_eq!(accs.len(), 1);
match &accs[0].auth {
EmailAuth::Password { username, password } => {
assert_eq!(username, "ops@example.com");
assert_eq!(password.expose_secret(), "hunter2");
}
_ => panic!("expected Password variant"),
}
}
#[test]
fn loader_oauth2_static_account() {
let tmp = tempfile::tempdir().unwrap();
write_secret(
tmp.path(),
"ops",
r#"
[auth]
kind = "oauth2_static"
username = "ops@gmail.com"
access_token = "ya29.fresh"
refresh_token = "1//rt"
expires_at = 1735689600
"#,
);
let (accs, warns, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@gmail.com".into())]);
assert!(errs.is_empty());
assert!(warns.is_empty());
match &accs[0].auth {
EmailAuth::OAuth2Static {
access_token,
refresh_token,
expires_at,
..
} => {
assert_eq!(access_token.expose_secret(), "ya29.fresh");
assert_eq!(refresh_token.as_ref().unwrap().expose_secret(), "1//rt");
assert_eq!(*expires_at, Some(1_735_689_600));
}
_ => panic!("expected OAuth2Static"),
}
}
#[test]
fn loader_oauth2_google_account() {
let tmp = tempfile::tempdir().unwrap();
write_secret(
tmp.path(),
"ops",
r#"
[auth]
kind = "oauth2_google"
username = "ops@gmail.com"
google_account_id = "ops"
"#,
);
let (accs, _, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@gmail.com".into())]);
assert!(errs.is_empty());
assert!(matches!(accs[0].auth, EmailAuth::OAuth2Google { .. }));
}
#[test]
fn loader_missing_file_yields_build_error() {
let tmp = tempfile::tempdir().unwrap();
let (accs, _, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
assert!(accs.is_empty());
assert_eq!(errs.len(), 1);
match &errs[0] {
BuildError::Credential {
channel,
source: CredentialError::FileMissing { .. },
..
} => assert_eq!(*channel, EMAIL),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn loader_malformed_toml_yields_build_error() {
let tmp = tempfile::tempdir().unwrap();
write_secret(tmp.path(), "ops", "this is not toml @@@");
let (_, _, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
assert_eq!(errs.len(), 1);
match &errs[0] {
BuildError::Credential {
source: CredentialError::InvalidSecret { .. },
..
} => {}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn loader_unknown_kind_yields_build_error() {
let tmp = tempfile::tempdir().unwrap();
write_secret(
tmp.path(),
"ops",
r#"
[auth]
kind = "totally_made_up"
username = "x"
"#,
);
let (_, _, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
assert_eq!(errs.len(), 1);
match &errs[0] {
BuildError::Credential {
source: CredentialError::InvalidSecret { .. },
..
} => {}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn loader_resolves_env_placeholder() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("EMAIL_TEST_PASS_48_2", "from-env");
write_secret(
tmp.path(),
"ops",
r#"
[auth]
kind = "password"
username = "ops@example.com"
password = "${EMAIL_TEST_PASS_48_2}"
"#,
);
let (accs, _, errs) =
load_email_secrets(tmp.path(), &[("ops".into(), "ops@example.com".into())]);
std::env::remove_var("EMAIL_TEST_PASS_48_2");
assert!(errs.is_empty(), "errs={errs:?}");
match &accs[0].auth {
EmailAuth::Password { password, .. } => {
assert_eq!(password.expose_secret(), "from-env");
}
_ => panic!("expected Password"),
}
}
#[tokio::test]
async fn resolve_token_password_returns_inline() {
let acct = pwd_account("ops", &[]);
let google = crate::google::GoogleCredentialStore::empty();
let tok = acct.resolve_access_token(&google).await.unwrap();
assert_eq!(tok.expose_secret(), "hunter2");
}
#[tokio::test]
async fn resolve_token_oauth2_google_unknown_errors() {
let acct = EmailAccount {
instance: "ops".into(),
address: "ops@gmail.com".into(),
auth: EmailAuth::OAuth2Google {
username: "ops@gmail.com".into(),
google_account_id: "missing".into(),
},
allow_agents: vec![],
};
let google = crate::google::GoogleCredentialStore::empty();
let err = acct.resolve_access_token(&google).await.unwrap_err();
assert!(matches!(err, CredentialError::NotFound { .. }));
}
}