aboutsummaryrefslogtreecommitdiffstats
path: root/src/mailurl.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailurl.rs')
-rw-r--r--src/mailurl.rs126
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());
+ }
+}