Skip to main content

ecr_store/
parse.rs

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) = &current {
265                        cfg.accounts.entry(name.clone()).or_default().from = non_empty(rest);
266                    }
267                }
268                "user" => {
269                    if let Some(name) = &current {
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}