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    /// The OAuth profile behind this account, read out of its `PassCmd`.
71    ///
72    /// `oauthman` is still recognised because it is what an existing setup says
73    /// until its configuration is regenerated, and losing the mapping would cost
74    /// doctor its token checks and a send failure its explanation — in exactly
75    /// the window where the user is midway through the switch.
76    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        // The binary is often an absolute path — a Nix store path, or
80        // ~/.local/bin — so only the file name can be matched.
81        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) = &current {
278                        cfg.accounts.entry(name.clone()).or_default().from = non_empty(rest);
279                    }
280                }
281                "user" => {
282                    if let Some(name) = &current {
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    /// The binary is written as an absolute path by anything that is not
465    /// relying on PATH — a Nix store path, or ~/.local/bin.
466    #[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    /// A setup that has not regenerated its mbsyncrc yet still says `oauthman`.
482    /// Losing the mapping there would cost doctor its token checks in exactly
483    /// the window where someone is midway through the switch.
484    #[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    /// `ecr` runs more than one subcommand, and only one of them is a token.
491    #[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}