BREAKING CHANGE(smtp-client): Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
This commit is contained in:
520
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
520
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub max_size: Option<u64>,
|
||||
pub starttls: bool,
|
||||
pub auth_methods: Vec<String>,
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
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<EhloCapabilities, SmtpClientError> {
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
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<SmtpClientResponse, SmtpClientError> {
|
||||
// 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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user