diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4efb3..67d7ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Support for bearer token authentication (#40) +- New config options `mail_dir` and `state_dir` to allow mujmap's persistent + storage to be split out according to local policy (eg XDG dirs) (#33) ### Changed - mujmap now prints a more comprehensive guide on how to recover from a missing diff --git a/mujmap.toml.example b/mujmap.toml.example index d1e0529..500b7be 100644 --- a/mujmap.toml.example +++ b/mujmap.toml.example @@ -54,11 +54,43 @@ password_command = "pass example@fastmail.com" # convert_dos_to_unix = true +################################################################################ +## Path config +## +## mujmap needs places to store email and bits of working state. These paths +## can all be specified in the config file, but most of the time you don't need to +## do this and it will choose reasonable defaults. +## +## mujmap has two "configuration modes" that inform how path defaults are chosen. +## +## In "directory mode", the --path option points to the directory containing +## mujmap.toml. In this mode, the default locations for mail and state files will +## be in subdirectories under this directory. This is the right mode to keep the +## entire mujmap data storage in a single place. +## +## In "file mode", the --path option points to a config file. In this mode, the +## default location for mail will be determined from notmuch's `mail_root` config +## variable, while the default location for state is operating-system specific. +## This mode is intended for tighter integration with notmuch "profiles". + ## The cache directory in which to store mail files while they are being -## downloaded. The default is operating-system specific. +## downloaded. It must be an absolute path. The default is operating-system +## specific. # cache_dir = +## The location of the mail dir, where downloaded email is finally stored. It +## must be an absolute path. If not given in the config file, mujmap will choose +## an appropriate default for the configuration mode. You probably don't want to +## set this. + +# mail_dir = + +## The directory to store state files in. It must be an absolute path. If not +## given, mujmap will choose an appropriate default for the configuration mode. +## You probably don't want to set this. + +# state_dir = ################################################################################ ## Tag config diff --git a/src/cache.rs b/src/cache.rs index 1333793..c3a9d37 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,7 +1,6 @@ use crate::config::Config; use crate::jmap; use crate::sync::NewEmail; -use directories::ProjectDirs; use snafu::prelude::*; use snafu::Snafu; use std::fs; @@ -54,15 +53,9 @@ pub struct Cache { impl Cache { /// Open the local store. /// - /// `mail_dir` *must* be a subdirectory of the notmuch path. + /// `mail_cur_dir` *must* be a subdirectory of the notmuch root maildir. pub fn open(mail_cur_dir: impl AsRef, config: &Config) -> Result { - let project_dirs = ProjectDirs::from("sh.eliza", "", "mujmap").unwrap(); - let default_cache_dir = project_dirs.cache_dir(); - - let cache_dir = match &config.cache_dir { - Some(cache_dir) => cache_dir.as_ref(), - None => default_cache_dir, - }; + let cache_dir = &config.cache_dir; // Ensure the cache dir exists. fs::create_dir_all(cache_dir).context(CreateCacheDirSnafu { path: cache_dir })?; diff --git a/src/config.rs b/src/config.rs index 552d12e..d66dd16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,10 @@ +use directories::ProjectDirs; +use lazy_static::lazy_static; use serde::Deserialize; use snafu::prelude::*; use std::{ fs, io, - path::{Path, PathBuf}, + path::PathBuf, process::{Command, ExitStatus}, string::FromUtf8Error, }; @@ -11,6 +13,9 @@ use snafu::Snafu; #[derive(Debug, Snafu)] pub enum Error { + #[snafu(display("Could not canonicalize config dir path: {}", source))] + Canonicalize { source: io::Error }, + #[snafu(display("Could not read config file `{}': {}", filename.to_string_lossy(), source))] ReadConfigFile { filename: PathBuf, @@ -40,6 +45,16 @@ pub enum Error { #[snafu(display("Could not decode password command output as utf-8"))] DecodePasswordCommand { source: FromUtf8Error }, + + #[snafu(display("`mail_dir' path is not a directory: {}", path.to_string_lossy()))] + MailDirPathNotDirectory { path: PathBuf }, + + #[snafu(display("`cache_dir' path must be absolute: {}", path.to_string_lossy()))] + CacheDirPathNotAbsolute { path: PathBuf }, + #[snafu(display("`mail_dir' path must be absolute: {}", path.to_string_lossy()))] + MailDirPathNotAbsolute { path: PathBuf }, + #[snafu(display("`state_dir' path must be absolute: {}", path.to_string_lossy()))] + StateDirPathNotAbsolute { path: PathBuf }, } pub type Result = std::result::Result; @@ -88,8 +103,20 @@ pub struct Config { /// The cache directory in which to store mail files while they are being downloaded. The /// default is operating-system specific. + #[serde(default = "default_cache_dir")] + pub cache_dir: PathBuf, + + /// The location of the mail dir, where downloaded email is finally stored. If not given, + /// mujmap will try to figure out what you want. You probably don't want to set this. + #[serde(default = "Default::default")] + pub mail_dir: Option, + + /// The directory to store state files in. If not given, mujmap will try to choose something + /// sensible. You probably don't want to set this. + // TODO: this is only `Option` to allow serde to omit it. It will never be `None` after + // `Config::from:path` returns. Making it non-optional somehow would be nice. #[serde(default = "Default::default")] - pub cache_dir: Option, + pub state_dir: Option, /// Customize the names and synchronization behaviors of notmuch tags with JMAP keywords and /// mailboxes. @@ -265,15 +292,54 @@ fn default_convert_dos_to_unix() -> bool { true } +lazy_static! { + static ref PROJECT_DIRS: ProjectDirs = ProjectDirs::from("sh.eliza", "", "mujmap").unwrap(); +} + +fn default_cache_dir() -> PathBuf { + PROJECT_DIRS.cache_dir().to_path_buf() +} + impl Config { - pub fn from_file(path: impl AsRef) -> Result { - let contents = fs::read_to_string(path.as_ref()).context(ReadConfigFileSnafu { - filename: path.as_ref(), + pub fn from_path(path: &PathBuf) -> Result { + let cpath = path.canonicalize().context(CanonicalizeSnafu)?; + + let filename = if path.is_dir() { + cpath.join("mujmap.toml") + } else { + cpath.clone() + }; + + let contents = fs::read_to_string(&filename).context(ReadConfigFileSnafu { + filename: &filename, })?; - let config: Self = toml::from_str(contents.as_str()).context(ParseConfigFileSnafu { - filename: path.as_ref(), + let mut config: Self = toml::from_str(contents.as_str()).context(ParseConfigFileSnafu { + filename: &filename, })?; + // In directory mode, if paths aren't offered then we use the config dir itself. + if cpath.is_dir() { + if config.mail_dir.is_none() { + config.mail_dir = Some(cpath.clone()); + } else { + ensure!(path.is_dir(), MailDirPathNotDirectorySnafu { path }); + } + if config.state_dir.is_none() { + config.state_dir = Some(cpath.clone()); + } + } + // In file mode, choose an appropriate state dir for the system. + else { + if config.state_dir.is_none() { + config.state_dir = Some( + PROJECT_DIRS + .state_dir() + .unwrap_or_else(|| PROJECT_DIRS.cache_dir()) + .to_path_buf(), + ); + } + } + // Perform final validation. ensure!( !(config.fqdn.is_some() && config.session_url.is_some()), @@ -287,6 +353,18 @@ impl Config { !config.tags.directory_separator.is_empty(), EmptyDirectorySeparatorSnafu {} ); + ensure!( + config.cache_dir.is_absolute(), + CacheDirPathNotAbsoluteSnafu { + path: config.cache_dir + } + ); + if let Some(ref path) = config.mail_dir { + ensure!(path.is_absolute(), MailDirPathNotAbsoluteSnafu { path }); + } + if let Some(ref path) = config.state_dir { + ensure!(path.is_absolute(), StateDirPathNotAbsoluteSnafu { path }); + } Ok(config) } diff --git a/src/local.rs b/src/local.rs index 4ffb659..b347283 100644 --- a/src/local.rs +++ b/src/local.rs @@ -1,5 +1,6 @@ use crate::jmap; use crate::sync::NewEmail; +use crate::Config; use const_format::formatcp; use lazy_static::lazy_static; use log::debug; @@ -14,9 +15,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::io; -use std::path::Path; use std::path::PathBuf; -use std::path::StripPrefixError; const ID_PATTERN: &'static str = r"[-A-Za-z0-9_]+"; const MAIL_PATTERN: &'static str = formatcp!(r"^({})\.({})(?:$|:)", ID_PATTERN, ID_PATTERN); @@ -35,17 +34,6 @@ pub enum Error { #[snafu(display("Could not canonicalize given path: {}", source))] Canonicalize { source: io::Error }, - #[snafu(display( - "Given maildir path `{}' is not a subdirectory of the notmuch root `{}'", - mail_dir.to_string_lossy(), - notmuch_root.to_string_lossy(), - ))] - MailDirNotASubdirOfNotmuchRoot { - mail_dir: PathBuf, - notmuch_root: PathBuf, - source: StripPrefixError, - }, - #[snafu(display("Could not open notmuch database: {}", source))] OpenDatabase { source: notmuch::Error }, @@ -89,9 +77,7 @@ pub struct Local { impl Local { /// Open the local store. - /// - /// `mail_dir` *must* be a subdirectory of the notmuch path. - pub fn open(mail_dir: impl AsRef, dry_run: bool) -> Result { + pub fn open(config: &Config, dry_run: bool) -> Result { // Open the notmuch database with default config options. let db = Database::open_with_config::( None, @@ -105,30 +91,40 @@ impl Local { ) .context(OpenDatabaseSnafu {})?; - // Get the relative directory of the maildir to the database path. - let canonical_db_path = db.path().canonicalize().context(CanonicalizeSnafu {})?; - let canonical_mail_dir_path = mail_dir - .as_ref() + // Get notmuch's idea of the mail root. If, for whatever reason, we get nothing back for + // that key (ancient version of notmuch?), use the database path. + let mail_root = db + .config(ConfigKey::MailRoot) + .map_or(db.path().into(), |root| PathBuf::from(root)) .canonicalize() .context(CanonicalizeSnafu {})?; - let relative_mail_dir = canonical_mail_dir_path - .strip_prefix(&canonical_db_path) - .context(MailDirNotASubdirOfNotmuchRootSnafu { - mail_dir: &canonical_mail_dir_path, - notmuch_root: &canonical_db_path, - })?; - // Build the query to search for all mail in our maildir. - let all_mail_query = format!("path:\"{}/**\"", relative_mail_dir.to_str().unwrap()); + // Figure out our maildir. Either the configured thing, or notmuch's mail root. Which in + // the worst case will be notmuch's database dir, but that's probably not the worst choice. + let mail_dir = match &config.mail_dir { + Some(ref dir) => dir.clone(), + _ => mail_root.clone(), + } + .canonicalize() + .context(CanonicalizeSnafu {})?; + debug!("mail dir: {}", mail_dir.to_string_lossy()); + + // Build the query to search for all mail in our maildir. If the maildir is under the + // notmuch mail root, then search under the relative maildir path (allowing multiple + // maildirs per notmuch dir). If not, assume this is the only maildir for the notmuch dir, + // and use a global query. + let all_mail_query = mail_dir + .strip_prefix(&mail_root) + .ok() + .filter(|rel| rel.components().count() > 0) + .map_or("path:**".to_string(), |rel| { + format!("path:\"{}/**\"", rel.to_str().unwrap()) + }); // Ensure the maildir contains the standard cur, new, and tmp dirs. - let mail_cur_dir = canonical_mail_dir_path.join("cur"); + let mail_cur_dir = mail_dir.join("cur"); if !dry_run { - for path in &[ - &mail_cur_dir, - &canonical_mail_dir_path.join("new"), - &canonical_mail_dir_path.join("tmp"), - ] { + for path in &[&mail_cur_dir, &mail_dir.join("new"), &mail_dir.join("tmp")] { fs::create_dir_all(path).context(CreateMaildirDirSnafu { path })?; } } diff --git a/src/main.rs b/src/main.rs index 43f6112..5ae5342 100755 --- a/src/main.rs +++ b/src/main.rs @@ -61,15 +61,12 @@ fn try_main(stdout: &mut StandardStream) -> Result<(), Error> { .to_owned(); // Determine working directory and load all data files. - let mail_dir = args.path.clone().unwrap_or_else(|| PathBuf::from(".")); - - let config = Config::from_file(mail_dir.join("mujmap.toml")).context(OpenConfigFileSnafu {})?; + let config_path = args.path.clone().unwrap_or_else(|| PathBuf::from(".")); + let config = Config::from_path(&config_path).context(OpenConfigFileSnafu {})?; debug!("Using config: {:?}", config); match args.command { - args::Command::Sync => { - sync(stdout, info_color_spec, mail_dir, args, config).context(SyncSnafu {}) - } + args::Command::Sync => sync(stdout, info_color_spec, args, config).context(SyncSnafu {}), args::Command::Send { read_recipients, recipients, diff --git a/src/sync.rs b/src/sync.rs index 992f37b..88e9ead 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -29,6 +29,9 @@ pub enum Error { #[snafu(display("Could not log string: {}", source))] Log { source: io::Error }, + #[snafu(display("Could not create mujmap state dir `{}': {}", path.to_string_lossy(), source))] + CreateStateDir { path: PathBuf, source: io::Error }, + #[snafu(display("Could not read mujmap state file `{}': {}", filename.to_string_lossy(), source))] ReadStateFile { filename: PathBuf, @@ -199,12 +202,19 @@ impl LatestState { pub fn sync( stdout: &mut StandardStream, info_color_spec: ColorSpec, - mail_dir: PathBuf, args: Args, config: Config, ) -> Result<(), Error> { + let state_dir = config.state_dir.as_ref().unwrap(); + debug!("state dir: {}", state_dir.to_string_lossy()); + + // Ensure the state dir exists. + fs::create_dir_all(&state_dir).context(CreateStateDirSnafu { + path: state_dir.clone(), + })?; + // Grab lock. - let lock_file_path = mail_dir.join("mujmap.lock"); + let lock_file_path = state_dir.join("mujmap.lock"); let mut lock = LockFile::open(&lock_file_path).context(OpenLockFileSnafu { path: lock_file_path, })?; @@ -215,14 +225,14 @@ pub fn sync( } // Load the intermediary state. - let latest_state_filename = mail_dir.join("mujmap.state.json"); + let latest_state_filename = state_dir.join("mujmap.state.json"); let latest_state = LatestState::open(&latest_state_filename).unwrap_or_else(|e| { warn!("{e}"); LatestState::empty() }); // Open the local notmuch database. - let local = Local::open(mail_dir, args.dry_run).context(OpenLocalSnafu {})?; + let local = Local::open(&config, args.dry_run).context(OpenLocalSnafu {})?; // Open the local cache. let cache = Cache::open(&local.mail_cur_dir, &config).context(OpenCacheSnafu {})?;