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