//! SMTP response builder. //! //! Constructs properly formatted SMTP response lines with status codes, //! multiline support, and EHLO capability advertisement. use serde::{Deserialize, Serialize}; /// An SMTP response to send to the client. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SmtpResponse { /// 3-digit SMTP status code. pub code: u16, /// Response lines (without the status code prefix). pub lines: Vec, } impl SmtpResponse { /// Create a single-line response. pub fn new(code: u16, message: impl Into) -> Self { Self { code, lines: vec![message.into()], } } /// Create a multiline response. pub fn multiline(code: u16, lines: Vec) -> Self { Self { code, lines } } /// Format the response as bytes ready to write to the socket. /// /// Multiline responses use `code-text` for intermediate lines /// and `code text` for the final line (RFC 5321 ยง4.2). pub fn to_bytes(&self) -> Vec { let mut buf = Vec::new(); if self.lines.is_empty() { buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes()); } else if self.lines.len() == 1 { buf.extend_from_slice( format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(), ); } else { for (i, line) in self.lines.iter().enumerate() { if i < self.lines.len() - 1 { buf.extend_from_slice( format!("{}-{}\r\n", self.code, line).as_bytes(), ); } else { buf.extend_from_slice( format!("{} {}\r\n", self.code, line).as_bytes(), ); } } } buf } // --- Common response constructors --- /// 220 Service ready greeting. pub fn greeting(hostname: &str) -> Self { Self::new(220, format!("{hostname} ESMTP Service Ready")) } /// 221 Service closing. pub fn closing(hostname: &str) -> Self { Self::new(221, format!("{hostname} Service closing transmission channel")) } /// 250 OK. pub fn ok(message: impl Into) -> Self { Self::new(250, message) } /// EHLO response with capabilities. pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self { let mut lines = Vec::with_capacity(capabilities.len() + 1); lines.push(format!("{hostname} greets you")); for cap in capabilities { lines.push(cap.clone()); } Self::multiline(250, lines) } /// 235 Authentication successful. pub fn auth_success() -> Self { Self::new(235, "2.7.0 Authentication successful") } /// 334 Auth challenge (base64-encoded prompt). pub fn auth_challenge(prompt: &str) -> Self { Self::new(334, prompt) } /// 354 Start mail input. pub fn start_data() -> Self { Self::new(354, "Start mail input; end with .") } /// 421 Service not available. pub fn service_unavailable(hostname: &str, reason: &str) -> Self { Self::new(421, format!("{hostname} {reason}")) } /// 450 Temporary failure. pub fn temp_failure(message: impl Into) -> Self { Self::new(450, message) } /// 451 Local error. pub fn local_error(message: impl Into) -> Self { Self::new(451, message) } /// 500 Syntax error. pub fn syntax_error() -> Self { Self::new(500, "Syntax error, command unrecognized") } /// 501 Syntax error in parameters. pub fn param_error(message: impl Into) -> Self { Self::new(501, message) } /// 502 Command not implemented. pub fn not_implemented() -> Self { Self::new(502, "Command not implemented") } /// 503 Bad sequence. pub fn bad_sequence(message: impl Into) -> Self { Self::new(503, message) } /// 530 Authentication required. pub fn auth_required() -> Self { Self::new(530, "5.7.0 Authentication required") } /// 535 Authentication failed. pub fn auth_failed() -> Self { Self::new(535, "5.7.8 Authentication credentials invalid") } /// 550 Mailbox unavailable. pub fn mailbox_unavailable(message: impl Into) -> Self { Self::new(550, message) } /// 552 Message size exceeded. pub fn size_exceeded(max_size: u64) -> Self { Self::new( 552, format!("5.3.4 Message size exceeds maximum of {max_size} bytes"), ) } /// 554 Transaction failed. pub fn transaction_failed(message: impl Into) -> Self { Self::new(554, message) } /// Check if this is a success response (2xx). pub fn is_success(&self) -> bool { self.code >= 200 && self.code < 300 } /// Check if this is a temporary error (4xx). pub fn is_temp_error(&self) -> bool { self.code >= 400 && self.code < 500 } /// Check if this is a permanent error (5xx). pub fn is_perm_error(&self) -> bool { self.code >= 500 && self.code < 600 } } /// Build the list of EHLO capabilities for the server. pub fn build_capabilities( max_size: u64, tls_available: bool, already_secure: bool, auth_available: bool, ) -> Vec { let mut caps = vec![ format!("SIZE {max_size}"), "8BITMIME".to_string(), "PIPELINING".to_string(), "ENHANCEDSTATUSCODES".to_string(), "HELP".to_string(), ]; // Only advertise STARTTLS if TLS is available and not already using TLS if tls_available && !already_secure { caps.push("STARTTLS".to_string()); } if auth_available { caps.push("AUTH PLAIN LOGIN".to_string()); } caps } #[cfg(test)] mod tests { use super::*; #[test] fn test_single_line() { let resp = SmtpResponse::new(250, "OK"); assert_eq!(resp.to_bytes(), b"250 OK\r\n"); } #[test] fn test_multiline() { let resp = SmtpResponse::multiline( 250, vec![ "mail.example.com greets you".into(), "SIZE 10485760".into(), "STARTTLS".into(), ], ); let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n"; assert_eq!(resp.to_bytes(), expected.to_vec()); } #[test] fn test_greeting() { let resp = SmtpResponse::greeting("mail.example.com"); assert_eq!(resp.code, 220); assert!(resp.lines[0].contains("mail.example.com")); } #[test] fn test_ehlo_response() { let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()]; let resp = SmtpResponse::ehlo_response("mail.example.com", &caps); assert_eq!(resp.code, 250); assert_eq!(resp.lines.len(), 3); // hostname + 2 caps } #[test] fn test_status_checks() { assert!(SmtpResponse::new(250, "OK").is_success()); assert!(SmtpResponse::new(450, "Try later").is_temp_error()); assert!(SmtpResponse::new(550, "No such user").is_perm_error()); assert!(!SmtpResponse::new(250, "OK").is_temp_error()); } #[test] fn test_build_capabilities() { let caps = build_capabilities(10485760, true, false, true); assert!(caps.contains(&"SIZE 10485760".to_string())); assert!(caps.contains(&"STARTTLS".to_string())); assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string())); assert!(caps.contains(&"PIPELINING".to_string())); } #[test] fn test_build_capabilities_secure() { // When already secure, STARTTLS should NOT be advertised let caps = build_capabilities(10485760, true, true, false); assert!(!caps.contains(&"STARTTLS".to_string())); assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string())); } #[test] fn test_empty_response() { let resp = SmtpResponse::multiline(250, vec![]); assert_eq!(resp.to_bytes(), b"250 \r\n"); } #[test] fn test_common_responses() { assert_eq!(SmtpResponse::start_data().code, 354); assert_eq!(SmtpResponse::syntax_error().code, 500); assert_eq!(SmtpResponse::not_implemented().code, 502); assert_eq!(SmtpResponse::bad_sequence("test").code, 503); assert_eq!(SmtpResponse::auth_required().code, 530); assert_eq!(SmtpResponse::auth_failed().code, 535); assert_eq!(SmtpResponse::auth_success().code, 235); } }