use std::collections::HashMap;
use anyhow::{Context, Result, bail};
#[cfg(any(feature = "imap", feature = "smtp"))]
use io_sasl::mechanism::Sasl;
use pimalaya_cli::spinner::Spinner;
use pimalaya_config::secret::SecretResolver;
#[cfg(any(
feature = "imap",
feature = "msgraph",
feature = "smtp",
feature = "dav"
))]
use pimalaya_stream::tls::Tls;
#[cfg(feature = "msgraph")]
use secrecy::SecretString;
#[cfg(any(feature = "imap", feature = "smtp", feature = "dav"))]
use url::Url;
#[cfg(feature = "smtp")]
use crate::config::SmtpConfig;
#[cfg(any(feature = "imap", feature = "smtp", feature = "dav"))]
use crate::config::server_url;
use crate::config::{AccountConfig, SourceBackendConfig, SourceConfig};
#[cfg(feature = "dav")]
use crate::dav::client::DavKind;
pub struct Account {
endpoints: HashMap<String, Result<SourceAccount, String>>,
}
impl Account {
pub fn resolve(config: &AccountConfig) -> Result<Self> {
let endpoints = config.endpoints()?;
let s = Spinner::start("Resolving credentials…");
let mut resolver = SecretResolver::new();
let endpoints: HashMap<_, _> = endpoints
.into_iter()
.map(|(name, config)| {
let resolved = SourceAccount::resolve_with(&name, &config, &mut resolver)
.map_err(|err| format!("{err:#}"));
(name, resolved)
})
.collect();
match endpoints.values().filter(|end| end.is_err()).count() {
0 => s.success("Resolved credentials"),
failed => s.success(format!("Resolved credentials, {failed} endpoint(s) failed")),
}
Ok(Self { endpoints })
}
pub fn get(&self, name: &str) -> Result<SourceAccount> {
match self.endpoints.get(name) {
Some(Ok(account)) => Ok(account.clone()),
Some(Err(err)) => bail!("{err}"),
None => bail!("This account declares no endpoint named {name}"),
}
}
}
#[derive(Clone)]
pub struct SourceAccount {
pub backend: SourceAccountBackend,
#[cfg(feature = "smtp")]
pub smtp: Option<SmtpAccount>,
}
impl SourceAccount {
#[cfg_attr(
not(any(feature = "imap", feature = "msgraph", feature = "dav")),
allow(dead_code)
)]
pub fn resolve(name: &str, config: &SourceConfig) -> Result<Self> {
Self::resolve_with(name, config, &mut SecretResolver::new())
}
fn resolve_with(
name: &str,
config: &SourceConfig,
resolver: &mut SecretResolver,
) -> Result<Self> {
let backend = SourceAccountBackend::resolve(&config.backend, resolver)
.with_context(|| format!("Resolve the credentials of {name}"))?;
#[cfg(feature = "smtp")]
let smtp = config
.smtp
.as_ref()
.map(|smtp| SmtpAccount::resolve_with(smtp, resolver))
.transpose()
.with_context(|| format!("Resolve the send credentials of {name}"))?;
Ok(Self {
backend,
#[cfg(feature = "smtp")]
smtp,
})
}
}
#[derive(Clone)]
pub enum SourceAccountBackend {
#[cfg(feature = "imap")]
Imap(ImapAccount),
#[cfg(feature = "dav")]
Dav(DavAccount),
#[cfg(feature = "msgraph")]
Msgraph(MsgraphAccount),
#[cfg(not(any(feature = "imap", feature = "msgraph", feature = "dav")))]
#[allow(dead_code)]
Unavailable,
}
impl SourceAccountBackend {
#[cfg_attr(
not(any(feature = "imap", feature = "msgraph", feature = "dav")),
allow(unused_variables)
)]
fn resolve(config: &SourceBackendConfig, resolver: &mut SecretResolver) -> Result<Self> {
match config {
#[cfg(feature = "imap")]
SourceBackendConfig::Imap(config) => {
let alpn = config
.alpn
.clone()
.unwrap_or_else(io_imap::client::default_alpn);
let server = server_url(&config.server, "imaps")?;
let sasl = config
.sasl
.clone()
.map(|sasl| {
let host = server.host_str().unwrap_or_default();
let port = server
.port()
.unwrap_or_else(|| io_imap::client::default_port(server.scheme()));
sasl.try_into_sasl(host, port, resolver)
})
.transpose()?;
Ok(Self::Imap(ImapAccount {
server,
tls: config.tls.clone().into_tls(alpn),
starttls: config.starttls,
sasl,
}))
}
#[cfg(feature = "dav")]
SourceBackendConfig::Carddav(config) => Ok(Self::Dav(DavAccount {
kind: DavKind::Card,
server: server_url(&config.server, "https")?,
tls: config.tls.clone().into_tls(config.alpn.clone()),
auth: config.auth.clone().try_into_dav_auth(resolver)?,
})),
#[cfg(feature = "dav")]
SourceBackendConfig::Caldav(config) => Ok(Self::Dav(DavAccount {
kind: DavKind::Cal,
server: server_url(&config.server, "https")?,
tls: config.tls.clone().into_tls(config.alpn.clone()),
auth: config.auth.clone().try_into_dav_auth(resolver)?,
})),
#[cfg(feature = "msgraph")]
SourceBackendConfig::Msgraph(config) => Ok(Self::Msgraph(MsgraphAccount {
token: resolver.resolve(config.auth.token.clone())?,
user_id: config.user_id.clone(),
tls: config.tls.clone().into_tls(config.alpn.clone()),
})),
#[allow(unreachable_patterns)]
_ => bail!(
"This side's backend is not available in this build (rebuild with the matching cargo feature; only the imap, msgraph and dav backends exist for now)"
),
}
}
}
#[cfg(feature = "imap")]
#[derive(Clone)]
pub struct ImapAccount {
pub server: Url,
pub tls: Tls,
pub starttls: bool,
pub sasl: Option<Sasl>,
}
#[cfg(feature = "dav")]
#[derive(Clone)]
pub struct DavAccount {
pub kind: DavKind,
pub server: Url,
pub tls: Tls,
pub auth: io_webdav::rfc4918::WebdavAuth,
}
#[cfg(feature = "msgraph")]
#[derive(Clone)]
pub struct MsgraphAccount {
pub token: SecretString,
pub user_id: String,
pub tls: Tls,
}
#[cfg(feature = "smtp")]
#[derive(Clone)]
pub struct SmtpAccount {
pub server: Url,
pub tls: Tls,
pub starttls: bool,
pub sasl: Option<Sasl>,
}
#[cfg(feature = "smtp")]
impl SmtpAccount {
#[cfg_attr(not(feature = "imap"), allow(dead_code))]
pub fn resolve(config: &SmtpConfig) -> Result<Self> {
Self::resolve_with(config, &mut SecretResolver::new())
}
fn resolve_with(config: &SmtpConfig, resolver: &mut SecretResolver) -> Result<Self> {
let server = server_url(&config.server, "smtps")?;
let alpn = config
.alpn
.clone()
.unwrap_or_else(io_smtp::client::SmtpClientStd::default_alpn);
let sasl = config
.sasl
.clone()
.map(|sasl| {
let host = server.host_str().unwrap_or_default();
let port = server.port().unwrap_or_else(|| {
io_smtp::client::SmtpClientStd::default_port(server.scheme())
});
sasl.try_into_sasl(host, port, resolver)
})
.transpose()?;
Ok(Self {
server,
tls: config.tls.clone().into_tls(alpn),
starttls: config.starttls,
sasl,
})
}
}
#[cfg(all(test, unix, feature = "imap", feature = "smtp", feature = "dav"))]
mod tests {
use std::{env::temp_dir, fs, process};
use super::Account;
use crate::config::AccountConfig;
#[test]
fn one_password_command_named_by_four_endpoints_is_spawned_once() {
let path = temp_dir().join(format!("neverest-resolve-once-{}", process::id()));
let _ = fs::remove_file(&path);
let command = format!("printf x >> {path}; printf s3cr3t", path = path.display());
let config: AccountConfig = toml::from_str(&format!(
r#"
imap.server = "imaps://localhost"
imap.sasl.plain.username = "user"
imap.sasl.plain.password.command = "{command}"
smtp.server = "smtps://localhost"
smtp.sasl.plain.username = "user"
smtp.sasl.plain.password.command = "{command}"
carddav.server = "https://localhost"
carddav.auth.basic.username = "user"
carddav.auth.basic.password.command = "{command}"
caldav.server = "https://localhost"
caldav.auth.basic.username = "user"
caldav.auth.basic.password.command = "{command}"
"#
))
.unwrap();
Account::resolve(&config).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"x");
fs::remove_file(&path).unwrap();
}
}