#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
Smtp,
Lmtp,
}
#[derive(Debug, Clone)]
pub struct RecipientResult {
pub recipient: String,
pub response: SmtpResponse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmtpResponse {
pub code: u16,
pub enhanced_code: Option<EnhancedStatusCode>,
pub lines: Vec<String>,
}
impl SmtpResponse {
pub fn is_success(&self) -> bool {
(200..300).contains(&self.code)
}
pub fn is_intermediate(&self) -> bool {
(300..400).contains(&self.code)
}
pub fn is_transient_error(&self) -> bool {
(400..500).contains(&self.code)
}
pub fn is_permanent_error(&self) -> bool {
(500..600).contains(&self.code)
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnhancedStatusCode {
pub class: u8,
pub subject: u16,
pub detail: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SmtpExtension {
EightBitMime,
Pipelining,
Size(Option<u64>),
StartTls,
Auth(Vec<AuthMechanism>),
Chunking,
BinaryMime,
SmtpUtf8,
EnhancedStatusCodes,
SaslIr,
Dsn,
RequireTls,
FutureRelease {
max_interval: Option<u64>,
max_datetime: Option<String>,
},
DeliverBy(Option<u64>),
MtPriority,
Vrfy,
Expn,
NoSoliciting(Option<String>),
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AuthMechanism {
Plain,
Login,
OAuthBearer,
XOAuth2,
Other(String),
}
impl AuthMechanism {
fn eq_mechanism(&self, other: &Self) -> bool {
match (self, other) {
(Self::Plain, Self::Plain)
| (Self::Login, Self::Login)
| (Self::OAuthBearer, Self::OAuthBearer)
| (Self::XOAuth2, Self::XOAuth2) => true,
(Self::Other(a), Self::Other(b)) => a.eq_ignore_ascii_case(b),
(Self::Other(name), Self::Plain) | (Self::Plain, Self::Other(name)) => {
name.eq_ignore_ascii_case("PLAIN")
}
(Self::Other(name), Self::Login) | (Self::Login, Self::Other(name)) => {
name.eq_ignore_ascii_case("LOGIN")
}
(Self::Other(name), Self::OAuthBearer) | (Self::OAuthBearer, Self::Other(name)) => {
name.eq_ignore_ascii_case("OAUTHBEARER")
}
(Self::Other(name), Self::XOAuth2) | (Self::XOAuth2, Self::Other(name)) => {
name.eq_ignore_ascii_case("XOAUTH2")
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ServerCapabilities {
pub greeting_name: String,
pub extensions: Vec<SmtpExtension>,
}
impl ServerCapabilities {
pub fn supports_auth(&self, mechanism: &AuthMechanism) -> bool {
self.extensions.iter().any(|ext| {
if let SmtpExtension::Auth(mechs) = ext {
mechs.iter().any(|m| m.eq_mechanism(mechanism))
} else {
false
}
})
}
fn has_extension(&self, predicate: fn(&SmtpExtension) -> bool) -> bool {
self.extensions.iter().any(predicate)
}
pub fn supports_starttls(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::StartTls))
}
pub fn supports_chunking(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Chunking))
}
pub fn supports_size(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Size(_)))
}
pub fn size_limit(&self) -> Option<u64> {
self.extensions.iter().find_map(|ext| {
if let SmtpExtension::Size(limit) = ext {
*limit
} else {
None
}
})
}
pub fn supports_8bitmime(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::EightBitMime))
}
pub fn supports_binarymime(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::BinaryMime))
}
pub fn supports_8bit_or_binary(&self) -> bool {
self.supports_8bitmime() || self.supports_binarymime()
}
pub fn supports_pipelining(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Pipelining))
}
pub fn supports_smtputf8(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::SmtpUtf8))
}
pub fn supports_sasl_ir(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::SaslIr))
}
pub fn supports_dsn(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Dsn))
}
pub fn supports_requiretls(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::RequireTls))
}
pub fn supports_future_release(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::FutureRelease { .. }))
}
pub fn supports_deliver_by(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::DeliverBy(_)))
}
pub fn supports_mt_priority(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::MtPriority))
}
pub fn supports_vrfy(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Vrfy))
}
pub fn supports_expn(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::Expn))
}
pub fn supports_enhanced_status_codes(&self) -> bool {
self.has_extension(|ext| matches!(ext, SmtpExtension::EnhancedStatusCodes))
}
}
#[derive(Debug, Clone, Default)]
pub struct MailFromParams {
pub size: Option<u64>,
pub body: Option<BodyType>,
pub smtputf8: bool,
pub requiretls: bool,
pub ret: Option<DsnRet>,
pub envid: Option<String>,
pub hold_for: Option<u64>,
pub hold_until: Option<String>,
pub deliver_by: Option<DeliverBy>,
pub mt_priority: Option<i8>,
}
#[derive(Debug, Clone, Default)]
pub struct RcptToParams {
pub notify: Option<Vec<DsnNotify>>,
pub orcpt: Option<String>,
}
impl RcptToParams {
pub fn is_empty(&self) -> bool {
self.notify.is_none() && self.orcpt.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeliverBy {
pub seconds: i64,
pub mode: DeliverByMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliverByMode {
Notify,
Return,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DsnRet {
Full,
Hdrs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DsnNotify {
Success,
Failure,
Delay,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyType {
SevenBit,
EightBitMime,
BinaryMime,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smtp_response_classification() {
let ok = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["OK".into()],
};
assert!(ok.is_success());
assert!(!ok.is_transient_error());
assert!(!ok.is_permanent_error());
let transient = SmtpResponse {
code: 421,
enhanced_code: None,
lines: vec!["Try again later".into()],
};
assert!(transient.is_transient_error());
assert!(!transient.is_success());
let permanent = SmtpResponse {
code: 550,
enhanced_code: None,
lines: vec!["Mailbox not found".into()],
};
assert!(permanent.is_permanent_error());
assert!(!permanent.is_transient_error());
}
#[test]
fn smtp_response_text() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["line1".into(), "line2".into()],
};
assert_eq!(resp.text(), "line1\nline2");
}
#[test]
fn server_capabilities_starttls() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::StartTls, SmtpExtension::Pipelining],
};
assert!(caps.supports_starttls());
assert!(!caps.supports_chunking());
}
#[test]
fn server_capabilities_auth() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Auth(vec![
AuthMechanism::Plain,
AuthMechanism::XOAuth2,
])],
};
assert!(caps.supports_auth(&AuthMechanism::Plain));
assert!(caps.supports_auth(&AuthMechanism::XOAuth2));
assert!(!caps.supports_auth(&AuthMechanism::Other("CRAM-MD5".into())));
}
#[test]
fn intermediate_response() {
let resp = SmtpResponse {
code: 354,
enhanced_code: None,
lines: vec!["Start mail input".into()],
};
assert!(resp.is_intermediate());
assert!(!resp.is_success());
}
#[test]
fn supports_8bitmime() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
assert!(caps.supports_8bitmime());
let empty = ServerCapabilities::default();
assert!(!empty.supports_8bitmime());
}
#[test]
fn supports_binarymime() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::BinaryMime],
};
assert!(caps.supports_binarymime());
let empty = ServerCapabilities::default();
assert!(!empty.supports_binarymime());
}
#[test]
fn supports_8bit_or_binary() {
let with_8bit = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
assert!(with_8bit.supports_8bit_or_binary());
let with_binary = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::BinaryMime],
};
assert!(with_binary.supports_8bit_or_binary());
let with_both = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::EightBitMime, SmtpExtension::BinaryMime],
};
assert!(with_both.supports_8bit_or_binary());
let empty = ServerCapabilities::default();
assert!(!empty.supports_8bit_or_binary());
}
#[test]
fn supports_pipelining() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Pipelining],
};
assert!(caps.supports_pipelining());
let empty = ServerCapabilities::default();
assert!(!empty.supports_pipelining());
}
#[test]
fn supports_smtputf8() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::SmtpUtf8],
};
assert!(caps.supports_smtputf8());
let empty = ServerCapabilities::default();
assert!(!empty.supports_smtputf8());
}
#[test]
fn supports_auth_case_insensitive_other_mechanism() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Auth(vec![
AuthMechanism::Other("LOGIN".into()),
AuthMechanism::Other("CRAM-MD5".into()),
])],
};
assert!(
caps.supports_auth(&AuthMechanism::Other("LOGIN".into())),
"exact case must match"
);
assert!(
caps.supports_auth(&AuthMechanism::Other("login".into())),
"RFC 4954 Section 3: mechanism names are case-insensitive; \
'login' must match stored 'LOGIN'"
);
assert!(
caps.supports_auth(&AuthMechanism::Other("Login".into())),
"RFC 4954 Section 3: mixed-case 'Login' must match stored 'LOGIN'"
);
assert!(
caps.supports_auth(&AuthMechanism::Other("cram-md5".into())),
"RFC 4954 Section 3: 'cram-md5' must match stored 'CRAM-MD5'"
);
assert!(
!caps.supports_auth(&AuthMechanism::Other("NTLM".into())),
"non-existent mechanism must not match"
);
}
#[test]
fn eq_mechanism_cross_variant_other_plain() {
assert!(
AuthMechanism::Other("PLAIN".into()).eq_mechanism(&AuthMechanism::Plain),
"Other(\"PLAIN\") must match Plain (RFC 4954 Section 3)"
);
assert!(
AuthMechanism::Plain.eq_mechanism(&AuthMechanism::Other("plain".into())),
"Plain must match Other(\"plain\") (RFC 4954 Section 3)"
);
}
#[test]
fn eq_mechanism_cross_variant_other_xoauth2() {
assert!(
AuthMechanism::Other("XOAUTH2".into()).eq_mechanism(&AuthMechanism::XOAuth2),
"Other(\"XOAUTH2\") must match XOAuth2"
);
assert!(
AuthMechanism::XOAuth2.eq_mechanism(&AuthMechanism::Other("xoauth2".into())),
"XOAuth2 must match Other(\"xoauth2\")"
);
}
#[test]
fn supports_auth_cross_variant_other_plain() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Auth(vec![AuthMechanism::Plain])],
};
assert!(
caps.supports_auth(&AuthMechanism::Other("PLAIN".into())),
"RFC 4954 Section 3: Other(\"PLAIN\") query must match \
stored Plain variant"
);
assert!(
caps.supports_auth(&AuthMechanism::Other("plain".into())),
"RFC 4954 Section 3: Other(\"plain\") query must match \
stored Plain variant (case-insensitive)"
);
}
#[test]
fn supports_chunking() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Chunking],
};
assert!(caps.supports_chunking());
let empty = ServerCapabilities::default();
assert!(!empty.supports_chunking());
}
#[test]
fn supports_size() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Size(Some(10_485_760))],
};
assert!(caps.supports_size());
let caps_no_limit = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Size(None)],
};
assert!(caps_no_limit.supports_size());
let empty = ServerCapabilities::default();
assert!(!empty.supports_size());
}
#[test]
fn supports_sasl_ir() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::SaslIr],
};
assert!(caps.supports_sasl_ir());
let empty = ServerCapabilities::default();
assert!(!empty.supports_sasl_ir());
}
#[test]
fn supports_enhanced_status_codes() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::EnhancedStatusCodes],
};
assert!(caps.supports_enhanced_status_codes());
let empty = ServerCapabilities::default();
assert!(!empty.supports_enhanced_status_codes());
}
#[test]
fn size_limit_with_value() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Size(Some(10_485_760))],
};
assert_eq!(caps.size_limit(), Some(10_485_760));
}
#[test]
fn size_limit_without_value() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Size(None)],
};
assert_eq!(caps.size_limit(), None);
}
#[test]
fn size_limit_not_advertised() {
let empty = ServerCapabilities::default();
assert_eq!(empty.size_limit(), None);
}
#[test]
fn supports_dsn() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Dsn],
};
assert!(caps.supports_dsn());
let empty = ServerCapabilities::default();
assert!(!empty.supports_dsn());
}
#[test]
fn supports_requiretls() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::RequireTls],
};
assert!(caps.supports_requiretls());
let empty = ServerCapabilities::default();
assert!(!empty.supports_requiretls());
}
#[test]
fn supports_auth_login() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Auth(vec![
AuthMechanism::Plain,
AuthMechanism::Login,
])],
};
assert!(caps.supports_auth(&AuthMechanism::Login));
assert!(caps.supports_auth(&AuthMechanism::Plain));
}
#[test]
fn eq_mechanism_login_identity() {
assert!(AuthMechanism::Login.eq_mechanism(&AuthMechanism::Login));
}
#[test]
fn eq_mechanism_cross_variant_other_login() {
assert!(
AuthMechanism::Other("LOGIN".into()).eq_mechanism(&AuthMechanism::Login),
"Other(\"LOGIN\") must match Login"
);
assert!(
AuthMechanism::Login.eq_mechanism(&AuthMechanism::Other("login".into())),
"Login must match Other(\"login\") (case-insensitive)"
);
assert!(
AuthMechanism::Login.eq_mechanism(&AuthMechanism::Other("Login".into())),
"Login must match Other(\"Login\") (mixed case)"
);
}
#[test]
fn supports_auth_cross_variant_other_login() {
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: vec![SmtpExtension::Auth(vec![AuthMechanism::Login])],
};
assert!(
caps.supports_auth(&AuthMechanism::Other("LOGIN".into())),
"Other(\"LOGIN\") query must match stored Login variant"
);
assert!(
caps.supports_auth(&AuthMechanism::Other("login".into())),
"Other(\"login\") query must match stored Login variant"
);
}
#[test]
fn eq_mechanism_login_does_not_match_plain() {
assert!(!AuthMechanism::Login.eq_mechanism(&AuthMechanism::Plain));
assert!(!AuthMechanism::Plain.eq_mechanism(&AuthMechanism::Login));
}
}