use std::os::unix::fs::MetadataExt as _;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context as _, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MailConfig {
pub notmuch_config: Option<PathBuf>,
pub notmuch: Option<PathBuf>,
pub identity: Option<String>,
pub deny_file: Option<PathBuf>,
pub attribution: Vec<String>,
}
#[derive(Deserialize)]
struct Layer {
mail: Option<Table>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
notmuch_config: Option<PathBuf>,
notmuch: Option<PathBuf>,
identity: Option<String>,
deny_file: Option<PathBuf>,
attribution: Option<Vec<String>>,
}
pub fn load() -> Result<Option<MailConfig>> {
let user: Vec<PathBuf> = crate::converge::config_base()
.map(|base| vec![base.join("ljos/ljos.toml"), base.join("ljos/mail.toml")])
.unwrap_or_default();
let shared: Vec<PathBuf> = crate::converge::config_paths(None)
.into_iter()
.filter(|path| !user.contains(path))
.collect();
load_from(&user, &shared)
}
pub fn load_from(user: &[PathBuf], shared: &[PathBuf]) -> Result<Option<MailConfig>> {
for path in shared {
let Some(text) = read(path)? else {
continue;
};
let layer: toml::Value =
toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
if layer.get("mail").is_some() {
bail!(
"mail config {}: [mail] may be set only in the user's own ljos.toml or mail.toml",
path.display()
);
}
}
let mut config: Option<MailConfig> = None;
for path in user {
let Some(text) = read(path)? else {
continue;
};
owned_by_user(path)?;
let layer: Layer =
toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
let Some(table) = layer.mail else {
continue;
};
check(&table).with_context(|| format!("mail config {}", path.display()))?;
let merged = config.get_or_insert_with(MailConfig::default);
if table.notmuch_config.is_some() {
merged.notmuch_config = table.notmuch_config;
}
if table.notmuch.is_some() {
merged.notmuch = table.notmuch;
}
if table.identity.is_some() {
merged.identity = table.identity;
}
if table.deny_file.is_some() {
merged.deny_file = table.deny_file;
}
if let Some(extra) = table.attribution {
merged.attribution = extra;
}
}
if config
.as_ref()
.is_some_and(|c| c.identity.is_some() && c.deny_file.is_none())
{
bail!("mail config: identity is set without deny_file, which would deny nothing");
}
Ok(config)
}
fn read(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("mail config {}: cannot read", path.display())),
}
}
fn owned_by_user(path: &Path) -> Result<()> {
let meta = std::fs::metadata(path)
.with_context(|| format!("mail config {}: cannot stat", path.display()))?;
if meta.uid() != crate::euid() {
bail!("mail config {}: not owned by this user", path.display());
}
if meta.mode() & 0o022 != 0 {
bail!("mail config {}: group or world writable", path.display());
}
Ok(())
}
fn check(table: &Table) -> Result<()> {
for (key, path) in [
("notmuch_config", &table.notmuch_config),
("deny_file", &table.deny_file),
] {
if path.as_deref().is_some_and(|p| !p.is_absolute()) {
bail!("{key} must be an absolute path");
}
}
if let Some(bin) = &table.notmuch {
if !bin.is_absolute() && bin.components().count() != 1 {
bail!("notmuch must be an absolute path or a bare name on PATH");
}
}
if let Some(identity) = &table.identity {
if !is_bare_address(identity) {
bail!("identity must be a bare address such as owner@example.test");
}
}
Ok(())
}
pub(crate) fn is_bare_address(text: &str) -> bool {
let Some((local, domain)) = text.split_once('@') else {
return false;
};
!local.is_empty()
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
&& !text
.chars()
.any(|c| c.is_whitespace() || c.is_control() || "<>\"(),;:[]\\".contains(c))
&& !domain.contains('@')
}
impl MailConfig {
#[must_use]
pub fn notmuch_bin(&self) -> &Path {
self.notmuch.as_deref().unwrap_or(Path::new("notmuch"))
}
}