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
119 for candidate in candidates {
120 if !candidate.path.is_file() {
121 continue;
122 }
123 match chosen {
124 None => chosen = Some(candidate),
125 Some(_) => shadowed.push(candidate.path),
126 }
127 }
128
129 match chosen {
130 Some(c) => ResolvedConfig {
131 kind,
132 path: Some(c.path),
133 source: c.source,
134 shadowed,
135 },
136 None => ResolvedConfig::missing(kind),
137 }
138 }
139}
140
141#[derive(Debug, Clone)]
142pub struct MailPaths {
143 pub notmuch: ResolvedConfig,
144 pub mbsync: ResolvedConfig,
145 pub msmtp: ResolvedConfig,
146 pub notmuch_config: NotmuchConfig,
147 pub mbsync_config: MbsyncConfig,
148 pub msmtp_config: MsmtpConfig,
149 pub maildir_root: PathBuf,
150 pub database_path: PathBuf,
151 pub binaries: crate::settings::Binaries,
152 pub ecr_config_dir: PathBuf,
154 pub ecr_state_dir: PathBuf,
157 pub use_index: bool,
162}
163
164impl MailPaths {
165 pub fn discover() -> Result<Self> {
166 Self::with(&Env::from_process(), &ServerSettings::load())
167 }
168
169 pub fn with(env: &Env, settings: &ServerSettings) -> Result<Self> {
170 let notmuch = env.resolve(ConfigKind::Notmuch, settings);
171 let mbsync = env.resolve(ConfigKind::Mbsync, settings);
172 let msmtp = env.resolve(ConfigKind::Msmtp, settings);
173
174 let notmuch_path = notmuch.path.clone().ok_or_else(|| Error::ConfigNotFound {
175 kind: "notmuch",
176 searched: env
177 .candidates(ConfigKind::Notmuch, settings)
178 .into_iter()
179 .map(|c| c.path)
180 .collect(),
181 })?;
182
183 let notmuch_config = NotmuchConfig::parse(&std::fs::read_to_string(¬much_path)?);
184 let mbsync_config = read_optional(&mbsync)
185 .map(|t| MbsyncConfig::parse(&t))
186 .unwrap_or_default();
187 let msmtp_config = read_optional(&msmtp)
188 .map(|t| MsmtpConfig::parse(&t))
189 .unwrap_or_default();
190
191 let database_path =
192 notmuch_config
193 .database_path
194 .clone()
195 .ok_or_else(|| Error::NoDatabasePath {
196 path: notmuch_path.clone(),
197 })?;
198
199 let maildir_root = settings
200 .maildir_root
201 .clone()
202 .or_else(|| notmuch_config.effective_mail_root().cloned())
203 .unwrap_or_else(|| database_path.clone());
204
205 Ok(Self {
206 notmuch,
207 mbsync,
208 msmtp,
209 notmuch_config,
210 mbsync_config,
211 msmtp_config,
212 maildir_root,
213 database_path,
214 binaries: crate::settings::Binaries::from_settings(settings),
215 ecr_config_dir: env.config_dir.join("ecr"),
216 ecr_state_dir: env.state_dir.join("ecr"),
217 use_index: settings.index.unwrap_or(true),
218 })
219 }
220
221 pub fn settings_file(&self) -> PathBuf {
223 self.ecr_config_dir.join("settings.toml")
224 }
225
226 pub fn oauth_profiles(&self) -> crate::oauth::Profiles {
235 crate::oauth::Profiles::under(&self.ecr_config_dir, &self.ecr_state_dir)
236 }
237
238 pub fn themes_dir(&self) -> PathBuf {
240 self.ecr_config_dir.join("themes")
241 }
242
243 pub fn resolve_relative(&self, rel: &str) -> Result<PathBuf> {
251 let unsafe_path = |reason| Error::UnsafePath {
252 path: rel.to_string(),
253 reason,
254 };
255
256 if rel.trim().is_empty() {
257 return Err(unsafe_path("it is empty"));
258 }
259
260 let candidate = Path::new(rel);
261 if candidate.is_absolute() {
262 return Err(unsafe_path("it is absolute"));
263 }
264
265 for part in candidate.components() {
266 match part {
267 Component::Normal(_) => {}
268 Component::CurDir => {}
269 Component::ParentDir => return Err(unsafe_path("it climbs above the config dir")),
270 Component::RootDir | Component::Prefix(_) => {
271 return Err(unsafe_path("it is absolute"));
272 }
273 }
274 }
275
276 if candidate.extension().and_then(|e| e.to_str()) != Some("toml") {
277 return Err(unsafe_path("it is not a .toml file"));
278 }
279
280 Ok(self.ecr_config_dir.join(candidate))
281 }
282
283 pub fn xapian_dir(&self) -> PathBuf {
284 self.database_path.join(".notmuch").join("xapian")
285 }
286
287 pub fn post_new_hook(&self) -> Option<PathBuf> {
288 let hook = self
289 .notmuch
290 .path
291 .as_ref()?
292 .parent()?
293 .join("hooks")
294 .join("post-new");
295 hook.is_file().then_some(hook)
296 }
297}
298
299fn read_optional(resolved: &ResolvedConfig) -> Option<String> {
300 std::fs::read_to_string(resolved.path.as_ref()?).ok()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::fs;
307
308 fn write(path: &Path, contents: &str) {
309 fs::create_dir_all(path.parent().unwrap()).unwrap();
310 fs::write(path, contents).unwrap();
311 }
312
313 #[test]
314 fn xdg_config_wins_over_the_legacy_dotfile() {
315 let home = tempfile::tempdir().unwrap();
316 let home = home.path();
317 write(
318 &home.join(".config/notmuch/default/config"),
319 "[database]\npath=/srv/Mail\n",
320 );
321 write(
322 &home.join(".notmuch-config"),
323 "[database]\npath=/srv/stale-mail\n",
324 );
325
326 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
327
328 assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
329 assert_eq!(paths.maildir_root, PathBuf::from("/srv/Mail"));
330 }
331
332 #[test]
333 fn the_stale_dotfile_is_reported_as_shadowed() {
334 let home = tempfile::tempdir().unwrap();
335 let home = home.path();
336 write(
337 &home.join(".config/notmuch/default/config"),
338 "[database]\npath=/srv/Mail\n",
339 );
340 write(&home.join(".notmuch-config"), "[database]\npath=/srv/old\n");
341
342 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
343
344 assert_eq!(paths.notmuch.shadowed, vec![home.join(".notmuch-config")]);
345 }
346
347 #[test]
348 fn server_settings_override_everything() {
349 let home = tempfile::tempdir().unwrap();
350 let home = home.path();
351 write(
352 &home.join(".config/notmuch/default/config"),
353 "[database]\npath=/srv/xdg\n",
354 );
355 let explicit = home.join("explicit-config");
356 write(&explicit, "[database]\npath=/srv/explicit\n");
357
358 let settings = ServerSettings {
359 notmuch_config: Some(explicit.clone()),
360 ..Default::default()
361 };
362 let paths = MailPaths::with(&Env::rooted_at(home), &settings).unwrap();
363
364 assert_eq!(paths.notmuch.source, ConfigSource::ServerToml);
365 assert_eq!(paths.maildir_root, PathBuf::from("/srv/explicit"));
366 }
367
368 #[test]
369 fn falls_back_to_the_legacy_dotfile_when_xdg_is_absent() {
370 let home = tempfile::tempdir().unwrap();
371 let home = home.path();
372 write(
373 &home.join(".notmuch-config"),
374 "[database]\npath=/srv/only\n",
375 );
376
377 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
378
379 assert_eq!(paths.notmuch.source, ConfigSource::LegacyDotfile);
380 assert_eq!(paths.maildir_root, PathBuf::from("/srv/only"));
381 }
382
383 #[test]
384 fn missing_notmuch_config_lists_every_location_searched() {
385 let home = tempfile::tempdir().unwrap();
386 let err =
387 MailPaths::with(&Env::rooted_at(home.path()), &ServerSettings::default()).unwrap_err();
388
389 let message = err.to_string();
390 assert!(
391 message.contains(".config/notmuch/default/config"),
392 "{message}"
393 );
394 assert!(message.contains(".notmuch-config"), "{message}");
395 }
396
397 #[test]
398 fn maildir_root_never_guesses_from_the_data_dir() {
399 let home = tempfile::tempdir().unwrap();
400 let home = home.path();
401 write(
402 &home.join(".config/notmuch/default/config"),
403 "[database]\npath=/home/someone/.local/share/Mail\n",
404 );
405
406 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
407
408 assert_eq!(
409 paths.maildir_root,
410 PathBuf::from("/home/someone/.local/share/Mail")
411 );
412 }
413
414 fn rooted_paths(home: &Path) -> MailPaths {
415 write(
416 &home.join(".config/notmuch/default/config"),
417 "[database]\npath=/srv/Mail\n",
418 );
419 MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap()
420 }
421
422 #[test]
423 fn a_relative_theme_resolves_inside_the_config_dir() {
424 let home = tempfile::tempdir().unwrap();
425 let paths = rooted_paths(home.path());
426
427 assert_eq!(
428 paths.resolve_relative("themes/nord.toml").unwrap(),
429 home.path().join(".config/ecr/themes/nord.toml")
430 );
431 }
432
433 #[test]
434 fn a_relative_path_cannot_climb_out_of_the_config_dir() {
435 let home = tempfile::tempdir().unwrap();
436 let paths = rooted_paths(home.path());
437
438 for attempt in [
439 "../../../etc/passwd.toml",
440 "themes/../../secrets.toml",
441 "/etc/passwd.toml",
442 "themes/../../../../../../etc/shadow.toml",
443 ] {
444 assert!(
445 paths.resolve_relative(attempt).is_err(),
446 "{attempt} was accepted"
447 );
448 }
449 }
450
451 #[test]
452 fn only_toml_files_resolve() {
453 let home = tempfile::tempdir().unwrap();
454 let paths = rooted_paths(home.path());
455
456 assert!(paths.resolve_relative("themes/nord.conf").is_err());
457 assert!(paths.resolve_relative("../.ssh/id_rsa").is_err());
458 assert!(paths.resolve_relative("").is_err());
459 assert!(paths.resolve_relative(" ").is_err());
460 }
461}