use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result, ensure};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub identity: Identity,
pub mail: Mail,
pub index: Index,
pub pager: Pager,
pub ui: Ui,
pub net: Net,
pub sidebar: Sidebar,
pub colors: HashMap<String, String>,
pub color_index: Vec<ColorRule>,
pub color_body: Vec<ColorRule>,
pub filters: HashMap<String, String>,
pub keys: Keys,
pub macros: Keys,
pub accounts: Vec<Account>,
pub identities: Vec<IdentityRule>,
pub folder_hooks: Vec<FolderHook>,
pub message_hooks: Vec<MessageHook>,
pub reply_hooks: Vec<MessageHook>,
pub fcc_hooks: Vec<FccHook>,
pub crypt_hooks: Vec<CryptHook>,
pub pgp: Pgp,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FolderHook {
pub folder: String,
pub command: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct MessageHook {
pub pattern: String,
pub command: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FccHook {
pub pattern: String,
pub mailbox: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct CryptHook {
pub address: String,
pub key: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Identity {
pub name: Option<String>,
pub email: Option<String>,
pub reverse_name: bool,
pub reverse_realname: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct IdentityRule {
pub folder: Option<String>,
pub recipient: Option<String>,
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Mail {
pub folder: Option<String>,
pub mailboxes: Vec<String>,
pub sent: Option<String>,
pub postponed: Option<String>,
pub sendmail: Option<String>,
pub editor: Option<String>,
pub ispell: Option<String>,
pub poll_seconds: Option<u64>,
pub print: Option<String>,
pub pipe_decode: Option<bool>,
pub print_decode: Option<bool>,
pub pipe_split: Option<bool>,
pub print_split: Option<bool>,
pub pipe_sep: Option<String>,
pub save: Option<String>,
pub forward: Option<String>,
pub query_command: Option<String>,
pub trash: Option<String>,
pub edit_headers: Option<bool>,
pub notmuch: Option<bool>,
pub fast_reply: bool,
pub quit: Option<String>,
pub postpone: Option<String>,
pub recall: Option<String>,
pub confirmappend: bool,
pub save_name: bool,
pub force_name: bool,
pub mark_old: Option<bool>,
pub delete_untag: Option<bool>,
pub flag_safe: bool,
pub maildir_trash: bool,
pub mail_check_recent: Option<bool>,
pub sort_alias: Option<String>,
pub shell: Option<String>,
pub tmpdir: Option<String>,
pub check_new: Option<bool>,
pub print_confirm: Option<String>,
pub alias_file: Option<String>,
pub attribution: Option<String>,
pub indent_string: Option<String>,
pub forward_format: Option<String>,
pub wrap_search: Option<bool>,
pub simple_search: Option<String>,
pub reply_regexp: Option<String>,
pub include: Option<String>,
pub forward_quote: bool,
pub signature: Option<String>,
pub sig_dashes: Option<bool>,
pub sig_on_top: Option<bool>,
pub hostname: Option<String>,
pub user_agent: Option<bool>,
pub abort_nosubject: Option<String>,
pub abort_unmodified: Option<bool>,
pub ask_cc: bool,
pub ask_bcc: bool,
pub autoedit: bool,
pub copy: Option<bool>,
pub lists: Vec<String>,
pub subscribed: Vec<String>,
pub alternates: Vec<String>,
pub my_hdr: Vec<String>,
pub metoo: bool,
pub text_flowed: bool,
pub new_mail_command: Option<String>,
pub delete: Option<String>,
pub abort_noattach: Option<String>,
pub attach_keyword: Option<String>,
pub undo_send: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Index {
pub format: Option<String>,
pub sort: Option<String>,
pub sort_aux: Option<String>,
pub date_format: Option<String>,
pub collapse_unread: Option<bool>,
pub uncollapse_jump: bool,
pub hide_thread_subject: Option<bool>,
pub uncollapse_new: Option<bool>,
pub strict_threads: Option<bool>,
pub sort_re: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Pager {
pub index_lines: u16,
pub context: usize,
pub search_context: usize,
pub quote_regexp: Option<String>,
pub ignore: Option<Vec<String>>,
pub unignore: Option<Vec<String>>,
pub hdr_order: Option<Vec<String>>,
pub format: Option<String>,
pub wrap: Option<i64>,
pub tilde: bool,
pub pager_stop: bool,
pub markers: Option<bool>,
pub smart_wrap: Option<bool>,
pub reflow_text: Option<bool>,
pub alternative_order: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Net {
pub connect_timeout: u64,
pub timeout: u64,
pub system_cas: bool,
pub certificate_file: Option<String>,
}
impl Default for Net {
fn default() -> Self {
Net {
connect_timeout: 10,
timeout: 30,
system_cas: true,
certificate_file: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Ui {
pub theme: Option<String>,
pub status_format: Option<String>,
pub beep: bool,
pub beep_new: bool,
pub wait_key: Option<bool>,
pub set_title: Option<bool>,
pub title_format: Option<String>,
pub history_file: Option<String>,
pub status_on_top: Option<bool>,
pub arrow_cursor: Option<bool>,
pub menu_scroll: Option<bool>,
pub menu_context: usize,
pub menu_move_off: Option<bool>,
pub help: Option<bool>,
pub sort_browser: Option<String>,
pub error_history: usize,
pub status_chars: Option<String>,
pub save_history: Option<usize>,
}
impl Default for Ui {
fn default() -> Self {
Ui {
theme: None,
status_format: None,
beep: true,
beep_new: false,
wait_key: None,
set_title: None,
title_format: None,
history_file: None,
save_history: None,
status_on_top: None,
arrow_cursor: None,
menu_scroll: None,
menu_context: 0,
menu_move_off: None,
help: None,
sort_browser: None,
error_history: 30,
status_chars: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ColorRule {
pub pattern: String,
pub fg: Option<String>,
pub bg: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Sidebar {
pub visible: bool,
pub width: u16,
}
impl Default for Sidebar {
fn default() -> Self {
Sidebar {
visible: false,
width: 24,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Keys {
pub index: HashMap<String, String>,
pub pager: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Account {
pub name: String,
pub user: String,
pub password_command: Option<String>,
pub password: Option<String>,
pub imap_host: Option<String>,
#[serde(default = "default_imap_port")]
pub imap_port: u16,
#[serde(default = "default_true")]
pub imap_tls: bool,
pub smtp_host: Option<String>,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default = "default_true")]
pub smtp_tls: bool,
pub auth: Option<String>,
pub token_command: Option<String>,
#[serde(default = "default_sent_folder")]
pub sent_folder: String,
pub identity: Option<Identity>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Pgp {
pub command: String,
pub sign_key: Option<String>,
pub sign_by_default: bool,
pub encrypt_by_default: bool,
pub reply_sign: bool,
pub reply_encrypt: bool,
pub reply_sign_encrypted: bool,
}
impl Default for Pgp {
fn default() -> Self {
Pgp {
command: "gpg".into(),
sign_key: None,
sign_by_default: false,
encrypt_by_default: false,
reply_sign: false,
reply_encrypt: false,
reply_sign_encrypted: false,
}
}
}
fn default_imap_port() -> u16 {
993
}
fn default_smtp_port() -> u16 {
587
}
fn default_true() -> bool {
true
}
fn default_sent_folder() -> String {
"Sent".into()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AuthKind {
Password,
XOAuth2,
OAuthBearer,
}
impl AuthKind {
pub fn sasl_name(self) -> &'static str {
match self {
AuthKind::Password => "PLAIN",
AuthKind::XOAuth2 => "XOAUTH2",
AuthKind::OAuthBearer => "OAUTHBEARER",
}
}
pub fn initial_response(self, user: &str, token: &str, host: &str, port: u16) -> String {
match self {
AuthKind::XOAuth2 => format!("user={user}\x01auth=Bearer {token}\x01\x01"),
AuthKind::OAuthBearer => {
format!("n,a={user},\x01host={host}\x01port={port}\x01auth=Bearer {token}\x01\x01")
}
AuthKind::Password => String::new(),
}
}
}
fn first_line_of(command: &str, what: &str, name: &str) -> Result<String> {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.output()
.with_context(|| format!("running {what} for account {name}"))?;
ensure!(
out.status.success(),
"{what} for account {name} exited with {}",
out.status
);
let secret = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or("")
.to_string();
ensure!(
!secret.is_empty(),
"{what} for account {name} printed nothing"
);
Ok(secret)
}
impl Account {
pub fn password(&self) -> Result<String> {
let Some(command) = &self.password_command else {
return self
.password
.clone()
.filter(|p| !p.is_empty())
.with_context(|| {
format!(
"account {} has neither password_command nor password",
self.name
)
});
};
first_line_of(command, "password command", &self.name)
}
pub fn auth_kind(&self) -> Result<AuthKind> {
match self.auth.as_deref() {
None | Some("password") => Ok(AuthKind::Password),
Some("xoauth2") => Ok(AuthKind::XOAuth2),
Some("oauthbearer") => Ok(AuthKind::OAuthBearer),
Some(other) => anyhow::bail!("unknown auth {other:?} for account {}", self.name),
}
}
pub fn secret(&self) -> Result<String> {
match self.auth_kind()? {
AuthKind::Password => self.password(),
_ => {
let command = self.token_command.as_deref().with_context(|| {
format!(
"account {} has auth = oauth but no token_command",
self.name
)
})?;
first_line_of(command, "token command", &self.name)
}
}
}
}
impl Config {
pub fn account(&self, name: &str) -> Option<&Account> {
self.accounts.iter().find(|a| a.name == name)
}
pub fn list_matchers(&self) -> Vec<crate::pattern::Matcher> {
self.mail
.lists
.iter()
.chain(&self.mail.subscribed)
.map(|spec| crate::pattern::Matcher::new(spec))
.collect()
}
pub fn subscribed_matchers(&self) -> Vec<crate::pattern::Matcher> {
self.mail
.subscribed
.iter()
.map(|spec| crate::pattern::Matcher::new(spec))
.collect()
}
pub fn alternate_matchers(&self) -> Vec<crate::pattern::Matcher> {
self.mail
.alternates
.iter()
.map(|spec| crate::pattern::Matcher::new(spec))
.collect()
}
pub fn identity_for(
&self,
folder: &str,
rcpts: &[String],
account: Option<&Account>,
) -> Identity {
let mut id = self.identity.clone();
let mut overlay = |name: &Option<String>, email: &Option<String>| {
if name.is_some() {
id.name = name.clone();
}
if email.is_some() {
id.email = email.clone();
}
};
if let Some(acct) = account.and_then(|a| a.identity.as_ref()) {
overlay(&acct.name, &acct.email);
}
for rule in &self.identities {
let folder_ok = rule.folder.as_deref().is_none_or(|g| glob_match(g, folder));
let recipient_ok = rule
.recipient
.as_deref()
.is_none_or(|g| rcpts.iter().any(|r| glob_match(g, r)));
if folder_ok && recipient_ok {
overlay(&rule.name, &rule.email);
}
}
id
}
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.to_lowercase().chars().collect();
let t: Vec<char> = text.to_lowercase().chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
while ti < t.len() {
if pi < p.len() && p[pi] == '*' {
star = Some((pi, ti));
pi += 1;
} else if pi < p.len() && p[pi] == t[ti] {
pi += 1;
ti += 1;
} else if let Some((sp, st)) = star {
pi = sp + 1;
ti = st + 1;
star = Some((sp, st + 1));
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub fn expand_folder(spec: &str, folder: Option<&str>) -> String {
let Some(rest) = spec.strip_prefix(['=', '+']) else {
return spec.to_string();
};
let Some(folder) = folder
.map(|f| f.trim_end_matches('/'))
.filter(|f| !f.is_empty())
else {
return spec.to_string();
};
match rest.is_empty() {
true => folder.to_string(),
false => format!("{folder}/{rest}"),
}
}
impl Config {
pub fn expand_folders(&mut self) {
let folder = self.mail.folder.clone();
let folder = folder.as_deref();
let one = |slot: &mut Option<String>| {
if let Some(v) = slot {
*v = expand_folder(v, folder);
}
};
one(&mut self.mail.sent);
one(&mut self.mail.postponed);
one(&mut self.mail.trash);
one(&mut self.mail.save);
for m in &mut self.mail.mailboxes {
*m = expand_folder(m, folder);
}
for hook in &mut self.fcc_hooks {
hook.mailbox = expand_folder(&hook.mailbox, folder);
}
}
}
pub fn path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("RMUT_CONFIG") {
return Some(PathBuf::from(p));
}
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config/rmut/config.toml"))
}
pub fn load_default() -> (Config, Option<String>) {
let Some(p) = path() else {
return (Config::default(), None);
};
let Ok(text) = std::fs::read_to_string(&p) else {
return (Config::default(), None);
};
match toml::from_str::<Config>(&text) {
Ok(cfg) => {
let warning = secret_exposed(&cfg, &p);
(cfg, warning)
}
Err(err) => {
let first = err
.to_string()
.lines()
.next()
.unwrap_or("parse error")
.to_string();
(
Config::default(),
Some(format!("config ignored ({}): {first}", p.display())),
)
}
}
}
fn secret_exposed(cfg: &Config, path: &std::path::Path) -> Option<String> {
use std::os::unix::fs::PermissionsExt;
let holds_password = cfg
.accounts
.iter()
.any(|a| a.password.as_ref().is_some_and(|p| !p.is_empty()));
if !holds_password {
return None;
}
let mode = std::fs::metadata(path).ok()?.permissions().mode();
if mode & 0o077 == 0 {
return None;
}
Some(format!(
"chmod 600 {} (it holds a password and others can read it)",
path.display()
))
}
impl Identity {
pub fn from_line(&self) -> Option<String> {
match (&self.name, &self.email) {
(Some(n), Some(e)) => Some(format!("{n} <{e}>")),
(None, Some(e)) => Some(e.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_readable_config_holding_a_password_warns() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let with_password: Config = toml::from_str(
r#"
[[accounts]]
name = "work"
user = "jane"
password = "hunter2"
"#,
)
.unwrap();
let with_command: Config = toml::from_str(
r#"
[[accounts]]
name = "work"
user = "jane"
password_command = "gpg -q -d ~/.config/rmut/imap.gpg"
"#,
)
.unwrap();
let mode =
|m: u32| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(m)).unwrap();
mode(0o644);
let warning = secret_exposed(&with_password, &path).expect("a warning");
assert!(warning.starts_with("chmod 600 "), "{warning}");
mode(0o600);
assert!(secret_exposed(&with_password, &path).is_none());
mode(0o644);
assert!(secret_exposed(&with_command, &path).is_none());
assert!(secret_exposed(&Config::default(), &path).is_none());
}
#[test]
fn folder_shorthand_expands_everywhere_a_mailbox_is_named() {
assert_eq!(expand_folder("=archive", Some("~/Mail")), "~/Mail/archive");
assert_eq!(expand_folder("+archive", Some("~/Mail/")), "~/Mail/archive");
assert_eq!(expand_folder("=", Some("~/Mail")), "~/Mail");
assert_eq!(
expand_folder("=Archive", Some("imap:work")),
"imap:work/Archive"
);
assert_eq!(expand_folder("~/other", Some("~/Mail")), "~/other");
assert_eq!(expand_folder("=archive", None), "=archive");
assert_eq!(expand_folder("=archive", Some("")), "=archive");
let mut cfg: Config = toml::from_str(
r#"
[mail]
folder = "~/Mail"
mailboxes = ["=inbox", "~/elsewhere"]
sent = "+sent"
trash = "=Trash"
[[fcc_hooks]]
pattern = "~A"
mailbox = "=work"
"#,
)
.unwrap();
cfg.expand_folders();
assert_eq!(cfg.mail.mailboxes, ["~/Mail/inbox", "~/elsewhere"]);
assert_eq!(cfg.mail.sent.as_deref(), Some("~/Mail/sent"));
assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
assert_eq!(cfg.fcc_hooks[0].mailbox, "~/Mail/work");
cfg.expand_folders();
assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
}
#[test]
fn parses_partial_config() {
let cfg: Config = toml::from_str(
r#"
[identity]
name = "Jane"
email = "jane@x"
[mail]
mailboxes = ["~/Maildir"]
sendmail = "/bin/true"
[keys.index]
sync = "w"
"#,
)
.unwrap();
assert_eq!(cfg.identity.from_line().as_deref(), Some("Jane <jane@x>"));
assert_eq!(cfg.mail.mailboxes, vec!["~/Maildir"]);
assert_eq!(cfg.mail.sendmail.as_deref(), Some("/bin/true"));
assert_eq!(cfg.keys.index.get("sync").map(String::as_str), Some("w"));
assert!(cfg.ui.theme.is_none());
}
#[test]
fn empty_and_unknown_keys_are_fine() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.identity.from_line().is_none());
let cfg: Config = toml::from_str("[future]\nx = 1\n").unwrap();
assert!(cfg.mail.mailboxes.is_empty());
assert!(cfg.accounts.is_empty());
}
#[test]
fn parses_accounts_with_defaults() {
let cfg: Config = toml::from_str(
r#"
[[accounts]]
name = "work"
user = "jane@example.com"
password_command = "pass show mail/work"
imap_host = "imap.example.com"
smtp_host = "smtp.example.com"
[[accounts]]
name = "test"
user = "u"
password_command = "true"
imap_host = "localhost"
imap_port = 10143
imap_tls = false
smtp_port = 465
sent_folder = "INBOX/Sent"
"#,
)
.unwrap();
let work = cfg.account("work").unwrap();
assert_eq!(work.imap_port, 993);
assert_eq!(work.smtp_port, 587);
assert!(work.imap_tls && work.smtp_tls);
assert_eq!(work.sent_folder, "Sent");
let test = cfg.account("test").unwrap();
assert_eq!(test.imap_port, 10143);
assert!(!test.imap_tls);
assert!(test.smtp_host.is_none());
assert_eq!(test.sent_folder, "INBOX/Sent");
assert!(cfg.account("nope").is_none());
}
#[test]
fn pgp_section_defaults_and_overrides() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.pgp.command, "gpg");
assert!(cfg.pgp.sign_key.is_none());
assert!(!cfg.pgp.sign_by_default && !cfg.pgp.encrypt_by_default);
let cfg: Config = toml::from_str(
"[pgp]\ncommand = \"gpg2\"\nsign_key = \"jane@x\"\nsign_by_default = true\n",
)
.unwrap();
assert_eq!(cfg.pgp.command, "gpg2");
assert_eq!(cfg.pgp.sign_key.as_deref(), Some("jane@x"));
assert!(cfg.pgp.sign_by_default && !cfg.pgp.encrypt_by_default);
}
#[test]
fn account_missing_required_field_fails_parse() {
assert!(toml::from_str::<Config>("[[accounts]]\nname = \"x\"\n").is_err());
}
fn test_account() -> Account {
Account {
name: "t".into(),
user: "u".into(),
password_command: None,
password: None,
imap_host: None,
imap_port: 993,
imap_tls: true,
smtp_host: None,
smtp_port: 587,
smtp_tls: true,
auth: None,
token_command: None,
sent_folder: "Sent".into(),
identity: None,
}
}
#[test]
fn glob_match_star_and_case() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*work*", "/home/jane/Maildir/work-stuff"));
assert!(glob_match("*@work.example.com", "Jane@Work.Example.Com"));
assert!(glob_match("imap:work/*", "imap:work/INBOX"));
assert!(!glob_match("*@work.example.com", "jane@example.com"));
assert!(!glob_match("work", "workplace")); assert!(glob_match("a*b*c", "aXbYc"));
assert!(!glob_match("a*b*c", "aXcYb"));
}
#[test]
fn identity_layers_like_hooks() {
let cfg: Config = toml::from_str(
r#"
[identity]
name = "Jane"
email = "jane@example.com"
reverse_name = true
[[identities]]
folder = "*work*"
email = "jane@work.example.com"
[[identities]]
recipient = "*@club.example.com"
name = "Jenny"
[[accounts]]
name = "acct"
user = "u"
imap_host = "h"
identity = { name = "Jane Acct", email = "acct@example.com" }
"#,
)
.unwrap();
assert!(cfg.identity.reverse_name);
let id = cfg.identity_for("~/Maildir", &[], None);
assert_eq!(id.from_line().as_deref(), Some("Jane <jane@example.com>"));
let id = cfg.identity_for("~/Maildir/work", &[], None);
assert_eq!(
id.from_line().as_deref(),
Some("Jane <jane@work.example.com>")
);
let rcpts = vec!["bob@club.example.com".to_string()];
let id = cfg.identity_for("~/Maildir", &rcpts, None);
assert_eq!(id.from_line().as_deref(), Some("Jenny <jane@example.com>"));
let id = cfg.identity_for("~/Maildir", &[], None);
assert_eq!(id.name.as_deref(), Some("Jane"));
let account = cfg.account("acct").unwrap();
let id = cfg.identity_for("imap:acct/INBOX", &[], Some(account));
assert_eq!(
id.from_line().as_deref(),
Some("Jane Acct <acct@example.com>")
);
let id = cfg.identity_for("imap:acct/work", &[], Some(account));
assert_eq!(
id.from_line().as_deref(),
Some("Jane Acct <jane@work.example.com>")
);
}
#[test]
fn password_command_takes_first_line() {
let account = |cmd: &str| Account {
password_command: Some(cmd.into()),
..test_account()
};
assert_eq!(
account("printf 'secret\\nrest\\n'").password().unwrap(),
"secret"
);
assert!(account("false").password().is_err());
assert!(account("true").password().is_err()); }
#[test]
fn auth_kinds_and_token_command() {
let acct = test_account();
assert_eq!(acct.auth_kind().unwrap(), AuthKind::Password);
let oauth = Account {
auth: Some("oauthbearer".into()),
token_command: Some("printf 'tok123\\nrest\\n'".into()),
..test_account()
};
assert_eq!(oauth.auth_kind().unwrap(), AuthKind::OAuthBearer);
assert_eq!(oauth.secret().unwrap(), "tok123");
let no_command = Account {
auth: Some("xoauth2".into()),
..test_account()
};
assert!(
no_command
.secret()
.unwrap_err()
.to_string()
.contains("no token_command")
);
let bad = Account {
auth: Some("kerberos".into()),
..test_account()
};
assert!(bad.auth_kind().is_err());
let explicit = Account {
auth: Some("password".into()),
password: Some("pw".into()),
..test_account()
};
assert_eq!(explicit.secret().unwrap(), "pw");
}
#[test]
fn oauth_initial_responses() {
assert_eq!(
AuthKind::XOAuth2.initial_response("jane", "tok", "imap.example.com", 993),
"user=jane\x01auth=Bearer tok\x01\x01"
);
assert_eq!(
AuthKind::OAuthBearer.initial_response("jane", "tok", "imap.example.com", 993),
"n,a=jane,\x01host=imap.example.com\x01port=993\x01auth=Bearer tok\x01\x01"
);
}
#[test]
fn stored_password_and_precedence() {
let stored = Account {
password: Some("hunter2".into()),
..test_account()
};
assert_eq!(stored.password().unwrap(), "hunter2");
let both = Account {
password_command: Some("echo from-command".into()),
password: Some("hunter2".into()),
..test_account()
};
assert_eq!(both.password().unwrap(), "from-command");
let neither = test_account();
assert!(neither.password().is_err());
let cfg: Config = toml::from_str(
"[[accounts]]\nname = \"x\"\nuser = \"u\"\npassword = \"pw\"\nimap_host = \"h\"\n",
)
.unwrap();
assert_eq!(cfg.account("x").unwrap().password().unwrap(), "pw");
}
}