use std::{collections::HashMap, path::PathBuf};
use anyhow::{Error, Result, anyhow, bail};
use crossterm::style::Color;
use io_sasl::{
login::SaslLoginCreds, mechanism::Sasl, rfc4505::anonymous::SaslAnonymousCreds,
rfc4616::plain::SaslPlainCreds, rfc5801::SaslGs2ChannelBinding, rfc5802::SaslScramCreds,
rfc7628::oauthbearer::SaslOauthbearerCreds, xoauth2::SaslXoauth2Creds,
};
use pimalaya_cli::table::ContentArrangement;
use pimalaya_config::{
secret::{Secret, SecretResolver},
toml::{TomlConfig, shell_expanded_path, shell_expanded_string},
};
use pimalaya_stream::{
proxy::{Proxy, ProxyAuth},
tls::{Rustls, RustlsCrypto, Tls, TlsProvider},
};
use secrecy::SecretString;
use serde::{Deserialize, Deserializer, Serialize, de::IgnoredAny};
use url::Url;
use crate::backend::Backend;
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default()
}
fn opt_shell_expanded_path<'de, D: Deserializer<'de>>(de: D) -> Result<Option<PathBuf>, D::Error> {
shell_expanded_path(de).map(Some)
}
fn is_default_imap_alpn(alpn: &[String]) -> bool {
alpn == default_imap_alpn().as_slice()
}
fn is_default_smtp_alpn(alpn: &[String]) -> bool {
alpn == default_smtp_alpn().as_slice()
}
fn is_default_sieve_alpn(alpn: &[String]) -> bool {
alpn.is_empty()
}
fn is_default_jmap_alpn(alpn: &[String]) -> bool {
alpn == default_jmap_alpn().as_slice()
}
pub(crate) fn default_imap_alpn() -> Vec<String> {
vec![String::from("imap")]
}
pub(crate) fn default_smtp_alpn() -> Vec<String> {
vec![String::from("smtp")]
}
pub(crate) fn default_sieve_alpn() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_jmap_alpn() -> Vec<String> {
vec![String::from("http/1.1")]
}
fn is_default_gmail_alpn(alpn: &[String]) -> bool {
alpn == default_gmail_alpn().as_slice()
}
fn is_default_msgraph_alpn(alpn: &[String]) -> bool {
alpn == default_msgraph_alpn().as_slice()
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
#[serde(alias = "from-name")]
pub display_name: Option<String>,
pub signature: Option<String>,
pub signature_delim: Option<String>,
#[serde(default, deserialize_with = "opt_shell_expanded_path")]
pub downloads_dir: Option<PathBuf>,
#[serde(default)]
pub table: TableConfig,
#[serde(default)]
pub envelope: EnvelopeConfig,
#[serde(default)]
pub mailbox: MailboxConfig,
#[serde(default)]
pub attachment: AttachmentConfig,
#[serde(default)]
pub message: MessageConfig,
#[serde(default)]
pub account: AccountListingConfig,
pub accounts: HashMap<String, AccountConfig>,
}
impl TomlConfig for Config {
type Account = AccountConfig;
fn project_name() -> &'static str {
env!("CARGO_PKG_NAME")
}
fn take_named_account(&mut self, name: &str) -> Option<(String, Self::Account)> {
let (name, mut account) = self.accounts.remove_entry(name)?;
account.inherit_proxy();
Some((name, account))
}
fn take_default_account(&mut self) -> Option<(String, Self::Account)> {
let name = self
.accounts
.iter()
.find_map(|(name, account)| account.default.then(|| name.clone()))?;
self.take_named_account(&name)
}
}
const RENDER_ORDER: [&str; 21] = [
"default",
"email",
"display-name",
"signature",
"signature-delim",
"proxy",
"imap",
"jmap",
"gmail",
"msgraph",
"maildir",
"m2dir",
"mbox",
"pimdir",
"smtp",
"sieve",
"mailbox",
"envelope",
"attachment",
"message",
"table",
];
impl AccountConfig {
fn inherit_proxy(&mut self) {
let Some(proxy) = &self.proxy else {
return;
};
let slots = [
self.imap.as_mut().map(|c| &mut c.proxy),
self.jmap.as_mut().map(|c| &mut c.proxy),
self.gmail.as_mut().map(|c| &mut c.proxy),
self.msgraph.as_mut().map(|c| &mut c.proxy),
self.smtp.as_mut().map(|c| &mut c.proxy),
self.sieve.as_mut().map(|c| &mut c.proxy),
];
for slot in slots.into_iter().flatten() {
slot.get_or_insert_with(|| proxy.clone());
}
}
pub fn no_backend_error(&self, name: &str, backend: Backend, supported: &[Backend]) -> Error {
let reason = match backend {
Backend::Auto => {
let supported: Vec<String> = supported.iter().map(|b| format!("`{b}`")).collect();
let supported = supported.join(", ");
format!("Account `{name}` configures no supported backend ({supported})")
}
backend if !supported.contains(&backend) => {
format!("Backend `{backend}` is not supported by this command or build")
}
backend => format!("Account `{name}` has no `{backend}` block"),
};
if self.backend.is_some() {
anyhow!("{reason}: its v1 `backend` table is ignored since v2, see MIGRATION.md")
} else {
anyhow!(reason)
}
}
pub fn render(&self, name: &str) -> Result<String> {
#[derive(Serialize)]
struct AccountDocument<'a> {
accounts: HashMap<&'a str, &'a AccountConfig>,
}
let document = AccountDocument {
accounts: HashMap::from([(name, self)]),
};
let rendered = pimalaya_config::toml::to_string(&document)?;
let (header, body) = match rendered.split_once('\n') {
Some((header, body)) => (header, body),
None => return Ok(rendered),
};
let mut groups: Vec<(String, Vec<&str>)> = Vec::new();
for line in body.lines().filter(|line| !line.trim().is_empty()) {
let key = line.split(['.', ' ']).next().unwrap_or(line).to_string();
match groups.iter_mut().find(|(name, _)| *name == key) {
Some((_, lines)) => lines.push(line),
None => groups.push((key, vec![line])),
}
}
groups.sort_by_key(|(key, _)| {
RENDER_ORDER
.iter()
.position(|known| known == key)
.unwrap_or(RENDER_ORDER.len())
});
let mut document = format!("{header}\n");
for (index, (key, mut lines)) in groups.into_iter().enumerate() {
if index > 0 {
document.push('\n');
}
let server = format!("{key}.server ");
lines.sort_by_key(|line| !line.starts_with(&server));
for line in lines {
document.push_str(line);
document.push('\n');
}
}
Ok(document)
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct AccountConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub default: bool,
#[serde(alias = "from")]
pub email: Option<String>,
#[serde(alias = "from-name")]
pub display_name: Option<String>,
pub signature: Option<String>,
pub signature_delim: Option<String>,
#[serde(default, deserialize_with = "opt_shell_expanded_path")]
pub downloads_dir: Option<PathBuf>,
#[serde(default)]
pub table: TableConfig,
#[serde(default)]
pub envelope: EnvelopeConfig,
#[serde(default)]
pub mailbox: MailboxConfig,
#[serde(default)]
pub attachment: AttachmentConfig,
#[serde(default)]
pub message: MessageConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[allow(unused)]
pub imap: Option<ImapConfig>,
#[allow(unused)]
pub jmap: Option<JmapConfig>,
#[allow(unused)]
pub gmail: Option<GmailConfig>,
#[allow(unused)]
pub msgraph: Option<MsgraphConfig>,
#[allow(unused)]
pub maildir: Option<MaildirConfig>,
#[allow(unused)]
pub m2dir: Option<M2dirConfig>,
#[allow(unused)]
pub mbox: Option<MboxConfig>,
#[allow(unused)]
pub pimdir: Option<PimdirConfig>,
#[allow(unused)]
pub smtp: Option<SmtpConfig>,
#[allow(unused)]
pub sieve: Option<SieveConfig>,
#[serde(default, skip_serializing)]
pub backend: Option<IgnoredAny>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct EnvelopeConfig {
#[serde(default)]
pub list: EnvelopeListConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MailboxConfig {
#[serde(default, rename = "alias", alias = "aliases")]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub list: MailboxListConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MailboxListConfig {
#[serde(default)]
pub table: MailboxListTableConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MailboxListTableConfig {
pub id_color: Option<Color>,
pub name_color: Option<Color>,
pub role_color: Option<Color>,
pub total_color: Option<Color>,
pub unread_color: Option<Color>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct MessageConfig {
#[serde(default)]
pub send: MessageSendConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct MessageSendConfig {
pub save_copy: Option<SaveCopyConfig>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum SaveCopyConfig {
Enabled(bool),
Mailbox(String),
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AttachmentConfig {
#[serde(default)]
pub list: AttachmentListConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AttachmentListConfig {
#[serde(default)]
pub table: AttachmentListTableConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AttachmentListTableConfig {
pub id_color: Option<Color>,
pub filename_color: Option<Color>,
pub type_color: Option<Color>,
pub size_color: Option<Color>,
pub inline_color: Option<Color>,
pub path_color: Option<Color>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AccountListingConfig {
#[serde(default)]
pub list: AccountListingListConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AccountListingListConfig {
#[serde(default)]
pub table: AccountListingTableConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AccountListingTableConfig {
pub name_color: Option<Color>,
pub backends_color: Option<Color>,
pub default_color: Option<Color>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct EnvelopeListConfig {
pub datetime_fmt: Option<String>,
pub datetime_local_tz: Option<bool>,
pub datetime_relative: Option<bool>,
pub page_size: Option<u32>,
#[serde(default)]
pub table: EnvelopeListTableConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct EnvelopeListTableConfig {
pub unseen_char: Option<char>,
pub replied_char: Option<char>,
pub flagged_char: Option<char>,
pub attachment_char: Option<char>,
pub id_color: Option<Color>,
pub flags_color: Option<Color>,
pub att_color: Option<Color>,
pub subject_color: Option<Color>,
pub from_color: Option<Color>,
pub to_color: Option<Color>,
pub date_color: Option<Color>,
pub size_color: Option<Color>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct TableConfig {
pub preset: Option<String>,
pub arrangement: Option<TableArrangementConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum TableArrangementConfig {
#[default]
Dynamic,
DynamicFullWidth,
Disabled,
}
impl From<TableArrangementConfig> for ContentArrangement {
fn from(arrangement: TableArrangementConfig) -> Self {
match arrangement {
TableArrangementConfig::Dynamic => ContentArrangement::Dynamic,
TableArrangementConfig::DynamicFullWidth => ContentArrangement::DynamicFullWidth,
TableArrangementConfig::Disabled => ContentArrangement::Disabled,
}
}
}
pub fn parse_server(server: &str, default_scheme: &str, allowed: &[&str]) -> Result<Url> {
let url = if server.contains("://") {
Url::parse(server)?
} else {
Url::parse(&format!("{default_scheme}://{server}"))?
};
let scheme = url.scheme();
if !allowed.contains(&scheme) {
bail!("Invalid server scheme `{scheme}`: expected one of {allowed:?}");
}
Ok(url)
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ImapConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(default, skip_serializing_if = "is_default")]
pub starttls: bool,
#[serde(
default = "default_imap_alpn",
skip_serializing_if = "is_default_imap_alpn"
)]
pub alpn: Vec<String>,
pub sasl: Option<SaslConfig>,
#[serde(default, skip_serializing_if = "is_default")]
pub sasl_ir: Option<bool>,
#[serde(default)]
pub id: ImapIdConfig,
#[serde(default)]
pub sort: ImapSortConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ImapSortConfig {
pub fallback: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ImapIdConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub auto: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub fields: HashMap<String, bool>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum MaildirKeywordHeaderConfig {
XKeywords,
XLabel,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MaildirKeywordsConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub dovecot: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<MaildirKeywordHeaderConfig>,
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MaildirConfig {
#[serde(deserialize_with = "shell_expanded_path")]
pub root: PathBuf,
#[serde(default)]
pub keywords: MaildirKeywordsConfig,
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct M2dirConfig {
#[serde(deserialize_with = "shell_expanded_path")]
pub root: PathBuf,
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MboxConfig {
#[serde(deserialize_with = "shell_expanded_path")]
pub root: PathBuf,
#[serde(
default,
deserialize_with = "opt_shell_expanded_path",
skip_serializing_if = "Option::is_none"
)]
pub inbox: Option<PathBuf>,
#[serde(default, skip_serializing_if = "is_default")]
pub format: MboxFormatConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub thunderbird: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub lock: MboxLockConfig,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MboxFormatConfig {
Mboxo,
#[default]
Mboxrd,
Mboxcl,
Mboxcl2,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MboxLockConfig {
#[serde(default = "default_true")]
pub dotlock: bool,
#[serde(default = "default_true")]
pub fcntl: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
}
impl Default for MboxLockConfig {
fn default() -> Self {
Self {
dotlock: true,
fcntl: true,
timeout: None,
}
}
}
fn default_true() -> bool {
true
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct PimdirConfig {
#[serde(deserialize_with = "shell_expanded_path")]
pub root: PathBuf,
#[serde(default)]
pub account: Option<String>,
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SmtpConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(default, skip_serializing_if = "is_default")]
pub starttls: bool,
#[serde(
default = "default_smtp_alpn",
skip_serializing_if = "is_default_smtp_alpn"
)]
pub alpn: Vec<String>,
pub sasl: Option<SaslConfig>,
}
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SieveConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(default, skip_serializing_if = "is_default")]
pub starttls: Option<bool>,
#[serde(
default = "default_sieve_alpn",
skip_serializing_if = "is_default_sieve_alpn"
)]
pub alpn: Vec<String>,
#[serde(default, skip_serializing_if = "is_default")]
pub allow_cleartext_auth: bool,
pub sasl: Option<SaslConfig>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ProxyConfig {
pub url: String,
pub username: Option<String>,
pub password: Option<Secret>,
}
impl ProxyConfig {
pub fn resolve(config: Option<Self>, resolver: &mut SecretResolver) -> Result<Proxy> {
match config {
Some(config) => config.try_into_proxy(resolver),
None => Ok(Proxy::System),
}
}
pub fn try_into_proxy(self, resolver: &mut SecretResolver) -> Result<Proxy> {
let mut proxy = Proxy::from_url(&self.url)?;
let auth = match (self.username, self.password) {
(None, None) => return Ok(proxy),
(None, Some(_)) => bail!("Proxy password requires a username"),
(Some(user), pass) => ProxyAuth {
user,
pass: match pass {
Some(pass) => resolver.resolve(pass)?,
None => SecretString::default(),
},
},
};
match &mut proxy {
Proxy::Socks5 { auth: slot, .. } | Proxy::Http { auth: slot, .. } => {
*slot = Some(auth);
}
Proxy::None | Proxy::System => {}
}
Ok(proxy)
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct TlsConfig {
pub provider: Option<TlsProviderConfig>,
#[serde(default)]
pub rustls: RustlsConfig,
#[serde(default, deserialize_with = "opt_shell_expanded_path")]
pub cert: Option<PathBuf>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum TlsProviderConfig {
Rustls,
NativeTls,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct RustlsConfig {
pub crypto: Option<RustlsCryptoConfig>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum RustlsCryptoConfig {
Aws,
Ring,
}
impl TlsConfig {
pub fn into_tls(self, alpn: Vec<String>) -> Tls {
Tls {
provider: self.provider.map(|p| match p {
TlsProviderConfig::Rustls => TlsProvider::Rustls,
TlsProviderConfig::NativeTls => TlsProvider::NativeTls,
}),
rustls: Rustls {
crypto: self.rustls.crypto.map(|c| match c {
RustlsCryptoConfig::Aws => RustlsCrypto::Aws,
RustlsCryptoConfig::Ring => RustlsCrypto::Ring,
}),
alpn,
},
cert: self.cert,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum SaslConfig {
Anonymous(SaslAnonymousConfig),
Login(SaslLoginConfig),
Plain(SaslPlainConfig),
Oauthbearer(SaslOauthbearerConfig),
Xoauth2(SaslXoauth2Config),
#[serde(rename = "scram-sha-256")]
ScramSha256(SaslScramSha256Config),
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslAnonymousConfig {
pub message: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslLoginConfig {
#[serde(deserialize_with = "shell_expanded_string")]
pub username: String,
pub password: Secret,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslPlainConfig {
pub authzid: Option<String>,
#[serde(deserialize_with = "shell_expanded_string")]
#[serde(alias = "username")]
pub authcid: String,
#[serde(alias = "password")]
pub passwd: Secret,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslOauthbearerConfig {
#[serde(deserialize_with = "shell_expanded_string")]
pub username: String,
pub token: Secret,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslXoauth2Config {
#[serde(deserialize_with = "shell_expanded_string")]
pub username: String,
pub token: Secret,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SaslScramSha256Config {
#[serde(deserialize_with = "shell_expanded_string")]
pub username: String,
pub password: Secret,
}
impl SaslConfig {
pub fn try_into_sasl(
self,
host: impl ToString,
port: u16,
resolver: &mut SecretResolver,
) -> Result<Sasl> {
Ok(match self {
SaslConfig::Anonymous(c) => Sasl::Anonymous(SaslAnonymousCreds { message: c.message }),
SaslConfig::Login(c) => Sasl::Login(SaslLoginCreds {
username: c.username,
password: resolver.resolve(c.password)?,
}),
SaslConfig::Plain(c) => Sasl::Plain(SaslPlainCreds {
authzid: c.authzid,
authcid: c.authcid,
passwd: resolver.resolve(c.passwd)?,
}),
SaslConfig::Oauthbearer(c) => Sasl::Oauthbearer(SaslOauthbearerCreds {
username: c.username,
host: host.to_string(),
port,
token: resolver.resolve(c.token)?,
}),
SaslConfig::Xoauth2(c) => Sasl::Xoauth2(SaslXoauth2Creds {
username: c.username,
token: resolver.resolve(c.token)?,
}),
SaslConfig::ScramSha256(c) => Sasl::ScramSha256(SaslScramCreds {
username: c.username,
password: resolver.resolve(c.password)?,
nonce: Vec::new(),
channel_binding: SaslGs2ChannelBinding::Unsupported,
}),
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct JmapConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(
default = "default_jmap_alpn",
skip_serializing_if = "is_default_jmap_alpn"
)]
pub alpn: Vec<String>,
pub auth: JmapAuthConfig,
pub identity_id: Option<String>,
pub drafts_mailbox_id: Option<String>,
pub sent_mailbox_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum JmapAuthConfig {
Header(Secret),
Bearer {
token: Secret,
},
Basic {
#[serde(deserialize_with = "shell_expanded_string")]
username: String,
password: Secret,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct GmailConfig {
#[serde(default = "default_gmail_user_id")]
pub user_id: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(
default = "default_gmail_alpn",
skip_serializing_if = "is_default_gmail_alpn"
)]
pub alpn: Vec<String>,
pub auth: GmailAuthConfig,
}
fn default_gmail_user_id() -> String {
String::from("me")
}
fn default_gmail_alpn() -> Vec<String> {
vec![String::from("http/1.1")]
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct GmailAuthConfig {
pub token: Secret,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MsgraphConfig {
#[serde(default = "default_msgraph_user_id")]
pub user_id: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxyConfig>,
#[serde(
default = "default_msgraph_alpn",
skip_serializing_if = "is_default_msgraph_alpn"
)]
pub alpn: Vec<String>,
pub auth: MsgraphAuthConfig,
}
fn default_msgraph_user_id() -> String {
String::from("me")
}
fn default_msgraph_alpn() -> Vec<String> {
vec![String::from("http/1.1")]
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MsgraphAuthConfig {
pub token: Secret,
}
#[cfg(test)]
mod tests {
use std::env::var;
use super::*;
const IMAP: &[&str] = &["imap", "imaps"];
#[test]
fn maildir_root_alone_keeps_keywords_off() {
let config: MaildirConfig = toml::from_str(r#"root = "/tmp/mail""#).unwrap();
assert!(!config.keywords.dovecot);
assert_eq!(config.keywords.header, None);
}
#[test]
fn maildir_keyword_options_parse() {
let config: MaildirConfig = toml::from_str(
r#"
root = "/tmp/mail"
keywords.dovecot = true
keywords.header = "x-label"
"#,
)
.unwrap();
assert!(config.keywords.dovecot);
assert_eq!(
config.keywords.header,
Some(MaildirKeywordHeaderConfig::XLabel)
);
}
#[test]
fn mbox_root_alone_takes_both_locks_and_writes_mboxrd() {
let config: MboxConfig = toml::from_str(r#"root = "/tmp/mail""#).unwrap();
assert_eq!(config.inbox, None);
assert_eq!(config.format, MboxFormatConfig::Mboxrd);
assert!(!config.thunderbird);
assert_eq!(config.lock, MboxLockConfig::default());
assert!(config.lock.dotlock && config.lock.fcntl);
}
#[test]
fn mbox_options_parse() {
let config: MboxConfig = toml::from_str(
r#"
root = "/tmp/mail"
inbox = "/var/mail/me"
format = "mboxcl2"
thunderbird = true
lock.dotlock = false
lock.timeout = 30
"#,
)
.unwrap();
assert_eq!(config.inbox, Some(PathBuf::from("/var/mail/me")));
assert_eq!(config.format, MboxFormatConfig::Mboxcl2);
assert!(config.thunderbird);
assert!(!config.lock.dotlock && config.lock.fcntl);
assert_eq!(config.lock.timeout, Some(30));
}
#[test]
fn sieve_config_defaults_to_no_alpn_and_refuses_cleartext_auth() {
let config: SieveConfig = toml::from_str(
r#"
server = "sieve.example.com:4190"
"#,
)
.unwrap();
assert_eq!(config.server, "sieve.example.com:4190");
assert_eq!(config.starttls, None);
assert!(config.alpn.is_empty());
assert!(!config.allow_cleartext_auth);
assert!(config.sasl.is_none());
}
#[test]
fn bare_host_defaults_to_secure_scheme() {
let url = parse_server("mail.example.com", "imaps", IMAP).unwrap();
assert_eq!(url.scheme(), "imaps");
assert_eq!(url.host_str(), Some("mail.example.com"));
assert_eq!(url.port(), None);
}
#[test]
fn bare_host_port_keeps_port_and_secure_scheme() {
let url = parse_server("mail.example.com:1993", "imaps", IMAP).unwrap();
assert_eq!(url.scheme(), "imaps");
assert_eq!(url.host_str(), Some("mail.example.com"));
assert_eq!(url.port(), Some(1993));
}
#[test]
fn full_url_scheme_host_port_is_kept_verbatim() {
let url = parse_server("imap://mail.example.com:143", "imaps", IMAP).unwrap();
assert_eq!(url.scheme(), "imap");
assert_eq!(url.host_str(), Some("mail.example.com"));
assert_eq!(url.port(), Some(143));
}
#[test]
fn unknown_scheme_is_rejected() {
let err = parse_server("ftp://mail.example.com", "imaps", IMAP).unwrap_err();
assert!(err.to_string().contains("Invalid server scheme `ftp`"));
}
#[test]
fn the_composing_config_reads_under_the_tui_spelling() {
let config: Config = toml::from_str(
r#"
from-name = "Alice"
signature = "Alice"
[accounts.example]
from = "alice@example.org"
from-name = "Alice at work"
signature-delim = "~~~\n"
"#,
)
.expect("the himalaya-tui spelling must deserialize");
let account = config.accounts.get("example").expect("the example account");
assert_eq!(config.display_name.as_deref(), Some("Alice"));
assert_eq!(config.signature.as_deref(), Some("Alice"));
assert_eq!(account.email.as_deref(), Some("alice@example.org"));
assert_eq!(account.display_name.as_deref(), Some("Alice at work"));
assert_eq!(account.signature_delim.as_deref(), Some("~~~\n"));
}
#[test]
fn backends_inherit_the_account_proxy_unless_they_name_one() {
let mut config: Config = toml::from_str(
r#"
[accounts.example]
proxy.url = "socks5h://account:1080"
imap.server = "imap.example.org"
smtp.server = "smtp.example.org"
smtp.proxy.url = "http://smtp:3128"
"#,
)
.expect("the proxy blocks must deserialize");
let (_, account) = config.take_named_account("example").unwrap();
let proxy = |proxy: Option<ProxyConfig>| proxy.map(|proxy| proxy.url);
assert_eq!(
proxy(account.imap.unwrap().proxy).as_deref(),
Some("socks5h://account:1080")
);
assert_eq!(
proxy(account.smtp.unwrap().proxy).as_deref(),
Some("http://smtp:3128")
);
}
#[test]
fn local_roots_expand_the_leading_tilde() {
let home = PathBuf::from(var("HOME").expect("HOME must be set"));
let maildir: MaildirConfig = toml::from_str(r#"root = "~/Mail""#).unwrap();
let m2dir: M2dirConfig = toml::from_str(r#"root = "~/Mail""#).unwrap();
let pimdir: PimdirConfig = toml::from_str(r#"root = "~/Mail""#).unwrap();
let mbox: MboxConfig = toml::from_str(
r#"root = "~/Mail"
inbox = "~/spool""#,
)
.unwrap();
assert_eq!(maildir.root, home.join("Mail"));
assert_eq!(m2dir.root, home.join("Mail"));
assert_eq!(pimdir.root, home.join("Mail"));
assert_eq!(mbox.root, home.join("Mail"));
assert_eq!(mbox.inbox, Some(home.join("spool")));
}
#[test]
fn optional_paths_expand_the_leading_tilde_and_stay_absent() {
let home = PathBuf::from(var("HOME").expect("HOME must be set"));
let tls: TlsConfig = toml::from_str(r#"cert = "~/ca.pem""#).unwrap();
assert_eq!(tls.cert, Some(home.join("ca.pem")));
let tls: TlsConfig = toml::from_str("").unwrap();
assert_eq!(tls.cert, None);
}
#[test]
fn path_is_preserved_for_full_url() {
let url = parse_server("https://example.com/jmap/session", "https", &["https"]).unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.path(), "/jmap/session");
}
}