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:
2026-02-11 07:17:05 +00:00
parent fc4877e06b
commit 27bab5f345
50 changed files with 2268 additions and 6737 deletions

View File

@@ -0,0 +1,157 @@
//! SMTP client configuration types.
use serde::Deserialize;
/// Configuration for connecting to an SMTP server.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmtpClientConfig {
/// Target SMTP server hostname.
pub host: String,
/// Target port (25 = SMTP, 465 = implicit TLS, 587 = submission).
pub port: u16,
/// Use implicit TLS (port 465). If false, STARTTLS is attempted.
#[serde(default)]
pub secure: bool,
/// Domain to use in EHLO command. Defaults to "localhost".
#[serde(default = "default_domain")]
pub domain: String,
/// Authentication credentials (optional).
pub auth: Option<SmtpAuthConfig>,
/// Connection timeout in seconds. Default: 30.
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u64,
/// Socket read/write timeout in seconds. Default: 120.
#[serde(default = "default_socket_timeout")]
pub socket_timeout_secs: u64,
/// Pool key override. Defaults to "host:port".
pub pool_key: Option<String>,
/// Maximum connections per pool. Default: 10.
#[serde(default = "default_max_pool_connections")]
pub max_pool_connections: usize,
}
/// Authentication configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmtpAuthConfig {
/// Username.
pub user: String,
/// Password.
pub pass: String,
/// Method: "PLAIN" or "LOGIN". Default: "PLAIN".
#[serde(default = "default_auth_method")]
pub method: String,
}
/// DKIM signing configuration (applied before sending).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DkimSignConfig {
/// Signing domain (e.g. "example.com").
pub domain: String,
/// DKIM selector (e.g. "default" or "mta").
pub selector: String,
/// PEM-encoded RSA private key.
pub private_key: String,
}
impl SmtpClientConfig {
/// Get the effective pool key for this config.
pub fn effective_pool_key(&self) -> String {
self.pool_key
.clone()
.unwrap_or_else(|| format!("{}:{}", self.host, self.port))
}
}
fn default_domain() -> String {
"localhost".to_string()
}
fn default_connection_timeout() -> u64 {
30
}
fn default_socket_timeout() -> u64 {
120
}
fn default_max_pool_connections() -> usize {
10
}
fn default_auth_method() -> String {
"PLAIN".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_minimal_config() {
let json = r#"{"host":"mail.example.com","port":25}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.host, "mail.example.com");
assert_eq!(config.port, 25);
assert!(!config.secure);
assert_eq!(config.domain, "localhost");
assert!(config.auth.is_none());
assert_eq!(config.connection_timeout_secs, 30);
assert_eq!(config.socket_timeout_secs, 120);
assert_eq!(config.max_pool_connections, 10);
}
#[test]
fn test_deserialize_full_config() {
let json = r#"{
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"domain": "myserver.com",
"auth": { "user": "u", "pass": "p", "method": "LOGIN" },
"connectionTimeoutSecs": 60,
"socketTimeoutSecs": 300,
"poolKey": "gmail",
"maxPoolConnections": 5
}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.host, "smtp.gmail.com");
assert_eq!(config.port, 465);
assert!(config.secure);
assert_eq!(config.domain, "myserver.com");
assert_eq!(config.connection_timeout_secs, 60);
assert_eq!(config.socket_timeout_secs, 300);
assert_eq!(config.effective_pool_key(), "gmail");
assert_eq!(config.max_pool_connections, 5);
let auth = config.auth.unwrap();
assert_eq!(auth.user, "u");
assert_eq!(auth.pass, "p");
assert_eq!(auth.method, "LOGIN");
}
#[test]
fn test_effective_pool_key_default() {
let json = r#"{"host":"mx.example.com","port":587}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.effective_pool_key(), "mx.example.com:587");
}
#[test]
fn test_dkim_config_deserialize() {
let json = r#"{"domain":"example.com","selector":"mta","privateKey":"-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"}"#;
let dkim: DkimSignConfig = serde_json::from_str(json).unwrap();
assert_eq!(dkim.domain, "example.com");
assert_eq!(dkim.selector, "mta");
assert!(dkim.private_key.contains("RSA PRIVATE KEY"));
}
}

View File

@@ -0,0 +1,206 @@
//! TCP/TLS connection management for the SMTP client.
use super::error::SmtpClientError;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_rustls::client::TlsStream;
use tracing::debug;
/// A client-side SMTP stream that may be plain or TLS.
pub enum ClientSmtpStream {
Plain(BufReader<TcpStream>),
Tls(BufReader<TlsStream<TcpStream>>),
}
impl std::fmt::Debug for ClientSmtpStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientSmtpStream::Plain(_) => write!(f, "ClientSmtpStream::Plain"),
ClientSmtpStream::Tls(_) => write!(f, "ClientSmtpStream::Tls"),
}
}
}
impl ClientSmtpStream {
/// Read a line from the stream (CRLF-terminated).
pub async fn read_line(&mut self, buf: &mut String) -> Result<usize, SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => reader.read_line(buf).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Read error: {e}"),
}
}),
ClientSmtpStream::Tls(reader) => reader.read_line(buf).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS read error: {e}"),
}
}),
}
}
/// Write bytes to the stream.
pub async fn write_all(&mut self, data: &[u8]) -> Result<(), SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => {
reader.get_mut().write_all(data).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Write error: {e}"),
}
})
}
ClientSmtpStream::Tls(reader) => {
reader.get_mut().write_all(data).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS write error: {e}"),
}
})
}
}
}
/// Flush the stream.
pub async fn flush(&mut self) -> Result<(), SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => {
reader.get_mut().flush().await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Flush error: {e}"),
}
})
}
ClientSmtpStream::Tls(reader) => {
reader.get_mut().flush().await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS flush error: {e}"),
}
})
}
}
}
/// Consume this stream and return the inner TcpStream (for STARTTLS upgrade).
/// Only works on Plain streams; returns an error on TLS streams.
pub fn into_tcp_stream(self) -> Result<TcpStream, SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => Ok(reader.into_inner()),
ClientSmtpStream::Tls(_) => Err(SmtpClientError::TlsError {
message: "Cannot extract TcpStream from an already-TLS stream".into(),
}),
}
}
}
/// Connect to an SMTP server via plain TCP.
pub async fn connect_plain(
host: &str,
port: u16,
timeout_secs: u64,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Connecting to {}:{} (plain)", host, port);
let addr = format!("{host}:{port}");
let stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
.await
.map_err(|_| SmtpClientError::TimeoutError {
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
})?
.map_err(|e| SmtpClientError::ConnectionError {
message: format!("Failed to connect to {addr}: {e}"),
})?;
Ok(ClientSmtpStream::Plain(BufReader::new(stream)))
}
/// Connect to an SMTP server via implicit TLS (port 465).
pub async fn connect_tls(
host: &str,
port: u16,
timeout_secs: u64,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Connecting to {}:{} (implicit TLS)", host, port);
let addr = format!("{host}:{port}");
let tcp_stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
.await
.map_err(|_| SmtpClientError::TimeoutError {
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
})?
.map_err(|e| SmtpClientError::ConnectionError {
message: format!("Failed to connect to {addr}: {e}"),
})?;
let tls_stream = perform_tls_handshake(tcp_stream, host).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
}
/// Upgrade a plain TCP connection to TLS (STARTTLS).
pub async fn upgrade_to_tls(
stream: ClientSmtpStream,
hostname: &str,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
let tcp_stream = stream.into_tcp_stream()?;
let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
}
/// Perform the TLS handshake on a TCP stream using webpki-roots.
async fn perform_tls_handshake(
tcp_stream: TcpStream,
hostname: &str,
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
SmtpClientError::TlsError {
message: format!("Invalid server name '{hostname}': {e}"),
}
})?;
let tls_stream = connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| SmtpClientError::TlsError {
message: format!("TLS handshake with {hostname} failed: {e}"),
})?;
Ok(tls_stream)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connect_plain_refused() {
// Connecting to a port that's not listening should fail
let result = connect_plain("127.0.0.1", 19999, 2).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, SmtpClientError::ConnectionError { .. }));
assert!(err.is_retryable());
}
#[tokio::test]
async fn test_connect_tls_refused() {
let result = connect_tls("127.0.0.1", 19998, 2).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_connect_timeout() {
// 192.0.2.1 is TEST-NET, should time out
let result = connect_plain("192.0.2.1", 25, 1).await;
assert!(result.is_err());
let err = result.unwrap_err();
// May be timeout or connection error depending on network
assert!(err.is_retryable());
}
}

View File

@@ -0,0 +1,160 @@
//! SMTP client error types.
use serde::Serialize;
/// Errors that can occur during SMTP client operations.
#[derive(Debug, thiserror::Error, Serialize)]
pub enum SmtpClientError {
#[error("Connection error: {message}")]
ConnectionError { message: String },
#[error("Timeout: {message}")]
TimeoutError { message: String },
#[error("TLS error: {message}")]
TlsError { message: String },
#[error("Authentication failed: {message}")]
AuthenticationError { message: String },
#[error("Protocol error ({code}): {message}")]
ProtocolError { code: u16, message: String },
#[error("Pool exhausted: {message}")]
PoolExhausted { message: String },
#[error("Invalid configuration: {message}")]
ConfigError { message: String },
}
impl SmtpClientError {
/// Whether this error is retryable (temporary failure).
/// Permanent failures (5xx, auth failures) are not retryable.
pub fn is_retryable(&self) -> bool {
match self {
SmtpClientError::ConnectionError { .. } => true,
SmtpClientError::TimeoutError { .. } => true,
SmtpClientError::TlsError { .. } => false,
SmtpClientError::AuthenticationError { .. } => false,
SmtpClientError::ProtocolError { code, .. } => *code >= 400 && *code < 500,
SmtpClientError::PoolExhausted { .. } => true,
SmtpClientError::ConfigError { .. } => false,
}
}
/// The error type as a string for IPC serialization.
pub fn error_type(&self) -> &'static str {
match self {
SmtpClientError::ConnectionError { .. } => "connection",
SmtpClientError::TimeoutError { .. } => "timeout",
SmtpClientError::TlsError { .. } => "tls",
SmtpClientError::AuthenticationError { .. } => "authentication",
SmtpClientError::ProtocolError { .. } => "protocol",
SmtpClientError::PoolExhausted { .. } => "pool_exhausted",
SmtpClientError::ConfigError { .. } => "config",
}
}
/// The SMTP code if this is a protocol error.
pub fn smtp_code(&self) -> Option<u16> {
match self {
SmtpClientError::ProtocolError { code, .. } => Some(*code),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_retryable_errors() {
assert!(SmtpClientError::ConnectionError {
message: "refused".into()
}
.is_retryable());
assert!(SmtpClientError::TimeoutError {
message: "timed out".into()
}
.is_retryable());
assert!(SmtpClientError::PoolExhausted {
message: "full".into()
}
.is_retryable());
assert!(SmtpClientError::ProtocolError {
code: 421,
message: "try later".into()
}
.is_retryable());
assert!(SmtpClientError::ProtocolError {
code: 450,
message: "mailbox busy".into()
}
.is_retryable());
}
#[test]
fn test_non_retryable_errors() {
assert!(!SmtpClientError::AuthenticationError {
message: "bad creds".into()
}
.is_retryable());
assert!(!SmtpClientError::TlsError {
message: "cert invalid".into()
}
.is_retryable());
assert!(!SmtpClientError::ProtocolError {
code: 550,
message: "no such user".into()
}
.is_retryable());
assert!(!SmtpClientError::ProtocolError {
code: 554,
message: "rejected".into()
}
.is_retryable());
assert!(!SmtpClientError::ConfigError {
message: "bad config".into()
}
.is_retryable());
}
#[test]
fn test_error_type_strings() {
assert_eq!(
SmtpClientError::ConnectionError {
message: "x".into()
}
.error_type(),
"connection"
);
assert_eq!(
SmtpClientError::ProtocolError {
code: 550,
message: "x".into()
}
.error_type(),
"protocol"
);
}
#[test]
fn test_smtp_code() {
assert_eq!(
SmtpClientError::ProtocolError {
code: 550,
message: "x".into()
}
.smtp_code(),
Some(550)
);
assert_eq!(
SmtpClientError::ConnectionError {
message: "x".into()
}
.smtp_code(),
None
);
}
}

View File

@@ -0,0 +1,16 @@
//! SMTP client module for outbound email delivery.
//!
//! Provides connection pooling, SMTP protocol, TLS, and authentication
//! for sending outbound emails through remote SMTP servers.
pub mod config;
pub mod connection;
pub mod error;
pub mod pool;
pub mod protocol;
// Re-export key types for convenience.
pub use config::{DkimSignConfig, SmtpAuthConfig, SmtpClientConfig};
pub use error::SmtpClientError;
pub use pool::{SmtpClientManager, SmtpSendResult, SmtpVerifyResult};
pub use protocol::{dot_stuff, EhloCapabilities, SmtpClientResponse};

View File

@@ -0,0 +1,503 @@
//! Connection pooling for the SMTP client.
//!
//! Manages reusable connections per destination `host:port`.
use super::config::SmtpClientConfig;
use super::connection::{connect_plain, connect_tls, ClientSmtpStream};
use super::error::SmtpClientError;
use super::protocol::{self, EhloCapabilities};
use dashmap::DashMap;
use serde::Serialize;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use tracing::{debug, info};
/// Maximum age of a pooled connection (5 minutes).
const MAX_CONNECTION_AGE_SECS: u64 = 300;
/// Maximum idle time before a connection is reaped (30 seconds).
const MAX_IDLE_SECS: u64 = 30;
/// Maximum messages per pooled connection before it's recycled.
const MAX_MESSAGES_PER_CONNECTION: u32 = 100;
/// A pooled SMTP connection.
pub struct PooledConnection {
pub stream: ClientSmtpStream,
pub capabilities: EhloCapabilities,
pub created_at: Instant,
pub last_used: Instant,
pub message_count: u32,
pub idle: bool,
}
/// Check if a pooled connection is stale (too old, too many messages, or idle too long).
fn is_connection_stale(conn: &PooledConnection) -> bool {
conn.created_at.elapsed().as_secs() > MAX_CONNECTION_AGE_SECS
|| conn.message_count >= MAX_MESSAGES_PER_CONNECTION
|| (conn.idle && conn.last_used.elapsed().as_secs() > MAX_IDLE_SECS)
}
/// Per-destination connection pool.
pub struct ConnectionPool {
connections: Vec<PooledConnection>,
max_connections: usize,
config: SmtpClientConfig,
}
impl ConnectionPool {
fn new(config: SmtpClientConfig) -> Self {
let max_connections = config.max_pool_connections;
Self {
connections: Vec::new(),
max_connections,
config,
}
}
/// Get an idle connection or create a new one.
async fn acquire(&mut self) -> Result<PooledConnection, SmtpClientError> {
// Remove stale connections first
self.cleanup_stale();
// Find an idle connection
if let Some(idx) = self
.connections
.iter()
.position(|c| c.idle && !is_connection_stale(c))
{
let mut conn = self.connections.remove(idx);
conn.idle = false;
conn.last_used = Instant::now();
debug!(
"Reusing pooled connection (age={}s, msgs={})",
conn.created_at.elapsed().as_secs(),
conn.message_count
);
return Ok(conn);
}
// Check if we can create a new connection
if self.connections.len() >= self.max_connections {
return Err(SmtpClientError::PoolExhausted {
message: format!(
"Pool for {} is at max capacity ({})",
self.config.effective_pool_key(),
self.max_connections
),
});
}
// Create a new connection
self.create_connection().await
}
/// Return a connection to the pool (or close it if it's expired).
fn release(&mut self, mut conn: PooledConnection) {
conn.message_count += 1;
conn.last_used = Instant::now();
conn.idle = true;
// Don't return if it's stale
if is_connection_stale(&conn) || self.connections.len() >= self.max_connections {
debug!("Discarding stale/excess pooled connection");
// Drop the connection (stream will be closed)
return;
}
self.connections.push(conn);
}
/// Create a fresh SMTP connection and complete the handshake.
async fn create_connection(&self) -> Result<PooledConnection, SmtpClientError> {
let mut stream = if self.config.secure {
connect_tls(
&self.config.host,
self.config.port,
self.config.connection_timeout_secs,
)
.await?
} else {
connect_plain(
&self.config.host,
self.config.port,
self.config.connection_timeout_secs,
)
.await?
};
// Read greeting
protocol::read_greeting(&mut stream, self.config.socket_timeout_secs).await?;
// Send EHLO
let mut capabilities =
protocol::send_ehlo(&mut stream, &self.config.domain, self.config.socket_timeout_secs)
.await?;
// STARTTLS if available and not already secure
if !self.config.secure && capabilities.starttls {
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
stream =
super::connection::upgrade_to_tls(stream, &self.config.host).await?;
// Re-EHLO after STARTTLS — use updated capabilities for auth
capabilities = protocol::send_ehlo(
&mut stream,
&self.config.domain,
self.config.socket_timeout_secs,
)
.await?;
}
// Authenticate if credentials provided
if let Some(auth) = &self.config.auth {
protocol::authenticate(
&mut stream,
auth,
&capabilities,
self.config.socket_timeout_secs,
)
.await?;
}
info!(
"New SMTP connection to {} established",
self.config.effective_pool_key()
);
Ok(PooledConnection {
stream,
capabilities,
created_at: Instant::now(),
last_used: Instant::now(),
message_count: 0,
idle: false,
})
}
fn cleanup_stale(&mut self) {
self.connections.retain(|c| !is_connection_stale(c));
}
/// Number of connections in the pool.
fn total(&self) -> usize {
self.connections.len()
}
/// Number of idle connections.
fn idle_count(&self) -> usize {
self.connections.iter().filter(|c| c.idle).count()
}
/// Close all connections.
fn close_all(&mut self) {
self.connections.clear();
}
}
/// Status report for a single pool.
#[derive(Debug, Clone, Serialize)]
pub struct PoolStatus {
pub total: usize,
pub active: usize,
pub idle: usize,
}
/// Manages connection pools for multiple SMTP destinations.
pub struct SmtpClientManager {
pools: DashMap<String, Arc<Mutex<ConnectionPool>>>,
}
impl SmtpClientManager {
pub fn new() -> Self {
Self {
pools: DashMap::new(),
}
}
/// Get or create a pool for the given config.
fn get_pool(&self, config: &SmtpClientConfig) -> Arc<Mutex<ConnectionPool>> {
let key = config.effective_pool_key();
self.pools
.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(ConnectionPool::new(config.clone()))))
.clone()
}
/// Acquire a connection from the pool, send a message, and release it.
pub async fn send_message(
&self,
config: &SmtpClientConfig,
sender: &str,
recipients: &[String],
message: &[u8],
) -> Result<SmtpSendResult, SmtpClientError> {
let pool_arc = self.get_pool(config);
let mut pool = pool_arc.lock().await;
let mut conn = pool.acquire().await?;
drop(pool); // Release the pool lock while we do network I/O
// Reset server state if reusing a connection that has already sent messages
if conn.message_count > 0 {
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
}
// Perform the SMTP transaction
let result =
Self::perform_send(&mut conn.stream, sender, recipients, message, config).await;
// Re-acquire the pool lock and release the connection
let mut pool = pool_arc.lock().await;
match &result {
Ok(_) => pool.release(conn),
Err(_) => {
// Don't return failed connections to the pool
debug!("Discarding connection after send failure");
}
}
result
}
/// Perform the SMTP send transaction on a connected stream.
async fn perform_send(
stream: &mut ClientSmtpStream,
sender: &str,
recipients: &[String],
message: &[u8],
config: &SmtpClientConfig,
) -> Result<SmtpSendResult, SmtpClientError> {
let timeout_secs = config.socket_timeout_secs;
// MAIL FROM
protocol::send_mail_from(stream, sender, timeout_secs).await?;
// RCPT TO for each recipient
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for rcpt in recipients {
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
Ok(resp) => {
if resp.is_success() {
accepted.push(rcpt.clone());
} else {
rejected.push(rcpt.clone());
}
}
Err(_) => {
rejected.push(rcpt.clone());
}
}
}
// If no recipients were accepted, fail
if accepted.is_empty() {
return Err(SmtpClientError::ProtocolError {
code: 550,
message: "All recipients were rejected".into(),
});
}
// DATA
let data_resp = protocol::send_data(stream, message, timeout_secs).await?;
// Extract message ID from the response if present
let message_id = data_resp
.lines
.iter()
.find_map(|line| {
// Look for a pattern like "queued as XXXX" or message-id
if line.contains("queued") || line.contains("id=") {
Some(line.clone())
} else {
None
}
});
Ok(SmtpSendResult {
accepted,
rejected,
message_id,
response: data_resp.full_message(),
envelope: SmtpEnvelope {
from: sender.to_string(),
to: recipients.to_vec(),
},
})
}
/// Verify connectivity to an SMTP server (connect, EHLO, QUIT).
pub async fn verify_connection(
&self,
config: &SmtpClientConfig,
) -> Result<SmtpVerifyResult, SmtpClientError> {
let mut stream = if config.secure {
connect_tls(
&config.host,
config.port,
config.connection_timeout_secs,
)
.await?
} else {
connect_plain(
&config.host,
config.port,
config.connection_timeout_secs,
)
.await?
};
let greeting = protocol::read_greeting(&mut stream, config.socket_timeout_secs).await?;
let caps =
protocol::send_ehlo(&mut stream, &config.domain, config.socket_timeout_secs).await?;
let _ = protocol::send_quit(&mut stream, config.socket_timeout_secs).await;
Ok(SmtpVerifyResult {
reachable: true,
greeting: Some(greeting.full_message()),
capabilities: Some(caps.extensions),
})
}
/// Get status of all pools.
pub fn pool_status(&self) -> std::collections::HashMap<String, PoolStatus> {
let mut result = std::collections::HashMap::new();
for entry in self.pools.iter() {
let key = entry.key().clone();
// Try to get the lock without blocking — if locked, report as active
match entry.value().try_lock() {
Ok(pool) => {
let total = pool.total();
let idle = pool.idle_count();
result.insert(
key,
PoolStatus {
total,
active: total - idle,
idle,
},
);
}
Err(_) => {
// Pool is in use; report as busy
result.insert(
key,
PoolStatus {
total: 0,
active: 1,
idle: 0,
},
);
}
}
}
result
}
/// Close a specific pool.
pub async fn close_pool(&self, key: &str) {
if let Some(pool_ref) = self.pools.get(key) {
let mut pool = pool_ref.lock().await;
pool.close_all();
}
self.pools.remove(key);
}
/// Close all pools.
pub async fn close_all_pools(&self) {
let keys: Vec<String> = self.pools.iter().map(|e| e.key().clone()).collect();
for key in keys {
self.close_pool(&key).await;
}
}
}
/// Result of sending an email via SMTP.
#[derive(Debug, Clone, Serialize)]
pub struct SmtpSendResult {
pub accepted: Vec<String>,
pub rejected: Vec<String>,
#[serde(rename = "messageId")]
pub message_id: Option<String>,
pub response: String,
pub envelope: SmtpEnvelope,
}
/// SMTP envelope (sender + recipients).
#[derive(Debug, Clone, Serialize)]
pub struct SmtpEnvelope {
pub from: String,
pub to: Vec<String>,
}
/// Result of verifying an SMTP connection.
#[derive(Debug, Clone, Serialize)]
pub struct SmtpVerifyResult {
pub reachable: bool,
pub greeting: Option<String>,
pub capabilities: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pool_status_serialization() {
let status = PoolStatus {
total: 5,
active: 2,
idle: 3,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"total\":5"));
assert!(json.contains("\"active\":2"));
assert!(json.contains("\"idle\":3"));
}
#[test]
fn test_send_result_serialization() {
let result = SmtpSendResult {
accepted: vec!["a@b.com".into()],
rejected: vec![],
message_id: Some("abc123".into()),
response: "250 OK".into(),
envelope: SmtpEnvelope {
from: "from@test.com".into(),
to: vec!["a@b.com".into()],
},
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"messageId\":\"abc123\""));
assert!(json.contains("\"accepted\":[\"a@b.com\"]"));
}
#[test]
fn test_verify_result_serialization() {
let result = SmtpVerifyResult {
reachable: true,
greeting: Some("220 mail.example.com".into()),
capabilities: Some(vec!["SIZE 10485760".into(), "STARTTLS".into()]),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"reachable\":true"));
}
#[test]
fn test_smtp_client_manager_new() {
let mgr = SmtpClientManager::new();
assert!(mgr.pool_status().is_empty());
}
#[tokio::test]
async fn test_close_all_empty() {
let mgr = SmtpClientManager::new();
mgr.close_all_pools().await;
assert!(mgr.pool_status().is_empty());
}
}

View 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());
}
}

View File

@@ -12,6 +12,7 @@
//! - TCP/TLS server (`server`)
//! - Connection handling (`connection`)
pub mod client;
pub mod command;
pub mod config;
pub mod connection;