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