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