Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8fcb6efa | |||
| 2088c9f76e | |||
| 7853ef67b6 | |||
| f7af8c4534 |
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-11 - 4.1.1 - fix(readme)
|
||||
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
|
||||
|
||||
- Changed IPC description to JSON-over-stdin/stdout (clarifies communication format between Rust and TypeScript)
|
||||
- Added Rust SMTP client entry and documented outbound mail data flow (TypeScript -> Rust signing/delivery -> result back)
|
||||
- Expanded testing instructions with commands for building Rust binary and running unit/E2E tests
|
||||
- Updated architecture diagram labels and Rust crate/module descriptions (mailer-smtp now includes client; test counts noted)
|
||||
- Documentation-only changes; no source code behavior modified
|
||||
|
||||
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
|
||||
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
|
||||
|
||||
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
|
||||
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
|
||||
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
|
||||
- Tests will skip when the Rust bridge or server cannot start (binary build required)
|
||||
|
||||
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
|
||||
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.1",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
|
||||
322
readme.md
322
readme.md
@@ -18,14 +18,14 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
|
||||
## 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. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
|
||||
`@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 engine runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via JSON-over-stdin/stdout IPC.
|
||||
|
||||
### ⚡ What's Inside
|
||||
|
||||
| Module | What It Does |
|
||||
|---|---|
|
||||
| **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 |
|
||||
| **Rust SMTP Server** | High-performance SMTP engine in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
||||
| **Rust SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation, DKIM signing — all in Rust |
|
||||
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||
| **SPF** | Full SPF record validation via Rust |
|
||||
| **DMARC** | Policy enforcement and verification |
|
||||
@@ -37,8 +37,7 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
| **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 Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
||||
| **DNS Manager** | Automatic DNS record management (MX, SPF, DKIM, DMARC) |
|
||||
|
||||
### 🏗️ Architecture
|
||||
|
||||
@@ -52,9 +51,9 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ │ └────────────────┘ │
|
||||
│ │ └───────┘ │ │ │
|
||||
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
@@ -63,18 +62,25 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data flow for inbound mail:**
|
||||
|
||||
1. Rust SMTP server accepts the connection and handles the full SMTP protocol
|
||||
2. On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
|
||||
3. Rust emits an `emailReceived` event via IPC with pre-computed security results attached
|
||||
4. TypeScript processes the email (routing decisions using the pre-computed results, delivery)
|
||||
5. Rust sends the final SMTP response to the client
|
||||
1. 📨 Rust SMTP server accepts the connection and handles the full SMTP protocol
|
||||
2. 🔒 On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
|
||||
3. 📤 Rust emits an `emailReceived` event via IPC with pre-computed security results attached
|
||||
4. 🔀 TypeScript processes the email (routing decisions using the pre-computed results, delivery)
|
||||
5. ✅ Rust sends the final SMTP response to the client
|
||||
|
||||
**Data flow for outbound mail:**
|
||||
|
||||
1. 📝 TypeScript constructs the email and resolves DKIM keys for the sender domain
|
||||
2. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
|
||||
3. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -163,32 +169,19 @@ await emailServer.start();
|
||||
|
||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||
|
||||
### 📧 Sending Emails with the SMTP Client
|
||||
### 📧 Sending Outbound Emails
|
||||
|
||||
Create and send emails using the built-in SMTP client with connection pooling:
|
||||
All outbound email delivery goes through the Rust SMTP client, accessed via `UnifiedEmailServer.sendOutboundEmail()`. The Rust client handles connection pooling, TLS negotiation, and DKIM signing automatically:
|
||||
|
||||
```typescript
|
||||
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||
|
||||
// Create a client with connection pooling
|
||||
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false, // will upgrade via STARTTLS
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
auth: {
|
||||
user: 'sender@example.com',
|
||||
pass: 'your-password',
|
||||
},
|
||||
});
|
||||
import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
|
||||
// Build an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
cc: ['cc@example.com'],
|
||||
subject: 'Hello from smartmta!',
|
||||
subject: 'Hello from smartmta! 🚀',
|
||||
text: 'Plain text body',
|
||||
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||
priority: 'high',
|
||||
@@ -201,32 +194,32 @@ const email = new Email({
|
||||
],
|
||||
});
|
||||
|
||||
// Send it
|
||||
const result = await client.sendMail(email);
|
||||
console.log(`Message sent: ${result.messageId}`);
|
||||
// Send via the Rust SMTP client (connection pooling, TLS, DKIM signing)
|
||||
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
|
||||
auth: { user: 'sender@example.com', pass: 'your-password' },
|
||||
dkimDomain: 'example.com',
|
||||
dkimSelector: 'default',
|
||||
});
|
||||
|
||||
console.log(`Accepted: ${result.accepted.join(', ')}`);
|
||||
console.log(`Response: ${result.response}`);
|
||||
// -> Accepted: recipient@example.com
|
||||
// -> Response: 2.0.0 Ok: queued
|
||||
```
|
||||
|
||||
Additional client factories are available:
|
||||
The `sendOutboundEmail` method:
|
||||
- 🔑 Automatically resolves DKIM keys from the `DKIMCreator` for the specified domain
|
||||
- 🔗 Uses connection pooling in Rust — reuses TCP/TLS connections across sends
|
||||
- ⏱️ Configurable connection and socket timeouts via `outbound` options on the server
|
||||
|
||||
```typescript
|
||||
// Pooled client for high-throughput scenarios
|
||||
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||
### 🔑 DKIM Signing & Key Management
|
||||
|
||||
// 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:
|
||||
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by the Rust SMTP client during outbound delivery:
|
||||
|
||||
```typescript
|
||||
import { DKIMCreator } from '@push.rocks/smartmta';
|
||||
|
||||
const dkimCreator = new DKIMCreator('/path/to/keys');
|
||||
const dkimCreator = new DKIMCreator('/path/to/keys', storageManager);
|
||||
|
||||
// Auto-generate keys if they don't exist
|
||||
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
||||
@@ -244,30 +237,34 @@ if (needsRotation) {
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
When `UnifiedEmailServer.start()` is called:
|
||||
- DKIM keys are generated or loaded for every configured domain
|
||||
- Signing is applied to all outbound mail via the Rust security bridge
|
||||
- Key rotation is checked automatically based on your `rotationInterval` config
|
||||
|
||||
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
||||
|
||||
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
|
||||
All verification is powered by the Rust binary. For inbound mail, `UnifiedEmailServer` runs the full security pipeline **automatically** — DKIM, SPF, DMARC, content scanning, and IP reputation in a single Rust pass. Results are attached as headers (`Received-SPF`, `X-DKIM-Result`, `X-DMARC-Result`).
|
||||
|
||||
You can also use the individual verifiers directly:
|
||||
|
||||
```typescript
|
||||
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||
|
||||
// SPF verification — first arg is an Email object
|
||||
// SPF verification
|
||||
const spfVerifier = new SpfVerifier();
|
||||
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||
// domain: string, ip: string }
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none', domain, ip }
|
||||
|
||||
// DKIM verification — takes raw email content
|
||||
// DKIM verification
|
||||
const dkimVerifier = new DKIMVerifier();
|
||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||
// -> [{ is_valid: true, domain: 'example.com', selector: 'default', status: 'pass' }]
|
||||
|
||||
// DMARC verification — first arg is an Email object
|
||||
// DMARC verification
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', policy, spfDomainAligned, dkimDomainAligned }
|
||||
```
|
||||
|
||||
### 🔀 Email Routing
|
||||
@@ -294,7 +291,7 @@ const router = new EmailRouter([
|
||||
priority: 50,
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -326,7 +323,16 @@ const router = new EmailRouter([
|
||||
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
```
|
||||
|
||||
**Match criteria available:**
|
||||
#### Route Action Types
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `forward` | Forward the email to another SMTP server via the Rust SMTP client |
|
||||
| `deliver` | Queue for local MTA delivery |
|
||||
| `process` | Queue for processing (with optional content scanning and DKIM signing) |
|
||||
| `reject` | Reject with a configurable SMTP error code and message |
|
||||
|
||||
#### Match Criteria
|
||||
|
||||
| Criterion | Description |
|
||||
|---|---|
|
||||
@@ -339,52 +345,6 @@ const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
| `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';
|
||||
|
||||
const scanner = new ContentScanner({
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: true,
|
||||
blockExecutables: true,
|
||||
blockMacros: true,
|
||||
minThreatScore: 30,
|
||||
highThreatScore: 70,
|
||||
customRules: [
|
||||
{
|
||||
pattern: /bitcoin.*wallet/i,
|
||||
type: 'scam',
|
||||
score: 80,
|
||||
description: 'Cryptocurrency scam pattern',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||
```
|
||||
|
||||
### 🌐 IP Reputation Checking
|
||||
|
||||
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 = IPReputationChecker.getInstance({
|
||||
enableDNSBL: true,
|
||||
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
|
||||
const reputation = await ipChecker.checkReputation('192.168.1.1');
|
||||
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
||||
```
|
||||
|
||||
### ⏱️ Rate Limiting
|
||||
|
||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||
@@ -445,7 +405,7 @@ const bounce = await bounceManager.processSmtpFailure(
|
||||
// Check if an address is suppressed due to bounces
|
||||
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||
|
||||
// Manually manage the suppression list
|
||||
// Manage the suppression list
|
||||
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||
```
|
||||
@@ -484,7 +444,7 @@ const email = await templates.createEmail('welcome', {
|
||||
|
||||
### 🌍 DNS Management
|
||||
|
||||
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:
|
||||
When `UnifiedEmailServer.start()` is called, it automatically ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains:
|
||||
|
||||
```typescript
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
@@ -492,7 +452,7 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
},
|
||||
],
|
||||
// ... other config
|
||||
@@ -506,99 +466,43 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
await emailServer.start();
|
||||
```
|
||||
|
||||
### 🦀 RustSecurityBridge
|
||||
|
||||
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';
|
||||
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
await bridge.start();
|
||||
|
||||
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||
const securityResult = await bridge.verifyEmail({
|
||||
rawMessage: rawEmailString,
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||
|
||||
// Individual security operations
|
||||
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||
const spfResult = await bridge.checkSpf({
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
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();
|
||||
```
|
||||
|
||||
> ⚠️ **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:
|
||||
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 four 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 (77 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, in-process security pipeline, rate limiting |
|
||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
|
||||
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
|
||||
| `mailer-smtp` | ✅ Complete (106 tests) | Full SMTP protocol engine — TCP/TLS server + client, STARTTLS, AUTH, pipelining, connection pooling, in-process security pipeline |
|
||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — wires everything together |
|
||||
|
||||
### What Runs in Rust
|
||||
### What Runs Where
|
||||
|
||||
| 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 |
|
||||
| SMTP server (port listening, protocol, TLS) | 🦀 Rust | Performance, memory safety, zero-copy parsing |
|
||||
| SMTP client (outbound delivery, connection pooling) | 🦀 Rust | Connection management, TLS negotiation |
|
||||
| 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 |
|
||||
| Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
|
||||
| Delivery queue & retry | 🟦 TypeScript | State management, persistence |
|
||||
| Template rendering | 🟦 TypeScript | String interpolation |
|
||||
| Domain & DNS management | 🟦 TypeScript | API integrations |
|
||||
|
||||
## Project Structure
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
smartmta/
|
||||
├── ts/ # TypeScript source
|
||||
│ ├── mail/
|
||||
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
||||
│ │ │ └── smtpclient/ # SMTP client with connection pooling
|
||||
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
|
||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||
@@ -606,14 +510,56 @@ smartmta/
|
||||
│ └── 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
|
||||
│ ├── mailer-smtp/ # Full SMTP server + client (TCP/TLS, rate limiting, pooling)
|
||||
│ └── mailer-bin/ # CLI + smartrust IPC bridge
|
||||
├── test/ # Test suite (116 TypeScript + 154 Rust tests)
|
||||
├── dist_ts/ # Compiled TypeScript output
|
||||
└── dist_rust/ # Compiled Rust binaries
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The project has comprehensive test coverage with both unit and end-to-end tests:
|
||||
|
||||
```bash
|
||||
# Build Rust binary first
|
||||
pnpm build
|
||||
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run specific test files
|
||||
tstest test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.routing-actions.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
|
||||
```
|
||||
|
||||
**E2E tests** exercise the full pipeline — starting `UnifiedEmailServer`, connecting via raw TCP sockets, sending SMTP transactions, verifying routing actions, and testing outbound delivery through a mock SMTP receiver.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Exported Classes (top-level)
|
||||
|
||||
| Class | Description |
|
||||
|---|---|
|
||||
| `UnifiedEmailServer` | 🎯 Main entry point — orchestrates SMTP server, routing, security, and delivery |
|
||||
| `Email` | Email message class with validation, attachments, headers, and RFC 822 serialization |
|
||||
| `EmailRouter` | Pattern-based route matching and evaluation engine |
|
||||
| `DomainRegistry` | Multi-domain configuration manager |
|
||||
| `DnsManager` | Automatic DNS record management |
|
||||
| `DKIMCreator` | DKIM key generation, storage, rotation |
|
||||
| `DKIMVerifier` | DKIM signature verification (delegates to Rust) |
|
||||
| `SpfVerifier` | SPF record validation (delegates to Rust) |
|
||||
| `DmarcVerifier` | DMARC policy enforcement (delegates to Rust) |
|
||||
|
||||
### Namespaced Exports
|
||||
|
||||
| Namespace | Classes |
|
||||
|---|---|
|
||||
| `Core` | `Email`, `EmailValidator`, `TemplateManager`, `BounceManager` |
|
||||
| `Delivery` | `UnifiedDeliveryQueue`, `MultiModeDeliverySystem`, `DeliveryStatus`, `UnifiedRateLimiter` |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
239
test/test.e2e.inbound-smtp.node.ts
Normal file
239
test/test.e2e.inbound-smtp.node.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import {
|
||||
connectToSmtp,
|
||||
waitForGreeting,
|
||||
sendSmtpCommand,
|
||||
performSmtpHandshake,
|
||||
createConcurrentConnections,
|
||||
createMimeMessage,
|
||||
} from './helpers/utils.js';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridge: RustSecurityBridge;
|
||||
let bridgeAvailable = false;
|
||||
|
||||
tap.test('setup - start server on port 10125', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10125],
|
||||
hostname: 'test.inbound.local',
|
||||
domains: [
|
||||
{
|
||||
domain: 'testdomain.com',
|
||||
dnsMode: 'forward',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all',
|
||||
priority: 0,
|
||||
match: {
|
||||
recipients: '*@testdomain.com',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
bridgeAvailable = true;
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('EHLO and capability discovery', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
|
||||
|
||||
// Verify we received capabilities from the EHLO response
|
||||
expect(capabilities.length).toBeGreaterThan(0);
|
||||
|
||||
// The server hostname should be in the first capability line
|
||||
const firstLine = capabilities[0];
|
||||
expect(firstLine).toBeTruthy();
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('send valid email - full SMTP transaction', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
|
||||
// EHLO
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
|
||||
// MAIL FROM
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
|
||||
// RCPT TO
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
|
||||
|
||||
// DATA
|
||||
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||
|
||||
// Send MIME message
|
||||
const mimeMessage = createMimeMessage({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@testdomain.com',
|
||||
subject: 'E2E Test Email',
|
||||
text: 'This is an end-to-end test email.',
|
||||
});
|
||||
|
||||
// Send the message data followed by the terminator
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeAllListeners('data');
|
||||
reject(new Error('DATA response timeout'));
|
||||
}, 10000);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
if (buffer.includes('250')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.write(mimeMessage + '\r\n.\r\n');
|
||||
});
|
||||
|
||||
// QUIT
|
||||
try {
|
||||
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
|
||||
} catch {
|
||||
// Ignore QUIT errors
|
||||
}
|
||||
socket.destroy();
|
||||
|
||||
// Verify the email was queued for processing
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.queueSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('multiple recipients', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
|
||||
|
||||
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||
|
||||
const mimeMessage = createMimeMessage({
|
||||
from: 'sender@example.com',
|
||||
to: 'user1@testdomain.com',
|
||||
subject: 'Multi-recipient Test',
|
||||
text: 'Testing multiple recipients.',
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeAllListeners('data');
|
||||
reject(new Error('DATA response timeout'));
|
||||
}, 10000);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
if (buffer.includes('250')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.write(mimeMessage + '\r\n.\r\n');
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('concurrent connections', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
|
||||
expect(sockets.length).toEqual(3);
|
||||
|
||||
// Perform EHLO on each connection
|
||||
for (const socket of sockets) {
|
||||
await waitForGreeting(socket, 10000);
|
||||
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
|
||||
}
|
||||
|
||||
// Close all connections
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RSET mid-session', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
|
||||
// Start a transaction
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
|
||||
// Reset the transaction
|
||||
await sendSmtpCommand(socket, 'RSET', '250', 10000);
|
||||
|
||||
// Start a new transaction after RSET
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server', async () => {
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
196
test/test.e2e.outbound-delivery.node.ts
Normal file
196
test/test.e2e.outbound-delivery.node.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import * as net from 'net';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridge: RustSecurityBridge;
|
||||
let bridgeAvailable = false;
|
||||
let mockSmtpServer: net.Server;
|
||||
|
||||
/**
|
||||
* Create a minimal mock SMTP server that accepts any email.
|
||||
* This avoids the IPC deadlock that occurs when the Rust SMTP client
|
||||
* sends to the same Rust process's SMTP server (the IPC stdin reader
|
||||
* blocks on the sendEmail command and can't process emailProcessingResult).
|
||||
*/
|
||||
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer((socket) => {
|
||||
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||
|
||||
let inData = false;
|
||||
let dataBuffer = '';
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
const input = chunk.toString();
|
||||
|
||||
if (inData) {
|
||||
dataBuffer += input;
|
||||
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||
inData = false;
|
||||
dataBuffer = '';
|
||||
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process SMTP commands line by line
|
||||
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||
for (const line of lines) {
|
||||
const cmd = line.toUpperCase();
|
||||
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||
} else if (cmd.startsWith('MAIL FROM')) {
|
||||
socket.write('250 2.1.0 Ok\r\n');
|
||||
} else if (cmd.startsWith('RCPT TO')) {
|
||||
socket.write('250 2.1.5 Ok\r\n');
|
||||
} else if (cmd === 'DATA') {
|
||||
inData = true;
|
||||
dataBuffer = '';
|
||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||
} else if (cmd === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (cmd === 'RSET') {
|
||||
socket.write('250 2.0.0 Ok\r\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
srv.listen(port, '127.0.0.1', () => {
|
||||
resolve(srv);
|
||||
});
|
||||
|
||||
srv.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup - start bridge and mock SMTP server', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10325],
|
||||
hostname: 'test.outbound.local',
|
||||
domains: [
|
||||
{
|
||||
domain: 'outbound-test.com',
|
||||
dnsMode: 'forward',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all',
|
||||
priority: 0,
|
||||
match: {
|
||||
recipients: '*',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
bridgeAvailable = true;
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a mock SMTP server on a separate port for outbound delivery tests
|
||||
mockSmtpServer = await createMockSmtpServer(10326);
|
||||
});
|
||||
|
||||
tap.test('send email to mock SMTP receiver', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@outbound-test.com',
|
||||
to: 'recipient@outbound-test.com',
|
||||
subject: 'Outbound E2E Test',
|
||||
text: 'Testing outbound delivery to the mock SMTP server.',
|
||||
});
|
||||
|
||||
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted).toBeTruthy();
|
||||
expect(result.accepted.length).toBeGreaterThan(0);
|
||||
expect(result.response).toBeTruthy();
|
||||
// Rust SMTP client returns enhanced status code without the 250 prefix
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
});
|
||||
|
||||
tap.test('send email - connection refused', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@outbound-test.com',
|
||||
to: 'recipient@outbound-test.com',
|
||||
subject: 'Connection Refused Test',
|
||||
text: 'This should fail — no server at port 59888.',
|
||||
});
|
||||
|
||||
try {
|
||||
await server.sendOutboundEmail('127.0.0.1', 59888, email);
|
||||
throw new Error('Expected sendOutboundEmail to fail on connection refused');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SMTP pool status and cleanup', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
|
||||
expect(status).toBeTruthy();
|
||||
expect(status.pools).toBeTruthy();
|
||||
expect(typeof status.pools).toEqual('object');
|
||||
|
||||
// Close all pools
|
||||
await bridge.closeSmtpPool();
|
||||
|
||||
// Verify pools are empty
|
||||
const statusAfter = await bridge.getSmtpPoolStatus();
|
||||
const poolKeys = Object.keys(statusAfter.pools);
|
||||
expect(poolKeys.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server and mock', async () => {
|
||||
if (mockSmtpServer) {
|
||||
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
|
||||
}
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
239
test/test.e2e.routing-actions.node.ts
Normal file
239
test/test.e2e.routing-actions.node.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import { SmtpState } from '../ts/mail/delivery/interfaces.js';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridge: RustSecurityBridge;
|
||||
let bridgeAvailable = false;
|
||||
|
||||
/**
|
||||
* Build a minimal SMTP session object for processEmailByMode().
|
||||
*/
|
||||
function buildSession(email: Email): any {
|
||||
return {
|
||||
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: email.from,
|
||||
rcptTo: email.to,
|
||||
emailData: '',
|
||||
useTLS: false,
|
||||
connectionEnded: false,
|
||||
remoteAddress: '127.0.0.1',
|
||||
clientHostname: 'test-client.local',
|
||||
secure: false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: email.from, args: {} },
|
||||
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('setup - start server with routing rules on port 10225', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10225],
|
||||
hostname: 'test.routing.local',
|
||||
domains: [
|
||||
{ domain: 'process.com', dnsMode: 'forward' },
|
||||
{ domain: 'local.com', dnsMode: 'forward' },
|
||||
{ domain: 'external.com', dnsMode: 'forward' },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'reject-route',
|
||||
priority: 40,
|
||||
match: {
|
||||
senders: '*@spammer.com',
|
||||
},
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: {
|
||||
code: 550,
|
||||
message: 'Spam rejected',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'process-route',
|
||||
priority: 30,
|
||||
match: {
|
||||
recipients: '*@process.com',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
process: {
|
||||
scan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deliver-route',
|
||||
priority: 20,
|
||||
match: {
|
||||
recipients: '*@local.com',
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'forward-route',
|
||||
priority: 10,
|
||||
match: {
|
||||
recipients: '*@external.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: '127.0.0.1',
|
||||
port: 59999, // No server listening — expected failure
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
bridgeAvailable = true;
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('process action - queues email for processing', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@process.com',
|
||||
subject: 'Process test',
|
||||
text: 'This email should be queued for processing.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
const result = await server.processEmailByMode(email, session);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.modes.process).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('deliver action - queues email for MTA delivery', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@local.com',
|
||||
subject: 'Deliver test',
|
||||
text: 'This email should be queued for local delivery.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
const result = await server.processEmailByMode(email, session);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.modes.mta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('reject action - throws with correct code', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'bad@spammer.com',
|
||||
to: 'user@process.com',
|
||||
subject: 'Spam attempt',
|
||||
text: 'This should be rejected.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for rejected email');
|
||||
} catch (err: any) {
|
||||
expect(err.responseCode).toEqual(550);
|
||||
expect(err.message).toInclude('Spam rejected');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('forward action - fails to unreachable host', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@external.com',
|
||||
subject: 'Forward test',
|
||||
text: 'This forward should fail — no server at port 59999.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
|
||||
} catch (err: any) {
|
||||
// We expect an error from the failed SMTP connection
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('no matching route - throws error', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'nobody@unmatched.com',
|
||||
subject: 'Unmatched route test',
|
||||
text: 'No route matches this recipient.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for no matching route');
|
||||
} catch (err: any) {
|
||||
expect(err.message).toInclude('No matching route');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server', async () => {
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
118
test/test.e2e.server-lifecycle.node.ts
Normal file
118
test/test.e2e.server-lifecycle.node.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import { connectToSmtp, waitForGreeting } from './helpers/utils.js';
|
||||
import * as net from 'net';
|
||||
|
||||
// Common mock pattern for dcRouter dependency
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridge: RustSecurityBridge;
|
||||
|
||||
tap.test('setup - reset bridge singleton', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
});
|
||||
|
||||
tap.test('construct server - should create UnifiedEmailServer', async () => {
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10025, 10587],
|
||||
hostname: 'test.e2e.local',
|
||||
domains: [
|
||||
{
|
||||
domain: 'e2e-test.com',
|
||||
dnsMode: 'forward',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all',
|
||||
priority: 0,
|
||||
match: {
|
||||
recipients: '*@e2e-test.com',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(server).toBeTruthy();
|
||||
expect(server).toBeInstanceOf(UnifiedEmailServer);
|
||||
});
|
||||
|
||||
tap.test('start server - should start and accept SMTP connections', async () => {
|
||||
try {
|
||||
await server.start();
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(bridge.running).toBeTrue();
|
||||
|
||||
// Connect to port 10025 and verify we get a 220 greeting
|
||||
const socket = await connectToSmtp('127.0.0.1', 10025, 10000);
|
||||
const greeting = await waitForGreeting(socket, 10000);
|
||||
expect(greeting).toInclude('220');
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('get stats - should return server statistics', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = server.getStats();
|
||||
expect(stats).toBeTruthy();
|
||||
expect(stats.startTime).toBeInstanceOf(Date);
|
||||
expect(stats.connections).toBeTruthy();
|
||||
expect(typeof stats.connections.current).toEqual('number');
|
||||
expect(typeof stats.connections.total).toEqual('number');
|
||||
expect(stats.messages).toBeTruthy();
|
||||
expect(typeof stats.messages.processed).toEqual('number');
|
||||
});
|
||||
|
||||
tap.test('stop server - should stop and refuse connections', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
await server.stop();
|
||||
|
||||
// Verify connection is refused after stop
|
||||
try {
|
||||
const socket = await connectToSmtp('127.0.0.1', 10025, 3000);
|
||||
socket.destroy();
|
||||
// If we get here, the connection was accepted — that's unexpected
|
||||
throw new Error('Expected connection to be refused after server stop');
|
||||
} catch (err) {
|
||||
// Connection refused or timeout is expected
|
||||
const msg = (err as Error).message;
|
||||
expect(
|
||||
msg.includes('ECONNREFUSED') || msg.includes('timeout') || msg.includes('refused')
|
||||
).toBeTrue();
|
||||
}
|
||||
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
// Clean up if not already stopped
|
||||
if (bridge.running) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '4.0.0',
|
||||
version: '4.1.1',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user