pub mod client;
pub mod reply;
pub mod stream;
pub use client::SmtpClient;
pub use reply::Reply;
pub use stream::SmtpStream;
use rustlavel_core::{Error, Result};
use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Encryption {
#[default]
Auto,
StartTls,
Tls,
None,
}
impl Encryption {
pub fn parse(value: &str) -> Result<Encryption> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Ok(Encryption::Auto),
"tls" | "ssl" | "implicit" => Ok(Encryption::Tls),
"starttls" => Ok(Encryption::StartTls),
"none" | "off" | "null" => Ok(Encryption::None),
other => Err(Error::msg(format!(
"`{other}` is not an encryption mode. Use one of: auto, starttls, tls, none."
))),
}
}
pub fn name(self) -> &'static str {
match self {
Encryption::Auto => "auto",
Encryption::StartTls => "starttls",
Encryption::Tls => "tls",
Encryption::None => "none",
}
}
}
#[derive(Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub encryption: Encryption,
pub timeout: Duration,
pub hello: String,
}
impl Default for SmtpConfig {
fn default() -> Self {
SmtpConfig {
host: "127.0.0.1".into(),
port: 587,
username: String::new(),
password: String::new(),
encryption: Encryption::Auto,
timeout: Duration::from_secs(30),
hello: "localhost".into(),
}
}
}
impl SmtpConfig {
pub fn new(host: impl Into<String>, port: u16) -> SmtpConfig {
SmtpConfig { host: host.into(), port, ..SmtpConfig::default() }
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = username.into();
self.password = password.into();
self
}
pub fn encryption(mut self, encryption: Encryption) -> Self {
self.encryption = encryption;
self
}
pub fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
pub fn implicit_tls(&self) -> bool {
match self.encryption {
Encryption::Tls => true,
Encryption::Auto => self.port == 465,
_ => false,
}
}
pub fn wants_starttls(&self) -> bool {
!self.implicit_tls() && matches!(self.encryption, Encryption::Auto | Encryption::StartTls)
}
pub fn requires_starttls(&self) -> bool {
!self.implicit_tls() && self.encryption == Encryption::StartTls
}
pub fn describe(&self) -> String {
let user = if self.username.is_empty() { "anonymous" } else { self.username.as_str() };
format!("{}:{} as {} ({})", self.host, self.port, user, self.encryption.name())
}
}
impl fmt::Debug for SmtpConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SmtpConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &"<redacted>")
.field("encryption", &self.encryption)
.field("timeout", &self.timeout)
.field("hello", &self.hello)
.finish()
}
}
pub fn may_authenticate(encrypted: bool, host: &str) -> bool {
encrypted || is_loopback(host)
}
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") || host.starts_with("127.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encryption_modes_parse_by_the_names_people_write_in_env_files() {
assert_eq!(Encryption::parse("").unwrap(), Encryption::Auto);
assert_eq!(Encryption::parse("SSL").unwrap(), Encryption::Tls);
assert_eq!(Encryption::parse(" starttls ").unwrap(), Encryption::StartTls);
assert!(Encryption::parse("maybe").unwrap_err().to_string().contains("auto, starttls"));
}
#[test]
fn port_465_means_tls_before_the_greeting() {
assert!(SmtpConfig::new("smtp.example.com", 465).implicit_tls());
assert!(!SmtpConfig::new("smtp.example.com", 587).implicit_tls());
assert!(SmtpConfig::new("smtp.example.com", 587).wants_starttls());
assert!(!SmtpConfig::new("smtp.example.com", 465).wants_starttls());
let off = SmtpConfig::new("localhost", 1025).encryption(Encryption::None);
assert!(!off.wants_starttls() && !off.implicit_tls());
}
#[test]
fn a_password_never_appears_in_debug_output_or_a_description() {
let config = SmtpConfig::new("smtp.example.com", 587).credentials("ada", "hunter2");
let debugged = format!("{config:?}");
assert!(!debugged.contains("hunter2"), "{debugged}");
assert!(debugged.contains("<redacted>"));
let described = config.describe();
assert!(!described.contains("hunter2"), "{described}");
assert!(described.contains("smtp.example.com:587 as ada"));
}
#[test]
fn credentials_are_only_sent_over_tls_or_to_this_machine() {
assert!(may_authenticate(true, "smtp.example.com"));
assert!(!may_authenticate(false, "smtp.example.com"));
assert!(may_authenticate(false, "localhost"));
assert!(may_authenticate(false, "127.0.0.1"));
}
}