use std::{
collections::HashMap,
fmt,
io::{IsTerminal, stdin},
path::PathBuf,
time::Duration,
};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, SecondsFormat, Utc};
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::printer::Printer;
use pimalaya_config::{
command::CommandConfig,
secret::{Secret, SecretResolver},
toml as config_toml,
toml::{TomlConfig, shell_expanded_string},
};
use pimalaya_stream::tls::{Rustls, RustlsCrypto, Tls, TlsProvider};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{cli::configure::offer_configuration, wizard::discover::CONFIG_SAMPLE_URL};
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default()
}
fn is_default_http_alpn(alpn: &[String]) -> bool {
alpn == default_http_alpn().as_slice()
}
macro_rules! source_config {
(
$(#[$struct_meta:meta])*
pub struct $Name:ident {
$(
$(#[$field_meta:meta])*
pub $field_name:ident: $field_ty:ty,
)*
}
) => {
$(#[$struct_meta])*
pub struct $Name {
$(
$(#[$field_meta])*
pub $field_name: $field_ty,
)*
#[serde(default, alias = "mailbox", skip_serializing_if = "is_default")]
pub collection: CollectionSourceConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub flag: FlagSourcePermissions,
#[serde(default, alias = "message", skip_serializing_if = "is_default")]
pub item: ItemSourcePermissions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool_size: Option<usize>,
}
};
}
macro_rules! source_accessor {
($name:ident, $ty:ty) => {
pub fn $name(&self) -> $ty {
match &self.backend {
SourceBackendConfig::Imap(c) => c.$name,
SourceBackendConfig::Carddav(c) => c.$name,
SourceBackendConfig::Caldav(c) => c.$name,
SourceBackendConfig::Jmap(c) => c.$name,
SourceBackendConfig::Gmail(c) => c.$name,
SourceBackendConfig::Msgraph(c) => c.$name,
}
}
};
}
macro_rules! source_ref_accessor {
($name:ident, $ty:ty) => {
pub fn $name(&self) -> &$ty {
match &self.backend {
SourceBackendConfig::Imap(c) => &c.$name,
SourceBackendConfig::Carddav(c) => &c.$name,
SourceBackendConfig::Caldav(c) => &c.$name,
SourceBackendConfig::Jmap(c) => &c.$name,
SourceBackendConfig::Gmail(c) => &c.$name,
SourceBackendConfig::Msgraph(c) => &c.$name,
}
}
};
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
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)> {
self.accounts.remove_entry(name)
}
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)
}
}
impl Config {
pub fn load_or_wizard(printer: &mut impl Printer, config_paths: &[PathBuf]) -> Result<Config> {
if let Some(config) = Config::from_paths_or_default(config_paths)? {
return Ok(config);
}
let target = Config::target_path(config_paths)?;
if !printer.is_json() && stdin().is_terminal() {
offer_configuration(printer, config_paths, &target)?;
}
match Config::from_paths_or_default(config_paths)? {
Some(config) => Ok(config),
None => bail!(
"No configuration found at {}, run `neverest configure` to generate one or write it by hand: {CONFIG_SAMPLE_URL}",
target.display(),
),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct AccountConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub default: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sources: HashMap<String, SourceConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub targets: HashMap<String, SourceConfig>,
#[serde(default, skip_serializing_if = "is_default")]
pub one_way: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retain: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub imap: Option<ImapConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carddav: Option<CarddavConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caldav: Option<CaldavConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jmap: Option<JmapConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gmail: Option<GmailConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub msgraph: Option<MsgraphConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smtp: Option<SmtpConfig>,
#[serde(default)]
pub store: StoreConfig,
#[serde(default)]
pub conflict: ConflictConfig,
#[serde(default, alias = "message")]
pub item: ItemSyncConfig,
#[serde(default)]
pub connections: Option<usize>,
#[serde(default, skip_serializing)]
left: Option<RemovedKey>,
#[serde(default, skip_serializing)]
right: Option<RemovedKey>,
#[serde(default, skip_serializing, alias = "mailbox")]
collection: Option<RemovedKey>,
}
const RENDER_ORDER: [&str; 16] = [
"default",
"imap",
"carddav",
"caldav",
"jmap",
"gmail",
"msgraph",
"smtp",
"sources",
"targets",
"one-way",
"retain",
"store",
"conflict",
"item",
"connections",
];
const ENDPOINT_KEYS: [&str; 2] = ["server", "user-id"];
impl AccountConfig {
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 = 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, (_, mut lines)) in groups.into_iter().enumerate() {
if index > 0 {
document.push('\n');
}
lines.sort_by_key(|line| {
let field = line.split(['.', ' ']).nth(1).unwrap_or_default();
ENDPOINT_KEYS
.iter()
.position(|known| *known == field)
.unwrap_or(ENDPOINT_KEYS.len())
});
for line in lines {
document.push_str(line);
document.push('\n');
}
}
Ok(document)
}
pub fn with_source(source: SourceConfig) -> Self {
let mut account = Self::default();
account.set_direct_source(source);
account
}
pub fn set_direct_source(&mut self, source: SourceConfig) {
let SourceConfig { backend, smtp } = source;
self.smtp = smtp;
match backend {
SourceBackendConfig::Imap(config) => self.imap = Some(config),
SourceBackendConfig::Carddav(config) => self.carddav = Some(config),
SourceBackendConfig::Caldav(config) => self.caldav = Some(config),
SourceBackendConfig::Jmap(config) => self.jmap = Some(config),
SourceBackendConfig::Gmail(config) => self.gmail = Some(config),
SourceBackendConfig::Msgraph(config) => self.msgraph = Some(config),
}
}
pub fn sources(&self) -> Result<HashMap<String, SourceConfig>> {
let mut sources = self.sources.clone();
let sugar = [
self.imap.clone().map(SourceBackendConfig::Imap),
self.carddav.clone().map(SourceBackendConfig::Carddav),
self.caldav.clone().map(SourceBackendConfig::Caldav),
self.jmap.clone().map(SourceBackendConfig::Jmap),
self.gmail.clone().map(SourceBackendConfig::Gmail),
self.msgraph.clone().map(SourceBackendConfig::Msgraph),
];
for backend in sugar.into_iter().flatten() {
let name = backend.protocol().to_string();
if sources.contains_key(&name) {
bail!(
"Source {name} is declared both directly under the account and in the \
`sources` table; the direct form is sugar for `sources.{name}`, so keep one."
);
}
sources.insert(name, SourceConfig::new(backend));
}
self.attach_send_channel(&mut sources)?;
Ok(sources)
}
fn attach_send_channel(&self, sources: &mut HashMap<String, SourceConfig>) -> Result<()> {
let Some(smtp) = &self.smtp else {
return Ok(());
};
let mut candidates: Vec<_> = sources
.iter()
.filter(|(name, source)| {
self.is_sugar(name) && source.carries_mail() && !source.sends_natively()
})
.map(|(name, _)| name.clone())
.collect();
candidates.sort();
let [name] = candidates.as_slice() else {
bail!(
"The account-level `smtp` channel needs exactly one direct mail backend to \
complete, and this account has {}; move it under the source that sends, as \
`sources.<name>.smtp`.",
candidates.len()
);
};
sources
.get_mut(name)
.expect("candidate name comes from the map")
.smtp = Some(smtp.clone());
Ok(())
}
fn is_sugar(&self, name: &str) -> bool {
!self.sources.contains_key(name)
}
pub fn endpoints(&self) -> Result<HashMap<String, SourceConfig>> {
let mut endpoints = self.sources()?;
endpoints.extend(self.targets.clone());
Ok(endpoints)
}
pub fn mode(&self) -> Result<AccountMode> {
let sources = self.sources()?;
let mut source_names: Vec<String> = sources.keys().cloned().collect();
let mut target_names: Vec<String> = self.targets.keys().cloned().collect();
source_names.sort();
target_names.sort();
match (source_names.len(), target_names.len(), self.one_way) {
(0, _, _) => bail!(
"This account declares no source. Write a backend directly under it \
(`imap.server = \"…\"`), or name one in its `sources` table."
),
(_, 0, _) => {}
(1, 1, _) => {}
(1, _, true) => {}
(1, n, false) => bail!(
"One source and {n} targets is a one-way copy: add `one-way = true`. Without it \
each target would also write back, and propagating between {} endpoints has no \
resolution order for neverest to pick.",
n + 1,
),
(n, _, _) => bail!(
"{n} sources and {} targets is not a shape neverest syncs. Either drop the \
targets, so every source syncs into the local store, or keep one source and \
copy it to the targets with `one-way = true`.",
target_names.len(),
),
}
if target_names.is_empty() && self.retain == Some(false) {
bail!(
"`retain = false` with no target would sync to nowhere: the local store is this \
account's destination. Drop the key, or name the targets to copy to."
);
}
let retain = self.retain.unwrap_or(target_names.is_empty());
Ok(AccountMode {
sources: source_names,
targets: target_names,
one_way: self.one_way,
retain,
})
}
pub fn validate(&self) -> Result<()> {
self.reject_removed_keys()?;
let sources = self.sources()?;
if sources.is_empty() {
bail!(
"This account declares no source. Write a backend directly under it \
(`imap.server = \"…\"`), or name one in its `sources` table."
);
}
for (name, source) in sources.iter().chain(&self.targets) {
source.validate(name)?;
}
if let Some(name) = sources.keys().find(|name| self.targets.contains_key(*name)) {
bail!(
"{name} is both a source and a target. A name is the pimdir source id every \
binding it owns is recorded under, so one name cannot be two endpoints; rename \
one of them."
);
}
self.mode()?;
let mut senders: Vec<_> = sources
.iter()
.filter(|(_, source)| source.smtp.is_some())
.map(|(name, _)| name.clone())
.collect();
senders.sort();
if senders.len() > 1 {
bail!(
"Sources {} each declare an `smtp` channel, and an account sends through one; \
keep the table on the source that sends and drop the others.",
senders.join(", "),
);
}
Ok(())
}
fn reject_removed_keys(&self) -> Result<()> {
if self.left.is_some() || self.right.is_some() {
bail!(
"`left` and `right` are gone: an account names its endpoints and the direction \
between them. Write the authoritative one under `sources` and the other under \
`targets`, and add `one-way = true` to copy rather than merge; leaving it off \
keeps them syncing both ways, which is what the pair used to do."
);
}
if self.collection.is_some() {
bail!(
"The account-level `collection` table is gone: a filter belongs to the source it \
filters, since an account may hold sources of several kinds. Write it as \
`sources.<name>.collection.filter`, or `<protocol>.collection.filter` under the \
account."
);
}
let namespaced: Vec<_> = self
.sources
.iter()
.chain(&self.targets)
.filter(|(_, source)| source.declares_namespace())
.map(|(name, _)| name.as_str())
.collect();
if !namespaced.is_empty() {
bail!(
"`collection.namespace` is gone, on {}: it said which sources met, which is now \
whether they sit under `sources` or `targets`, and it never said which way, \
which is now `one-way`. Drop it.",
namespaced.join(", "),
);
}
self.store.reject_removed_keys()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RemovedKey;
impl<'de> Deserialize<'de> for RemovedKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(Self)
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ConflictConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merger: Option<CommandConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct StoreConfig {
#[serde(default, deserialize_with = "shell_expanded_path_opt")]
pub root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purge_after: Option<HumanDuration>,
#[serde(default, skip_serializing)]
retention: Option<RemovedKey>,
#[serde(default, skip_serializing)]
hydration: Option<RemovedKey>,
}
impl StoreConfig {
fn reject_removed_keys(&self) -> Result<()> {
if self.retention.is_some() || self.hydration.is_some() {
bail!(
"`store.retention` and `store.hydration` are gone: whether the store keeps \
bodies is the account's `retain`, which is true when the store is the \
destination and false when targets are named."
);
}
Ok(())
}
pub fn purge_cutoff(&self, now: DateTime<Utc>) -> Option<String> {
let after = chrono::Duration::from_std(self.purge_after?.0).ok()?;
let cutoff = now.checked_sub_signed(after)?;
Some(cutoff.to_rfc3339_opts(SecondsFormat::Millis, true))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HumanDuration(pub Duration);
impl HumanDuration {
fn parse(raw: &str) -> Result<Self, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err(String::from("empty duration"));
}
let digits = raw.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &raw[digits.len()..];
let count: u64 = digits
.parse()
.map_err(|_| format!("{raw} is not a `<number><unit>` duration (e.g. `90d`)"))?;
let seconds = match unit {
"s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86400,
"w" => 7 * 86400,
"" if count == 0 => 1,
"" => {
return Err(format!(
"duration {raw} misses its unit (`s`, `m`, `h`, `d` or `w`)"
));
}
other => {
return Err(format!(
"unknown duration unit {other} in {raw} (expected `s`, `m`, `h`, `d` or `w`)"
));
}
};
let total = count
.checked_mul(seconds)
.ok_or_else(|| format!("duration {raw} overflows"))?;
Ok(Self(Duration::from_secs(total)))
}
}
impl fmt::Display for HumanDuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let secs = self.0.as_secs();
if secs == 0 {
return f.write_str("0");
}
for (unit, size) in [("w", 7 * 86400), ("d", 86400), ("h", 3600), ("m", 60)] {
if secs.is_multiple_of(size) {
return write!(f, "{}{unit}", secs / size);
}
}
write!(f, "{secs}s")
}
}
impl<'de> Deserialize<'de> for HumanDuration {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
HumanDuration::parse(&raw).map_err(serde::de::Error::custom)
}
}
impl Serialize for HumanDuration {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AccountMode {
pub sources: Vec<String>,
pub targets: Vec<String>,
pub one_way: bool,
pub retain: bool,
}
impl AccountMode {
pub fn is_local(&self) -> bool {
self.targets.is_empty()
}
pub fn streams(&self, sources: &HashMap<String, SourceConfig>) -> bool {
!self.retain
&& !self.is_local()
&& self
.sources
.iter()
.chain(&self.targets)
.all(|name| sources.get(name).is_some_and(SourceConfig::is_streamable))
}
}
impl fmt::Display for AccountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let sources = self.sources.join(", ");
if self.is_local() {
let verb = if self.one_way {
"overwrite the local store, discarding local edits"
} else {
"sync both ways with the local store"
};
return write!(f, "{sources} {verb}");
}
let targets = self.targets.join(", ");
let body = if self.retain {
", keeping a local copy"
} else {
", keeping no local copy"
};
if self.one_way {
write!(f, "{sources} overwrites {targets}{body}")
} else {
write!(f, "{sources} and {targets} sync both ways{body}")
}
}
}
fn shell_expanded_path_opt<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<String> = Option::deserialize(deserializer)?;
Ok(raw.map(|s| PathBuf::from(shellexpand::tilde(&s).into_owned())))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SourceConfig {
#[serde(flatten)]
pub backend: SourceBackendConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smtp: Option<SmtpConfig>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum SourceBackendConfig {
Imap(ImapConfig),
Carddav(CarddavConfig),
Caldav(CaldavConfig),
Jmap(JmapConfig),
Gmail(GmailConfig),
Msgraph(MsgraphConfig),
}
impl SourceBackendConfig {
pub fn protocol(&self) -> &'static str {
match self {
Self::Imap(_) => "imap",
Self::Carddav(_) => "carddav",
Self::Caldav(_) => "caldav",
Self::Jmap(_) => "jmap",
Self::Gmail(_) => "gmail",
Self::Msgraph(_) => "msgraph",
}
}
}
#[allow(dead_code)]
impl SourceConfig {
pub fn new(backend: SourceBackendConfig) -> Self {
Self {
backend,
smtp: None,
}
}
source_ref_accessor!(collection, CollectionSourceConfig);
source_accessor!(flag, FlagSourcePermissions);
source_accessor!(item, ItemSourcePermissions);
source_accessor!(pool_size, Option<usize>);
fn declares_namespace(&self) -> bool {
self.collection().namespace.is_some()
}
pub fn is_imap(&self) -> bool {
matches!(self.backend, SourceBackendConfig::Imap(_))
}
pub fn is_http(&self) -> bool {
matches!(
self.backend,
SourceBackendConfig::Jmap(_)
| SourceBackendConfig::Gmail(_)
| SourceBackendConfig::Msgraph(_)
| SourceBackendConfig::Carddav(_)
| SourceBackendConfig::Caldav(_)
)
}
pub fn sends_natively(&self) -> bool {
matches!(self.backend, SourceBackendConfig::Msgraph(_))
}
pub fn carries_mail(&self) -> bool {
!matches!(
self.backend,
SourceBackendConfig::Carddav(_) | SourceBackendConfig::Caldav(_)
)
}
pub fn is_streamable(&self) -> bool {
self.is_imap()
}
pub fn validate(&self, name: &str) -> Result<()> {
if self.smtp.is_some() && !self.carries_mail() {
bail!(
"The `sources.{name}.smtp` channel is a mail capability and this source syncs \
contacts; drop the table, or move it to the source that sends."
);
}
Ok(())
}
pub fn permissions(&self) -> SourcePermissions {
SourcePermissions {
collection: self.collection().permissions(),
flag: self.flag(),
item: self.item(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct SourcePermissions {
pub collection: CollectionPermissions,
pub flag: FlagSourcePermissions,
pub item: ItemSourcePermissions,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ItemSyncConfig {}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum CollectionFilter {
#[default]
All,
Include(Vec<String>),
Exclude(Vec<String>),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct CollectionSourceConfig {
#[serde(default = "default_true")]
pub create: bool,
#[serde(default = "default_true")]
pub delete: bool,
#[serde(default, skip_serializing)]
namespace: Option<RemovedKey>,
#[serde(default, alias = "filters", skip_serializing_if = "is_default")]
pub filter: CollectionFilter,
}
impl CollectionSourceConfig {
pub fn permissions(&self) -> CollectionPermissions {
CollectionPermissions {
create: self.create,
delete: self.delete,
}
}
}
impl Default for CollectionSourceConfig {
fn default() -> Self {
Self {
create: true,
delete: true,
namespace: None,
filter: CollectionFilter::default(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CollectionPermissions {
pub create: bool,
pub delete: bool,
}
impl Default for CollectionPermissions {
fn default() -> Self {
Self {
create: true,
delete: true,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct FlagSourcePermissions {
pub update: bool,
}
impl Default for FlagSourcePermissions {
fn default() -> Self {
Self { update: true }
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ItemSourcePermissions {
pub create: bool,
pub delete: bool,
#[serde(default = "default_true")]
pub update: bool,
}
impl Default for ItemSourcePermissions {
fn default() -> Self {
Self {
create: true,
delete: true,
update: true,
}
}
}
fn default_true() -> bool {
true
}
source_config! {
#[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 = "is_default")]
pub starttls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alpn: Option<Vec<String>>,
pub sasl: Option<SaslConfig>,
}
}
source_config! {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct CarddavConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(
default = "default_http_alpn",
skip_serializing_if = "is_default_http_alpn"
)]
pub alpn: Vec<String>,
pub auth: DavAuthConfig,
}
}
source_config! {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct CaldavConfig {
pub server: String,
#[serde(default)]
pub tls: TlsConfig,
#[serde(
default = "default_http_alpn",
skip_serializing_if = "is_default_http_alpn"
)]
pub alpn: Vec<String>,
pub auth: DavAuthConfig,
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum DavAuthConfig {
Basic {
#[serde(deserialize_with = "shell_expanded_string")]
username: String,
password: Secret,
},
Bearer { token: Secret },
}
#[cfg(feature = "dav")]
impl DavAuthConfig {
pub fn try_into_dav_auth(
self,
resolver: &mut SecretResolver,
) -> Result<io_webdav::rfc4918::WebdavAuth> {
use io_http::{rfc6750::bearer::HttpAuthBearer, rfc7617::basic::HttpAuthBasic};
use io_webdav::rfc4918::WebdavAuth;
use secrecy::ExposeSecret;
Ok(match self {
Self::Basic { username, password } => WebdavAuth::Basic(HttpAuthBasic::new(
username,
resolver.resolve(password)?.expose_secret(),
)),
Self::Bearer { token } => WebdavAuth::Bearer(HttpAuthBearer::new(
resolver.resolve(token)?.expose_secret(),
)),
})
}
}
source_config! {
#[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 = "default_http_alpn",
skip_serializing_if = "is_default_http_alpn"
)]
pub alpn: Vec<String>,
pub auth: JmapAuthConfig,
pub identity_id: Option<String>,
pub drafts_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,
},
}
source_config! {
#[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 = "default_http_alpn",
skip_serializing_if = "is_default_http_alpn"
)]
pub alpn: Vec<String>,
pub auth: GmailAuthConfig,
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct GmailAuthConfig {
pub token: Secret,
}
source_config! {
#[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 = "default_http_alpn",
skip_serializing_if = "is_default_http_alpn"
)]
pub alpn: Vec<String>,
pub auth: MsgraphAuthConfig,
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct MsgraphAuthConfig {
pub token: Secret,
}
#[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 = "is_default")]
pub starttls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alpn: Option<Vec<String>>,
pub sasl: Option<SaslConfig>,
}
#[cfg_attr(
not(any(feature = "imap", feature = "smtp", feature = "dav")),
allow(dead_code)
)]
pub fn server_url(server: &str, scheme: &str) -> Result<Url> {
let url = if server.contains("://") {
Url::parse(server)
} else {
Url::parse(&format!("{scheme}://{server}"))
};
url.with_context(|| format!("Cannot parse {server} as a server URL"))
}
fn default_gmail_user_id() -> String {
String::from("me")
}
fn default_msgraph_user_id() -> String {
String::from("me")
}
fn default_http_alpn() -> Vec<String> {
vec![String::from("http/1.1")]
}
#[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 = "shell_expanded_path_opt")]
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,
}
#[cfg_attr(
not(any(feature = "imap", feature = "msgraph", feature = "smtp")),
allow(dead_code)
)]
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,
}
#[cfg_attr(not(any(feature = "imap", feature = "smtp")), allow(dead_code))]
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,
}),
})
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn a_generated_config_renders_as_dotted_keys_under_one_header() {
let account: AccountConfig = toml::from_str(
r#"
default = true
msgraph.user-id = "me"
msgraph.auth.token.command = ["ortie", "token", "show", "-a", "msgraph"]
"#,
)
.unwrap();
let config = Config {
accounts: HashMap::from([(String::from("outlook"), account)]),
};
assert_eq!(
config_toml::to_string(&config).unwrap(),
r#"[accounts.outlook]
default = true
msgraph.auth.token.command = ["ortie", "token", "show", "-a", "msgraph"]
msgraph.user-id = "me"
"#
);
}
#[test]
fn msgraph_auth_is_bearer_token_only() {
let config: MsgraphConfig = toml::from_str(
r#"
auth.token.raw = "tok"
"#,
)
.unwrap();
assert_eq!(config.user_id, "me");
let config: MsgraphConfig = toml::from_str(
r#"
user-id = "user@example.org"
auth.token.command = ["ortie", "-a", "msgraph", "token", "show", "--auto-refresh"]
"#,
)
.unwrap();
assert_eq!(config.user_id, "user@example.org");
let err = toml::from_str::<MsgraphConfig>(
r#"
[auth.device-code]
client-id = "id"
"#,
)
.unwrap_err();
assert!(err.to_string().contains("device-code"));
}
#[test]
fn the_direct_backend_sugar_expands_to_the_same_source() {
let sugar: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
imap.item.create = true
imap.item.delete = false
"#,
)
.unwrap();
let explicit: AccountConfig = toml::from_str(
r#"
sources.imap.imap.server = "imaps://imap.example.org:993"
sources.imap.imap.item.create = true
sources.imap.imap.item.delete = false
"#,
)
.unwrap();
let sugar = sugar.sources().unwrap();
let explicit = explicit.sources().unwrap();
assert_eq!(sugar.keys().collect::<Vec<_>>(), vec!["imap"]);
assert_eq!(explicit.keys().collect::<Vec<_>>(), vec!["imap"]);
assert!(!sugar["imap"].permissions().item.delete);
assert!(!explicit["imap"].permissions().item.delete);
}
#[test]
fn a_protocol_declared_both_ways_is_refused() {
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
sources.imap.imap.server = "imaps://other.example.org:993"
"#,
)
.unwrap();
let err = account.sources().unwrap_err().to_string();
assert!(err.contains("declared both"), "got {err}");
}
#[test]
fn several_sources_of_one_protocol_live_under_one_account() {
let account: AccountConfig = toml::from_str(
r#"
sources.fastmail.imap.server = "imaps://imap.fastmail.com:993"
sources.gmail.imap.server = "imaps://imap.gmail.com:993"
sources.dav.carddav.server = "https://carddav.fastmail.com/"
sources.dav.carddav.auth.basic.username = "user"
sources.dav.carddav.auth.basic.password.raw = "pw"
"#,
)
.unwrap();
account.validate().unwrap();
let sources = account.sources().unwrap();
assert_eq!(sources.len(), 3);
}
#[test]
fn mail_and_contacts_sit_under_one_account() {
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.fastmail.com:993"
carddav.server = "https://carddav.fastmail.com/"
carddav.auth.basic.username = "user"
carddav.auth.basic.password.raw = "pw"
smtp.server = "smtps://smtp.fastmail.com:465"
"#,
)
.unwrap();
account.validate().unwrap();
let sources = account.sources().unwrap();
assert!(sources["imap"].is_imap());
assert!(sources["imap"].smtp.is_some(), "the channel completes mail");
assert!(!sources["carddav"].carries_mail());
assert!(sources["carddav"].smtp.is_none());
}
#[test]
fn the_mode_is_the_arity_and_the_two_flags() {
let local: AccountConfig = toml::from_str(
r#"
sources.fastmail.imap.server = "imaps://imap.fastmail.com:993"
sources.gmail.imap.server = "imaps://imap.gmail.com:993"
"#,
)
.unwrap();
let mode = local.mode().unwrap();
assert!(mode.is_local());
assert!(!mode.one_way);
assert!(mode.retain, "the store is the destination");
let mirror: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
targets.b.imap.server = "imaps://b.example.org:993"
"#,
)
.unwrap();
let mode = mirror.mode().unwrap();
assert!(!mode.is_local());
assert!(!mode.one_way, "two-way remote to remote is the default");
assert!(!mode.retain, "a named target asked to copy, not to store");
let copy: AccountConfig = toml::from_str(
r#"
one-way = true
retain = true
sources.a.imap.server = "imaps://a.example.org:993"
targets.b.imap.server = "imaps://b.example.org:993"
targets.c.imap.server = "imaps://c.example.org:993"
"#,
)
.unwrap();
let mode = copy.mode().unwrap();
assert_eq!(mode.targets, vec!["b", "c"]);
assert!(mode.one_way);
assert!(mode.retain, "migrating while keeping a local copy");
}
#[test]
fn many_targets_without_one_way_are_refused() {
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
targets.b.imap.server = "imaps://b.example.org:993"
targets.c.imap.server = "imaps://c.example.org:993"
"#,
)
.unwrap();
let err = account.mode().unwrap_err().to_string();
assert!(err.contains("one-way = true"), "got {err}");
}
#[test]
fn many_sources_with_a_target_are_refused() {
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
sources.b.imap.server = "imaps://b.example.org:993"
targets.c.imap.server = "imaps://c.example.org:993"
"#,
)
.unwrap();
let err = account.mode().unwrap_err().to_string();
assert!(err.contains("not a shape neverest syncs"), "got {err}");
}
#[test]
fn refusing_to_retain_with_no_target_is_refused() {
let account: AccountConfig = toml::from_str(
r#"
retain = false
imap.server = "imaps://imap.example.org:993"
"#,
)
.unwrap();
let err = account.mode().unwrap_err().to_string();
assert!(err.contains("sync to nowhere"), "got {err}");
}
#[test]
fn only_an_imap_pairing_streams_a_crossing() {
let imap: AccountConfig = toml::from_str(
r#"
one-way = true
sources.a.imap.server = "imaps://a.example.org:993"
targets.b.imap.server = "imaps://b.example.org:993"
"#,
)
.unwrap();
assert!(imap.mode().unwrap().streams(&imap.endpoints().unwrap()));
let dav: AccountConfig = toml::from_str(
r#"
one-way = true
sources.a.carddav.server = "https://a.example.org/"
sources.a.carddav.auth.bearer.token.raw = "tok"
targets.b.carddav.server = "https://b.example.org/"
targets.b.carddav.auth.bearer.token.raw = "tok"
"#,
)
.unwrap();
assert!(
!dav.mode().unwrap().streams(&dav.endpoints().unwrap()),
"a DAV crossing is staged and released, which `retain` cannot tell apart",
);
}
#[test]
fn a_removed_key_is_refused_by_name_with_its_replacement() {
let account: AccountConfig = toml::from_str(
r#"
left.imap.server = "imaps://imap.example.org:993"
right.imap.server = "imaps://imap.other.org:993"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("`targets`"), "got {err}");
assert!(err.contains("one-way"), "got {err}");
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
collection.filter.include = ["INBOX"]
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("collection.filter"), "got {err}");
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
store.retention = "retain"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("the account's `retain`"), "got {err}");
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
store.hydration = "full"
"#,
)
.unwrap();
assert!(account.validate().is_err());
}
#[test]
fn an_account_with_no_source_is_refused() {
let account: AccountConfig = toml::from_str("default = true").unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("no source"), "got {err}");
}
#[test]
fn one_source_at_most_carries_the_send_channel() {
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
sources.a.smtp.server = "smtps://a.example.org:465"
sources.b.imap.server = "imaps://b.example.org:993"
sources.b.smtp.server = "smtps://b.example.org:465"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("a, b"), "got {err}");
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
sources.a.smtp.server = "smtps://a.example.org:465"
sources.b.imap.server = "imaps://b.example.org:993"
"#,
)
.unwrap();
account.validate().unwrap();
}
#[test]
fn the_flat_send_channel_needs_one_direct_mail_backend() {
let account: AccountConfig = toml::from_str(
r#"
carddav.server = "https://dav.example.org/"
carddav.auth.bearer.token.raw = "tok"
smtp.server = "smtps://smtp.example.org:465"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("exactly one direct mail backend"), "got {err}");
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
smtp.server = "smtps://a.example.org:465"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("exactly one direct mail backend"), "got {err}");
}
#[test]
fn the_send_channel_names_a_sasl_mechanism() {
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
smtp.server = "smtp.example.org"
smtp.sasl.xoauth2.username = "user@example.org"
smtp.sasl.xoauth2.token.command = ["ortie", "token", "read", "example"]
"#,
)
.unwrap();
let smtp = account.smtp.as_ref().expect("a declared channel");
assert_eq!(smtp.server, "smtp.example.org");
assert!(matches!(smtp.sasl, Some(SaslConfig::Xoauth2(_))));
let relay: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
smtp.server = "smtp://127.0.0.1:2525"
"#,
)
.unwrap();
assert!(relay.smtp.expect("a declared channel").sasl.is_none());
}
#[test]
fn the_flat_login_and_password_spelling_is_refused() {
let err = toml::from_str::<AccountConfig>(
r#"
imap.server = "imaps://imap.example.org:993"
smtp.server = "smtps://smtp.example.org:465"
smtp.login = "user@example.org"
smtp.password.raw = "pw"
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("login"), "got {err}");
}
#[test]
fn a_server_resolves_from_an_authority_with_or_without_a_port() {
for (server, scheme, host, port) in [
(
"dav.example.org:8443",
"https",
"dav.example.org",
Some(8443),
),
("dav.example.org", "https", "dav.example.org", None),
(
"imap.example.org:143",
"imaps",
"imap.example.org",
Some(143),
),
("smtp.example.org", "smtps", "smtp.example.org", None),
] {
let url = server_url(server, scheme).unwrap();
assert_eq!(url.scheme(), scheme, "{server}");
assert_eq!(url.host_str(), Some(host), "{server}");
assert_eq!(url.port(), port, "{server}");
}
}
#[test]
fn a_server_carrying_a_scheme_is_left_alone() {
let url = server_url("http://127.0.0.1:5232/dav/", "https").unwrap();
assert_eq!(url.scheme(), "http");
assert_eq!(url.port(), Some(5232));
assert_eq!(url.path(), "/dav/");
let url = server_url("imap://example.org:143", "imaps").unwrap();
assert_eq!(url.scheme(), "imap");
}
#[test]
fn the_pre_generic_pim_sync_spellings_still_load() {
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
imap.mailbox.create = false
imap.mailbox.delete = false
imap.message.create = true
imap.message.delete = false
"#,
)
.unwrap();
let sources = account.sources().unwrap();
let perms = sources["imap"].permissions();
assert!(!perms.collection.create);
assert!(!perms.collection.delete);
assert!(perms.item.create);
assert!(!perms.item.delete);
assert!(perms.flag.update);
assert!(perms.item.update);
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
imap.collection.create = false
imap.collection.delete = false
imap.collection.filter.include = ["INBOX"]
imap.item.create = true
imap.item.delete = false
"#,
)
.unwrap();
let sources = account.sources().unwrap();
assert_eq!(
sources["imap"].collection().filter,
CollectionFilter::Include(vec![String::from("INBOX")])
);
let perms = sources["imap"].permissions();
assert!(!perms.collection.create);
assert!(!perms.collection.delete);
assert!(perms.item.create);
assert!(!perms.item.delete);
}
#[test]
fn item_update_is_denied_only_when_asked_for() {
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
imap.item.create = true
imap.item.delete = true
imap.item.update = false
"#,
)
.unwrap();
let sources = account.sources().unwrap();
let perms = sources["imap"].permissions();
assert!(perms.item.create);
assert!(perms.item.delete);
assert!(!perms.item.update);
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
imap.item.create = true
imap.item.delete = true
"#,
)
.unwrap();
let sources = account.sources().unwrap();
assert!(sources["imap"].permissions().item.update);
}
#[test]
fn a_tilde_path_is_expanded_at_deserialize() {
let home = PathBuf::from(shellexpand::tilde("~").into_owned());
let store: StoreConfig = toml::from_str(r#"root = "~/store""#).unwrap();
assert_eq!(store.root, Some(home.join("store")));
let tls: TlsConfig = toml::from_str(r#"cert = "~/ca.pem""#).unwrap();
assert_eq!(tls.cert, Some(home.join("ca.pem")));
let reloaded: TlsConfig = toml::from_str(&toml::to_string(&tls).unwrap()).unwrap();
assert_eq!(reloaded.cert, tls.cert);
let tls: TlsConfig = toml::from_str("").unwrap();
assert_eq!(tls.cert, None);
}
#[test]
fn the_documented_sample_still_loads() {
let raw = fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/config.sample.toml"))
.expect("read the sample");
let config: Config = toml::from_str(&raw).expect("the sample must parse");
let account = config.accounts.get("example").expect("the sample account");
account.validate().expect("the sample must validate");
assert!(account.sources().unwrap()["imap"].is_imap());
}
#[test]
fn the_purge_delay_is_a_human_duration_and_drives_the_cutoff() {
let now: DateTime<Utc> = "2026-08-07T12:00:00Z".parse().unwrap();
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
"#,
)
.unwrap();
assert!(account.store.purge_after.is_none());
assert!(account.store.purge_cutoff(now).is_none());
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
store.purge-after = "90d"
"#,
)
.unwrap();
assert_eq!(
account.store.purge_cutoff(now).as_deref(),
Some("2026-05-09T12:00:00.000Z")
);
let account: AccountConfig = toml::from_str(
r#"
imap.server = "imaps://imap.example.org:993"
store.purge-after = "0"
"#,
)
.unwrap();
assert_eq!(
account.store.purge_cutoff(now).as_deref(),
Some("2026-08-07T12:00:00.000Z")
);
let err = toml::from_str::<AccountConfig>(
r#"
imap.server = "imaps://imap.example.org:993"
store.purge-after = "90 days"
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("90 days"), "{err}");
}
#[test]
fn a_human_duration_round_trips_through_the_document() {
for (raw, secs) in [
("0", 0),
("45s", 45),
("30m", 1800),
("12h", 43200),
("90d", 7776000),
("2w", 1209600),
] {
let parsed = HumanDuration::parse(raw).expect(raw);
assert_eq!(parsed.0.as_secs(), secs, "{raw}");
assert_eq!(parsed.to_string(), raw, "{raw}");
}
assert_eq!(HumanDuration(Duration::from_secs(86400)).to_string(), "1d");
assert_eq!(
HumanDuration(Duration::from_secs(90061)).to_string(),
"90061s"
);
assert!(HumanDuration::parse("").is_err());
assert!(HumanDuration::parse("90").is_err());
assert!(HumanDuration::parse("90y").is_err());
assert!(HumanDuration::parse("d").is_err());
}
#[test]
fn a_source_pairs_one_backend_with_its_send_channel() {
let account: AccountConfig = toml::from_str(
r#"
msgraph.auth.token.raw = "tok"
"#,
)
.unwrap();
let sources = account.sources().unwrap();
assert!(sources["msgraph"].sends_natively());
assert!(sources["msgraph"].smtp.is_none());
let err = toml::from_str::<AccountConfig>(
r#"
sources.a.imapp.server = "imaps://imap.example.org:993"
"#,
)
.unwrap_err();
assert!(
err.to_string()
.contains("no variant of enum SourceBackendConfig")
);
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://imap.example.org:993"
sources.a.msgraph.auth.token.raw = "tok"
"#,
)
.unwrap();
assert!(account.sources().unwrap()["a"].is_imap());
}
#[cfg(feature = "dav")]
#[test]
fn a_dav_source_carries_no_send_channel() {
let account: AccountConfig = toml::from_str(
r#"
carddav.server = "https://dav.example.org/"
carddav.auth.basic.username = "user"
carddav.auth.basic.password.raw = "pw"
caldav.server = "https://dav.example.org/"
caldav.auth.basic.username = "user"
caldav.auth.basic.password.raw = "pw"
"#,
)
.unwrap();
let sources = account.sources().unwrap();
for name in ["carddav", "caldav"] {
assert!(!sources[name].carries_mail(), "{name} does not submit");
assert!(!sources[name].sends_natively());
}
account.validate().unwrap();
let account: AccountConfig = toml::from_str(
r#"
sources.dav.carddav.server = "https://dav.example.org/"
sources.dav.carddav.auth.bearer.token.raw = "tok"
sources.dav.smtp.server = "smtps://smtp.example.org:465"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("`sources.dav.smtp`"), "got {err}");
}
#[test]
fn a_declared_namespace_is_refused_by_name() {
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
sources.a.imap.collection.namespace = "mail"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("`collection.namespace` is gone"), "got {err}");
assert!(err.contains("one-way"), "got {err}");
}
#[test]
fn a_name_used_twice_is_refused() {
let account: AccountConfig = toml::from_str(
r#"
sources.a.imap.server = "imaps://a.example.org:993"
targets.a.imap.server = "imaps://b.example.org:993"
"#,
)
.unwrap();
let err = account.validate().unwrap_err().to_string();
assert!(err.contains("both a source and a target"), "got {err}");
}
}