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:
157
rust/crates/mailer-smtp/src/client/config.rs
Normal file
157
rust/crates/mailer-smtp/src/client/config.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
206
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
206
rust/crates/mailer-smtp/src/client/connection.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
160
rust/crates/mailer-smtp/src/client/error.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal 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};
|
||||
503
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
503
rust/crates/mailer-smtp/src/client/pool.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! - TCP/TLS server (`server`)
|
||||
//! - Connection handling (`connection`)
|
||||
|
||||
pub mod client;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
|
||||
Reference in New Issue
Block a user