Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1c5546186 | |||
| 5220ee0857 |
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.2.0 - feat(mailer-smtp)
|
||||||
|
implement in-process SMTP server and management IPC integration
|
||||||
|
|
||||||
|
- Add full SMTP protocol engine crate (mailer-smtp) with modules: command, config, connection, data, response, session, state, validation, rate_limiter and server
|
||||||
|
- Introduce SmtpServerConfig, DataAccumulator (DATA phase handling, dot-unstuffing, size limits) and SmtpResponse builder with EHLO capability construction
|
||||||
|
- Add in-process RateLimiter using DashMap and runtime-configurable RateLimitConfig
|
||||||
|
- Add TCP/TLS server start/stop API (start_server) with TlsAcceptor building from PEM and SmtpServerHandle for shutdown and status
|
||||||
|
- Integrate callback registry and oneshot-based correlation callbacks in mailer-bin management mode for email processing/auth results and JSON IPC parsing for SmtpServerConfig
|
||||||
|
- TypeScript bridge and routing updates: new IPC commands/types (startSmtpServer, stopSmtpServer, emailProcessingResult, authResult, configureRateLimits) and event handlers (emailReceived, authRequest)
|
||||||
|
- Update Cargo manifests and lockfile to add dependencies (dashmap, regex, rustls, rustls-pemfile, rustls-pki-types, uuid, serde_json, base64, etc.)
|
||||||
|
- Add comprehensive unit tests for new modules (config, data, response, session, state, rate_limiter, validation)
|
||||||
|
|
||||||
## 2026-02-10 - 2.1.0 - feat(security)
|
## 2026-02-10 - 2.1.0 - feat(security)
|
||||||
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
|
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '2.0.1',
|
version: '2.1.0',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
||||||
@@ -164,6 +164,17 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Stop the unified email server
|
* Stop the unified email server
|
||||||
*/
|
*/
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Handle an emailReceived event from the Rust SMTP server.
|
||||||
|
* Decodes the email data, processes it through the routing system,
|
||||||
|
* and sends back the result via the correlation-ID callback.
|
||||||
|
*/
|
||||||
|
private handleRustEmailReceived;
|
||||||
|
/**
|
||||||
|
* Handle an authRequest event from the Rust SMTP server.
|
||||||
|
* Validates credentials and sends back the result.
|
||||||
|
*/
|
||||||
|
private handleRustAuthRequest;
|
||||||
/**
|
/**
|
||||||
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
||||||
* Falls back gracefully if the bridge is not running.
|
* Falls back gracefully if the bridge is not running.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
92
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
92
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
@@ -60,6 +60,53 @@ interface IVersionInfo {
|
|||||||
security: string;
|
security: string;
|
||||||
smtp: string;
|
smtp: string;
|
||||||
}
|
}
|
||||||
|
interface ISmtpServerConfig {
|
||||||
|
hostname: string;
|
||||||
|
ports: number[];
|
||||||
|
securePort?: number;
|
||||||
|
tlsCertPem?: string;
|
||||||
|
tlsKeyPem?: string;
|
||||||
|
maxMessageSize?: number;
|
||||||
|
maxConnections?: number;
|
||||||
|
maxRecipients?: number;
|
||||||
|
connectionTimeoutSecs?: number;
|
||||||
|
dataTimeoutSecs?: number;
|
||||||
|
authEnabled?: boolean;
|
||||||
|
maxAuthFailures?: number;
|
||||||
|
socketTimeoutSecs?: number;
|
||||||
|
processingTimeoutSecs?: number;
|
||||||
|
rateLimits?: IRateLimitConfig;
|
||||||
|
}
|
||||||
|
interface IRateLimitConfig {
|
||||||
|
maxConnectionsPerIp?: number;
|
||||||
|
maxMessagesPerSender?: number;
|
||||||
|
maxAuthFailuresPerIp?: number;
|
||||||
|
windowSecs?: number;
|
||||||
|
}
|
||||||
|
interface IEmailData {
|
||||||
|
type: 'inline' | 'file';
|
||||||
|
base64?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
interface IEmailReceivedEvent {
|
||||||
|
correlationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
mailFrom: string;
|
||||||
|
rcptTo: string[];
|
||||||
|
data: IEmailData;
|
||||||
|
remoteAddr: string;
|
||||||
|
clientHostname: string | null;
|
||||||
|
secure: boolean;
|
||||||
|
authenticatedUser: string | null;
|
||||||
|
securityResults: any | null;
|
||||||
|
}
|
||||||
|
interface IAuthRequestEvent {
|
||||||
|
correlationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remoteAddr: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Bridge between TypeScript and the Rust `mailer-bin` binary.
|
* Bridge between TypeScript and the Rust `mailer-bin` binary.
|
||||||
*
|
*
|
||||||
@@ -135,5 +182,48 @@ export declare class RustSecurityBridge {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
mailFrom: string;
|
mailFrom: string;
|
||||||
}): Promise<IEmailSecurityResult>;
|
}): Promise<IEmailSecurityResult>;
|
||||||
|
/**
|
||||||
|
* Start the Rust SMTP server.
|
||||||
|
* The server will listen on the configured ports and emit events for
|
||||||
|
* emailReceived and authRequest that must be handled by the caller.
|
||||||
|
*/
|
||||||
|
startSmtpServer(config: ISmtpServerConfig): Promise<boolean>;
|
||||||
|
/** Stop the Rust SMTP server. */
|
||||||
|
stopSmtpServer(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Send the result of email processing back to the Rust SMTP server.
|
||||||
|
* This resolves a pending correlation-ID callback, allowing the Rust
|
||||||
|
* server to send the SMTP response to the client.
|
||||||
|
*/
|
||||||
|
sendEmailProcessingResult(opts: {
|
||||||
|
correlationId: string;
|
||||||
|
accepted: boolean;
|
||||||
|
smtpCode?: number;
|
||||||
|
smtpMessage?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Send the result of authentication validation back to the Rust SMTP server.
|
||||||
|
*/
|
||||||
|
sendAuthResult(opts: {
|
||||||
|
correlationId: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Update rate limit configuration at runtime. */
|
||||||
|
configureRateLimits(config: IRateLimitConfig): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Register a handler for emailReceived events from the Rust SMTP server.
|
||||||
|
* These events fire when a complete email has been received and needs processing.
|
||||||
|
*/
|
||||||
|
onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
|
||||||
|
/**
|
||||||
|
* Register a handler for authRequest events from the Rust SMTP server.
|
||||||
|
* The handler must call sendAuthResult() with the correlationId.
|
||||||
|
*/
|
||||||
|
onAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
|
||||||
|
/** Remove an emailReceived event handler. */
|
||||||
|
offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
|
||||||
|
/** Remove an authRequest event handler. */
|
||||||
|
offAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
|
||||||
}
|
}
|
||||||
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, };
|
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, ISmtpServerConfig, IRateLimitConfig, IEmailData, IEmailReceivedEvent, IAuthRequestEvent, };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
|
|||||||
18
rust/Cargo.lock
generated
18
rust/Cargo.lock
generated
@@ -1005,6 +1005,7 @@ name = "mailer-bin"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
@@ -1068,15 +1069,23 @@ dependencies = [
|
|||||||
name = "mailer-smtp"
|
name = "mailer-smtp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
|
"mailer-security",
|
||||||
|
"regex",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1491,6 +1500,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
|
|||||||
@@ -6,9 +6,16 @@
|
|||||||
//! integration with `@push.rocks/smartrust` from TypeScript
|
//! integration with `@push.rocks/smartrust` from TypeScript
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
use mailer_smtp::connection::{
|
||||||
|
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
||||||
|
};
|
||||||
|
|
||||||
/// mailer-bin: Rust-powered email security tools
|
/// mailer-bin: Rust-powered email security tools
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -105,6 +112,43 @@ struct IpcEvent {
|
|||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pending callbacks for correlation-ID based reverse calls ---
|
||||||
|
|
||||||
|
/// Stores oneshot senders for pending email processing and auth callbacks.
|
||||||
|
struct PendingCallbacks {
|
||||||
|
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||||
|
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingCallbacks {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
email: DashMap::new(),
|
||||||
|
auth: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackRegistry for PendingCallbacks {
|
||||||
|
fn register_email_callback(
|
||||||
|
&self,
|
||||||
|
correlation_id: &str,
|
||||||
|
) -> oneshot::Receiver<EmailProcessingResult> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.email.insert(correlation_id.to_string(), tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_auth_callback(
|
||||||
|
&self,
|
||||||
|
correlation_id: &str,
|
||||||
|
) -> oneshot::Receiver<AuthResult> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.auth.insert(correlation_id.to_string(), tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -278,7 +322,17 @@ fn main() {
|
|||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
|
/// Shared state for the management mode.
|
||||||
|
struct ManagementState {
|
||||||
|
callbacks: Arc<PendingCallbacks>,
|
||||||
|
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
||||||
|
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Run in management/IPC mode for smartrust bridge.
|
/// Run in management/IPC mode for smartrust bridge.
|
||||||
|
///
|
||||||
|
/// This mode supports both request/response IPC (existing commands) and
|
||||||
|
/// long-running SMTP server with event-based callbacks.
|
||||||
fn run_management_mode() {
|
fn run_management_mode() {
|
||||||
// Signal readiness
|
// Signal readiness
|
||||||
let ready_event = IpcEvent {
|
let ready_event = IpcEvent {
|
||||||
@@ -294,39 +348,153 @@ fn run_management_mode() {
|
|||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
|
||||||
let stdin = io::stdin();
|
let callbacks = Arc::new(PendingCallbacks::new());
|
||||||
for line in stdin.lock().lines() {
|
let mut state = ManagementState {
|
||||||
let line = match line {
|
callbacks: callbacks.clone(),
|
||||||
Ok(l) => l,
|
smtp_handle: None,
|
||||||
Err(_) => break,
|
smtp_event_rx: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if line.trim().is_empty() {
|
// We need to read stdin in a separate thread (blocking I/O)
|
||||||
continue;
|
// and process commands + SMTP events in the tokio runtime.
|
||||||
}
|
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<String>(256);
|
||||||
|
|
||||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
// Spawn stdin reader thread
|
||||||
Ok(r) => r,
|
std::thread::spawn(move || {
|
||||||
Err(e) => {
|
let stdin = io::stdin();
|
||||||
let resp = IpcResponse {
|
for line in stdin.lock().lines() {
|
||||||
id: "unknown".to_string(),
|
match line {
|
||||||
success: false,
|
Ok(l) if !l.trim().is_empty() => {
|
||||||
result: None,
|
if cmd_tx.blocking_send(l).is_err() {
|
||||||
error: Some(format!("Invalid request: {}", e)),
|
break;
|
||||||
};
|
}
|
||||||
println!("{}", serde_json::to_string(&resp).unwrap());
|
}
|
||||||
io::stdout().flush().unwrap();
|
Ok(_) => continue,
|
||||||
continue;
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let response = rt.block_on(handle_ipc_request(&req));
|
rt.block_on(async {
|
||||||
println!("{}", serde_json::to_string(&response).unwrap());
|
loop {
|
||||||
io::stdout().flush().unwrap();
|
// Select between stdin commands and SMTP server events
|
||||||
|
tokio::select! {
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
match cmd {
|
||||||
|
Some(line) => {
|
||||||
|
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let resp = IpcResponse {
|
||||||
|
id: "unknown".to_string(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid request: {}", e)),
|
||||||
|
};
|
||||||
|
emit_line(&serde_json::to_string(&resp).unwrap());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = handle_ipc_request(&req, &mut state).await;
|
||||||
|
emit_line(&serde_json::to_string(&response).unwrap());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// stdin closed — shut down
|
||||||
|
if let Some(handle) = state.smtp_handle.take() {
|
||||||
|
handle.shutdown().await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = async {
|
||||||
|
if let Some(rx) = &mut state.smtp_event_rx {
|
||||||
|
rx.recv().await
|
||||||
|
} else {
|
||||||
|
// No SMTP server running — wait forever (yields to other branch)
|
||||||
|
std::future::pending::<Option<ConnectionEvent>>().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(event) = event {
|
||||||
|
handle_smtp_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a line to stdout and flush.
|
||||||
|
fn emit_line(line: &str) {
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut handle = stdout.lock();
|
||||||
|
let _ = writeln!(handle, "{}", line);
|
||||||
|
let _ = handle.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an IPC event to stdout.
|
||||||
|
fn emit_event(event_name: &str, data: serde_json::Value) {
|
||||||
|
let event = IpcEvent {
|
||||||
|
event: event_name.to_string(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
emit_line(&serde_json::to_string(&event).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a connection event from the SMTP server.
|
||||||
|
fn handle_smtp_event(event: ConnectionEvent) {
|
||||||
|
match event {
|
||||||
|
ConnectionEvent::EmailReceived {
|
||||||
|
correlation_id,
|
||||||
|
session_id,
|
||||||
|
mail_from,
|
||||||
|
rcpt_to,
|
||||||
|
data,
|
||||||
|
remote_addr,
|
||||||
|
client_hostname,
|
||||||
|
secure,
|
||||||
|
authenticated_user,
|
||||||
|
security_results,
|
||||||
|
} => {
|
||||||
|
emit_event(
|
||||||
|
"emailReceived",
|
||||||
|
serde_json::json!({
|
||||||
|
"correlationId": correlation_id,
|
||||||
|
"sessionId": session_id,
|
||||||
|
"mailFrom": mail_from,
|
||||||
|
"rcptTo": rcpt_to,
|
||||||
|
"data": data,
|
||||||
|
"remoteAddr": remote_addr,
|
||||||
|
"clientHostname": client_hostname,
|
||||||
|
"secure": secure,
|
||||||
|
"authenticatedUser": authenticated_user,
|
||||||
|
"securityResults": security_results,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConnectionEvent::AuthRequest {
|
||||||
|
correlation_id,
|
||||||
|
session_id,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remote_addr,
|
||||||
|
} => {
|
||||||
|
emit_event(
|
||||||
|
"authRequest",
|
||||||
|
serde_json::json!({
|
||||||
|
"correlationId": correlation_id,
|
||||||
|
"sessionId": session_id,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"remoteAddr": remote_addr,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
"ping" => IpcResponse {
|
"ping" => IpcResponse {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
@@ -636,6 +804,35 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SMTP Server lifecycle commands ---
|
||||||
|
|
||||||
|
"startSmtpServer" => {
|
||||||
|
handle_start_smtp_server(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"stopSmtpServer" => {
|
||||||
|
handle_stop_smtp_server(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"emailProcessingResult" => {
|
||||||
|
handle_email_processing_result(req, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
"authResult" => {
|
||||||
|
handle_auth_result(req, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
"configureRateLimits" => {
|
||||||
|
// Rate limit configuration is set at startSmtpServer time.
|
||||||
|
// This command allows runtime updates, but for now we acknowledge it.
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"configured": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_ => IpcResponse {
|
_ => IpcResponse {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
@@ -644,3 +841,214 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle startSmtpServer IPC command.
|
||||||
|
async fn handle_start_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||||
|
// Stop existing server if running
|
||||||
|
if let Some(handle) = state.smtp_handle.take() {
|
||||||
|
handle.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse config from params
|
||||||
|
let config = match parse_smtp_config(&req.params) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid config: {}", e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse optional rate limit config
|
||||||
|
let rate_config = req.params.get("rateLimits").and_then(|v| {
|
||||||
|
serde_json::from_value::<mailer_smtp::rate_limiter::RateLimitConfig>(v.clone()).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
match mailer_smtp::server::start_server(config, state.callbacks.clone(), rate_config).await {
|
||||||
|
Ok((handle, event_rx)) => {
|
||||||
|
state.smtp_handle = Some(handle);
|
||||||
|
state.smtp_event_rx = Some(event_rx);
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"started": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Failed to start SMTP server: {}", e)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle stopSmtpServer IPC command.
|
||||||
|
async fn handle_stop_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||||
|
if let Some(handle) = state.smtp_handle.take() {
|
||||||
|
handle.shutdown().await;
|
||||||
|
state.smtp_event_rx = None;
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"stopped": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"stopped": true, "wasRunning": false})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle emailProcessingResult IPC command — resolves a pending email callback.
|
||||||
|
fn handle_email_processing_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
let correlation_id = req
|
||||||
|
.params
|
||||||
|
.get("correlationId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let result = EmailProcessingResult {
|
||||||
|
accepted: req.params.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
smtp_code: req.params.get("smtpCode").and_then(|v| v.as_u64()).map(|v| v as u16),
|
||||||
|
smtp_message: req
|
||||||
|
.params
|
||||||
|
.get("smtpMessage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, tx)) = state.callbacks.email.remove(correlation_id) {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"resolved": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!(
|
||||||
|
"No pending callback for correlationId: {}",
|
||||||
|
correlation_id
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle authResult IPC command — resolves a pending auth callback.
|
||||||
|
fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
let correlation_id = req
|
||||||
|
.params
|
||||||
|
.get("correlationId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let result = AuthResult {
|
||||||
|
success: req.params.get("success").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
message: req
|
||||||
|
.params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, tx)) = state.callbacks.auth.remove(correlation_id) {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"resolved": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!(
|
||||||
|
"No pending auth callback for correlationId: {}",
|
||||||
|
correlation_id
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SmtpServerConfig from IPC params JSON.
|
||||||
|
fn parse_smtp_config(
|
||||||
|
params: &serde_json::Value,
|
||||||
|
) -> Result<mailer_smtp::config::SmtpServerConfig, String> {
|
||||||
|
let mut config = mailer_smtp::config::SmtpServerConfig::default();
|
||||||
|
|
||||||
|
if let Some(hostname) = params.get("hostname").and_then(|v| v.as_str()) {
|
||||||
|
config.hostname = hostname.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ports) = params.get("ports").and_then(|v| v.as_array()) {
|
||||||
|
config.ports = ports
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_u64().map(|p| p as u16))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(secure_port) = params.get("securePort").and_then(|v| v.as_u64()) {
|
||||||
|
config.secure_port = Some(secure_port as u16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cert) = params.get("tlsCertPem").and_then(|v| v.as_str()) {
|
||||||
|
config.tls_cert_pem = Some(cert.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(key) = params.get("tlsKeyPem").and_then(|v| v.as_str()) {
|
||||||
|
config.tls_key_pem = Some(key.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(size) = params.get("maxMessageSize").and_then(|v| v.as_u64()) {
|
||||||
|
config.max_message_size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(conns) = params.get("maxConnections").and_then(|v| v.as_u64()) {
|
||||||
|
config.max_connections = conns as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rcpts) = params.get("maxRecipients").and_then(|v| v.as_u64()) {
|
||||||
|
config.max_recipients = rcpts as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timeout) = params.get("connectionTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||||
|
config.connection_timeout_secs = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timeout) = params.get("dataTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||||
|
config.data_timeout_secs = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(auth) = params.get("authEnabled").and_then(|v| v.as_bool()) {
|
||||||
|
config.auth_enabled = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(failures) = params.get("maxAuthFailures").and_then(|v| v.as_u64()) {
|
||||||
|
config.max_auth_failures = failures as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timeout) = params.get("socketTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||||
|
config.socket_timeout_secs = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timeout) = params.get("processingTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||||
|
config.processing_timeout_secs = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mailer-core = { path = "../mailer-core" }
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mailer-security = { path = "../mailer-security" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-rustls.workspace = true
|
tokio-rustls.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
@@ -14,3 +15,10 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
base64.workspace = true
|
||||||
|
rustls-pki-types.workspace = true
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
|||||||
421
rust/crates/mailer-smtp/src/command.rs
Normal file
421
rust/crates/mailer-smtp/src/command.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
//! SMTP command parser.
|
||||||
|
//!
|
||||||
|
//! Parses raw SMTP command lines into structured `SmtpCommand` variants.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// A parsed SMTP command.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SmtpCommand {
|
||||||
|
/// EHLO with client hostname/IP
|
||||||
|
Ehlo(String),
|
||||||
|
/// HELO with client hostname/IP
|
||||||
|
Helo(String),
|
||||||
|
/// MAIL FROM with sender address and optional parameters (e.g. SIZE=12345)
|
||||||
|
MailFrom {
|
||||||
|
address: String,
|
||||||
|
params: HashMap<String, Option<String>>,
|
||||||
|
},
|
||||||
|
/// RCPT TO with recipient address and optional parameters
|
||||||
|
RcptTo {
|
||||||
|
address: String,
|
||||||
|
params: HashMap<String, Option<String>>,
|
||||||
|
},
|
||||||
|
/// DATA command — begin message body
|
||||||
|
Data,
|
||||||
|
/// RSET — reset current transaction
|
||||||
|
Rset,
|
||||||
|
/// NOOP — no operation
|
||||||
|
Noop,
|
||||||
|
/// QUIT — close connection
|
||||||
|
Quit,
|
||||||
|
/// STARTTLS — upgrade to TLS
|
||||||
|
StartTls,
|
||||||
|
/// AUTH with mechanism and optional initial response
|
||||||
|
Auth {
|
||||||
|
mechanism: AuthMechanism,
|
||||||
|
initial_response: Option<String>,
|
||||||
|
},
|
||||||
|
/// HELP with optional topic
|
||||||
|
Help(Option<String>),
|
||||||
|
/// VRFY with address or username
|
||||||
|
Vrfy(String),
|
||||||
|
/// EXPN with mailing list name
|
||||||
|
Expn(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported AUTH mechanisms.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum AuthMechanism {
|
||||||
|
Plain,
|
||||||
|
Login,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during command parsing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error("empty command line")]
|
||||||
|
Empty,
|
||||||
|
#[error("unrecognized command: {0}")]
|
||||||
|
UnrecognizedCommand(String),
|
||||||
|
#[error("syntax error in parameters: {0}")]
|
||||||
|
SyntaxError(String),
|
||||||
|
#[error("missing required argument for {0}")]
|
||||||
|
MissingArgument(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a raw SMTP command line (without trailing CRLF) into a `SmtpCommand`.
|
||||||
|
pub fn parse_command(line: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let line = line.trim_end_matches('\r').trim_end_matches('\n');
|
||||||
|
if line.is_empty() {
|
||||||
|
return Err(ParseError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into verb and the rest
|
||||||
|
let (verb, rest) = split_first_word(line);
|
||||||
|
let verb_upper = verb.to_ascii_uppercase();
|
||||||
|
|
||||||
|
match verb_upper.as_str() {
|
||||||
|
"EHLO" => {
|
||||||
|
let hostname = rest.trim();
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("EHLO".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Ehlo(hostname.to_string()))
|
||||||
|
}
|
||||||
|
"HELO" => {
|
||||||
|
let hostname = rest.trim();
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("HELO".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Helo(hostname.to_string()))
|
||||||
|
}
|
||||||
|
"MAIL" => parse_mail_from(rest),
|
||||||
|
"RCPT" => parse_rcpt_to(rest),
|
||||||
|
"DATA" => Ok(SmtpCommand::Data),
|
||||||
|
"RSET" => Ok(SmtpCommand::Rset),
|
||||||
|
"NOOP" => Ok(SmtpCommand::Noop),
|
||||||
|
"QUIT" => Ok(SmtpCommand::Quit),
|
||||||
|
"STARTTLS" => Ok(SmtpCommand::StartTls),
|
||||||
|
"AUTH" => parse_auth(rest),
|
||||||
|
"HELP" => {
|
||||||
|
let topic = rest.trim();
|
||||||
|
if topic.is_empty() {
|
||||||
|
Ok(SmtpCommand::Help(None))
|
||||||
|
} else {
|
||||||
|
Ok(SmtpCommand::Help(Some(topic.to_string())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"VRFY" => {
|
||||||
|
let arg = rest.trim();
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("VRFY".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Vrfy(arg.to_string()))
|
||||||
|
}
|
||||||
|
"EXPN" => {
|
||||||
|
let arg = rest.trim();
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("EXPN".into()));
|
||||||
|
}
|
||||||
|
Ok(SmtpCommand::Expn(arg.to_string()))
|
||||||
|
}
|
||||||
|
_ => Err(ParseError::UnrecognizedCommand(verb_upper)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `FROM:<addr> [PARAM=VALUE ...]` after "MAIL".
|
||||||
|
fn parse_mail_from(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
// Expect "FROM:" prefix (case-insensitive, whitespace-flexible)
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest_upper = rest.to_ascii_uppercase();
|
||||||
|
if !rest_upper.starts_with("FROM") {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected FROM after MAIL".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[4..]; // skip "FROM"
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if !rest.starts_with(':') {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected colon after MAIL FROM".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[1..]; // skip ':'
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
parse_address_and_params(rest, "MAIL FROM").map(|(address, params)| SmtpCommand::MailFrom {
|
||||||
|
address,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `TO:<addr> [PARAM=VALUE ...]` after "RCPT".
|
||||||
|
fn parse_rcpt_to(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest_upper = rest.to_ascii_uppercase();
|
||||||
|
if !rest_upper.starts_with("TO") {
|
||||||
|
return Err(ParseError::SyntaxError("expected TO after RCPT".into()));
|
||||||
|
}
|
||||||
|
let rest = &rest[2..]; // skip "TO"
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if !rest.starts_with(':') {
|
||||||
|
return Err(ParseError::SyntaxError(
|
||||||
|
"expected colon after RCPT TO".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rest = &rest[1..]; // skip ':'
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
parse_address_and_params(rest, "RCPT TO").map(|(address, params)| SmtpCommand::RcptTo {
|
||||||
|
address,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `<address> [PARAM=VALUE ...]` from the rest of a MAIL FROM or RCPT TO line.
|
||||||
|
fn parse_address_and_params(
|
||||||
|
input: &str,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<(String, HashMap<String, Option<String>>), ParseError> {
|
||||||
|
if !input.starts_with('<') {
|
||||||
|
return Err(ParseError::SyntaxError(format!(
|
||||||
|
"expected '<' in {context}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let close_bracket = input.find('>').ok_or_else(|| {
|
||||||
|
ParseError::SyntaxError(format!("missing '>' in {context}"))
|
||||||
|
})?;
|
||||||
|
let address = input[1..close_bracket].to_string();
|
||||||
|
let remainder = &input[close_bracket + 1..];
|
||||||
|
let params = parse_params(remainder)?;
|
||||||
|
Ok((address, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SMTP extension parameters like `SIZE=12345 BODY=8BITMIME`.
|
||||||
|
fn parse_params(input: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
for token in input.split_whitespace() {
|
||||||
|
if let Some(eq_pos) = token.find('=') {
|
||||||
|
let key = token[..eq_pos].to_ascii_uppercase();
|
||||||
|
let value = token[eq_pos + 1..].to_string();
|
||||||
|
params.insert(key, Some(value));
|
||||||
|
} else {
|
||||||
|
params.insert(token.to_ascii_uppercase(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse AUTH command: `AUTH <mechanism> [initial-response]`.
|
||||||
|
fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||||
|
let rest = rest.trim();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(ParseError::MissingArgument("AUTH".into()));
|
||||||
|
}
|
||||||
|
let (mech_str, initial) = split_first_word(rest);
|
||||||
|
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||||
|
"PLAIN" => AuthMechanism::Plain,
|
||||||
|
"LOGIN" => AuthMechanism::Login,
|
||||||
|
other => {
|
||||||
|
return Err(ParseError::SyntaxError(format!(
|
||||||
|
"unsupported AUTH mechanism: {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let initial_response = {
|
||||||
|
let s = initial.trim();
|
||||||
|
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||||
|
};
|
||||||
|
Ok(SmtpCommand::Auth {
|
||||||
|
mechanism,
|
||||||
|
initial_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a string into the first whitespace-delimited word and the remainder.
|
||||||
|
fn split_first_word(s: &str) -> (&str, &str) {
|
||||||
|
match s.find(char::is_whitespace) {
|
||||||
|
Some(pos) => (&s[..pos], &s[pos + 1..]),
|
||||||
|
None => (s, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo() {
|
||||||
|
let cmd = parse_command("EHLO mail.example.com").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Ehlo("mail.example.com".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_case_insensitive() {
|
||||||
|
let cmd = parse_command("ehlo MAIL.EXAMPLE.COM").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Ehlo("MAIL.EXAMPLE.COM".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_helo() {
|
||||||
|
let cmd = parse_command("HELO example.com").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Helo("example.com".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_missing_arg() {
|
||||||
|
let err = parse_command("EHLO").unwrap_err();
|
||||||
|
assert!(matches!(err, ParseError::MissingArgument(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<sender@example.com>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::MailFrom {
|
||||||
|
address: "sender@example.com".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_with_params() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<sender@example.com> SIZE=12345 BODY=8BITMIME").unwrap();
|
||||||
|
if let SmtpCommand::MailFrom { address, params } = cmd {
|
||||||
|
assert_eq!(address, "sender@example.com");
|
||||||
|
assert_eq!(params.get("SIZE"), Some(&Some("12345".into())));
|
||||||
|
assert_eq!(params.get("BODY"), Some(&Some("8BITMIME".into())));
|
||||||
|
} else {
|
||||||
|
panic!("expected MailFrom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_empty_address() {
|
||||||
|
let cmd = parse_command("MAIL FROM:<>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::MailFrom {
|
||||||
|
address: "".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mail_from_flexible_spacing() {
|
||||||
|
let cmd = parse_command("MAIL FROM: <user@example.com>").unwrap();
|
||||||
|
if let SmtpCommand::MailFrom { address, .. } = cmd {
|
||||||
|
assert_eq!(address, "user@example.com");
|
||||||
|
} else {
|
||||||
|
panic!("expected MailFrom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rcpt_to() {
|
||||||
|
let cmd = parse_command("RCPT TO:<recipient@example.com>").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::RcptTo {
|
||||||
|
address: "recipient@example.com".into(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data() {
|
||||||
|
assert_eq!(parse_command("DATA").unwrap(), SmtpCommand::Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rset() {
|
||||||
|
assert_eq!(parse_command("RSET").unwrap(), SmtpCommand::Rset);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_noop() {
|
||||||
|
assert_eq!(parse_command("NOOP").unwrap(), SmtpCommand::Noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit() {
|
||||||
|
assert_eq!(parse_command("QUIT").unwrap(), SmtpCommand::Quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_starttls() {
|
||||||
|
assert_eq!(parse_command("STARTTLS").unwrap(), SmtpCommand::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_plain() {
|
||||||
|
let cmd = parse_command("AUTH PLAIN dGVzdAB0ZXN0AHBhc3N3b3Jk").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::Auth {
|
||||||
|
mechanism: AuthMechanism::Plain,
|
||||||
|
initial_response: Some("dGVzdAB0ZXN0AHBhc3N3b3Jk".into()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_login_no_initial() {
|
||||||
|
let cmd = parse_command("AUTH LOGIN").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
SmtpCommand::Auth {
|
||||||
|
mechanism: AuthMechanism::Login,
|
||||||
|
initial_response: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help() {
|
||||||
|
assert_eq!(parse_command("HELP").unwrap(), SmtpCommand::Help(None));
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("HELP MAIL").unwrap(),
|
||||||
|
SmtpCommand::Help(Some("MAIL".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vrfy() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("VRFY user@example.com").unwrap(),
|
||||||
|
SmtpCommand::Vrfy("user@example.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expn() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("EXPN staff").unwrap(),
|
||||||
|
SmtpCommand::Expn("staff".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty() {
|
||||||
|
assert!(matches!(parse_command(""), Err(ParseError::Empty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unrecognized() {
|
||||||
|
let err = parse_command("FOOBAR test").unwrap_err();
|
||||||
|
assert!(matches!(err, ParseError::UnrecognizedCommand(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crlf_stripped() {
|
||||||
|
let cmd = parse_command("QUIT\r\n").unwrap();
|
||||||
|
assert_eq!(cmd, SmtpCommand::Quit);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
rust/crates/mailer-smtp/src/config.rs
Normal file
86
rust/crates/mailer-smtp/src/config.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! SMTP server configuration.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Configuration for an SMTP server instance.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpServerConfig {
|
||||||
|
/// Server hostname for greeting and EHLO responses.
|
||||||
|
pub hostname: String,
|
||||||
|
/// Ports to listen on (e.g. [25, 587]).
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
||||||
|
pub secure_port: Option<u16>,
|
||||||
|
/// TLS certificate chain in PEM format.
|
||||||
|
pub tls_cert_pem: Option<String>,
|
||||||
|
/// TLS private key in PEM format.
|
||||||
|
pub tls_key_pem: Option<String>,
|
||||||
|
/// Maximum message size in bytes.
|
||||||
|
pub max_message_size: u64,
|
||||||
|
/// Maximum number of concurrent connections.
|
||||||
|
pub max_connections: u32,
|
||||||
|
/// Maximum recipients per message.
|
||||||
|
pub max_recipients: u32,
|
||||||
|
/// Connection timeout in seconds.
|
||||||
|
pub connection_timeout_secs: u64,
|
||||||
|
/// Data phase timeout in seconds.
|
||||||
|
pub data_timeout_secs: u64,
|
||||||
|
/// Whether authentication is available.
|
||||||
|
pub auth_enabled: bool,
|
||||||
|
/// Maximum authentication failures before disconnect.
|
||||||
|
pub max_auth_failures: u32,
|
||||||
|
/// Socket timeout in seconds (idle timeout for the entire connection).
|
||||||
|
pub socket_timeout_secs: u64,
|
||||||
|
/// Timeout in seconds waiting for TS to respond to email processing.
|
||||||
|
pub processing_timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SmtpServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hostname: "mail.example.com".to_string(),
|
||||||
|
ports: vec![25],
|
||||||
|
secure_port: None,
|
||||||
|
tls_cert_pem: None,
|
||||||
|
tls_key_pem: None,
|
||||||
|
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||||
|
max_connections: 100,
|
||||||
|
max_recipients: 100,
|
||||||
|
connection_timeout_secs: 30,
|
||||||
|
data_timeout_secs: 60,
|
||||||
|
auth_enabled: false,
|
||||||
|
max_auth_failures: 3,
|
||||||
|
socket_timeout_secs: 300,
|
||||||
|
processing_timeout_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpServerConfig {
|
||||||
|
/// Check if TLS is configured.
|
||||||
|
pub fn has_tls(&self) -> bool {
|
||||||
|
self.tls_cert_pem.is_some() && self.tls_key_pem.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_defaults() {
|
||||||
|
let cfg = SmtpServerConfig::default();
|
||||||
|
assert_eq!(cfg.max_message_size, 10 * 1024 * 1024);
|
||||||
|
assert_eq!(cfg.max_connections, 100);
|
||||||
|
assert!(!cfg.has_tls());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_tls() {
|
||||||
|
let mut cfg = SmtpServerConfig::default();
|
||||||
|
cfg.tls_cert_pem = Some("cert".into());
|
||||||
|
assert!(!cfg.has_tls()); // need both
|
||||||
|
cfg.tls_key_pem = Some("key".into());
|
||||||
|
assert!(cfg.has_tls());
|
||||||
|
}
|
||||||
|
}
|
||||||
1023
rust/crates/mailer-smtp/src/connection.rs
Normal file
1023
rust/crates/mailer-smtp/src/connection.rs
Normal file
File diff suppressed because it is too large
Load Diff
289
rust/crates/mailer-smtp/src/data.rs
Normal file
289
rust/crates/mailer-smtp/src/data.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
//! Email DATA phase processor.
|
||||||
|
//!
|
||||||
|
//! Handles dot-unstuffing, end-of-data detection, size enforcement,
|
||||||
|
//! and streaming accumulation of email data.
|
||||||
|
|
||||||
|
/// Result of processing a chunk of DATA input.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum DataAction {
|
||||||
|
/// More data needed — continue accumulating.
|
||||||
|
Continue,
|
||||||
|
/// End-of-data detected. The complete message body is ready.
|
||||||
|
Complete,
|
||||||
|
/// Message size limit exceeded.
|
||||||
|
SizeExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming email data accumulator.
|
||||||
|
///
|
||||||
|
/// Processes incoming bytes from the DATA phase, handling:
|
||||||
|
/// - CRLF line ending normalization
|
||||||
|
/// - Dot-unstuffing (RFC 5321 §4.5.2)
|
||||||
|
/// - End-of-data marker detection (`<CRLF>.<CRLF>`)
|
||||||
|
/// - Size enforcement
|
||||||
|
pub struct DataAccumulator {
|
||||||
|
/// Accumulated message bytes.
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
/// Maximum allowed size in bytes. 0 = unlimited.
|
||||||
|
max_size: u64,
|
||||||
|
/// Whether we've detected end-of-data.
|
||||||
|
complete: bool,
|
||||||
|
/// Whether the current position is at the start of a line.
|
||||||
|
at_line_start: bool,
|
||||||
|
/// Partial state for cross-chunk boundary handling.
|
||||||
|
partial: PartialState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks partial sequences that span chunk boundaries.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum PartialState {
|
||||||
|
/// No partial sequence.
|
||||||
|
None,
|
||||||
|
/// Saw `\r`, waiting for `\n`.
|
||||||
|
Cr,
|
||||||
|
/// At line start, saw `.`, waiting to determine dot-stuffing vs end-of-data.
|
||||||
|
Dot,
|
||||||
|
/// At line start, saw `.\r`, waiting for `\n` (end-of-data) or other.
|
||||||
|
DotCr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataAccumulator {
|
||||||
|
/// Create a new accumulator with the given size limit.
|
||||||
|
pub fn new(max_size: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: Vec::with_capacity(8192),
|
||||||
|
max_size,
|
||||||
|
complete: false,
|
||||||
|
at_line_start: true, // First byte is at start of first line
|
||||||
|
partial: PartialState::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a chunk of incoming data.
|
||||||
|
///
|
||||||
|
/// Returns the action to take: continue, complete, or size exceeded.
|
||||||
|
pub fn process_chunk(&mut self, chunk: &[u8]) -> DataAction {
|
||||||
|
if self.complete {
|
||||||
|
return DataAction::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
for &byte in chunk {
|
||||||
|
match self.partial {
|
||||||
|
PartialState::None => {
|
||||||
|
if self.at_line_start && byte == b'.' {
|
||||||
|
self.partial = PartialState::Dot;
|
||||||
|
} else if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
self.at_line_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::Cr => {
|
||||||
|
if byte == b'\n' {
|
||||||
|
self.buffer.extend_from_slice(b"\r\n");
|
||||||
|
self.at_line_start = true;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
} else {
|
||||||
|
// Bare CR — emit it and process current byte
|
||||||
|
self.buffer.push(b'\r');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
// Re-process current byte
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::Dot => {
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::DotCr;
|
||||||
|
} else if byte == b'.' {
|
||||||
|
// Dot-unstuffing: \r\n.. → \r\n.
|
||||||
|
// Emit one dot, consume the other
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
} else {
|
||||||
|
// Dot at line start but not stuffing or end-of-data
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.buffer.push(byte);
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartialState::DotCr => {
|
||||||
|
if byte == b'\n' {
|
||||||
|
// End-of-data: <CRLF>.<CRLF>
|
||||||
|
// Remove the trailing \r\n from the buffer
|
||||||
|
// (it was part of the terminator, not the message)
|
||||||
|
if self.buffer.ends_with(b"\r\n") {
|
||||||
|
let new_len = self.buffer.len() - 2;
|
||||||
|
self.buffer.truncate(new_len);
|
||||||
|
}
|
||||||
|
self.complete = true;
|
||||||
|
return DataAction::Complete;
|
||||||
|
} else {
|
||||||
|
// Not end-of-data — emit .\r and process current byte
|
||||||
|
self.buffer.push(b'.');
|
||||||
|
self.buffer.push(b'\r');
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.partial = PartialState::None;
|
||||||
|
// Re-process current byte
|
||||||
|
if byte == b'\r' {
|
||||||
|
self.partial = PartialState::Cr;
|
||||||
|
} else {
|
||||||
|
self.buffer.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size limit
|
||||||
|
if self.max_size > 0 && self.buffer.len() as u64 > self.max_size {
|
||||||
|
return DataAction::SizeExceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DataAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the accumulator and return the complete message data.
|
||||||
|
///
|
||||||
|
/// Returns `None` if end-of-data has not been detected.
|
||||||
|
pub fn into_message(self) -> Option<Vec<u8>> {
|
||||||
|
if !self.complete {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the accumulated data so far.
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current accumulated size.
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
self.buffer.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether end-of-data has been detected.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_message() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Subject: Test\r\n\r\nHello world\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Subject: Test\r\n\r\nHello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_unstuffing() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
// A line starting with ".." should become "."
|
||||||
|
let data = b"Line 1\r\n..dot-stuffed\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Line 1\r\n.dot-stuffed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Subject: Test\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\r\nBody line 1\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body line 2\r\n.\r\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Subject: Test\r\n\r\nBody line 1\r\nBody line 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_of_data_spanning_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body\r\n"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b".\r"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_limit() {
|
||||||
|
let mut acc = DataAccumulator::new(10);
|
||||||
|
let data = b"This is definitely more than 10 bytes\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::SizeExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_not_complete() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
acc.process_chunk(b"partial data");
|
||||||
|
assert!(!acc.is_complete());
|
||||||
|
assert!(acc.into_message().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_message() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let action = acc.process_chunk(b".\r\n");
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert!(msg.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_not_at_line_start() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Hello.World\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Hello.World");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_dots_in_line() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"...\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
// First dot at line start is dot-unstuffed, leaving ".."
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crlf_dot_spanning_three_chunks() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
assert_eq!(acc.process_chunk(b"Body\r"), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\n."), DataAction::Continue);
|
||||||
|
assert_eq!(acc.process_chunk(b"\r\n"), DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bare_cr() {
|
||||||
|
let mut acc = DataAccumulator::new(0);
|
||||||
|
let data = b"Hello\rWorld\r\n.\r\n";
|
||||||
|
let action = acc.process_chunk(data);
|
||||||
|
assert_eq!(action, DataAction::Complete);
|
||||||
|
let msg = acc.into_message().unwrap();
|
||||||
|
assert_eq!(msg, b"Hello\rWorld");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
//! mailer-smtp: SMTP protocol engine (server + client).
|
//! mailer-smtp: SMTP protocol engine (server + client).
|
||||||
|
//!
|
||||||
|
//! This crate provides the SMTP protocol implementation including:
|
||||||
|
//! - Command parsing (`command`)
|
||||||
|
//! - State machine (`state`)
|
||||||
|
//! - Response building (`response`)
|
||||||
|
//! - Email data accumulation (`data`)
|
||||||
|
//! - Per-connection session state (`session`)
|
||||||
|
//! - Address/input validation (`validation`)
|
||||||
|
//! - Server configuration (`config`)
|
||||||
|
//! - Rate limiting (`rate_limiter`)
|
||||||
|
//! - TCP/TLS server (`server`)
|
||||||
|
//! - Connection handling (`connection`)
|
||||||
|
|
||||||
|
pub mod command;
|
||||||
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod data;
|
||||||
|
pub mod rate_limiter;
|
||||||
|
pub mod response;
|
||||||
|
pub mod server;
|
||||||
|
pub mod session;
|
||||||
|
pub mod state;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
pub use mailer_core;
|
pub use mailer_core;
|
||||||
|
|
||||||
/// Placeholder for the SMTP server and client implementation.
|
// Re-export key types for convenience.
|
||||||
|
pub use command::{AuthMechanism, SmtpCommand};
|
||||||
|
pub use config::SmtpServerConfig;
|
||||||
|
pub use data::{DataAccumulator, DataAction};
|
||||||
|
pub use response::SmtpResponse;
|
||||||
|
pub use session::SmtpSession;
|
||||||
|
pub use state::SmtpState;
|
||||||
|
|
||||||
|
/// Crate version.
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_version() {
|
|
||||||
assert!(!version().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! In-process SMTP rate limiter.
|
||||||
|
//!
|
||||||
|
//! Uses DashMap for lock-free concurrent access to rate counters.
|
||||||
|
//! Tracks connections per IP, messages per sender, and auth failures.
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Rate limiter configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Maximum connections per IP per window.
|
||||||
|
pub max_connections_per_ip: u32,
|
||||||
|
/// Maximum messages per sender per window.
|
||||||
|
pub max_messages_per_sender: u32,
|
||||||
|
/// Maximum auth failures per IP per window.
|
||||||
|
pub max_auth_failures_per_ip: u32,
|
||||||
|
/// Window duration in seconds.
|
||||||
|
pub window_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_connections_per_ip: 50,
|
||||||
|
max_messages_per_sender: 100,
|
||||||
|
max_auth_failures_per_ip: 5,
|
||||||
|
window_secs: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timestamped counter entry.
|
||||||
|
struct CounterEntry {
|
||||||
|
count: u32,
|
||||||
|
window_start: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-process rate limiter using DashMap.
|
||||||
|
pub struct RateLimiter {
|
||||||
|
config: RateLimitConfig,
|
||||||
|
window: Duration,
|
||||||
|
connections: DashMap<String, CounterEntry>,
|
||||||
|
messages: DashMap<String, CounterEntry>,
|
||||||
|
auth_failures: DashMap<String, CounterEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
/// Create a new rate limiter with the given configuration.
|
||||||
|
pub fn new(config: RateLimitConfig) -> Self {
|
||||||
|
let window = Duration::from_secs(config.window_secs);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
window,
|
||||||
|
connections: DashMap::new(),
|
||||||
|
messages: DashMap::new(),
|
||||||
|
auth_failures: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the configuration at runtime.
|
||||||
|
pub fn update_config(&mut self, config: RateLimitConfig) {
|
||||||
|
self.window = Duration::from_secs(config.window_secs);
|
||||||
|
self.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record a new connection from an IP.
|
||||||
|
/// Returns `true` if the connection should be allowed.
|
||||||
|
pub fn check_connection(&self, ip: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.connections,
|
||||||
|
ip,
|
||||||
|
self.config.max_connections_per_ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record a message from a sender.
|
||||||
|
/// Returns `true` if the message should be allowed.
|
||||||
|
pub fn check_message(&self, sender: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.messages,
|
||||||
|
sender,
|
||||||
|
self.config.max_messages_per_sender,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and record an auth failure from an IP.
|
||||||
|
/// Returns `true` if more attempts should be allowed.
|
||||||
|
pub fn check_auth_failure(&self, ip: &str) -> bool {
|
||||||
|
self.increment_and_check(
|
||||||
|
&self.auth_failures,
|
||||||
|
ip,
|
||||||
|
self.config.max_auth_failures_per_ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment a counter and check against the limit.
|
||||||
|
/// Returns `true` if within limits.
|
||||||
|
fn increment_and_check(
|
||||||
|
&self,
|
||||||
|
map: &DashMap<String, CounterEntry>,
|
||||||
|
key: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entry = map
|
||||||
|
.entry(key.to_string())
|
||||||
|
.or_insert_with(|| CounterEntry {
|
||||||
|
count: 0,
|
||||||
|
window_start: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset window if expired
|
||||||
|
if now.duration_since(entry.window_start) > self.window {
|
||||||
|
entry.count = 0;
|
||||||
|
entry.window_start = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
entry.count <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired entries. Call periodically.
|
||||||
|
pub fn cleanup(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let window = self.window;
|
||||||
|
self.connections
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
self.messages
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
self.auth_failures
|
||||||
|
.retain(|_, v| now.duration_since(v.window_start) <= window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_connections_per_ip: 3,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(limiter.check_connection("1.2.3.4"));
|
||||||
|
assert!(!limiter.check_connection("1.2.3.4")); // 4th = over limit
|
||||||
|
|
||||||
|
// Different IP is independent
|
||||||
|
assert!(limiter.check_connection("5.6.7.8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_messages_per_sender: 2,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_message("sender@example.com"));
|
||||||
|
assert!(limiter.check_message("sender@example.com"));
|
||||||
|
assert!(!limiter.check_message("sender@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_failure_limit() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_auth_failures_per_ip: 2,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
assert!(limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
assert!(!limiter.check_auth_failure("1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup() {
|
||||||
|
let limiter = RateLimiter::new(RateLimitConfig {
|
||||||
|
max_connections_per_ip: 1,
|
||||||
|
window_secs: 60,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
limiter.check_connection("1.2.3.4");
|
||||||
|
assert_eq!(limiter.connections.len(), 1);
|
||||||
|
|
||||||
|
limiter.cleanup(); // entries not expired
|
||||||
|
assert_eq!(limiter.connections.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
284
rust/crates/mailer-smtp/src/response.rs
Normal file
284
rust/crates/mailer-smtp/src/response.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! SMTP response builder.
|
||||||
|
//!
|
||||||
|
//! Constructs properly formatted SMTP response lines with status codes,
|
||||||
|
//! multiline support, and EHLO capability advertisement.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An SMTP response to send to the client.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpResponse {
|
||||||
|
/// 3-digit SMTP status code.
|
||||||
|
pub code: u16,
|
||||||
|
/// Response lines (without the status code prefix).
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpResponse {
|
||||||
|
/// Create a single-line response.
|
||||||
|
pub fn new(code: u16, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
lines: vec![message.into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a multiline response.
|
||||||
|
pub fn multiline(code: u16, lines: Vec<String>) -> Self {
|
||||||
|
Self { code, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the response as bytes ready to write to the socket.
|
||||||
|
///
|
||||||
|
/// Multiline responses use `code-text` for intermediate lines
|
||||||
|
/// and `code text` for the final line (RFC 5321 §4.2).
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
if self.lines.is_empty() {
|
||||||
|
buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes());
|
||||||
|
} else if self.lines.len() == 1 {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (i, line) in self.lines.iter().enumerate() {
|
||||||
|
if i < self.lines.len() - 1 {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{}-{}\r\n", self.code, line).as_bytes(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buf.extend_from_slice(
|
||||||
|
format!("{} {}\r\n", self.code, line).as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Common response constructors ---
|
||||||
|
|
||||||
|
/// 220 Service ready greeting.
|
||||||
|
pub fn greeting(hostname: &str) -> Self {
|
||||||
|
Self::new(220, format!("{hostname} ESMTP Service Ready"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 221 Service closing.
|
||||||
|
pub fn closing(hostname: &str) -> Self {
|
||||||
|
Self::new(221, format!("{hostname} Service closing transmission channel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 250 OK.
|
||||||
|
pub fn ok(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(250, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EHLO response with capabilities.
|
||||||
|
pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self {
|
||||||
|
let mut lines = Vec::with_capacity(capabilities.len() + 1);
|
||||||
|
lines.push(format!("{hostname} greets you"));
|
||||||
|
for cap in capabilities {
|
||||||
|
lines.push(cap.clone());
|
||||||
|
}
|
||||||
|
Self::multiline(250, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 235 Authentication successful.
|
||||||
|
pub fn auth_success() -> Self {
|
||||||
|
Self::new(235, "2.7.0 Authentication successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 334 Auth challenge (base64-encoded prompt).
|
||||||
|
pub fn auth_challenge(prompt: &str) -> Self {
|
||||||
|
Self::new(334, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 354 Start mail input.
|
||||||
|
pub fn start_data() -> Self {
|
||||||
|
Self::new(354, "Start mail input; end with <CRLF>.<CRLF>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 421 Service not available.
|
||||||
|
pub fn service_unavailable(hostname: &str, reason: &str) -> Self {
|
||||||
|
Self::new(421, format!("{hostname} {reason}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 450 Temporary failure.
|
||||||
|
pub fn temp_failure(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(450, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 451 Local error.
|
||||||
|
pub fn local_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(451, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 500 Syntax error.
|
||||||
|
pub fn syntax_error() -> Self {
|
||||||
|
Self::new(500, "Syntax error, command unrecognized")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 501 Syntax error in parameters.
|
||||||
|
pub fn param_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(501, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 502 Command not implemented.
|
||||||
|
pub fn not_implemented() -> Self {
|
||||||
|
Self::new(502, "Command not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 503 Bad sequence.
|
||||||
|
pub fn bad_sequence(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(503, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 530 Authentication required.
|
||||||
|
pub fn auth_required() -> Self {
|
||||||
|
Self::new(530, "5.7.0 Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 535 Authentication failed.
|
||||||
|
pub fn auth_failed() -> Self {
|
||||||
|
Self::new(535, "5.7.8 Authentication credentials invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 550 Mailbox unavailable.
|
||||||
|
pub fn mailbox_unavailable(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(550, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 552 Message size exceeded.
|
||||||
|
pub fn size_exceeded(max_size: u64) -> Self {
|
||||||
|
Self::new(
|
||||||
|
552,
|
||||||
|
format!("5.3.4 Message size exceeds maximum of {max_size} bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 554 Transaction failed.
|
||||||
|
pub fn transaction_failed(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(554, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a success response (2xx).
|
||||||
|
pub fn is_success(&self) -> bool {
|
||||||
|
self.code >= 200 && self.code < 300
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a temporary error (4xx).
|
||||||
|
pub fn is_temp_error(&self) -> bool {
|
||||||
|
self.code >= 400 && self.code < 500
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a permanent error (5xx).
|
||||||
|
pub fn is_perm_error(&self) -> bool {
|
||||||
|
self.code >= 500 && self.code < 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the list of EHLO capabilities for the server.
|
||||||
|
pub fn build_capabilities(
|
||||||
|
max_size: u64,
|
||||||
|
tls_available: bool,
|
||||||
|
already_secure: bool,
|
||||||
|
auth_available: bool,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut caps = vec![
|
||||||
|
format!("SIZE {max_size}"),
|
||||||
|
"8BITMIME".to_string(),
|
||||||
|
"PIPELINING".to_string(),
|
||||||
|
"ENHANCEDSTATUSCODES".to_string(),
|
||||||
|
"HELP".to_string(),
|
||||||
|
];
|
||||||
|
// Only advertise STARTTLS if TLS is available and not already using TLS
|
||||||
|
if tls_available && !already_secure {
|
||||||
|
caps.push("STARTTLS".to_string());
|
||||||
|
}
|
||||||
|
if auth_available {
|
||||||
|
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||||
|
}
|
||||||
|
caps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_line() {
|
||||||
|
let resp = SmtpResponse::new(250, "OK");
|
||||||
|
assert_eq!(resp.to_bytes(), b"250 OK\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiline() {
|
||||||
|
let resp = SmtpResponse::multiline(
|
||||||
|
250,
|
||||||
|
vec![
|
||||||
|
"mail.example.com greets you".into(),
|
||||||
|
"SIZE 10485760".into(),
|
||||||
|
"STARTTLS".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n";
|
||||||
|
assert_eq!(resp.to_bytes(), expected.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_greeting() {
|
||||||
|
let resp = SmtpResponse::greeting("mail.example.com");
|
||||||
|
assert_eq!(resp.code, 220);
|
||||||
|
assert!(resp.lines[0].contains("mail.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_response() {
|
||||||
|
let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()];
|
||||||
|
let resp = SmtpResponse::ehlo_response("mail.example.com", &caps);
|
||||||
|
assert_eq!(resp.code, 250);
|
||||||
|
assert_eq!(resp.lines.len(), 3); // hostname + 2 caps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_status_checks() {
|
||||||
|
assert!(SmtpResponse::new(250, "OK").is_success());
|
||||||
|
assert!(SmtpResponse::new(450, "Try later").is_temp_error());
|
||||||
|
assert!(SmtpResponse::new(550, "No such user").is_perm_error());
|
||||||
|
assert!(!SmtpResponse::new(250, "OK").is_temp_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_capabilities() {
|
||||||
|
let caps = build_capabilities(10485760, true, false, true);
|
||||||
|
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
||||||
|
assert!(caps.contains(&"STARTTLS".to_string()));
|
||||||
|
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||||
|
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_capabilities_secure() {
|
||||||
|
// When already secure, STARTTLS should NOT be advertised
|
||||||
|
let caps = build_capabilities(10485760, true, true, false);
|
||||||
|
assert!(!caps.contains(&"STARTTLS".to_string()));
|
||||||
|
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_response() {
|
||||||
|
let resp = SmtpResponse::multiline(250, vec![]);
|
||||||
|
assert_eq!(resp.to_bytes(), b"250 \r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_common_responses() {
|
||||||
|
assert_eq!(SmtpResponse::start_data().code, 354);
|
||||||
|
assert_eq!(SmtpResponse::syntax_error().code, 500);
|
||||||
|
assert_eq!(SmtpResponse::not_implemented().code, 502);
|
||||||
|
assert_eq!(SmtpResponse::bad_sequence("test").code, 503);
|
||||||
|
assert_eq!(SmtpResponse::auth_required().code, 530);
|
||||||
|
assert_eq!(SmtpResponse::auth_failed().code, 535);
|
||||||
|
assert_eq!(SmtpResponse::auth_success().code, 235);
|
||||||
|
}
|
||||||
|
}
|
||||||
308
rust/crates/mailer-smtp/src/server.rs
Normal file
308
rust/crates/mailer-smtp/src/server.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
//! SMTP TCP/TLS server.
|
||||||
|
//!
|
||||||
|
//! Listens on configured ports, accepts connections, and dispatches
|
||||||
|
//! them to per-connection handlers.
|
||||||
|
|
||||||
|
use crate::config::SmtpServerConfig;
|
||||||
|
use crate::connection::{
|
||||||
|
self, CallbackRegistry, ConnectionEvent, SmtpStream,
|
||||||
|
};
|
||||||
|
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||||
|
|
||||||
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::BufReader as TokioBufReader;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Handle for a running SMTP server.
|
||||||
|
pub struct SmtpServerHandle {
|
||||||
|
/// Shutdown signal.
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
/// Join handles for the listener tasks.
|
||||||
|
handles: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
/// Active connection count.
|
||||||
|
pub active_connections: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpServerHandle {
|
||||||
|
/// Signal shutdown and wait for all listeners to stop.
|
||||||
|
pub async fn shutdown(self) {
|
||||||
|
self.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
for handle in self.handles {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
info!("SMTP server shut down");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the server is running.
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
!self.shutdown.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the SMTP server with the given configuration.
|
||||||
|
///
|
||||||
|
/// Returns a handle that can be used to shut down the server,
|
||||||
|
/// and an event receiver for connection events (emailReceived, authRequest).
|
||||||
|
pub async fn start_server(
|
||||||
|
config: SmtpServerConfig,
|
||||||
|
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||||
|
rate_limit_config: Option<RateLimitConfig>,
|
||||||
|
) -> Result<(SmtpServerHandle, mpsc::Receiver<ConnectionEvent>), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
{
|
||||||
|
let config = Arc::new(config);
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let active_connections = Arc::new(AtomicU32::new(0));
|
||||||
|
let rate_limiter = Arc::new(RateLimiter::new(
|
||||||
|
rate_limit_config.unwrap_or_default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
|
||||||
|
|
||||||
|
// Build TLS acceptor if configured
|
||||||
|
let tls_acceptor = if config.has_tls() {
|
||||||
|
Some(Arc::new(build_tls_acceptor(&config)?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
// Start listeners on each port
|
||||||
|
for &port in &config.ports {
|
||||||
|
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
|
info!(port = port, "SMTP server listening (STARTTLS)");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(accept_loop(
|
||||||
|
listener,
|
||||||
|
config.clone(),
|
||||||
|
shutdown.clone(),
|
||||||
|
active_connections.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
callback_registry.clone(),
|
||||||
|
tls_acceptor.clone(),
|
||||||
|
false, // not implicit TLS
|
||||||
|
));
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start implicit TLS listener if configured
|
||||||
|
if let Some(secure_port) = config.secure_port {
|
||||||
|
if tls_acceptor.is_some() {
|
||||||
|
let listener =
|
||||||
|
TcpListener::bind(format!("0.0.0.0:{secure_port}")).await?;
|
||||||
|
info!(port = secure_port, "SMTP server listening (implicit TLS)");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(accept_loop(
|
||||||
|
listener,
|
||||||
|
config.clone(),
|
||||||
|
shutdown.clone(),
|
||||||
|
active_connections.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
callback_registry.clone(),
|
||||||
|
tls_acceptor.clone(),
|
||||||
|
true, // implicit TLS
|
||||||
|
));
|
||||||
|
handles.push(handle);
|
||||||
|
} else {
|
||||||
|
warn!("Secure port configured but TLS certificates not provided");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn periodic rate limiter cleanup
|
||||||
|
{
|
||||||
|
let rate_limiter = rate_limiter.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval =
|
||||||
|
tokio::time::interval(tokio::time::Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
rate_limiter.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
SmtpServerHandle {
|
||||||
|
shutdown,
|
||||||
|
handles,
|
||||||
|
active_connections,
|
||||||
|
},
|
||||||
|
event_rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept loop for a single listener.
|
||||||
|
async fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
config: Arc<SmtpServerConfig>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
active_connections: Arc<AtomicU32>,
|
||||||
|
rate_limiter: Arc<RateLimiter>,
|
||||||
|
event_tx: mpsc::Sender<ConnectionEvent>,
|
||||||
|
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||||
|
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||||
|
implicit_tls: bool,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short timeout to check shutdown periodically
|
||||||
|
let accept_result = tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_secs(1),
|
||||||
|
listener.accept(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (tcp_stream, peer_addr) = match accept_result {
|
||||||
|
Ok(Ok((stream, addr))) => (stream, addr),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!(error = %e, "Accept error");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => continue, // timeout, check shutdown
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check max connections
|
||||||
|
let current = active_connections.load(Ordering::SeqCst);
|
||||||
|
if current >= config.max_connections {
|
||||||
|
warn!(
|
||||||
|
current = current,
|
||||||
|
max = config.max_connections,
|
||||||
|
"Max connections reached, rejecting"
|
||||||
|
);
|
||||||
|
drop(tcp_stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_addr = peer_addr.ip().to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
let rate_limiter = rate_limiter.clone();
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
let callback_registry = callback_registry.clone();
|
||||||
|
let tls_acceptor = tls_acceptor.clone();
|
||||||
|
let active_connections = active_connections.clone();
|
||||||
|
|
||||||
|
active_connections.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let stream = if implicit_tls {
|
||||||
|
// Implicit TLS: wrap immediately
|
||||||
|
if let Some(acceptor) = &tls_acceptor {
|
||||||
|
match acceptor.accept(tcp_stream).await {
|
||||||
|
Ok(tls_stream) => {
|
||||||
|
SmtpStream::Tls(TokioBufReader::new(tls_stream))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
remote_addr = %remote_addr,
|
||||||
|
error = %e,
|
||||||
|
"Implicit TLS handshake failed"
|
||||||
|
);
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SmtpStream::Plain(TokioBufReader::new(tcp_stream))
|
||||||
|
};
|
||||||
|
|
||||||
|
connection::handle_connection(
|
||||||
|
stream,
|
||||||
|
config,
|
||||||
|
rate_limiter,
|
||||||
|
event_tx,
|
||||||
|
callback_registry,
|
||||||
|
tls_acceptor,
|
||||||
|
remote_addr,
|
||||||
|
implicit_tls,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a TLS acceptor from PEM cert/key strings.
|
||||||
|
fn build_tls_acceptor(
|
||||||
|
config: &SmtpServerConfig,
|
||||||
|
) -> Result<tokio_rustls::TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let cert_pem = config
|
||||||
|
.tls_cert_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("TLS cert not configured")?;
|
||||||
|
let key_pem = config
|
||||||
|
.tls_key_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("TLS key not configured")?;
|
||||||
|
|
||||||
|
// Parse certificates
|
||||||
|
let certs: Vec<CertificateDer<'static>> = {
|
||||||
|
let mut reader = BufReader::new(cert_pem.as_bytes());
|
||||||
|
rustls_pemfile::certs(&mut reader)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if certs.is_empty() {
|
||||||
|
return Err("No certificates found in PEM".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse private key
|
||||||
|
let key: PrivateKeyDer<'static> = {
|
||||||
|
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||||
|
// Try PKCS8 first, then RSA, then EC
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for item in rustls_pemfile::read_all(&mut reader) {
|
||||||
|
match item? {
|
||||||
|
rustls_pemfile::Item::Pkcs8Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Pkcs8(key));
|
||||||
|
}
|
||||||
|
rustls_pemfile::Item::Pkcs1Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Pkcs1(key));
|
||||||
|
}
|
||||||
|
rustls_pemfile::Item::Sec1Key(key) => {
|
||||||
|
keys.push(PrivateKeyDer::Sec1(key));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or("No private key found in PEM")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let tls_config = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)?;
|
||||||
|
|
||||||
|
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_config_defaults() {
|
||||||
|
let config = SmtpServerConfig::default();
|
||||||
|
assert!(!config.has_tls());
|
||||||
|
assert_eq!(config.ports, vec![25]);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
rust/crates/mailer-smtp/src/session.rs
Normal file
206
rust/crates/mailer-smtp/src/session.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
//! Per-connection SMTP session state.
|
||||||
|
//!
|
||||||
|
//! Tracks the envelope, authentication, TLS status, and counters
|
||||||
|
//! for a single SMTP connection.
|
||||||
|
|
||||||
|
use crate::state::SmtpState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Envelope accumulator for the current mail transaction.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Envelope {
|
||||||
|
/// Sender address from MAIL FROM.
|
||||||
|
pub mail_from: String,
|
||||||
|
/// Recipient addresses from RCPT TO.
|
||||||
|
pub rcpt_to: Vec<String>,
|
||||||
|
/// Declared message size from MAIL FROM SIZE= param (if any).
|
||||||
|
pub declared_size: Option<u64>,
|
||||||
|
/// BODY parameter (e.g. "8BITMIME").
|
||||||
|
pub body_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication state for the session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum AuthState {
|
||||||
|
/// Not authenticated and not in progress.
|
||||||
|
None,
|
||||||
|
/// Waiting for AUTH credentials (LOGIN flow step).
|
||||||
|
WaitingForUsername,
|
||||||
|
/// Have username, waiting for password.
|
||||||
|
WaitingForPassword { username: String },
|
||||||
|
/// Successfully authenticated.
|
||||||
|
Authenticated { username: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthState {
|
||||||
|
fn default() -> Self {
|
||||||
|
AuthState::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-connection session state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpSession {
|
||||||
|
/// Unique session identifier.
|
||||||
|
pub id: String,
|
||||||
|
/// Current protocol state.
|
||||||
|
pub state: SmtpState,
|
||||||
|
/// Client's EHLO/HELO hostname.
|
||||||
|
pub client_hostname: Option<String>,
|
||||||
|
/// Whether the client used EHLO (vs HELO).
|
||||||
|
pub esmtp: bool,
|
||||||
|
/// Whether the connection is using TLS.
|
||||||
|
pub secure: bool,
|
||||||
|
/// Authentication state.
|
||||||
|
pub auth_state: AuthState,
|
||||||
|
/// Current transaction envelope.
|
||||||
|
pub envelope: Envelope,
|
||||||
|
/// Remote IP address.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Number of messages sent in this session.
|
||||||
|
pub message_count: u32,
|
||||||
|
/// Number of failed auth attempts.
|
||||||
|
pub auth_failures: u32,
|
||||||
|
/// Number of invalid commands.
|
||||||
|
pub invalid_commands: u32,
|
||||||
|
/// Maximum allowed invalid commands before disconnect.
|
||||||
|
pub max_invalid_commands: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpSession {
|
||||||
|
/// Create a new session for a connection.
|
||||||
|
pub fn new(remote_addr: String, secure: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
state: SmtpState::Connected,
|
||||||
|
client_hostname: None,
|
||||||
|
esmtp: false,
|
||||||
|
secure,
|
||||||
|
auth_state: AuthState::None,
|
||||||
|
envelope: Envelope::default(),
|
||||||
|
remote_addr,
|
||||||
|
message_count: 0,
|
||||||
|
auth_failures: 0,
|
||||||
|
invalid_commands: 0,
|
||||||
|
max_invalid_commands: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the current transaction (RSET), preserving connection state.
|
||||||
|
pub fn reset_transaction(&mut self) {
|
||||||
|
self.envelope = Envelope::default();
|
||||||
|
if self.state != SmtpState::Connected {
|
||||||
|
self.state = SmtpState::Greeted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset session for a new EHLO (preserves counters and TLS).
|
||||||
|
pub fn reset_for_ehlo(&mut self, hostname: String, esmtp: bool) {
|
||||||
|
self.client_hostname = Some(hostname);
|
||||||
|
self.esmtp = esmtp;
|
||||||
|
self.envelope = Envelope::default();
|
||||||
|
self.state = SmtpState::Greeted;
|
||||||
|
// Auth state is reset on new EHLO per RFC
|
||||||
|
self.auth_state = AuthState::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the client is authenticated.
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
matches!(self.auth_state, AuthState::Authenticated { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the authenticated username, if any.
|
||||||
|
pub fn authenticated_user(&self) -> Option<&str> {
|
||||||
|
match &self.auth_state {
|
||||||
|
AuthState::Authenticated { username } => Some(username),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a completed message delivery.
|
||||||
|
pub fn record_message(&mut self) {
|
||||||
|
self.message_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed auth attempt. Returns true if limit exceeded.
|
||||||
|
pub fn record_auth_failure(&mut self, max_failures: u32) -> bool {
|
||||||
|
self.auth_failures += 1;
|
||||||
|
self.auth_failures >= max_failures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an invalid command. Returns true if limit exceeded.
|
||||||
|
pub fn record_invalid_command(&mut self) -> bool {
|
||||||
|
self.invalid_commands += 1;
|
||||||
|
self.invalid_commands >= self.max_invalid_commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_session() {
|
||||||
|
let session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert_eq!(session.state, SmtpState::Connected);
|
||||||
|
assert!(!session.secure);
|
||||||
|
assert!(!session.is_authenticated());
|
||||||
|
assert!(session.client_hostname.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_transaction() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
session.state = SmtpState::RcptTo;
|
||||||
|
session.envelope.mail_from = "sender@example.com".into();
|
||||||
|
session.envelope.rcpt_to.push("rcpt@example.com".into());
|
||||||
|
|
||||||
|
session.reset_transaction();
|
||||||
|
|
||||||
|
assert_eq!(session.state, SmtpState::Greeted);
|
||||||
|
assert!(session.envelope.mail_from.is_empty());
|
||||||
|
assert!(session.envelope.rcpt_to.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_for_ehlo() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), true);
|
||||||
|
session.auth_state = AuthState::Authenticated {
|
||||||
|
username: "user".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
session.reset_for_ehlo("mail.example.com".into(), true);
|
||||||
|
|
||||||
|
assert_eq!(session.state, SmtpState::Greeted);
|
||||||
|
assert_eq!(session.client_hostname.as_deref(), Some("mail.example.com"));
|
||||||
|
assert!(session.esmtp);
|
||||||
|
assert!(!session.is_authenticated()); // Auth reset after EHLO
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_failures() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert!(!session.record_auth_failure(3));
|
||||||
|
assert!(!session.record_auth_failure(3));
|
||||||
|
assert!(session.record_auth_failure(3)); // 3rd failure -> limit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_commands() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
session.max_invalid_commands = 3;
|
||||||
|
assert!(!session.record_invalid_command());
|
||||||
|
assert!(!session.record_invalid_command());
|
||||||
|
assert!(session.record_invalid_command()); // 3rd -> limit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_count() {
|
||||||
|
let mut session = SmtpSession::new("127.0.0.1".into(), false);
|
||||||
|
assert_eq!(session.message_count, 0);
|
||||||
|
session.record_message();
|
||||||
|
session.record_message();
|
||||||
|
assert_eq!(session.message_count, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
rust/crates/mailer-smtp/src/state.rs
Normal file
219
rust/crates/mailer-smtp/src/state.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! SMTP protocol state machine.
|
||||||
|
//!
|
||||||
|
//! Defines valid states and transitions for an SMTP session.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// SMTP session states following RFC 5321.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum SmtpState {
|
||||||
|
/// Initial state — waiting for server greeting.
|
||||||
|
Connected,
|
||||||
|
/// After successful EHLO/HELO.
|
||||||
|
Greeted,
|
||||||
|
/// After MAIL FROM accepted.
|
||||||
|
MailFrom,
|
||||||
|
/// After at least one RCPT TO accepted.
|
||||||
|
RcptTo,
|
||||||
|
/// In DATA mode — accumulating message body.
|
||||||
|
Data,
|
||||||
|
/// Transaction completed — can start a new one or QUIT.
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State transition errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||||
|
pub enum TransitionError {
|
||||||
|
#[error("cannot {action} in state {state:?}")]
|
||||||
|
InvalidTransition {
|
||||||
|
state: SmtpState,
|
||||||
|
action: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpState {
|
||||||
|
/// Check whether EHLO/HELO is valid in the current state.
|
||||||
|
/// EHLO/HELO can be issued at any time to reset the session.
|
||||||
|
pub fn can_ehlo(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether MAIL FROM is valid in the current state.
|
||||||
|
pub fn can_mail_from(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether RCPT TO is valid in the current state.
|
||||||
|
pub fn can_rcpt_to(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::MailFrom | SmtpState::RcptTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether DATA is valid in the current state.
|
||||||
|
pub fn can_data(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::RcptTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether STARTTLS is valid in the current state.
|
||||||
|
/// Only before a transaction starts.
|
||||||
|
pub fn can_starttls(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Connected | SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether AUTH is valid in the current state.
|
||||||
|
/// Only after EHLO and before a transaction starts.
|
||||||
|
pub fn can_auth(&self) -> bool {
|
||||||
|
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Greeted state (after EHLO/HELO).
|
||||||
|
pub fn transition_ehlo(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
// EHLO is always valid — it resets the session.
|
||||||
|
Ok(SmtpState::Greeted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to MailFrom state (after MAIL FROM accepted).
|
||||||
|
pub fn transition_mail_from(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_mail_from() {
|
||||||
|
Ok(SmtpState::MailFrom)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "MAIL FROM",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to RcptTo state (after RCPT TO accepted).
|
||||||
|
pub fn transition_rcpt_to(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_rcpt_to() {
|
||||||
|
Ok(SmtpState::RcptTo)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "RCPT TO",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Data state (after DATA command accepted).
|
||||||
|
pub fn transition_data(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if self.can_data() {
|
||||||
|
Ok(SmtpState::Data)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "DATA",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Finished state (after end-of-data).
|
||||||
|
pub fn transition_finished(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
if *self == SmtpState::Data {
|
||||||
|
Ok(SmtpState::Finished)
|
||||||
|
} else {
|
||||||
|
Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "finish DATA",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset to Greeted state (after RSET command).
|
||||||
|
pub fn transition_rset(&self) -> Result<SmtpState, TransitionError> {
|
||||||
|
match self {
|
||||||
|
SmtpState::Connected => Err(TransitionError::InvalidTransition {
|
||||||
|
state: *self,
|
||||||
|
action: "RSET",
|
||||||
|
}),
|
||||||
|
_ => Ok(SmtpState::Greeted),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_state() {
|
||||||
|
let state = SmtpState::Connected;
|
||||||
|
assert!(!state.can_mail_from());
|
||||||
|
assert!(!state.can_rcpt_to());
|
||||||
|
assert!(!state.can_data());
|
||||||
|
assert!(state.can_starttls());
|
||||||
|
assert!(state.can_ehlo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_always_valid() {
|
||||||
|
for state in [
|
||||||
|
SmtpState::Connected,
|
||||||
|
SmtpState::Greeted,
|
||||||
|
SmtpState::MailFrom,
|
||||||
|
SmtpState::RcptTo,
|
||||||
|
SmtpState::Data,
|
||||||
|
SmtpState::Finished,
|
||||||
|
] {
|
||||||
|
assert!(state.can_ehlo());
|
||||||
|
assert!(state.transition_ehlo().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normal_flow() {
|
||||||
|
let state = SmtpState::Connected;
|
||||||
|
let state = state.transition_ehlo().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Greeted);
|
||||||
|
|
||||||
|
let state = state.transition_mail_from().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::MailFrom);
|
||||||
|
|
||||||
|
let state = state.transition_rcpt_to().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::RcptTo);
|
||||||
|
|
||||||
|
// Multiple RCPT TO
|
||||||
|
let state = state.transition_rcpt_to().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::RcptTo);
|
||||||
|
|
||||||
|
let state = state.transition_data().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Data);
|
||||||
|
|
||||||
|
let state = state.transition_finished().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Finished);
|
||||||
|
|
||||||
|
// New transaction
|
||||||
|
let state = state.transition_mail_from().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::MailFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_transitions() {
|
||||||
|
assert!(SmtpState::Connected.transition_mail_from().is_err());
|
||||||
|
assert!(SmtpState::Connected.transition_rcpt_to().is_err());
|
||||||
|
assert!(SmtpState::Connected.transition_data().is_err());
|
||||||
|
assert!(SmtpState::Greeted.transition_rcpt_to().is_err());
|
||||||
|
assert!(SmtpState::Greeted.transition_data().is_err());
|
||||||
|
assert!(SmtpState::MailFrom.transition_data().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rset() {
|
||||||
|
let state = SmtpState::RcptTo;
|
||||||
|
let state = state.transition_rset().unwrap();
|
||||||
|
assert_eq!(state, SmtpState::Greeted);
|
||||||
|
|
||||||
|
// RSET from Connected is invalid (no EHLO yet)
|
||||||
|
assert!(SmtpState::Connected.transition_rset().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_starttls_validity() {
|
||||||
|
assert!(SmtpState::Connected.can_starttls());
|
||||||
|
assert!(SmtpState::Greeted.can_starttls());
|
||||||
|
assert!(!SmtpState::MailFrom.can_starttls());
|
||||||
|
assert!(!SmtpState::RcptTo.can_starttls());
|
||||||
|
assert!(!SmtpState::Data.can_starttls());
|
||||||
|
assert!(SmtpState::Finished.can_starttls());
|
||||||
|
}
|
||||||
|
}
|
||||||
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
//! SMTP-level validation utilities.
|
||||||
|
//!
|
||||||
|
//! Address parsing, EHLO hostname validation, and header injection detection.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
/// Regex for basic email address format validation.
|
||||||
|
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal).
|
||||||
|
/// Currently unused in favor of a more permissive check, but available
|
||||||
|
/// for strict validation if needed.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
static EHLO_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
// Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words
|
||||||
|
Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Validate an email address for basic SMTP format.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the address has a valid-looking format.
|
||||||
|
/// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`.
|
||||||
|
pub fn is_valid_smtp_address(address: &str) -> bool {
|
||||||
|
// Empty address is valid for MAIL FROM (bounce)
|
||||||
|
if address.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EMAIL_RE.is_match(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an EHLO/HELO hostname.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hostname looks syntactically valid.
|
||||||
|
/// We are permissive because real-world SMTP clients send all kinds of values.
|
||||||
|
pub fn is_valid_ehlo_hostname(hostname: &str) -> bool {
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Be permissive — most SMTP servers accept anything non-empty.
|
||||||
|
// Only reject obviously malicious patterns.
|
||||||
|
if hostname.len() > 255 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if contains_header_injection(hostname) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Must not contain null bytes
|
||||||
|
if hostname.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for SMTP header injection attempts.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the input contains characters that could be used
|
||||||
|
/// for header injection (bare CR/LF).
|
||||||
|
pub fn contains_header_injection(input: &str) -> bool {
|
||||||
|
input.contains('\r') || input.contains('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the size parameter from MAIL FROM.
|
||||||
|
///
|
||||||
|
/// Returns the parsed size if valid and within the max, or an error message.
|
||||||
|
pub fn validate_size_param(value: &str, max_size: u64) -> Result<u64, String> {
|
||||||
|
let size: u64 = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("invalid SIZE value: {value}"))?;
|
||||||
|
if size > max_size {
|
||||||
|
return Err(format!(
|
||||||
|
"message size {size} exceeds maximum {max_size}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the domain part from an email address.
|
||||||
|
pub fn extract_domain(address: &str) -> Option<&str> {
|
||||||
|
if address.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
address.rsplit_once('@').map(|(_, domain)| domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize an email address by lowercasing the domain part.
|
||||||
|
pub fn normalize_address(address: &str) -> String {
|
||||||
|
if address.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
match address.rsplit_once('@') {
|
||||||
|
Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()),
|
||||||
|
None => address.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_email() {
|
||||||
|
assert!(is_valid_smtp_address("user@example.com"));
|
||||||
|
assert!(is_valid_smtp_address("user+tag@sub.example.com"));
|
||||||
|
assert!(is_valid_smtp_address("a@b.c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_address_valid() {
|
||||||
|
assert!(is_valid_smtp_address(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email() {
|
||||||
|
assert!(!is_valid_smtp_address("no-at-sign"));
|
||||||
|
assert!(!is_valid_smtp_address("@no-local.com"));
|
||||||
|
assert!(!is_valid_smtp_address("user@"));
|
||||||
|
assert!(!is_valid_smtp_address("user@nodot"));
|
||||||
|
assert!(!is_valid_smtp_address("has space@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_ehlo() {
|
||||||
|
assert!(is_valid_ehlo_hostname("mail.example.com"));
|
||||||
|
assert!(is_valid_ehlo_hostname("localhost"));
|
||||||
|
assert!(is_valid_ehlo_hostname("[127.0.0.1]"));
|
||||||
|
assert!(is_valid_ehlo_hostname("[IPv6:::1]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_ehlo() {
|
||||||
|
assert!(!is_valid_ehlo_hostname(""));
|
||||||
|
assert!(!is_valid_ehlo_hostname("host\r\nname"));
|
||||||
|
assert!(!is_valid_ehlo_hostname(&"a".repeat(256)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_header_injection() {
|
||||||
|
assert!(contains_header_injection("test\r\nBcc: evil@evil.com"));
|
||||||
|
assert!(contains_header_injection("test\ninjection"));
|
||||||
|
assert!(contains_header_injection("test\rinjection"));
|
||||||
|
assert!(!contains_header_injection("normal text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_param() {
|
||||||
|
assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345));
|
||||||
|
assert!(validate_size_param("99999999", 1_000).is_err());
|
||||||
|
assert!(validate_size_param("notanumber", 1_000).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_domain() {
|
||||||
|
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
|
||||||
|
assert_eq!(extract_domain(""), None);
|
||||||
|
assert_eq!(extract_domain("nodomain"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_address() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_address("User@EXAMPLE.COM"),
|
||||||
|
"User@example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_address(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '2.1.0',
|
version: '2.2.0',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||||
// Deliverability types (IPWarmupManager and SenderReputationMonitor are optional external modules)
|
// Deliverability types (IPWarmupManager and SenderReputationMonitor are optional external modules)
|
||||||
interface IIPWarmupConfig {
|
interface IIPWarmupConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -401,129 +402,81 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
||||||
|
|
||||||
// Prepare the certificate and key if available
|
// Prepare the certificate and key if available
|
||||||
let key: string | undefined;
|
let tlsCertPem: string | undefined;
|
||||||
let cert: string | undefined;
|
let tlsKeyPem: string | undefined;
|
||||||
|
|
||||||
if (hasTlsConfig) {
|
if (hasTlsConfig) {
|
||||||
try {
|
try {
|
||||||
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||||
cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||||
logger.log('info', 'TLS certificates loaded successfully');
|
logger.log('info', 'TLS certificates loaded successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a SMTP server for each port
|
// --- Start Rust SMTP server ---
|
||||||
for (const port of this.options.ports as number[]) {
|
// Register event handlers for email reception and auth
|
||||||
// Create a reference object to hold the MTA service during setup
|
this.rustBridge.onEmailReceived(async (data) => {
|
||||||
const mtaRef = {
|
try {
|
||||||
config: {
|
await this.handleRustEmailReceived(data);
|
||||||
smtp: {
|
} catch (err) {
|
||||||
hostname: this.options.hostname
|
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||||
},
|
// Send rejection back to Rust
|
||||||
security: {
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
checkIPReputation: false,
|
correlationId: data.correlationId,
|
||||||
verifyDkim: true,
|
accepted: false,
|
||||||
verifySpf: true,
|
smtpCode: 451,
|
||||||
verifyDmarc: true
|
smtpMessage: 'Internal processing error',
|
||||||
}
|
});
|
||||||
},
|
}
|
||||||
// Security verification delegated to the Rust bridge
|
});
|
||||||
dkimVerifier: {
|
|
||||||
verify: async (rawMessage: string) => {
|
|
||||||
try {
|
|
||||||
const results = await this.rustBridge.verifyDkim(rawMessage);
|
|
||||||
const first = results[0];
|
|
||||||
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
|
|
||||||
} catch (err) {
|
|
||||||
logger.log('warn', `Rust DKIM verification failed: ${(err as Error).message}`);
|
|
||||||
return { isValid: false, domain: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
spfVerifier: {
|
|
||||||
verifyAndApply: async (session: any) => {
|
|
||||||
if (!session?.remoteAddress || session.remoteAddress === '127.0.0.1') {
|
|
||||||
return true; // localhost — skip SPF
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await this.rustBridge.checkSpf({
|
|
||||||
ip: session.remoteAddress,
|
|
||||||
heloDomain: session.clientHostname || '',
|
|
||||||
hostname: this.options.hostname,
|
|
||||||
mailFrom: session.envelope?.mailFrom?.address || session.mailFrom || '',
|
|
||||||
});
|
|
||||||
return result.result === 'pass' || result.result === 'none' || result.result === 'neutral';
|
|
||||||
} catch (err) {
|
|
||||||
logger.log('warn', `Rust SPF check failed: ${(err as Error).message}`);
|
|
||||||
return true; // Accept on error to avoid blocking mail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dmarcVerifier: {
|
|
||||||
verify: async () => ({}),
|
|
||||||
applyPolicy: () => true
|
|
||||||
},
|
|
||||||
processIncomingEmail: async (email: Email) => {
|
|
||||||
// Process email using the new route-based system
|
|
||||||
await this.processEmailByMode(email, {
|
|
||||||
id: 'session-' + Math.random().toString(36).substring(2),
|
|
||||||
state: SmtpState.FINISHED,
|
|
||||||
mailFrom: email.from,
|
|
||||||
rcptTo: email.to,
|
|
||||||
emailData: email.toRFC822String(), // Use the proper method to get the full email content
|
|
||||||
useTLS: false,
|
|
||||||
connectionEnded: true,
|
|
||||||
remoteAddress: '127.0.0.1',
|
|
||||||
clientHostname: '',
|
|
||||||
secure: false,
|
|
||||||
authenticated: false,
|
|
||||||
envelope: {
|
|
||||||
mailFrom: { address: email.from, args: {} },
|
|
||||||
rcptTo: email.to.map(recipient => ({ address: recipient, args: {} }))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
this.rustBridge.onAuthRequest(async (data) => {
|
||||||
}
|
try {
|
||||||
};
|
await this.handleRustAuthRequest(data);
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`);
|
||||||
|
await this.rustBridge.sendAuthResult({
|
||||||
|
correlationId: data.correlationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Internal auth error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Create server options
|
// Determine which ports need STARTTLS and which need implicit TLS
|
||||||
const serverOptions = {
|
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
||||||
port,
|
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||||
hostname: this.options.hostname,
|
|
||||||
key,
|
|
||||||
cert
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and start the SMTP server
|
const started = await this.rustBridge.startSmtpServer({
|
||||||
const smtpServer = createSmtpServer(mtaRef as any, serverOptions);
|
hostname: this.options.hostname,
|
||||||
this.servers.push(smtpServer);
|
ports: smtpPorts,
|
||||||
|
securePort: securePort,
|
||||||
|
tlsCertPem,
|
||||||
|
tlsKeyPem,
|
||||||
|
maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024,
|
||||||
|
maxConnections: this.options.maxConnections || this.options.maxClients || 100,
|
||||||
|
maxRecipients: 100,
|
||||||
|
connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
|
||||||
|
dataTimeoutSecs: 60,
|
||||||
|
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
||||||
|
maxAuthFailures: 3,
|
||||||
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
||||||
|
processingTimeoutSecs: 30,
|
||||||
|
rateLimits: this.options.rateLimits ? {
|
||||||
|
maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
|
||||||
|
maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
|
||||||
|
maxAuthFailuresPerIp: this.options.rateLimits.global?.maxAuthFailuresPerIP || 5,
|
||||||
|
windowSecs: 60,
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Start the server
|
if (!started) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
throw new Error('Failed to start Rust SMTP server');
|
||||||
try {
|
|
||||||
// Leave this empty for now, smtpServer.start() is handled by the SMTPServer class internally
|
|
||||||
// The server is started when it's created
|
|
||||||
logger.log('info', `UnifiedEmailServer listening on port ${port}`);
|
|
||||||
|
|
||||||
// Event handlers are managed internally by the SmtpServer class
|
|
||||||
// No need to access the private server property
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as any).code === 'EADDRINUSE') {
|
|
||||||
logger.log('error', `Port ${port} is already in use`);
|
|
||||||
reject(new Error(`Port ${port} is already in use`));
|
|
||||||
} else {
|
|
||||||
logger.log('error', `Error starting server on port ${port}: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
||||||
logger.log('info', 'UnifiedEmailServer started successfully');
|
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||||
this.emit('started');
|
this.emit('started');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -587,6 +540,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', 'Stopping UnifiedEmailServer');
|
logger.log('info', 'Stopping UnifiedEmailServer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Stop the Rust SMTP server first
|
||||||
|
try {
|
||||||
|
await this.rustBridge.stopSmtpServer();
|
||||||
|
logger.log('info', 'Rust SMTP server stopped');
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the servers array - servers will be garbage collected
|
// Clear the servers array - servers will be garbage collected
|
||||||
this.servers = [];
|
this.servers = [];
|
||||||
|
|
||||||
@@ -624,9 +585,110 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rust SMTP server event handlers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an emailReceived event from the Rust SMTP server.
|
||||||
|
* Decodes the email data, processes it through the routing system,
|
||||||
|
* and sends back the result via the correlation-ID callback.
|
||||||
|
*/
|
||||||
|
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
||||||
|
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
||||||
|
|
||||||
|
logger.log('info', `Rust SMTP received email from=${mailFrom} to=${rcptTo.join(',')} remote=${remoteAddr}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode the email data
|
||||||
|
let rawMessageBuffer: Buffer;
|
||||||
|
if (data.data.type === 'inline' && data.data.base64) {
|
||||||
|
rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
|
||||||
|
} else if (data.data.type === 'file' && data.data.path) {
|
||||||
|
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
plugins.fs.unlinkSync(data.data.path);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid email data transport');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a session-like object for processEmailByMode
|
||||||
|
const session: IExtendedSmtpSession = {
|
||||||
|
id: data.sessionId || 'rust-' + Math.random().toString(36).substring(2),
|
||||||
|
state: SmtpState.FINISHED,
|
||||||
|
mailFrom: mailFrom,
|
||||||
|
rcptTo: rcptTo,
|
||||||
|
emailData: rawMessageBuffer.toString('utf8'),
|
||||||
|
useTLS: secure,
|
||||||
|
connectionEnded: false,
|
||||||
|
remoteAddress: remoteAddr,
|
||||||
|
clientHostname: clientHostname || '',
|
||||||
|
secure: secure,
|
||||||
|
authenticated: !!authenticatedUser,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { address: mailFrom, args: {} },
|
||||||
|
rcptTo: rcptTo.map(addr => ({ address: addr, args: {} })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authenticatedUser) {
|
||||||
|
session.user = { username: authenticatedUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the email through the routing system
|
||||||
|
await this.processEmailByMode(rawMessageBuffer, session);
|
||||||
|
|
||||||
|
// Send acceptance back to Rust
|
||||||
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
|
correlationId,
|
||||||
|
accepted: true,
|
||||||
|
smtpCode: 250,
|
||||||
|
smtpMessage: '2.0.0 Message accepted for delivery',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
|
||||||
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
|
correlationId,
|
||||||
|
accepted: false,
|
||||||
|
smtpCode: 550,
|
||||||
|
smtpMessage: `5.0.0 Processing failed: ${(err as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an authRequest event from the Rust SMTP server.
|
||||||
|
* Validates credentials and sends back the result.
|
||||||
|
*/
|
||||||
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
||||||
|
const { correlationId, username, password, remoteAddr } = data;
|
||||||
|
|
||||||
|
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
||||||
|
|
||||||
|
// Check against configured users
|
||||||
|
const users = this.options.auth?.users || [];
|
||||||
|
const matched = users.find(
|
||||||
|
u => u.username === username && u.password === password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
await this.rustBridge.sendAuthResult({
|
||||||
|
correlationId,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Auth failed for user=${username} from=${remoteAddr}`);
|
||||||
|
await this.rustBridge.sendAuthResult({
|
||||||
|
correlationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid credentials',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
||||||
|
|||||||
@@ -73,6 +73,60 @@ interface IVersionInfo {
|
|||||||
smtp: string;
|
smtp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SMTP Server types ---
|
||||||
|
|
||||||
|
interface ISmtpServerConfig {
|
||||||
|
hostname: string;
|
||||||
|
ports: number[];
|
||||||
|
securePort?: number;
|
||||||
|
tlsCertPem?: string;
|
||||||
|
tlsKeyPem?: string;
|
||||||
|
maxMessageSize?: number;
|
||||||
|
maxConnections?: number;
|
||||||
|
maxRecipients?: number;
|
||||||
|
connectionTimeoutSecs?: number;
|
||||||
|
dataTimeoutSecs?: number;
|
||||||
|
authEnabled?: boolean;
|
||||||
|
maxAuthFailures?: number;
|
||||||
|
socketTimeoutSecs?: number;
|
||||||
|
processingTimeoutSecs?: number;
|
||||||
|
rateLimits?: IRateLimitConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRateLimitConfig {
|
||||||
|
maxConnectionsPerIp?: number;
|
||||||
|
maxMessagesPerSender?: number;
|
||||||
|
maxAuthFailuresPerIp?: number;
|
||||||
|
windowSecs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IEmailData {
|
||||||
|
type: 'inline' | 'file';
|
||||||
|
base64?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IEmailReceivedEvent {
|
||||||
|
correlationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
mailFrom: string;
|
||||||
|
rcptTo: string[];
|
||||||
|
data: IEmailData;
|
||||||
|
remoteAddr: string;
|
||||||
|
clientHostname: string | null;
|
||||||
|
secure: boolean;
|
||||||
|
authenticatedUser: string | null;
|
||||||
|
securityResults: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAuthRequestEvent {
|
||||||
|
correlationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remoteAddr: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe command map for the mailer-bin IPC bridge.
|
* Type-safe command map for the mailer-bin IPC bridge.
|
||||||
*/
|
*/
|
||||||
@@ -128,6 +182,35 @@ type TMailerCommands = {
|
|||||||
};
|
};
|
||||||
result: IEmailSecurityResult;
|
result: IEmailSecurityResult;
|
||||||
};
|
};
|
||||||
|
startSmtpServer: {
|
||||||
|
params: ISmtpServerConfig;
|
||||||
|
result: { started: boolean };
|
||||||
|
};
|
||||||
|
stopSmtpServer: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
result: { stopped: boolean; wasRunning?: boolean };
|
||||||
|
};
|
||||||
|
emailProcessingResult: {
|
||||||
|
params: {
|
||||||
|
correlationId: string;
|
||||||
|
accepted: boolean;
|
||||||
|
smtpCode?: number;
|
||||||
|
smtpMessage?: string;
|
||||||
|
};
|
||||||
|
result: { resolved: boolean };
|
||||||
|
};
|
||||||
|
authResult: {
|
||||||
|
params: {
|
||||||
|
correlationId: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
result: { resolved: boolean };
|
||||||
|
};
|
||||||
|
configureRateLimits: {
|
||||||
|
params: IRateLimitConfig;
|
||||||
|
result: { configured: boolean };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -314,6 +397,85 @@ export class RustSecurityBridge {
|
|||||||
}): Promise<IEmailSecurityResult> {
|
}): Promise<IEmailSecurityResult> {
|
||||||
return this.bridge.sendCommand('verifyEmail', opts);
|
return this.bridge.sendCommand('verifyEmail', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SMTP Server lifecycle
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the Rust SMTP server.
|
||||||
|
* The server will listen on the configured ports and emit events for
|
||||||
|
* emailReceived and authRequest that must be handled by the caller.
|
||||||
|
*/
|
||||||
|
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
||||||
|
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
||||||
|
return result?.started === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the Rust SMTP server. */
|
||||||
|
public async stopSmtpServer(): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the result of email processing back to the Rust SMTP server.
|
||||||
|
* This resolves a pending correlation-ID callback, allowing the Rust
|
||||||
|
* server to send the SMTP response to the client.
|
||||||
|
*/
|
||||||
|
public async sendEmailProcessingResult(opts: {
|
||||||
|
correlationId: string;
|
||||||
|
accepted: boolean;
|
||||||
|
smtpCode?: number;
|
||||||
|
smtpMessage?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('emailProcessingResult', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the result of authentication validation back to the Rust SMTP server.
|
||||||
|
*/
|
||||||
|
public async sendAuthResult(opts: {
|
||||||
|
correlationId: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('authResult', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update rate limit configuration at runtime. */
|
||||||
|
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('configureRateLimits', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Event registration — delegates to the underlying bridge EventEmitter
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for emailReceived events from the Rust SMTP server.
|
||||||
|
* These events fire when a complete email has been received and needs processing.
|
||||||
|
*/
|
||||||
|
public onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||||
|
this.bridge.on('management:emailReceived', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for authRequest events from the Rust SMTP server.
|
||||||
|
* The handler must call sendAuthResult() with the correlationId.
|
||||||
|
*/
|
||||||
|
public onAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||||
|
this.bridge.on('management:authRequest', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an emailReceived event handler. */
|
||||||
|
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||||
|
this.bridge.off('management:emailReceived', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an authRequest event handler. */
|
||||||
|
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||||
|
this.bridge.off('management:authRequest', handler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export interfaces for consumers
|
// Re-export interfaces for consumers
|
||||||
@@ -327,4 +489,9 @@ export type {
|
|||||||
IContentScanResult,
|
IContentScanResult,
|
||||||
IReputationResult as IRustReputationResult,
|
IReputationResult as IRustReputationResult,
|
||||||
IVersionInfo,
|
IVersionInfo,
|
||||||
|
ISmtpServerConfig,
|
||||||
|
IRateLimitConfig,
|
||||||
|
IEmailData,
|
||||||
|
IEmailReceivedEvent,
|
||||||
|
IAuthRequestEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user