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 notmuch_config: Option<PathBuf>,
18 pub notmuch_profile: Option<String>,
19 pub mbsyncrc: Option<PathBuf>,
20}
21
22impl Env {
23 pub fn from_process() -> Self {
24 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
25 Self {
26 config_dir: dirs::config_dir().unwrap_or_else(|| home.join(".config")),
27 home,
28 notmuch_config: std::env::var_os("NOTMUCH_CONFIG").map(PathBuf::from),
29 notmuch_profile: std::env::var("NOTMUCH_PROFILE").ok(),
30 mbsyncrc: std::env::var_os("MBSYNCRC").map(PathBuf::from),
31 }
32 }
33
34 pub fn rooted_at(home: &Path) -> Self {
35 Self {
36 home: home.to_path_buf(),
37 config_dir: home.join(".config"),
38 notmuch_config: None,
39 notmuch_profile: None,
40 mbsyncrc: None,
41 }
42 }
43
44 fn candidates(&self, kind: ConfigKind, settings: &ServerSettings) -> Vec<Candidate> {
45 let mut out = Vec::new();
46
47 let explicit = match kind {
48 ConfigKind::Notmuch => settings.notmuch_config.as_ref(),
49 ConfigKind::Mbsync => settings.mbsync_config.as_ref(),
50 ConfigKind::Msmtp => settings.msmtp_config.as_ref(),
51 };
52 if let Some(path) = explicit {
53 out.push(Candidate {
54 path: path.clone(),
55 source: ConfigSource::ServerToml,
56 });
57 }
58
59 match kind {
60 ConfigKind::Notmuch => {
61 if let Some(path) = &self.notmuch_config {
62 out.push(Candidate {
63 path: path.clone(),
64 source: ConfigSource::EnvVar("NOTMUCH_CONFIG".into()),
65 });
66 }
67 let profile = self.notmuch_profile.as_deref().unwrap_or("default");
68 out.push(Candidate {
69 path: self.config_dir.join("notmuch").join(profile).join("config"),
70 source: ConfigSource::Xdg,
71 });
72 out.push(Candidate {
73 path: self.home.join(".notmuch-config"),
74 source: ConfigSource::LegacyDotfile,
75 });
76 }
77 ConfigKind::Mbsync => {
78 if let Some(path) = &self.mbsyncrc {
79 out.push(Candidate {
80 path: path.clone(),
81 source: ConfigSource::EnvVar("MBSYNCRC".into()),
82 });
83 }
84 out.push(Candidate {
85 path: self.config_dir.join("isyncrc"),
86 source: ConfigSource::Xdg,
87 });
88 out.push(Candidate {
89 path: self.config_dir.join("mbsync").join("mbsyncrc"),
90 source: ConfigSource::Xdg,
91 });
92 out.push(Candidate {
93 path: self.home.join(".mbsyncrc"),
94 source: ConfigSource::LegacyDotfile,
95 });
96 }
97 ConfigKind::Msmtp => {
98 out.push(Candidate {
99 path: self.config_dir.join("msmtp").join("config"),
100 source: ConfigSource::Xdg,
101 });
102 out.push(Candidate {
103 path: self.home.join(".msmtprc"),
104 source: ConfigSource::LegacyDotfile,
105 });
106 }
107 }
108 out
109 }
110
111 pub fn resolve(&self, kind: ConfigKind, settings: &ServerSettings) -> ResolvedConfig {
112 let candidates = self.candidates(kind, settings);
113 let mut chosen: Option<Candidate> = None;
114 let mut shadowed = Vec::new();
115
116 for candidate in candidates {
117 if !candidate.path.is_file() {
118 continue;
119 }
120 match chosen {
121 None => chosen = Some(candidate),
122 Some(_) => shadowed.push(candidate.path),
123 }
124 }
125
126 match chosen {
127 Some(c) => ResolvedConfig {
128 kind,
129 path: Some(c.path),
130 source: c.source,
131 shadowed,
132 },
133 None => ResolvedConfig::missing(kind),
134 }
135 }
136}
137
138#[derive(Debug, Clone)]
139pub struct MailPaths {
140 pub notmuch: ResolvedConfig,
141 pub mbsync: ResolvedConfig,
142 pub msmtp: ResolvedConfig,
143 pub notmuch_config: NotmuchConfig,
144 pub mbsync_config: MbsyncConfig,
145 pub msmtp_config: MsmtpConfig,
146 pub maildir_root: PathBuf,
147 pub database_path: PathBuf,
148 pub binaries: crate::settings::Binaries,
149 pub ecr_config_dir: PathBuf,
151}
152
153impl MailPaths {
154 pub fn discover() -> Result<Self> {
155 Self::with(&Env::from_process(), &ServerSettings::load())
156 }
157
158 pub fn with(env: &Env, settings: &ServerSettings) -> Result<Self> {
159 let notmuch = env.resolve(ConfigKind::Notmuch, settings);
160 let mbsync = env.resolve(ConfigKind::Mbsync, settings);
161 let msmtp = env.resolve(ConfigKind::Msmtp, settings);
162
163 let notmuch_path = notmuch.path.clone().ok_or_else(|| Error::ConfigNotFound {
164 kind: "notmuch",
165 searched: env
166 .candidates(ConfigKind::Notmuch, settings)
167 .into_iter()
168 .map(|c| c.path)
169 .collect(),
170 })?;
171
172 let notmuch_config = NotmuchConfig::parse(&std::fs::read_to_string(¬much_path)?);
173 let mbsync_config = read_optional(&mbsync)
174 .map(|t| MbsyncConfig::parse(&t))
175 .unwrap_or_default();
176 let msmtp_config = read_optional(&msmtp)
177 .map(|t| MsmtpConfig::parse(&t))
178 .unwrap_or_default();
179
180 let database_path =
181 notmuch_config
182 .database_path
183 .clone()
184 .ok_or_else(|| Error::NoDatabasePath {
185 path: notmuch_path.clone(),
186 })?;
187
188 let maildir_root = settings
189 .maildir_root
190 .clone()
191 .or_else(|| notmuch_config.effective_mail_root().cloned())
192 .unwrap_or_else(|| database_path.clone());
193
194 Ok(Self {
195 notmuch,
196 mbsync,
197 msmtp,
198 notmuch_config,
199 mbsync_config,
200 msmtp_config,
201 maildir_root,
202 database_path,
203 binaries: crate::settings::Binaries::from_settings(settings),
204 ecr_config_dir: env.config_dir.join("ecr"),
205 })
206 }
207
208 pub fn settings_file(&self) -> PathBuf {
210 self.ecr_config_dir.join("settings.toml")
211 }
212
213 pub fn themes_dir(&self) -> PathBuf {
215 self.ecr_config_dir.join("themes")
216 }
217
218 pub fn resolve_relative(&self, rel: &str) -> Result<PathBuf> {
226 let unsafe_path = |reason| Error::UnsafePath {
227 path: rel.to_string(),
228 reason,
229 };
230
231 if rel.trim().is_empty() {
232 return Err(unsafe_path("it is empty"));
233 }
234
235 let candidate = Path::new(rel);
236 if candidate.is_absolute() {
237 return Err(unsafe_path("it is absolute"));
238 }
239
240 for part in candidate.components() {
241 match part {
242 Component::Normal(_) => {}
243 Component::CurDir => {}
244 Component::ParentDir => return Err(unsafe_path("it climbs above the config dir")),
245 Component::RootDir | Component::Prefix(_) => {
246 return Err(unsafe_path("it is absolute"));
247 }
248 }
249 }
250
251 if candidate.extension().and_then(|e| e.to_str()) != Some("toml") {
252 return Err(unsafe_path("it is not a .toml file"));
253 }
254
255 Ok(self.ecr_config_dir.join(candidate))
256 }
257
258 pub fn xapian_dir(&self) -> PathBuf {
259 self.database_path.join(".notmuch").join("xapian")
260 }
261
262 pub fn post_new_hook(&self) -> Option<PathBuf> {
263 let hook = self
264 .notmuch
265 .path
266 .as_ref()?
267 .parent()?
268 .join("hooks")
269 .join("post-new");
270 hook.is_file().then_some(hook)
271 }
272}
273
274fn read_optional(resolved: &ResolvedConfig) -> Option<String> {
275 std::fs::read_to_string(resolved.path.as_ref()?).ok()
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::fs;
282
283 fn write(path: &Path, contents: &str) {
284 fs::create_dir_all(path.parent().unwrap()).unwrap();
285 fs::write(path, contents).unwrap();
286 }
287
288 #[test]
289 fn xdg_config_wins_over_the_legacy_dotfile() {
290 let home = tempfile::tempdir().unwrap();
291 let home = home.path();
292 write(
293 &home.join(".config/notmuch/default/config"),
294 "[database]\npath=/srv/Mail\n",
295 );
296 write(
297 &home.join(".notmuch-config"),
298 "[database]\npath=/srv/stale-mail\n",
299 );
300
301 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
302
303 assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
304 assert_eq!(paths.maildir_root, PathBuf::from("/srv/Mail"));
305 }
306
307 #[test]
308 fn the_stale_dotfile_is_reported_as_shadowed() {
309 let home = tempfile::tempdir().unwrap();
310 let home = home.path();
311 write(
312 &home.join(".config/notmuch/default/config"),
313 "[database]\npath=/srv/Mail\n",
314 );
315 write(&home.join(".notmuch-config"), "[database]\npath=/srv/old\n");
316
317 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
318
319 assert_eq!(paths.notmuch.shadowed, vec![home.join(".notmuch-config")]);
320 }
321
322 #[test]
323 fn server_settings_override_everything() {
324 let home = tempfile::tempdir().unwrap();
325 let home = home.path();
326 write(
327 &home.join(".config/notmuch/default/config"),
328 "[database]\npath=/srv/xdg\n",
329 );
330 let explicit = home.join("explicit-config");
331 write(&explicit, "[database]\npath=/srv/explicit\n");
332
333 let settings = ServerSettings {
334 notmuch_config: Some(explicit.clone()),
335 ..Default::default()
336 };
337 let paths = MailPaths::with(&Env::rooted_at(home), &settings).unwrap();
338
339 assert_eq!(paths.notmuch.source, ConfigSource::ServerToml);
340 assert_eq!(paths.maildir_root, PathBuf::from("/srv/explicit"));
341 }
342
343 #[test]
344 fn falls_back_to_the_legacy_dotfile_when_xdg_is_absent() {
345 let home = tempfile::tempdir().unwrap();
346 let home = home.path();
347 write(
348 &home.join(".notmuch-config"),
349 "[database]\npath=/srv/only\n",
350 );
351
352 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
353
354 assert_eq!(paths.notmuch.source, ConfigSource::LegacyDotfile);
355 assert_eq!(paths.maildir_root, PathBuf::from("/srv/only"));
356 }
357
358 #[test]
359 fn missing_notmuch_config_lists_every_location_searched() {
360 let home = tempfile::tempdir().unwrap();
361 let err =
362 MailPaths::with(&Env::rooted_at(home.path()), &ServerSettings::default()).unwrap_err();
363
364 let message = err.to_string();
365 assert!(
366 message.contains(".config/notmuch/default/config"),
367 "{message}"
368 );
369 assert!(message.contains(".notmuch-config"), "{message}");
370 }
371
372 #[test]
373 fn maildir_root_never_guesses_from_the_data_dir() {
374 let home = tempfile::tempdir().unwrap();
375 let home = home.path();
376 write(
377 &home.join(".config/notmuch/default/config"),
378 "[database]\npath=/home/someone/.local/share/Mail\n",
379 );
380
381 let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
382
383 assert_eq!(
384 paths.maildir_root,
385 PathBuf::from("/home/someone/.local/share/Mail")
386 );
387 }
388
389 fn rooted_paths(home: &Path) -> MailPaths {
390 write(
391 &home.join(".config/notmuch/default/config"),
392 "[database]\npath=/srv/Mail\n",
393 );
394 MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap()
395 }
396
397 #[test]
398 fn a_relative_theme_resolves_inside_the_config_dir() {
399 let home = tempfile::tempdir().unwrap();
400 let paths = rooted_paths(home.path());
401
402 assert_eq!(
403 paths.resolve_relative("themes/nord.toml").unwrap(),
404 home.path().join(".config/ecr/themes/nord.toml")
405 );
406 }
407
408 #[test]
409 fn a_relative_path_cannot_climb_out_of_the_config_dir() {
410 let home = tempfile::tempdir().unwrap();
411 let paths = rooted_paths(home.path());
412
413 for attempt in [
414 "../../../etc/passwd.toml",
415 "themes/../../secrets.toml",
416 "/etc/passwd.toml",
417 "themes/../../../../../../etc/shadow.toml",
418 ] {
419 assert!(
420 paths.resolve_relative(attempt).is_err(),
421 "{attempt} was accepted"
422 );
423 }
424 }
425
426 #[test]
427 fn only_toml_files_resolve() {
428 let home = tempfile::tempdir().unwrap();
429 let paths = rooted_paths(home.path());
430
431 assert!(paths.resolve_relative("themes/nord.conf").is_err());
432 assert!(paths.resolve_relative("../.ssh/id_rsa").is_err());
433 assert!(paths.resolve_relative("").is_err());
434 assert!(paths.resolve_relative(" ").is_err());
435 }
436}