4 Commits

Author SHA1 Message Date
595634fb0f v2.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:04:56 +00:00
cee8a51081 fix(readme): Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates 2026-02-10 22:04:56 +00:00
f1c5546186 v2.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:00:44 +00:00
5220ee0857 feat(mailer-smtp): implement in-process SMTP server and management IPC integration 2026-02-10 22:00:44 +00:00
26 changed files with 4596 additions and 400 deletions

View File

@@ -1,5 +1,26 @@
# Changelog
## 2026-02-10 - 2.2.1 - fix(readme)
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
- Emphasizes that the SMTP server is Rust-powered (high-performance) and not a nodemailer-based TS server.
- Documents that the Rust binary (mailer-bin) is required — if unavailable UnifiedEmailServer.start() will throw an error.
- Adds installation/build note: run `pnpm build` to compile the Rust binary.
- Adds a new Rust Acceleration Layer section listing workspace crates and responsibilities (mailer-core, mailer-security, mailer-smtp, mailer-bin, mailer-napi).
- Updates project structure: marks legacy TS SMTP server as fallback/legacy, adds dist_rust output, and clarifies which operations run in Rust vs TypeScript.
## 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)
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

View File

@@ -3,7 +3,7 @@
*/
export const commitinfo = {
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.'
};
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=

View File

@@ -164,6 +164,17 @@ export declare class UnifiedEmailServer extends EventEmitter {
* Stop the unified email server
*/
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.
* Falls back gracefully if the bridge is not running.

File diff suppressed because one or more lines are too long

View File

@@ -60,6 +60,53 @@ interface IVersionInfo {
security: 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.
*
@@ -135,5 +182,48 @@ export declare class RustSecurityBridge {
hostname?: string;
mailFrom: string;
}): 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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartmta",
"version": "2.1.0",
"version": "2.2.1",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [
"mta",

299
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartmta
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration — no nodemailer, no shortcuts.
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
## Issue Reporting and Security
@@ -14,68 +14,83 @@ pnpm install @push.rocks/smartmta
npm install @push.rocks/smartmta
```
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required**`smartmta` will not start without it.
## Overview
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. No wrappers around nodemailer. No half-measures.
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
### What's Inside
### What's Inside
| Module | What It Does |
|---|---|
| **SMTP Server** | RFC 5321-compliant server with TLS/STARTTLS, authentication, pipelining |
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
| **DKIM** | Key generation, signing, and verification — per domain |
| **SPF** | Full SPF record validation |
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification |
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
| **Bounce Manager** | Automatic bounce detection, classification (hard/soft), and tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring |
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
| **Delivery Queue** | Persistent queue with exponential backoff retry |
| **Template Engine** | Email templates with variable substitution |
| **Domain Registry** | Multi-domain management with per-domain configuration |
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
| **Rust Accelerator** | Performance-critical operations (DKIM, MIME, validation) in Rust via IPC |
| **Rust Security Bridge** | Compound email security verification (DKIM+SPF+DMARC) via Rust binary |
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
### Architecture
### 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────┐
┌──────────────────────────────────────────────────────────────
│ UnifiedEmailServer │
│ (orchestrates all components, emits events) │
├────────────────────┬──────────────────────────────────┤
SMTP │ Email │ Security Delivery │
Server │ Router │ Stack System │
│ ┌─────┐ │ ┌─────┐ │ ┌───────┐ │ ┌────────────────┐ │
│ │ TLS │ │ │Match│ │ │ DKIM │ │ Queue │ │
│ │ Auth│ │ │Route│ │ │ SPF │ │ Rate Limit │ │
│ │ Cmd │ │ │ Act │ │ │ DMARC │ │ SMTP Client │ │
│ Data│ │ │ │ IPRep │ │ Retry Logic │ │
└─────┘ │ └─────┘ │ │ Scan │ │ └────────────────┘ │
│ └───────┘ │ │
├────────────────────┴──────────────────────────────────┤
│ Rust Security Bridge
│ (RustSecurityBridge singleton via smartrust IPC) │
├─────────────────────────────────────────────────────────┤
├───────────┬───────────┬──────────────┬───────────────────────┤
│ Email │ Security Delivery │ Configuration
│ Router │ Stack System
│ ┌─────┐ │ ┌───────┐ │ ┌─────────┐ │ ┌────────────────┐
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry
│ │Route │ │ │ SPF │ │ Rate Lim │ │ │ DnsManager
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator
└──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates
│ │ Scan │ │ └──────────┘ │ └────────────────┘
│ │ └───────┘
├───────────┴───────────┴──────────────┴───────────────────────┤
│ Rust Security Bridge (smartrust IPC)
├──────────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer │
(mailer-core, mailer-security, mailer-bin)
└─────────────────────────────────────────────────────────┘
┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
**Data flow for inbound mail:**
1. Rust SMTP server accepts the connection and handles the SMTP protocol
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
3. TypeScript processes the email (routing, scanning, delivery decisions)
4. TypeScript sends the processing result back to Rust via IPC
5. Rust sends the final SMTP response to the client
## Usage
### Setting Up the Email Server
### 🚀 Setting Up the Email Server
The central entry point is `UnifiedEmailServer`, which orchestrates SMTP, routing, security, and delivery:
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
```typescript
import { UnifiedEmailServer } from '@push.rocks/smartmta';
const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
ports: [25, 587, 465],
hostname: 'mail.example.com',
// Multi-domain configuration
domains: [
{
domain: 'example.com',
@@ -87,11 +102,13 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
rotationInterval: 90,
},
rateLimits: {
maxMessagesPerMinute: 100,
maxRecipientsPerMessage: 50,
outbound: { messagesPerMinute: 100 },
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
},
},
],
// Routing rules (evaluated by priority, highest first)
routes: [
{
name: 'catch-all-forward',
@@ -122,31 +139,39 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
},
},
],
// Authentication settings for the SMTP server
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
},
// TLS certificates
tls: {
certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key',
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
});
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
await emailServer.start();
```
### Sending Emails with the SMTP Client
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
### 📧 Sending Emails with the SMTP Client
Create and send emails using the built-in SMTP client with connection pooling:
```typescript
import { Email, createSmtpClient } from '@push.rocks/smartmta';
import { Email, Delivery } from '@push.rocks/smartmta';
// Create a client with connection pooling
const client = createSmtpClient({
const client = Delivery.smtpClientMod.createSmtpClient({
host: 'smtp.example.com',
port: 587,
secure: false, // will upgrade via STARTTLS
@@ -181,9 +206,22 @@ const result = await client.sendMail(email);
console.log(`Message sent: ${result.messageId}`);
```
### DKIM Signing
Additional client factories are available:
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery — there is no standalone `signEmail()` call:
```typescript
// Pooled client for high-throughput scenarios
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
// Optimized for bulk sending
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
// Optimized for transactional emails
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
```
### 🔑 DKIM Signing
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
```typescript
import { DKIMCreator } from '@push.rocks/smartmta';
@@ -206,11 +244,11 @@ if (needsRotation) {
}
```
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the keys managed by `DKIMCreator`. The `RustSecurityBridge` can also perform DKIM signing via its `signDkim()` method for high-performance scenarios.
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the Rust security bridge's `signDkim()` method for maximum performance.
### Email Authentication (SPF, DKIM, DMARC)
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
Verify incoming emails against all three authentication standards. Note that the first argument to `SpfVerifier.verify()` and `DmarcVerifier.verify()` is an `Email` object:
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
```typescript
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
@@ -221,7 +259,7 @@ const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
// domain: string, ip: string }
// DKIM verification
// DKIM verification — takes raw email content
const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent);
@@ -232,9 +270,9 @@ const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
```
### Email Routing
### 🔀 Email Routing
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first) using `evaluateRoutes()`:
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
```typescript
import { EmailRouter } from '@push.rocks/smartmta';
@@ -288,9 +326,22 @@ const router = new EmailRouter([
const matchedRoute = await router.evaluateRoutes(emailContext);
```
### Content Scanning
**Match criteria available:**
Built-in content scanner for detecting spam, phishing, malware, and other threats. Use the `scanEmail()` method:
| Criterion | Description |
|---|---|
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
| `senders` | Glob patterns for sender addresses |
| `clientIp` | IP addresses or CIDR ranges |
| `authenticated` | Require authentication status |
| `headers` | Match specific headers (string or RegExp) |
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
| `subject` | Subject line pattern (string or RegExp) |
| `hasAttachments` | Filter by attachment presence |
### 🔍 Content Scanning
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
```typescript
import { ContentScanner } from '@push.rocks/smartmta';
@@ -317,14 +368,14 @@ const result = await scanner.scanEmail(email);
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
```
### IP Reputation Checking
### 🌐 IP Reputation Checking
Check sender IP addresses against DNSBL blacklists and classify IP types:
Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
```typescript
import { IPReputationChecker } from '@push.rocks/smartmta';
const ipChecker = new IPReputationChecker({
const ipChecker = IPReputationChecker.getInstance({
enableDNSBL: true,
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
@@ -334,14 +385,13 @@ const reputation = await ipChecker.checkReputation('192.168.1.1');
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
```
When the `RustSecurityBridge` is running, `IPReputationChecker` automatically delegates DNSBL lookups to the Rust binary for improved performance.
### ⏱️ Rate Limiting
### Rate Limiting
Hierarchical rate limiting to protect your server and maintain deliverability. Configuration uses `maxMessagesPerMinute` and organizes domain-level limits under the `domains` key:
Hierarchical rate limiting to protect your server and maintain deliverability:
```typescript
import { UnifiedRateLimiter } from '@push.rocks/smartmta';
import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({
global: {
@@ -374,12 +424,13 @@ if (!allowed.allowed) {
}
```
### Bounce Management
### 📬 Bounce Management
Automatic bounce detection, classification, and suppression tracking. Use `isEmailSuppressed()` to check if an address should be suppressed:
Automatic bounce detection (via Rust), classification, and suppression tracking:
```typescript
import { BounceManager } from '@push.rocks/smartmta';
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
const bounceManager = new BounceManager();
@@ -399,16 +450,17 @@ bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com');
```
### Email Templates
### 📝 Email Templates
Template engine with variable substitution for transactional and notification emails. Use `createEmail()` to produce a ready-to-send `Email` from a registered template:
Template engine with variable substitution for transactional and notification emails:
```typescript
import { TemplateManager } from '@push.rocks/smartmta';
import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>2026 Example Corp</p>',
footerHtml: '<p>&copy; 2026 Example Corp</p>',
});
// Register a template
@@ -430,19 +482,11 @@ const email = await templates.createEmail('welcome', {
});
```
### DNS Management
### 🌍 DNS Management
DNS record management for email authentication is handled internally by `UnifiedEmailServer`. The `DnsManager` is not instantiated directly — it receives its configuration from the `dcRouter` reference and automatically ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains:
DNS record management for email authentication is handled automatically by `UnifiedEmailServer`. When the server starts, it ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains via the Cloudflare API:
```typescript
// DNS management is automatic when using UnifiedEmailServer.
// When the server starts, it calls ensureDnsRecords() internally
// for all configured domains, setting up:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com',
domains: [
@@ -454,47 +498,17 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
// ... other config
});
// DNS records are set up automatically on start
// DNS records are set up automatically on start:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
await emailServer.start();
```
For DNS lookups and record verification outside of the server lifecycle, the `DNSManager` class (note the capital N) can be used directly:
### 🦀 RustSecurityBridge
```typescript
import { DNSManager, DKIMCreator } from '@push.rocks/smartmta';
const dkimCreator = new DKIMCreator('/path/to/keys');
const dnsManager = new DNSManager(dkimCreator);
// Verify all email authentication records for a domain
const results = await dnsManager.verifyEmailAuthRecords('example.com', 'default');
console.log(results.spf); // { valid: boolean, record: string, ... }
console.log(results.dkim); // { valid: boolean, record: string, ... }
console.log(results.dmarc); // { valid: boolean, record: string, ... }
// Generate recommended DNS records
const records = await dnsManager.generateAllRecommendedRecords('example.com');
```
## Rust Acceleration
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC).
### Rust Crates
The Rust workspace is at `rust/` with five crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | Complete (12 tests) | DKIM signing/verification, SPF checks, DMARC policy, IP reputation/DNSBL |
| `mailer-bin` | Complete | CLI + smartrust IPC bridge (handles `verifyEmail` compound method) |
| `mailer-smtp` | Planned (Phase 3) | SMTP protocol in Rust |
| `mailer-napi` | Planned (Phase 3) | Native Node.js addon |
### RustSecurityBridge
The `RustSecurityBridge` is a singleton that manages the Rust binary process and provides high-performance security verification. It is automatically started and stopped with `UnifiedEmailServer`:
The `RustSecurityBridge` is the singleton that manages the Rust binary process. It handles security verification, content scanning, bounce detection, and the SMTP server lifecycle — all via `@push.rocks/smartrust` IPC:
```typescript
import { RustSecurityBridge } from '@push.rocks/smartmta';
@@ -511,7 +525,7 @@ const securityResult = await bridge.verifyEmail({
});
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
// Individual operations
// Individual security operations
const dkimResults = await bridge.verifyDkim(rawEmailString);
const spfResult = await bridge.checkSpf({
ip: '203.0.113.10',
@@ -520,10 +534,61 @@ const spfResult = await bridge.checkSpf({
});
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
// DKIM signing
const signed = await bridge.signDkim({
email: rawEmailString,
domain: 'example.com',
selector: 'default',
privateKeyPem: privateKey,
});
// Content scanning
const scanResult = await bridge.scanContent({
subject: 'Win a free iPhone!!!',
body: '<a href="http://phishing.example.com">Click here</a>',
from: 'scammer@evil.com',
});
// Bounce detection
const bounceResult = await bridge.detectBounce({
subject: 'Delivery Status Notification (Failure)',
body: '550 5.1.1 User unknown',
from: 'mailer-daemon@example.com',
});
await bridge.stop();
```
When the bridge is running, the TypeScript security components (`SpfVerifier`, `DKIMVerifier`, `IPReputationChecker`) automatically delegate to the Rust binary. If the binary is unavailable, the system falls back gracefully to TypeScript-only verification.
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
## 🦀 Rust Acceleration Layer
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
### What Runs in Rust
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
| SPF validation | Rust | DNS lookups with async resolver |
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | Rust | Parallel DNS queries |
| Content scanning (text patterns) | Rust | Regex engine performance |
| Bounce detection (pattern matching) | Rust | Regex engine performance |
| Email validation & MIME building | Rust | Parsing performance |
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
| Email routing & orchestration | TypeScript | Business logic, flexibility |
| Delivery queue & retry | TypeScript | State management, persistence |
| Template rendering | TypeScript | String interpolation |
## Project Structure
@@ -534,14 +599,20 @@ smartmta/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
│ │ │ └── smtpserver/ # SMTP server with TLS, auth, pipelining
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace
│ └── crates/ # mailer-core, mailer-security, mailer-bin, mailer-smtp, mailer-napi
├── test/ # Comprehensive test suite
└── dist_ts/ # Compiled output
│ └── crates/
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
│ └── mailer-napi/ # N-API addon (planned)
├── test/ # Test suite
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
```
## License and Legal Information

18
rust/Cargo.lock generated
View File

@@ -1005,6 +1005,7 @@ name = "mailer-bin"
version = "0.1.0"
dependencies = [
"clap",
"dashmap",
"hickory-resolver 0.25.2",
"mailer-core",
"mailer-security",
@@ -1068,15 +1069,23 @@ dependencies = [
name = "mailer-smtp"
version = "0.1.0"
dependencies = [
"base64",
"bytes",
"dashmap",
"hickory-resolver 0.25.2",
"mailer-core",
"mailer-security",
"regex",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
]
[[package]]
@@ -1491,6 +1500,15 @@ dependencies = [
"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]]
name = "rustls-pki-types"
version = "1.14.0"

View File

@@ -18,3 +18,4 @@ serde.workspace = true
serde_json.workspace = true
clap.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true

View File

@@ -6,9 +6,16 @@
//! integration with `@push.rocks/smartrust` from TypeScript
use clap::{Parser, Subcommand};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, Write};
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
#[derive(Parser)]
@@ -105,6 +112,43 @@ struct IpcEvent {
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() {
let cli = Cli::parse();
@@ -278,7 +322,17 @@ fn main() {
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.
///
/// This mode supports both request/response IPC (existing commands) and
/// long-running SMTP server with event-based callbacks.
fn run_management_mode() {
// Signal readiness
let ready_event = IpcEvent {
@@ -294,17 +348,40 @@ fn run_management_mode() {
let rt = tokio::runtime::Runtime::new().unwrap();
let stdin = io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
let callbacks = Arc::new(PendingCallbacks::new());
let mut state = ManagementState {
callbacks: callbacks.clone(),
smtp_handle: None,
smtp_event_rx: None,
};
if line.trim().is_empty() {
continue;
}
// We need to read stdin in a separate thread (blocking I/O)
// and process commands + SMTP events in the tokio runtime.
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<String>(256);
// Spawn stdin reader thread
std::thread::spawn(move || {
let stdin = io::stdin();
for line in stdin.lock().lines() {
match line {
Ok(l) if !l.trim().is_empty() => {
if cmd_tx.blocking_send(l).is_err() {
break;
}
}
Ok(_) => continue,
Err(_) => break,
}
}
});
rt.block_on(async {
loop {
// 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) => {
@@ -314,19 +391,110 @@ fn run_management_mode() {
result: None,
error: Some(format!("Invalid request: {}", e)),
};
println!("{}", serde_json::to_string(&resp).unwrap());
io::stdout().flush().unwrap();
emit_line(&serde_json::to_string(&resp).unwrap());
continue;
}
};
let response = rt.block_on(handle_ipc_request(&req));
println!("{}", serde_json::to_string(&response).unwrap());
io::stdout().flush().unwrap();
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() {
"ping" => IpcResponse {
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 {
id: req.id.clone(),
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)
}

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tokio-rustls.workspace = true
hickory-resolver.workspace = true
@@ -14,3 +15,10 @@ thiserror.workspace = true
tracing.workspace = true
bytes.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"

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

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,18 +1,39 @@
//! 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;
/// 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 {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

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

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

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

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

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

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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartmta',
version: '2.1.0',
version: '2.2.1',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
}

View File

@@ -10,6 +10,7 @@ import {
import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.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)
interface IIPWarmupConfig {
enabled?: boolean;
@@ -401,129 +402,81 @@ export class UnifiedEmailServer extends EventEmitter {
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
// Prepare the certificate and key if available
let key: string | undefined;
let cert: string | undefined;
let tlsCertPem: string | undefined;
let tlsKeyPem: string | undefined;
if (hasTlsConfig) {
try {
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
logger.log('info', 'TLS certificates loaded successfully');
} catch (error) {
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
}
}
// Create a SMTP server for each port
for (const port of this.options.ports as number[]) {
// Create a reference object to hold the MTA service during setup
const mtaRef = {
config: {
smtp: {
hostname: this.options.hostname
},
security: {
checkIPReputation: false,
verifyDkim: true,
verifySpf: true,
verifyDmarc: true
}
},
// Security verification delegated to the Rust bridge
dkimVerifier: {
verify: async (rawMessage: string) => {
// --- Start Rust SMTP server ---
// Register event handlers for email reception and auth
this.rustBridge.onEmailReceived(async (data) => {
try {
const results = await this.rustBridge.verifyDkim(rawMessage);
const first = results[0];
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
await this.handleRustEmailReceived(data);
} 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
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
// Send rejection back to Rust
await this.rustBridge.sendEmailProcessingResult({
correlationId: data.correlationId,
accepted: false,
smtpCode: 451,
smtpMessage: 'Internal processing error',
});
}
});
this.rustBridge.onAuthRequest(async (data) => {
try {
const result = await this.rustBridge.checkSpf({
ip: session.remoteAddress,
heloDomain: session.clientHostname || '',
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',
});
}
});
// Determine which ports need STARTTLS and which need implicit TLS
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
const securePort = (this.options.ports as number[]).find(p => p === 465);
const started = await this.rustBridge.startSmtpServer({
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: {} }))
}
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,
});
return true;
}
};
// Create server options
const serverOptions = {
port,
hostname: this.options.hostname,
key,
cert
};
// Create and start the SMTP server
const smtpServer = createSmtpServer(mtaRef as any, serverOptions);
this.servers.push(smtpServer);
// Start the server
await new Promise<void>((resolve, reject) => {
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);
}
}
});
if (!started) {
throw new Error('Failed to start Rust SMTP server');
}
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
logger.log('info', 'UnifiedEmailServer started successfully');
this.emit('started');
} catch (error) {
@@ -587,6 +540,14 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', 'Stopping UnifiedEmailServer');
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
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.

View File

@@ -73,6 +73,60 @@ interface IVersionInfo {
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.
*/
@@ -128,6 +182,35 @@ type TMailerCommands = {
};
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> {
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
@@ -327,4 +489,9 @@ export type {
IContentScanResult,
IReputationResult as IRustReputationResult,
IVersionInfo,
ISmtpServerConfig,
IRateLimitConfig,
IEmailData,
IEmailReceivedEvent,
IAuthRequestEvent,
};