285 lines
8.5 KiB
Rust
285 lines
8.5 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
impl SmtpResponse {
|
|
/// Create a single-line response.
|
|
pub fn new(code: u16, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code,
|
|
lines: vec![message.into()],
|
|
}
|
|
}
|
|
|
|
/// Create a multiline response.
|
|
pub fn multiline(code: u16, lines: Vec<String>) -> 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<u8> {
|
|
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<String>) -> 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 <CRLF>.<CRLF>")
|
|
}
|
|
|
|
/// 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<String>) -> Self {
|
|
Self::new(450, message)
|
|
}
|
|
|
|
/// 451 Local error.
|
|
pub fn local_error(message: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String> {
|
|
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);
|
|
}
|
|
}
|