1use crate::error::{Error, Result};
2use crate::parse::{MbsyncConfig, MsmtpConfig, NotmuchConfig};
3use crate::settings::ServerSettings;
4use ecr_core::doctor::{ConfigKind, ConfigSource, ResolvedConfig};
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct Candidate {
9 pub path: PathBuf,
10 pub source: ConfigSource,
11}
12
13#[derive(Debug, Clone)]
14pub struct Env {
15 pub home: PathBuf,
16 pub config_dir: PathBuf,
17 pub state_dir: PathBuf,
18 pub notmuch_config: Option<PathBuf>,
19 pub notmuch_profile: Option<String>,
20 pub mbsyncrc: Option<PathBuf>,
21}
22
23impl Env {
24 pub fn from_process() -> Self {
25 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
26 Self {
27 config_dir: dirs::config_dir().unwrap_or_else(|| home.join(".config")),
28 state_dir: dirs::state_dir().unwrap_or_else(|| home.join(".local").join("state")),
29 home,
30 notmuch_config: std::env::var_os("NOTMUCH_CONFIG").map(PathBuf::from),
31 notmuch_profile: std::env::var("NOTMUCH_PROFILE").ok(),
32 mbsyncrc: std::env::var_os("MBSYNCRC").map(PathBuf::from),
33 }
34 }
35
36 pub fn rooted_at(home: &Path) -> Self {
37 Self {
38 home: home.to_path_buf(),
39 config_dir: home.join(".config"),
40 state_dir: home.join(".local").join("state"),
41 notmuch_config: None,
42 notmuch_profile: None,
43 mbsyncrc: None,
44 }
45 }
46
47 fn candidates(&self, kind: ConfigKind, settings: &ServerSettings) -> Vec<Candidate> {
48 let mut out = Vec::new();
49
50 let explicit = match kind {
51 ConfigKind::Notmuch => settings.notmuch_config.as_ref(),
52 ConfigKind::Mbsync => settings.mbsync_config.as_ref(),
53 ConfigKind::Msmtp => settings.msmtp_config.as_ref(),
54 };
55 if let Some(path) = explicit {
56 out.push(Candidate {
57 path: path.clone(),
58 source: ConfigSource::ServerToml,
59 });
60 }
61
62 match kind {
63 ConfigKind::Notmuch => {
64 if let Some(path) = &self.notmuch_config {
65 out.push(Candidate {
66 path: path.clone(),
67 source: ConfigSource::EnvVar("NOTMUCH_CONFIG".into()),
68 });
69 }
70 let profile = self.notmuch_profile.as_deref().unwrap_or("default");
71 out.push(Candidate {
72 path: self.config_dir.join("notmuch").join(profile).join("config"),
73 source: ConfigSource::Xdg,
74 });
75 out.push(Candidate {
76 path: self.home.join(".notmuch-config"),
77 source: ConfigSource::LegacyDotfile,
78 });
79 }
80 ConfigKind::Mbsync => {
81 if let Some(path) = &self.mbsyncrc {
82 out.push(Candidate {
83 path: path.clone(),
84 source: ConfigSource::EnvVar("MBSYNCRC".into()),
85 });
86 }
87 out.push(Candidate {
88 path: self.config_dir.join("isyncrc"),
89 source: ConfigSource::Xdg,
90 });
91 out.push(Candidate {
92 path: self.config_dir.join("mbsync").join("mbsyncrc"),
93 source: ConfigSource::Xdg,
94 });
95 out.push(Candidate {
96 path: self.home.join(".mbsyncrc"),
97 source: ConfigSource::LegacyDotfile,
98 });
99 }
100 ConfigKind::Msmtp => {
101 out.push(Candidate {
102 path: self.config_dir.join("msmtp").join("config"),
103 source: ConfigSource::Xdg,
104 });
105 out.push(Candidate {
106 path: self.home.join(".msmtprc"),
107 source: ConfigSource::LegacyDotfile,
108 });
109 }
110 }
111 out
112 }
113
114 pub fn resolve(&self, kind: ConfigKind, settings: &ServerSettings) -> ResolvedConfig {
115 let candidates = self.candidates(kind, settings);
116 let mut chosen: Option<Candidate> = None;
117 let mut shadowed = Vec::new();
118 let mut seen: Vec<PathBuf> = Vec::new();
125
126 for candidate in candidates {
127 if !candidate.path.is_file() {
128 continue;
129 }
130 let canon =
131 std::fs::canonicalize(&candidate.path).unwrap_or_else(|_| candidate.path.clone());
132 if seen.contains(&canon) {
133 continue;
134 }
135 seen.push(canon);
136 match chosen {
137 None => chosen = Some(candidate),
138 Some(_) => shadowed.push(candidate.path),
139 }
140 }
141
142 match chosen {
143 Some(c) => ResolvedConfig {
144 kind,
145 path: Some(c.path),
146 source: c.source,
147 shadowed,
148 },
149 None => ResolvedConfig::missing(kind),
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
155pub struct MailPaths {
156 pub notmuch: ResolvedConfig,
157 pub mbsync: ResolvedConfig,
158 pub msmtp: ResolvedConfig,
159 pub notmuch_config: NotmuchConfig,
160 pub mbsync_config: MbsyncConfig,
161 pub msmtp_config: MsmtpConfig,
162 pub maildir_root: PathBuf,
163 pub database_path: PathBuf,
164 pub binaries: crate::settings::Binaries,
165 pub ecr_config_dir: PathBuf,
167 pub ecr_state_dir: PathBuf,
170 pub use_index: bool,
175}
176
177impl MailPaths {
178 pub fn discover() -> Result<Self> {
179 Self::with(&Env::from_process(), &ServerSettings::load())
180 }
181
182 pub fn with(env: &Env, settings: &ServerSettings) -> Result<Self> {
183 let notmuch = env.resolve(ConfigKind::Notmuch, settings);
184 let mbsync = env.resolve(ConfigKind::Mbsync, settings);
185 let msmtp = env.resolve(ConfigKind::Msmtp, settings);
186
187 let notmuch_path = notmuch.path.clone().ok_or_else(|| Error::ConfigNotFound {
188 kind: "notmuch",
189 searched: env
190 .candidates(ConfigKind::Notmuch, settings)
191 .into_iter()
192 .map(|c| c.path)
193 .collect(),
194 })?;
195
196 let notmuch_config = NotmuchConfig::parse(&std::fs::read_to_string(¬much_path)?);
197 let mbsync_config = read_optional(&mbsync)
198 .map(|t| MbsyncConfig::parse(&t))
199 .unwrap_or_default();
200 let msmtp_config = read_optional(&msmtp)
201 .map(|t| MsmtpConfig::parse(&t))
202 .unwrap_or_default();
203
204 let database_path =
205 notmuch_config
206 .database_path
207 .clone()
208 .ok_or_else(|| Error::NoDatabasePath {
209 path: notmuch_path.clone(),
210 })?;
211
212 let maildir_root = settings
213 .maildir_root
214 .clone()
215 .or_else(|| notmuch_config.effective_mail_root().cloned())
216 .unwrap_or_else(|| database_path.clone());
217
218 Ok(Self {
219 notmuch,
220 mbsync,
221 msmtp,
222 notmuch_config,
223 mbsync_config,
224 msmtp_config,
225 maildir_root,
226 database_path,
227 binaries: crate::settings::Binaries::from_settings(settings),
228 ecr_config_dir: env.config_dir.join("ecr"),
229 ecr_state_dir: env.state_dir.join("ecr"),
230 use_index: settings.index.unwrap_or(true),
231 })
232 }
233
234 pub fn settings_file(&self) -> PathBuf {
236 self.ecr_config_dir.join("settings.toml")
237 }
238
239 pub fn oauth_profiles(&self) -> crate::oauth::Profiles {
248 crate::oauth::Profiles::under(&self.ecr_config_dir, &self.ecr_state_dir)
249 }
250
251 pub fn themes_dir(&self) -> PathBuf {
253 self.ecr_config_dir.join("themes")
254 }
255
256 pub fn resolve_relative(&self, rel: &str) -> Result<PathBuf> {
264 let unsafe_path = |reason| Error::UnsafePath {
265 path: rel.to_string(),
266 reason,
267 };
268
269 if rel.trim().is_empty() {
270 return Err(unsafe_path("it is empty"));
271 }
272
273 let candidate = Path::new(rel);
274 if candidate.is_absolute() {
275 return Err(unsafe_path("it is absolute"));
276 }
277
278 for part in candidate.components() {
279 match part {
280 Component::Normal(_) => {}
281 Component::CurDir => {}
282 Component::ParentDir => return Err(unsafe_path("it climbs above the config dir")),
283 Component::RootDir | Component::Prefix(_) => {
284 return Err(unsafe_path("it is absolute"));
285 }
286 }
287 }
288
289 if candidate.extension().and_then(|e| e.to_str()) != Some("toml") {
290 return Err(unsafe_path("it is not a .toml file"));
291 }
292
293 Ok(self.ecr_config_dir.join(candidate))
294 }
295
296 pub fn xapian_dir(&self) -> PathBuf {
297 self.database_path.join(".notmuch").join("xapian")
298 }
299
300 pub fn post_new_hook(&self) -> Option<PathBuf> {
301 let hook = self
302 .notmuch
303 .path
304 .as_ref()?
305 .parent()?
306 .join("hooks")
307 .join("post-new");
308 hook.is_file().then_some(hook)
309 }
310}
311
312fn read_optional(resolved: &ResolvedConfig) -> Option<String> {
313 std::fs::read_to_string(resolved.path.as_ref()?).ok()
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use std::fs;
320
321 fn write(path: &Path, contents: &str) {
322 fs::create_dir_all(path.parent().unwrap()).unwrap();
323 fs::write(path, contents).unwrap();
324 }
325
326 #[test]
327 fn xdg_config_wins_over_the_legacy_dotfile() {
328 let home = tempfile::tempdir().unwrap();
329 let home = home.path();
330 write(
331 &home.join(".config/notmuch/default/config"),
332 "[database]\npath=/srv/Mail\n",
333 );
334 write(
335 &home.join(".notmuch-config"),
336 "[database]\npath=/srv/stale-mail\n",
337 );
338
339 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
340
341 assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
342 assert_eq!(paths.maildir_root, PathBuf::from("/srv/Mail"));
343 }
344
345 #[test]
346 fn the_stale_dotfile_is_reported_as_shadowed() {
347 let home = tempfile::tempdir().unwrap();
348 let home = home.path();
349 write(
350 &home.join(".config/notmuch/default/config"),
351 "[database]\npath=/srv/Mail\n",
352 );
353 write(&home.join(".notmuch-config"), "[database]\npath=/srv/old\n");
354
355 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
356
357 assert_eq!(paths.notmuch.shadowed, vec![home.join(".notmuch-config")]);
358 }
359
360 #[test]
361 fn an_env_var_pointing_at_the_xdg_default_is_not_its_own_stale_copy() {
362 let home = tempfile::tempdir().unwrap();
363 let home = home.path();
364 let xdg = home.join(".config/notmuch/default/config");
365 write(&xdg, "[database]\npath=/srv/Mail\n");
366
367 let env = Env {
369 notmuch_config: Some(xdg.clone()),
370 ..Env::rooted_at(home)
371 };
372
373 let paths = MailPaths::with(&env, &ServerSettings::default()).unwrap();
374
375 assert_eq!(
376 paths.notmuch.source,
377 ConfigSource::EnvVar("NOTMUCH_CONFIG".into())
378 );
379 assert_eq!(paths.notmuch.path.as_deref(), Some(xdg.as_path()));
380 assert!(
381 paths.notmuch.shadowed.is_empty(),
382 "the XDG default reached through $NOTMUCH_CONFIG is the same file, not a stale copy: {:?}",
383 paths.notmuch.shadowed
384 );
385 }
386
387 #[test]
388 fn a_legacy_dotfile_symlinked_to_the_chosen_config_is_not_stale() {
389 let home = tempfile::tempdir().unwrap();
390 let home = home.path();
391 let xdg = home.join(".config/notmuch/default/config");
392 write(&xdg, "[database]\npath=/srv/Mail\n");
393 std::os::unix::fs::symlink(&xdg, home.join(".notmuch-config")).unwrap();
395
396 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
397
398 assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
399 assert!(
400 paths.notmuch.shadowed.is_empty(),
401 "a symlink to the chosen config is the same file, not a stale copy: {:?}",
402 paths.notmuch.shadowed
403 );
404 }
405
406 #[test]
407 fn server_settings_override_everything() {
408 let home = tempfile::tempdir().unwrap();
409 let home = home.path();
410 write(
411 &home.join(".config/notmuch/default/config"),
412 "[database]\npath=/srv/xdg\n",
413 );
414 let explicit = home.join("explicit-config");
415 write(&explicit, "[database]\npath=/srv/explicit\n");
416
417 let settings = ServerSettings {
418 notmuch_config: Some(explicit.clone()),
419 ..Default::default()
420 };
421 let paths = MailPaths::with(&Env::rooted_at(home), &settings).unwrap();
422
423 assert_eq!(paths.notmuch.source, ConfigSource::ServerToml);
424 assert_eq!(paths.maildir_root, PathBuf::from("/srv/explicit"));
425 }
426
427 #[test]
428 fn falls_back_to_the_legacy_dotfile_when_xdg_is_absent() {
429 let home = tempfile::tempdir().unwrap();
430 let home = home.path();
431 write(
432 &home.join(".notmuch-config"),
433 "[database]\npath=/srv/only\n",
434 );
435
436 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
437
438 assert_eq!(paths.notmuch.source, ConfigSource::LegacyDotfile);
439 assert_eq!(paths.maildir_root, PathBuf::from("/srv/only"));
440 }
441
442 #[test]
443 fn missing_notmuch_config_lists_every_location_searched() {
444 let home = tempfile::tempdir().unwrap();
445 let err =
446 MailPaths::with(&Env::rooted_at(home.path()), &ServerSettings::default()).unwrap_err();
447
448 let message = err.to_string();
449 assert!(
450 message.contains(".config/notmuch/default/config"),
451 "{message}"
452 );
453 assert!(message.contains(".notmuch-config"), "{message}");
454 }
455
456 #[test]
457 fn maildir_root_never_guesses_from_the_data_dir() {
458 let home = tempfile::tempdir().unwrap();
459 let home = home.path();
460 write(
461 &home.join(".config/notmuch/default/config"),
462 "[database]\npath=/home/someone/.local/share/Mail\n",
463 );
464
465 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
466
467 assert_eq!(
468 paths.maildir_root,
469 PathBuf::from("/home/someone/.local/share/Mail")
470 );
471 }
472
473 fn rooted_paths(home: &Path) -> MailPaths {
474 write(
475 &home.join(".config/notmuch/default/config"),
476 "[database]\npath=/srv/Mail\n",
477 );
478 MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap()
479 }
480
481 #[test]
482 fn a_relative_theme_resolves_inside_the_config_dir() {
483 let home = tempfile::tempdir().unwrap();
484 let paths = rooted_paths(home.path());
485
486 assert_eq!(
487 paths.resolve_relative("themes/nord.toml").unwrap(),
488 home.path().join(".config/ecr/themes/nord.toml")
489 );
490 }
491
492 #[test]
493 fn a_relative_path_cannot_climb_out_of_the_config_dir() {
494 let home = tempfile::tempdir().unwrap();
495 let paths = rooted_paths(home.path());
496
497 for attempt in [
498 "../../../etc/passwd.toml",
499 "themes/../../secrets.toml",
500 "/etc/passwd.toml",
501 "themes/../../../../../../etc/shadow.toml",
502 ] {
503 assert!(
504 paths.resolve_relative(attempt).is_err(),
505 "{attempt} was accepted"
506 );
507 }
508 }
509
510 #[test]
511 fn only_toml_files_resolve() {
512 let home = tempfile::tempdir().unwrap();
513 let paths = rooted_paths(home.path());
514
515 assert!(paths.resolve_relative("themes/nord.conf").is_err());
516 assert!(paths.resolve_relative("../.ssh/id_rsa").is_err());
517 assert!(paths.resolve_relative("").is_err());
518 assert!(paths.resolve_relative(" ").is_err());
519 }
520}