diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 18:47:40 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 18:47:40 -0300 |
| commit | d9738b4be2add82ff955f061c79316b0dc91768f (patch) | |
| tree | e0f31bb9f72dff315fc475f9e9f30322b69602ff /src/mailurl.rs | |
| download | posta-d9738b4be2add82ff955f061c79316b0dc91768f.tar.gz posta-d9738b4be2add82ff955f061c79316b0dc91768f.zip | |
Diffstat (limited to 'src/mailurl.rs')
| -rw-r--r-- | src/mailurl.rs | 126 |
1 files changed, 126 insertions, 0 deletions
diff --git a/src/mailurl.rs b/src/mailurl.rs new file mode 100644 index 0000000..c4285e4 --- /dev/null +++ b/src/mailurl.rs @@ -0,0 +1,126 @@ +//! 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<Endpoint> { + 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::<u16>() + .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()); + } +} |