use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::{config, settings, text};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
#[default]
Imap,
Pop3,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Security {
#[default]
Tls,
Starttls,
None,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Auth {
#[default]
Password,
Oauth2,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PasswordFrom {
#[default]
Keychain,
Ask,
Command,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Account {
pub name: String,
pub email: String,
pub display_name: String,
pub protocol: Protocol,
pub host: String,
pub port: Option<u16>,
pub security: Security,
pub username: String,
pub auth: Auth,
pub password: PasswordFrom,
pub password_command: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub oauth_client_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub oauth_client_secret: String,
pub smtp_host: String,
pub smtp_port: Option<u16>,
pub smtp_security: Security,
pub smtp_username: String,
pub save_sent: Option<bool>,
pub certificate: Option<PathBuf>,
pub signature: String,
pub leave_on_server: bool,
pub sent_folder: String,
pub drafts_folder: String,
pub trash_folder: String,
pub archive_folder: String,
pub junk_folder: String,
}
impl Default for Account {
fn default() -> Self {
Self {
name: String::new(),
email: String::new(),
display_name: String::new(),
protocol: Protocol::Imap,
host: String::new(),
port: None,
security: Security::Tls,
username: String::new(),
auth: Auth::Password,
password: PasswordFrom::Keychain,
password_command: Vec::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
smtp_host: String::new(),
smtp_port: None,
smtp_security: Security::Tls,
smtp_username: String::new(),
save_sent: None,
certificate: None,
signature: String::new(),
leave_on_server: true,
sent_folder: String::new(),
drafts_folder: String::new(),
trash_folder: String::new(),
archive_folder: String::new(),
junk_folder: String::new(),
}
}
}
impl Account {
pub fn id(&self) -> String {
self.email.trim().to_lowercase()
}
pub fn shown_name(&self) -> String {
let name = if self.name.trim().is_empty() {
&self.email
} else {
&self.name
};
text::one_line(name, 60)
}
pub fn short_name(&self) -> String {
if !self.name.trim().is_empty() {
return self.shown_name();
}
let email = self.email.trim();
let local = email.rsplit_once('@').map_or(email, |(local, _)| local);
text::one_line(local, 60)
}
pub fn username(&self) -> &str {
let name = self.username.trim();
if name.is_empty() {
self.email.trim()
} else {
name
}
}
pub fn smtp_username(&self) -> &str {
let name = self.smtp_username.trim();
if name.is_empty() {
self.username()
} else {
name
}
}
pub fn port(&self) -> u16 {
self.port.unwrap_or(match (self.protocol, self.security) {
(Protocol::Imap, Security::Tls) => 993,
(Protocol::Imap, _) => 143,
(Protocol::Pop3, Security::Tls) => 995,
(Protocol::Pop3, _) => 110,
})
}
pub fn smtp_port(&self) -> u16 {
self.smtp_port.unwrap_or(match self.smtp_security {
Security::Tls => 465,
Security::Starttls => 587,
Security::None => 25,
})
}
pub fn save_sent(&self) -> bool {
self.save_sent.unwrap_or_else(|| {
let host = self.smtp_host.to_ascii_lowercase();
!(host.ends_with("gmail.com")
|| host.ends_with("googlemail.com")
|| host.ends_with("office365.com")
|| host.ends_with("outlook.com")
|| (is_local(&host) && self.smtp_port() == 1025))
})
}
pub fn problem(&self) -> Option<String> {
if !valid_address(self.email.trim()) {
return Some("its email address isn't one".into());
}
if !valid_host(&self.host) {
return Some("its server (host) isn't a host name".into());
}
if !valid_host(&self.smtp_host) {
return Some("its sending server (smtp_host) isn't a host name".into());
}
if self.security == Security::None && !is_local(&self.host) {
return Some(format!(
"security = \"none\" is only for a server on this computer, not {}",
text::one_line(&self.host, 60)
));
}
if self.smtp_security == Security::None && !is_local(&self.smtp_host) {
return Some(format!(
"smtp_security = \"none\" is only for a server on this computer, not {}",
text::one_line(&self.smtp_host, 60)
));
}
if [self.username(), self.smtp_username()]
.iter()
.any(|n| n.chars().any(char::is_control))
{
return Some("its username has control characters".into());
}
if crate::oauth::built_in(self) {
match crate::oauth::provider_for(self) {
None => {
return Some(
"OAuth2 signs in here with Microsoft or Google only: for other servers \
set password_command"
.into(),
);
}
Some(provider) if crate::oauth::client_for(self).is_none() => {
return Some(format!(
"signing in with {} needs oauth_client_id (see OAuth2 in tuimail's README)",
provider.name()
));
}
Some(provider) => {
let smtp = self.smtp_host.trim().trim_end_matches('.').to_lowercase();
if !provider.smtp_hosts().contains(&smtp.as_str()) {
return Some(format!(
"signing in with {} sends mail through {} only, not {}",
provider.name(),
provider.smtp_hosts().join(" or "),
text::one_line(&self.smtp_host, 60)
));
}
}
}
}
let needs_command = self.password == PasswordFrom::Command
|| (self.auth == Auth::Oauth2 && !crate::oauth::built_in(self));
if needs_command {
match self.password_command.first() {
None => return Some("it needs a password_command".into()),
Some(program) if !Path::new(program).is_absolute() => {
return Some("password_command must start with the program's full path".into());
}
Some(program) if config::maybe_remote(Path::new(program)) => {
return Some("password_command's program must be on this computer".into());
}
_ => {}
}
}
if let Some(cert) = &self.certificate
&& (!cert.is_absolute() || config::maybe_remote(cert))
{
return Some("certificate must be a full path to a file on this computer".into());
}
None
}
}
pub fn is_local(host: &str) -> bool {
matches!(
host.trim().to_ascii_lowercase().as_str(),
"localhost" | "127.0.0.1" | "::1" | "[::1]"
)
}
pub fn valid_host(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']' | '_'))
}
pub fn valid_address(addr: &str) -> bool {
let Some((local, domain)) = addr.rsplit_once('@') else {
return false;
};
!local.is_empty()
&& !domain.is_empty()
&& !local.contains('@')
&& addr.len() <= 254
&& !addr.chars().any(|c| {
c.is_whitespace()
|| c.is_control()
|| text::is_hidden(c)
|| matches!(
c,
'<' | '>' | ',' | ';' | '"' | '\\' | '(' | ')' | '[' | ']' | ':'
)
})
&& !domain.starts_with('.')
&& !domain.ends_with('.')
}
#[derive(Default, Serialize, Deserialize)]
struct AccountsFile {
#[serde(default, rename = "account")]
accounts: Vec<Account>,
}
pub fn path(data_dir: &Path) -> PathBuf {
data_dir.join("accounts.toml")
}
pub fn load(path: &Path) -> Result<(Vec<Account>, Vec<String>)> {
let shown = config::shown(path);
let text = match config::read_small(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), Vec::new())),
Err(e) => return Err(e).with_context(|| format!("cannot read {shown}")),
};
let file: AccountsFile =
toml::from_str(&text).with_context(|| format!("{shown} is invalid"))?;
let mut warnings = unknown_keys(&text);
let mut accounts: Vec<Account> = Vec::new();
for account in file.accounts {
if let Some(problem) = account.problem() {
warnings.push(format!(
"the account {} can't be used: {problem}",
account.shown_name()
));
} else if accounts.iter().any(|a| a.id() == account.id()) {
warnings.push(format!(
"{} is in accounts.toml twice; the first is used",
text::one_line(&account.email, 60)
));
} else {
accounts.push(account);
}
}
Ok((accounts, warnings))
}
fn unknown_keys(text: &str) -> Vec<String> {
let known = toml::Table::try_from(Account::default())
.map(|t| t.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let Ok(table) = toml::from_str::<toml::Table>(text) else {
return Vec::new();
};
let mut unknown: Vec<String> = table
.keys()
.filter(|k| *k != "account")
.map(|k| {
format!(
"accounts.toml: tuimail doesn't know {}",
text::one_line(k, 40)
)
})
.collect();
let accounts = table.get("account").and_then(|a| a.as_array());
for account in accounts.into_iter().flatten().filter_map(|a| a.as_table()) {
for key in account.keys() {
let optional = [
"port",
"smtp_port",
"save_sent",
"certificate",
"oauth_client_id",
"oauth_client_secret",
];
if !known.contains(key) && !optional.contains(&key.as_str()) {
unknown.push(format!(
"accounts.toml: tuimail doesn't know {}",
text::one_line(key, 40)
));
}
}
}
unknown
}
pub fn save(path: &Path, account: &Account) -> Result<()> {
let shown = config::shown(path);
let mut file: AccountsFile = match config::read_small(path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("{shown} is invalid, so it wasn't changed"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AccountsFile::default(),
Err(e) => return Err(e).with_context(|| format!("cannot read {shown}")),
};
match file.accounts.iter_mut().find(|a| a.id() == account.id()) {
Some(old) => *old = account.clone(),
None => file.accounts.push(account.clone()),
}
write(path, &file)
}
pub fn remove(path: &Path, id: &str) -> Result<()> {
let shown = config::shown(path);
let text = config::read_small(path).with_context(|| format!("cannot read {shown}"))?;
let mut file: AccountsFile = toml::from_str(&text)
.with_context(|| format!("{shown} is invalid, so it wasn't changed"))?;
file.accounts.retain(|a| a.id() != id);
write(path, &file)
}
fn write(path: &Path, file: &AccountsFile) -> Result<()> {
let mut text = String::from(
"# tuimail's accounts. Passwords aren't kept here: see `password` in\n\
# tuimail's README. Edit while tuimail isn't running.\n\n",
);
text.push_str(&toml::to_string(file)?);
settings::write_private(path, &text)
.with_context(|| format!("cannot write {}", config::shown(path)))
}
#[derive(Clone, Debug, PartialEq)]
pub struct Preset {
pub imap: (&'static str, u16, Security),
pub pop3: (&'static str, u16, Security),
pub smtp: (&'static str, u16, Security),
pub note: &'static str,
}
const TLS: Security = Security::Tls;
const STARTTLS: Security = Security::Starttls;
pub fn preset(email: &str) -> Option<Preset> {
let domain = email.rsplit_once('@')?.1.trim().to_ascii_lowercase();
let p = |imap, pop3, smtp, note| {
Some(Preset {
imap,
pop3,
smtp,
note,
})
};
match domain.as_str() {
"gmail.com" | "googlemail.com" => p(
("imap.gmail.com", 993, TLS),
("pop.gmail.com", 995, TLS),
("smtp.gmail.com", 465, TLS),
"Gmail needs an app password: myaccount.google.com/apppasswords",
),
"outlook.com" | "hotmail.com" | "live.com" | "msn.com" | "outlook.de" | "hotmail.co.uk"
| "hotmail.fr" | "live.co.uk" => p(
("outlook.office365.com", 993, TLS),
("outlook.office365.com", 995, TLS),
("smtp-mail.outlook.com", 587, STARTTLS),
"Outlook.com allows only OAuth2: see OAuth2 in tuimail's README",
),
"icloud.com" | "me.com" | "mac.com" => p(
("imap.mail.me.com", 993, TLS),
("imap.mail.me.com", 993, TLS),
("smtp.mail.me.com", 587, STARTTLS),
"iCloud needs an app-specific password: account.apple.com",
),
"yahoo.com" | "ymail.com" | "rocketmail.com" | "yahoo.co.uk" | "yahoo.fr" => p(
("imap.mail.yahoo.com", 993, TLS),
("pop.mail.yahoo.com", 995, TLS),
("smtp.mail.yahoo.com", 465, TLS),
"Yahoo needs an app password: Account security → App passwords",
),
"aol.com" => p(
("imap.aol.com", 993, TLS),
("pop.aol.com", 995, TLS),
("smtp.aol.com", 465, TLS),
"AOL needs an app password: Account security → App passwords",
),
"fastmail.com" | "fastmail.fm" => p(
("imap.fastmail.com", 993, TLS),
("pop.fastmail.com", 995, TLS),
("smtp.fastmail.com", 465, TLS),
"Fastmail needs an app password: Settings → Privacy & Security",
),
"proton.me" | "protonmail.com" | "pm.me" | "protonmail.ch" => p(
("127.0.0.1", 1143, STARTTLS),
("127.0.0.1", 1143, STARTTLS),
("127.0.0.1", 1025, STARTTLS),
"Proton needs Proton Mail Bridge running; use the password it shows, \
and its exported certificate (certificate in accounts.toml)",
),
"zoho.com" | "zohomail.com" => p(
("imap.zoho.com", 993, TLS),
("pop.zoho.com", 995, TLS),
("smtp.zoho.com", 465, TLS),
"",
),
"gmx.com" | "gmx.net" | "gmx.de" => p(
("imap.gmx.net", 993, TLS),
("pop.gmx.net", 995, TLS),
("mail.gmx.net", 587, STARTTLS),
"GMX: turn on IMAP/POP3 access in GMX's settings first",
),
"web.de" => p(
("imap.web.de", 993, TLS),
("pop3.web.de", 995, TLS),
("smtp.web.de", 587, STARTTLS),
"WEB.DE: turn on IMAP/POP3 access in WEB.DE's settings first",
),
"yandex.com" | "yandex.ru" | "ya.ru" => p(
("imap.yandex.com", 993, TLS),
("pop.yandex.com", 995, TLS),
("smtp.yandex.com", 465, TLS),
"Yandex needs an app password",
),
"mail.ru" | "inbox.ru" | "list.ru" | "bk.ru" => p(
("imap.mail.ru", 993, TLS),
("pop.mail.ru", 995, TLS),
("smtp.mail.ru", 465, TLS),
"Mail.ru needs a password for external apps",
),
"posteo.de" | "posteo.net" => p(
("posteo.de", 993, TLS),
("posteo.de", 995, TLS),
("posteo.de", 465, TLS),
"",
),
"mailbox.org" => p(
("imap.mailbox.org", 993, TLS),
("pop3.mailbox.org", 995, TLS),
("smtp.mailbox.org", 465, TLS),
"",
),
"comcast.net" => p(
("imap.comcast.net", 993, TLS),
("mail.comcast.net", 995, TLS),
("smtp.comcast.net", 587, STARTTLS),
"",
),
_ => None,
}
}
pub fn guess(email: &str, protocol: Protocol) -> (Account, &'static str) {
let email = email.trim().to_string();
let domain = email
.rsplit_once('@')
.map(|(_, d)| d.to_ascii_lowercase())
.unwrap_or_default();
let (incoming, smtp, note) = match preset(&email) {
Some(p) => {
let incoming = match protocol {
Protocol::Imap => p.imap,
Protocol::Pop3 => p.pop3,
};
(
(incoming.0.to_string(), incoming.1, incoming.2),
(p.smtp.0.to_string(), p.smtp.1, p.smtp.2),
p.note,
)
}
None => {
let (prefix, port) = match protocol {
Protocol::Imap => ("imap", 993),
Protocol::Pop3 => ("pop", 995),
};
(
(format!("{prefix}.{domain}"), port, TLS),
(format!("smtp.{domain}"), 465, TLS),
"These servers are a guess: check them with your provider",
)
}
};
let oauth = matches!(
domain.as_str(),
"outlook.com" | "hotmail.com" | "live.com" | "msn.com"
);
let account = Account {
email,
protocol,
host: incoming.0,
port: Some(incoming.1),
security: incoming.2,
smtp_host: smtp.0,
smtp_port: Some(smtp.1),
smtp_security: smtp.2,
auth: if oauth { Auth::Oauth2 } else { Auth::Password },
..Account::default()
};
(account, note)
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tuimail-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn known_providers_get_their_servers_and_others_a_guess() {
let (gmail, note) = guess("me@gmail.com", Protocol::Imap);
assert_eq!(gmail.host, "imap.gmail.com");
assert_eq!((gmail.port(), gmail.security), (993, Security::Tls));
assert_eq!(gmail.smtp_host, "smtp.gmail.com");
assert!(!gmail.save_sent(), "Gmail saves sent mail itself");
assert!(note.contains("app password"));
let (pop, _) = guess("me@gmail.com", Protocol::Pop3);
assert_eq!((pop.host.as_str(), pop.port()), ("pop.gmail.com", 995));
let (other, note) = guess("me@example.org", Protocol::Imap);
assert_eq!(other.host, "imap.example.org");
assert_eq!(other.smtp_host, "smtp.example.org");
assert!(other.save_sent());
assert!(note.contains("guess"));
let (outlook, _) = guess("me@outlook.com", Protocol::Imap);
assert_eq!(outlook.auth, Auth::Oauth2);
assert_eq!(outlook.smtp_port(), 587);
}
#[test]
fn default_ports_follow_the_protocol_and_security() {
let mut a = Account {
email: "a@b.c".into(),
..Account::default()
};
assert_eq!(a.port(), 993);
a.security = Security::Starttls;
assert_eq!(a.port(), 143);
a.protocol = Protocol::Pop3;
assert_eq!(a.port(), 110);
a.security = Security::Tls;
assert_eq!(a.port(), 995);
assert_eq!(a.smtp_port(), 465);
a.smtp_security = Security::Starttls;
assert_eq!(a.smtp_port(), 587);
a.port = Some(2993);
assert_eq!(a.port(), 2993);
assert_eq!(a.username(), "a@b.c");
a.username = "alice".into();
assert_eq!((a.username(), a.smtp_username()), ("alice", "alice"));
}
#[test]
fn signing_in_needs_microsoft_or_google_and_an_app_to_sign_in_as() {
let outlook = guess("me@outlook.com", Protocol::Imap).0;
assert_eq!(outlook.auth, Auth::Oauth2);
let own = Account {
oauth_client_id: "my-app".into(),
..outlook.clone()
};
assert_eq!(own.problem(), None);
if crate::oauth::MICROSOFT_CLIENT_ID.is_empty() {
assert!(outlook.problem().unwrap().contains("oauth_client_id"));
}
let elsewhere = Account {
host: "imap.example.org".into(),
..own.clone()
};
assert!(elsewhere.problem().unwrap().contains("password_command"));
let program = if cfg!(windows) {
r"C:\Program Files\oama\oama.exe"
} else {
"/usr/bin/oama"
};
let command = Account {
password_command: vec![program.into(), "access".into()],
..elsewhere
};
assert_eq!(command.problem(), None);
let text = toml::to_string(&outlook).unwrap();
assert!(!text.contains("oauth_client"), "{text}");
assert!(
toml::to_string(&own)
.unwrap()
.contains("oauth_client_id = \"my-app\"")
);
}
#[test]
fn accounts_that_could_leak_or_inject_are_refused() {
let good = guess("me@example.org", Protocol::Imap).0;
assert_eq!(good.problem(), None);
let plain = Account {
security: Security::None,
..good.clone()
};
assert!(plain.problem().unwrap().contains("this computer"));
let local = Account {
host: "127.0.0.1".into(),
security: Security::None,
..good.clone()
};
assert_eq!(local.problem(), None);
let injected = Account {
host: "imap.example.org\r\nA1 LOGOUT".into(),
..good.clone()
};
assert!(injected.problem().is_some());
let user = Account {
username: "me\r\nA2 DELETE INBOX".into(),
..good.clone()
};
assert!(user.problem().is_some());
let command = Account {
password: PasswordFrom::Command,
password_command: vec!["pass".into(), "show".into()],
..good.clone()
};
assert!(command.problem().unwrap().contains("full path"));
let outlook = Account {
email: "me@outlook.com".into(),
host: "outlook.office365.com".into(),
smtp_host: "smtp.office365.com".into(),
auth: Auth::Oauth2,
oauth_client_id: "my-app".into(),
..good.clone()
};
assert_eq!(outlook.problem(), None);
let elsewhere = Account {
smtp_host: "smtp.example.org".into(),
..outlook
};
assert!(elsewhere.problem().unwrap().contains("smtp.office365.com"));
assert!(!valid_address("a b@c.d"));
assert!(!valid_address("<a@c.d>"));
assert!(valid_address("a.b+tag@c.d"));
for bad in [
"x\"@a.example",
"a(b)@c.d",
"a\\b@c.d",
"a@b@c.d",
"a@[1.2.3.4]",
"a:b@c.d",
] {
assert!(!valid_address(bad), "{bad}");
}
}
#[test]
fn accounts_round_trip_through_the_file_and_unknown_keys_are_reported() {
let dir = dir("accounts");
let file = path(&dir);
let _ = std::fs::remove_file(&file);
assert!(load(&file).unwrap().0.is_empty());
let mut work = guess("Me@Example.org", Protocol::Imap).0;
work.name = "Work".into();
save(&file, &work).unwrap();
let home = guess("me@gmail.com", Protocol::Pop3).0;
save(&file, &home).unwrap();
work.signature = "Me".into();
save(&file, &work).unwrap();
let (accounts, warnings) = load(&file).unwrap();
assert_eq!(accounts, [work.clone(), home.clone()], "{warnings:?}");
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(accounts[0].id(), "me@example.org");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
remove(&file, "me@gmail.com").unwrap();
assert_eq!(load(&file).unwrap().0, [work]);
let mut text = std::fs::read_to_string(&file).unwrap();
text.push_str("\n[[account]]\nemail = \"x@y.z\"\nhost = \"imap.y.z\"\nsmtp_host = \"smtp.y.z\"\npasword = \"oops\"\n");
std::fs::write(&file, text).unwrap();
let (accounts, warnings) = load(&file).unwrap();
assert_eq!(accounts.len(), 2);
assert_eq!(warnings, ["accounts.toml: tuimail doesn't know pasword"]);
std::fs::remove_dir_all(&dir).unwrap();
}
}