1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10
11pub struct Import {
17 pub toml: String,
18 pub aliases: Vec<String>,
19 pub alias_file: Option<String>,
22}
23
24pub fn import_file(path: &Path) -> Result<Import> {
25 let text =
26 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
27 let dir = path.parent().unwrap_or(Path::new("."));
28 Ok(import(&text, dir))
29}
30
31pub fn import(text: &str, dir: &Path) -> Import {
33 let mut st = State::default();
34 parse_into(text, dir, 0, &mut st);
35 Import {
36 toml: st.to_toml(),
37 aliases: st.aliases.clone(),
38 alias_file: st.alias_file.clone(),
39 }
40}
41
42struct IdRule {
44 folder: Option<String>,
45 recipient: Option<String>,
46 name: Option<String>,
47 email: Option<String>,
48}
49
50#[derive(Default)]
51struct State {
52 name: Option<String>,
53 email: Option<String>,
54 reverse_name: bool,
55 no_reverse_realname: bool,
57 identity_rules: Vec<IdRule>,
58 folder_hook_lines: Vec<(String, String)>,
61 message_hooks: Vec<(String, String)>,
63 reply_hooks: Vec<(String, String)>,
64 fcc_hooks: Vec<(String, String)>,
66 crypt_hooks: Vec<(String, String)>,
68 folder: Option<String>,
70 spoolfile: Option<String>,
71 mailboxes: Vec<String>,
72 sent: Option<String>,
73 postponed: Option<String>,
74 sendmail: Option<String>,
75 editor: Option<String>,
76 poll_seconds: Option<u64>,
77 index_format: Option<String>,
78 colors: BTreeMap<&'static str, String>,
79 quoted_colors: BTreeMap<usize, String>,
81 color_index_rules: Vec<(String, String, String)>,
83 color_body_rules: Vec<(String, String, String)>,
85 quote_regexp: Option<String>,
86 hdr_ignore: Vec<String>,
88 lists: Vec<String>,
92 subscribed: Vec<String>,
93 alternates: Vec<String>,
95 my_hdr: Vec<String>,
97 metoo: bool,
99 forward_quote: bool,
101 signature: Option<String>,
103 no_sig_dashes: bool,
104 sig_on_top: bool,
105 hostname: Option<String>,
106 user_agent: bool,
107 abort_nosubject: Option<String>,
110 no_abort_unmodified: bool,
111 text_flowed: bool,
113 delete: Option<String>,
115 abort_noattach: Option<String>,
118 attach_keyword: Option<String>,
119 no_reflow_text: bool,
121 alternative_order: Vec<String>,
123 hdr_unignore: Vec<String>,
124 hdr_order: Vec<String>,
125 pager_format: Option<String>,
126 wrap: Option<i64>,
127 tilde: bool,
128 status_format: Option<String>,
129 title_format: Option<String>,
130 set_title: bool,
131 history_file: Option<String>,
132 status_on_top: bool,
133 arrow_cursor: bool,
134 status_chars: Option<String>,
135 no_beep: bool,
136 beep_new: bool,
138 no_wait_key: bool,
140 no_mark_old: bool,
142 no_delete_untag: bool,
145 flag_safe: bool,
146 maildir_trash: bool,
147 no_mail_check_recent: bool,
148 no_check_new: bool,
149 no_uncollapse_new: bool,
152 no_menu_scroll: bool,
155 menu_context: Option<usize>,
156 no_menu_move_off: bool,
157 no_help: bool,
158 sort_browser: Option<String>,
160 sort_alias: Option<String>,
161 shell: Option<String>,
162 tmpdir: Option<String>,
163 ispell: Option<String>,
164 print_confirm: Option<String>,
166 quit: Option<String>,
168 postpone: Option<String>,
169 recall: Option<String>,
170 confirmappend: bool,
171 save_name: bool,
172 force_name: bool,
173 pager_stop: bool,
176 markers_off: bool,
177 smart_wrap_off: bool,
178 collapse_unread_off: bool,
179 uncollapse_jump: bool,
180 hide_thread_subject: bool,
181 strict_threads: bool,
184 sort_re_off: bool,
185 attribution: Option<String>,
188 indent_string: Option<String>,
189 reply_regexp: Option<String>,
190 simple_search: Option<String>,
191 no_wrap_search: bool,
192 forward_format: Option<String>,
193 include: Option<String>,
195 ask_cc: bool,
196 ask_bcc: bool,
197 connect_timeout: Option<u64>,
199 certificate_file: Option<String>,
202 no_system_cas: bool,
203 keys_index: BTreeMap<&'static str, String>,
204 keys_pager: BTreeMap<&'static str, String>,
205 macros_index: BTreeMap<String, String>,
206 macros_pager: BTreeMap<String, String>,
207 sign_key: Option<String>,
208 sign_by_default: bool,
209 encrypt_by_default: bool,
210 reply_sign: bool,
211 reply_encrypt: bool,
212 reply_sign_encrypted: bool,
213 print: Option<String>,
214 query_command: Option<String>,
215 trash: Option<String>,
216 edit_headers_on: bool,
218 sort: Option<String>,
219 sort_aux: Option<String>,
220 date_format: Option<String>,
221 pager_index_lines: Option<u64>,
222 pager_context: Option<u64>,
223 search_context: Option<u64>,
224 forward_attach: bool,
225 forward_ask: bool,
227 fast_reply: bool,
228 autoedit: bool,
229 no_copy: bool,
231 new_mail_command: Option<String>,
232 save_default: Option<String>,
233 filters: BTreeMap<String, String>,
234 imap_user: Option<String>,
235 imap_pass: Option<String>,
236 oauth: Option<String>,
238 smtp_pass: Option<String>,
239 smtp_url: Option<String>,
240 aliases: Vec<String>,
241 skipped: Vec<String>,
242 satisfied: Vec<String>,
245 differs: Vec<String>,
248 sidebar_visible: bool,
249 sidebar_width: Option<u16>,
250 alias_file: Option<String>,
251}
252
253fn parse_into(text: &str, dir: &Path, depth: usize, st: &mut State) {
254 for line in logical_lines(text) {
255 let tokens = tokenize(&line);
256 let Some(cmd) = tokens.first() else {
257 continue;
258 };
259 match cmd.as_str() {
260 "set" => {
261 for (name, value) in assignments(&tokens[1..]) {
262 st.set(&name, &value, &line);
263 }
264 }
265 "mailboxes" => {
266 for t in &tokens[1..] {
267 let m = st.expand_mailbox(t);
268 if !st.mailboxes.contains(&m) {
269 st.mailboxes.push(m);
270 }
271 }
272 }
273 "alias" => st.aliases.push(line.clone()),
274 "lists" => {
275 for t in &tokens[1..] {
276 if !st.lists.contains(t) {
277 st.lists.push(t.clone());
278 }
279 }
280 }
281 "subscribe" => {
282 for t in &tokens[1..] {
283 if !st.subscribed.contains(t) {
284 st.subscribed.push(t.clone());
285 }
286 }
287 }
288 "unlists" | "unsubscribe" => {
289 for t in &tokens[1..] {
290 st.lists.retain(|l| l != t);
291 st.subscribed.retain(|l| l != t);
292 }
293 }
294 "alternates" => {
295 for t in &tokens[1..] {
296 if !st.alternates.contains(t) {
297 st.alternates.push(t.clone());
298 }
299 }
300 }
301 "unalternates" => {
302 for t in &tokens[1..] {
303 if t == "*" {
304 st.alternates.clear();
305 } else {
306 st.alternates.retain(|a| a != t);
307 }
308 }
309 }
310 "my_hdr" => {
313 let rest = line["my_hdr".len()..].trim().trim_matches('"').to_string();
314 if rest.contains(':') {
315 st.set_my_hdr(rest);
316 } else {
317 st.skip(&line, "my_hdr needs a \"Name: value\" header line");
318 }
319 }
320 "unmy_hdr" => {
321 for t in &tokens[1..] {
322 if t == "*" {
323 st.my_hdr.clear();
324 } else {
325 let name = t.trim_end_matches(':');
326 st.my_hdr.retain(|h| !header_named(h, name));
327 }
328 }
329 }
330 "ignore" => st.hdr_ignore.extend(tokens[1..].iter().cloned()),
331 "unignore" => st.hdr_unignore.extend(tokens[1..].iter().cloned()),
332 "hdr_order" => st.hdr_order.extend(
333 tokens[1..]
334 .iter()
335 .map(|t| t.trim_end_matches(':').to_string()),
336 ),
337 "bind" => st.bind(&tokens[1..], &line),
338 "macro" => st.mutt_macro(&tokens[1..], &line),
339 "color" => st.color(&tokens[1..], &line),
340 "auto_view" | "unauto_view" => {
341 for mime in &tokens[1..] {
342 st.auto_view(cmd == "auto_view", mime, &line);
343 }
344 }
345 "alternative_order" | "unalternative_order" => {
346 for mime in &tokens[1..] {
347 let mime = mime.to_lowercase();
348 if cmd == "alternative_order" {
349 if !st.alternative_order.contains(&mime) {
350 st.alternative_order.push(mime);
351 }
352 } else if mime == "*" {
353 st.alternative_order.clear();
354 } else {
355 st.alternative_order.retain(|t| *t != mime);
356 }
357 }
358 }
359 "folder-hook" | "send-hook" => {
360 let folder_hook = cmd == "folder-hook";
361 st.hook(folder_hook, &tokens[1..], &line);
362 }
363 "message-hook" | "reply-hook" => st.command_hook(cmd, &tokens[1..], &line),
364 "fcc-hook" | "fcc-save-hook" => st.fcc_hook(cmd, &tokens[1..], &line),
365 "crypt-hook" | "pgp-hook" => match &tokens[1..] {
366 [address, key] => st.crypt_hooks.push((address.clone(), key.clone())),
367 _ => st.skip(&line, "crypt-hook ADDRESS KEYID"),
368 },
369 "save-hook" => match (tokens.get(1).map(String::as_str), tokens.get(2)) {
370 (Some("." | "~A"), Some(mailbox)) => st.save_default = Some(mailbox.clone()),
372 _ => st.skip(&line, "only the catch-all pattern . maps to [mail] save"),
373 },
374 "source" if tokens.len() >= 2 => {
375 if depth >= 10 {
376 st.skip(&line, "source nesting too deep");
377 continue;
378 }
379 let target = expand_path(&tokens[1], dir);
380 match std::fs::read_to_string(&target) {
381 Ok(included) => {
382 let sub = target.parent().unwrap_or(dir).to_path_buf();
383 parse_into(&included, &sub, depth + 1, st);
384 }
385 Err(err) => st.skip(&line, &format!("cannot read: {err}")),
386 }
387 }
388 _ => st.skip(&line, "no rmut equivalent"),
389 }
390 }
391}
392
393fn logical_lines(text: &str) -> Vec<String> {
397 let mut out = Vec::new();
398 let mut pending = String::new();
399 for raw in text.lines() {
400 pending.push_str(raw);
401 if pending.ends_with('\\') {
402 pending.pop();
403 continue;
404 }
405 let line = strip_comment(&pending);
406 if !line.trim().is_empty() {
407 out.push(line.trim().to_string());
408 }
409 pending.clear();
410 }
411 if !pending.trim().is_empty() {
412 out.push(strip_comment(&pending).trim().to_string());
413 }
414 out.retain(|l| !l.is_empty());
415 out
416}
417
418fn strip_comment(line: &str) -> String {
420 let mut quote = None;
421 for (i, c) in line.char_indices() {
422 match (quote, c) {
423 (None, '#') => return line[..i].to_string(),
424 (None, '\'' | '"') => quote = Some(c),
425 (Some(q), c) if c == q => quote = None,
426 _ => {}
427 }
428 }
429 line.to_string()
430}
431
432pub(crate) fn tokenize(line: &str) -> Vec<String> {
435 let mut out = Vec::new();
436 let mut cur = String::new();
437 let mut has = false;
438 let mut quote: Option<char> = None;
439 let mut chars = line.chars();
440 while let Some(c) = chars.next() {
441 match (quote, c) {
442 (Some('\''), '\'') | (Some('"'), '"') => quote = None,
443 (Some(_), c) => cur.push(c),
444 (None, '\'' | '"') => {
445 quote = Some(c);
446 has = true;
447 }
448 (None, '\\') => {
449 if let Some(next) = chars.next() {
450 cur.push('\\');
451 cur.push(next);
452 has = true;
453 }
454 }
455 (None, c) if c.is_whitespace() => {
456 if has || !cur.is_empty() {
457 out.push(std::mem::take(&mut cur));
458 has = false;
459 }
460 }
461 (None, c) => {
462 cur.push(c);
463 has = true;
464 }
465 }
466 }
467 if has || !cur.is_empty() {
468 out.push(cur);
469 }
470 out
471}
472
473pub(crate) fn assignments(tokens: &[String]) -> Vec<(String, String)> {
476 let mut out = Vec::new();
477 let mut i = 0;
478 while i < tokens.len() {
479 let t = &tokens[i];
480 if let Some((name, value)) = t.split_once('=') {
481 if !name.is_empty() && !value.is_empty() {
482 out.push((name.to_string(), value.to_string()));
483 i += 1;
484 } else if !name.is_empty() {
485 out.push((
487 name.to_string(),
488 tokens.get(i + 1).cloned().unwrap_or_default(),
489 ));
490 i += 2;
491 } else {
492 i += 1;
493 }
494 } else if tokens.get(i + 1).map(String::as_str) == Some("=") {
495 out.push((t.clone(), tokens.get(i + 2).cloned().unwrap_or_default()));
496 i += 3;
497 } else if let Some(v) = tokens.get(i + 1).and_then(|n| n.strip_prefix('=')) {
498 out.push((t.clone(), v.to_string()));
499 i += 2;
500 } else if let Some(name) = t.strip_prefix("no") {
501 out.push((name.to_string(), "no".into()));
502 i += 1;
503 } else {
504 out.push((t.clone(), "yes".into()));
505 i += 1;
506 }
507 }
508 out
509}
510
511pub(crate) fn is_yes(value: &str) -> bool {
512 matches!(value, "yes" | "ask-yes" | "true" | "1")
513}
514
515pub(crate) fn split_from(v: &str) -> (Option<String>, String) {
517 match v.split_once('<') {
518 Some((n, rest)) => {
519 let n = n.trim().trim_matches('"');
520 (
521 (!n.is_empty()).then(|| n.to_string()),
522 rest.trim_end_matches('>').trim().to_string(),
523 )
524 }
525 None => (None, v.trim().to_string()),
526 }
527}
528
529fn hook_glob(pattern: &str) -> Option<String> {
533 let p = pattern.trim();
534 let p = p
535 .strip_prefix("~t ")
536 .or_else(|| p.strip_prefix("~C "))
537 .unwrap_or(p)
538 .trim()
539 .trim_start_matches(['+', '=']);
540 if p.starts_with('~') || p.starts_with('%') {
541 return None;
542 }
543 if p == "." || p == ".*" {
544 return Some("*".into());
545 }
546 let (p, anchored_start) = match p.strip_prefix('^') {
547 Some(rest) => (rest, true),
548 None => (p, false),
549 };
550 let (p, anchored_end) = match p.strip_suffix('$') {
551 Some(rest) => (rest, true),
552 None => (p, false),
553 };
554 let mut glob = String::new();
555 let mut chars = p.chars().peekable();
556 while let Some(c) = chars.next() {
557 match c {
558 '\\' => glob.push(chars.next()?),
559 '.' if chars.peek() == Some(&'*') => {
560 chars.next();
561 glob.push('*');
562 }
563 '(' | ')' | '[' | ']' | '{' | '}' | '|' | '+' | '?' | '$' | '^' | '*' => return None,
564 c => glob.push(c),
565 }
566 }
567 if !anchored_start && !glob.starts_with('*') {
568 glob.insert(0, '*');
569 }
570 if !anchored_end && !glob.ends_with('*') {
571 glob.push('*');
572 }
573 Some(glob)
574}
575
576fn word_boundaries(re: &str) -> String {
584 re.replace(r"\\<", r"\b")
585 .replace(r"\\>", r"\b")
586 .replace(r"\<", r"\b")
587 .replace(r"\>", r"\b")
588}
589
590pub fn alias_block(aliases: &[String], target: &std::path::Path) -> String {
593 if aliases.is_empty() {
594 return String::new();
595 }
596 let mut out = format!(
597 "\n# aliases found: rmut reads mutt-format alias files; put these\n\
598 # lines in {} (or point $RMUT_ALIASES at them):\n",
599 target.display()
600 );
601 for a in aliases {
602 out += &format!("# {a}\n");
603 }
604 out
605}
606
607fn default_hook_pattern(pattern: &str) -> String {
608 let p = pattern.trim();
609 if p == "." || p == ".*" {
610 return "~A".into();
611 }
612 if p.starts_with('~') || p.starts_with('!') || p.starts_with('(') || p.contains(" ~") {
613 return p.to_string();
614 }
615 format!("(~f \"{p}\" !~P) | (~P ~C \"{p}\")")
616}
617
618fn expand_path(value: &str, dir: &Path) -> PathBuf {
619 if let Some(rest) = value.strip_prefix("~/")
620 && let Ok(home) = std::env::var("HOME")
621 {
622 return PathBuf::from(home).join(rest);
623 }
624 let p = PathBuf::from(value);
625 if p.is_relative() { dir.join(p) } else { p }
626}
627
628const ACCOUNT: &str = "mutt";
632
633fn header_named(entry: &str, name: &str) -> bool {
635 entry
636 .split_once(':')
637 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
638}
639
640impl State {
641 fn skip(&mut self, line: &str, why: &str) {
642 self.skipped.push(format!("{line} ({why})"));
643 }
644
645 fn set_my_hdr(&mut self, entry: String) {
648 let Some((name, _)) = entry.split_once(':') else {
649 return;
650 };
651 let name = name.trim().to_string();
652 self.my_hdr.retain(|h| !header_named(h, &name));
653 self.my_hdr.push(entry);
654 }
655
656 fn satisfy(&mut self, line: &str, why: &str) {
657 self.satisfied.push(format!("{line} ({why})"));
658 }
659
660 fn differently(&mut self, line: &str, how: &str) {
663 self.differs.push(format!("{line} ({how})"));
664 }
665
666 fn set(&mut self, name: &str, value: &str, line: &str) {
667 let v = value.to_string();
668 match name {
669 "realname" => self.name = Some(v),
670 "from" => {
671 let (n, e) = split_from(&v);
672 if let Some(n) = n {
673 self.name.get_or_insert(n);
674 }
675 self.email = Some(e);
676 }
677 "metoo" => {
678 self.metoo = is_yes(value);
679 }
680 "delete" => {
681 match v.as_str() {
685 "yes" | "no" => self.delete = Some(v),
686 _ => {}
687 }
688 }
689 "abort_noattach" => {
690 self.abort_noattach = Some(
692 match v.as_str() {
693 "yes" => "yes",
694 "no" => "no",
695 _ => "ask",
696 }
697 .to_string(),
698 );
699 }
700 "abort_noattach_regex" => self.attach_keyword = Some(word_boundaries(&v)),
701 "text_flowed" => {
702 self.text_flowed = is_yes(value);
703 }
704 "reflow_text" => {
705 self.no_reflow_text = !is_yes(value);
706 }
707 "reverse_name" => {
708 if is_yes(&v) {
709 self.reverse_name = true;
710 } else {
711 self.satisfy(line, "off is rmut's default");
712 }
713 }
714 "folder" => self.folder = Some(v),
715 "spoolfile" => self.spoolfile = Some(v),
716 "record" => self.sent = Some(v),
717 "postponed" => self.postponed = Some(v),
718 "sendmail" => self.sendmail = Some(v),
719 "editor" | "visual" => self.editor = Some(v),
720 "mail_check" => match v.parse() {
721 Ok(n) => self.poll_seconds = Some(n),
722 Err(_) => self.skip(line, "not a number"),
723 },
724 "index_format" => self.index_format = Some(v),
725 "pgp_sign_as" | "pgp_default_key" => self.sign_key = Some(v),
726 "crypt_autosign" | "pgp_autosign" => self.sign_by_default = is_yes(&v),
727 "crypt_autoencrypt" | "pgp_autoencrypt" => self.encrypt_by_default = is_yes(&v),
728 "crypt_replysign" => self.reply_sign = is_yes(&v),
729 "crypt_replyencrypt" => self.reply_encrypt = is_yes(&v),
730 "crypt_replysignencrypted" => self.reply_sign_encrypted = is_yes(&v),
731 "assumed_charset" => self.differently(
732 line,
733 "rmut lets mailparse decode declared charsets; undeclared 8-bit is read as UTF-8",
734 ),
735 "print_command" => self.print = Some(v),
736 "query_command" => self.query_command = Some(v),
737 "trash" => self.trash = Some(v),
738 "edit_headers" => {
739 if is_yes(&v) {
740 self.edit_headers_on = true;
741 } else {
742 self.satisfy(line, "off is rmut's default too");
743 }
744 }
745 "status_format" => self.status_format = Some(v),
746 "status_chars" => self.status_chars = Some(v),
747 "ts_status_format" | "ts_icon_format" => self.title_format = Some(v),
748 "history_file" => self.history_file = Some(v),
749 "status_on_top" => {
750 if is_yes(value) {
751 self.status_on_top = true;
752 } else {
753 self.satisfy(line, "the status bar sits at the bottom, as rmut has it");
754 }
755 }
756 "arrow_cursor" => {
757 if is_yes(value) {
758 self.arrow_cursor = true;
759 } else {
760 self.satisfy(line, "rmut marks the selection with reverse video by default");
761 }
762 }
763 "save_history" | "history" => self.differently(
764 line,
765 "rmut keeps 100 entries per prompt; set [ui] history_file to persist them",
766 ),
767 "ts_enabled" => {
768 if is_yes(value) {
769 self.set_title = true;
770 } else {
771 self.satisfy(line, "rmut leaves the terminal title alone by default");
772 }
773 }
774 "imap_user" => self.imap_user = Some(v),
775 "smtp_url" => self.smtp_url = Some(v),
776 "imap_pass" => self.imap_pass = Some(v),
777 "smtp_pass" => self.smtp_pass = Some(v),
778 "certificate_file" | "ssl_ca_certificates_file" => self.certificate_file = Some(v),
779 "ssl_usesystemcerts" => {
780 if is_yes(&v) {
781 self.satisfy(line, "rmut trusts the OS store by default");
782 } else {
783 self.no_system_cas = true;
784 }
785 }
786 "ssl_verify_host" | "ssl_verify_dates" => {
787 if is_yes(&v) {
790 self.satisfy(line, "rmut always verifies the certificate");
791 } else {
792 self.skip(line, "rmut cannot be told to skip verification");
793 }
794 }
795 "tunnel" => self.skip(line, "rmut has no $tunnel transport yet"),
796 "ssl_client_cert" => self.skip(line, "rmut has no client-certificate auth yet"),
797 "ssl_starttls" | "ssl_force_tls" => {
798 if is_yes(&v) {
799 self.satisfy(line, "rmut always negotiates TLS/STARTTLS");
800 } else {
801 self.skip(line, "rmut cannot skip TLS (imap_tls = false is for tests)");
802 }
803 }
804 "charset" | "send_charset" => {
805 if v.to_lowercase().replace(['-', '_'], "").contains("utf8") {
806 self.satisfy(line, "rmut is UTF-8 native");
807 } else {
808 self.skip(line, "rmut is UTF-8 only");
809 }
810 }
811 "pgp_auto_decode" => {
812 if is_yes(&v) {
813 self.satisfy(line, "rmut always decrypts/verifies PGP on view");
814 } else {
815 self.skip(line, "rmut always checks PGP; there is no off switch");
816 }
817 }
818 "sort" => {
819 let (rev, name) = match v.strip_prefix("reverse-") {
820 Some(rest) => ("reverse-", rest),
821 None => ("", v.as_str()),
822 };
823 match name {
824 "date" | "date-sent" | "date-received" => {
825 self.sort = Some(format!("{rev}date"));
826 }
827 "threads" => self.sort = Some("threads".into()),
828 "subject" | "size" | "from" | "label" => {
829 self.sort = Some(format!("{rev}{name}"))
830 }
831 _ => self.skip(line, "no matching rmut sort order"),
832 }
833 }
834 "sort_aux" => {
835 let spec = v.trim().to_lowercase();
839 let bare = spec.strip_prefix("reverse-").unwrap_or(&spec);
840 let bare = bare.strip_prefix("last-").unwrap_or(bare);
841 match bare {
842 "date" | "date-sent" | "date-received" => {
843 if spec == "date" {
844 self.satisfy(line, "threads are ordered oldest-first by default");
845 } else {
846 self.sort_aux = Some(spec);
847 }
848 }
849 _ => self.skip(line, "rmut orders threads by date"),
850 }
851 }
852 "date_format" => {
853 self.date_format = Some(v.trim_start_matches('!').to_string());
856 }
857 "pager_index_lines" => match v.parse() {
858 Ok(n) => self.pager_index_lines = Some(n),
859 Err(_) => self.skip(line, "not a number"),
860 },
861 "pager_context" => match v.parse() {
862 Ok(n) => self.pager_context = Some(n),
863 Err(_) => self.skip(line, "not a number"),
864 },
865 "search_context" => match v.parse() {
866 Ok(n) => self.search_context = Some(n),
867 Err(_) => self.skip(line, "not a number"),
868 },
869 "quote_regexp" => self.quote_regexp = Some(v.to_string()),
870 "new_mail_command" => self.new_mail_command = Some(v.to_string()),
871 "pager_format" => self.pager_format = Some(v.to_string()),
872 "wrap" => match v.parse() {
873 Ok(n) => self.wrap = Some(n),
874 Err(_) => self.skip(line, "not a number"),
875 },
876 "tilde" => {
877 if is_yes(&v) {
878 self.tilde = true;
879 } else {
880 self.satisfy(line, "no tilde padding is rmut's default");
881 }
882 }
883 "mime_forward" => {
884 let m = v.to_lowercase();
885 if m.starts_with("ask") {
886 self.forward_ask = true;
887 } else if is_yes(&v) {
888 self.forward_attach = true;
889 } else {
890 self.satisfy(line, "inline forwarding is rmut's default");
891 }
892 }
893 "connect_timeout" => match v.parse::<i64>() {
894 Ok(secs) => self.connect_timeout = Some(secs.max(0) as u64),
896 Err(_) => self.skip(line, "connect_timeout wants a number"),
897 },
898 "beep" => {
899 if is_yes(&v) {
900 self.satisfy(line, "beeping on errors is rmut's default");
901 } else {
902 self.no_beep = true;
903 }
904 }
905 "beep_new" => {
906 if is_yes(value) {
907 self.beep_new = true;
908 } else {
909 self.satisfy(line, "an arrival rings no bell of its own");
910 }
911 }
912 "wait_key" => {
913 if is_yes(value) {
914 self.satisfy(line, "a shell escape waits for Enter already");
915 } else {
916 self.no_wait_key = true;
917 }
918 }
919 "mark_old" => {
920 if is_yes(value) {
921 self.satisfy(line, "unread mail ages to old on the way out, as in mutt");
922 } else {
923 self.no_mark_old = true;
924 }
925 }
926 "delete_untag" => {
927 if is_yes(value) {
928 self.satisfy(line, "deleting a tagged message untags it, as in mutt");
929 } else {
930 self.no_delete_untag = true;
931 }
932 }
933 "flag_safe" => {
934 if is_yes(value) {
935 self.flag_safe = true;
936 } else {
937 self.satisfy(line, "flagged messages can be deleted, as in mutt");
938 }
939 }
940 "maildir_trash" => {
941 if is_yes(value) {
942 self.maildir_trash = true;
943 } else {
944 self.satisfy(line, "a purge unlinks, as in mutt");
945 }
946 }
947 "keep_flagged" => {
948 self.differently(line, "rmut has no $move: nothing leaves the spool on its own");
949 }
950 "uncollapse_new" => {
951 if is_yes(value) {
952 self.satisfy(line, "a folded thread unfolds when it grows, as in mutt");
953 } else {
954 self.no_uncollapse_new = true;
955 }
956 }
957 "hide_limited" | "hide_top_limited" => {
958 if is_yes(value) {
959 self.satisfy(line, "rmut never marks limited-out messages in the tree");
960 } else {
961 self.differently(line, "rmut's tree shows only what the limit shows");
962 }
963 }
964 "thread_received" => {
965 self.differently(
966 line,
967 "rmut threads by References only; there is no subject threading to date",
968 );
969 }
970 "mail_check_recent" => {
971 if is_yes(value) {
972 self.satisfy(line, "only a mailbox that grew is announced, as in mutt");
973 } else {
974 self.no_mail_check_recent = true;
975 }
976 }
977 "check_new" => {
978 if is_yes(value) {
979 self.satisfy(line, "the open maildir is rescanned as it changes, as in mutt");
980 } else {
981 self.no_check_new = true;
982 }
983 }
984 "menu_move_off" => {
985 if is_yes(value) {
986 self.satisfy(line, "the last message may scroll off the bottom, as in mutt");
987 } else {
988 self.no_menu_move_off = true;
989 }
990 }
991 "menu_context" => match v.parse::<usize>() {
992 Ok(0) => self.satisfy(line, "no context lines is the default"),
993 Ok(n) => self.menu_context = Some(n),
994 Err(_) => self.skip(line, "menu_context wants a number"),
995 },
996 "help" => {
997 if is_yes(value) {
998 self.satisfy(line, "the help bar is on by default");
999 } else {
1000 self.no_help = true;
1001 }
1002 }
1003 "sort_browser" => match v.as_str() {
1004 "alpha" => self.satisfy(line, "the browser sorts by name by default"),
1005 _ => self.sort_browser = Some(v.clone()),
1006 },
1007 "sort_alias" => match v.as_str() {
1008 "address" => self.satisfy(line, "completion sorts by address by default"),
1009 _ => self.sort_alias = Some(v.clone()),
1010 },
1011 "shell" => self.shell = Some(v.clone()),
1012 "ispell" => match v.as_str() {
1013 "ispell" => self.satisfy(line, "ispell is the default"),
1014 _ => self.ispell = Some(v.clone()),
1015 },
1016 "tmpdir" => self.tmpdir = Some(v.clone()),
1017 "sleep_time" => {
1018 self.differently(line, "rmut never pauses on a message; it stays until the next key");
1019 }
1020 "read_inc" | "write_inc" | "net_inc" | "time_inc" => {
1021 self.differently(line, "progress shows as the connection reports it");
1022 }
1023 "print" => {
1024 let want = v.trim().to_lowercase();
1027 match want.as_str() {
1028 "ask-no" => self.satisfy(line, "p asks first, with Enter declining"),
1029 "yes" | "no" | "ask-yes" => self.print_confirm = Some(want),
1030 _ => self.skip(line, "print wants yes / no / ask-yes / ask-no"),
1031 }
1032 }
1033 "reverse_realname" => {
1034 if is_yes(value) {
1035 self.satisfy(line, "the name comes over with the address");
1036 } else {
1037 self.no_reverse_realname = true;
1038 }
1039 }
1040 "timeout" => self.differently(
1041 line,
1042 "rmut polls on a timer of its own, not on an idle keypress; [mail] poll_seconds is the interval",
1043 ),
1044 "strict_threads" => {
1045 if is_yes(value) {
1046 self.strict_threads = true;
1047 } else {
1048 self.satisfy(
1049 line,
1050 "rmut groups what carries no References by subject, as mutt does",
1051 );
1052 }
1053 }
1054 "duplicate_threads" => {
1055 if is_yes(value) {
1056 self.differently(
1057 line,
1058 "rmut gives each copy of a Message-ID its own line; ~= finds them",
1059 );
1060 } else {
1061 self.satisfy(line, "rmut keeps duplicate Message-IDs apart");
1062 }
1063 }
1064 "hide_missing" | "hide_top_missing" => {
1065 if is_yes(value) {
1066 self.satisfy(line, "rmut never draws a message it does not have");
1067 } else {
1068 self.skip(line, "rmut cannot draw messages it does not have");
1069 }
1070 }
1071 "narrow_tree" => {
1072 if is_yes(value) {
1073 self.satisfy(line, "rmut's tree is two columns a level already");
1074 } else {
1075 self.differently(line, "rmut's tree is two columns a level, wide or not");
1076 }
1077 }
1078 "quit" => {
1079 let want = v.trim().to_lowercase();
1080 match want.as_str() {
1081 "yes" => self.satisfy(line, "q leaves at once, as in mutt"),
1082 "no" | "ask-yes" | "ask-no" => self.quit = Some(want),
1083 _ => self.skip(line, "quit wants yes / no / ask-yes / ask-no"),
1084 }
1085 }
1086 "postpone" => {
1087 let want = v.trim().to_lowercase();
1088 match want.as_str() {
1089 "ask-yes" => self.satisfy(line, "leaving a draft asks, as in mutt"),
1090 "yes" | "no" | "ask-no" => self.postpone = Some(want),
1091 _ => self.skip(line, "postpone wants yes / no / ask-yes / ask-no"),
1092 }
1093 }
1094 "recall" => {
1095 let want = v.trim().to_lowercase();
1096 match want.as_str() {
1097 "ask-yes" | "ask-no" => {
1098 self.satisfy(line, "rmut offers new-or-recall when drafts wait")
1099 }
1100 "yes" | "no" => self.recall = Some(want),
1101 _ => self.skip(line, "recall wants yes / no / ask-yes / ask-no"),
1102 }
1103 }
1104 "confirmappend" => {
1105 if is_yes(value) {
1106 self.confirmappend = true;
1107 } else {
1108 self.satisfy(line, "rmut adds to an existing mailbox without asking");
1109 }
1110 }
1111 "save_name" => {
1112 if is_yes(value) {
1113 self.save_name = true;
1114 } else {
1115 self.satisfy(line, "the save prompt offers [mail] save, as rmut always has");
1116 }
1117 }
1118 "force_name" => {
1119 if is_yes(value) {
1120 self.force_name = true;
1121 } else {
1122 self.satisfy(line, "rmut offers a named mailbox only when it exists");
1123 }
1124 }
1125 "move" => {
1126 if is_yes(value) {
1127 self.differently(
1128 line,
1129 "rmut leaves read mail where it is; a folder-hook with a macro can move it",
1130 );
1131 } else {
1132 self.satisfy(line, "rmut leaves read mail where it is");
1133 }
1134 }
1135 "pager_stop" => {
1136 if is_yes(value) {
1137 self.pager_stop = true;
1138 } else {
1139 self.satisfy(line, "paging past the end opens the next message, as in mutt");
1140 }
1141 }
1142 "markers" => {
1143 if is_yes(value) {
1144 self.satisfy(line, "rmut marks wrapped lines with + already");
1145 } else {
1146 self.markers_off = true;
1147 }
1148 }
1149 "smart_wrap" => {
1150 if is_yes(value) {
1151 self.satisfy(line, "rmut wraps at word boundaries already");
1152 } else {
1153 self.smart_wrap_off = true;
1154 }
1155 }
1156 "collapse_unread" => {
1157 if is_yes(value) {
1158 self.satisfy(line, "rmut folds every thread, as mutt does by default");
1159 } else {
1160 self.collapse_unread_off = true;
1161 }
1162 }
1163 "uncollapse_jump" => {
1164 if is_yes(value) {
1165 self.uncollapse_jump = true;
1166 } else {
1167 self.satisfy(line, "unfolding keeps the cursor where it was, as in mutt");
1168 }
1169 }
1170 "hide_thread_subject" => {
1171 if is_yes(value) {
1172 self.hide_thread_subject = true;
1173 } else {
1174 self.satisfy(line, "rmut shows every subject in a thread by default");
1175 }
1176 }
1177 "sidebar_visible" => {
1178 if is_yes(value) {
1179 self.sidebar_visible = true;
1180 } else {
1181 self.satisfy(line, "the sidebar is hidden until B shows it");
1182 }
1183 }
1184 "sidebar_width" => match v.trim().parse::<u16>() {
1185 Ok(n) => self.sidebar_width = Some(n),
1186 Err(_) => self.skip(line, "not a number"),
1187 },
1188 "sidebar_format" => self.differently(
1189 line,
1190 "rmut's sidebar draws the mailbox and its new count, with no format string",
1191 ),
1192 "sidebar_short_path" | "sidebar_delim_chars" | "sidebar_folder_indent" => {
1193 self.differently(line, "rmut's sidebar shows the mailbox as configured")
1194 }
1195 "alias_file" => self.alias_file = Some(v),
1196 "imap_idle" => {
1197 if is_yes(value) {
1198 self.satisfy(line, "rmut IDLEs whenever the server offers it");
1199 } else {
1200 self.skip(line, "rmut cannot be told not to IDLE");
1201 }
1202 }
1203 "imap_keepalive" => self.differently(
1204 line,
1205 "rmut re-issues IDLE about every 25 minutes; [mail] poll_seconds is the fallback poll",
1206 ),
1207 "header_cache" | "message_cachedir" | "header_cache_backend" => self.differently(
1208 line,
1209 "rmut keeps its own header and body cache under ~/.cache/rmut",
1210 ),
1211 "crypt_use_gpgme" => {
1212 self.differently(line, "rmut runs gpg(1) directly, not through GPGME")
1213 }
1214 "mailcap_path" => self.differently(
1215 line,
1216 "rmut reads the files $MAILCAPS names, or the usual mailcap places",
1217 ),
1218 "implicit_autoview" => self.differently(
1219 line,
1220 "an empty [filters] command takes the mailcap one, per type",
1221 ),
1222 "attribution" => self.attribution = Some(v),
1223 "indent_string" => self.indent_string = Some(v),
1224 "reply_regexp" => self.reply_regexp = Some(v),
1225 "simple_search" => self.simple_search = Some(v),
1226 "wrap_search" => {
1227 if is_yes(value) {
1228 self.satisfy(line, "search wraps around by default, as in mutt");
1229 } else {
1230 self.no_wrap_search = true;
1231 }
1232 }
1233 "sort_re" => {
1234 if is_yes(value) {
1235 self.satisfy(line, "only a Re: subject is grouped, as mutt has it");
1236 } else {
1237 self.sort_re_off = true;
1238 }
1239 }
1240 "forward_format" => self.forward_format = Some(v),
1241 "include" => {
1242 let want = v.trim().to_lowercase();
1244 match want.as_str() {
1245 "yes" | "no" | "ask-yes" | "ask-no" => self.include = Some(want),
1246 _ => self.skip(line, "include wants yes / no / ask-yes / ask-no"),
1247 }
1248 }
1249 "forward_quote" => {
1250 if is_yes(value) {
1251 self.forward_quote = true;
1252 } else {
1253 self.satisfy(line, "a forward comes in unquoted, as in mutt");
1254 }
1255 }
1256 "signature" => self.signature = Some(v),
1257 "sig_on_top" => {
1258 if is_yes(value) {
1259 self.sig_on_top = true;
1260 } else {
1261 self.satisfy(line, "the signature goes below the quote, as in mutt");
1262 }
1263 }
1264 "hostname" => self.hostname = Some(v),
1265 "use_domain" => self.differently(
1266 line,
1267 "rmut takes the Message-ID host from the system name or [mail] hostname",
1268 ),
1269 "user_agent" => {
1270 if is_yes(value) {
1271 self.user_agent = true;
1272 } else {
1273 self.satisfy(line, "rmut adds no User-Agent by default");
1274 }
1275 }
1276 "sig_dashes" => {
1277 if !is_yes(value) {
1278 self.no_sig_dashes = true;
1279 } else {
1280 self.satisfy(line, "the \"-- \" line is rmut's default too");
1281 }
1282 }
1283 "abort_nosubject" => {
1284 let want = v.trim().to_lowercase();
1285 match want.as_str() {
1286 "ask-yes" => self.satisfy(line, "rmut asks, with Enter aborting"),
1287 "yes" | "no" | "ask-no" => self.abort_nosubject = Some(want),
1288 _ => self.skip(line, "abort_nosubject wants yes / no / ask-yes / ask-no"),
1289 }
1290 }
1291 "abort_unmodified" => {
1292 if is_yes(value) {
1293 self.satisfy(line, "an untouched first edit drops the draft already");
1294 } else {
1295 self.no_abort_unmodified = true;
1296 }
1297 }
1298 "reply_to" => {
1299 match v.trim().to_lowercase().as_str() {
1303 "ask-yes" => self.satisfy(line, "rmut asks before taking a Reply-To"),
1304 _ => self.skip(line, "rmut always asks about a Reply-To (mutt's ask-yes)"),
1305 }
1306 }
1307 "honor_followup_to" => {
1308 if is_yes(value) {
1309 self.satisfy(line, "a group reply honors Mail-Followup-To already");
1310 } else {
1311 self.skip(line, "rmut always honors a sender's Mail-Followup-To");
1312 }
1313 }
1314 "askcc" => self.ask_cc = is_yes(value),
1315 "askbcc" => self.ask_bcc = is_yes(value),
1316 "fast_reply" => {
1317 if is_yes(&v) {
1318 self.fast_reply = true;
1319 } else {
1320 self.satisfy(line, "prompting is rmut's default");
1321 }
1322 }
1323 "autoedit" => {
1324 if is_yes(&v) {
1325 self.autoedit = true;
1326 } else {
1327 self.satisfy(line, "prompting is rmut's default");
1328 }
1329 }
1330 "copy" => {
1331 if is_yes(&v) {
1332 self.satisfy(line, "the sent copy is rmut's default");
1333 } else {
1334 self.no_copy = true;
1335 }
1336 }
1337 "forward_decode" => {
1338 if is_yes(&v) {
1339 self.satisfy(line, "inline forwards always quote the decoded text");
1340 } else {
1341 self.skip(line, "rmut always decodes when quoting a forward");
1342 }
1343 }
1344 "mime_forward_rest" => {
1345 self.satisfy(line, "the entire original message is attached");
1346 }
1347 "imap_peek" => {
1348 if is_yes(&v) {
1349 self.satisfy(line, "fetches use BODY.PEEK; \\Seen is set only on sync");
1350 } else {
1351 self.skip(line, "rmut never marks messages read while fetching");
1352 }
1353 }
1354 "menu_scroll" => {
1355 if is_yes(value) {
1356 self.satisfy(line, "the index scrolls a line at a time by default");
1357 } else {
1358 self.no_menu_scroll = true;
1359 }
1360 }
1361 "imap_authenticators" | "smtp_authenticators" => {
1362 let m = v.to_lowercase();
1363 if m.contains("oauthbearer") {
1364 self.oauth = Some("oauthbearer".into());
1365 } else if m.contains("xoauth2") {
1366 self.oauth = Some("xoauth2".into());
1367 } else if m.contains("plain") || m.contains("login") {
1368 self.satisfy(line, "rmut negotiates AUTH PLAIN/LOGIN by itself");
1369 } else {
1370 self.skip(
1371 line,
1372 "rmut supports PLAIN/LOGIN and OAuth (xoauth2/oauthbearer)",
1373 );
1374 }
1375 }
1376 _ => self.skip(line, "no rmut equivalent"),
1377 }
1378 }
1379
1380 fn hook(&mut self, folder_hook: bool, args: &[String], line: &str) {
1387 let [pattern, command] = args else {
1388 self.skip(line, "unrecognized hook syntax");
1389 return;
1390 };
1391 let Some(glob) = hook_glob(pattern) else {
1392 self.skip(line, "the pattern does not translate to a glob");
1393 return;
1394 };
1395 let tokens = tokenize(command);
1396 let only_identity_sets = "only 'set from/realname' send-hooks translate";
1397 let identity_sets = || -> Option<(Option<String>, Option<String>)> {
1398 if tokens.first().map(String::as_str) != Some("set") {
1399 return None;
1400 }
1401 let (mut name, mut email) = (None, None);
1402 for (key, value) in assignments(&tokens[1..]) {
1403 match key.as_str() {
1404 "realname" => name = Some(value),
1405 "from" => {
1406 let (n, e) = split_from(&value);
1407 if name.is_none() {
1408 name = n;
1409 }
1410 email = Some(e);
1411 }
1412 _ => return None,
1413 }
1414 }
1415 (name.is_some() || email.is_some()).then_some((name, email))
1416 };
1417 if let Some((name, email)) = identity_sets() {
1418 self.identity_rules.push(IdRule {
1419 folder: folder_hook.then(|| glob.clone()),
1420 recipient: (!folder_hook).then_some(glob),
1421 name,
1422 email,
1423 });
1424 return;
1425 }
1426 if !folder_hook {
1427 self.skip(line, only_identity_sets);
1428 return;
1429 }
1430 match crate::command::parse(command) {
1431 Ok(cmds) if !cmds.is_empty() => self.folder_hook_lines.push((glob, command.clone())),
1432 Ok(_) => self.skip(line, "the hook command does nothing"),
1433 Err(err) => self.skip(line, &err),
1434 }
1435 }
1436
1437 fn command_hook(&mut self, cmd: &str, args: &[String], line: &str) {
1440 let [pattern, command] = args else {
1441 self.skip(line, &format!("{cmd} PATTERN COMMAND"));
1442 return;
1443 };
1444 if let Err(err) = crate::pattern::parse(pattern) {
1445 self.skip(line, &format!("pattern does not translate: {err}"));
1446 return;
1447 }
1448 match crate::command::parse(command) {
1449 Ok(cmds) if !cmds.is_empty() => {
1450 let table = if cmd == "message-hook" {
1451 &mut self.message_hooks
1452 } else {
1453 &mut self.reply_hooks
1454 };
1455 table.push((pattern.clone(), command.clone()));
1456 }
1457 Ok(_) => self.skip(line, "the hook command does nothing"),
1458 Err(err) => self.skip(line, &err),
1459 }
1460 }
1461
1462 fn fcc_hook(&mut self, cmd: &str, args: &[String], line: &str) {
1467 let [pattern, mailbox] = args else {
1468 self.skip(line, &format!("{cmd} PATTERN MAILBOX"));
1469 return;
1470 };
1471 let expanded = default_hook_pattern(pattern);
1472 if let Err(err) = crate::pattern::parse(&expanded) {
1473 self.skip(line, &format!("pattern does not translate: {err}"));
1474 return;
1475 }
1476 self.fcc_hooks.push((expanded, mailbox.clone()));
1477 if cmd == "fcc-save-hook" && matches!(pattern.as_str(), "." | ".*" | "~A") {
1478 self.save_default = Some(mailbox.clone());
1479 }
1480 }
1481
1482 fn expand_mailbox(&self, value: &str) -> String {
1486 if is_imap_url(value) {
1487 return format!("imap:{ACCOUNT}/{}", url_mailbox(value));
1488 }
1489 let Some(rest) = value.strip_prefix(['+', '=']) else {
1490 return value.to_string();
1491 };
1492 let rest = rest.trim_matches('/');
1493 match &self.folder {
1494 Some(f) if is_imap_url(f) => format!("imap:{ACCOUNT}/{rest}"),
1495 Some(f) => format!("{}/{rest}", f.trim_end_matches('/')),
1496 None => rest.to_string(),
1497 }
1498 }
1499
1500 fn bind(&mut self, args: &[String], line: &str) {
1501 let [menus, key, function] = args else {
1502 self.skip(line, "unrecognized bind syntax");
1503 return;
1504 };
1505 if function == "noop" {
1506 self.satisfy(line, "unbound keys already do nothing");
1507 return;
1508 }
1509 let Some(key) = convert_key(key) else {
1510 self.skip(line, "key has no rmut syntax");
1511 return;
1512 };
1513 let mut used = false;
1514 for menu in menus.split(',') {
1515 let table = match menu {
1516 "index" => &mut self.keys_index,
1517 "pager" => &mut self.keys_pager,
1518 _ => continue,
1519 };
1520 let actions = match menu {
1521 "index" => index_function(function),
1522 _ => pager_function(function),
1523 };
1524 match actions {
1525 Some(action) => {
1526 table.insert(action, key.clone());
1527 used = true;
1528 }
1529 None => self.skip(line, &format!("no rmut action for {function} in {menu}")),
1530 }
1531 }
1532 if !used && !menus.split(',').any(|m| m == "index" || m == "pager") {
1533 self.skip(line, "only index and pager menus exist in rmut");
1534 }
1535 }
1536
1537 fn mutt_macro(&mut self, args: &[String], line: &str) {
1541 let (menus, key, seq) = match args {
1542 [m, k, s] | [m, k, s, _] => (m, k, s),
1543 _ => {
1544 self.skip(line, "unrecognized macro syntax");
1545 return;
1546 }
1547 };
1548 let Some(key) = convert_key(key) else {
1549 self.skip(line, "key has no rmut syntax");
1550 return;
1551 };
1552 let Some(sequence) = convert_sequence(seq) else {
1553 self.skip(
1554 line,
1555 "only plain keys translate; mutt function names do not",
1556 );
1557 return;
1558 };
1559 let mut used = false;
1560 for menu in menus.split(',') {
1561 let table = match menu {
1562 "index" => &mut self.macros_index,
1563 "pager" => &mut self.macros_pager,
1564 _ => continue,
1565 };
1566 table.insert(key.clone(), sequence.clone());
1567 used = true;
1568 }
1569 if !used {
1570 self.skip(line, "only index and pager menus exist in rmut");
1571 }
1572 }
1573
1574 fn auto_view(&mut self, add: bool, mime: &str, line: &str) {
1578 let mime = mime.to_lowercase();
1579 if !add {
1580 if mime == "*" {
1581 self.filters.clear();
1582 } else {
1583 self.filters.remove(&mime);
1584 }
1585 return;
1586 }
1587 match mime.as_str() {
1588 "application/pgp" | "application/pgp-signature" | "application/pgp-encrypted" => {
1589 self.satisfy(line, "PGP is handled natively");
1590 }
1591 _ => {
1592 self.filters.entry(mime).or_default();
1593 }
1594 }
1595 }
1596
1597 fn color(&mut self, args: &[String], line: &str) {
1598 let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2)) else {
1599 self.skip(line, "unrecognized color syntax");
1600 return;
1601 };
1602 let fg = convert_color(fg);
1603 let bg = convert_color(bg);
1604 let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
1608 bg.clone()
1609 } else {
1610 fg.clone()
1611 };
1612 if let Some(n) = object.strip_prefix("quoted")
1614 && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
1615 {
1616 self.quoted_colors.insert(depth, vivid);
1617 return;
1618 }
1619 match (object.as_str(), args.get(3).map(String::as_str)) {
1620 ("status", _) => {
1621 self.colors.insert("status_fg", fg);
1622 self.colors.insert("status_bg", bg);
1623 }
1624 ("search", _) => {
1625 if fg != "default" {
1626 self.colors.insert("search_fg", fg.clone());
1627 }
1628 if bg != "default" {
1629 self.colors.insert("search_bg", bg.clone());
1630 }
1631 }
1632 ("body", Some(regex)) => {
1633 self.color_body_rules.push((regex.to_string(), fg, bg));
1634 }
1635 ("header" | "hdrdefault", _) => {
1636 self.colors.insert("header", fg);
1637 }
1638 ("error", _) => {
1639 self.colors.insert("error", vivid);
1640 }
1641 ("index", Some("~D")) if bg == "default" => {
1646 self.colors.insert("deleted", vivid);
1647 }
1648 ("index", Some("~F")) if bg == "default" => {
1649 self.colors.insert("flagged", vivid);
1650 }
1651 ("index", Some("~T")) if bg == "default" => {
1652 self.colors.insert("tagged", vivid);
1653 }
1654 ("index", Some(pattern)) if crate::pattern::parse(pattern).is_ok() => {
1657 self.color_index_rules.push((pattern.to_string(), fg, bg));
1658 }
1659 _ => self.skip(line, "no rmut color slot"),
1660 }
1661 }
1662
1663 fn to_toml(&self) -> String {
1666 let mut out = String::from("# generated by rmut --import-muttrc; review before use\n");
1667 if self.name.is_some()
1668 || self.email.is_some()
1669 || self.reverse_name
1670 || self.no_reverse_realname
1671 {
1672 out += "\n[identity]\n";
1673 if let Some(n) = &self.name {
1674 out += &format!("name = {}\n", quote(n));
1675 }
1676 if let Some(e) = &self.email {
1677 out += &format!("email = {}\n", quote(e));
1678 }
1679 if self.reverse_name {
1680 out += "reverse_name = true\n";
1681 }
1682 if self.no_reverse_realname {
1683 out += "reverse_realname = false\n";
1684 }
1685 }
1686 for rule in &self.identity_rules {
1687 out += "\n[[identities]]\n";
1688 if let Some(f) = &rule.folder {
1689 out += &format!("folder = {}\n", quote(f));
1690 }
1691 if let Some(r) = &rule.recipient {
1692 out += &format!("recipient = {}\n", quote(r));
1693 }
1694 if let Some(n) = &rule.name {
1695 out += &format!("name = {}\n", quote(n));
1696 }
1697 if let Some(e) = &rule.email {
1698 out += &format!("email = {}\n", quote(e));
1699 }
1700 }
1701 for (glob, command) in &self.folder_hook_lines {
1702 out += &format!(
1703 "\n[[folder_hooks]]\nfolder = {}\ncommand = {}\n",
1704 quote(glob),
1705 quote(command)
1706 );
1707 }
1708 for (table, hooks) in [
1709 ("message_hooks", &self.message_hooks),
1710 ("reply_hooks", &self.reply_hooks),
1711 ] {
1712 for (pattern, command) in hooks {
1713 out += &format!(
1714 "\n[[{table}]]\npattern = {}\ncommand = {}\n",
1715 quote(pattern),
1716 quote(command)
1717 );
1718 }
1719 }
1720 for (pattern, mailbox) in &self.fcc_hooks {
1721 out += &format!(
1722 "\n[[fcc_hooks]]\npattern = {}\nmailbox = {}\n",
1723 quote(pattern),
1724 quote(&self.expand_mailbox(mailbox))
1725 );
1726 }
1727 for (address, key) in &self.crypt_hooks {
1728 out += &format!(
1729 "\n[[crypt_hooks]]\naddress = {}\nkey = {}\n",
1730 quote(address),
1731 quote(key)
1732 );
1733 }
1734 let imap = self
1737 .folder
1738 .as_deref()
1739 .filter(|f| is_imap_url(f))
1740 .or_else(|| self.spoolfile.as_deref().filter(|s| is_imap_url(s)));
1741 let mut mailboxes = Vec::new();
1742 if let Some(spool) = &self.spoolfile {
1743 mailboxes.push(match imap {
1744 Some(_) if is_imap_url(spool) => {
1745 format!("imap:{ACCOUNT}/{}", url_mailbox(spool))
1746 }
1747 Some(_) => {
1748 let inbox = spool.trim_start_matches(['+', '=']).trim_matches('/');
1749 format!("imap:{ACCOUNT}/{inbox}")
1750 }
1751 None => self.expand_mailbox(spool),
1752 });
1753 }
1754 for m in &self.mailboxes {
1755 if !mailboxes.contains(m) {
1756 mailboxes.push(m.clone());
1757 }
1758 }
1759 let sent_local = self.sent.as_deref().filter(|_| imap.is_none());
1760 let folder_setting = self.folder.clone().filter(|f| !is_imap_url(f));
1764 if folder_setting.is_some()
1765 || !mailboxes.is_empty()
1766 || sent_local.is_some()
1767 || self.postponed.is_some()
1768 || self.sendmail.is_some()
1769 || self.editor.is_some()
1770 || self.poll_seconds.is_some()
1771 || self.print.is_some()
1772 || self.query_command.is_some()
1773 || self.trash.is_some()
1774 || self.save_default.is_some()
1775 || self.forward_attach
1776 || self.forward_ask
1777 || self.fast_reply
1778 || self.quit.is_some()
1779 || self.postpone.is_some()
1780 || self.recall.is_some()
1781 || self.confirmappend
1782 || self.save_name
1783 || self.force_name
1784 || self.alias_file.is_some()
1785 || self.attribution.is_some()
1786 || self.indent_string.is_some()
1787 || self.reply_regexp.is_some()
1788 || self.simple_search.is_some()
1789 || self.no_wrap_search
1790 || self.forward_format.is_some()
1791 || self.include.is_some()
1792 || self.ask_cc
1793 || self.ask_bcc
1794 || self.autoedit
1795 || self.no_copy
1796 || self.new_mail_command.is_some()
1797 || self.edit_headers_on
1798 || !self.lists.is_empty()
1799 || !self.subscribed.is_empty()
1800 || !self.alternates.is_empty()
1801 || !self.my_hdr.is_empty()
1802 || self.metoo
1803 || self.no_mark_old
1804 || self.no_delete_untag
1805 || self.flag_safe
1806 || self.maildir_trash
1807 || self.no_mail_check_recent
1808 || self.no_check_new
1809 || self.sort_alias.is_some()
1810 || self.shell.is_some()
1811 || self.tmpdir.is_some()
1812 || self.ispell.is_some()
1813 || self.print_confirm.is_some()
1814 || self.forward_quote
1815 || self.signature.is_some()
1816 || self.no_sig_dashes
1817 || self.sig_on_top
1818 || self.hostname.is_some()
1819 || self.user_agent
1820 || self.abort_nosubject.is_some()
1821 || self.no_abort_unmodified
1822 || self.text_flowed
1823 || self.delete.is_some()
1824 || self.abort_noattach.is_some()
1825 || self.attach_keyword.is_some()
1826 {
1827 out += "\n[mail]\n";
1828 if let Some(f) = &folder_setting {
1829 out += &format!("folder = {}\n", quote(f));
1830 }
1831 for (key, values) in [
1832 ("lists", &self.lists),
1833 ("subscribed", &self.subscribed),
1834 ("alternates", &self.alternates),
1835 ("my_hdr", &self.my_hdr),
1836 ] {
1837 if !values.is_empty() {
1838 let list: Vec<String> = values.iter().map(|v| quote(v)).collect();
1839 out += &format!("{key} = [{}]\n", list.join(", "));
1840 }
1841 }
1842 if !mailboxes.is_empty() {
1843 let list: Vec<String> = mailboxes.iter().map(|m| quote(m)).collect();
1844 out += &format!("mailboxes = [{}]\n", list.join(", "));
1845 }
1846 if let Some(s) = sent_local {
1847 out += &format!("sent = {}\n", quote(&self.expand_mailbox(s)));
1848 }
1849 if let Some(p) = &self.postponed {
1850 out += &format!("postponed = {}\n", quote(&self.expand_mailbox(p)));
1851 }
1852 if let Some(s) = &self.sendmail {
1853 out += &format!("sendmail = {}\n", quote(s));
1854 }
1855 if let Some(e) = &self.editor {
1856 out += &format!("editor = {}\n", quote(e));
1857 }
1858 if let Some(n) = self.poll_seconds {
1859 out += &format!("poll_seconds = {n}\n");
1860 }
1861 if let Some(p) = &self.print {
1862 out += &format!("print = {}\n", quote(p));
1863 }
1864 if let Some(q) = &self.query_command {
1865 out += &format!("query_command = {}\n", quote(q));
1866 }
1867 if let Some(t) = &self.trash {
1868 out += &format!("trash = {}\n", quote(&self.expand_mailbox(t)));
1869 }
1870 if let Some(save) = &self.save_default {
1871 out += &format!("save = {}\n", quote(&self.expand_mailbox(save)));
1872 }
1873 if self.forward_ask {
1874 out += "forward = \"ask\"\n";
1875 } else if self.forward_attach {
1876 out += "forward = \"attach\"\n";
1877 }
1878 if self.fast_reply {
1879 out += "fast_reply = true\n";
1880 }
1881 if let Some(v) = &self.postpone {
1882 out += &format!("postpone = {}\n", quote(v));
1883 }
1884 if let Some(v) = &self.recall {
1885 out += &format!("recall = {}\n", quote(v));
1886 }
1887 if let Some(v) = &self.quit {
1888 out += &format!("quit = {}\n", quote(v));
1889 }
1890 if self.confirmappend {
1891 out += "confirmappend = true\n";
1892 }
1893 if self.save_name {
1894 out += "save_name = true\n";
1895 }
1896 if self.force_name {
1897 out += "force_name = true\n";
1898 }
1899 if let Some(v) = &self.alias_file {
1900 out += &format!("alias_file = {}\n", quote(v));
1901 }
1902 if let Some(v) = &self.attribution {
1903 out += &format!("attribution = {}\n", quote(v));
1904 }
1905 if let Some(v) = &self.indent_string {
1906 out += &format!("indent_string = {}\n", quote(v));
1907 }
1908 if let Some(v) = &self.reply_regexp {
1909 out += &format!("reply_regexp = {}\n", quote(v));
1910 }
1911 if let Some(v) = &self.simple_search {
1912 out += &format!("simple_search = {}\n", quote(v));
1913 }
1914 if self.no_wrap_search {
1915 out += "wrap_search = false\n";
1916 }
1917 if let Some(v) = &self.forward_format {
1918 out += &format!("forward_format = {}\n", quote(v));
1919 }
1920 if let Some(v) = &self.include {
1921 out += &format!("include = {}\n", quote(v));
1922 }
1923 if self.ask_cc {
1924 out += "ask_cc = true\n";
1925 }
1926 if self.ask_bcc {
1927 out += "ask_bcc = true\n";
1928 }
1929 if self.autoedit {
1930 out += "autoedit = true\n";
1931 }
1932 if self.no_copy {
1933 out += "copy = false\n";
1934 }
1935 if self.metoo {
1936 out += "metoo = true\n";
1937 }
1938 if self.no_mark_old {
1939 out += "mark_old = false\n";
1940 }
1941 if self.no_delete_untag {
1942 out += "delete_untag = false\n";
1943 }
1944 if self.flag_safe {
1945 out += "flag_safe = true\n";
1946 }
1947 if self.maildir_trash {
1948 out += "maildir_trash = true\n";
1949 }
1950 if self.no_mail_check_recent {
1951 out += "mail_check_recent = false\n";
1952 }
1953 if self.no_check_new {
1954 out += "check_new = false\n";
1955 }
1956 if let Some(v) = &self.sort_alias {
1957 out += &format!("sort_alias = {}\n", quote(v));
1958 }
1959 if let Some(v) = &self.shell {
1960 out += &format!("shell = {}\n", quote(v));
1961 }
1962 if let Some(v) = &self.tmpdir {
1963 out += &format!("tmpdir = {}\n", quote(v));
1964 }
1965 if let Some(v) = &self.ispell {
1966 out += &format!("ispell = {}\n", quote(v));
1967 }
1968 if let Some(v) = &self.print_confirm {
1969 out += &format!("print_confirm = {}\n", quote(v));
1970 }
1971 if self.forward_quote {
1972 out += "forward_quote = true\n";
1973 }
1974 if let Some(v) = &self.signature {
1975 out += &format!("signature = {}\n", quote(v));
1976 }
1977 if self.no_sig_dashes {
1978 out += "sig_dashes = false\n";
1979 }
1980 if self.sig_on_top {
1981 out += "sig_on_top = true\n";
1982 }
1983 if let Some(v) = &self.hostname {
1984 out += &format!("hostname = {}\n", quote(v));
1985 }
1986 if self.user_agent {
1987 out += "user_agent = true\n";
1988 }
1989 if let Some(v) = &self.abort_nosubject {
1990 out += &format!("abort_nosubject = {}\n", quote(v));
1991 }
1992 if self.no_abort_unmodified {
1993 out += "abort_unmodified = false\n";
1994 }
1995 if self.text_flowed {
1996 out += "text_flowed = true\n";
1997 }
1998 if let Some(v) = &self.delete {
1999 out += &format!("delete = {}\n", quote(v));
2000 }
2001 if let Some(v) = &self.abort_noattach {
2002 out += &format!("abort_noattach = {}\n", quote(v));
2003 }
2004 if let Some(v) = &self.attach_keyword {
2005 out += &format!("attach_keyword = {}\n", quote(v));
2006 }
2007 if let Some(c) = &self.new_mail_command {
2008 out += &format!("new_mail_command = {}\n", quote(c));
2009 }
2010 if self.edit_headers_on {
2011 out += "edit_headers = true\n";
2012 }
2013 }
2014 if self.index_format.is_some()
2015 || self.sort.is_some()
2016 || self.sort_aux.is_some()
2017 || self.date_format.is_some()
2018 || self.collapse_unread_off
2019 || self.uncollapse_jump
2020 || self.hide_thread_subject
2021 || self.no_uncollapse_new
2022 || self.strict_threads
2023 || self.sort_re_off
2024 {
2025 out += "\n[index]\n";
2026 if let Some(f) = &self.index_format {
2027 out += "# rmut renders %C %Z %d %F %L %c %l %s and %?X?then&else? conditionals\n";
2028 out += &format!("format = {}\n", quote(f));
2029 }
2030 if let Some(sort) = &self.sort {
2031 out += &format!("sort = {}\n", quote(sort));
2032 }
2033 if let Some(aux) = &self.sort_aux {
2034 out += &format!("sort_aux = {}\n", quote(aux));
2035 }
2036 if let Some(df) = &self.date_format {
2037 out += &format!("date_format = {}\n", quote(df));
2038 }
2039 if self.collapse_unread_off {
2040 out += "collapse_unread = false\n";
2041 }
2042 if self.uncollapse_jump {
2043 out += "uncollapse_jump = true\n";
2044 }
2045 if self.hide_thread_subject {
2046 out += "hide_thread_subject = true\n";
2047 }
2048 if self.no_uncollapse_new {
2049 out += "uncollapse_new = false\n";
2050 }
2051 if self.strict_threads {
2052 out += "strict_threads = true\n";
2053 }
2054 if self.sort_re_off {
2055 out += "sort_re = false\n";
2056 }
2057 }
2058 if self.pager_index_lines.is_some()
2059 || self.pager_context.is_some()
2060 || self.search_context.is_some()
2061 || self.quote_regexp.is_some()
2062 || self.pager_format.is_some()
2063 || self.wrap.is_some()
2064 || self.tilde
2065 || !self.hdr_ignore.is_empty()
2066 || !self.hdr_unignore.is_empty()
2067 || !self.hdr_order.is_empty()
2068 || self.no_reflow_text
2069 || self.pager_stop
2070 || self.markers_off
2071 || self.smart_wrap_off
2072 || !self.alternative_order.is_empty()
2073 {
2074 out += "\n[pager]\n";
2075 if let Some(n) = self.pager_index_lines {
2076 out += &format!("index_lines = {n}\n");
2077 }
2078 if let Some(n) = self.pager_context {
2079 out += &format!("context = {n}\n");
2080 }
2081 if let Some(n) = self.search_context {
2082 out += &format!("search_context = {n}\n");
2083 }
2084 if let Some(re) = &self.quote_regexp {
2085 out += &format!("quote_regexp = {}\n", quote(re));
2086 }
2087 for (key, list) in [
2088 ("ignore", &self.hdr_ignore),
2089 ("unignore", &self.hdr_unignore),
2090 ("hdr_order", &self.hdr_order),
2091 ] {
2092 if !list.is_empty() {
2093 let items: Vec<String> = list.iter().map(|s| quote(s)).collect();
2094 out += &format!("{key} = [{}]\n", items.join(", "));
2095 }
2096 }
2097 if let Some(f) = &self.pager_format {
2098 out += "# rmut renders %C %m %n %s %Z %P %f and %>X here\n";
2099 out += &format!("format = {}\n", quote(f));
2100 }
2101 if let Some(n) = self.wrap {
2102 out += &format!("wrap = {n}\n");
2103 }
2104 if self.tilde {
2105 out += "tilde = true\n";
2106 }
2107 if self.no_reflow_text {
2108 out += "reflow_text = false\n";
2109 }
2110 if self.pager_stop {
2111 out += "pager_stop = true\n";
2112 }
2113 if self.markers_off {
2114 out += "markers = false\n";
2115 }
2116 if self.smart_wrap_off {
2117 out += "smart_wrap = false\n";
2118 }
2119 if !self.alternative_order.is_empty() {
2120 let items: Vec<String> = self.alternative_order.iter().map(|s| quote(s)).collect();
2121 out += &format!("alternative_order = [{}]\n", items.join(", "));
2122 }
2123 }
2124 if !self.filters.is_empty() {
2125 out += "\n[filters]\n# auto_view: an empty command means rmut takes it from your\n# mailcap (the first copiousoutput entry), as mutt does; put a\n# command here to override it\n";
2126 for (mime, command) in &self.filters {
2127 out += &format!("{} = {}\n", quote(mime), quote(command));
2128 }
2129 }
2130 if !self.colors.is_empty() || !self.quoted_colors.is_empty() {
2131 out += "\n[colors]\n";
2132 for (k, v) in &self.colors {
2133 out += &format!("{k} = {}\n", quote(v));
2134 }
2135 for (n, v) in &self.quoted_colors {
2136 let key = if *n == 0 {
2137 "quoted".to_string()
2138 } else {
2139 format!("quoted{n}")
2140 };
2141 out += &format!("{key} = {}\n", quote(v));
2142 }
2143 }
2144 for (pattern, fg, bg) in &self.color_index_rules {
2145 out += "\n[[color_index]]\n";
2146 out += &format!("pattern = {}\n", quote(pattern));
2147 if fg != "default" {
2148 out += &format!("fg = {}\n", quote(fg));
2149 }
2150 if bg != "default" {
2151 out += &format!("bg = {}\n", quote(bg));
2152 }
2153 }
2154 for (pattern, fg, bg) in &self.color_body_rules {
2155 out += "\n[[color_body]]\n";
2156 out += &format!("pattern = {}\n", quote(pattern));
2157 if fg != "default" {
2158 out += &format!("fg = {}\n", quote(fg));
2159 }
2160 if bg != "default" {
2161 out += &format!("bg = {}\n", quote(bg));
2162 }
2163 }
2164 if self.sidebar_visible || self.sidebar_width.is_some() {
2165 out += "\n[sidebar]\n";
2166 if self.sidebar_visible {
2167 out += "visible = true\n";
2168 }
2169 if let Some(width) = self.sidebar_width {
2170 out += &format!("width = {width}\n");
2171 }
2172 }
2173 if self.connect_timeout.is_some() || self.certificate_file.is_some() || self.no_system_cas {
2174 out += "\n[net]\n";
2175 if let Some(secs) = self.connect_timeout {
2176 out += &format!("connect_timeout = {secs}\n");
2177 }
2178 if let Some(path) = &self.certificate_file {
2179 out += &format!("certificate_file = {}\n", quote(path));
2180 }
2181 if self.no_system_cas {
2182 out += "system_cas = false\n";
2183 }
2184 }
2185 if self.status_format.is_some()
2186 || self.title_format.is_some()
2187 || self.history_file.is_some()
2188 || self.status_on_top
2189 || self.arrow_cursor
2190 || self.no_menu_scroll
2191 || self.menu_context.is_some()
2192 || self.no_menu_move_off
2193 || self.no_help
2194 || self.sort_browser.is_some()
2195 || self.status_chars.is_some()
2196 || self.set_title
2197 || self.no_beep
2198 || self.beep_new
2199 || self.no_wait_key
2200 {
2201 out += "\n[ui]\n";
2202 if let Some(sf) = &self.status_format {
2203 out += "# rmut renders %f %m %M %n %u %d %F %t %s %V %r %v and\n";
2204 out += "# %?X?then&else? conditionals; other specifiers show literally\n";
2205 out += &format!("status_format = {}\n", quote(sf));
2206 }
2207 if self.set_title {
2208 out += "set_title = true\n";
2209 }
2210 if let Some(v) = &self.history_file {
2211 out += &format!("history_file = {}\n", quote(v));
2212 }
2213 if self.status_on_top {
2214 out += "status_on_top = true\n";
2215 }
2216 if self.arrow_cursor {
2217 out += "arrow_cursor = true\n";
2218 }
2219 if self.no_menu_scroll {
2220 out += "menu_scroll = false\n";
2221 }
2222 if let Some(n) = self.menu_context {
2223 out += &format!("menu_context = {n}\n");
2224 }
2225 if self.no_menu_move_off {
2226 out += "menu_move_off = false\n";
2227 }
2228 if self.no_help {
2229 out += "help = false\n";
2230 }
2231 if let Some(v) = &self.sort_browser {
2232 out += &format!("sort_browser = {}\n", quote(v));
2233 }
2234 if let Some(v) = &self.status_chars {
2235 out += &format!("status_chars = {}\n", quote(v));
2236 }
2237 if let Some(tf) = &self.title_format {
2238 out += &format!("title_format = {}\n", quote(tf));
2239 }
2240 if self.no_beep {
2241 out += "beep = false\n";
2242 }
2243 if self.beep_new {
2244 out += "beep_new = true\n";
2245 }
2246 if self.no_wait_key {
2247 out += "wait_key = false\n";
2248 }
2249 }
2250 for (section, table) in [("index", &self.keys_index), ("pager", &self.keys_pager)] {
2251 if !table.is_empty() {
2252 out += &format!("\n[keys.{section}]\n");
2253 for (action, key) in table {
2254 out += &format!("{action} = {}\n", quote(key));
2255 }
2256 }
2257 }
2258 for (section, table) in [("index", &self.macros_index), ("pager", &self.macros_pager)] {
2259 if !table.is_empty() {
2260 out += &format!("\n[macros.{section}]\n");
2261 for (key, sequence) in table {
2262 out += &format!("{} = {}\n", quote(key), quote(sequence));
2263 }
2264 }
2265 }
2266 if self.sign_key.is_some()
2267 || self.sign_by_default
2268 || self.encrypt_by_default
2269 || self.reply_sign
2270 || self.reply_encrypt
2271 || self.reply_sign_encrypted
2272 {
2273 out += "\n[pgp]\n";
2274 if let Some(k) = &self.sign_key {
2275 out += &format!("sign_key = {}\n", quote(k));
2276 }
2277 if self.sign_by_default {
2278 out += "sign_by_default = true\n";
2279 }
2280 if self.encrypt_by_default {
2281 out += "encrypt_by_default = true\n";
2282 }
2283 if self.reply_sign {
2284 out += "reply_sign = true\n";
2285 }
2286 if self.reply_encrypt {
2287 out += "reply_encrypt = true\n";
2288 }
2289 if self.reply_sign_encrypted {
2290 out += "reply_sign_encrypted = true\n";
2291 }
2292 }
2293 let mut skipped = self.skipped.clone();
2294 if imap.is_some() || self.smtp_url.is_some() {
2295 out += &self.account_toml(imap, &mut skipped);
2296 } else if self.imap_pass.is_some() || self.smtp_pass.is_some() {
2297 skipped.push(
2298 "set imap_pass/smtp_pass = (redacted) (no IMAP/SMTP server, nowhere to put it)"
2299 .into(),
2300 );
2301 }
2302 if !self.satisfied.is_empty() {
2303 out += "\n# satisfied by rmut's defaults (nothing to configure):\n";
2304 for s in &self.satisfied {
2305 out += &format!("# {s}\n");
2306 }
2307 }
2308 if !self.differs.is_empty() {
2309 out += "\n# rmut does these its own way:\n";
2310 for s in &self.differs {
2311 out += &format!("# {s}\n");
2312 }
2313 }
2314 if !skipped.is_empty() {
2315 out += "\n# not imported:\n";
2316 for s in &skipped {
2317 out += &format!("# {s}\n");
2318 }
2319 }
2320 out
2321 }
2322
2323 fn password_toml(&self, out: &mut String, skipped: &mut Vec<String>) {
2326 match (&self.imap_pass, &self.smtp_pass) {
2327 (Some(a), Some(b)) if a != b => {
2328 *out += "# imported from imap_pass; consider password_command instead\n";
2329 *out += &format!("password = {}\n", quote(a));
2330 skipped.push(
2331 "set smtp_pass = (redacted) (differs from imap_pass; rmut uses one password per account)"
2332 .into(),
2333 );
2334 }
2335 (Some(pass), _) | (None, Some(pass)) => {
2336 *out += "# imported from imap_pass/smtp_pass; consider password_command instead\n";
2337 *out += &format!("password = {}\n", quote(pass));
2338 }
2339 (None, None) => {
2340 *out += "# TODO: set a command that prints the password (or password = \"...\"):\n";
2341 *out += "password_command = \"pass show mail/TODO\"\n";
2342 }
2343 }
2344 }
2345
2346 fn account_toml(&self, folder_url: Option<&str>, skipped: &mut Vec<String>) -> String {
2347 let mut out = format!("\n[[accounts]]\nname = {}\n", quote(ACCOUNT));
2348 let url_user = folder_url
2350 .into_iter()
2351 .chain(self.smtp_url.as_deref())
2352 .find_map(|url| split_url(url).0.map(str::to_string));
2353 let user = self
2354 .imap_user
2355 .clone()
2356 .or(url_user)
2357 .or_else(|| self.email.clone())
2358 .unwrap_or_else(|| "TODO".into());
2359 out += &format!("user = {}\n", quote(&user));
2360 if let Some(mech) = &self.oauth {
2361 out += &format!("auth = {}\n", quote(mech));
2362 out += "# TODO: set a command that prints a fresh access token\n";
2363 out += "# (oauth2ms, mutt_oauth2.py, ...):\n";
2364 out += "token_command = \"oauth2ms\"\n";
2365 if self.imap_pass.is_some() || self.smtp_pass.is_some() {
2366 skipped.push(
2367 "set imap_pass/smtp_pass = (redacted) (the account authenticates with OAuth)"
2368 .into(),
2369 );
2370 }
2371 } else {
2372 self.password_toml(&mut out, skipped);
2375 }
2376 if let Some(url) = folder_url {
2377 let (_, host, port, tls) = split_url(url);
2378 out += &format!("imap_host = {}\n", quote(host));
2379 match (port, tls) {
2382 (Some(p), _) => out += &format!("imap_port = {p}\n"),
2383 (None, false) => out += "imap_port = 143\n",
2384 (None, true) => {}
2385 }
2386 }
2387 if let Some(smtp) = &self.smtp_url {
2388 let (_, host, port, tls) = split_url(smtp);
2389 out += &format!("smtp_host = {}\n", quote(host));
2390 match (port, tls) {
2391 (Some(p), _) => out += &format!("smtp_port = {p}\n"),
2392 (None, true) => out += "smtp_port = 465\n",
2394 (None, false) => {}
2395 }
2396 }
2397 if folder_url.is_some()
2398 && let Some(sent) = &self.sent
2399 {
2400 let folder = if is_imap_url(sent) {
2401 url_mailbox(sent)
2402 } else {
2403 sent.trim_start_matches(['+', '=']).trim_matches('/').into()
2404 };
2405 out += &format!("sent_folder = {}\n", quote(&folder));
2406 }
2407 out
2408 }
2409}
2410
2411fn is_imap_url(value: &str) -> bool {
2412 value.starts_with("imap://") || value.starts_with("imaps://")
2413}
2414
2415fn split_url(url: &str) -> (Option<&str>, &str, Option<u16>, bool) {
2419 let (tls, rest) = match url.split_once("://") {
2420 Some((scheme, rest)) => (scheme.ends_with('s'), rest),
2421 None => (true, url),
2422 };
2423 let rest = rest.split('/').next().unwrap_or(rest);
2424 let (user, rest) = match rest.rsplit_once('@') {
2425 Some((u, r)) if !u.is_empty() => (Some(u), r),
2426 _ => (None, rest),
2427 };
2428 match rest.rsplit_once(':') {
2429 Some((host, port)) if !host.is_empty() => (user, host, port.parse().ok(), tls),
2430 _ => (user, rest, None, tls),
2431 }
2432}
2433
2434fn url_mailbox(url: &str) -> String {
2436 let rest = url.split_once("://").map_or(url, |(_, r)| r);
2437 let path = rest
2438 .split_once('/')
2439 .map_or("", |(_, p)| p)
2440 .trim_matches('/');
2441 if path.is_empty() {
2442 "INBOX".into()
2443 } else {
2444 path.to_string()
2445 }
2446}
2447
2448fn quote(value: &str) -> String {
2449 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
2450}
2451
2452pub fn convert_key(key: &str) -> Option<String> {
2454 if let Some(c) = key.strip_prefix("\\C").or_else(|| key.strip_prefix("\\c")) {
2455 let mut chars = c.chars();
2456 let c = chars.next()?;
2457 return chars
2458 .next()
2459 .is_none()
2460 .then(|| format!("ctrl+{}", c.to_ascii_lowercase()));
2461 }
2462 if let Some(c) = key
2463 .strip_prefix("\\e")
2464 .or_else(|| key.strip_prefix("<esc>").filter(|rest| !rest.is_empty()))
2465 {
2466 let mut chars = c.chars();
2467 let c = chars.next()?;
2468 return chars.next().is_none().then(|| format!("alt+{c}"));
2469 }
2470 let named = match key.to_lowercase().as_str() {
2471 "<return>" | "<enter>" => "enter",
2472 "<esc>" => "esc",
2473 "<space>" => "space",
2474 "<tab>" => "tab",
2475 "<backspace>" => "backspace",
2476 "<up>" => "up",
2477 "<down>" => "down",
2478 "<pageup>" => "pgup",
2479 "<pagedown>" => "pgdn",
2480 "<home>" => "home",
2481 "<end>" => "end",
2482 _ => {
2483 let mut chars = key.chars();
2484 let c = chars.next()?;
2485 return (chars.next().is_none() && c != '<').then(|| c.to_string());
2486 }
2487 };
2488 Some(named.to_string())
2489}
2490
2491fn convert_sequence(seq: &str) -> Option<String> {
2495 const NAMES: [&str; 15] = [
2496 "enter",
2497 "return",
2498 "esc",
2499 "escape",
2500 "space",
2501 "tab",
2502 "backspace",
2503 "up",
2504 "down",
2505 "pgup",
2506 "pageup",
2507 "pgdn",
2508 "pagedown",
2509 "home",
2510 "end",
2511 ];
2512 let mut out = String::new();
2513 let mut chars = seq.chars();
2514 while let Some(c) = chars.next() {
2515 match c {
2516 '<' => {
2517 let mut name = String::new();
2518 loop {
2519 match chars.next() {
2520 Some('>') => break,
2521 Some(c) => name.push(c),
2522 None => return None,
2523 }
2524 }
2525 let name = name.to_lowercase();
2526 if !NAMES.contains(&name.as_str()) {
2527 return None; }
2529 out += &format!("<{name}>");
2530 }
2531 '\\' => match chars.next()? {
2532 'n' | 'r' => out += "<enter>",
2533 't' => out += "<tab>",
2534 'e' => out += "<esc>",
2535 'c' | 'C' => out += &format!("<ctrl+{}>", chars.next()?.to_ascii_lowercase()),
2536 other => out.push(other),
2537 },
2538 c => out.push(c),
2539 }
2540 }
2541 Some(out)
2542}
2543
2544pub(crate) fn convert_color(name: &str) -> String {
2546 match name.strip_prefix("bright") {
2547 Some(base) => format!("light{base}"),
2548 None => name.to_string(),
2549 }
2550}
2551
2552pub fn index_function(name: &str) -> Option<&'static str> {
2553 Some(match name {
2554 "quit" => "quit",
2555 "exit" => "abort",
2556 "next-entry" | "next-undeleted" => "down",
2557 "previous-entry" | "previous-undeleted" => "up",
2558 "next-page" => "page-down",
2559 "previous-page" => "page-up",
2560 "first-entry" => "first",
2561 "last-entry" => "last",
2562 "display-message" => "view",
2563 "delete-message" => "delete",
2564 "undelete-message" => "undelete",
2565 "flag-message" => "flag",
2566 "toggle-new" => "toggle-new",
2567 "sync-mailbox" => "sync",
2568 "mail" => "compose",
2569 "reply" => "reply",
2570 "group-reply" => "group-reply",
2571 "forward-message" => "forward",
2572 "sort-mailbox" => "sort",
2573 "limit" => "limit",
2574 "search" => "search",
2575 "search-next" => "search-next",
2576 "next-new" | "next-new-then-unread" | "next-unread" => "next-new",
2577 "previous-new" | "previous-new-then-unread" | "previous-unread" => "previous-new",
2578 "delete-pattern" => "delete-pattern",
2579 "undelete-pattern" => "undelete-pattern",
2580 "tag-pattern" => "tag-pattern",
2581 "untag-pattern" => "untag-pattern",
2582 "change-folder" => "change-mailbox",
2583 "view-attachments" => "attachments",
2584 "collapse-thread" => "fold-thread",
2585 "collapse-all" => "fold-all",
2586 "delete-thread" => "delete-thread",
2588 "undelete-thread" => "undelete-thread",
2589 "tag-thread" => "tag-thread",
2590 "delete-subthread" => "delete-subthread",
2591 "undelete-subthread" => "undelete-subthread",
2592 "next-thread" => "next-thread",
2593 "previous-thread" => "previous-thread",
2594 "break-thread" => "break-thread",
2595 "link-threads" => "link-threads",
2596 "read-thread" => "read-thread",
2597 "read-subthread" => "read-subthread",
2598 "tag-subthread" => "tag-subthread",
2599 "parent-message" => "parent-message",
2600 "root-message" => "root-message",
2601 "imap-fetch-mail" | "fetch-mail" => "fetch-mail",
2602 "tag-entry" | "tag-message" => "tag",
2603 "tag-prefix" => "tag-prefix",
2604 "query" => "query",
2605 "save-message" => "save",
2606 "decode-save" => "decode-save",
2607 "decode-copy" => "decode-copy",
2608 "print-message" => "print",
2609 "edit" => "edit",
2610 "resend-message" => "resend",
2611 "edit-label" => "edit-label",
2612 "show-version" => "show-version",
2613 "show-limit" => "show-limit",
2614 "display-address" => "display-address",
2615 "toggle-write" => "toggle-write",
2616 "top-page" => "top-page",
2617 "middle-page" => "middle-page",
2618 "bottom-page" => "bottom-page",
2619 "help" => "help",
2620 _ => return None,
2621 })
2622}
2623
2624pub fn pager_function(name: &str) -> Option<&'static str> {
2625 Some(match name {
2626 "exit" => "back",
2627 "next-line" => "down",
2628 "previous-line" => "up",
2629 "next-page" => "page-down",
2630 "previous-page" => "page-up",
2631 "half-down" => "half-down",
2632 "half-up" => "half-up",
2633 "top" => "top",
2634 "bottom" => "bottom",
2635 "toggle-quoted" => "toggle-quoted",
2636 "skip-quoted" => "skip-quoted",
2637 "next-entry" => "next",
2638 "previous-entry" => "previous",
2639 "next-undeleted" => "next-undeleted",
2640 "previous-undeleted" => "previous-undeleted",
2641 "delete-message" => "delete",
2642 "display-toggle-weed" => "headers",
2643 "search" => "search",
2644 "search-next" => "search-next",
2645 "search-opposite" => "search-prev",
2646 "search-toggle" => "search-toggle",
2647 "view-attachments" => "attachments",
2648 "mail" => "compose",
2649 "reply" => "reply",
2650 "group-reply" => "group-reply",
2651 "forward-message" => "forward",
2652 "save-message" => "save",
2653 "print-message" => "print",
2654 "edit" => "edit",
2655 "resend-message" => "resend",
2656 "help" => "help",
2657 _ => return None,
2658 })
2659}
2660
2661#[cfg(test)]
2662mod tests {
2663 use super::*;
2664 use crate::config::Config;
2665
2666 fn to_config(muttrc: &str) -> (Config, String) {
2667 let import = import(muttrc, Path::new("/nonexistent"));
2668 let cfg: Config = toml::from_str(&import.toml)
2669 .unwrap_or_else(|e| panic!("bad TOML: {e}\n{}", import.toml));
2670 (cfg, import.toml)
2671 }
2672
2673 #[test]
2674 fn format_flowed_options_import() {
2675 let (cfg, toml) = to_config("set text_flowed\nset noreflow_text\n");
2676 assert!(cfg.mail.text_flowed);
2677 assert_eq!(cfg.pager.reflow_text, Some(false));
2678 assert!(toml.contains("text_flowed = true"), "{toml}");
2679 assert!(toml.contains("reflow_text = false"), "{toml}");
2680 let (cfg, _) = to_config("set notext_flowed\nset reflow_text\n");
2682 assert!(!cfg.mail.text_flowed);
2683 assert_eq!(cfg.pager.reflow_text, None);
2684 }
2685
2686 #[test]
2687 fn index_colors_keep_a_background() {
2688 let (cfg, toml) = to_config(concat!(
2689 "color index black magenta \"~D\"\n",
2690 "color index brightred default \"~F\"\n",
2691 ));
2692 let deleted = cfg.color_index.iter().find(|r| r.pattern == "~D").unwrap();
2695 assert_eq!(deleted.fg.as_deref(), Some("black"));
2696 assert_eq!(deleted.bg.as_deref(), Some("magenta"));
2697 assert_eq!(
2700 cfg.colors.get("flagged").map(String::as_str),
2701 Some("lightred")
2702 );
2703 assert!(toml.contains("[[color_index]]"), "{toml}");
2704 }
2705
2706 #[test]
2707 fn attachment_reminder_imports() {
2708 let (cfg, toml) = to_config(concat!(
2709 "set abort_noattach = ask-yes\n",
2710 "set abort_noattach_regex = \"\\\\<(attach|pripojen)\"\n",
2711 ));
2712 assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("ask"));
2714 assert_eq!(
2715 cfg.mail.attach_keyword.as_deref(),
2716 Some("\\b(attach|pripojen)")
2717 );
2718 assert!(toml.contains("abort_noattach"), "{toml}");
2719 let (cfg, _) = to_config("set abort_noattach = yes\n");
2720 assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("yes"));
2721 }
2722
2723 #[test]
2724 fn hooks_round_two_translate() {
2725 let (cfg, toml) = to_config(concat!(
2726 "folder-hook work 'set index_format=\"%s\"'\n",
2727 "folder-hook . 'set sort=threads'\n",
2728 "message-hook '~f boss@example\\.com' 'set pager_context=5'\n",
2729 "reply-hook '~t @work\\.example\\.com' 'set from=jane@work.example.com'\n",
2730 "fcc-hook '~t @work\\.example\\.com' +work-sent\n",
2731 "fcc-save-hook boss@example.com +boss\n",
2732 "crypt-hook boss@example.com 0xDEADBEEF\n",
2733 "message-hook '~X 3' 'set beep'\n", "reply-hook '~s x' 'frobnicate'\n", ));
2736 assert_eq!(cfg.folder_hooks.len(), 2);
2737 assert_eq!(cfg.folder_hooks[0].folder, "*work*");
2738 assert_eq!(cfg.folder_hooks[0].command, "set index_format=\"%s\"");
2739 assert_eq!(cfg.folder_hooks[1].folder, "*");
2740 assert_eq!(cfg.message_hooks.len(), 1);
2741 assert_eq!(cfg.message_hooks[0].command, "set pager_context=5");
2742 assert_eq!(cfg.reply_hooks.len(), 1);
2743 assert_eq!(cfg.reply_hooks[0].pattern, "~t @work\\.example\\.com");
2744 assert_eq!(cfg.fcc_hooks.len(), 2);
2745 assert_eq!(cfg.fcc_hooks[0].mailbox, "work-sent");
2746 assert_eq!(
2748 cfg.fcc_hooks[1].pattern,
2749 "(~f \"boss@example.com\" !~P) | (~P ~C \"boss@example.com\")"
2750 );
2751 assert_eq!(cfg.crypt_hooks.len(), 1);
2752 assert_eq!(cfg.crypt_hooks[0].key, "0xDEADBEEF");
2753 assert!(toml.contains("pattern does not translate"), "{toml}");
2755 assert!(toml.contains("frobnicate"), "{toml}");
2756 }
2757
2758 #[test]
2759 fn default_hook_expansion() {
2760 assert_eq!(default_hook_pattern("."), "~A");
2761 assert_eq!(default_hook_pattern("~t x"), "~t x");
2762 assert_eq!(default_hook_pattern("!~P"), "!~P");
2763 assert_eq!(
2764 default_hook_pattern("a@b"),
2765 "(~f \"a@b\" !~P) | (~P ~C \"a@b\")"
2766 );
2767 }
2768
2769 #[test]
2770 fn alternates_and_my_hdr_import() {
2771 let (cfg, toml) = to_config(concat!(
2772 "alternates jane@old\\.example\\.com '@club\\.example\\.com$'\n",
2773 "alternates typo@example\\.com\n",
2774 "unalternates typo@example\\.com\n",
2775 "my_hdr Organization: Acme\n",
2776 "my_hdr X-Mailer: rmut\n",
2777 "my_hdr Organization: Acme Ltd\n",
2778 "unmy_hdr X-Mailer\n",
2779 "set metoo\n",
2780 ));
2781 assert_eq!(
2782 cfg.mail.alternates,
2783 ["jane@old\\.example\\.com", "@club\\.example\\.com$"]
2784 );
2785 assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
2788 assert!(cfg.mail.metoo);
2789 assert!(toml.contains("alternates = ["), "{toml}");
2790 assert!(toml.contains("metoo = true"), "{toml}");
2791 }
2792
2793 #[test]
2794 fn hooks_become_identity_rules() {
2795 let (cfg, toml) = to_config(concat!(
2796 "set reverse_name = yes\n",
2797 "folder-hook work 'set from=\"Jane Work <jane@work.example.com>\"'\n",
2798 "send-hook '~t @club\\.example\\.com' 'set realname=\"Jenny\"'\n",
2799 "folder-hook . 'push <collapse-all>'\n",
2800 "send-hook '~l' 'set from=list@example.com'\n",
2801 ));
2802 assert!(cfg.identity.reverse_name);
2803 assert_eq!(cfg.identities.len(), 2);
2804 let work = &cfg.identities[0];
2805 assert_eq!(work.folder.as_deref(), Some("*work*"));
2806 assert!(work.recipient.is_none());
2807 assert_eq!(work.name.as_deref(), Some("Jane Work"));
2808 assert_eq!(work.email.as_deref(), Some("jane@work.example.com"));
2809 let club = &cfg.identities[1];
2810 assert_eq!(club.recipient.as_deref(), Some("*@club.example.com*"));
2811 assert!(club.folder.is_none());
2812 assert_eq!(club.name.as_deref(), Some("Jenny"));
2813 assert_eq!(cfg.folder_hooks.len(), 1);
2816 assert_eq!(cfg.folder_hooks[0].folder, "*");
2817 assert_eq!(cfg.folder_hooks[0].command, "push <collapse-all>");
2818 assert!(toml.contains("does not translate to a glob"), "{toml}");
2819 let (cfg, toml) = to_config("set reverse_name = no\n");
2821 assert!(!cfg.identity.reverse_name);
2822 assert!(toml.contains("# satisfied"), "{toml}");
2823 }
2824
2825 #[test]
2826 fn oauth_authenticators_become_account_auth() {
2827 let (cfg, toml) = to_config(concat!(
2828 "set folder = \"imaps://outlook.example.com/\"\n",
2829 "set imap_user = \"jane@example.com\"\n",
2830 "set imap_pass = \"hunter2\"\n",
2831 "set imap_authenticators = \"oauthbearer\"\n",
2832 "set smtp_authenticators = \"xoauth2:plain\"\n",
2833 ));
2834 let account = cfg.account("mutt").unwrap();
2835 assert_eq!(account.auth.as_deref(), Some("xoauth2"));
2837 assert_eq!(account.token_command.as_deref(), Some("oauth2ms"));
2838 assert!(account.password.is_none());
2840 assert!(!toml.contains("hunter2"), "{toml}");
2841 assert!(toml.contains("the account authenticates with OAuth"));
2842 assert!(toml.contains("fresh access token"), "{toml}");
2843 let (_, toml) = to_config("set smtp_authenticators = \"plain\"\n");
2845 assert!(toml.contains("# satisfied"), "{toml}");
2846 let (_, toml) = to_config("set smtp_authenticators = \"gssapi\"\n");
2847 assert!(toml.contains("xoauth2/oauthbearer"), "{toml}");
2848 }
2849
2850 #[test]
2851 fn color_index_patterns_and_status_format_import() {
2852 let (cfg, toml) = to_config(concat!(
2853 "color index yellow default \"~f boss@example.com\"\n",
2854 "color index brightred blue \"~d <1w ~U\"\n",
2855 "color index red default ~D\n", "color index green default \"~X 3\"\n", "set status_format = \"-%r- %f [%m msgs%?t?, %t tagged?]\"\n",
2858 ));
2859 assert_eq!(cfg.color_index.len(), 2);
2860 assert_eq!(cfg.color_index[0].pattern, "~f boss@example.com");
2861 assert_eq!(cfg.color_index[0].fg.as_deref(), Some("yellow"));
2862 assert!(cfg.color_index[0].bg.is_none()); assert_eq!(cfg.color_index[1].fg.as_deref(), Some("lightred"));
2864 assert_eq!(cfg.color_index[1].bg.as_deref(), Some("blue"));
2865 assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
2866 assert_eq!(
2867 cfg.ui.status_format.as_deref(),
2868 Some("-%r- %f [%m msgs%?t?, %t tagged?]")
2869 );
2870 assert!(toml.contains("no rmut color slot"), "{toml}");
2871 }
2872
2873 #[test]
2874 fn macros_translate_plain_sequences() {
2875 let (cfg, toml) = to_config(concat!(
2876 "macro index L \"l~f jane\\n\" \"limit to jane\"\n",
2877 "macro index,pager \\Cs \"~s urgent<Enter>\"\n",
2878 "macro index A \"<collapse-all>\"\n",
2879 "macro compose X \"y\"\n",
2880 ));
2881 assert_eq!(
2882 cfg.macros.index.get("L").map(String::as_str),
2883 Some("l~f jane<enter>")
2884 );
2885 assert_eq!(
2886 cfg.macros.index.get("ctrl+s").map(String::as_str),
2887 Some("~s urgent<enter>")
2888 );
2889 assert_eq!(
2890 cfg.macros.pager.get("ctrl+s").map(String::as_str),
2891 Some("~s urgent<enter>")
2892 );
2893 assert!(toml.contains("mutt function names do not"), "{toml}");
2895 assert!(toml.contains("only index and pager menus"), "{toml}");
2896 }
2897
2898 #[test]
2899 fn hook_glob_shapes() {
2900 assert_eq!(hook_glob(".").as_deref(), Some("*"));
2901 assert_eq!(hook_glob("work").as_deref(), Some("*work*"));
2902 assert_eq!(hook_glob("^/mail/work$").as_deref(), Some("/mail/work"));
2903 assert_eq!(hook_glob("=lists").as_deref(), Some("*lists*"));
2904 assert_eq!(
2905 hook_glob("~t bob@example\\.com").as_deref(),
2906 Some("*bob@example.com*")
2907 );
2908 assert_eq!(hook_glob("work.*").as_deref(), Some("*work*"));
2909 assert!(hook_glob("(a|b)").is_none());
2910 assert!(hook_glob("~f jane").is_none());
2911 }
2912
2913 #[test]
2914 fn set_forms_and_identity() {
2915 let (cfg, _) = to_config(concat!(
2916 "set realname = \"Jane Doe\"\n",
2917 "set from=jane@example.com\n",
2918 "set editor = vim # trailing comment\n",
2919 "set mail_check=30\n",
2920 ));
2921 assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2922 assert_eq!(cfg.identity.email.as_deref(), Some("jane@example.com"));
2923 assert_eq!(cfg.mail.editor.as_deref(), Some("vim"));
2924 assert_eq!(cfg.mail.poll_seconds, Some(30));
2925 }
2926
2927 #[test]
2928 fn from_with_display_name_fills_both() {
2929 let (cfg, _) = to_config("set from = \"Jane Doe <jane@x.org>\"\n");
2930 assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2931 assert_eq!(cfg.identity.email.as_deref(), Some("jane@x.org"));
2932 let (cfg, _) = to_config("set realname=RN\nset from = \"DN <j@x>\"\n");
2934 assert_eq!(cfg.identity.name.as_deref(), Some("RN"));
2935 }
2936
2937 #[test]
2938 fn mailboxes_expand_against_folder() {
2939 let (cfg, _) = to_config(concat!(
2940 "set folder = ~/Mail\n",
2941 "set spoolfile = +inbox\n",
2942 "set record = +sent\n",
2943 "set postponed = +drafts\n",
2944 "mailboxes +inbox +work ~/other\n",
2945 ));
2946 assert_eq!(
2947 cfg.mail.mailboxes,
2948 vec!["~/Mail/inbox", "~/Mail/work", "~/other"]
2949 );
2950 assert_eq!(cfg.mail.sent.as_deref(), Some("~/Mail/sent"));
2951 assert_eq!(cfg.mail.postponed.as_deref(), Some("~/Mail/drafts"));
2952 assert_eq!(cfg.mail.folder.as_deref(), Some("~/Mail"));
2955 }
2956
2957 #[test]
2958 fn binds_translate_keys_and_functions() {
2959 let (cfg, toml) = to_config(concat!(
2960 "bind index \\Cd delete-message\n",
2961 "bind index <esc>s sync-mailbox\n",
2962 "bind pager <space> next-page\n",
2963 "bind index,pager R group-reply\n",
2964 "bind index gg first-entry\n", "bind index Z frobnicate\n", ));
2967 assert_eq!(
2968 cfg.keys.index.get("delete").map(String::as_str),
2969 Some("ctrl+d")
2970 );
2971 assert_eq!(
2972 cfg.keys.index.get("sync").map(String::as_str),
2973 Some("alt+s")
2974 );
2975 assert_eq!(
2976 cfg.keys.pager.get("page-down").map(String::as_str),
2977 Some("space")
2978 );
2979 assert_eq!(
2980 cfg.keys.index.get("group-reply").map(String::as_str),
2981 Some("R")
2982 );
2983 assert_eq!(
2984 cfg.keys.pager.get("group-reply").map(String::as_str),
2985 Some("R")
2986 );
2987 assert!(toml.contains("# not imported:"));
2988 assert!(toml.contains("bind index gg first-entry"));
2989 assert!(toml.contains("bind index Z frobnicate"));
2990 }
2991
2992 #[test]
2993 fn colors_map_to_rmut_slots() {
2994 let (cfg, toml) = to_config(concat!(
2995 "color status brightyellow blue\n",
2996 "color header cyan default\n",
2997 "color index red default ~D\n",
2998 "color index brightmagenta default ~F\n",
2999 "color indicator black white\n", ));
3001 assert_eq!(
3002 cfg.colors.get("status_fg").map(String::as_str),
3003 Some("lightyellow")
3004 );
3005 assert_eq!(
3006 cfg.colors.get("status_bg").map(String::as_str),
3007 Some("blue")
3008 );
3009 assert_eq!(cfg.colors.get("header").map(String::as_str), Some("cyan"));
3010 assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
3011 assert_eq!(
3012 cfg.colors.get("flagged").map(String::as_str),
3013 Some("lightmagenta")
3014 );
3015 assert!(toml.contains("color indicator"));
3016 }
3017
3018 #[test]
3019 fn pager_colors_and_motion_translate() {
3020 let (cfg, toml) = to_config(concat!(
3021 "color quoted cyan default\n",
3022 "color quoted1 yellow default\n",
3023 "color body magenta default \"https?://[^ ]+\"\n",
3024 "color search black yellow\n",
3025 "set quote_regexp=\"^( *[>|])+\"\n",
3026 "bind pager \\Cd half-down\n",
3027 "bind pager T toggle-quoted\n",
3028 "bind pager S skip-quoted\n",
3029 ));
3030 assert_eq!(cfg.colors.get("quoted").map(String::as_str), Some("cyan"));
3031 assert_eq!(
3032 cfg.colors.get("quoted1").map(String::as_str),
3033 Some("yellow")
3034 );
3035 assert_eq!(
3036 cfg.colors.get("search_bg").map(String::as_str),
3037 Some("yellow")
3038 );
3039 assert_eq!(cfg.color_body.len(), 1);
3040 assert_eq!(cfg.color_body[0].pattern, "https?://[^ ]+");
3041 assert_eq!(cfg.color_body[0].fg.as_deref(), Some("magenta"));
3042 assert_eq!(cfg.pager.quote_regexp.as_deref(), Some("^( *[>|])+"));
3043 assert_eq!(
3044 cfg.keys.pager.get("half-down").map(String::as_str),
3045 Some("ctrl+d")
3046 );
3047 assert_eq!(
3048 cfg.keys.pager.get("toggle-quoted").map(String::as_str),
3049 Some("T")
3050 );
3051 assert!(toml.contains("[[color_body]]"));
3052 }
3053
3054 #[test]
3055 fn header_weeding_and_pager_polish_translate() {
3056 let (cfg, toml) = to_config(concat!(
3057 "ignore *\n",
3058 "unignore from date subject\n",
3059 "hdr_order Date: From: Subject:\n",
3060 "set pager_format=\"-%Z- %C/%m: %s\"\n",
3061 "set wrap = 78\n",
3062 "set tilde\n",
3063 ));
3064 assert_eq!(cfg.pager.ignore.as_deref(), Some(&["*".to_string()][..]));
3065 assert_eq!(
3066 cfg.pager.unignore.as_deref(),
3067 Some(&["from".to_string(), "date".into(), "subject".into()][..])
3068 );
3069 assert_eq!(
3070 cfg.pager.hdr_order.as_deref(),
3071 Some(&["Date".to_string(), "From".into(), "Subject".into()][..])
3072 );
3073 assert_eq!(cfg.pager.format.as_deref(), Some("-%Z- %C/%m: %s"));
3074 assert_eq!(cfg.pager.wrap, Some(78));
3075 assert!(cfg.pager.tilde);
3076 assert!(toml.contains("hdr_order"));
3077 }
3078
3079 #[test]
3080 fn compose_round_two_translates() {
3081 let (cfg, _) = to_config(concat!(
3082 "set fast_reply = yes\n",
3083 "set autoedit\n",
3084 "set nocopy\n",
3085 "set mime_forward = ask-yes\n",
3086 "set forward_decode\n", "set new_mail_command=\"notify-send 'rmut: %n new in %f'\"\n",
3088 ));
3089 assert!(cfg.mail.fast_reply);
3090 assert!(cfg.mail.autoedit);
3091 assert_eq!(cfg.mail.copy, Some(false));
3092 assert_eq!(cfg.mail.forward.as_deref(), Some("ask"));
3093 assert_eq!(
3094 cfg.mail.new_mail_command.as_deref(),
3095 Some("notify-send 'rmut: %n new in %f'")
3096 );
3097 }
3098
3099 #[test]
3100 fn the_signature_and_the_send_questions_import() {
3101 let (cfg, toml) = to_config(concat!(
3102 "set signature = \"~/.signature\"\n",
3103 "set nosig_dashes\n",
3104 "set forward_quote\n",
3105 "set abort_nosubject = no\n",
3106 "set noabort_unmodified\n",
3107 ));
3108 assert_eq!(cfg.mail.signature.as_deref(), Some("~/.signature"));
3109 assert_eq!(cfg.mail.sig_dashes, Some(false));
3110 assert!(cfg.mail.forward_quote);
3111 assert_eq!(cfg.mail.abort_nosubject.as_deref(), Some("no"));
3112 assert_eq!(cfg.mail.abort_unmodified, Some(false));
3113 assert!(toml.contains("signature = \"~/.signature\""), "{toml}");
3114 }
3115
3116 #[test]
3117 fn what_rmut_already_asks_is_satisfied_not_skipped() {
3118 let (cfg, toml) = to_config(concat!(
3121 "set reply_to = ask-yes\n",
3122 "set honor_followup_to = yes\n",
3123 "set abort_nosubject = ask-yes\n",
3124 "set abort_unmodified = yes\n",
3125 "set sig_dashes = yes\n",
3126 "set noforward_quote\n",
3127 ));
3128 assert_eq!(cfg.mail.abort_nosubject, None);
3129 assert_eq!(cfg.mail.abort_unmodified, None);
3130 assert_eq!(cfg.mail.sig_dashes, None);
3131 assert!(!cfg.mail.forward_quote);
3132 let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3133 assert!(unclaimed.trim().is_empty(), "{toml}");
3134 assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3135 let (_, toml) = to_config("set reply_to = yes\n");
3137 let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3138 assert!(unclaimed.contains("set reply_to = yes"), "{toml}");
3139 }
3140
3141 #[test]
3142 fn the_small_habits_import_or_are_already_rmut() {
3143 let (cfg, toml) = to_config(concat!(
3144 "set nomark_old\n",
3145 "set beep_new = yes\n",
3146 "set nowait_key\n",
3147 "set print = ask-yes\n",
3148 "set noreverse_realname\n",
3149 ));
3150 assert_eq!(cfg.mail.mark_old, Some(false));
3151 assert!(cfg.ui.beep_new);
3152 assert_eq!(cfg.ui.wait_key, Some(false));
3153 assert_eq!(cfg.mail.print_confirm.as_deref(), Some("ask-yes"));
3154 assert_eq!(cfg.identity.reverse_realname, Some(false));
3155 assert!(toml.contains("beep_new = true"), "{toml}");
3156
3157 let (_, toml) = to_config(concat!(
3160 "set mark_old = yes\n",
3161 "set nobeep_new\n",
3162 "set wait_key = yes\n",
3163 "set print = ask-no\n",
3164 "set reverse_realname = yes\n",
3165 "set timeout = 15\n",
3166 ));
3167 let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3168 assert!(unclaimed.trim().is_empty(), "{toml}");
3169 assert!(toml.contains("# set timeout = 15 (rmut polls"), "{toml}");
3170 }
3171
3172 #[test]
3173 fn pgp_settings_translate() {
3174 let (cfg, _) = to_config(concat!(
3175 "set pgp_sign_as = 0xDEADBEEF\n",
3176 "set crypt_autosign = yes\n",
3177 "set nocrypt_autoencrypt\n",
3178 ));
3179 assert_eq!(cfg.pgp.sign_key.as_deref(), Some("0xDEADBEEF"));
3180 assert!(cfg.pgp.sign_by_default);
3181 assert!(!cfg.pgp.encrypt_by_default);
3182 }
3183
3184 #[test]
3185 fn what_rmut_has_is_imported_and_what_it_does_its_own_way_is_said() {
3186 let (cfg, toml) = to_config(concat!(
3187 "set sidebar_visible = yes\n",
3188 "set sidebar_width = 24\n",
3189 "set sidebar_format = \"%B%* %N\"\n",
3190 "set alias_file = ~/.mutt/aliases\n",
3191 "set imap_idle = yes\n",
3192 "set header_cache = ~/.cache/mutt\n",
3193 "set crypt_use_gpgme = yes\n",
3194 "set implicit_autoview = yes\n",
3195 ));
3196 assert!(cfg.sidebar.visible, "{toml}");
3198 assert_eq!(cfg.sidebar.width, 24);
3199 assert_eq!(cfg.mail.alias_file.as_deref(), Some("~/.mutt/aliases"));
3200 assert!(
3202 toml.contains("rmut IDLEs whenever the server offers it"),
3203 "{toml}"
3204 );
3205 assert!(toml.contains("# rmut does these its own way:"), "{toml}");
3206 assert!(toml.contains("under ~/.cache/rmut"), "{toml}");
3207 assert!(toml.contains("gpg(1) directly"), "{toml}");
3208 assert!(toml.contains("no format string"), "{toml}");
3209 let not_imported = toml.split("# not imported:").nth(1).unwrap_or("");
3211 for gone in [
3212 "sidebar_visible",
3213 "imap_idle",
3214 "header_cache",
3215 "crypt_use_gpgme",
3216 ] {
3217 assert!(!not_imported.contains(gone), "{gone} still skipped: {toml}");
3218 }
3219 }
3220
3221 #[test]
3222 fn the_reply_and_forward_text_carries_over() {
3223 let (cfg, toml) = to_config(concat!(
3224 "set attribution = \"On %d, %n wrote:\"\n",
3225 "set indent_string = \"| \"\n",
3226 "set forward_format = \"Fwd: %s\"\n",
3227 "set include = no\n",
3228 "set askcc = yes\n",
3229 "set askbcc = yes\n",
3230 ));
3231 assert_eq!(
3232 cfg.mail.attribution.as_deref(),
3233 Some("On %d, %n wrote:"),
3234 "{toml}"
3235 );
3236 assert_eq!(cfg.mail.indent_string.as_deref(), Some("| "));
3237 assert_eq!(cfg.mail.forward_format.as_deref(), Some("Fwd: %s"));
3238 assert_eq!(cfg.mail.include.as_deref(), Some("no"));
3239 assert!(cfg.mail.ask_cc && cfg.mail.ask_bcc);
3240 let import = import("set include = maybe\n", Path::new("/nonexistent"));
3243 assert!(
3244 import
3245 .toml
3246 .contains("include wants yes / no / ask-yes / ask-no"),
3247 "{}",
3248 import.toml
3249 );
3250 }
3251
3252 #[test]
3253 fn connect_timeout_carries_over() {
3254 let (cfg, toml) = to_config("set connect_timeout=15\n");
3255 assert_eq!(cfg.net.connect_timeout, 15, "{toml}");
3256 let (cfg, _) = to_config("set connect_timeout=-1\n");
3258 assert_eq!(cfg.net.connect_timeout, 0);
3259 }
3260
3261 #[test]
3262 fn wrap_search_off_carries_over() {
3263 let (cfg, toml) = to_config("set nowrap_search\n");
3264 assert_eq!(cfg.mail.wrap_search, Some(false), "{toml}");
3265 }
3266
3267 #[test]
3268 fn simple_search_carries_over() {
3269 let (cfg, toml) = to_config("set simple_search = \"~f %s | ~s %s | ~b %s\"\n");
3270 assert_eq!(
3271 cfg.mail.simple_search.as_deref(),
3272 Some("~f %s | ~s %s | ~b %s"),
3273 "{toml}"
3274 );
3275 }
3276
3277 #[test]
3278 fn search_context_carries_over() {
3279 let (cfg, toml) = to_config("set search_context = 3\n");
3280 assert_eq!(cfg.pager.search_context, 3, "{toml}");
3281 }
3282
3283 #[test]
3284 fn hide_thread_subject_carries_over() {
3285 let (cfg, toml) = to_config("set hide_thread_subject = yes\n");
3286 assert_eq!(cfg.index.hide_thread_subject, Some(true), "{toml}");
3287 }
3288
3289 #[test]
3290 fn mark_options_carry_over() {
3291 let (cfg, toml) = to_config(
3294 "set nodelete_untag\nset flag_safe\nset maildir_trash\n\
3295 set nomail_check_recent\nset nocheck_new\nset nouncollapse_new\n",
3296 );
3297 assert_eq!(cfg.mail.delete_untag, Some(false), "{toml}");
3298 assert!(cfg.mail.flag_safe);
3299 assert!(cfg.mail.maildir_trash);
3300 assert_eq!(cfg.mail.mail_check_recent, Some(false));
3301 assert_eq!(cfg.mail.check_new, Some(false));
3302 assert_eq!(cfg.index.uncollapse_new, Some(false));
3303 let (cfg, toml) = to_config(
3304 "set delete_untag\nset noflag_safe\nset nomaildir_trash\n\
3305 set mail_check_recent\nset check_new\nset uncollapse_new\n",
3306 );
3307 assert_eq!(cfg.mail.delete_untag, None, "{toml}");
3308 assert!(!cfg.mail.flag_safe);
3309 assert!(!cfg.mail.maildir_trash);
3310 assert_eq!(cfg.index.uncollapse_new, None);
3311 assert!(!toml.contains("not imported"), "{toml}");
3312 }
3313
3314 #[test]
3315 fn menu_knobs_carry_over() {
3316 let (cfg, toml) =
3317 to_config("set nomenu_scroll\nset menu_context = 3\nset nomenu_move_off\nset nohelp\n");
3318 assert_eq!(cfg.ui.menu_scroll, Some(false), "{toml}");
3319 assert_eq!(cfg.ui.menu_context, 3);
3320 assert_eq!(cfg.ui.menu_move_off, Some(false));
3321 assert_eq!(cfg.ui.help, Some(false));
3322 let (_, toml) = to_config(
3324 "set keep_flagged\nset thread_received\nset sleep_time = 0\nset read_inc = 100\n\
3325 set hide_limited\n",
3326 );
3327 assert!(!toml.contains("not imported"), "{toml}");
3328 assert!(toml.contains("no $move"), "{toml}");
3329 }
3330
3331 #[test]
3332 fn browser_alias_shell_tmpdir_carry_over() {
3333 let (cfg, toml) = to_config(
3334 "set sort_browser = reverse-date\nset sort_alias = alias\nset shell = /bin/zsh\n\
3335 set tmpdir = ~/tmp\n",
3336 );
3337 assert_eq!(
3338 cfg.ui.sort_browser.as_deref(),
3339 Some("reverse-date"),
3340 "{toml}"
3341 );
3342 assert_eq!(cfg.mail.sort_alias.as_deref(), Some("alias"));
3343 assert_eq!(cfg.mail.shell.as_deref(), Some("/bin/zsh"));
3344 assert_eq!(cfg.mail.tmpdir.as_deref(), Some("~/tmp"));
3345 let (cfg, _) = to_config("set ispell = \"aspell -c\"\n");
3346 assert_eq!(cfg.mail.ispell.as_deref(), Some("aspell -c"));
3347 let (_, toml) = to_config("set sort_browser = alpha\nset sort_alias = address\n");
3348 assert!(!toml.contains("not imported"), "{toml}");
3349 }
3350
3351 #[test]
3352 fn the_threading_knobs_carry_over() {
3353 let (cfg, toml) = to_config("set strict_threads = yes\nset nosort_re\n");
3354 assert_eq!(cfg.index.strict_threads, Some(true), "{toml}");
3355 assert_eq!(cfg.index.sort_re, Some(false), "{toml}");
3356 let (cfg, toml) = to_config("set nostrict_threads\nset sort_re = yes\n");
3359 assert_eq!(cfg.index.strict_threads, None, "{toml}");
3360 assert_eq!(cfg.index.sort_re, None, "{toml}");
3361 assert!(!toml.contains("not imported"), "{toml}");
3362 }
3363
3364 #[test]
3365 fn status_chars_carries_over() {
3366 let (cfg, toml) = to_config("set status_chars = \"-*%A\"\n");
3367 assert_eq!(cfg.ui.status_chars.as_deref(), Some("-*%A"), "{toml}");
3368 }
3369
3370 #[test]
3371 fn layout_settings_carry_over() {
3372 let (cfg, toml) = to_config("set status_on_top = yes\nset arrow_cursor = yes\n");
3373 assert_eq!(cfg.ui.status_on_top, Some(true), "{toml}");
3374 assert_eq!(cfg.ui.arrow_cursor, Some(true));
3375 }
3376
3377 #[test]
3378 fn history_file_carries_over() {
3379 let (cfg, toml) = to_config("set history_file = ~/.rmut_history\n");
3380 assert_eq!(
3381 cfg.ui.history_file.as_deref(),
3382 Some("~/.rmut_history"),
3383 "{toml}"
3384 );
3385 }
3386
3387 #[test]
3388 fn ts_title_settings_carry_over() {
3389 let (cfg, toml) = to_config(concat!(
3390 "set ts_enabled = yes\n",
3391 "set ts_status_format = \"rmut %f (%m)\"\n",
3392 ));
3393 assert_eq!(cfg.ui.set_title, Some(true), "{toml}");
3394 assert_eq!(cfg.ui.title_format.as_deref(), Some("rmut %f (%m)"));
3395 }
3396
3397 #[test]
3398 fn reply_crypto_settings_carry_over() {
3399 let (cfg, toml) = to_config(concat!(
3400 "set crypt_replysign = yes\n",
3401 "set crypt_replyencrypt = yes\n",
3402 ));
3403 assert!(cfg.pgp.reply_sign, "{toml}");
3404 assert!(cfg.pgp.reply_encrypt);
3405 assert!(!cfg.pgp.reply_sign_encrypted);
3406 }
3407
3408 #[test]
3409 fn postpone_and_recall_quadoptions_carry_over() {
3410 let (cfg, toml) = to_config(concat!("set postpone = no\n", "set recall = yes\n",));
3411 assert_eq!(cfg.mail.postpone.as_deref(), Some("no"), "{toml}");
3412 assert_eq!(cfg.mail.recall.as_deref(), Some("yes"));
3413 let (cfg, _) = to_config(concat!("set postpone = ask-yes\n", "set recall = ask-no\n",));
3416 assert_eq!(cfg.mail.postpone, None);
3417 assert_eq!(cfg.mail.recall, None);
3418 }
3419
3420 #[test]
3421 fn the_envelope_settings_carry_over() {
3422 let (cfg, toml) = to_config(concat!(
3423 "set hostname = mail.example.net\n",
3424 "set user_agent = yes\n",
3425 "set sig_on_top = yes\n",
3426 ));
3427 assert_eq!(
3428 cfg.mail.hostname.as_deref(),
3429 Some("mail.example.net"),
3430 "{toml}"
3431 );
3432 assert_eq!(cfg.mail.user_agent, Some(true));
3433 assert_eq!(cfg.mail.sig_on_top, Some(true));
3434 }
3435
3436 #[test]
3437 fn the_trust_settings_carry_over() {
3438 let (cfg, toml) = to_config(concat!(
3439 "set certificate_file = ~/.mutt/certs.pem\n",
3440 "set ssl_usesystemcerts = no\n",
3441 ));
3442 assert_eq!(
3443 cfg.net.certificate_file.as_deref(),
3444 Some("~/.mutt/certs.pem"),
3445 "{toml}"
3446 );
3447 assert!(!cfg.net.system_cas, "{toml}");
3448 let (cfg, _) = to_config(concat!(
3451 "set ssl_ca_certificates_file = /etc/ssl/roots.pem\n",
3452 "set ssl_usesystemcerts = yes\n",
3453 ));
3454 assert_eq!(
3455 cfg.net.certificate_file.as_deref(),
3456 Some("/etc/ssl/roots.pem")
3457 );
3458 assert!(cfg.net.system_cas);
3459 }
3460
3461 #[test]
3462 fn imap_folder_becomes_an_account() {
3463 let (cfg, toml) = to_config(concat!(
3464 "set folder = imaps://mail.example.com\n",
3465 "set spoolfile = +INBOX\n",
3466 "set imap_user = jane\n",
3467 "set imap_pass = hunter2\n",
3468 "set smtp_url = smtps://jane@smtp.example.com:465\n",
3469 "set record = +Sent\n",
3470 "mailboxes +INBOX +Archive\n",
3471 ));
3472 let acct = cfg.account("mutt").unwrap();
3473 assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3474 assert_eq!(acct.user, "jane");
3475 assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3476 assert_eq!(acct.smtp_port, 465);
3477 assert_eq!(acct.sent_folder, "Sent");
3478 assert_eq!(
3479 cfg.mail.mailboxes,
3480 vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3481 );
3482 assert_eq!(acct.password.as_deref(), Some("hunter2"));
3484 assert_eq!(acct.password().unwrap(), "hunter2");
3485 assert!(toml.contains("consider password_command"), "{toml}");
3486 }
3487
3488 #[test]
3489 fn plain_imap_url_means_starttls_port_never_disabled_tls() {
3490 let (cfg, toml) = to_config(concat!(
3491 "set folder = imap://mail.example.com\n",
3492 "set imap_user = u\n",
3493 "set smtp_url = smtps://smtp.example.com\n",
3494 ));
3495 let acct = cfg.account("mutt").unwrap();
3496 assert_eq!(acct.imap_port, 143);
3497 assert!(acct.imap_tls, "STARTTLS, not plaintext");
3498 assert!(!toml.contains("imap_tls"), "{toml}");
3499 assert_eq!(acct.smtp_port, 465);
3500 }
3501
3502 #[test]
3503 fn sort_pager_and_forward_directives_import() {
3504 let (cfg, toml) = to_config(concat!(
3505 "set sort = \"threads\"\n",
3506 "set sort_aux = last-date-sent\n",
3507 "set date_format = \"%d.%m.%Y\"\n",
3508 "set pager_index_lines = 10\n",
3509 "set pager_context = 3\n",
3510 "set mime_forward = yes\n",
3511 "set mime_forward_rest = yes\n",
3512 "set query_command = \"khard email --parsable %s\"\n",
3513 "set trash = +Trash\n",
3514 "set edit_headers = yes\n",
3515 "save-hook . +General\n",
3516 "set folder = ~/Mail\n",
3517 "bind index G imap-fetch-mail\n",
3518 ));
3519 assert_eq!(cfg.index.sort.as_deref(), Some("threads"));
3520 assert_eq!(cfg.index.sort_aux.as_deref(), Some("last-date-sent"));
3521 assert_eq!(cfg.index.date_format.as_deref(), Some("%d.%m.%Y"));
3522 assert_eq!(cfg.pager.index_lines, 10);
3523 assert_eq!(cfg.pager.context, 3);
3524 assert_eq!(cfg.mail.forward.as_deref(), Some("attach"));
3525 assert_eq!(
3526 cfg.mail.query_command.as_deref(),
3527 Some("khard email --parsable %s")
3528 );
3529 assert_eq!(cfg.mail.save.as_deref(), Some("~/Mail/General"));
3530 assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
3531 assert_eq!(cfg.mail.edit_headers, Some(true));
3532 let (cfg2, _) = to_config("set edit_headers = no\n");
3534 assert!(cfg2.mail.edit_headers.is_none());
3535 assert_eq!(
3536 cfg.keys.index.get("fetch-mail").map(String::as_str),
3537 Some("G")
3538 );
3539 assert!(toml.contains("mime_forward_rest"), "{toml}");
3540 assert!(!toml.contains("# not imported"), "{toml}");
3541 }
3542
3543 #[test]
3544 fn auto_view_and_peek_and_tagged_color() {
3545 let (cfg, toml) = to_config(concat!(
3546 "auto_view application/zip\n",
3547 "auto_view text/x-patch text/x-diff\n",
3548 "auto_view application/pgp-signature application/pgp\n",
3549 "auto_view text/html\n",
3550 "auto_view text/calendar\n",
3551 "unauto_view text/calendar\n",
3552 "alternative_order text/enriched text/plain TEXT/HTML\n",
3553 "unalternative_order text/enriched\n",
3554 "set imap_peek = yes\n",
3555 "set menu_scroll\n",
3556 "bind index % noop\n",
3557 "color index black cyan \"~T\"\n",
3558 ));
3559 for mime in [
3563 "application/zip",
3564 "text/x-patch",
3565 "text/x-diff",
3566 "text/html",
3567 ] {
3568 assert_eq!(
3569 cfg.filters.get(mime).map(String::as_str),
3570 Some(""),
3571 "{mime}"
3572 );
3573 }
3574 assert!(!cfg.filters.contains_key("text/calendar"), "{toml}");
3576 assert_eq!(cfg.pager.alternative_order, ["text/plain", "text/html"]);
3577 let tagged = cfg
3580 .color_index
3581 .iter()
3582 .find(|r| r.pattern == "~T")
3583 .expect("a ~T rule");
3584 assert_eq!(tagged.fg.as_deref(), Some("black"));
3585 assert_eq!(tagged.bg.as_deref(), Some("cyan"));
3586 assert!(!cfg.colors.contains_key("tagged"));
3587 assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3588 for satisfied in ["pgp-signature", "imap_peek", "menu_scroll", "noop"] {
3589 assert!(toml.contains(satisfied), "{satisfied} missing:\n{toml}");
3590 }
3591 assert!(toml.contains("application/zip"), "{toml}");
3592 }
3593
3594 #[test]
3595 fn imap_pass_without_imap_folder_is_redacted() {
3596 let (cfg, toml) = to_config("set folder = ~/Mail\nset imap_pass = hunter2\n");
3597 assert!(cfg.accounts.is_empty());
3598 assert!(!toml.contains("hunter2"), "password must not leak:\n{toml}");
3599 assert!(toml.contains("(redacted)"), "{toml}");
3600 }
3601
3602 #[test]
3603 fn url_style_spoolfile_record_and_mailboxes() {
3604 let (cfg, toml) = to_config(concat!(
3606 "set folder = \"imaps://mail.example.com/\"\n",
3607 "set spoolfile = \"imaps://mail.example.com/INBOX\"\n",
3608 "set record = \"imaps://mail.example.com/Sent\"\n",
3609 "set imap_user = jane\n",
3610 "mailboxes imaps://mail.example.com/INBOX imaps://mail.example.com/Archive\n",
3611 ));
3612 let acct = cfg.account("mutt").unwrap();
3613 assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3614 assert_eq!(acct.sent_folder, "Sent");
3615 assert_eq!(
3616 cfg.mail.mailboxes,
3617 vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3618 );
3619 assert!(!toml.contains("//INBOX"), "no doubled separators:\n{toml}");
3620 }
3621
3622 #[test]
3623 fn url_with_userinfo_and_empty_port() {
3624 let (cfg, toml) = to_config(concat!(
3626 "set folder = \"imap://jane@mail.example.com:/\"\n",
3627 "set spoolfile = \"imap://jane@mail.example.com:/INBOX\"\n",
3628 ));
3629 let acct = cfg.account("mutt").unwrap();
3630 assert_eq!(acct.user, "jane");
3631 assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3632 assert_eq!(acct.imap_port, 143);
3633 assert!(acct.imap_tls, "imap:// means STARTTLS, not plaintext");
3634 assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3635 assert!(
3636 !toml.contains("jane@mail.example.com"),
3637 "no URLs in specs:\n{toml}"
3638 );
3639 }
3640
3641 #[test]
3642 fn url_spoolfile_alone_identifies_the_server() {
3643 let (cfg, _) = to_config(concat!(
3644 "set spoolfile = imaps://mail.example.com:1993/INBOX\n",
3645 "set imap_user = jane\n",
3646 ));
3647 let acct = cfg.account("mutt").unwrap();
3648 assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3649 assert_eq!(acct.imap_port, 1993);
3650 assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3651 }
3652
3653 #[test]
3654 fn smtp_only_muttrc_still_gets_an_account() {
3655 let (cfg, _) = to_config(concat!(
3656 "set folder = ~/Mail\n",
3657 "set from = jane@x\n",
3658 "set smtp_url = smtp://smtp.example.com:587\n",
3659 "set smtp_pass = sekrit\n",
3660 ));
3661 let acct = cfg.account("mutt").unwrap();
3662 assert!(acct.imap_host.is_none());
3663 assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3664 assert_eq!(acct.user, "jane@x");
3665 assert_eq!(acct.password.as_deref(), Some("sekrit"));
3666 }
3667
3668 #[test]
3669 fn differing_smtp_pass_is_noted_and_redacted() {
3670 let (cfg, toml) = to_config(concat!(
3671 "set folder = imaps://h\n",
3672 "set imap_user = u\n",
3673 "set imap_pass = aaa\n",
3674 "set smtp_url = smtp://s\n",
3675 "set smtp_pass = bbb\n",
3676 ));
3677 assert_eq!(
3678 cfg.account("mutt").unwrap().password.as_deref(),
3679 Some("aaa")
3680 );
3681 assert!(!toml.contains("bbb"), "smtp_pass must not leak:\n{toml}");
3682 assert!(toml.contains("differs from imap_pass"), "{toml}");
3683 }
3684
3685 #[test]
3686 fn default_matching_directives_are_acknowledged() {
3687 let (cfg, toml) = to_config(concat!(
3688 "set ssl_starttls = yes\n",
3689 "set ssl_force_tls = yes\n",
3690 "set charset = \"UTF-8\"\n",
3691 "set pgp_auto_decode = yes\n",
3692 "set smtp_authenticators =\"login\"\n",
3693 "set print_command = \"a2ps\"\n",
3694 ));
3695 assert_eq!(cfg.mail.print.as_deref(), Some("a2ps"));
3696 assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3697 for directive in [
3698 "ssl_starttls",
3699 "ssl_force_tls",
3700 "charset",
3701 "pgp_auto_decode",
3702 "smtp_authenticators",
3703 ] {
3704 assert!(toml.contains(directive), "{directive} missing:\n{toml}");
3705 }
3706 assert!(!toml.contains("# not imported"), "{toml}");
3707 }
3708
3709 #[test]
3710 fn non_default_tls_and_charset_still_surface() {
3711 let (_cfg, toml) = to_config(concat!(
3712 "set ssl_force_tls = no\n",
3713 "set charset = \"iso-8859-2\"\n",
3714 "set smtp_authenticators = \"oauthbearer\"\n",
3715 ));
3716 assert!(toml.contains("# not imported:"), "{toml}");
3717 assert!(!toml.contains("# satisfied"), "{toml}");
3718 }
3719
3720 #[test]
3721 fn account_without_imap_pass_gets_a_placeholder() {
3722 let (cfg, toml) = to_config("set folder = imaps://h.example.com\nset imap_user = u\n");
3723 assert!(cfg.account("mutt").unwrap().password_command.is_some());
3724 assert!(toml.contains("pass show mail/TODO"), "{toml}");
3725 }
3726
3727 #[test]
3728 fn aliases_and_unknowns_surface_as_comments() {
3729 let import = import(
3730 "alias petr Petr Novak <petr@example.com>\nset sleep_time = 0\nmacro index x \"<shell-escape>ls\\n\"\n",
3731 Path::new("/"),
3732 );
3733 assert_eq!(import.aliases, ["alias petr Petr Novak <petr@example.com>"]);
3737 assert!(!import.toml.contains("alias petr"), "{}", import.toml);
3738 let block = alias_block(&import.aliases, Path::new("/home/x/.config/rmut/aliases"));
3739 assert!(
3740 block.contains("# alias petr Petr Novak <petr@example.com>"),
3741 "{block}"
3742 );
3743 assert!(block.contains("/home/x/.config/rmut/aliases"), "{block}");
3744 assert!(alias_block(&[], Path::new("/x")).is_empty());
3745 assert!(import.toml.contains("set sleep_time = 0"));
3746 assert!(import.toml.contains("macro index x"), "{}", import.toml);
3747 assert!(import.toml.contains("mutt function names"));
3748 toml::from_str::<Config>(&import.toml).unwrap();
3750 }
3751
3752 #[test]
3753 fn source_includes_are_followed() {
3754 let tmp = tempfile::tempdir().unwrap();
3755 std::fs::write(tmp.path().join("extra"), "set realname = Included\n").unwrap();
3756 let import = import("source extra\nsource ./missing\n", tmp.path());
3757 assert!(import.toml.contains("name = \"Included\""));
3758 assert!(import.toml.contains("source ./missing"));
3759 }
3760
3761 #[test]
3762 fn continuations_and_quoting() {
3763 let lines = logical_lines("set realname = \\\n \"Jane # not a comment\"\n# gone\n");
3764 assert_eq!(lines, vec!["set realname = \"Jane # not a comment\""]);
3765 assert_eq!(
3766 tokenize("bind index \\Cd delete-message"),
3767 vec!["bind", "index", "\\Cd", "delete-message"]
3768 );
3769 assert_eq!(tokenize("set from='a b' c"), vec!["set", "from=a b", "c"]);
3770 }
3771
3772 #[test]
3773 fn convert_key_forms() {
3774 assert_eq!(convert_key("\\Cx").as_deref(), Some("ctrl+x"));
3775 assert_eq!(convert_key("\\CX").as_deref(), Some("ctrl+x"));
3776 assert_eq!(convert_key("\\ev").as_deref(), Some("alt+v"));
3777 assert_eq!(convert_key("<esc>V").as_deref(), Some("alt+V"));
3778 assert_eq!(convert_key("<Enter>").as_deref(), Some("enter"));
3779 assert_eq!(convert_key("<PageDown>").as_deref(), Some("pgdn"));
3780 assert_eq!(convert_key("G").as_deref(), Some("G"));
3781 assert_eq!(convert_key("gg"), None);
3782 assert_eq!(convert_key("<f5>"), None);
3783 }
3784}