diff --git a/smtp-types/Cargo.toml b/smtp-types/Cargo.toml index acbc21c..7c6b771 100644 --- a/smtp-types/Cargo.toml +++ b/smtp-types/Cargo.toml @@ -1,16 +1,42 @@ [package] name = "smtp-types" -description = "Misuse-resistant SMTP types" +description = "Misuse-resistant data structures for SMTP" keywords = ["email", "smtp", "types"] +categories = ["email", "data-structures", "network-programming"] version = "0.2.0" authors = ["Damian Poddebniak "] repository = "https://github.com/duesee/smtp-codec" license = "MIT OR Apache-2.0" -edition = "2021" +rust-version.workspace = true +edition = "2024" +exclude = [ + ".github", +] [features] -default = [] +arbitrary = ["dep:arbitrary"] serde = ["dep:serde"] +# SMTP Extensions +starttls = [] +ext_auth = [] +ext_size = [] +ext_8bitmime = [] +ext_pipelining = [] +ext_smtputf8 = [] +ext_enhancedstatuscodes = [] + [dependencies] -serde = { version = "1", features = ["derive"], optional = true } +arbitrary = { version = "1.4.2", optional = true, default-features = false, features = ["derive"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +bounded-static-derive = { version = "0.8.0", default-features = false } +bounded-static = { version = "0.8.0", default-features = false, features = ["alloc"] } +serde = { version = "1.0.228", features = ["derive"], optional = true } +thiserror = "2.0.18" + +[dev-dependencies] +serde_json = { version = "1.0.149" } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/smtp-types/README.md b/smtp-types/README.md index 75182b4..0bf5444 100644 --- a/smtp-types/README.md +++ b/smtp-types/README.md @@ -1,14 +1,52 @@ -# Misuse-resistant SMTP Types +# smtp-types -This library provides types, i.e., `struct`s and `enum`s, to support [SMTP] implementations. +Misuse-resistant data structures for SMTP (RFC 5321). + +## Overview + +This crate provides types for SMTP protocol messages including: + +- **Core types**: `Domain`, `Mailbox`, `Atom`, `Text`, `Parameter` +- **Commands**: `Command` enum with all RFC 5321 commands (EHLO, HELO, MAIL, RCPT, DATA, etc.) +- **Responses**: `Response`, `Greeting`, `EhloResponse`, `ReplyCode` +- **Authentication**: `AuthMechanism`, `AuthenticateData` (with `ext_auth` feature) ## Features -* Rust's type system is used to enforce correctness and make the library misuse-resistant. -It must not be possible to construct a type that violates the SMTP specification. +| Feature | Description | +|-------------------------|------------------------------------------| +| `starttls` | STARTTLS command support | +| `ext_auth` | SMTP Authentication (RFC 4954) | +| `ext_size` | Message Size Declaration (RFC 1870) | +| `ext_8bitmime` | 8-bit MIME Transport (RFC 6152) | +| `ext_pipelining` | Command Pipelining (RFC 2920) | +| `ext_smtputf8` | Internationalized Email (RFC 6531) | +| `ext_enhancedstatuscodes` | Enhanced Error Codes (RFC 2034) | +| `arbitrary` | Derive `Arbitrary` for fuzzing | +| `serde` | Derive `Serialize`/`Deserialize` | + +## Usage + +```rust +use smtp_types::{ + command::Command, + core::{Domain, ForwardPath, LocalPart, Mailbox, ReversePath}, +}; + +// Create an EHLO command +let domain = Domain::try_from("client.example.com").unwrap(); +let cmd = Command::ehlo(domain); + +// Create a MAIL FROM command +let cmd = Command::mail(ReversePath::Null); -# License +// Create a RCPT TO command +let local = LocalPart::try_from("user").unwrap(); +let domain = Domain::try_from("example.com").unwrap(); +let mailbox = Mailbox::new(local, domain.into()); +let cmd = Command::rcpt(ForwardPath::from(mailbox)); +``` -This crate is dual-licensed under Apache 2.0 and MIT terms. +## License -[SMTP]: https://www.rfc-editor.org/rfc/rfc5321 +Licensed under either of Apache License, Version 2.0 or MIT license at your option. diff --git a/smtp-types/src/auth.rs b/smtp-types/src/auth.rs new file mode 100644 index 0000000..278c1ed --- /dev/null +++ b/smtp-types/src/auth.rs @@ -0,0 +1,245 @@ +//! Authentication-related types for SMTP AUTH command. + +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, + str::FromStr, +}; + +#[cfg(feature = "arbitrary")] +use arbitrary::{Arbitrary, Unstructured}; +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::{ + core::{Atom, impl_try_from}, + error::ValidationError, + secret::Secret, +}; + +/// Authentication mechanism for SMTP AUTH. +/// +/// # Reference +/// +/// RFC 4954: SMTP Service Extension for Authentication +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +#[non_exhaustive] +pub enum AuthMechanism<'a> { + /// The PLAIN SASL mechanism. + /// + /// ```text + /// base64(b"\x00\x00") + /// ``` + /// + /// # Reference + /// + /// RFC 4616: The PLAIN Simple Authentication and Security Layer (SASL) Mechanism + Plain, + + /// The (non-standardized) LOGIN SASL mechanism. + /// + /// ```text + /// base64(b"") + /// base64(b"") + /// ``` + /// + /// # Reference + /// + /// draft-murchison-sasl-login-00: The LOGIN SASL Mechanism + Login, + + /// OAuth 2.0 bearer token mechanism. + /// + /// # Reference + /// + /// RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth + OAuthBearer, + + /// Google's OAuth 2.0 mechanism. + /// + /// ```text + /// base64(b"user=\x01auth=Bearer \x01\x01") + /// ``` + XOAuth2, + + /// SCRAM-SHA-1 + /// + /// # Reference + /// + /// RFC 5802: Salted Challenge Response Authentication Mechanism (SCRAM) + ScramSha1, + + /// SCRAM-SHA-1-PLUS + /// + /// # Reference + /// + /// RFC 5802: Salted Challenge Response Authentication Mechanism (SCRAM) + ScramSha1Plus, + + /// SCRAM-SHA-256 + /// + /// # Reference + /// + /// RFC 7677: SCRAM-SHA-256 and SCRAM-SHA-256-PLUS SASL Mechanisms + ScramSha256, + + /// SCRAM-SHA-256-PLUS + /// + /// # Reference + /// + /// RFC 7677: SCRAM-SHA-256 and SCRAM-SHA-256-PLUS SASL Mechanisms + ScramSha256Plus, + + /// SCRAM-SHA3-512 + ScramSha3_512, + + /// SCRAM-SHA3-512-PLUS + ScramSha3_512Plus, + + /// CRAM-MD5 (legacy mechanism) + /// + /// # Reference + /// + /// RFC 2195: IMAP/POP AUTHorize Extension for Simple Challenge/Response + CramMd5, + + /// Some other (unknown) mechanism. + Other(AuthMechanismOther<'a>), +} + +impl_try_from!(Atom<'a>, 'a, &'a [u8], AuthMechanism<'a>); +impl_try_from!(Atom<'a>, 'a, Vec, AuthMechanism<'a>); +impl_try_from!(Atom<'a>, 'a, &'a str, AuthMechanism<'a>); +impl_try_from!(Atom<'a>, 'a, String, AuthMechanism<'a>); +impl_try_from!(Atom<'a>, 'a, Cow<'a, str>, AuthMechanism<'a>); + +impl<'a> From> for AuthMechanism<'a> { + fn from(atom: Atom<'a>) -> Self { + match atom.as_ref().to_ascii_uppercase().as_str() { + "PLAIN" => Self::Plain, + "LOGIN" => Self::Login, + "OAUTHBEARER" => Self::OAuthBearer, + "XOAUTH2" => Self::XOAuth2, + "SCRAM-SHA-1" => Self::ScramSha1, + "SCRAM-SHA-1-PLUS" => Self::ScramSha1Plus, + "SCRAM-SHA-256" => Self::ScramSha256, + "SCRAM-SHA-256-PLUS" => Self::ScramSha256Plus, + "SCRAM-SHA3-512" => Self::ScramSha3_512, + "SCRAM-SHA3-512-PLUS" => Self::ScramSha3_512Plus, + "CRAM-MD5" => Self::CramMd5, + _ => Self::Other(AuthMechanismOther(atom)), + } + } +} + +impl Display for AuthMechanism<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_ref()) + } +} + +impl AsRef for AuthMechanism<'_> { + fn as_ref(&self) -> &str { + match self { + Self::Plain => "PLAIN", + Self::Login => "LOGIN", + Self::OAuthBearer => "OAUTHBEARER", + Self::XOAuth2 => "XOAUTH2", + Self::ScramSha1 => "SCRAM-SHA-1", + Self::ScramSha1Plus => "SCRAM-SHA-1-PLUS", + Self::ScramSha256 => "SCRAM-SHA-256", + Self::ScramSha256Plus => "SCRAM-SHA-256-PLUS", + Self::ScramSha3_512 => "SCRAM-SHA3-512", + Self::ScramSha3_512Plus => "SCRAM-SHA3-512-PLUS", + Self::CramMd5 => "CRAM-MD5", + Self::Other(other) => other.0.as_ref(), + } + } +} + +impl FromStr for AuthMechanism<'static> { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + AuthMechanism::try_from(s.to_string()) + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for AuthMechanism<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let variant: u8 = u.int_in_range(0..=10)?; + Ok(match variant { + 0 => AuthMechanism::Plain, + 1 => AuthMechanism::Login, + 2 => AuthMechanism::OAuthBearer, + 3 => AuthMechanism::XOAuth2, + 4 => AuthMechanism::ScramSha1, + 5 => AuthMechanism::ScramSha1Plus, + 6 => AuthMechanism::ScramSha256, + 7 => AuthMechanism::ScramSha256Plus, + 8 => AuthMechanism::ScramSha3_512, + 9 => AuthMechanism::ScramSha3_512Plus, + _ => AuthMechanism::CramMd5, + }) + } +} + +/// An (unknown) authentication mechanism. +/// +/// It's guaranteed that this type can't represent any known mechanism from [`AuthMechanism`]. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct AuthMechanismOther<'a>(pub(crate) Atom<'a>); + +/// Data line used during SMTP AUTH exchange. +/// +/// Holds the raw binary data, i.e., a `Vec`, *not* the BASE64 string. +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub enum AuthenticateData<'a> { + /// Continue SASL authentication with response data. + Continue(Secret>), + /// Cancel SASL authentication. + /// + /// The client sends a single "*" to cancel the authentication exchange. + Cancel, +} + +impl<'a> AuthenticateData<'a> { + /// Create a continuation response with the given data. + pub fn r#continue(data: D) -> Self + where + D: Into>, + { + Self::Continue(Secret::new(data.into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_conversion() { + assert!(AuthMechanism::try_from("plain").is_ok()); + assert!(AuthMechanism::try_from("login").is_ok()); + assert!(AuthMechanism::try_from("oauthbearer").is_ok()); + assert!(AuthMechanism::try_from("xoauth2").is_ok()); + assert!(AuthMechanism::try_from("cram-md5").is_ok()); + assert!(AuthMechanism::try_from("xxxplain").is_ok()); + assert!(AuthMechanism::try_from("xxxlogin").is_ok()); + } + + #[test] + fn test_display() { + assert_eq!(AuthMechanism::Plain.to_string(), "PLAIN"); + assert_eq!(AuthMechanism::Login.to_string(), "LOGIN"); + assert_eq!(AuthMechanism::CramMd5.to_string(), "CRAM-MD5"); + } +} diff --git a/smtp-types/src/command.rs b/smtp-types/src/command.rs new file mode 100644 index 0000000..a94013f --- /dev/null +++ b/smtp-types/src/command.rs @@ -0,0 +1,442 @@ +//! SMTP command types. +//! +//! This module defines the commands that an SMTP client sends to a server +//! as specified in RFC 5321. + +use std::borrow::Cow; + +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::core::{Domain, EhloDomain, ForwardPath, Parameter, ReversePath}; +#[cfg(feature = "ext_auth")] +use crate::{auth::AuthMechanism, secret::Secret}; + +/// An SMTP command. +/// +/// # Reference +/// +/// RFC 5321 Section 4.1: SMTP Commands +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +#[non_exhaustive] +pub enum Command<'a> { + /// Extended HELLO - identifies the client and requests extended features. + /// + /// # ABNF + /// + /// ```abnf + /// ehlo = "EHLO" SP ( Domain / address-literal ) CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.1 + Ehlo { + /// The client's domain or address literal + domain: EhloDomain<'a>, + }, + + /// HELLO - identifies the client to the server (legacy). + /// + /// # ABNF + /// + /// ```abnf + /// helo = "HELO" SP Domain CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.1 + Helo { + /// The client's domain + domain: Domain<'a>, + }, + + /// MAIL FROM - initiates a mail transaction with the sender's address. + /// + /// # ABNF + /// + /// ```abnf + /// mail = "MAIL FROM:" Reverse-path [SP Mail-parameters] CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.2 + Mail { + /// The sender's reverse path (can be null <>) + reverse_path: ReversePath<'a>, + /// Optional ESMTP parameters (e.g., SIZE, BODY) + parameters: Vec>, + }, + + /// RCPT TO - specifies a recipient for the mail. + /// + /// # ABNF + /// + /// ```abnf + /// rcpt = "RCPT TO:" ( "" / "" / + /// Forward-path ) [SP Rcpt-parameters] CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.3 + Rcpt { + /// The recipient's forward path + forward_path: ForwardPath<'a>, + /// Optional ESMTP parameters + parameters: Vec>, + }, + + /// DATA - begins the mail data transfer. + /// + /// After this command, the client sends the message content, + /// terminated by `.`. + /// + /// # ABNF + /// + /// ```abnf + /// data = "DATA" CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.4 + Data, + + /// RSET - aborts the current mail transaction. + /// + /// # ABNF + /// + /// ```abnf + /// rset = "RSET" CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.5 + Rset, + + /// QUIT - requests connection termination. + /// + /// # ABNF + /// + /// ```abnf + /// quit = "QUIT" CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.10 + Quit, + + /// NOOP - no operation (used to keep connection alive). + /// + /// # ABNF + /// + /// ```abnf + /// noop = "NOOP" [ SP String ] CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.9 + Noop { + /// Optional string argument (ignored by server) + string: Option>, + }, + + /// VRFY - verifies a user or mailbox name. + /// + /// # ABNF + /// + /// ```abnf + /// vrfy = "VRFY" SP String CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.6 + Vrfy { + /// The string to verify (usually a user name or address) + string: Cow<'a, str>, + }, + + /// EXPN - expands a mailing list. + /// + /// # ABNF + /// + /// ```abnf + /// expn = "EXPN" SP String CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.7 + Expn { + /// The mailing list name to expand + string: Cow<'a, str>, + }, + + /// HELP - requests help information. + /// + /// # ABNF + /// + /// ```abnf + /// help = "HELP" [ SP String ] CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 5321 Section 4.1.1.8 + Help { + /// Optional topic for specific help + topic: Option>, + }, + + /// STARTTLS - initiates TLS encryption. + /// + /// # Reference + /// + /// RFC 3207: SMTP Service Extension for Secure SMTP over Transport Layer Security + #[cfg(feature = "starttls")] + StartTls, + + /// AUTH - initiates SASL authentication. + /// + /// # ABNF + /// + /// ```abnf + /// auth = "AUTH" SP sasl-mech [SP initial-response] CRLF + /// ``` + /// + /// # Reference + /// + /// RFC 4954: SMTP Service Extension for Authentication + #[cfg(feature = "ext_auth")] + Auth { + /// The SASL mechanism to use + mechanism: AuthMechanism<'a>, + /// Optional initial response (base64-encoded by encoder) + initial_response: Option>>, + }, +} + +impl<'a> Command<'a> { + /// Creates an EHLO command. + pub fn ehlo(domain: impl Into>) -> Self { + Command::Ehlo { + domain: domain.into(), + } + } + + /// Creates a HELO command. + pub fn helo(domain: Domain<'a>) -> Self { + Command::Helo { domain } + } + + /// Creates a MAIL FROM command with no parameters. + pub fn mail(reverse_path: ReversePath<'a>) -> Self { + Command::Mail { + reverse_path, + parameters: Vec::new(), + } + } + + /// Creates a MAIL FROM command with parameters. + pub fn mail_with_params(reverse_path: ReversePath<'a>, parameters: Vec>) -> Self { + Command::Mail { + reverse_path, + parameters, + } + } + + /// Creates a RCPT TO command with no parameters. + pub fn rcpt(forward_path: ForwardPath<'a>) -> Self { + Command::Rcpt { + forward_path, + parameters: Vec::new(), + } + } + + /// Creates a RCPT TO command with parameters. + pub fn rcpt_with_params(forward_path: ForwardPath<'a>, parameters: Vec>) -> Self { + Command::Rcpt { + forward_path, + parameters, + } + } + + /// Creates a DATA command. + pub fn data() -> Self { + Command::Data + } + + /// Creates a RSET command. + pub fn rset() -> Self { + Command::Rset + } + + /// Creates a QUIT command. + pub fn quit() -> Self { + Command::Quit + } + + /// Creates a NOOP command with no argument. + pub fn noop() -> Self { + Command::Noop { string: None } + } + + /// Creates a NOOP command with an argument. + pub fn noop_with_string(string: impl Into>) -> Self { + Command::Noop { + string: Some(string.into()), + } + } + + /// Creates a VRFY command. + pub fn vrfy(string: impl Into>) -> Self { + Command::Vrfy { + string: string.into(), + } + } + + /// Creates an EXPN command. + pub fn expn(string: impl Into>) -> Self { + Command::Expn { + string: string.into(), + } + } + + /// Creates a HELP command with no topic. + pub fn help() -> Self { + Command::Help { topic: None } + } + + /// Creates a HELP command with a specific topic. + pub fn help_with_topic(topic: impl Into>) -> Self { + Command::Help { + topic: Some(topic.into()), + } + } + + /// Creates a STARTTLS command. + #[cfg(feature = "starttls")] + pub fn starttls() -> Self { + Command::StartTls + } + + /// Creates an AUTH command with no initial response. + #[cfg(feature = "ext_auth")] + pub fn auth(mechanism: AuthMechanism<'a>) -> Self { + Command::Auth { + mechanism, + initial_response: None, + } + } + + /// Creates an AUTH command with an initial response. + #[cfg(feature = "ext_auth")] + pub fn auth_with_initial_response( + mechanism: AuthMechanism<'a>, + initial_response: impl Into>, + ) -> Self { + Command::Auth { + mechanism, + initial_response: Some(Secret::new(initial_response.into())), + } + } + + /// Returns the command name as a string. + pub fn name(&self) -> &'static str { + match self { + Command::Ehlo { .. } => "EHLO", + Command::Helo { .. } => "HELO", + Command::Mail { .. } => "MAIL", + Command::Rcpt { .. } => "RCPT", + Command::Data => "DATA", + Command::Rset => "RSET", + Command::Quit => "QUIT", + Command::Noop { .. } => "NOOP", + Command::Vrfy { .. } => "VRFY", + Command::Expn { .. } => "EXPN", + Command::Help { .. } => "HELP", + #[cfg(feature = "starttls")] + Command::StartTls => "STARTTLS", + #[cfg(feature = "ext_auth")] + Command::Auth { .. } => "AUTH", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{LocalPart, Mailbox}; + + #[test] + fn test_command_creation() { + // EHLO + let domain = Domain::try_from("example.com").unwrap(); + let cmd = Command::ehlo(domain); + assert!(matches!(cmd, Command::Ehlo { .. })); + assert_eq!(cmd.name(), "EHLO"); + + // MAIL FROM with null path + let cmd = Command::mail(ReversePath::Null); + assert!(matches!( + cmd, + Command::Mail { + reverse_path: ReversePath::Null, + .. + } + )); + assert_eq!(cmd.name(), "MAIL"); + + // RCPT TO + let local = LocalPart::try_from("user").unwrap(); + let domain = Domain::try_from("example.com").unwrap(); + let mailbox = Mailbox::new(local, domain.into()); + let cmd = Command::rcpt(ForwardPath::from(mailbox)); + assert!(matches!(cmd, Command::Rcpt { .. })); + assert_eq!(cmd.name(), "RCPT"); + + // Simple commands + assert!(matches!(Command::data(), Command::Data)); + assert_eq!(Command::data().name(), "DATA"); + assert!(matches!(Command::rset(), Command::Rset)); + assert_eq!(Command::rset().name(), "RSET"); + assert!(matches!(Command::quit(), Command::Quit)); + assert_eq!(Command::quit().name(), "QUIT"); + assert!(matches!(Command::noop(), Command::Noop { string: None })); + assert_eq!(Command::noop().name(), "NOOP"); + assert!(matches!(Command::help(), Command::Help { topic: None })); + assert_eq!(Command::help().name(), "HELP"); + } + + #[test] + fn test_noop_with_string() { + let cmd = Command::noop_with_string("test"); + match cmd { + Command::Noop { string } => { + assert_eq!(string, Some(Cow::Borrowed("test"))); + } + _ => panic!("Expected Noop command"), + } + } + + #[test] + fn test_vrfy_expn() { + let cmd = Command::vrfy("postmaster"); + assert!(matches!(cmd, Command::Vrfy { .. })); + assert_eq!(cmd.name(), "VRFY"); + + let cmd = Command::expn("users"); + assert!(matches!(cmd, Command::Expn { .. })); + assert_eq!(cmd.name(), "EXPN"); + } +} diff --git a/smtp-types/src/core.rs b/smtp-types/src/core.rs new file mode 100644 index 0000000..e01b2b3 --- /dev/null +++ b/smtp-types/src/core.rs @@ -0,0 +1,1059 @@ +//! Core SMTP data types. +//! +//! This module provides the fundamental types used in SMTP protocol messages, +//! including domain names, email addresses, and text types. + +use std::{ + borrow::Cow, + fmt::{Debug, Display, Formatter}, + net::{Ipv4Addr, Ipv6Addr}, + str::from_utf8, + vec::IntoIter, +}; + +#[cfg(feature = "arbitrary")] +use arbitrary::{Arbitrary, Unstructured}; +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{ValidationError, ValidationErrorKind}, + utils::indicators::{is_atext, is_qtext, is_text_char}, +}; + +#[cfg(feature = "arbitrary")] +fn arbitrary_alphanum(u: &mut Unstructured) -> arbitrary::Result { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let idx = u.choose_index(CHARS.len())?; + Ok(CHARS[idx] as char) +} + +#[cfg(feature = "arbitrary")] +fn arbitrary_atext(u: &mut Unstructured) -> arbitrary::Result { + const CHARS: &[u8] = + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-/=?^_`{|}~"; + let idx = u.choose_index(CHARS.len())?; + Ok(CHARS[idx] as char) +} + +#[cfg(feature = "arbitrary")] +fn arbitrary_text_char(u: &mut Unstructured) -> arbitrary::Result { + // Printable ASCII (32-126) plus tab (9) + let c: u8 = u.int_in_range(32..=126)?; + Ok(c as char) +} + +#[cfg(feature = "ext_auth")] +macro_rules! impl_try_from { + ($via:ty, $lifetime:lifetime, $from:ty, $target:ty) => { + impl<$lifetime> TryFrom<$from> for $target { + type Error = <$via as TryFrom<$from>>::Error; + + fn try_from(value: $from) -> Result { + let value = <$via>::try_from(value)?; + + Ok(Self::from(value)) + } + } + }; +} + +#[cfg(feature = "ext_auth")] +pub(crate) use impl_try_from; + +/// A domain name (hostname). +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Domain = sub-domain *("." sub-domain) +/// sub-domain = Let-dig [Ldh-str] +/// Let-dig = ALPHA / DIGIT +/// Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String"))] +#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, ToStatic)] +pub struct Domain<'a>(pub(crate) Cow<'a, str>); + +impl Debug for Domain<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "Domain({:?})", self.0) + } +} + +impl Display for Domain<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'a> Domain<'a> { + /// Validates if value conforms to domain's ABNF definition. + pub fn validate(value: impl AsRef<[u8]>) -> Result<(), ValidationError> { + let value = value.as_ref(); + + if value.is_empty() { + return Err(ValidationError::new(ValidationErrorKind::Empty)); + } + + // Check it's valid UTF-8 and contains only valid domain characters + let s = from_utf8(value).map_err(|_| ValidationError::new(ValidationErrorKind::Invalid))?; + + // Check each subdomain + for subdomain in s.split('.') { + if subdomain.is_empty() { + return Err(ValidationError::new(ValidationErrorKind::Invalid)); + } + + let bytes = subdomain.as_bytes(); + + // First char must be alphanumeric + if !bytes[0].is_ascii_alphanumeric() { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: bytes[0], + at: 0, + })); + } + + // Last char must be alphanumeric + if bytes.len() > 1 && !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: bytes[bytes.len() - 1], + at: bytes.len() - 1, + })); + } + + // Middle chars can be alphanumeric or hyphen + for (i, &b) in bytes.iter().enumerate() { + if !b.is_ascii_alphanumeric() && b != b'-' { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: b, + at: i, + })); + } + } + } + + Ok(()) + } + + /// Returns a reference to the inner value. + pub fn inner(&self) -> &str { + self.0.as_ref() + } + + /// Consumes the domain, returning the inner value. + pub fn into_inner(self) -> Cow<'a, str> { + self.0 + } + + /// Constructs a domain without validation. + /// + /// # Warning: SMTP conformance + /// + /// The caller must ensure that `inner` is valid according to [`Self::validate`]. + /// Failing to do so may create invalid/unparsable SMTP messages. + /// Do not call this constructor with untrusted data. + /// + /// Note: This method will `panic!` on wrong input in debug builds. + pub fn unvalidated(inner: C) -> Self + where + C: Into>, + { + let inner = inner.into(); + + #[cfg(debug_assertions)] + Self::validate(inner.as_bytes()).unwrap(); + + Self(inner) + } +} + +impl<'a> TryFrom<&'a [u8]> for Domain<'a> { + type Error = ValidationError; + + fn try_from(value: &'a [u8]) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(from_utf8(value).unwrap()))) + } +} + +impl TryFrom> for Domain<'_> { + type Error = ValidationError; + + fn try_from(value: Vec) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(String::from_utf8(value).unwrap()))) + } +} + +impl<'a> TryFrom<&'a str> for Domain<'a> { + type Error = ValidationError; + + fn try_from(value: &'a str) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(value))) + } +} + +impl TryFrom for Domain<'_> { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Domain(Cow::Owned(value))) + } +} + +impl AsRef for Domain<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Domain<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + // Generate a valid domain name + let labels: u8 = u.int_in_range(1..=3)?; + let mut parts = Vec::new(); + for _ in 0..labels { + let len: usize = u.int_in_range(1..=10)?; + let mut label = String::with_capacity(len); + // First char must be alphanumeric + label.push(arbitrary_alphanum(u)?); + // Middle chars can be alphanumeric or hyphen + for _ in 1..len.saturating_sub(1) { + let c = if u.ratio(1, 4)? { + '-' + } else { + arbitrary_alphanum(u)? + }; + label.push(c); + } + // Last char must be alphanumeric (if len > 1) + if len > 1 { + label.push(arbitrary_alphanum(u)?); + } + parts.push(label); + } + Ok(Domain(Cow::Owned(parts.join(".")))) + } +} + +/// An address literal, either IPv4, IPv6, or a general address. +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// address-literal = "[" ( IPv4-address-literal / +/// IPv6-address-literal / +/// General-address-literal ) "]" +/// IPv4-address-literal = Snum 3("." Snum) +/// IPv6-address-literal = "IPv6:" IPv6-addr +/// General-address-literal = Standardized-tag ":" 1*dcontent +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AddressLiteral<'a> { + /// IPv4 address, e.g., `[192.168.1.1]` + IPv4(Ipv4Addr), + /// IPv6 address, e.g., `[IPv6:2001:db8::1]` + IPv6(Ipv6Addr), + /// General address literal, e.g., `[tag:content]` + General { + tag: Atom<'a>, + content: Cow<'a, str>, + }, +} + +impl bounded_static::ToBoundedStatic for AddressLiteral<'_> { + type Static = AddressLiteral<'static>; + + fn to_static(&self) -> Self::Static { + match self { + AddressLiteral::IPv4(addr) => AddressLiteral::IPv4(*addr), + AddressLiteral::IPv6(addr) => AddressLiteral::IPv6(*addr), + AddressLiteral::General { tag, content } => AddressLiteral::General { + tag: tag.to_static(), + content: Cow::Owned(content.clone().into_owned()), + }, + } + } +} + +impl bounded_static::IntoBoundedStatic for AddressLiteral<'_> { + type Static = AddressLiteral<'static>; + + fn into_static(self) -> Self::Static { + match self { + AddressLiteral::IPv4(addr) => AddressLiteral::IPv4(addr), + AddressLiteral::IPv6(addr) => AddressLiteral::IPv6(addr), + AddressLiteral::General { tag, content } => AddressLiteral::General { + tag: tag.into_static(), + content: Cow::Owned(content.into_owned()), + }, + } + } +} + +impl Display for AddressLiteral<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + AddressLiteral::IPv4(addr) => write!(f, "[{addr}]"), + AddressLiteral::IPv6(addr) => write!(f, "[IPv6:{addr}]"), + AddressLiteral::General { tag, content } => write!(f, "[{tag}:{content}]"), + } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for AddressLiteral<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let variant: u8 = u.int_in_range(0..=1)?; + match variant { + 0 => Ok(AddressLiteral::IPv4(Ipv4Addr::arbitrary(u)?)), + _ => Ok(AddressLiteral::IPv6(Ipv6Addr::arbitrary(u)?)), + } + } +} + +/// The domain identifier used in EHLO/HELO commands. +/// +/// Can be either a domain name or an address literal. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub enum EhloDomain<'a> { + /// A domain name + Domain(Domain<'a>), + /// An address literal (IPv4, IPv6, or general) + AddressLiteral(AddressLiteral<'a>), +} + +impl Display for EhloDomain<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + EhloDomain::Domain(domain) => write!(f, "{domain}"), + EhloDomain::AddressLiteral(addr) => write!(f, "{addr}"), + } + } +} + +impl<'a> From> for EhloDomain<'a> { + fn from(domain: Domain<'a>) -> Self { + EhloDomain::Domain(domain) + } +} + +impl<'a> From> for EhloDomain<'a> { + fn from(addr: AddressLiteral<'a>) -> Self { + EhloDomain::AddressLiteral(addr) + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for EhloDomain<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + if u.ratio(3, 4)? { + Ok(EhloDomain::Domain(Domain::arbitrary(u)?)) + } else { + Ok(EhloDomain::AddressLiteral(AddressLiteral::arbitrary(u)?)) + } + } +} + +/// The local part of an email address (before the @). +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Local-part = Dot-string / Quoted-string +/// Dot-string = Atom *("." Atom) +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String"))] +#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, ToStatic)] +pub struct LocalPart<'a>(pub(crate) Cow<'a, str>); + +impl Debug for LocalPart<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "LocalPart({:?})", self.0) + } +} + +impl Display for LocalPart<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'a> LocalPart<'a> { + /// Validates if value conforms to local-part's ABNF definition. + pub fn validate(value: impl AsRef<[u8]>) -> Result<(), ValidationError> { + let value = value.as_ref(); + + if value.is_empty() { + return Err(ValidationError::new(ValidationErrorKind::Empty)); + } + + // Check for valid UTF-8 + let _ = from_utf8(value).map_err(|_| ValidationError::new(ValidationErrorKind::Invalid))?; + + // Check each character is valid for local-part (simplified check) + for (i, &b) in value.iter().enumerate() { + if !is_atext(b) && b != b'.' && !is_qtext(b) { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: b, + at: i, + })); + } + } + + Ok(()) + } + + /// Returns a reference to the inner value. + pub fn inner(&self) -> &str { + self.0.as_ref() + } + + /// Consumes the local part, returning the inner value. + pub fn into_inner(self) -> Cow<'a, str> { + self.0 + } + + /// Constructs a local part without validation. + pub fn unvalidated(inner: C) -> Self + where + C: Into>, + { + let inner = inner.into(); + + #[cfg(debug_assertions)] + Self::validate(inner.as_bytes()).unwrap(); + + Self(inner) + } +} + +impl<'a> TryFrom<&'a [u8]> for LocalPart<'a> { + type Error = ValidationError; + + fn try_from(value: &'a [u8]) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(from_utf8(value).unwrap()))) + } +} + +impl TryFrom> for LocalPart<'_> { + type Error = ValidationError; + + fn try_from(value: Vec) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(String::from_utf8(value).unwrap()))) + } +} + +impl<'a> TryFrom<&'a str> for LocalPart<'a> { + type Error = ValidationError; + + fn try_from(value: &'a str) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(value))) + } +} + +impl TryFrom for LocalPart<'_> { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(value))) + } +} + +impl AsRef for LocalPart<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for LocalPart<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let len: usize = u.int_in_range(1..=10)?; + let mut s = String::with_capacity(len); + for _ in 0..len { + s.push(arbitrary_atext(u)?); + } + Ok(LocalPart(Cow::Owned(s))) + } +} + +/// A full email address: local-part@domain. +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Mailbox = Local-part "@" ( Domain / address-literal ) +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct Mailbox<'a> { + /// The local part (before @) + pub local_part: LocalPart<'a>, + /// The domain (after @) + pub domain: EhloDomain<'a>, +} + +impl Display for Mailbox<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}@{}", self.local_part, self.domain) + } +} + +impl<'a> Mailbox<'a> { + /// Creates a new mailbox. + pub fn new(local_part: LocalPart<'a>, domain: EhloDomain<'a>) -> Self { + Self { local_part, domain } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Mailbox<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(Mailbox { + local_part: LocalPart::arbitrary(u)?, + domain: EhloDomain::arbitrary(u)?, + }) + } +} + +/// The reverse path for MAIL FROM (can be null <>). +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Reverse-path = Path / "<>" +/// Path = "<" [ A-d-l ":" ] Mailbox ">" +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic, Default)] +pub enum ReversePath<'a> { + /// Null reverse path (<>) + #[default] + Null, + /// A mailbox address + Mailbox(Mailbox<'a>), +} + +impl Display for ReversePath<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + ReversePath::Null => write!(f, "<>"), + ReversePath::Mailbox(mailbox) => write!(f, "<{mailbox}>"), + } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for ReversePath<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + if u.ratio(1, 4)? { + Ok(ReversePath::Null) + } else { + Ok(ReversePath::Mailbox(Mailbox::arbitrary(u)?)) + } + } +} + +/// The forward path for RCPT TO. +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Forward-path = Path +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct ForwardPath<'a>(pub Mailbox<'a>); + +impl Display for ForwardPath<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "<{}>", self.0) + } +} + +impl<'a> From> for ForwardPath<'a> { + fn from(mailbox: Mailbox<'a>) -> Self { + ForwardPath(mailbox) + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for ForwardPath<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(ForwardPath(Mailbox::arbitrary(u)?)) + } +} + +/// An SMTP atom (similar to IMAP but different character rules). +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Atom = 1*atext +/// atext = ALPHA / DIGIT / +/// "!" / "#" / "$" / "%" / "&" / "'" / "*" / +/// "+" / "-" / "/" / "=" / "?" / "^" / "_" / +/// "`" / "{" / "|" / "}" / "~" +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String"))] +#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, ToStatic)] +pub struct Atom<'a>(pub(crate) Cow<'a, str>); + +impl Debug for Atom<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "Atom({:?})", self.0) + } +} + +impl Display for Atom<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'a> Atom<'a> { + /// Validates if value conforms to atom's ABNF definition. + pub fn validate(value: impl AsRef<[u8]>) -> Result<(), ValidationError> { + let value = value.as_ref(); + + if value.is_empty() { + return Err(ValidationError::new(ValidationErrorKind::Empty)); + } + + if let Some(at) = value.iter().position(|b| !is_atext(*b)) { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: value[at], + at, + })); + }; + + Ok(()) + } + + /// Returns a reference to the inner value. + pub fn inner(&self) -> &str { + self.0.as_ref() + } + + /// Consumes the atom, returning the inner value. + pub fn into_inner(self) -> Cow<'a, str> { + self.0 + } + + /// Constructs an atom without validation. + pub fn unvalidated(inner: C) -> Self + where + C: Into>, + { + let inner = inner.into(); + + #[cfg(debug_assertions)] + Self::validate(inner.as_bytes()).unwrap(); + + Self(inner) + } +} + +impl<'a> TryFrom<&'a [u8]> for Atom<'a> { + type Error = ValidationError; + + fn try_from(value: &'a [u8]) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(from_utf8(value).unwrap()))) + } +} + +impl TryFrom> for Atom<'_> { + type Error = ValidationError; + + fn try_from(value: Vec) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(String::from_utf8(value).unwrap()))) + } +} + +impl<'a> TryFrom<&'a str> for Atom<'a> { + type Error = ValidationError; + + fn try_from(value: &'a str) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(value))) + } +} + +impl TryFrom for Atom<'_> { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Atom(Cow::Owned(value))) + } +} + +impl<'a> TryFrom> for Atom<'a> { + type Error = ValidationError; + + fn try_from(value: Cow<'a, str>) -> Result { + Self::validate(value.as_bytes())?; + Ok(Self(value)) + } +} + +impl AsRef for Atom<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Atom<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let len: usize = u.int_in_range(1..=10)?; + let mut s = String::with_capacity(len); + for _ in 0..len { + s.push(arbitrary_atext(u)?); + } + Ok(Atom(Cow::Owned(s))) + } +} + +/// A human-readable text string used in SMTP responses. +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// textstring = 1*(%d09 / %d32-126) ; HT, SP, Printable US-ASCII +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String"))] +#[derive(PartialEq, Eq, Hash, Clone, ToStatic)] +pub struct Text<'a>(pub(crate) Cow<'a, str>); + +impl Debug for Text<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "Text({:?})", self.0) + } +} + +impl Display for Text<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0.as_ref()) + } +} + +impl<'a> Text<'a> { + pub fn validate(value: impl AsRef<[u8]>) -> Result<(), ValidationError> { + let value = value.as_ref(); + + // Empty text is allowed in SMTP (unlike IMAP) + if let Some(at) = value.iter().position(|b| !is_text_char(*b)) { + return Err(ValidationError::new(ValidationErrorKind::InvalidByteAt { + byte: value[at], + at, + })); + }; + + Ok(()) + } + + pub fn inner(&self) -> &str { + self.0.as_ref() + } + + pub fn into_inner(self) -> Cow<'a, str> { + self.0 + } + + /// Constructs text without validation. + pub fn unvalidated(inner: C) -> Self + where + C: Into>, + { + let inner = inner.into(); + + #[cfg(debug_assertions)] + Self::validate(inner.as_bytes()).unwrap(); + + Self(inner) + } +} + +impl<'a> TryFrom<&'a [u8]> for Text<'a> { + type Error = ValidationError; + + fn try_from(value: &'a [u8]) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(from_utf8(value).unwrap()))) + } +} + +impl TryFrom> for Text<'_> { + type Error = ValidationError; + + fn try_from(value: Vec) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(String::from_utf8(value).unwrap()))) + } +} + +impl<'a> TryFrom<&'a str> for Text<'a> { + type Error = ValidationError; + + fn try_from(value: &'a str) -> Result { + Self::validate(value)?; + Ok(Self(Cow::Borrowed(value))) + } +} + +impl TryFrom for Text<'_> { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(Cow::Owned(value))) + } +} + +impl AsRef for Text<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Text<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let len: usize = u.int_in_range(0..=50)?; + let mut s = String::with_capacity(len); + for _ in 0..len { + s.push(arbitrary_text_char(u)?); + } + Ok(Text(Cow::Owned(s))) + } +} + +/// An ESMTP parameter (keyword[=value]). +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// esmtp-param = esmtp-keyword ["=" esmtp-value] +/// esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") +/// esmtp-value = 1*(%d33-60 / %d62-126) ; any CHAR excluding "=", SP, and CTL +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct Parameter<'a> { + /// The parameter keyword + pub keyword: Atom<'a>, + /// The optional parameter value + pub value: Option>, +} + +impl Display for Parameter<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match &self.value { + Some(value) => write!(f, "{}={}", self.keyword, value), + None => write!(f, "{}", self.keyword), + } + } +} + +impl<'a> Parameter<'a> { + /// Creates a new parameter with just a keyword. + pub fn new(keyword: Atom<'a>) -> Self { + Self { + keyword, + value: None, + } + } + + /// Creates a new parameter with a keyword and value. + pub fn with_value(keyword: Atom<'a>, value: impl Into>) -> Self { + Self { + keyword, + value: Some(value.into()), + } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Parameter<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let keyword = Atom::arbitrary(u)?; + let has_value: bool = u.arbitrary()?; + let value = if has_value { + // Generate a valid esmtp-value (printable ASCII excluding '=' and space) + let len: usize = u.int_in_range(1..=10)?; + let mut s = String::with_capacity(len); + for _ in 0..len { + let c: u8 = u.int_in_range(33..=126)?; + // Skip '=' (61) + if c != 61 { + s.push(c as char); + } else { + s.push('a'); + } + } + Some(Cow::Owned(s)) + } else { + None + }; + Ok(Parameter { keyword, value }) + } +} + +/// A [`Vec`] containing >= 1 elements, i.e., a non-empty vector. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "Vec"))] +#[derive(Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct Vec1(pub(crate) Vec); + +impl Debug for Vec1 +where + T: Debug, +{ + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + self.0.fmt(f)?; + write!(f, "+") + } +} + +impl Vec1 { + pub fn validate(value: &[T]) -> Result<(), ValidationError> { + if value.is_empty() { + return Err(ValidationError::new(ValidationErrorKind::NotEnough { + min: 1, + })); + } + Ok(()) + } + + /// Constructs a non-empty vector without validation. + pub fn unvalidated(inner: Vec) -> Self { + #[cfg(debug_assertions)] + Self::validate(&inner).unwrap(); + + Self(inner) + } + + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl From for Vec1 { + fn from(value: T) -> Self { + Vec1(vec![value]) + } +} + +impl TryFrom> for Vec1 { + type Error = ValidationError; + + fn try_from(inner: Vec) -> Result { + Self::validate(&inner)?; + Ok(Self(inner)) + } +} + +impl IntoIterator for Vec1 { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl AsRef<[T]> for Vec1 { + fn as_ref(&self) -> &[T] { + &self.0 + } +} + +#[cfg(feature = "arbitrary")] +impl<'a, T> Arbitrary<'a> for Vec1 +where + T: Arbitrary<'a>, +{ + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let len: usize = u.int_in_range(1..=5)?; + let mut vec = Vec::with_capacity(len); + for _ in 0..len { + vec.push(T::arbitrary(u)?); + } + Ok(Vec1(vec)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_validation() { + assert!(Domain::try_from("example.com").is_ok()); + assert!(Domain::try_from("mail.example.com").is_ok()); + assert!(Domain::try_from("a").is_ok()); + assert!(Domain::try_from("a-b").is_ok()); + assert!(Domain::try_from("a1").is_ok()); + + // Invalid: empty + assert!(Domain::try_from("").is_err()); + // Invalid: starts with hyphen + assert!(Domain::try_from("-example").is_err()); + // Invalid: ends with hyphen + assert!(Domain::try_from("example-").is_err()); + } + + #[test] + fn test_atom_validation() { + assert!(Atom::try_from("HELLO").is_ok()); + assert!(Atom::try_from("test123").is_ok()); + assert!(Atom::try_from("a").is_ok()); + + // Invalid: empty + assert!(Atom::try_from("").is_err()); + // Invalid: space + assert!(Atom::try_from("hello world").is_err()); + } + + #[test] + fn test_text_validation() { + assert!(Text::try_from("Hello World!").is_ok()); + assert!(Text::try_from("").is_ok()); // Empty is allowed in SMTP + assert!(Text::try_from("\t tab").is_ok()); + + // Invalid: CR + assert!(Text::try_from("hello\rworld").is_err()); + // Invalid: LF + assert!(Text::try_from("hello\nworld").is_err()); + } + + #[test] + fn test_vec1() { + assert!(Vec1::::try_from(vec![]).is_err()); + assert!(Vec1::::try_from(vec![1]).is_ok()); + assert!(Vec1::::try_from(vec![1, 2]).is_ok()); + } +} diff --git a/smtp-types/src/error.rs b/smtp-types/src/error.rs new file mode 100644 index 0000000..5aecf3a --- /dev/null +++ b/smtp-types/src/error.rs @@ -0,0 +1,37 @@ +//! Error-related types. + +use std::fmt::{Display, Formatter}; + +use thiserror::Error; + +/// A validation error. +/// +/// This error can be returned during validation of a value, e.g., a tag, atom, etc. +#[derive(Clone, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)] +pub struct ValidationError { + kind: ValidationErrorKind, +} + +impl Display for ValidationError { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "Validation failed: {}", self.kind) + } +} + +#[derive(Clone, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) enum ValidationErrorKind { + #[error("Must not be empty")] + Empty, + #[error("Must have at least {min} elements")] + NotEnough { min: usize }, + #[error("Invalid value")] + Invalid, + #[error("Invalid byte b'\\x{byte:02x}' at index {at}")] + InvalidByteAt { byte: u8, at: usize }, +} + +impl ValidationError { + pub(crate) fn new(kind: ValidationErrorKind) -> Self { + Self { kind } + } +} diff --git a/smtp-types/src/lib.rs b/smtp-types/src/lib.rs index c2797cf..bfa24a6 100644 --- a/smtp-types/src/lib.rs +++ b/smtp-types/src/lib.rs @@ -1,970 +1,131 @@ -use std::{borrow::Cow, fmt, io::Write, ops::Deref}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::utils::escape_quoted; - -mod utils; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Command { - Ehlo { - domain_or_address: DomainOrAddress, - }, - Helo { - domain_or_address: DomainOrAddress, - }, - Mail { - reverse_path: String, - parameters: Vec, - }, - Rcpt { - forward_path: String, - parameters: Vec, - }, - Data, - Rset, - /// This command asks the receiver to confirm that the argument - /// identifies a user or mailbox. If it is a user name, information is - /// returned as specified in Section 3.5. - /// - /// This command has no effect on the reverse-path buffer, the forward- - /// path buffer, or the mail data buffer. - Vrfy { - user_or_mailbox: AtomOrQuoted, - }, - /// This command asks the receiver to confirm that the argument - /// identifies a mailing list, and if so, to return the membership of - /// that list. If the command is successful, a reply is returned - /// containing information as described in Section 3.5. This reply will - /// have multiple lines except in the trivial case of a one-member list. - /// - /// This command has no effect on the reverse-path buffer, the forward- - /// path buffer, or the mail data buffer, and it may be issued at any - /// time. - Expn { - mailing_list: AtomOrQuoted, - }, - /// This command causes the server to send helpful information to the - /// client. The command MAY take an argument (e.g., any command name) - /// and return more specific information as a response. - /// - /// SMTP servers SHOULD support HELP without arguments and MAY support it - /// with arguments. - /// - /// This command has no effect on the reverse-path buffer, the forward- - /// path buffer, or the mail data buffer, and it may be issued at any - /// time. - Help { - argument: Option, - }, - /// This command does not affect any parameters or previously entered - /// commands. It specifies no action other than that the receiver send a - /// "250 OK" reply. - /// - /// If a parameter string is specified, servers SHOULD ignore it. - /// - /// This command has no effect on the reverse-path buffer, the forward- - /// path buffer, or the mail data buffer, and it may be issued at any - /// time. - Noop { - argument: Option, - }, - /// This command specifies that the receiver MUST send a "221 OK" reply, - /// and then close the transmission channel. - /// - /// The receiver MUST NOT intentionally close the transmission channel - /// until it receives and replies to a QUIT command (even if there was an - /// error). The sender MUST NOT intentionally close the transmission - /// channel until it sends a QUIT command, and it SHOULD wait until it - /// receives the reply (even if there was an error response to a previous - /// command). If the connection is closed prematurely due to violations - /// of the above or system or network failure, the server MUST cancel any - /// pending transaction, but not undo any previously completed - /// transaction, and generally MUST act as if the command or transaction - /// in progress had received a temporary error (i.e., a 4yz response). - /// - /// The QUIT command may be issued at any time. Any current uncompleted - /// mail transaction will be aborted. - Quit, - // Extensions - StartTLS, - // AUTH LOGIN - AuthLogin(Option), - // AUTH PLAIN - AuthPlain(Option), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum DomainOrAddress { - Domain(String), - Address(String), -} - -impl DomainOrAddress { - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - DomainOrAddress::Domain(domain) => write!(writer, "{}", domain), - DomainOrAddress::Address(address) => write!(writer, "[{}]", address), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum Parameter { - /// Message size declaration [RFC1870] - Size(u32), - Other { - keyword: String, - value: Option, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AtomOrQuoted { - Atom(String), - Quoted(String), -} - -impl Command { - pub fn name(&self) -> &'static str { - match self { - Command::Ehlo { .. } => "EHLO", - Command::Helo { .. } => "HELO", - Command::Mail { .. } => "MAIL", - Command::Rcpt { .. } => "RCPT", - Command::Data => "DATA", - Command::Rset => "RSET", - Command::Vrfy { .. } => "VRFY", - Command::Expn { .. } => "EXPN", - Command::Help { .. } => "HELP", - Command::Noop { .. } => "NOOP", - Command::Quit => "QUIT", - // Extensions - Command::StartTLS => "STARTTLS", - // TODO: SMTP AUTH LOGIN - Command::AuthLogin(_) => "AUTHLOGIN", - // TODO: SMTP AUTH PLAIN - Command::AuthPlain(_) => "AUTHPLAIN", - } - } - - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - use Command::*; - - match self { - // helo = "HELO" SP Domain CRLF - Helo { domain_or_address } => { - writer.write_all(b"HELO ")?; - domain_or_address.serialize(writer)?; - } - // ehlo = "EHLO" SP ( Domain / address-literal ) CRLF - Ehlo { domain_or_address } => { - writer.write_all(b"EHLO ")?; - domain_or_address.serialize(writer)?; - } - // mail = "MAIL FROM:" Reverse-path [SP Mail-parameters] CRLF - Mail { - reverse_path, - parameters, - } => { - writer.write_all(b"MAIL FROM:<")?; - writer.write_all(reverse_path.as_bytes())?; - writer.write_all(b">")?; - - for parameter in parameters { - writer.write_all(b" ")?; - parameter.serialize(writer)?; - } - } - // rcpt = "RCPT TO:" ( "" / "" / Forward-path ) [SP Rcpt-parameters] CRLF - Rcpt { - forward_path, - parameters, - } => { - writer.write_all(b"RCPT TO:<")?; - writer.write_all(forward_path.as_bytes())?; - writer.write_all(b">")?; - - for parameter in parameters { - writer.write_all(b" ")?; - parameter.serialize(writer)?; - } - } - // data = "DATA" CRLF - Data => writer.write_all(b"DATA")?, - // rset = "RSET" CRLF - Rset => writer.write_all(b"RSET")?, - // vrfy = "VRFY" SP String CRLF - Vrfy { user_or_mailbox } => { - writer.write_all(b"VRFY ")?; - user_or_mailbox.serialize(writer)?; - } - // expn = "EXPN" SP String CRLF - Expn { mailing_list } => { - writer.write_all(b"EXPN ")?; - mailing_list.serialize(writer)?; - } - // help = "HELP" [ SP String ] CRLF - Help { argument: None } => writer.write_all(b"HELP")?, - Help { - argument: Some(data), - } => { - writer.write_all(b"HELP ")?; - data.serialize(writer)?; - } - // noop = "NOOP" [ SP String ] CRLF - Noop { argument: None } => writer.write_all(b"NOOP")?, - Noop { - argument: Some(data), - } => { - writer.write_all(b"NOOP ")?; - data.serialize(writer)?; - } - // quit = "QUIT" CRLF - Quit => writer.write_all(b"QUIT")?, - // ----- Extensions ----- - // starttls = "STARTTLS" CRLF - StartTLS => writer.write_all(b"STARTTLS")?, - // auth_login_command = "AUTH LOGIN" [SP username] CRLF - AuthLogin(None) => { - writer.write_all(b"AUTH LOGIN")?; - } - AuthLogin(Some(data)) => { - writer.write_all(b"AUTH LOGIN ")?; - writer.write_all(data.as_bytes())?; - } - // auth_plain_command = "AUTH PLAIN" [SP base64] CRLF - AuthPlain(None) => { - writer.write_all(b"AUTH PLAIN")?; - } - AuthPlain(Some(data)) => { - writer.write_all(b"AUTH PLAIN ")?; - writer.write_all(data.as_bytes())?; - } - } - - write!(writer, "\r\n") - } -} - -impl Parameter { - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - Parameter::Size(size) => { - write!(writer, "SIZE={}", size)?; - } - Parameter::Other { keyword, value } => { - writer.write_all(keyword.as_bytes())?; - - if let Some(ref value) = value { - writer.write_all(b"=")?; - writer.write_all(value.as_bytes())?; - } - } - }; - - Ok(()) - } -} - -impl AtomOrQuoted { - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - AtomOrQuoted::Atom(atom) => { - writer.write_all(atom.as_bytes())?; - } - AtomOrQuoted::Quoted(quoted) => { - writer.write_all(b"\"")?; - writer.write_all(escape_quoted(quoted).as_bytes())?; - writer.write_all(b"\"")?; - } - } - - Ok(()) - } -} - -// ------------------------------------------------------------------------------------------------- - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Response { - Greeting { - domain: String, - text: String, - }, - Ehlo { - domain: String, - greet: Option, - capabilities: Vec, - }, - Other { - code: ReplyCode, - lines: Vec>, - }, -} - -impl Response { - pub fn greeting(domain: D, text: T) -> Response - where - D: Into, - T: Into, - { - Response::Greeting { - domain: domain.into(), - text: text.into(), - } - } - - pub fn ehlo(domain: D, greet: Option, capabilities: Vec) -> Response - where - D: Into, - G: Into, - { - Response::Ehlo { - domain: domain.into(), - greet: greet.map(Into::into), - capabilities, - } - } - - pub fn other(code: ReplyCode, text: TextString<'static>) -> Response - where - T: Into, - { - Response::Other { - code, - lines: vec![text], - } - } - - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - Response::Greeting { domain, text } => { - let lines = text.lines().collect::>(); - - if let Some((first, tail)) = lines.split_first() { - if let Some((last, head)) = tail.split_last() { - write!(writer, "220-{} {}\r\n", domain, first)?; - - for line in head { - write!(writer, "220-{}\r\n", line)?; - } - - write!(writer, "220 {}\r\n", last)?; - } else { - write!(writer, "220 {} {}\r\n", domain, first)?; - } - } else { - write!(writer, "220 {}\r\n", domain)?; - } - } - Response::Ehlo { - domain, - greet, - capabilities, - } => { - let greet = match greet { - Some(greet) => format!(" {}", greet), - None => "".to_string(), - }; - - if let Some((tail, head)) = capabilities.split_last() { - writer.write_all(format!("250-{}{}\r\n", domain, greet).as_bytes())?; - - for capability in head { - writer.write_all(b"250-")?; - capability.serialize(writer)?; - writer.write_all(b"\r\n")?; - } - - writer.write_all(b"250 ")?; - tail.serialize(writer)?; - writer.write_all(b"\r\n")?; - } else { - writer.write_all(format!("250 {}{}\r\n", domain, greet).as_bytes())?; - } - } - Response::Other { code, lines } => { - let code = u16::from(*code); - for line in lines.iter().take(lines.len().saturating_sub(1)) { - write!(writer, "{}-{}\r\n", code, line,)?; - } - - match lines.last() { - Some(s) => write!(writer, "{} {}\r\n", code, s)?, - None => write!(writer, "{}\r\n", code)?, - }; - } - } - - Ok(()) - } -} - -// ------------------------------------------------------------------------------------------------- - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Capability { - // Send as mail [RFC821] - // The description of SEND was updated by [RFC1123] and then its actual use was deprecated in [RFC2821] - // SEND, - - // Send as mail or to terminal [RFC821] - // The description of SOML was updated by [RFC1123] and then its actual use was deprecated in [RFC2821] - // SOML, - - // Send as mail and to terminal [RFC821] - // The description of SAML was updated by [RFC1123] and then its actual use was deprecated in [RFC2821] - // SAML, - - // Interchange the client and server roles [RFC821] - // The actual use of TURN was deprecated in [RFC2821] - // TURN, - - // SMTP Responsible Submitter [RFC4405] - // Deprecated by [https://datatracker.ietf.org/doc/status-change-change-sender-id-to-historic]. - // SUBMITTER, - - // Internationalized email address [RFC5336] - // Experimental; deprecated in [RFC6531]. - // UTF8SMTP, - - // --------------------------------------------------------------------------------------------- - /// Verbose [Eric Allman] - // VERB, - - /// One message transaction only [Eric Allman] - // ONEX, - - // --------------------------------------------------------------------------------------------- - - /// Expand the mailing list [RFC821] - /// Command description updated by [RFC5321] - EXPN, - /// Supply helpful information [RFC821] - /// Command description updated by [RFC5321] - Help, - - /// SMTP and Submit transport of 8bit MIME content [RFC6152] - EightBitMIME, - - /// Message size declaration [RFC1870] - Size(u32), - - /// Chunking [RFC3030] - Chunking, - - /// Binary MIME [RFC3030] - BinaryMIME, - - /// Checkpoint/Restart [RFC1845] - Checkpoint, - - /// Deliver By [RFC2852] - DeliverBy, - - /// Command Pipelining [RFC2920] - Pipelining, - - /// Delivery Status Notification [RFC3461] - DSN, - - /// Extended Turn [RFC1985] - /// SMTP [RFC5321] only. Not for use on Submit port 587. - ETRN, - - /// Enhanced Status Codes [RFC2034] - EnhancedStatusCodes, - - /// Start TLS [RFC3207] - StartTLS, - - /// Notification of no soliciting [RFC3865] - // NoSoliciting, - - /// Message Tracking [RFC3885] - MTRK, - - /// Authenticated TURN [RFC2645] - /// SMTP [RFC5321] only. Not for use on Submit port 587. - ATRN, - - /// Authentication [RFC4954] - Auth(Vec), - - /// Remote Content [RFC4468] - /// Submit [RFC6409] only. Not for use with SMTP on port 25. - BURL, - - /// Future Message Release [RFC4865] - // FutureRelease, - - /// Content Conversion Permission [RFC4141] - // ConPerm, - - /// Content Conversion Negotiation [RFC4141] - // ConNeg, - - /// Internationalized email address [RFC6531] - SMTPUTF8, - - /// Priority Message Handling [RFC6710] - // MTPRIORITY, - - /// Require Recipient Valid Since [RFC7293] - RRVS, - - /// Require TLS [RFC8689] - RequireTLS, - - // Observed ... - // TIME, - // XACK, - // VERP, - // VRFY, - /// Other - Other { - keyword: String, - params: Vec, - }, -} - -impl Capability { - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - Capability::EXPN => writer.write_all(b"EXPN"), - Capability::Help => writer.write_all(b"HELP"), - Capability::EightBitMIME => writer.write_all(b"8BITMIME"), - Capability::Size(number) => writer.write_all(format!("SIZE {}", number).as_bytes()), - Capability::Chunking => writer.write_all(b"CHUNKING"), - Capability::BinaryMIME => writer.write_all(b"BINARYMIME"), - Capability::Checkpoint => writer.write_all(b"CHECKPOINT"), - Capability::DeliverBy => writer.write_all(b"DELIVERBY"), - Capability::Pipelining => writer.write_all(b"PIPELINING"), - Capability::DSN => writer.write_all(b"DSN"), - Capability::ETRN => writer.write_all(b"ETRN"), - Capability::EnhancedStatusCodes => writer.write_all(b"ENHANCEDSTATUSCODES"), - Capability::StartTLS => writer.write_all(b"STARTTLS"), - Capability::MTRK => writer.write_all(b"MTRK"), - Capability::ATRN => writer.write_all(b"ATRN"), - Capability::Auth(mechanisms) => { - if let Some((tail, head)) = mechanisms.split_last() { - writer.write_all(b"AUTH ")?; - - for mechanism in head { - mechanism.serialize(writer)?; - writer.write_all(b" ")?; - } - - tail.serialize(writer) - } else { - writer.write_all(b"AUTH") - } - } - Capability::BURL => writer.write_all(b"BURL"), - Capability::SMTPUTF8 => writer.write_all(b"SMTPUTF8"), - Capability::RRVS => writer.write_all(b"RRVS"), - Capability::RequireTLS => writer.write_all(b"REQUIRETLS"), - Capability::Other { keyword, params } => { - if let Some((tail, head)) = params.split_last() { - writer.write_all(keyword.as_bytes())?; - writer.write_all(b" ")?; - - for param in head { - writer.write_all(param.as_bytes())?; - writer.write_all(b" ")?; - } - - writer.write_all(tail.as_bytes()) - } else { - writer.write_all(keyword.as_bytes()) - } - } - } - } -} - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub enum ReplyCode { - /// 211 System status, or system help reply - SystemStatus, - /// 214 Help message - /// - /// Information on how to use the receiver or the meaning of a particular non-standard - /// command; this reply is useful only to the human user. - HelpMessage, - /// 220 Service ready - Ready, - /// 221 Service closing transmission channel - ClosingChannel, - /// 250 Requested mail action okay, completed - Ok, - /// 251 User not local; will forward to - UserNotLocalWillForward, - /// 252 Cannot VRFY user, but will accept message and attempt delivery - CannotVrfy, - /// 354 Start mail input; end with . - StartMailInput, - /// 421 Service not available, closing transmission channel - /// - /// This may be a reply to any command if the service knows it must shut down. - NotAvailable, - /// 450 Requested mail action not taken: mailbox unavailable - /// - /// E.g., mailbox busy or temporarily blocked for policy reasons. - MailboxTemporarilyUnavailable, - /// 451 Requested action aborted: local error in processing - ProcessingError, - /// 452 Requested action not taken: insufficient system storage - InsufficientStorage, - /// 455 Server unable to accommodate parameters - UnableToAccommodateParameters, - /// 500 Syntax error, command unrecognized - SyntaxError, - /// 501 Syntax error in parameters or arguments - ParameterSyntaxError, - /// 502 Command not implemented - CommandNotImplemented, - /// 503 Bad sequence of commands - BadSequence, - /// 504 Command parameter not implemented - ParameterNotImplemented, - /// 521 does not accept mail (see RFC 1846) - NoMailService, - /// 550 Requested action not taken: mailbox unavailable - /// - /// E.g. mailbox not found, no access, or command rejected for policy reasons. - MailboxPermanentlyUnavailable, - /// 551 User not local; please try - UserNotLocal, - /// 552 Requested mail action aborted: exceeded storage allocation - ExceededStorageAllocation, - /// 553 Requested action not taken: mailbox name not allowed - /// - /// E.g. mailbox syntax incorrect. - MailboxNameNotAllowed, - /// 554 Transaction failed - /// - /// Or, in the case of a connection-opening response, "No SMTP service here". - TransactionFailed, - /// 555 MAIL FROM/RCPT TO parameters not recognized or not implemented - ParametersNotImplemented, - /// Miscellaneous reply codes - Other(u16), -} - -impl ReplyCode { - pub fn is_completed(&self) -> bool { - let code = u16::from(*self); - code > 199 && code < 300 - } - - pub fn is_accepted(&self) -> bool { - let code = u16::from(*self); - code > 299 && code < 400 - } - - pub fn is_temporary_error(&self) -> bool { - let code = u16::from(*self); - code > 399 && code < 500 - } - - pub fn is_permanent_error(&self) -> bool { - let code = u16::from(*self); - code > 499 && code < 600 - } -} - -impl From for ReplyCode { - fn from(value: u16) -> Self { - match value { - 211 => ReplyCode::SystemStatus, - 214 => ReplyCode::HelpMessage, - 220 => ReplyCode::Ready, - 221 => ReplyCode::ClosingChannel, - 250 => ReplyCode::Ok, - 251 => ReplyCode::UserNotLocalWillForward, - 252 => ReplyCode::CannotVrfy, - 354 => ReplyCode::StartMailInput, - 421 => ReplyCode::NotAvailable, - 450 => ReplyCode::MailboxTemporarilyUnavailable, - 451 => ReplyCode::ProcessingError, - 452 => ReplyCode::InsufficientStorage, - 455 => ReplyCode::UnableToAccommodateParameters, - 500 => ReplyCode::SyntaxError, - 501 => ReplyCode::ParameterSyntaxError, - 502 => ReplyCode::CommandNotImplemented, - 503 => ReplyCode::BadSequence, - 504 => ReplyCode::ParameterNotImplemented, - 521 => ReplyCode::NoMailService, - 550 => ReplyCode::MailboxPermanentlyUnavailable, - 551 => ReplyCode::UserNotLocal, - 552 => ReplyCode::ExceededStorageAllocation, - 553 => ReplyCode::MailboxNameNotAllowed, - 554 => ReplyCode::TransactionFailed, - 555 => ReplyCode::ParametersNotImplemented, - _ => ReplyCode::Other(value), - } - } -} - -impl From for u16 { - fn from(value: ReplyCode) -> Self { - match value { - ReplyCode::SystemStatus => 211, - ReplyCode::HelpMessage => 214, - ReplyCode::Ready => 220, - ReplyCode::ClosingChannel => 221, - ReplyCode::Ok => 250, - ReplyCode::UserNotLocalWillForward => 251, - ReplyCode::CannotVrfy => 252, - ReplyCode::StartMailInput => 354, - ReplyCode::NotAvailable => 421, - ReplyCode::MailboxTemporarilyUnavailable => 450, - ReplyCode::ProcessingError => 451, - ReplyCode::InsufficientStorage => 452, - ReplyCode::UnableToAccommodateParameters => 455, - ReplyCode::SyntaxError => 500, - ReplyCode::ParameterSyntaxError => 501, - ReplyCode::CommandNotImplemented => 502, - ReplyCode::BadSequence => 503, - ReplyCode::ParameterNotImplemented => 504, - ReplyCode::NoMailService => 521, - ReplyCode::MailboxPermanentlyUnavailable => 550, - ReplyCode::UserNotLocal => 551, - ReplyCode::ExceededStorageAllocation => 552, - ReplyCode::MailboxNameNotAllowed => 553, - ReplyCode::TransactionFailed => 554, - ReplyCode::ParametersNotImplemented => 555, - ReplyCode::Other(v) => v, - } - } -} - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum AuthMechanism { - Plain, - Login, - GSSAPI, - - CramMD5, - CramSHA1, - ScramMD5, - DigestMD5, - NTLM, - - Other(String), -} - -impl AuthMechanism { - pub fn serialize(&self, writer: &mut impl Write) -> std::io::Result<()> { - match self { - AuthMechanism::Plain => writer.write_all(b"PLAIN"), - AuthMechanism::Login => writer.write_all(b"LOGIN"), - AuthMechanism::GSSAPI => writer.write_all(b"GSSAPI"), - - AuthMechanism::CramMD5 => writer.write_all(b"CRAM-MD5"), - AuthMechanism::CramSHA1 => writer.write_all(b"CRAM-SHA1"), - AuthMechanism::ScramMD5 => writer.write_all(b"SCRAM-MD5"), - AuthMechanism::DigestMD5 => writer.write_all(b"DIGEST-MD5"), - AuthMechanism::NTLM => writer.write_all(b"NTLM"), - - AuthMechanism::Other(other) => writer.write_all(other.as_bytes()), - } - } -} - -/// A string containing of tab, space and printable ASCII characters -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TextString<'a>(pub(crate) Cow<'a, str>); - -impl<'a> TextString<'a> { - pub fn new(s: &'a str) -> Result { - if s.is_empty() { - return Err(InvalidTextString(())); - } - - match s.as_bytes().iter().all(|&b| is_text_string_byte(b)) { - true => Ok(TextString(Cow::Borrowed(s))), - false => Err(InvalidTextString(())), - } - } - - pub fn new_unchecked(s: &'a str) -> Self { - #[cfg(debug_assertions)] - return TextString::new(s).expect("String should have been valid but wasn't."); - - #[cfg(not(debug_assertions))] - return TextString(Cow::Borrowed(s)); - } - - pub fn into_owned(self) -> TextString<'static> { - TextString(self.0.into_owned().into()) - } -} - -impl Deref for TextString<'_> { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for TextString<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug)] -pub struct InvalidTextString(()); - -impl fmt::Display for InvalidTextString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "input contains invalid characters") - } -} - -impl std::error::Error for InvalidTextString {} - -// ------------------------------------------------------------------------------------------------- - -fn is_text_string_byte(byte: u8) -> bool { - matches!(byte, 9 | 32..=126) -} - -// ------------------------------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{Capability, ReplyCode, Response, TextString}; - - #[test] - fn test_serialize_greeting() { - let tests = &[ - ( - Response::Greeting { - domain: "example.org".into(), - text: "".into(), - }, - b"220 example.org\r\n".as_ref(), - ), - ( - Response::Greeting { - domain: "example.org".into(), - text: "A".into(), - }, - b"220 example.org A\r\n".as_ref(), - ), - ( - Response::Greeting { - domain: "example.org".into(), - text: "A\nB".into(), - }, - b"220-example.org A\r\n220 B\r\n".as_ref(), - ), - ( - Response::Greeting { - domain: "example.org".into(), - text: "A\nB\nC".into(), - }, - b"220-example.org A\r\n220-B\r\n220 C\r\n".as_ref(), - ), - ]; - - for (test, expected) in tests.iter() { - let mut got = Vec::new(); - test.serialize(&mut got).unwrap(); - assert_eq!(expected, &got); - } - } - - #[test] - fn test_serialize_ehlo() { - let tests = &[ - ( - Response::Ehlo { - domain: "example.org".into(), - greet: None, - capabilities: vec![], - }, - b"250 example.org\r\n".as_ref(), - ), - ( - Response::Ehlo { - domain: "example.org".into(), - greet: Some("...".into()), - capabilities: vec![], - }, - b"250 example.org ...\r\n".as_ref(), - ), - ( - Response::Ehlo { - domain: "example.org".into(), - greet: Some("...".into()), - capabilities: vec![Capability::StartTLS], - }, - b"250-example.org ...\r\n250 STARTTLS\r\n".as_ref(), - ), - ( - Response::Ehlo { - domain: "example.org".into(), - greet: Some("...".into()), - capabilities: vec![Capability::StartTLS, Capability::Size(12345)], - }, - b"250-example.org ...\r\n250-STARTTLS\r\n250 SIZE 12345\r\n".as_ref(), - ), - ]; - - for (test, expected) in tests.iter() { - let mut got = Vec::new(); - test.serialize(&mut got).unwrap(); - assert_eq!(expected, &got); - } - } - - #[test] - fn test_serialize_other() { - let tests = &[ - ( - Response::Other { - code: ReplyCode::StartMailInput, - lines: vec![], - }, - b"354\r\n".as_ref(), - ), - ( - Response::Other { - code: ReplyCode::StartMailInput, - lines: vec![TextString::new("A").unwrap()], - }, - b"354 A\r\n".as_ref(), - ), - ( - Response::Other { - code: ReplyCode::StartMailInput, - lines: vec![TextString::new("A").unwrap(), TextString::new("B").unwrap()], - }, - b"354-A\r\n354 B\r\n".as_ref(), - ), - ]; - - for (test, expected) in tests.iter() { - let mut got = Vec::new(); - test.serialize(&mut got).unwrap(); - assert_eq!(expected, &got); - } +//! # Misuse-resistant SMTP types +//! +//! The most prominent types in smtp-types are [`Greeting`](response::Greeting), +//! [`Command`](command::Command), and [`Response`](response::Response). +//! These types ensure correctness by validating their contents at construction time. +//! +//! ## Understanding and using the core types +//! +//! The [`core`] module contains fundamental types like [`Domain`](core::Domain), +//! [`Mailbox`](core::Mailbox), [`Atom`](core::Atom), and [`Text`](core::Text). +//! These types validate their contents according to RFC 5321 rules. +//! +//! ## Construction of messages +//! +//! smtp-types relies on the standard conversion traits, i.e., [`From`], [`TryFrom`], +//! [`Into`], and [`TryInto`]. More convenient constructors are available for types +//! that are more cumbersome to create. +//! +//! Note: When you are *sure* that the thing you want to create is valid, you can use +//! the `unvalidated(...)` functions. These bypass validation in release builds but +//! will panic in debug builds if the value is invalid. +//! +//! ### Example +//! +//! ``` +//! use smtp_types::{ +//! command::Command, +//! core::{Domain, ForwardPath, LocalPart, Mailbox, ReversePath}, +//! }; +//! +//! // Create an EHLO command +//! let domain = Domain::try_from("client.example.com").unwrap(); +//! let cmd = Command::ehlo(domain); +//! +//! // Create a MAIL FROM command with null path (bounce message) +//! let cmd = Command::mail(ReversePath::Null); +//! +//! // Create a RCPT TO command +//! let local = LocalPart::try_from("user").unwrap(); +//! let domain = Domain::try_from("example.com").unwrap(); +//! let mailbox = Mailbox::new(local, domain.into()); +//! let cmd = Command::rcpt(ForwardPath::from(mailbox)); +//! +//! // Simple commands +//! let cmd = Command::data(); +//! let cmd = Command::quit(); +//! ``` +//! +//! # Supported SMTP extensions +//! +//! | Feature | Description | RFC | +//! |-------------------------|-----------------------------------------------------------|----------| +//! | starttls | SMTP over TLS | RFC 3207 | +//! | ext_auth | SMTP Authentication | RFC 4954 | +//! | ext_size | Message Size Declaration | RFC 1870 | +//! | ext_8bitmime | 8-bit MIME Transport | RFC 6152 | +//! | ext_pipelining | Command Pipelining | RFC 2920 | +//! | ext_smtputf8 | Internationalized Email | RFC 6531 | +//! | ext_enhancedstatuscodes | Enhanced Error Codes | RFC 2034 | +//! +//! # Features +//! +//! | Feature | Description | Default | +//! |-----------|---------------------------------------------------------------|---------| +//! | arbitrary | Derive `Arbitrary` implementations for fuzzing | No | +//! | serde | Derive `Serialize` and `Deserialize` implementations | No | +//! +//! [RFC 1870]: https://datatracker.ietf.org/doc/html/rfc1870 +//! [RFC 2034]: https://datatracker.ietf.org/doc/html/rfc2034 +//! [RFC 2920]: https://datatracker.ietf.org/doc/html/rfc2920 +//! [RFC 3207]: https://datatracker.ietf.org/doc/html/rfc3207 +//! [RFC 4954]: https://datatracker.ietf.org/doc/html/rfc4954 +//! [RFC 5321]: https://datatracker.ietf.org/doc/html/rfc5321 +//! [RFC 6152]: https://datatracker.ietf.org/doc/html/rfc6152 +//! [RFC 6531]: https://datatracker.ietf.org/doc/html/rfc6531 + +#![forbid(unsafe_code)] +#![deny(missing_debug_implementations)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +use bounded_static::{IntoBoundedStatic, ToBoundedStatic}; + +#[cfg(feature = "ext_auth")] +pub mod auth; +pub mod command; +pub mod core; +pub mod error; +pub mod response; +pub mod secret; +pub mod state; +pub mod utils; + +/// Create owned variant of object. +/// +/// Useful, e.g., if you want to pass the object to another thread or executor. +pub trait ToStatic { + type Static: 'static; + + fn to_static(&self) -> Self::Static; +} + +impl ToStatic for T +where + T: ToBoundedStatic, +{ + type Static = ::Static; + + fn to_static(&self) -> Self::Static { + ToBoundedStatic::to_static(self) + } +} + +/// Create owned variant of object (consuming it). +/// +/// Useful, e.g., if you want to pass the object to another thread or executor. +pub trait IntoStatic { + type Static: 'static; + + fn into_static(self) -> Self::Static; +} + +impl IntoStatic for T +where + T: IntoBoundedStatic, +{ + type Static = ::Static; + + fn into_static(self) -> Self::Static { + IntoBoundedStatic::into_static(self) } } diff --git a/smtp-types/src/response.rs b/smtp-types/src/response.rs new file mode 100644 index 0000000..c2604d7 --- /dev/null +++ b/smtp-types/src/response.rs @@ -0,0 +1,794 @@ +//! SMTP response types. +//! +//! This module defines the responses that an SMTP server sends to a client +//! as specified in RFC 5321. + +use std::{ + borrow::Cow, + fmt::{Debug, Display, Formatter}, + str::FromStr, +}; + +#[cfg(feature = "arbitrary")] +use arbitrary::{Arbitrary, Unstructured}; +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ext_auth")] +use crate::auth::AuthMechanism; +use crate::{ + core::{Atom, Domain, Text, Vec1}, + error::{ValidationError, ValidationErrorKind}, +}; + +/// A 3-digit SMTP reply code. +/// +/// # ABNF Definition (RFC 5321) +/// +/// ```abnf +/// Reply-code = %x32-35 %x30-35 %x30-39 +/// ``` +/// +/// # Reference +/// +/// RFC 5321 Section 4.2: SMTP Replies +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, ToStatic)] +pub struct ReplyCode(u16); + +impl ReplyCode { + // Positive Completion (2xx) + /// 211 System status + pub const SYSTEM_STATUS: Self = Self(211); + /// 214 Help message + pub const HELP_MESSAGE: Self = Self(214); + /// 220 Service ready + pub const SERVICE_READY: Self = Self(220); + /// 221 Service closing transmission channel + pub const SERVICE_CLOSING: Self = Self(221); + /// 235 Authentication successful (RFC 4954) + pub const AUTH_SUCCESSFUL: Self = Self(235); + /// 250 Requested mail action okay, completed + pub const OK: Self = Self(250); + /// 251 User not local; will forward + pub const USER_NOT_LOCAL_WILL_FORWARD: Self = Self(251); + /// 252 Cannot VRFY user, but will accept message + pub const CANNOT_VRFY_USER: Self = Self(252); + + // Positive Intermediate (3xx) + /// 334 Server challenge (AUTH continuation) + pub const AUTH_CONTINUE: Self = Self(334); + /// 354 Start mail input + pub const START_MAIL_INPUT: Self = Self(354); + + // Transient Negative (4xx) + /// 421 Service not available, closing transmission channel + pub const SERVICE_NOT_AVAILABLE: Self = Self(421); + /// 450 Requested mail action not taken: mailbox unavailable + pub const MAILBOX_UNAVAILABLE_TEMP: Self = Self(450); + /// 451 Requested action aborted: local error in processing + pub const LOCAL_ERROR: Self = Self(451); + /// 452 Requested action not taken: insufficient system storage + pub const INSUFFICIENT_STORAGE: Self = Self(452); + /// 455 Server unable to accommodate parameters + pub const UNABLE_TO_ACCOMMODATE: Self = Self(455); + + // Permanent Negative (5xx) + /// 500 Syntax error, command unrecognized + pub const SYNTAX_ERROR: Self = Self(500); + /// 501 Syntax error in parameters or arguments + pub const SYNTAX_ERROR_PARAMS: Self = Self(501); + /// 502 Command not implemented + pub const COMMAND_NOT_IMPLEMENTED: Self = Self(502); + /// 503 Bad sequence of commands + pub const BAD_SEQUENCE: Self = Self(503); + /// 504 Command parameter not implemented + pub const PARAM_NOT_IMPLEMENTED: Self = Self(504); + /// 530 Authentication required (RFC 4954) + pub const AUTH_REQUIRED: Self = Self(530); + /// 534 Authentication mechanism is too weak (RFC 4954) + pub const AUTH_TOO_WEAK: Self = Self(534); + /// 535 Authentication credentials invalid (RFC 4954) + pub const AUTH_INVALID: Self = Self(535); + /// 550 Requested action not taken: mailbox unavailable + pub const MAILBOX_UNAVAILABLE: Self = Self(550); + /// 551 User not local; please try forwarding + pub const USER_NOT_LOCAL: Self = Self(551); + /// 552 Requested mail action aborted: exceeded storage allocation + pub const EXCEEDED_STORAGE: Self = Self(552); + /// 553 Requested action not taken: mailbox name not allowed + pub const MAILBOX_NAME_NOT_ALLOWED: Self = Self(553); + /// 554 Transaction failed + pub const TRANSACTION_FAILED: Self = Self(554); + /// 555 MAIL FROM/RCPT TO parameters not recognized or not implemented + pub const PARAMS_NOT_RECOGNIZED: Self = Self(555); + + /// Creates a new reply code from a u16. + /// + /// Returns `None` if the value is not a valid 3-digit code (200-599). + pub fn new(code: u16) -> Option { + if (200..600).contains(&code) { + Some(Self(code)) + } else { + None + } + } + + /// Returns the numeric value of the reply code. + pub fn code(&self) -> u16 { + self.0 + } + + /// Returns the first digit (class) of the reply code. + pub fn class(&self) -> u8 { + (self.0 / 100) as u8 + } + + /// Returns true if this is a positive completion reply (2xx). + pub fn is_positive_completion(&self) -> bool { + self.class() == 2 + } + + /// Returns true if this is a positive intermediate reply (3xx). + pub fn is_positive_intermediate(&self) -> bool { + self.class() == 3 + } + + /// Returns true if this is a transient negative reply (4xx). + pub fn is_transient_negative(&self) -> bool { + self.class() == 4 + } + + /// Returns true if this is a permanent negative reply (5xx). + pub fn is_permanent_negative(&self) -> bool { + self.class() == 5 + } + + /// Returns true if this is a success reply (2xx or 3xx). + pub fn is_success(&self) -> bool { + self.is_positive_completion() || self.is_positive_intermediate() + } + + /// Returns true if this is an error reply (4xx or 5xx). + pub fn is_error(&self) -> bool { + self.is_transient_negative() || self.is_permanent_negative() + } +} + +impl Debug for ReplyCode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "ReplyCode({})", self.0) + } +} + +impl Display for ReplyCode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for u16 { + fn from(code: ReplyCode) -> Self { + code.0 + } +} + +impl TryFrom for ReplyCode { + type Error = ValidationError; + + fn try_from(value: u16) -> Result { + ReplyCode::new(value).ok_or_else(|| ValidationError::new(ValidationErrorKind::Invalid)) + } +} + +impl FromStr for ReplyCode { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + let code: u16 = s + .parse() + .map_err(|_| ValidationError::new(ValidationErrorKind::Invalid))?; + ReplyCode::try_from(code) + } +} + +/// Enhanced status code (RFC 3463). +/// +/// Format: class.subject.detail (e.g., 2.1.0, 5.7.1) +/// +/// # Reference +/// +/// RFC 3463: Enhanced Mail System Status Codes +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ToStatic)] +#[cfg(feature = "ext_enhancedstatuscodes")] +pub struct EnhancedStatusCode { + /// Class: 2 (success), 4 (temporary failure), or 5 (permanent failure) + pub class: u8, + /// Subject: 0-999 + pub subject: u16, + /// Detail: 0-999 + pub detail: u16, +} + +#[cfg(feature = "ext_enhancedstatuscodes")] +impl EnhancedStatusCode { + /// Creates a new enhanced status code. + /// + /// Returns `None` if class is not 2, 4, or 5. + pub fn new(class: u8, subject: u16, detail: u16) -> Option { + if matches!(class, 2 | 4 | 5) && subject < 1000 && detail < 1000 { + Some(Self { + class, + subject, + detail, + }) + } else { + None + } + } + + /// Returns true if this indicates success. + pub fn is_success(&self) -> bool { + self.class == 2 + } + + /// Returns true if this indicates a temporary failure. + pub fn is_temporary_failure(&self) -> bool { + self.class == 4 + } + + /// Returns true if this indicates a permanent failure. + pub fn is_permanent_failure(&self) -> bool { + self.class == 5 + } +} + +#[cfg(feature = "ext_enhancedstatuscodes")] +impl Display for EnhancedStatusCode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.class, self.subject, self.detail) + } +} + +/// Server greeting sent upon connection. +/// +/// The greeting is the first response from an SMTP server, +/// typically a 220 response indicating the server is ready. +/// +/// # ABNF +/// +/// ```abnf +/// greeting = "220" SP Domain [ SP textstring ] CRLF +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct Greeting<'a> { + /// The server's domain name + pub domain: Domain<'a>, + /// Optional greeting text + pub text: Option>, +} + +impl<'a> Greeting<'a> { + /// Creates a new greeting. + pub fn new(domain: Domain<'a>, text: Option>) -> Self { + Self { domain, text } + } +} + +impl Display for Greeting<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "220 {}", self.domain)?; + if let Some(ref text) = self.text { + write!(f, " {text}")?; + } + Ok(()) + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Greeting<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(Greeting { + domain: Domain::arbitrary(u)?, + text: if u.arbitrary()? { + Some(Text::arbitrary(u)?) + } else { + None + }, + }) + } +} + +/// A complete SMTP response (possibly multi-line). +/// +/// # ABNF +/// +/// ```abnf +/// Reply-line = *( Reply-code "-" [ textstring ] CRLF ) +/// Reply-code [ SP textstring ] CRLF +/// ``` +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct Response<'a> { + /// The 3-digit reply code + pub code: ReplyCode, + /// Enhanced status code (if ENHANCEDSTATUSCODES extension is supported) + #[cfg(feature = "ext_enhancedstatuscodes")] + pub enhanced_code: Option, + /// One or more response lines + pub lines: Vec1>, +} + +impl<'a> Response<'a> { + /// Creates a new single-line response. + pub fn new(code: ReplyCode, text: Text<'a>) -> Self { + Self { + code, + #[cfg(feature = "ext_enhancedstatuscodes")] + enhanced_code: None, + lines: Vec1::from(text), + } + } + + /// Creates a new multi-line response. + pub fn new_multiline(code: ReplyCode, lines: Vec1>) -> Self { + Self { + code, + #[cfg(feature = "ext_enhancedstatuscodes")] + enhanced_code: None, + lines, + } + } + + /// Creates a new response with an enhanced status code. + #[cfg(feature = "ext_enhancedstatuscodes")] + pub fn with_enhanced_code( + code: ReplyCode, + enhanced_code: EnhancedStatusCode, + text: Text<'a>, + ) -> Self { + Self { + code, + enhanced_code: Some(enhanced_code), + lines: Vec1::from(text), + } + } + + /// Returns true if this is a success response. + pub fn is_success(&self) -> bool { + self.code.is_success() + } + + /// Returns true if this is an error response. + pub fn is_error(&self) -> bool { + self.code.is_error() + } + + /// Returns the first (or only) line of text. + pub fn text(&self) -> &Text<'a> { + &self.lines.as_ref()[0] + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Response<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(Response { + code: ReplyCode::arbitrary(u)?, + #[cfg(feature = "ext_enhancedstatuscodes")] + enhanced_code: if u.arbitrary()? { + Some(EnhancedStatusCode::arbitrary(u)?) + } else { + None + }, + lines: Vec1::arbitrary(u)?, + }) + } +} + +/// An SMTP server capability announced in EHLO response. +/// +/// # Reference +/// +/// RFC 5321 Section 4.1.1.1 +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "content"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +#[non_exhaustive] +pub enum Capability<'a> { + /// SIZE extension with optional maximum size. + /// + /// # Reference + /// + /// RFC 1870: SMTP Service Extension for Message Size Declaration + #[cfg(feature = "ext_size")] + Size(Option), + + /// 8BITMIME extension. + /// + /// # Reference + /// + /// RFC 6152: SMTP Service Extension for 8-bit MIME Transport + #[cfg(feature = "ext_8bitmime")] + EightBitMime, + + /// PIPELINING extension. + /// + /// # Reference + /// + /// RFC 2920: SMTP Service Extension for Command Pipelining + #[cfg(feature = "ext_pipelining")] + Pipelining, + + /// STARTTLS extension. + /// + /// # Reference + /// + /// RFC 3207: SMTP Service Extension for Secure SMTP over TLS + #[cfg(feature = "starttls")] + StartTls, + + /// SMTPUTF8 extension. + /// + /// # Reference + /// + /// RFC 6531: SMTP Extension for Internationalized Email + #[cfg(feature = "ext_smtputf8")] + SmtpUtf8, + + /// ENHANCEDSTATUSCODES extension. + /// + /// # Reference + /// + /// RFC 2034: SMTP Service Extension for Returning Enhanced Error Codes + #[cfg(feature = "ext_enhancedstatuscodes")] + EnhancedStatusCodes, + + /// AUTH extension with supported mechanisms. + /// + /// # Reference + /// + /// RFC 4954: SMTP Service Extension for Authentication + #[cfg(feature = "ext_auth")] + Auth(Vec>), + + /// Other/unknown capability. + Other { + /// The capability keyword + keyword: Atom<'a>, + /// Optional parameters + params: Option>, + }, +} + +impl Display for Capability<'_> { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + #[cfg(feature = "ext_size")] + Capability::Size(Some(size)) => write!(f, "SIZE {size}"), + #[cfg(feature = "ext_size")] + Capability::Size(None) => write!(f, "SIZE"), + #[cfg(feature = "ext_8bitmime")] + Capability::EightBitMime => write!(f, "8BITMIME"), + #[cfg(feature = "ext_pipelining")] + Capability::Pipelining => write!(f, "PIPELINING"), + #[cfg(feature = "starttls")] + Capability::StartTls => write!(f, "STARTTLS"), + #[cfg(feature = "ext_smtputf8")] + Capability::SmtpUtf8 => write!(f, "SMTPUTF8"), + #[cfg(feature = "ext_enhancedstatuscodes")] + Capability::EnhancedStatusCodes => write!(f, "ENHANCEDSTATUSCODES"), + #[cfg(feature = "ext_auth")] + Capability::Auth(mechanisms) => { + write!(f, "AUTH")?; + for mech in mechanisms { + write!(f, " {}", mech.as_ref())?; + } + Ok(()) + } + Capability::Other { keyword, params } => { + write!(f, "{keyword}")?; + if let Some(params) = params { + write!(f, " {params}")?; + } + Ok(()) + } + } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for Capability<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + // Count available variants based on features + #[allow(unused_mut)] + let mut variant_count = 1; // Other is always available + #[cfg(feature = "ext_size")] + { + variant_count += 1; + } + #[cfg(feature = "ext_8bitmime")] + { + variant_count += 1; + } + #[cfg(feature = "ext_pipelining")] + { + variant_count += 1; + } + #[cfg(feature = "starttls")] + { + variant_count += 1; + } + #[cfg(feature = "ext_smtputf8")] + { + variant_count += 1; + } + #[cfg(feature = "ext_enhancedstatuscodes")] + { + variant_count += 1; + } + #[cfg(feature = "ext_auth")] + { + variant_count += 1; + } + + #[allow(unused_variables)] + let variant: u8 = u.int_in_range(0..=(variant_count - 1))?; + #[allow(unused_mut, unused_variables)] + let mut idx = 0u8; + + #[cfg(feature = "ext_size")] + { + if variant == idx { + return Ok(Capability::Size(if u.arbitrary()? { + Some(u.arbitrary()?) + } else { + None + })); + } + idx += 1; + } + #[cfg(feature = "ext_8bitmime")] + { + if variant == idx { + return Ok(Capability::EightBitMime); + } + idx += 1; + } + #[cfg(feature = "ext_pipelining")] + { + if variant == idx { + return Ok(Capability::Pipelining); + } + idx += 1; + } + #[cfg(feature = "starttls")] + { + if variant == idx { + return Ok(Capability::StartTls); + } + idx += 1; + } + #[cfg(feature = "ext_smtputf8")] + { + if variant == idx { + return Ok(Capability::SmtpUtf8); + } + idx += 1; + } + #[cfg(feature = "ext_enhancedstatuscodes")] + { + if variant == idx { + return Ok(Capability::EnhancedStatusCodes); + } + idx += 1; + } + #[cfg(feature = "ext_auth")] + { + if variant == idx { + let len: usize = u.int_in_range(1..=3)?; + let mut mechs = Vec::with_capacity(len); + for _ in 0..len { + mechs.push(AuthMechanism::arbitrary(u)?); + } + return Ok(Capability::Auth(mechs)); + } + #[allow(unused_assignments)] + { + idx += 1; + } + } + + // Default: Other capability + Ok(Capability::Other { + keyword: Atom::arbitrary(u)?, + params: if u.arbitrary()? { + Some(Cow::Owned(String::arbitrary(u)?)) + } else { + None + }, + }) + } +} + +/// EHLO response containing server capabilities. +/// +/// The first line contains the server's domain, subsequent lines +/// contain one capability per line. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] +pub struct EhloResponse<'a> { + /// The server's domain name + pub domain: Domain<'a>, + /// Optional greeting text on the first line + pub greet: Option>, + /// Server capabilities + pub capabilities: Vec>, +} + +impl<'a> EhloResponse<'a> { + /// Creates a new EHLO response. + pub fn new(domain: Domain<'a>) -> Self { + Self { + domain, + greet: None, + capabilities: Vec::new(), + } + } + + /// Creates a new EHLO response with greeting text. + pub fn with_greet(domain: Domain<'a>, greet: Text<'a>) -> Self { + Self { + domain, + greet: Some(greet), + capabilities: Vec::new(), + } + } + + /// Adds a capability to the response. + pub fn add_capability(&mut self, capability: Capability<'a>) { + self.capabilities.push(capability); + } + + /// Returns true if the server supports the given capability. + pub fn has_capability(&self, name: &str) -> bool { + let name_upper = name.to_ascii_uppercase(); + self.capabilities.iter().any(|cap| match cap { + #[cfg(feature = "ext_size")] + Capability::Size(_) => name_upper == "SIZE", + #[cfg(feature = "ext_8bitmime")] + Capability::EightBitMime => name_upper == "8BITMIME", + #[cfg(feature = "ext_pipelining")] + Capability::Pipelining => name_upper == "PIPELINING", + #[cfg(feature = "starttls")] + Capability::StartTls => name_upper == "STARTTLS", + #[cfg(feature = "ext_smtputf8")] + Capability::SmtpUtf8 => name_upper == "SMTPUTF8", + #[cfg(feature = "ext_enhancedstatuscodes")] + Capability::EnhancedStatusCodes => name_upper == "ENHANCEDSTATUSCODES", + #[cfg(feature = "ext_auth")] + Capability::Auth(_) => name_upper == "AUTH", + Capability::Other { keyword, .. } => { + keyword.as_ref().to_ascii_uppercase() == name_upper + } + }) + } + + /// Returns the AUTH mechanisms if AUTH capability is present. + #[cfg(feature = "ext_auth")] + pub fn auth_mechanisms(&self) -> Option<&[AuthMechanism<'a>]> { + self.capabilities.iter().find_map(|cap| match cap { + Capability::Auth(mechanisms) => Some(mechanisms.as_slice()), + _ => None, + }) + } + + /// Returns the maximum message size if SIZE capability is present. + #[cfg(feature = "ext_size")] + pub fn max_size(&self) -> Option { + self.capabilities.iter().find_map(|cap| match cap { + Capability::Size(size) => *size, + _ => None, + }) + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> Arbitrary<'a> for EhloResponse<'static> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let len: usize = u.int_in_range(0..=5)?; + let mut capabilities = Vec::with_capacity(len); + for _ in 0..len { + capabilities.push(Capability::arbitrary(u)?); + } + Ok(EhloResponse { + domain: Domain::arbitrary(u)?, + greet: if u.arbitrary()? { + Some(Text::arbitrary(u)?) + } else { + None + }, + capabilities, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reply_code() { + assert_eq!(ReplyCode::OK.code(), 250); + assert!(ReplyCode::OK.is_positive_completion()); + assert!(ReplyCode::OK.is_success()); + assert!(!ReplyCode::OK.is_error()); + + assert_eq!(ReplyCode::START_MAIL_INPUT.code(), 354); + assert!(ReplyCode::START_MAIL_INPUT.is_positive_intermediate()); + assert!(ReplyCode::START_MAIL_INPUT.is_success()); + + assert_eq!(ReplyCode::MAILBOX_UNAVAILABLE_TEMP.code(), 450); + assert!(ReplyCode::MAILBOX_UNAVAILABLE_TEMP.is_transient_negative()); + assert!(ReplyCode::MAILBOX_UNAVAILABLE_TEMP.is_error()); + + assert_eq!(ReplyCode::SYNTAX_ERROR.code(), 500); + assert!(ReplyCode::SYNTAX_ERROR.is_permanent_negative()); + assert!(ReplyCode::SYNTAX_ERROR.is_error()); + } + + #[test] + fn test_reply_code_parse() { + assert_eq!(ReplyCode::from_str("250").unwrap(), ReplyCode::OK); + assert!(ReplyCode::from_str("999").is_err()); + assert!(ReplyCode::from_str("abc").is_err()); + } + + #[test] + fn test_reply_code_class() { + assert_eq!(ReplyCode::OK.class(), 2); + assert_eq!(ReplyCode::START_MAIL_INPUT.class(), 3); + assert_eq!(ReplyCode::LOCAL_ERROR.class(), 4); + assert_eq!(ReplyCode::SYNTAX_ERROR.class(), 5); + } + + #[cfg(feature = "ext_enhancedstatuscodes")] + #[test] + fn test_enhanced_status_code() { + let code = EnhancedStatusCode::new(2, 1, 0).unwrap(); + assert!(code.is_success()); + assert_eq!(code.to_string(), "2.1.0"); + + let code = EnhancedStatusCode::new(5, 7, 1).unwrap(); + assert!(code.is_permanent_failure()); + assert_eq!(code.to_string(), "5.7.1"); + + // Invalid class + assert!(EnhancedStatusCode::new(3, 0, 0).is_none()); + } + + #[test] + fn test_greeting() { + let domain = Domain::try_from("mail.example.com").unwrap(); + let greeting = Greeting::new(domain.clone(), None); + assert_eq!(greeting.to_string(), "220 mail.example.com"); + + let text = Text::try_from("ESMTP ready").unwrap(); + let greeting = Greeting::new(domain, Some(text)); + assert_eq!(greeting.to_string(), "220 mail.example.com ESMTP ready"); + } + + #[test] + fn test_response() { + let text = Text::try_from("OK").unwrap(); + let response = Response::new(ReplyCode::OK, text); + assert!(response.is_success()); + assert!(!response.is_error()); + assert_eq!(response.text().inner(), "OK"); + } +} diff --git a/smtp-types/src/secret.rs b/smtp-types/src/secret.rs new file mode 100644 index 0000000..19f9401 --- /dev/null +++ b/smtp-types/src/secret.rs @@ -0,0 +1,70 @@ +//! Handling of secret values. +//! +//! This module provides a `Secret` ensuring that sensitive values are not +//! `Debug`-printed by accident. + +use std::fmt::{Debug, Formatter}; + +#[cfg(feature = "arbitrary")] +use arbitrary::Arbitrary; +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A wrapper to ensure that secrets are redacted during `Debug`-printing. +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] +#[derive(Clone, Eq, Hash, PartialEq, ToStatic)] +pub struct Secret(T); + +impl Secret { + /// Create a new secret. + pub fn new(inner: T) -> Self { + Self(inner) + } + + /// Expose the inner secret. + pub fn declassify(&self) -> &T { + &self.0 + } +} + +impl From for Secret { + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl Debug for Secret +where + T: Debug, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + #[cfg(not(debug_assertions))] + return write!(f, "/* REDACTED */"); + #[cfg(debug_assertions)] + return self.0.fmt(f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(not(debug_assertions))] + fn test_that_secret_is_redacted() { + let secret = Secret("xyz123"); + let got = format!("{:?}", secret); + assert!(!got.contains("xyz123")); + assert!(got.contains("REDACTED")); + } + + #[test] + #[cfg(debug_assertions)] + fn test_that_secret_is_visible_in_debug() { + let secret = Secret("xyz123"); + let got = format!("{:?}", secret); + assert!(got.contains("xyz123")); + } +} diff --git a/smtp-types/src/state.rs b/smtp-types/src/state.rs new file mode 100644 index 0000000..55a2a95 --- /dev/null +++ b/smtp-types/src/state.rs @@ -0,0 +1,213 @@ +//! SMTP session state. +//! +//! SMTP is a stateful protocol where certain commands are only valid in certain states. +//! The session progresses through states as commands are executed successfully. +//! +//! ```text +//! +----------------------+ +//! |connection established| +//! +----------------------+ +//! || +//! \/ +//! +----------------------+ +//! | Greeting (220) | +//! +----------------------+ +//! || +//! \/ +//! +----------------------+ +//! | EHLO/HELO |<----+ +//! +----------------------+ | +//! || | +//! \/ | +//! +----------------------+ | +//! | Ready |-----+ (RSET) +//! +----------------------+ +//! || +//! \/ (MAIL FROM) +//! +----------------------+ +//! | Mail | +//! +----------------------+ +//! || +//! \/ (RCPT TO) +//! +----------------------+ +//! | Rcpt |<----+ (more RCPT TO) +//! +----------------------+-----+ +//! || +//! \/ (DATA) +//! +----------------------+ +//! | Data | +//! +----------------------+ +//! || +//! \/ (message + CRLF.CRLF) +//! +----------------------+ +//! | Ready | +//! +----------------------+ +//! || +//! \/ (QUIT) +//! +----------------------+ +//! | Quit | +//! +----------------------+ +//! ``` + +#[cfg(feature = "arbitrary")] +use arbitrary::Arbitrary; +use bounded_static_derive::ToStatic; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// State of an SMTP session. +/// +/// # Reference +/// +/// RFC 5321 Section 3: The SMTP Procedures +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ToStatic, Default)] +pub enum State { + /// Initial state after connection, waiting for server greeting. + /// + /// The client should wait for a 220 response from the server. + #[default] + Connect, + + /// After receiving the 220 greeting, before EHLO/HELO. + /// + /// The client should send EHLO or HELO to identify itself. + Greeted, + + /// After successful EHLO/HELO, ready for mail transaction. + /// + /// In this state, the client can: + /// - Send MAIL FROM to start a transaction + /// - Send QUIT to end the session + /// - Send RSET, NOOP, HELP, VRFY, EXPN + /// - Send STARTTLS (if supported) + /// - Send AUTH (if supported and not authenticated) + Ready, + + /// After successful MAIL FROM, waiting for RCPT TO. + /// + /// In this state, the client must send at least one RCPT TO. + Mail, + + /// After at least one successful RCPT TO, ready for DATA or more RCPT TO. + /// + /// In this state, the client can: + /// - Send more RCPT TO commands + /// - Send DATA to begin message transfer + /// - Send RSET to abort the transaction + Rcpt, + + /// After DATA command, transferring message content. + /// + /// In this state, the client sends the message content, + /// terminated by `.`. + Data, + + /// Session ended (after QUIT or server disconnect). + /// + /// No more commands should be sent. + Quit, +} + +impl State { + /// Returns true if MAIL FROM is valid in this state. + pub fn can_mail(&self) -> bool { + matches!(self, State::Ready) + } + + /// Returns true if RCPT TO is valid in this state. + pub fn can_rcpt(&self) -> bool { + matches!(self, State::Mail | State::Rcpt) + } + + /// Returns true if DATA is valid in this state. + pub fn can_data(&self) -> bool { + matches!(self, State::Rcpt) + } + + /// Returns true if RSET is valid in this state. + pub fn can_rset(&self) -> bool { + !matches!(self, State::Connect | State::Quit | State::Data) + } + + /// Returns true if the session is active (not quit). + pub fn is_active(&self) -> bool { + !matches!(self, State::Quit) + } + + /// Returns true if we're in the middle of a mail transaction. + pub fn in_transaction(&self) -> bool { + matches!(self, State::Mail | State::Rcpt | State::Data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IntoStatic, ToStatic}; + + #[test] + fn test_state_transitions() { + // Initial state + let state = State::default(); + assert_eq!(state, State::Connect); + assert!(!state.can_mail()); + assert!(state.is_active()); + + // After greeting + let state = State::Greeted; + assert!(!state.can_mail()); + + // Ready state + let state = State::Ready; + assert!(state.can_mail()); + assert!(!state.can_rcpt()); + assert!(!state.can_data()); + assert!(state.can_rset()); + + // Mail state + let state = State::Mail; + assert!(!state.can_mail()); + assert!(state.can_rcpt()); + assert!(!state.can_data()); + assert!(state.in_transaction()); + + // Rcpt state + let state = State::Rcpt; + assert!(state.can_rcpt()); + assert!(state.can_data()); + assert!(state.in_transaction()); + + // Data state + let state = State::Data; + assert!(!state.can_rset()); + assert!(state.in_transaction()); + + // Quit state + let state = State::Quit; + assert!(!state.is_active()); + assert!(!state.can_rset()); + } + + #[test] + fn test_conversion() { + let tests = [ + State::Connect, + State::Greeted, + State::Ready, + State::Mail, + State::Rcpt, + State::Data, + State::Quit, + ]; + + for test in tests { + let test_to_static = test.to_static(); + assert_eq!(test, test_to_static); + + let test_into_static = test.into_static(); + assert_eq!(test_to_static, test_into_static); + } + } +} diff --git a/smtp-types/src/utils.rs b/smtp-types/src/utils.rs index 5ed3ebb..fce69bd 100644 --- a/smtp-types/src/utils.rs +++ b/smtp-types/src/utils.rs @@ -1,6 +1,167 @@ +//! Functions that may come in handy. + use std::borrow::Cow; -pub(crate) fn escape_quoted(unescaped: &str) -> Cow { +/// Converts bytes into a ready-to-be-printed form. +pub fn escape_byte_string(bytes: B) -> String +where + B: AsRef<[u8]>, +{ + let bytes = bytes.as_ref(); + + bytes + .iter() + .map(|byte| match byte { + 0x00..=0x08 => format!("\\x{byte:02x}"), + 0x09 => String::from("\\t"), + 0x0A => String::from("\\n"), + 0x0B => format!("\\x{byte:02x}"), + 0x0C => format!("\\x{byte:02x}"), + 0x0D => String::from("\\r"), + 0x0e..=0x1f => format!("\\x{byte:02x}"), + 0x20..=0x21 => format!("{}", *byte as char), + 0x22 => String::from("\\\""), + 0x23..=0x5B => format!("{}", *byte as char), + 0x5C => String::from("\\\\"), + 0x5D..=0x7E => format!("{}", *byte as char), + 0x7f => format!("\\x{byte:02x}"), + 0x80..=0xff => format!("\\x{byte:02x}"), + }) + .collect::>() + .join("") +} + +pub mod indicators { + //! Character class indicators for SMTP (RFC 5321). + + /// Any 7-bit US-ASCII character, excluding NUL + /// + /// CHAR = %x01-7F + #[inline] + pub fn is_char(byte: u8) -> bool { + matches!(byte, 0x01..=0x7f) + } + + /// Controls + /// + /// CTL = %x00-1F / %x7F + #[inline] + pub fn is_ctl(byte: u8) -> bool { + matches!(byte, 0x00..=0x1f | 0x7f) + } + + /// SMTP atext characters (RFC 5321/5322) + /// + /// ```abnf + /// atext = ALPHA / DIGIT / + /// "!" / "#" / "$" / "%" / "&" / "'" / "*" / + /// "+" / "-" / "/" / "=" / "?" / "^" / "_" / + /// "`" / "{" / "|" / "}" / "~" + /// ``` + #[inline] + pub fn is_atext(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'/' + | b'=' + | b'?' + | b'^' + | b'_' + | b'`' + | b'{' + | b'|' + | b'}' + | b'~' + ) + } + + /// SMTP qtext characters (RFC 5321) + /// + /// ```abnf + /// qtext = %d32-33 / %d35-91 / %d93-126 ; printable except \ and " + /// ``` + #[inline] + pub fn is_qtext(byte: u8) -> bool { + matches!(byte, 32..=33 | 35..=91 | 93..=126) + } + + /// Text string characters for SMTP response text + /// + /// ```abnf + /// textstring = 1*(%d09 / %d32-126) ; HT, SP, Printable US-ASCII + /// ``` + #[inline] + pub fn is_text_char(byte: u8) -> bool { + byte == 0x09 || matches!(byte, 0x20..=0x7e) + } + + /// Let-dig: alphanumeric character (RFC 5321) + /// + /// ```abnf + /// Let-dig = ALPHA / DIGIT + /// ``` + #[inline] + pub fn is_let_dig(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + } + + /// Ldh-str character: alphanumeric or hyphen (RFC 5321) + /// + /// ```abnf + /// Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig + /// ``` + #[inline] + pub fn is_ldh_str_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'-' + } + + /// ESMTP keyword character (RFC 5321) + /// + /// ```abnf + /// esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") + /// ``` + #[inline] + pub fn is_esmtp_keyword_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'-' + } + + /// ESMTP value character (RFC 5321) + /// + /// ```abnf + /// esmtp-value = 1*(%d33-60 / %d62-126) ; any CHAR excluding "=", SP, and CTL + /// ``` + #[inline] + pub fn is_esmtp_value_char(byte: u8) -> bool { + matches!(byte, 33..=60 | 62..=126) + } + + /// Reply code digit (RFC 5321) + #[inline] + pub fn is_digit(byte: u8) -> bool { + byte.is_ascii_digit() + } + + /// Dcontent character for address literals (RFC 5321) + /// + /// ```abnf + /// dcontent = %d33-90 / %d94-126 ; printable except [ \ ] + /// ``` + #[inline] + pub fn is_dcontent(byte: u8) -> bool { + matches!(byte, 33..=90 | 94..=126) + } +} + +pub fn escape_quoted(unescaped: &str) -> Cow<'_, str> { let mut escaped = Cow::Borrowed(unescaped); if escaped.contains('\\') { @@ -8,8 +169,139 @@ pub(crate) fn escape_quoted(unescaped: &str) -> Cow { } if escaped.contains('\"') { - escaped = Cow::Owned(escaped.replace('\"', "\\\"")); + escaped = Cow::Owned(escaped.replace('"', "\\\"")); } escaped } + +pub fn unescape_quoted(escaped: &str) -> Cow<'_, str> { + let mut unescaped = Cow::Borrowed(escaped); + + if unescaped.contains("\\\\") { + unescaped = Cow::Owned(unescaped.replace("\\\\", "\\")); + } + + if unescaped.contains("\\\"") { + unescaped = Cow::Owned(unescaped.replace("\\\"", "\"")); + } + + unescaped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_quoted() { + let tests = [ + ("", ""), + ("\\", "\\\\"), + ("\"", "\\\""), + ("alice", "alice"), + ("\\alice\\", "\\\\alice\\\\"), + ("alice\"", "alice\\\""), + (r#"\alice\ ""#, r#"\\alice\\ \""#), + ]; + + for (test, expected) in tests { + let got = escape_quoted(test); + assert_eq!(expected, got); + } + } + + #[test] + fn test_unescape_quoted() { + let tests = [ + ("", ""), + ("\\\\", "\\"), + ("\\\"", "\""), + ("alice", "alice"), + ("\\\\alice\\\\", "\\alice\\"), + ("alice\\\"", "alice\""), + (r#"\\alice\\ \""#, r#"\alice\ ""#), + ]; + + for (test, expected) in tests { + let got = unescape_quoted(test); + assert_eq!(expected, got); + } + } + + #[test] + fn test_that_unescape_is_inverse_of_escape() { + let input = "\\\"\\abc_*:;059^$%!\""; + + assert_eq!(input, unescape_quoted(escape_quoted(input).as_ref())); + } + + #[test] + fn test_escape_byte_string() { + for byte in 0u8..=255 { + let got = escape_byte_string([byte]); + + if byte.is_ascii_alphanumeric() { + assert_eq!((byte as char).to_string(), got.to_string()); + } else if byte.is_ascii_whitespace() { + if byte == b'\t' { + assert_eq!(String::from("\\t"), got); + } else if byte == b'\n' { + assert_eq!(String::from("\\n"), got); + } + } else if byte.is_ascii_punctuation() { + if byte == b'\\' { + assert_eq!(String::from("\\\\"), got); + } else if byte == b'"' { + assert_eq!(String::from("\\\""), got); + } else { + assert_eq!((byte as char).to_string(), got); + } + } else { + assert_eq!(format!("\\x{byte:02x}"), got); + } + } + + let tests = [(b"Hallo \"\\\x00", String::from(r#"Hallo \"\\\x00"#))]; + + for (test, expected) in tests { + let got = escape_byte_string(test); + assert_eq!(expected, got); + } + } + + #[test] + fn test_is_atext() { + // Alphanumeric + assert!(indicators::is_atext(b'a')); + assert!(indicators::is_atext(b'Z')); + assert!(indicators::is_atext(b'0')); + assert!(indicators::is_atext(b'9')); + + // Special chars + assert!(indicators::is_atext(b'!')); + assert!(indicators::is_atext(b'#')); + assert!(indicators::is_atext(b'+')); + assert!(indicators::is_atext(b'-')); + + // Invalid + assert!(!indicators::is_atext(b' ')); + assert!(!indicators::is_atext(b'@')); + assert!(!indicators::is_atext(b'<')); + assert!(!indicators::is_atext(b'>')); + } + + #[test] + fn test_is_text_char() { + assert!(indicators::is_text_char(b' ')); + assert!(indicators::is_text_char(b'A')); + assert!(indicators::is_text_char(b'~')); + assert!(indicators::is_text_char(0x09)); // HT + + // Invalid + assert!(!indicators::is_text_char(0x00)); + assert!(!indicators::is_text_char(0x0d)); // CR + assert!(!indicators::is_text_char(0x0a)); // LF + assert!(!indicators::is_text_char(0x7f)); // DEL + } +}