Skip to main content

rmut_core/
config.rs

1//! TOML configuration from $RMUT_CONFIG or ~/.config/rmut/config.toml.
2//!
3//! ```toml
4//! [identity]
5//! name = "Jane Doe"
6//! email = "jane@example.com"
7//! reverse_name = false  # reply From = the address the mail came to
8//!
9//! [[identities]]        # conditional identity (folder-/send-hook)
10//! folder = "*work*"     # glob on the open mailbox, and/or:
11//! recipient = "*@work.example.com"   # glob on a draft recipient
12//! name = "Jane Work"
13//! email = "jane@work.example.com"
14//!
15//! [mail]
16//! alternates = ["jane@old\\.example\\.com"]  # my other addresses
17//! my_hdr = ["Organization: Acme"]        # on every draft
18//! mailboxes = ["~/Maildir", "~/Maildir/.Sent"]
19//! sent = "~/Maildir/.Sent"
20//! postponed = "~/Maildir/.Drafts"
21//! sendmail = "/usr/sbin/sendmail"
22//! editor = "vim"
23//! poll_seconds = 5
24//! print = "lpr"
25//!
26//! [index]
27//! format = "%4C %Z %-6d %-15.15L (%?l?%4l&%4c?) %s"
28//!
29//! [ui]
30//! theme = "default"   # or "mono"
31//!
32//! [colors]            # overrides: status_fg status_bg deleted flagged header
33//! deleted = "red"
34//!
35//! [keys.index]        # action = key, e.g. sync = "w", delete = "ctrl+d"
36//! [keys.pager]
37//!
38//! [[accounts]]        # remote account, opened as imap:name/FOLDER
39//! name = "work"
40//! user = "jane@example.com"
41//! password_command = "pass show mail/work"   # or: password = "..."
42//! imap_host = "imap.example.com"   # imap_port = 993, imap_tls = true
43//! smtp_host = "smtp.example.com"   # smtp_port = 587, smtp_tls = true
44//! sent_folder = "Sent"             # Fcc target via IMAP APPEND
45//!
46//! [[folder_hooks]]      # mutt's folder-hook, any `:` command line
47//! folder = "*work*"
48//! command = "set index_format=\"%4C %Z %-6d %-20.20F %s\""
49//!
50//! [[message_hooks]]     # applied while the message is selected
51//! pattern = "~f boss@example.com"
52//! command = "set pager_context=5"
53//!
54//! [[reply_hooks]]       # applied while a reply to it is built
55//! pattern = "~t @work.example.com"
56//! command = "set from=jane@work.example.com"
57//!
58//! [[fcc_hooks]]         # where the sent copy goes
59//! pattern = "~t @work.example.com"
60//! mailbox = "~/Maildir/.WorkSent"
61//!
62//! [[crypt_hooks]]       # encrypt to this key for this recipient
63//! address = "boss@example.com"
64//! key = "0xDEADBEEF"
65//!
66//! [pgp]
67//! command = "gpg"              # runs via $PATH; passphrases come from
68//! sign_key = "jane@example.com"  # the gpg agent, never from rmut
69//! sign_by_default = false
70//! encrypt_by_default = false
71//! ```
72
73use std::collections::HashMap;
74use std::path::PathBuf;
75
76use anyhow::{Context, Result, ensure};
77use serde::Deserialize;
78
79#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(default)]
81pub struct Config {
82    pub identity: Identity,
83    pub mail: Mail,
84    pub index: Index,
85    pub pager: Pager,
86    pub ui: Ui,
87    pub net: Net,
88    pub sidebar: Sidebar,
89    pub colors: HashMap<String, String>,
90    /// Pattern → color rules for index lines, evaluated in order.
91    pub color_index: Vec<ColorRule>,
92    /// Regex → color rules for pager body spans (mutt's `color body`),
93    /// applied in order; `pattern` here is a plain regex, not a
94    /// message pattern.
95    pub color_body: Vec<ColorRule>,
96    /// MIME type → shell command that renders the part (stdin → stdout),
97    /// e.g. "text/html" = "w3m -dump -T text/html", mutt's auto_view.
98    /// Applied to matching parts wherever they sit in the message,
99    /// preferred in multipart/alternative, and used in the attachment
100    /// viewer. An empty command means mutt's arrangement: the command
101    /// comes from mailcap, from the first `copiousoutput` entry for
102    /// the type; a type with no such entry simply does not autoview.
103    pub filters: HashMap<String, String>,
104    pub keys: Keys,
105    /// Macros: a key that replays a sequence of keys, per menu:
106    /// [macros.index] / [macros.pager], `key = "sequence"`. The
107    /// sequence is literal characters plus `<enter>`/`<esc>`/
108    /// `<ctrl+x>`/... names in angle brackets; it feeds the input
109    /// queue, so it can drive prompts.
110    pub macros: Keys,
111    pub accounts: Vec<Account>,
112    /// Conditional identities, applied in order over `identity` when
113    /// their globs match: the minimal folder-hook / send-hook.
114    pub identities: Vec<IdentityRule>,
115    /// mutt's folder-hook with an arbitrary command: enter-command
116    /// lines run when a matching mailbox is opened.
117    pub folder_hooks: Vec<FolderHook>,
118    /// mutt's message-hook: lines applied while a matching message is
119    /// the selected one, and taken back off when it stops matching.
120    pub message_hooks: Vec<MessageHook>,
121    /// mutt's reply-hook: lines applied while a reply to a matching
122    /// message is built.
123    pub reply_hooks: Vec<MessageHook>,
124    /// mutt's fcc-hook: where a matching outgoing message's copy goes.
125    pub fcc_hooks: Vec<FccHook>,
126    /// mutt's crypt-hook: the PGP key to encrypt to for a recipient.
127    pub crypt_hooks: Vec<CryptHook>,
128    pub pgp: Pgp,
129}
130
131/// One `[[folder_hooks]]` entry: mutt's folder-hook. Like mutt, a
132/// folder-hook is not undone when you leave the mailbox, so a
133/// catch-all entry (`folder = "*"`) is the way to put a setting back.
134#[derive(Debug, Clone, Default, Deserialize)]
135#[serde(default)]
136pub struct FolderHook {
137    /// Glob (`*`) on the opened mailbox: a path or an `imap:` spec.
138    pub folder: String,
139    /// One enter-command line, e.g. `set index_format="%s"`.
140    pub command: String,
141}
142
143/// One `[[message_hooks]]` or `[[reply_hooks]]` entry: a message
144/// pattern and the enter-command line it runs.
145#[derive(Debug, Clone, Default, Deserialize)]
146#[serde(default)]
147pub struct MessageHook {
148    pub pattern: String,
149    pub command: String,
150}
151
152/// One `[[fcc_hooks]]` entry: the mailbox a matching outgoing
153/// message's copy goes to (a local maildir path).
154#[derive(Debug, Clone, Default, Deserialize)]
155#[serde(default)]
156pub struct FccHook {
157    /// Message pattern matched against the draft being sent.
158    pub pattern: String,
159    pub mailbox: String,
160}
161
162/// One `[[crypt_hooks]]` entry: encrypt to `key` for any recipient
163/// address matching `address` (a case-insensitive regex).
164#[derive(Debug, Clone, Default, Deserialize)]
165#[serde(default)]
166pub struct CryptHook {
167    pub address: String,
168    pub key: String,
169}
170
171#[derive(Debug, Clone, Default, Deserialize)]
172#[serde(default)]
173pub struct Identity {
174    pub name: Option<String>,
175    pub email: Option<String>,
176    /// mutt's reverse_name: a reply's From becomes whichever of your
177    /// addresses the original was sent to.
178    pub reverse_name: bool,
179    /// mutt's $reverse_realname: the display name comes over with the
180    /// address reverse_name found. True unless set otherwise, as in
181    /// mutt; false keeps the configured name and takes the address
182    /// alone.
183    pub reverse_realname: Option<bool>,
184}
185
186/// One `[[identities]]` entry. With both globs set, both must match;
187/// with neither, it always applies. Unset name/email keep the value
188/// from the layer below.
189#[derive(Debug, Clone, Default, Deserialize)]
190#[serde(default)]
191pub struct IdentityRule {
192    /// Glob (`*`) on the open mailbox: a path or an `imap:` spec.
193    pub folder: Option<String>,
194    /// Glob (`*`) on any recipient address of the draft.
195    pub recipient: Option<String>,
196    pub name: Option<String>,
197    pub email: Option<String>,
198}
199
200#[derive(Debug, Clone, Default, Deserialize)]
201#[serde(default)]
202pub struct Mail {
203    /// mutt's $folder: where mailboxes live, so `=x` and `+x` name a
204    /// mailbox under it, at a prompt or in a macro. An IMAP account
205    /// spec works too ("imap:work"), making `=Archive` mean
206    /// imap:work/Archive.
207    pub folder: Option<String>,
208    pub mailboxes: Vec<String>,
209    pub sent: Option<String>,
210    pub postponed: Option<String>,
211    pub sendmail: Option<String>,
212    pub editor: Option<String>,
213    /// mutt's $ispell: the spell checker the compose menu's `i` runs
214    /// over the draft, as `ispell -x FILE`. "ispell" when unset.
215    pub ispell: Option<String>,
216    pub poll_seconds: Option<u64>,
217    /// Shell command the printed message is piped to (default lpr).
218    pub print: Option<String>,
219    /// mutt's $pipe_decode: pipe the decoded message (brief headers,
220    /// decoded body) rather than the raw one. Off by default.
221    pub pipe_decode: Option<bool>,
222    /// mutt's $print_decode: print the decoded message. On by
223    /// default, as in mutt.
224    pub print_decode: Option<bool>,
225    /// mutt's $pipe_split / $print_split: run the command once per
226    /// tagged message instead of once over them all. Off by default.
227    pub pipe_split: Option<bool>,
228    pub print_split: Option<bool>,
229    /// mutt's $pipe_sep: what separates concatenated messages in one
230    /// pipe run. Newline by default.
231    pub pipe_sep: Option<String>,
232    /// Default target offered by `s` (save message to a mailbox).
233    pub save: Option<String>,
234    /// "inline" (quoted text, the default) or "attach" (the original
235    /// goes along as a message/rfc822 part, mutt's mime_forward).
236    pub forward: Option<String>,
237    /// mutt's query_command: external address lookup for Tab
238    /// completion at the To prompt (`%s` = the word, or appended),
239    /// e.g. "khard email --parsable %s".
240    pub query_command: Option<String>,
241    /// mutt's $trash: purged messages move here (a maildir path, or
242    /// `imap:account/folder` of the open account) instead of being
243    /// erased; purging inside the trash itself deletes for real.
244    pub trash: Option<String>,
245    /// mutt's edit_headers (default false, like mutt): true puts the
246    /// draft's header block (From/To/Cc/Subject, Attach: lines) into
247    /// the editor buffer. Off, the prompts set To/Subject and
248    /// attachments go through the compose menu's a.
249    pub edit_headers: Option<bool>,
250    /// notmuch(1) search (`X`): false disables the key; unset/true
251    /// leaves it on (notmuch itself must be installed; keep the
252    /// database fresh with `notmuch new` in a hook or cron).
253    pub notmuch: Option<bool>,
254    /// mutt's $fast_reply: replies skip the To and Subject prompts,
255    /// forwards skip Subject (the ask-yes questions still run).
256    pub fast_reply: bool,
257    /// mutt's $quit: "yes" (the default) leaves at once, "no"
258    /// refuses, "ask-yes" and "ask-no" ask first.
259    pub quit: Option<String>,
260    /// mutt's $postpone: leaving a draft. "ask-yes" (the default) and
261    /// "ask-no" ask whether to postpone (else discard); "yes"
262    /// postpones without asking, "no" discards without asking.
263    pub postpone: Option<String>,
264    /// mutt's $recall: composing when postponed drafts exist. "no"
265    /// never offers to recall (always a new message); "yes" recalls
266    /// the newest without asking; "ask-yes" / "ask-no" (the default
267    /// is to ask) offer the new/recall choice.
268    pub recall: Option<String>,
269    /// mutt's $confirmappend: ask before adding messages to a mailbox
270    /// that already exists. Off by default, where mutt asks: rmut has
271    /// never asked, and a save is one keystroke either way.
272    pub confirmappend: bool,
273    /// mutt's $save_name: the default save target is the sender's
274    /// local part under $folder, when such a mailbox exists.
275    pub save_name: bool,
276    /// mutt's $force_name: the same, whether or not it exists.
277    pub force_name: bool,
278    /// mutt's $mark_old: unread mail left behind in the mailbox ages
279    /// to old (O in the index, out of the new count) when you leave.
280    /// True unless set otherwise, as in mutt.
281    pub mark_old: Option<bool>,
282    /// mutt's $delete_untag: marking a tagged message for deletion
283    /// (d, or a save) takes its tag off. True unless set otherwise,
284    /// as in mutt.
285    pub delete_untag: Option<bool>,
286    /// mutt's $flag_safe: a flagged message cannot be marked for
287    /// deletion, by any of the ways of doing so. Off by default.
288    pub flag_safe: bool,
289    /// mutt's $maildir_trash: a purge gives deleted messages the
290    /// maildir T flag instead of unlinking them; they stay in the
291    /// index marked D. Maildir only. Off by default.
292    pub maildir_trash: bool,
293    /// mutt's $mail_check_recent: "new mail in X" only when X has
294    /// grown since the last look. False announces any mailbox holding
295    /// new mail, once, until it empties. True unless set otherwise.
296    pub mail_check_recent: Option<bool>,
297    /// mutt's $sort_alias: the order address completion offers alias
298    /// expansions in: "address" (by the expansion, the default here),
299    /// "alias" (by nick). "unsorted" reads as "alias": the alias file
300    /// is a map. A "reverse-" prefix flips it.
301    pub sort_alias: Option<String>,
302    /// mutt's $shell: what a bare `!` (an empty shell command) runs
303    /// interactively. $SHELL, then sh, when unset.
304    pub shell: Option<String>,
305    /// mutt's $tmpdir: where temporary files go (drafts on their way
306    /// to the editor, parts on their way to a viewer). $TMPDIR, then
307    /// /tmp, when unset.
308    pub tmpdir: Option<String>,
309    /// mutt's $check_new: look for mail delivered to the open maildir
310    /// while it is open. False stops the rescan (IMAP is unaffected,
311    /// as in mutt). True unless set otherwise.
312    pub check_new: Option<bool>,
313    /// mutt's $print: what `p` does. "ask-no" (the default) asks with
314    /// Enter declining, "ask-yes" asks with Enter printing, "yes"
315    /// prints without asking and "no" refuses to print at all.
316    pub print_confirm: Option<String>,
317    /// mutt's $alias_file: where aliases are read from and where
318    /// create-alias appends. $RMUT_ALIASES, then
319    /// ~/.config/rmut/aliases, when unset.
320    pub alias_file: Option<String>,
321    /// mutt's $attribution: the line a quoted reply opens with, over
322    /// the message being replied to (%a address, %n name, %f the From
323    /// header, %s subject, %i message-id, %d date, %{...} strftime).
324    pub attribution: Option<String>,
325    /// mutt's $indent_string: what each quoted line is prefixed with,
326    /// `"> "` by default.
327    pub indent_string: Option<String>,
328    /// mutt's $forward_format: the subject a forward carries, the
329    /// same format string over the message being forwarded.
330    pub forward_format: Option<String>,
331    /// mutt's $wrap_search: `n` wraps around the ends of the index.
332    /// True by default, as in mutt; false stops at the last / first
333    /// match instead.
334    pub wrap_search: Option<bool>,
335    /// mutt's $simple_search: the template a bare-word search expands
336    /// to, `%s` the word. Default `~f %s | ~s %s` (from or subject),
337    /// which is what a bare word already did; set it to add `~b %s`
338    /// for the body, say. Only a single word with no `~` expands.
339    pub simple_search: Option<String>,
340    /// mutt's $reply_regexp: what a reply's subject may already start
341    /// with ("Re:" with an optional [n], by default). Replying takes
342    /// it off and puts "Re: " on, so prefixes never pile up; a
343    /// locale's own prefixes go in as `^(re|aw|sv):[ \t]*`.
344    /// Case-insensitive unless the regex has an uppercase letter, as
345    /// mutt compiles it.
346    pub reply_regexp: Option<String>,
347    /// mutt's $include: quote the original in a reply? "ask-yes" (the
348    /// default) and "ask-no" ask, "yes" and "no" decide it.
349    pub include: Option<String>,
350    /// mutt's $forward_quote: the forwarded text inside the
351    /// "----- Forwarded message" markers is quoted with
352    /// $indent_string, the way a reply is.
353    pub forward_quote: bool,
354    /// mutt's $signature: a file whose contents end every new draft,
355    /// or, when the name ends in `|`, a command whose output does.
356    /// `~` is expanded. Unset (the default) appends nothing.
357    pub signature: Option<String>,
358    /// mutt's $sig_dashes: the signature is introduced by a line
359    /// holding "-- ". True unless set otherwise, as in mutt.
360    pub sig_dashes: Option<bool>,
361    /// mutt's $sig_on_top: the signature goes above the quoted
362    /// original rather than below it. Off by default, as in mutt.
363    pub sig_on_top: Option<bool>,
364    /// mutt's $hostname: the host in a generated Message-ID. The
365    /// system hostname when unset.
366    pub hostname: Option<String>,
367    /// mutt's $user_agent: add a `User-Agent: rmut/VERSION` header to
368    /// outgoing mail. Off by default, as neomutt has it.
369    pub user_agent: Option<bool>,
370    /// mutt's $abort_nosubject: a draft with an empty subject.
371    /// "ask-yes" (the default) asks with Enter aborting, "ask-no"
372    /// asks with Enter sending it on, "yes" aborts without asking,
373    /// "no" never asks.
374    pub abort_nosubject: Option<String>,
375    /// mutt's $abort_unmodified: the first editor pass came back with
376    /// the body untouched, so the draft is dropped. True unless set
377    /// otherwise, as in mutt; only the first edit is checked.
378    pub abort_unmodified: Option<bool>,
379    /// mutt's $askcc / $askbcc: ask for those recipients when a draft
380    /// is started, prefilled with what a group reply worked out.
381    pub ask_cc: bool,
382    pub ask_bcc: bool,
383    /// mutt's $autoedit (needs edit_headers): skip every initial
384    /// prompt and question, straight into the editor; the compose
385    /// menu follows as usual.
386    pub autoedit: bool,
387    /// mutt's $copy: false skips the sent copy (Fcc) entirely; an
388    /// Fcc set in the compose menu still wins.
389    pub copy: Option<bool>,
390    /// mutt's `lists`: address patterns naming mailing lists you know
391    /// of. They drive `~l`, the `L` list-reply target, and the
392    /// Mail-Followup-To rmut sets on mail to a list.
393    pub lists: Vec<String>,
394    /// mutt's `subscribe`: lists you are on. Subscribed lists count as
395    /// known lists too, and a reply to one leaves your own address out
396    /// of Mail-Followup-To, so the list copy is the only one you get.
397    pub subscribed: Vec<String>,
398    /// mutt's `alternates`: regexes matching your other addresses
399    /// (aliases, an old domain, a role address). They join the
400    /// identity addresses everywhere rmut asks "is this me?": `~p`
401    /// and `~P`, the `+`/`T`/`C`/`F` index marks, reverse_name, the
402    /// group-reply dedup, and Mail-Followup-To.
403    pub alternates: Vec<String>,
404    /// mutt's `my_hdr`: header lines added to every draft, e.g.
405    /// "Organization: Acme" or "Bcc: me@example.com". One naming a
406    /// header rmut already wrote replaces it (so `From:` and
407    /// `Reply-To:` win); To/Cc/Bcc gain the address instead.
408    pub my_hdr: Vec<String>,
409    /// mutt's $metoo: keep your own address among a group reply's
410    /// recipients instead of dropping it.
411    pub metoo: bool,
412    /// mutt's $text_flowed: outgoing text/plain is declared
413    /// `format=flowed` and space-stuffed (RFC 3676), so a reader can
414    /// rewrap it. The paragraphs themselves come from your editor,
415    /// which has to leave a trailing space on a line that continues.
416    pub text_flowed: bool,
417    /// Shell command run when new mail arrives (neomutt's
418    /// new_mail_command): `%f` = the mailbox, `%n` = how many, e.g.
419    /// "notify-send 'rmut: %n new in %f'". Fire-and-forget.
420    pub new_mail_command: Option<String>,
421    /// mutt's $delete (a quadoption): what `$` and quitting do with
422    /// messages marked for deletion. "ask" (the default) asks, with
423    /// Enter taking the yes; "yes" purges them without asking; "no"
424    /// never purges, so the marks stay for a later change of mind.
425    pub delete: Option<String>,
426    /// neomutt's $abort_noattach: what to do when the body mentions
427    /// an attachment and none is attached. "no" (the default) never
428    /// checks, "ask" asks before sending, "yes" refuses the send.
429    /// neomutt's ask-yes / ask-no both import as "ask".
430    pub abort_noattach: Option<String>,
431    /// neomutt's $abort_noattach_regex: what counts as mentioning one.
432    /// Case-insensitive; the default is
433    /// `\b(attach|attached|attaching|attachment|attachments)\b`.
434    pub attach_keyword: Option<String>,
435    /// Seconds a sent message waits before it actually goes out, so
436    /// `z` can take it back (rmut's own; mutt sends at once). 0 is
437    /// off. A held message is sent when the timer runs out or when
438    /// rmut exits; batch sends (-s and friends) never hold.
439    pub undo_send: u64,
440}
441
442#[derive(Debug, Clone, Default, Deserialize)]
443#[serde(default)]
444pub struct Index {
445    pub format: Option<String>,
446    /// Initial sort: date/from/subject/size/threads, "reverse-" prefix
447    /// allowed (the `o` menu can still change it at runtime).
448    pub sort: Option<String>,
449    /// "last-date-sent" orders threads by their newest message instead
450    /// of the default oldest-first.
451    pub sort_aux: Option<String>,
452    /// chrono strftime string for the index date column (mutt's
453    /// date_format), e.g. "%d.%m.%Y"; default "%b %e", like mutt's
454    /// index date.
455    pub date_format: Option<String>,
456    /// mutt's $collapse_unread (default true, as in mutt): a thread
457    /// holding unread mail folds like any other. False leaves those
458    /// threads open when everything else folds.
459    pub collapse_unread: Option<bool>,
460    /// mutt's $uncollapse_jump: unfolding a thread puts the cursor on
461    /// its first unread message.
462    pub uncollapse_jump: bool,
463    /// mutt's $hide_thread_subject: a thread reply whose subject
464    /// matches its parent's shows a blank subject (just the tree
465    /// arrow). Off by default here, where mutt has it on, so rmut's
466    /// look is unchanged unless asked.
467    pub hide_thread_subject: Option<bool>,
468    /// mutt's $uncollapse_new: a collapsed thread that receives a new
469    /// message unfolds. True unless set otherwise, as in mutt.
470    pub uncollapse_new: Option<bool>,
471}
472
473#[derive(Debug, Clone, Default, Deserialize)]
474#[serde(default)]
475pub struct Pager {
476    /// Lines of the message index kept visible above the pager.
477    pub index_lines: u16,
478    /// Lines of overlap when paging (mutt's pager_context).
479    pub context: usize,
480    /// mutt's $search_context: lines of context kept above a pager
481    /// search hit scrolled toward the top. Default 0.
482    pub search_context: usize,
483    /// mutt's $quote_regexp: classifies quoted body lines (depth =
484    /// quote characters in the match). Default `^([ \t]*[|>:}#])+`.
485    pub quote_regexp: Option<String>,
486    /// mutt's ignore list: header-name prefixes hidden from the brief
487    /// view (`*` = all). Unset keeps the classic view (everything
488    /// hidden except Date/From/To/Cc/Subject).
489    pub ignore: Option<Vec<String>>,
490    /// mutt's unignore list: prefixes shown even when ignored.
491    pub unignore: Option<Vec<String>>,
492    /// mutt's hdr_order: name prefixes sorting the brief view;
493    /// unlisted headers follow in message order.
494    pub hdr_order: Option<Vec<String>>,
495    /// mutt's $pager_format for the pager's bottom line; the default
496    /// reproduces the classic "---Message n/m: subject -- NN%".
497    pub format: Option<String>,
498    /// mutt's $wrap: wrap body text at N columns (negative = a right
499    /// margin of |N|); unset wraps at the window width.
500    pub wrap: Option<i64>,
501    /// mutt's $tilde: pad the rows below end-of-message with ~.
502    pub tilde: bool,
503    /// mutt's $pager_stop: paging past the end of a message stays
504    /// put instead of opening the next one.
505    pub pager_stop: bool,
506    /// mutt's $markers (default true, as in mutt): the `+` at the
507    /// start of a wrapped continuation line.
508    pub markers: Option<bool>,
509    /// mutt's $smart_wrap (default true, as in mutt): wrapped lines
510    /// break at a word boundary rather than at the column.
511    pub smart_wrap: Option<bool>,
512    /// mutt's $reflow_text (default true): a `format=flowed` part is
513    /// put back into paragraphs and wrapped at the display width
514    /// instead of keeping the sender's line breaks.
515    pub reflow_text: Option<bool>,
516    /// mutt's alternative_order: MIME types, most wanted first, that
517    /// decide which part of a multipart/alternative shows. `text/*`
518    /// wildcards allowed; consulted before the auto_view filters and
519    /// the built-in text ranking.
520    pub alternative_order: Vec<String>,
521}
522
523/// How long to wait on the network before saying so. A mail client
524/// that blocks has nothing to draw and no keys to read, so these are
525/// short by default: an unreachable server should cost seconds, not
526/// the OS default of about two minutes.
527#[derive(Debug, Clone, Deserialize)]
528#[serde(default)]
529pub struct Net {
530    /// Seconds to wait for a connection (mutt's $connect_timeout).
531    /// 0 waits as long as the OS does.
532    pub connect_timeout: u64,
533    /// Seconds to wait for data on a live connection. Never off:
534    /// IMAP IDLE uses it as its heartbeat, and anything under five
535    /// seconds is treated as five.
536    pub timeout: u64,
537    /// mutt's $ssl_usesystemcerts: trust the OS certificate store on
538    /// top of the built-in Mozilla roots. On by default, as in mutt.
539    pub system_cas: bool,
540    /// mutt's $certificate_file: a PEM file of extra roots to trust
541    /// (a private CA, a self-signed server's own cert). Added to the
542    /// Mozilla roots, never replacing them.
543    pub certificate_file: Option<String>,
544}
545
546impl Default for Net {
547    fn default() -> Self {
548        Net {
549            connect_timeout: 10,
550            timeout: 30,
551            system_cas: true,
552            certificate_file: None,
553        }
554    }
555}
556
557#[derive(Debug, Clone, Deserialize)]
558#[serde(default)]
559pub struct Ui {
560    pub theme: Option<String>,
561    /// mutt's status_format for the bottom line (see
562    /// format::DEFAULT_STATUS_FORMAT for the specifiers).
563    pub status_format: Option<String>,
564    /// Ring the terminal bell on error statuses (mutt's $beep).
565    pub beep: bool,
566    /// mutt's $beep_new: ring it when mail arrives, too. Off by
567    /// default, as in mutt.
568    pub beep_new: bool,
569    /// mutt's $wait_key: a shell escape ends with "Press Enter to
570    /// continue", so whatever it printed can be read before the
571    /// index paints over it. True unless set otherwise, as in mutt.
572    pub wait_key: Option<bool>,
573    /// mutt's $ts_enabled: set the terminal title (and icon) while
574    /// running. Off by default, as in mutt.
575    pub set_title: Option<bool>,
576    /// mutt's $ts_status_format: the title's format, the same
577    /// specifiers as status_format. Defaults to "rmut: %f".
578    pub title_format: Option<String>,
579    /// mutt's $history_file: where prompt history persists across
580    /// sessions. Unset means in-memory only, as rmut was before.
581    pub history_file: Option<String>,
582    /// mutt's $status_on_top: the status bar (and message line) sit at
583    /// the top, under the help bar, rather than the bottom. Off by
584    /// default, as in mutt.
585    pub status_on_top: Option<bool>,
586    /// mutt's $arrow_cursor: mark the selected row with an arrow
587    /// instead of reverse video. Off by default, as in mutt.
588    pub arrow_cursor: Option<bool>,
589    /// mutt's $menu_scroll: the index scrolls a line at a time when
590    /// the cursor leaves the screen; false shows the next page
591    /// instead. On by default here (rmut always scrolled), where mutt
592    /// pages.
593    pub menu_scroll: Option<bool>,
594    /// mutt's $menu_context: lines kept in view beyond the cursor
595    /// when the index scrolls or pages. 0 by default.
596    pub menu_context: usize,
597    /// mutt's $menu_move_off: the last message may scroll up past
598    /// the bottom of the screen; false keeps the index bottom-stuck
599    /// once it fills the screen. True unless set otherwise.
600    pub menu_move_off: Option<bool>,
601    /// mutt's $help: the key-help bar on the top line. True unless
602    /// set otherwise.
603    pub help: Option<bool>,
604    /// mutt's $sort_browser: the folder browser's order: "alpha" (the
605    /// default), "count" / "unread" (by new-mail count), "date" (by
606    /// the maildir's change time), "unsorted" (as configured, then as
607    /// found). A "reverse-" prefix flips it. "size" reads as alpha.
608    pub sort_browser: Option<String>,
609    /// mutt's $error_history: how many past errors error-history
610    /// shows. 0 disables it. 30 by default, as in mutt.
611    pub error_history: usize,
612    /// mutt's $status_chars: the characters `%r` shows for the
613    /// mailbox state — [0] unchanged, [1] changed (needs sync), [2]
614    /// read-only. Unset keeps rmut's own (nothing / `*` / `%`).
615    pub status_chars: Option<String>,
616    /// mutt's $save_history: entries kept per history bucket in the
617    /// file. Defaults to 100 (rmut's in-memory cap).
618    pub save_history: Option<usize>,
619}
620
621impl Default for Ui {
622    fn default() -> Self {
623        Ui {
624            theme: None,
625            status_format: None,
626            beep: true,
627            beep_new: false,
628            wait_key: None,
629            set_title: None,
630            title_format: None,
631            history_file: None,
632            save_history: None,
633            status_on_top: None,
634            arrow_cursor: None,
635            menu_scroll: None,
636            menu_context: 0,
637            menu_move_off: None,
638            help: None,
639            sort_browser: None,
640            error_history: 30,
641            status_chars: None,
642        }
643    }
644}
645
646/// One `[[color_index]]` rule (mutt's `color index FG BG PATTERN`):
647/// index lines whose message matches `pattern` take these colors.
648/// First matching rule wins; rules are checked in config order.
649#[derive(Debug, Clone, Default, Deserialize)]
650#[serde(default)]
651pub struct ColorRule {
652    pub pattern: String,
653    pub fg: Option<String>,
654    pub bg: Option<String>,
655}
656
657/// The optional left pane listing `mail.mailboxes` with new-mail
658/// counts (toggle with B at runtime).
659#[derive(Debug, Clone, Deserialize)]
660#[serde(default)]
661pub struct Sidebar {
662    pub visible: bool,
663    pub width: u16,
664}
665
666impl Default for Sidebar {
667    fn default() -> Self {
668        Sidebar {
669            visible: false,
670            width: 24,
671        }
672    }
673}
674
675#[derive(Debug, Clone, Default, Deserialize)]
676#[serde(default)]
677pub struct Keys {
678    pub index: HashMap<String, String>,
679    pub pager: HashMap<String, String>,
680}
681
682/// One remote account: IMAP for reading, SMTP for sending. The
683/// password comes from `password_command` (preferred) or, when you
684/// accept a secret sitting in the config file, a literal `password`.
685#[derive(Debug, Clone, Deserialize)]
686pub struct Account {
687    pub name: String,
688    pub user: String,
689    /// Shell command whose first stdout line is the password
690    /// (pass(1)-style). Wins over `password` when both are set.
691    pub password_command: Option<String>,
692    /// Plaintext password. Convenient, but anyone who can read the
693    /// config can read your mail, so keep it at mode 600.
694    pub password: Option<String>,
695    pub imap_host: Option<String>,
696    #[serde(default = "default_imap_port")]
697    pub imap_port: u16,
698    /// Encrypt IMAP (default): TLS from the first byte on port 993,
699    /// STARTTLS on any other port. Disabling is for tests only.
700    #[serde(default = "default_true")]
701    pub imap_tls: bool,
702    pub smtp_host: Option<String>,
703    #[serde(default = "default_smtp_port")]
704    pub smtp_port: u16,
705    /// Encrypt SMTP (default): implicit TLS on port 465, STARTTLS
706    /// otherwise. Disabling is for tests only.
707    #[serde(default = "default_true")]
708    pub smtp_tls: bool,
709    /// "password" (default), "xoauth2", or "oauthbearer". The OAuth
710    /// mechanisms authenticate with an access token from
711    /// `token_command` instead of a password.
712    pub auth: Option<String>,
713    /// Shell command whose first stdout line is a *fresh* OAuth access
714    /// token (refresh is its business: oauth2ms, mutt_oauth2.py, ...).
715    /// Run for every connection; tokens expire, so it is never cached.
716    pub token_command: Option<String>,
717    /// IMAP folder that receives the Fcc copy of sent mail.
718    #[serde(default = "default_sent_folder")]
719    pub sent_folder: String,
720    /// From identity when composing from this account's mailboxes,
721    /// e.g. identity = { name = "Jane Work", email = "jane@work.example.com" }.
722    pub identity: Option<Identity>,
723}
724
725/// PGP via gpg(1). Decrypt/verify happens automatically when a viewed
726/// message is PGP; signing and encrypting are chosen at the send
727/// prompt. Passphrases are gpg-agent's business; rmut never sees them.
728#[derive(Debug, Clone, Deserialize)]
729#[serde(default)]
730pub struct Pgp {
731    /// The gpg executable (a name looked up in $PATH or a full path).
732    pub command: String,
733    /// Signing key for --local-user; gpg's default key when unset.
734    pub sign_key: Option<String>,
735    /// Preselect signing / encrypting for new drafts (the compose menu's
736    /// security menu can still change it per message).
737    pub sign_by_default: bool,
738    pub encrypt_by_default: bool,
739    /// mutt's $crypt_replysign: a reply to a signed message defaults
740    /// to signed. $crypt_replyencrypt: a reply to an encrypted one
741    /// defaults to encrypted (on in mutt, off here until asked).
742    /// $crypt_replysignencrypted: a reply to signed-and-encrypted
743    /// mail defaults to signed too. All off by default.
744    pub reply_sign: bool,
745    pub reply_encrypt: bool,
746    pub reply_sign_encrypted: bool,
747}
748
749impl Default for Pgp {
750    fn default() -> Self {
751        Pgp {
752            command: "gpg".into(),
753            sign_key: None,
754            sign_by_default: false,
755            encrypt_by_default: false,
756            reply_sign: false,
757            reply_encrypt: false,
758            reply_sign_encrypted: false,
759        }
760    }
761}
762
763fn default_imap_port() -> u16 {
764    993
765}
766
767fn default_smtp_port() -> u16 {
768    587
769}
770
771fn default_true() -> bool {
772    true
773}
774
775fn default_sent_folder() -> String {
776    "Sent".into()
777}
778
779/// How an account authenticates, from its `auth` key.
780#[derive(Clone, Copy, PartialEq, Eq, Debug)]
781pub enum AuthKind {
782    Password,
783    XOAuth2,
784    OAuthBearer,
785}
786
787impl AuthKind {
788    pub fn sasl_name(self) -> &'static str {
789        match self {
790            AuthKind::Password => "PLAIN",
791            AuthKind::XOAuth2 => "XOAUTH2",
792            AuthKind::OAuthBearer => "OAUTHBEARER",
793        }
794    }
795
796    /// The SASL initial response (before base64): RFC 7628 for
797    /// OAUTHBEARER, the Google shape for XOAUTH2.
798    pub fn initial_response(self, user: &str, token: &str, host: &str, port: u16) -> String {
799        match self {
800            AuthKind::XOAuth2 => format!("user={user}\x01auth=Bearer {token}\x01\x01"),
801            AuthKind::OAuthBearer => {
802                format!("n,a={user},\x01host={host}\x01port={port}\x01auth=Bearer {token}\x01\x01")
803            }
804            AuthKind::Password => String::new(),
805        }
806    }
807}
808
809/// First stdout line of a credential command.
810fn first_line_of(command: &str, what: &str, name: &str) -> Result<String> {
811    let out = std::process::Command::new("sh")
812        .arg("-c")
813        .arg(command)
814        .output()
815        .with_context(|| format!("running {what} for account {name}"))?;
816    ensure!(
817        out.status.success(),
818        "{what} for account {name} exited with {}",
819        out.status
820    );
821    let secret = String::from_utf8_lossy(&out.stdout)
822        .lines()
823        .next()
824        .unwrap_or("")
825        .to_string();
826    ensure!(
827        !secret.is_empty(),
828        "{what} for account {name} printed nothing"
829    );
830    Ok(secret)
831}
832
833impl Account {
834    /// First stdout line of `password_command`, or the stored
835    /// `password` when no command is configured.
836    pub fn password(&self) -> Result<String> {
837        let Some(command) = &self.password_command else {
838            return self
839                .password
840                .clone()
841                .filter(|p| !p.is_empty())
842                .with_context(|| {
843                    format!(
844                        "account {} has neither password_command nor password",
845                        self.name
846                    )
847                });
848        };
849        first_line_of(command, "password command", &self.name)
850    }
851
852    pub fn auth_kind(&self) -> Result<AuthKind> {
853        match self.auth.as_deref() {
854            None | Some("password") => Ok(AuthKind::Password),
855            Some("xoauth2") => Ok(AuthKind::XOAuth2),
856            Some("oauthbearer") => Ok(AuthKind::OAuthBearer),
857            Some(other) => anyhow::bail!("unknown auth {other:?} for account {}", self.name),
858        }
859    }
860
861    /// The credential matching `auth_kind`: the password, or a fresh
862    /// access token from `token_command`.
863    pub fn secret(&self) -> Result<String> {
864        match self.auth_kind()? {
865            AuthKind::Password => self.password(),
866            _ => {
867                let command = self.token_command.as_deref().with_context(|| {
868                    format!(
869                        "account {} has auth = oauth but no token_command",
870                        self.name
871                    )
872                })?;
873                first_line_of(command, "token command", &self.name)
874            }
875        }
876    }
877}
878
879impl Config {
880    pub fn account(&self, name: &str) -> Option<&Account> {
881        self.accounts.iter().find(|a| a.name == name)
882    }
883
884    /// The identity for a draft, layered like mutt hooks: `[identity]`,
885    /// then the account's, then every matching `[[identities]]` rule in
886    /// order (a later rule overrides an earlier one; unset fields keep
887    /// the value below). `rcpts` are the draft's bare recipient
888    /// addresses, empty when they are not known yet, which makes
889    /// recipient rules not match.
890    /// Every known mailing-list pattern (`lists` plus `subscribed`),
891    /// compiled for matching against addresses.
892    pub fn list_matchers(&self) -> Vec<crate::pattern::Matcher> {
893        self.mail
894            .lists
895            .iter()
896            .chain(&self.mail.subscribed)
897            .map(|spec| crate::pattern::Matcher::new(spec))
898            .collect()
899    }
900
901    /// The `subscribed` half on its own, for the Mail-Followup-To rule.
902    pub fn subscribed_matchers(&self) -> Vec<crate::pattern::Matcher> {
903        self.mail
904            .subscribed
905            .iter()
906            .map(|spec| crate::pattern::Matcher::new(spec))
907            .collect()
908    }
909
910    /// mutt's `alternates`, compiled for matching against a bare
911    /// address.
912    pub fn alternate_matchers(&self) -> Vec<crate::pattern::Matcher> {
913        self.mail
914            .alternates
915            .iter()
916            .map(|spec| crate::pattern::Matcher::new(spec))
917            .collect()
918    }
919
920    pub fn identity_for(
921        &self,
922        folder: &str,
923        rcpts: &[String],
924        account: Option<&Account>,
925    ) -> Identity {
926        let mut id = self.identity.clone();
927        let mut overlay = |name: &Option<String>, email: &Option<String>| {
928            if name.is_some() {
929                id.name = name.clone();
930            }
931            if email.is_some() {
932                id.email = email.clone();
933            }
934        };
935        if let Some(acct) = account.and_then(|a| a.identity.as_ref()) {
936            overlay(&acct.name, &acct.email);
937        }
938        for rule in &self.identities {
939            let folder_ok = rule.folder.as_deref().is_none_or(|g| glob_match(g, folder));
940            let recipient_ok = rule
941                .recipient
942                .as_deref()
943                .is_none_or(|g| rcpts.iter().any(|r| glob_match(g, r)));
944            if folder_ok && recipient_ok {
945                overlay(&rule.name, &rule.email);
946            }
947        }
948        id
949    }
950}
951
952/// Glob match: `*` spans anything, everything else is literal;
953/// case-insensitive, anchored at both ends.
954pub fn glob_match(pattern: &str, text: &str) -> bool {
955    let p: Vec<char> = pattern.to_lowercase().chars().collect();
956    let t: Vec<char> = text.to_lowercase().chars().collect();
957    let (mut pi, mut ti) = (0usize, 0usize);
958    let mut star: Option<(usize, usize)> = None;
959    while ti < t.len() {
960        if pi < p.len() && p[pi] == '*' {
961            star = Some((pi, ti));
962            pi += 1;
963        } else if pi < p.len() && p[pi] == t[ti] {
964            pi += 1;
965            ti += 1;
966        } else if let Some((sp, st)) = star {
967            // Backtrack: let the last * swallow one more character.
968            pi = sp + 1;
969            ti = st + 1;
970            star = Some((sp, st + 1));
971        } else {
972            return false;
973        }
974    }
975    while pi < p.len() && p[pi] == '*' {
976        pi += 1;
977    }
978    pi == p.len()
979}
980
981/// mutt's `+x` / `=x`: a mailbox named under $folder. `=` or `+`
982/// alone is $folder itself; anything else, and any name at all when
983/// no folder is configured, comes back untouched. This runs on every
984/// mailbox rmut is handed, typed or configured, before anything
985/// tries to read it as a path or an imap: spec.
986pub fn expand_folder(spec: &str, folder: Option<&str>) -> String {
987    let Some(rest) = spec.strip_prefix(['=', '+']) else {
988        return spec.to_string();
989    };
990    let Some(folder) = folder
991        .map(|f| f.trim_end_matches('/'))
992        .filter(|f| !f.is_empty())
993    else {
994        return spec.to_string();
995    };
996    match rest.is_empty() {
997        true => folder.to_string(),
998        false => format!("{folder}/{rest}"),
999    }
1000}
1001
1002impl Config {
1003    /// Expand `=x` / `+x` in every mailbox the config names, so the
1004    /// rest of the program only ever sees real paths and imap: specs.
1005    /// Idempotent: an expanded name no longer starts with = or +.
1006    pub fn expand_folders(&mut self) {
1007        let folder = self.mail.folder.clone();
1008        let folder = folder.as_deref();
1009        let one = |slot: &mut Option<String>| {
1010            if let Some(v) = slot {
1011                *v = expand_folder(v, folder);
1012            }
1013        };
1014        one(&mut self.mail.sent);
1015        one(&mut self.mail.postponed);
1016        one(&mut self.mail.trash);
1017        one(&mut self.mail.save);
1018        for m in &mut self.mail.mailboxes {
1019            *m = expand_folder(m, folder);
1020        }
1021        for hook in &mut self.fcc_hooks {
1022            hook.mailbox = expand_folder(&hook.mailbox, folder);
1023        }
1024    }
1025}
1026
1027pub fn path() -> Option<PathBuf> {
1028    if let Ok(p) = std::env::var("RMUT_CONFIG") {
1029        return Some(PathBuf::from(p));
1030    }
1031    std::env::var("HOME")
1032        .ok()
1033        .map(|h| PathBuf::from(h).join(".config/rmut/config.toml"))
1034}
1035
1036/// Load the config; a missing file is fine (defaults), a broken file
1037/// returns defaults plus a warning to show the user.
1038pub fn load_default() -> (Config, Option<String>) {
1039    let Some(p) = path() else {
1040        return (Config::default(), None);
1041    };
1042    let Ok(text) = std::fs::read_to_string(&p) else {
1043        return (Config::default(), None);
1044    };
1045    match toml::from_str::<Config>(&text) {
1046        Ok(cfg) => {
1047            let warning = secret_exposed(&cfg, &p);
1048            (cfg, warning)
1049        }
1050        Err(err) => {
1051            let first = err
1052                .to_string()
1053                .lines()
1054                .next()
1055                .unwrap_or("parse error")
1056                .to_string();
1057            (
1058                Config::default(),
1059                Some(format!("config ignored ({}): {first}", p.display())),
1060            )
1061        }
1062    }
1063}
1064
1065/// A plaintext `password` in a config anyone can read is the one
1066/// mistake worth interrupting for: the file holds the keys to the
1067/// mail. Says so once at startup, and only when the bits are
1068/// actually open, so a 600 config stays quiet.
1069fn secret_exposed(cfg: &Config, path: &std::path::Path) -> Option<String> {
1070    use std::os::unix::fs::PermissionsExt;
1071    let holds_password = cfg
1072        .accounts
1073        .iter()
1074        .any(|a| a.password.as_ref().is_some_and(|p| !p.is_empty()));
1075    if !holds_password {
1076        return None;
1077    }
1078    let mode = std::fs::metadata(path).ok()?.permissions().mode();
1079    if mode & 0o077 == 0 {
1080        return None;
1081    }
1082    // The imperative first: the message line clips at the window
1083    // edge, and the path is usually the long part.
1084    Some(format!(
1085        "chmod 600 {} (it holds a password and others can read it)",
1086        path.display()
1087    ))
1088}
1089
1090impl Identity {
1091    /// "Name <email>" / "email" for the From header, if configured.
1092    pub fn from_line(&self) -> Option<String> {
1093        match (&self.name, &self.email) {
1094            (Some(n), Some(e)) => Some(format!("{n} <{e}>")),
1095            (None, Some(e)) => Some(e.clone()),
1096            _ => None,
1097        }
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    #[test]
1106    fn a_readable_config_holding_a_password_warns() {
1107        use std::os::unix::fs::PermissionsExt;
1108        let tmp = tempfile::tempdir().unwrap();
1109        let path = tmp.path().join("config.toml");
1110        std::fs::write(&path, "").unwrap();
1111        let with_password: Config = toml::from_str(
1112            r#"
1113            [[accounts]]
1114            name = "work"
1115            user = "jane"
1116            password = "hunter2"
1117            "#,
1118        )
1119        .unwrap();
1120        let with_command: Config = toml::from_str(
1121            r#"
1122            [[accounts]]
1123            name = "work"
1124            user = "jane"
1125            password_command = "gpg -q -d ~/.config/rmut/imap.gpg"
1126            "#,
1127        )
1128        .unwrap();
1129
1130        let mode =
1131            |m: u32| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(m)).unwrap();
1132        mode(0o644);
1133        let warning = secret_exposed(&with_password, &path).expect("a warning");
1134        assert!(warning.starts_with("chmod 600 "), "{warning}");
1135        // Shut when the bits are shut, and when there is no secret to
1136        // expose in the first place.
1137        mode(0o600);
1138        assert!(secret_exposed(&with_password, &path).is_none());
1139        mode(0o644);
1140        assert!(secret_exposed(&with_command, &path).is_none());
1141        assert!(secret_exposed(&Config::default(), &path).is_none());
1142    }
1143
1144    #[test]
1145    fn folder_shorthand_expands_everywhere_a_mailbox_is_named() {
1146        assert_eq!(expand_folder("=archive", Some("~/Mail")), "~/Mail/archive");
1147        assert_eq!(expand_folder("+archive", Some("~/Mail/")), "~/Mail/archive");
1148        // = or + alone is $folder itself.
1149        assert_eq!(expand_folder("=", Some("~/Mail")), "~/Mail");
1150        // An IMAP account works as $folder, so =x is one of its folders.
1151        assert_eq!(
1152            expand_folder("=Archive", Some("imap:work")),
1153            "imap:work/Archive"
1154        );
1155        // Nothing to expand, or nowhere to expand to: untouched.
1156        assert_eq!(expand_folder("~/other", Some("~/Mail")), "~/other");
1157        assert_eq!(expand_folder("=archive", None), "=archive");
1158        assert_eq!(expand_folder("=archive", Some("")), "=archive");
1159
1160        let mut cfg: Config = toml::from_str(
1161            r#"
1162            [mail]
1163            folder = "~/Mail"
1164            mailboxes = ["=inbox", "~/elsewhere"]
1165            sent = "+sent"
1166            trash = "=Trash"
1167            [[fcc_hooks]]
1168            pattern = "~A"
1169            mailbox = "=work"
1170            "#,
1171        )
1172        .unwrap();
1173        cfg.expand_folders();
1174        assert_eq!(cfg.mail.mailboxes, ["~/Mail/inbox", "~/elsewhere"]);
1175        assert_eq!(cfg.mail.sent.as_deref(), Some("~/Mail/sent"));
1176        assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
1177        assert_eq!(cfg.fcc_hooks[0].mailbox, "~/Mail/work");
1178        // Idempotent: an expanded name no longer starts with = or +.
1179        cfg.expand_folders();
1180        assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
1181    }
1182
1183    #[test]
1184    fn parses_partial_config() {
1185        let cfg: Config = toml::from_str(
1186            r#"
1187            [identity]
1188            name = "Jane"
1189            email = "jane@x"
1190            [mail]
1191            mailboxes = ["~/Maildir"]
1192            sendmail = "/bin/true"
1193            [keys.index]
1194            sync = "w"
1195            "#,
1196        )
1197        .unwrap();
1198        assert_eq!(cfg.identity.from_line().as_deref(), Some("Jane <jane@x>"));
1199        assert_eq!(cfg.mail.mailboxes, vec!["~/Maildir"]);
1200        assert_eq!(cfg.mail.sendmail.as_deref(), Some("/bin/true"));
1201        assert_eq!(cfg.keys.index.get("sync").map(String::as_str), Some("w"));
1202        assert!(cfg.ui.theme.is_none());
1203    }
1204
1205    #[test]
1206    fn empty_and_unknown_keys_are_fine() {
1207        let cfg: Config = toml::from_str("").unwrap();
1208        assert!(cfg.identity.from_line().is_none());
1209        let cfg: Config = toml::from_str("[future]\nx = 1\n").unwrap();
1210        assert!(cfg.mail.mailboxes.is_empty());
1211        assert!(cfg.accounts.is_empty());
1212    }
1213
1214    #[test]
1215    fn parses_accounts_with_defaults() {
1216        let cfg: Config = toml::from_str(
1217            r#"
1218            [[accounts]]
1219            name = "work"
1220            user = "jane@example.com"
1221            password_command = "pass show mail/work"
1222            imap_host = "imap.example.com"
1223            smtp_host = "smtp.example.com"
1224
1225            [[accounts]]
1226            name = "test"
1227            user = "u"
1228            password_command = "true"
1229            imap_host = "localhost"
1230            imap_port = 10143
1231            imap_tls = false
1232            smtp_port = 465
1233            sent_folder = "INBOX/Sent"
1234            "#,
1235        )
1236        .unwrap();
1237        let work = cfg.account("work").unwrap();
1238        assert_eq!(work.imap_port, 993);
1239        assert_eq!(work.smtp_port, 587);
1240        assert!(work.imap_tls && work.smtp_tls);
1241        assert_eq!(work.sent_folder, "Sent");
1242        let test = cfg.account("test").unwrap();
1243        assert_eq!(test.imap_port, 10143);
1244        assert!(!test.imap_tls);
1245        assert!(test.smtp_host.is_none());
1246        assert_eq!(test.sent_folder, "INBOX/Sent");
1247        assert!(cfg.account("nope").is_none());
1248    }
1249
1250    #[test]
1251    fn pgp_section_defaults_and_overrides() {
1252        let cfg: Config = toml::from_str("").unwrap();
1253        assert_eq!(cfg.pgp.command, "gpg");
1254        assert!(cfg.pgp.sign_key.is_none());
1255        assert!(!cfg.pgp.sign_by_default && !cfg.pgp.encrypt_by_default);
1256        let cfg: Config = toml::from_str(
1257            "[pgp]\ncommand = \"gpg2\"\nsign_key = \"jane@x\"\nsign_by_default = true\n",
1258        )
1259        .unwrap();
1260        assert_eq!(cfg.pgp.command, "gpg2");
1261        assert_eq!(cfg.pgp.sign_key.as_deref(), Some("jane@x"));
1262        assert!(cfg.pgp.sign_by_default && !cfg.pgp.encrypt_by_default);
1263    }
1264
1265    #[test]
1266    fn account_missing_required_field_fails_parse() {
1267        assert!(toml::from_str::<Config>("[[accounts]]\nname = \"x\"\n").is_err());
1268    }
1269
1270    fn test_account() -> Account {
1271        Account {
1272            name: "t".into(),
1273            user: "u".into(),
1274            password_command: None,
1275            password: None,
1276            imap_host: None,
1277            imap_port: 993,
1278            imap_tls: true,
1279            smtp_host: None,
1280            smtp_port: 587,
1281            smtp_tls: true,
1282            auth: None,
1283            token_command: None,
1284            sent_folder: "Sent".into(),
1285            identity: None,
1286        }
1287    }
1288
1289    #[test]
1290    fn glob_match_star_and_case() {
1291        assert!(glob_match("*", "anything"));
1292        assert!(glob_match("*work*", "/home/jane/Maildir/work-stuff"));
1293        assert!(glob_match("*@work.example.com", "Jane@Work.Example.Com"));
1294        assert!(glob_match("imap:work/*", "imap:work/INBOX"));
1295        assert!(!glob_match("*@work.example.com", "jane@example.com"));
1296        assert!(!glob_match("work", "workplace")); // anchored
1297        assert!(glob_match("a*b*c", "aXbYc"));
1298        assert!(!glob_match("a*b*c", "aXcYb"));
1299    }
1300
1301    #[test]
1302    fn identity_layers_like_hooks() {
1303        let cfg: Config = toml::from_str(
1304            r#"
1305            [identity]
1306            name = "Jane"
1307            email = "jane@example.com"
1308            reverse_name = true
1309
1310            [[identities]]
1311            folder = "*work*"
1312            email = "jane@work.example.com"
1313
1314            [[identities]]
1315            recipient = "*@club.example.com"
1316            name = "Jenny"
1317
1318            [[accounts]]
1319            name = "acct"
1320            user = "u"
1321            imap_host = "h"
1322            identity = { name = "Jane Acct", email = "acct@example.com" }
1323            "#,
1324        )
1325        .unwrap();
1326        assert!(cfg.identity.reverse_name);
1327        // No match: the global identity as-is.
1328        let id = cfg.identity_for("~/Maildir", &[], None);
1329        assert_eq!(id.from_line().as_deref(), Some("Jane <jane@example.com>"));
1330        // Folder rule overrides the email, keeps the name.
1331        let id = cfg.identity_for("~/Maildir/work", &[], None);
1332        assert_eq!(
1333            id.from_line().as_deref(),
1334            Some("Jane <jane@work.example.com>")
1335        );
1336        // Recipient rule overlays the name; needs a matching recipient.
1337        let rcpts = vec!["bob@club.example.com".to_string()];
1338        let id = cfg.identity_for("~/Maildir", &rcpts, None);
1339        assert_eq!(id.from_line().as_deref(), Some("Jenny <jane@example.com>"));
1340        let id = cfg.identity_for("~/Maildir", &[], None);
1341        assert_eq!(id.name.as_deref(), Some("Jane"));
1342        // The account identity sits between global and the rules.
1343        let account = cfg.account("acct").unwrap();
1344        let id = cfg.identity_for("imap:acct/INBOX", &[], Some(account));
1345        assert_eq!(
1346            id.from_line().as_deref(),
1347            Some("Jane Acct <acct@example.com>")
1348        );
1349        let id = cfg.identity_for("imap:acct/work", &[], Some(account));
1350        assert_eq!(
1351            id.from_line().as_deref(),
1352            Some("Jane Acct <jane@work.example.com>")
1353        );
1354    }
1355
1356    #[test]
1357    fn password_command_takes_first_line() {
1358        let account = |cmd: &str| Account {
1359            password_command: Some(cmd.into()),
1360            ..test_account()
1361        };
1362        assert_eq!(
1363            account("printf 'secret\\nrest\\n'").password().unwrap(),
1364            "secret"
1365        );
1366        assert!(account("false").password().is_err());
1367        assert!(account("true").password().is_err()); // empty output
1368    }
1369
1370    #[test]
1371    fn auth_kinds_and_token_command() {
1372        let acct = test_account();
1373        assert_eq!(acct.auth_kind().unwrap(), AuthKind::Password);
1374        let oauth = Account {
1375            auth: Some("oauthbearer".into()),
1376            token_command: Some("printf 'tok123\\nrest\\n'".into()),
1377            ..test_account()
1378        };
1379        assert_eq!(oauth.auth_kind().unwrap(), AuthKind::OAuthBearer);
1380        assert_eq!(oauth.secret().unwrap(), "tok123");
1381        let no_command = Account {
1382            auth: Some("xoauth2".into()),
1383            ..test_account()
1384        };
1385        assert!(
1386            no_command
1387                .secret()
1388                .unwrap_err()
1389                .to_string()
1390                .contains("no token_command")
1391        );
1392        let bad = Account {
1393            auth: Some("kerberos".into()),
1394            ..test_account()
1395        };
1396        assert!(bad.auth_kind().is_err());
1397        // "password" is an explicit spelling of the default.
1398        let explicit = Account {
1399            auth: Some("password".into()),
1400            password: Some("pw".into()),
1401            ..test_account()
1402        };
1403        assert_eq!(explicit.secret().unwrap(), "pw");
1404    }
1405
1406    #[test]
1407    fn oauth_initial_responses() {
1408        assert_eq!(
1409            AuthKind::XOAuth2.initial_response("jane", "tok", "imap.example.com", 993),
1410            "user=jane\x01auth=Bearer tok\x01\x01"
1411        );
1412        assert_eq!(
1413            AuthKind::OAuthBearer.initial_response("jane", "tok", "imap.example.com", 993),
1414            "n,a=jane,\x01host=imap.example.com\x01port=993\x01auth=Bearer tok\x01\x01"
1415        );
1416    }
1417
1418    #[test]
1419    fn stored_password_and_precedence() {
1420        let stored = Account {
1421            password: Some("hunter2".into()),
1422            ..test_account()
1423        };
1424        assert_eq!(stored.password().unwrap(), "hunter2");
1425        // A configured command wins over the stored password.
1426        let both = Account {
1427            password_command: Some("echo from-command".into()),
1428            password: Some("hunter2".into()),
1429            ..test_account()
1430        };
1431        assert_eq!(both.password().unwrap(), "from-command");
1432        let neither = test_account();
1433        assert!(neither.password().is_err());
1434        let cfg: Config = toml::from_str(
1435            "[[accounts]]\nname = \"x\"\nuser = \"u\"\npassword = \"pw\"\nimap_host = \"h\"\n",
1436        )
1437        .unwrap();
1438        assert_eq!(cfg.account("x").unwrap().password().unwrap(), "pw");
1439    }
1440}