1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
5pub struct NotmuchConfig {
6 pub database_path: Option<PathBuf>,
7 pub mail_root: Option<PathBuf>,
8 pub primary_email: Option<String>,
9 pub other_email: Vec<String>,
10 pub user_name: Option<String>,
11 pub exclude_tags: Vec<String>,
12 pub new_tags: Vec<String>,
13}
14
15impl NotmuchConfig {
16 pub fn parse(text: &str) -> Self {
17 let mut cfg = NotmuchConfig::default();
18 let mut section = String::new();
19
20 for line in text.lines() {
21 let line = line.trim();
22 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
23 continue;
24 }
25 if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
26 section = name.trim().to_ascii_lowercase();
27 continue;
28 }
29 let Some((key, value)) = line.split_once('=') else {
30 continue;
31 };
32 let key = key.trim().to_ascii_lowercase();
33 let value = value.trim();
34
35 match (section.as_str(), key.as_str()) {
36 ("database", "path") => cfg.database_path = Some(PathBuf::from(value)),
37 ("database", "mail_root") => cfg.mail_root = Some(PathBuf::from(value)),
38 ("user", "primary_email") => cfg.primary_email = non_empty(value),
39 ("user", "other_email") => cfg.other_email = split_list(value),
40 ("user", "name") => cfg.user_name = non_empty(value),
41 ("search", "exclude_tags") => cfg.exclude_tags = split_list(value),
42 ("new", "tags") => cfg.new_tags = split_list(value),
43 _ => {}
44 }
45 }
46 cfg
47 }
48
49 pub fn effective_mail_root(&self) -> Option<&PathBuf> {
50 self.mail_root.as_ref().or(self.database_path.as_ref())
51 }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct MbsyncConfig {
56 pub imap_accounts: BTreeMap<String, ImapAccount>,
57 pub imap_stores: BTreeMap<String, String>,
58 pub maildir_stores: BTreeMap<String, MaildirStore>,
59 pub channels: BTreeMap<String, Channel>,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct ImapAccount {
64 pub user: Option<String>,
65 pub host: Option<String>,
66 pub pass_cmd: Option<String>,
67}
68
69impl ImapAccount {
70 pub fn oauth_profile(&self) -> Option<&str> {
77 let cmd = self.pass_cmd.as_deref()?.trim_matches('"');
78 let mut words = cmd.split_whitespace();
79 let binary = words.next()?.rsplit('/').next()?;
82 match binary {
83 "ecr" => (words.next()? == "oauth" && words.next()? == "token")
84 .then(|| words.next())
85 .flatten(),
86 "oauthman" => (words.next()? == "token").then(|| words.next()).flatten(),
87 _ => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct MaildirStore {
94 pub path: Option<PathBuf>,
95 pub inbox: Option<PathBuf>,
96}
97
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct Channel {
100 pub far: Option<String>,
101 pub near: Option<String>,
102}
103
104impl Channel {
105 pub fn near_store(&self) -> Option<&str> {
106 self.near.as_deref().map(strip_store_ref)
107 }
108
109 pub fn far_store(&self) -> Option<&str> {
110 self.far.as_deref().map(strip_store_ref)
111 }
112}
113
114fn strip_store_ref(value: &str) -> &str {
115 value
116 .trim()
117 .trim_matches(':')
118 .split(':')
119 .next()
120 .unwrap_or("")
121}
122
123enum Block {
124 ImapAccount(String),
125 ImapStore(String),
126 MaildirStore(String),
127 Channel(String),
128 Other,
129}
130
131impl MbsyncConfig {
132 pub fn parse(text: &str) -> Self {
133 let mut cfg = MbsyncConfig::default();
134 let mut block = Block::Other;
135
136 for line in text.lines() {
137 let line = line.trim();
138 if line.is_empty() || line.starts_with('#') {
139 continue;
140 }
141 let (keyword, rest) = match line.split_once(char::is_whitespace) {
142 Some((k, r)) => (k, r.trim()),
143 None => (line, ""),
144 };
145
146 match keyword.to_ascii_lowercase().as_str() {
147 "maildirstore" => {
148 block = Block::MaildirStore(rest.to_string());
149 cfg.maildir_stores.entry(rest.to_string()).or_default();
150 }
151 "channel" => {
152 block = Block::Channel(rest.to_string());
153 cfg.channels.entry(rest.to_string()).or_default();
154 }
155 "imapaccount" => {
156 block = Block::ImapAccount(rest.to_string());
157 cfg.imap_accounts.entry(rest.to_string()).or_default();
158 }
159 "imapstore" => {
160 block = Block::ImapStore(rest.to_string());
161 }
162 "group" => block = Block::Other,
163 "account" => {
164 if let Block::ImapStore(store) = &block {
165 cfg.imap_stores.insert(store.clone(), rest.to_string());
166 }
167 }
168 "user" => {
169 if let Block::ImapAccount(name) = &block {
170 if let Some(account) = cfg.imap_accounts.get_mut(name) {
171 account.user = non_empty(rest);
172 }
173 }
174 }
175 "host" => {
176 if let Block::ImapAccount(name) = &block {
177 if let Some(account) = cfg.imap_accounts.get_mut(name) {
178 account.host = non_empty(rest);
179 }
180 }
181 }
182 "passcmd" => {
183 if let Block::ImapAccount(name) = &block {
184 if let Some(account) = cfg.imap_accounts.get_mut(name) {
185 account.pass_cmd = non_empty(rest);
186 }
187 }
188 }
189 "path" => {
190 if let Block::MaildirStore(name) = &block {
191 if let Some(store) = cfg.maildir_stores.get_mut(name) {
192 store.path = Some(PathBuf::from(rest));
193 }
194 }
195 }
196 "inbox" => {
197 if let Block::MaildirStore(name) = &block {
198 if let Some(store) = cfg.maildir_stores.get_mut(name) {
199 store.inbox = Some(PathBuf::from(rest));
200 }
201 }
202 }
203 "far" => {
204 if let Block::Channel(name) = &block {
205 if let Some(channel) = cfg.channels.get_mut(name) {
206 channel.far = Some(rest.to_string());
207 }
208 }
209 }
210 "near" => {
211 if let Block::Channel(name) = &block {
212 if let Some(channel) = cfg.channels.get_mut(name) {
213 channel.near = Some(rest.to_string());
214 }
215 }
216 }
217 _ => {}
218 }
219 }
220 cfg
221 }
222
223 pub fn channel_maildir(&self, channel: &str) -> Option<&PathBuf> {
224 let store = self.channels.get(channel)?.near_store()?;
225 self.maildir_stores.get(store)?.path.as_ref()
226 }
227
228 pub fn channel_imap_account(&self, channel: &str) -> Option<&ImapAccount> {
229 let store = self.channels.get(channel)?.far_store()?;
230 let account = self.imap_stores.get(store)?;
231 self.imap_accounts.get(account)
232 }
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq)]
236pub struct MsmtpConfig {
237 pub accounts: BTreeMap<String, MsmtpAccount>,
238 pub default_account: Option<String>,
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct MsmtpAccount {
243 pub from: Option<String>,
244 pub user: Option<String>,
245}
246
247impl MsmtpConfig {
248 pub fn parse(text: &str) -> Self {
249 let mut cfg = MsmtpConfig::default();
250 let mut current: Option<String> = None;
251
252 for line in text.lines() {
253 let line = line.trim();
254 if line.is_empty() || line.starts_with('#') {
255 continue;
256 }
257 let (keyword, rest) = match line.split_once(char::is_whitespace) {
258 Some((k, r)) => (k, r.trim()),
259 None => (line, ""),
260 };
261
262 match keyword.to_ascii_lowercase().as_str() {
263 "account" => {
264 if let Some((alias, target)) = rest.split_once(':') {
265 let alias = alias.trim();
266 let target = target.trim();
267 if alias == "default" {
268 cfg.default_account = Some(target.to_string());
269 }
270 current = Some(target.to_string());
271 } else {
272 cfg.accounts.entry(rest.to_string()).or_default();
273 current = Some(rest.to_string());
274 }
275 }
276 "from" => {
277 if let Some(name) = ¤t {
278 cfg.accounts.entry(name.clone()).or_default().from = non_empty(rest);
279 }
280 }
281 "user" => {
282 if let Some(name) = ¤t {
283 cfg.accounts.entry(name.clone()).or_default().user = non_empty(rest);
284 }
285 }
286 _ => {}
287 }
288 }
289 cfg
290 }
291
292 pub fn account_for_address(&self, address: &str) -> Option<&str> {
293 self.accounts
294 .iter()
295 .find(|(_, a)| a.from.as_deref() == Some(address) || a.user.as_deref() == Some(address))
296 .map(|(name, _)| name.as_str())
297 }
298}
299
300fn non_empty(value: &str) -> Option<String> {
301 let value = value.trim();
302 (!value.is_empty()).then(|| value.to_string())
303}
304
305fn split_list(value: &str) -> Vec<String> {
306 value
307 .split(';')
308 .flat_map(|part| part.split(','))
309 .map(str::trim)
310 .filter(|s| !s.is_empty())
311 .map(str::to_string)
312 .collect()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 const LIVE_NOTMUCH: &str = r#"
320# Generated by Home Manager.
321
322[database]
323path=/home/alice/.local/share/Mail
324
325[maildir]
326synchronize_flags=true
327
328[new]
329ignore=.uidvalidity;.mbsyncstate
330tags=new;unread
331
332[search]
333exclude_tags=deleted;spam;trash
334
335[user]
336name=Alice Example
337other_email=
338primary_email=alice@example.com
339"#;
340
341 #[test]
342 fn parses_the_live_notmuch_config() {
343 let cfg = NotmuchConfig::parse(LIVE_NOTMUCH);
344 assert_eq!(
345 cfg.database_path,
346 Some(PathBuf::from("/home/alice/.local/share/Mail"))
347 );
348 assert_eq!(cfg.primary_email.as_deref(), Some("alice@example.com"));
349 assert_eq!(cfg.user_name.as_deref(), Some("Alice Example"));
350 assert_eq!(cfg.exclude_tags, vec!["deleted", "spam", "trash"]);
351 assert_eq!(cfg.new_tags, vec!["new", "unread"]);
352 assert!(cfg.other_email.is_empty());
353 }
354
355 #[test]
356 fn mail_root_falls_back_to_database_path() {
357 let cfg = NotmuchConfig::parse(LIVE_NOTMUCH);
358 assert_eq!(
359 cfg.effective_mail_root(),
360 Some(&PathBuf::from("/home/alice/.local/share/Mail"))
361 );
362 }
363
364 #[test]
365 fn explicit_mail_root_wins_over_database_path() {
366 let cfg =
367 NotmuchConfig::parse("[database]\npath=/var/lib/notmuch\nmail_root=/home/alice/Mail\n");
368 assert_eq!(
369 cfg.effective_mail_root(),
370 Some(&PathBuf::from("/home/alice/Mail"))
371 );
372 }
373
374 const LIVE_ISYNCRC: &str = r#"
375# Generated by Home Manager.
376
377IMAPAccount work
378AuthMechs XOAUTH2
379Host outlook.office365.com
380PassCmd "ecr oauth token work"
381Port 993
382TLSType IMAPS
383User alice@example.org
384
385IMAPStore work-remote
386Account work
387
388MaildirStore work-local
389Inbox /home/alice/.local/share/Mail/work/Inbox
390Path /home/alice/.local/share/Mail/work/
391SubFolders Verbatim
392
393Channel work
394Create Near
395Far :work-remote:
396Near :work-local:
397Patterns *
398
399IMAPAccount main
400AuthMechs XOAUTH2
401Host imap.gmail.com
402PassCmd "ecr oauth token main"
403Port 993
404User alice@example.com
405
406IMAPStore main-remote
407Account main
408
409MaildirStore main-local
410Inbox /home/alice/.local/share/Mail/main/Inbox
411Path /home/alice/.local/share/Mail/main/
412SubFolders Verbatim
413
414Channel main
415Create Near
416Expunge Both
417Far :main-remote:
418Near :main-local:
419Patterns *
420"#;
421
422 #[test]
423 fn parses_the_live_isyncrc() {
424 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
425 assert_eq!(cfg.channels.len(), 2);
426 assert!(cfg.channels.contains_key("main"));
427 assert!(cfg.channels.contains_key("work"));
428 assert_eq!(
429 cfg.maildir_stores["main-local"].path,
430 Some(PathBuf::from("/home/alice/.local/share/Mail/main/"))
431 );
432 }
433
434 #[test]
435 fn channel_resolves_to_its_imap_account_address() {
436 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
437
438 assert_eq!(
439 cfg.channel_imap_account("main")
440 .and_then(|a| a.user.as_deref()),
441 Some("alice@example.com")
442 );
443 assert_eq!(
444 cfg.channel_imap_account("work")
445 .and_then(|a| a.user.as_deref()),
446 Some("alice@example.org")
447 );
448 }
449
450 #[test]
451 fn extracts_the_oauth_profile_from_passcmd() {
452 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
453
454 assert_eq!(cfg.imap_accounts["main"].oauth_profile(), Some("main"));
455 assert_eq!(cfg.imap_accounts["work"].oauth_profile(), Some("work"));
456 }
457
458 #[test]
459 fn a_plain_password_has_no_oauth_profile() {
460 let cfg = MbsyncConfig::parse("IMAPAccount x\nPass hunter2\nUser a@b.c\n");
461 assert_eq!(cfg.imap_accounts["x"].oauth_profile(), None);
462 }
463
464 #[test]
467 fn the_profile_survives_an_absolute_path_to_the_binary() {
468 for cmd in [
469 "/run/current-system/sw/bin/ecr oauth token main",
470 "/home/alice/.local/bin/oauthman token main",
471 ] {
472 let cfg = MbsyncConfig::parse(&format!("IMAPAccount x\nPassCmd \"{cmd}\"\n"));
473 assert_eq!(
474 cfg.imap_accounts["x"].oauth_profile(),
475 Some("main"),
476 "{cmd}"
477 );
478 }
479 }
480
481 #[test]
485 fn the_previous_helper_is_still_recognised() {
486 let cfg = MbsyncConfig::parse("IMAPAccount x\nPassCmd \"oauthman token main\"\n");
487 assert_eq!(cfg.imap_accounts["x"].oauth_profile(), Some("main"));
488 }
489
490 #[test]
492 fn another_ecr_subcommand_is_not_an_oauth_profile() {
493 for cmd in ["ecr token new phone", "ecr oauth status main", "ecr doctor"] {
494 let cfg = MbsyncConfig::parse(&format!("IMAPAccount x\nPassCmd \"{cmd}\"\n"));
495 assert_eq!(cfg.imap_accounts["x"].oauth_profile(), None, "{cmd}");
496 }
497 }
498
499 #[test]
500 fn the_store_account_keyword_does_not_overwrite_imap_account_users() {
501 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
502
503 assert_eq!(cfg.imap_stores["main-remote"], "main");
504 assert_eq!(
505 cfg.imap_accounts["main"].user.as_deref(),
506 Some("alice@example.com")
507 );
508 }
509
510 #[test]
511 fn channel_resolves_through_its_near_store() {
512 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
513 assert_eq!(
514 cfg.channel_maildir("main"),
515 Some(&PathBuf::from("/home/alice/.local/share/Mail/main/"))
516 );
517 }
518
519 #[test]
520 fn account_keywords_do_not_leak_into_the_maildir_store() {
521 let cfg = MbsyncConfig::parse(LIVE_ISYNCRC);
522 assert_eq!(cfg.maildir_stores.len(), 2);
523 assert!(!cfg.maildir_stores.contains_key("main"));
524 assert!(cfg.maildir_stores.contains_key("main-local"));
525 }
526
527 const LIVE_MSMTP: &str = r#"
528# Generated by Home Manager.
529account main
530auth xoauth2
531from alice@example.com
532host smtp.gmail.com
533user alice@example.com
534account default : main
535"#;
536
537 #[test]
538 fn parses_the_live_msmtp_config() {
539 let cfg = MsmtpConfig::parse(LIVE_MSMTP);
540 assert_eq!(cfg.default_account.as_deref(), Some("main"));
541 assert_eq!(
542 cfg.accounts["main"].from.as_deref(),
543 Some("alice@example.com")
544 );
545 }
546
547 #[test]
548 fn finds_the_account_serving_an_address() {
549 let cfg = MsmtpConfig::parse(LIVE_MSMTP);
550 assert_eq!(cfg.account_for_address("alice@example.com"), Some("main"));
551 assert_eq!(cfg.account_for_address("nobody@example.com"), None);
552 }
553}