//! SMTP client protocol engine. //! //! Implements the SMTP command/response flow for sending outbound email. use super::config::SmtpAuthConfig; use super::connection::ClientSmtpStream; use super::error::SmtpClientError; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use serde::{Deserialize, Serialize}; use tokio::time::{timeout, Duration}; use tracing::debug; /// Parsed SMTP response (from the remote server). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmtpClientResponse { pub code: u16, pub lines: Vec, } impl SmtpClientResponse { pub fn is_success(&self) -> bool { self.code >= 200 && self.code < 300 } pub fn is_positive_intermediate(&self) -> bool { self.code >= 300 && self.code < 400 } pub fn is_temp_error(&self) -> bool { self.code >= 400 && self.code < 500 } pub fn is_perm_error(&self) -> bool { self.code >= 500 } /// Full response text (all lines joined). pub fn full_message(&self) -> String { self.lines.join(" ") } /// Convert to a protocol error if this is an error response. pub fn to_error(&self) -> SmtpClientError { SmtpClientError::ProtocolError { code: self.code, message: self.full_message(), } } } /// Server capabilities parsed from EHLO response. #[derive(Debug, Clone, Default)] pub struct EhloCapabilities { pub extensions: Vec, pub max_size: Option, pub starttls: bool, pub auth_methods: Vec, pub pipelining: bool, pub eight_bit_mime: bool, } /// Read a multi-line SMTP response from the server. pub async fn read_response( stream: &mut ClientSmtpStream, timeout_secs: u64, ) -> Result { let mut lines = Vec::new(); let mut code: u16; loop { let mut line = String::new(); let n = timeout( Duration::from_secs(timeout_secs), stream.read_line(&mut line), ) .await .map_err(|_| SmtpClientError::TimeoutError { message: format!("Timeout reading SMTP response after {timeout_secs}s"), })??; if n == 0 { return Err(SmtpClientError::ConnectionError { message: "Connection closed while reading response".into(), }); } // Guard against unbounded lines from malicious servers (RFC 5321 §4.5.3.1.4 says 512 max) if line.len() > 4096 { return Err(SmtpClientError::ProtocolError { code: 0, message: format!("Response line too long ({} bytes, max 4096)", line.len()), }); } let line = line.trim_end_matches('\n').trim_end_matches('\r'); if line.len() < 3 { return Err(SmtpClientError::ProtocolError { code: 0, message: format!("Invalid response line: {line}"), }); } // Parse the 3-digit code let parsed_code: u16 = line[..3].parse().map_err(|_| SmtpClientError::ProtocolError { code: 0, message: format!("Invalid response code in: {line}"), })?; code = parsed_code; // Text after the code (skip the separator character) let text = if line.len() > 4 { &line[4..] } else { "" }; lines.push(text.to_string()); // Check for continuation: "250-" means more lines, "250 " means last line if line.len() >= 4 && line.as_bytes()[3] == b'-' { continue; } else { break; } } debug!("SMTP response: {} {}", code, lines.join(" | ")); Ok(SmtpClientResponse { code, lines }) } /// Read the server greeting (first response after connect). pub async fn read_greeting( stream: &mut ClientSmtpStream, timeout_secs: u64, ) -> Result { let resp = read_response(stream, timeout_secs).await?; if resp.code == 220 { Ok(resp) } else { Err(SmtpClientError::ProtocolError { code: resp.code, message: format!("Unexpected greeting: {}", resp.full_message()), }) } } /// Send a raw command and read the response. async fn send_command( stream: &mut ClientSmtpStream, command: &str, timeout_secs: u64, ) -> Result { debug!("SMTP C: {}", command); stream .write_all(format!("{command}\r\n").as_bytes()) .await?; stream.flush().await?; read_response(stream, timeout_secs).await } /// Send EHLO and parse capabilities. pub async fn send_ehlo( stream: &mut ClientSmtpStream, domain: &str, timeout_secs: u64, ) -> Result { let resp = send_command(stream, &format!("EHLO {domain}"), timeout_secs).await?; if !resp.is_success() { // Fall back to HELO let helo_resp = send_command(stream, &format!("HELO {domain}"), timeout_secs).await?; if !helo_resp.is_success() { return Err(helo_resp.to_error()); } return Ok(EhloCapabilities::default()); } let mut caps = EhloCapabilities::default(); // First line is the greeting, remaining lines are capabilities for line in resp.lines.iter().skip(1) { let upper = line.to_uppercase(); if upper.starts_with("SIZE ") { caps.max_size = upper[5..].trim().parse().ok(); } else if upper == "STARTTLS" { caps.starttls = true; } else if upper.starts_with("AUTH ") { caps.auth_methods = upper[5..] .split_whitespace() .map(|s| s.to_string()) .collect(); } else if upper == "PIPELINING" { caps.pipelining = true; } else if upper == "8BITMIME" { caps.eight_bit_mime = true; } caps.extensions.push(line.clone()); } Ok(caps) } /// Send STARTTLS command (does not perform the TLS handshake itself). pub async fn send_starttls( stream: &mut ClientSmtpStream, timeout_secs: u64, ) -> Result<(), SmtpClientError> { let resp = send_command(stream, "STARTTLS", timeout_secs).await?; if resp.code != 220 { return Err(SmtpClientError::ProtocolError { code: resp.code, message: format!("STARTTLS rejected: {}", resp.full_message()), }); } Ok(()) } /// Authenticate using AUTH PLAIN. pub async fn send_auth_plain( stream: &mut ClientSmtpStream, user: &str, pass: &str, timeout_secs: u64, ) -> Result<(), SmtpClientError> { // AUTH PLAIN sends \0user\0pass in base64 let credentials = format!("\x00{user}\x00{pass}"); let encoded = BASE64.encode(credentials.as_bytes()); let resp = send_command(stream, &format!("AUTH PLAIN {encoded}"), timeout_secs).await?; if resp.code != 235 { return Err(SmtpClientError::AuthenticationError { message: format!("AUTH PLAIN failed ({}): {}", resp.code, resp.full_message()), }); } Ok(()) } /// Authenticate using AUTH LOGIN. pub async fn send_auth_login( stream: &mut ClientSmtpStream, user: &str, pass: &str, timeout_secs: u64, ) -> Result<(), SmtpClientError> { // Step 1: Send AUTH LOGIN let resp = send_command(stream, "AUTH LOGIN", timeout_secs).await?; if resp.code != 334 { return Err(SmtpClientError::AuthenticationError { message: format!( "AUTH LOGIN challenge failed ({}): {}", resp.code, resp.full_message() ), }); } // Step 2: Send base64 username let user_b64 = BASE64.encode(user.as_bytes()); let resp = send_command(stream, &user_b64, timeout_secs).await?; if resp.code != 334 { return Err(SmtpClientError::AuthenticationError { message: format!( "AUTH LOGIN username rejected ({}): {}", resp.code, resp.full_message() ), }); } // Step 3: Send base64 password let pass_b64 = BASE64.encode(pass.as_bytes()); let resp = send_command(stream, &pass_b64, timeout_secs).await?; if resp.code != 235 { return Err(SmtpClientError::AuthenticationError { message: format!( "AUTH LOGIN password rejected ({}): {}", resp.code, resp.full_message() ), }); } Ok(()) } /// Authenticate using the configured method. pub async fn authenticate( stream: &mut ClientSmtpStream, auth: &SmtpAuthConfig, _caps: &EhloCapabilities, timeout_secs: u64, ) -> Result<(), SmtpClientError> { match auth.method.to_uppercase().as_str() { "LOGIN" => send_auth_login(stream, &auth.user, &auth.pass, timeout_secs).await, _ => send_auth_plain(stream, &auth.user, &auth.pass, timeout_secs).await, } } /// Send MAIL FROM. pub async fn send_mail_from( stream: &mut ClientSmtpStream, sender: &str, timeout_secs: u64, ) -> Result { let resp = send_command(stream, &format!("MAIL FROM:<{sender}>"), timeout_secs).await?; if !resp.is_success() { return Err(resp.to_error()); } Ok(resp) } /// Send RCPT TO. Returns per-recipient success/failure. pub async fn send_rcpt_to( stream: &mut ClientSmtpStream, recipient: &str, timeout_secs: u64, ) -> Result { let resp = send_command(stream, &format!("RCPT TO:<{recipient}>"), timeout_secs).await?; // We don't fail the entire send on per-recipient errors; // the caller decides based on the response code. Ok(resp) } /// Send DATA command, followed by the message body with dot-stuffing. pub async fn send_data( stream: &mut ClientSmtpStream, message: &[u8], timeout_secs: u64, ) -> Result { // Send DATA command let resp = send_command(stream, "DATA", timeout_secs).await?; if !resp.is_positive_intermediate() { return Err(resp.to_error()); } // Send the message body with dot-stuffing let stuffed = dot_stuff(message); stream.write_all(&stuffed).await?; // Send terminator: CRLF.CRLF // If the message doesn't end with CRLF, add one if !stuffed.ends_with(b"\r\n") { stream.write_all(b"\r\n").await?; } stream.write_all(b".\r\n").await?; stream.flush().await?; // Read final response let final_resp = read_response(stream, timeout_secs).await?; if !final_resp.is_success() { return Err(final_resp.to_error()); } Ok(final_resp) } /// Send RSET command to reset the server state between messages on a reused connection. pub async fn send_rset( stream: &mut ClientSmtpStream, timeout_secs: u64, ) -> Result<(), SmtpClientError> { let resp = send_command(stream, "RSET", timeout_secs).await?; if !resp.is_success() { return Err(resp.to_error()); } Ok(()) } /// Send QUIT command. pub async fn send_quit( stream: &mut ClientSmtpStream, timeout_secs: u64, ) -> Result<(), SmtpClientError> { // Best-effort QUIT — ignore errors since we're closing anyway let _ = send_command(stream, "QUIT", timeout_secs).await; Ok(()) } /// Apply SMTP dot-stuffing to a message body. /// /// Any line starting with a period gets an extra period prepended. /// Also normalizes bare LF to CRLF. pub fn dot_stuff(data: &[u8]) -> Vec { let mut result = Vec::with_capacity(data.len() + data.len() / 40); let mut at_line_start = true; for i in 0..data.len() { let byte = data[i]; // Normalize bare LF to CRLF if byte == b'\n' && (i == 0 || data[i - 1] != b'\r') { result.push(b'\r'); result.push(b'\n'); at_line_start = true; continue; } // Dot-stuff: add extra dot at start of line if at_line_start && byte == b'.' { result.push(b'.'); } result.push(byte); at_line_start = byte == b'\n'; } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_dot_stuffing_basic() { assert_eq!( dot_stuff(b"Hello\r\n.World\r\n"), b"Hello\r\n..World\r\n" ); } #[test] fn test_dot_stuffing_leading_dot() { assert_eq!(dot_stuff(b".starts with dot\r\n"), b"..starts with dot\r\n"); } #[test] fn test_dot_stuffing_multiple_dots() { assert_eq!( dot_stuff(b"ok\r\n.line1\r\n..line2\r\n"), b"ok\r\n..line1\r\n...line2\r\n" ); } #[test] fn test_dot_stuffing_bare_lf() { assert_eq!( dot_stuff(b"line1\nline2\n"), b"line1\r\nline2\r\n" ); } #[test] fn test_dot_stuffing_bare_lf_with_dot() { assert_eq!( dot_stuff(b"ok\n.dotline\n"), b"ok\r\n..dotline\r\n" ); } #[test] fn test_dot_stuffing_no_change() { assert_eq!( dot_stuff(b"Hello World\r\nNo dots here\r\n"), b"Hello World\r\nNo dots here\r\n" ); } #[test] fn test_dot_stuffing_empty() { assert_eq!(dot_stuff(b""), b""); } #[test] fn test_response_is_success() { let resp = SmtpClientResponse { code: 250, lines: vec!["OK".into()], }; assert!(resp.is_success()); assert!(!resp.is_temp_error()); assert!(!resp.is_perm_error()); } #[test] fn test_response_temp_error() { let resp = SmtpClientResponse { code: 450, lines: vec!["Mailbox busy".into()], }; assert!(!resp.is_success()); assert!(resp.is_temp_error()); } #[test] fn test_response_perm_error() { let resp = SmtpClientResponse { code: 550, lines: vec!["No such user".into()], }; assert!(!resp.is_success()); assert!(resp.is_perm_error()); } #[test] fn test_response_positive_intermediate() { let resp = SmtpClientResponse { code: 354, lines: vec!["Start mail input".into()], }; assert!(resp.is_positive_intermediate()); assert!(!resp.is_success()); } #[test] fn test_response_full_message() { let resp = SmtpClientResponse { code: 250, lines: vec!["OK".into(), "SIZE 10485760".into()], }; assert_eq!(resp.full_message(), "OK SIZE 10485760"); } #[test] fn test_ehlo_capabilities_default() { let caps = EhloCapabilities::default(); assert!(!caps.starttls); assert!(!caps.pipelining); assert!(!caps.eight_bit_mime); assert!(caps.auth_methods.is_empty()); assert!(caps.max_size.is_none()); } }