//! Parsing of `imap[s]://` and `smtp[s]://` endpoint URLs. use anyhow::{anyhow, bail, Context, Result}; /// A mail server endpoint: who to log in as, where, and how TLS starts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Endpoint { pub user: String, pub host: String, pub port: u16, /// `true` for TLS-from-the-start (ports 993/465); `false` for STARTTLS. pub implicit_tls: bool, } /// Parses `imap[s]://user@host[:port]` or `smtp[s]://user@host[:port]`. /// /// The user part may itself contain `@` (e.g. `imap://me@example.com@mail.example.com`); /// the split happens at the last `@`. Plain schemes upgrade to implicit TLS when the /// port is the well-known implicit one (993/465) — STARTTLS makes no sense there. pub fn parse_mail_url(url: &str) -> Result { let (rest, default_port, mut implicit_tls) = if let Some(r) = url.strip_prefix("imaps://") { (r, 993, true) } else if let Some(r) = url.strip_prefix("imap://") { (r, 143, false) } else if let Some(r) = url.strip_prefix("smtps://") { (r, 465, true) } else if let Some(r) = url.strip_prefix("smtp://") { (r, 587, false) } else { bail!("URL must start with imap://, imaps://, smtp:// or smtps:// (got: {url})") }; let rest = rest.trim_end_matches('/'); let (user, hostport) = rest .rsplit_once('@') .ok_or_else(|| anyhow!("URL is missing the user part (user@host): {url}"))?; if user.is_empty() { bail!("URL has an empty user part: {url}"); } let (host, port) = match hostport.rsplit_once(':') { Some((h, p)) => ( h.to_string(), p.parse::() .with_context(|| format!("invalid port in URL: {url}"))?, ), None => (hostport.to_string(), default_port), }; if host.is_empty() { bail!("URL has an empty host: {url}"); } if port == 993 || port == 465 { implicit_tls = true; } Ok(Endpoint { user: user.to_string(), host, port, implicit_tls, }) } #[cfg(test)] mod tests { use super::*; #[test] fn imap_starttls_with_default_port() { let ep = parse_mail_url("imap://alice@mail.example.com").unwrap(); assert_eq!(ep.user, "alice"); assert_eq!(ep.host, "mail.example.com"); assert_eq!(ep.port, 143); assert!(!ep.implicit_tls); } #[test] fn imaps_defaults_to_993_implicit() { let ep = parse_mail_url("imaps://alice@imap.example.com").unwrap(); assert_eq!(ep.port, 993); assert!(ep.implicit_tls); } #[test] fn user_may_contain_at_sign() { let ep = parse_mail_url("imap://alice@example.com@mail.example.com:143").unwrap(); assert_eq!(ep.user, "alice@example.com"); assert_eq!(ep.host, "mail.example.com"); assert_eq!(ep.port, 143); } #[test] fn smtp_defaults_to_587_starttls() { let ep = parse_mail_url("smtp://bob@smtp.example.com").unwrap(); assert_eq!(ep.port, 587); assert!(!ep.implicit_tls); } #[test] fn smtps_defaults_to_465_implicit() { let ep = parse_mail_url("smtps://bob@smtp.example.com").unwrap(); assert_eq!(ep.port, 465); assert!(ep.implicit_tls); } #[test] fn plain_scheme_on_implicit_port_upgrades() { assert!(parse_mail_url("imap://a@h:993").unwrap().implicit_tls); assert!(parse_mail_url("smtp://a@h:465").unwrap().implicit_tls); } #[test] fn trailing_slash_is_tolerated() { let ep = parse_mail_url("imap://alice@mail.example.com/").unwrap(); assert_eq!(ep.host, "mail.example.com"); } #[test] fn rejects_unknown_scheme_and_malformed() { assert!(parse_mail_url("http://a@h").is_err()); assert!(parse_mail_url("imap://nouser").is_err()); assert!(parse_mail_url("imap://@host").is_err()); assert!(parse_mail_url("imap://user@host:notaport").is_err()); } }