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