Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d898b648 | |||
| b422639c34 | |||
| c45ba2a7b4 | |||
| b10597fd5e | |||
| 7908cbaefa | |||
| 526dcb4dac | |||
| cf8fcb6efa | |||
| 2088c9f76e | |||
| 7853ef67b6 | |||
| f7af8c4534 | |||
| a7ea1d86cb | |||
| 27bab5f345 | |||
| fc4877e06b | |||
| 36006191fc | |||
| d43fc15d8e | |||
| 248bfcfe78 |
77
changelog.md
77
changelog.md
@@ -1,5 +1,82 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-11 - 5.1.1 - fix(release)
|
||||
no changes
|
||||
|
||||
- No files changed in this commit.
|
||||
- Current package version remains 5.1.0 (from package.json).
|
||||
|
||||
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
|
||||
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
|
||||
|
||||
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
|
||||
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
|
||||
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
|
||||
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
|
||||
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
|
||||
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
|
||||
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
|
||||
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
|
||||
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
|
||||
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
|
||||
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
|
||||
|
||||
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
|
||||
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
|
||||
|
||||
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
|
||||
- Removed ts/errors/index.ts — MTA-specific error classes removed
|
||||
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
|
||||
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
|
||||
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
|
||||
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
|
||||
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
|
||||
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
|
||||
|
||||
## 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
|
||||
|
||||
- Introduce a Rust SMTP client crate with connection handling, TLS, protocol engine, and connection pooling (new modules: connection, pool, protocol, error, config).
|
||||
- Add IPC handlers and management commands in the Rust binary: sendEmail, sendRawEmail, verifySmtpConnection, closeSmtpPool, getSmtpPoolStatus and integrate a SmtpClientManager into the runtime.
|
||||
- Update TypeScript bridge (RustSecurityBridge) with new types and methods (ISmtpSendOptions, ISmtpSendResult, verifySmtpConnection, sendOutboundEmail, sendRawEmail, getSmtpPoolStatus, closeSmtpPool) and rework UnifiedEmailServer to use the Rust bridge for outbound delivery and DKIM signing.
|
||||
- Remove the previous TypeScript SMTP client implementation and associated tests/utilities (many ts/mail/delivery/smtpclient modules and tests deleted) in favor of the Rust implementation.
|
||||
- Bump dependencies and cargo config: @push.rocks/smartrust to ^1.2.0 in package.json and add/require crates (uuid, base64, webpki-roots) in Rust Cargo files.
|
||||
|
||||
## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security)
|
||||
implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies
|
||||
|
||||
- RustSecurityBridge now extends EventEmitter and includes a BridgeState state machine, IBridgeResilienceConfig with DEFAULT_RESILIENCE_CONFIG, auto-restart with exponential backoff, periodic health checks, restart/restore logic, and descriptive ensureRunning() guards on command methods.
|
||||
- Added static methods: resetInstance() (test-friendly) and configure(...) to tweak resilience settings at runtime.
|
||||
- Added stateChange events and logging for lifecycle transitions; new tests added for resilience: test/test.rustsecuritybridge.resilience.node.ts.
|
||||
- Removed the TypeScript SMTP test helper (test/helpers/server.loader.ts), the DNSManager (ts/mail/routing/classes.dnsmanager.ts), and many deliverability-related interfaces/implementations (IP warmup manager and sender reputation monitor) from unified email server.
|
||||
- Removed public types ISmtpServerOptions and ISmtpTransactionResult from ts/mail/delivery/interfaces.ts, which is a breaking API change for consumers relying on those types.
|
||||
- Removed unused dependencies from package.json: ip and mailauth.
|
||||
|
||||
## 2026-02-10 - 2.4.0 - feat(docs)
|
||||
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
|
||||
|
||||
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
|
||||
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
|
||||
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
|
||||
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
|
||||
|
||||
## 2026-02-10 - 2.3.2 - fix(tests)
|
||||
remove large SMTP client test suites and update SmartFile API usage
|
||||
|
||||
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "2.3.2",
|
||||
"version": "5.1.1",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
@@ -44,36 +44,14 @@
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.5.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.3.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartnetwork": "^4.0.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartproxy": "^23.1.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@push.rocks/smartrust": "^1.1.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.0.4",
|
||||
"@push.rocks/smartrust": "^1.2.0",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"ip": "^2.0.1",
|
||||
"lru-cache": "^11.2.5",
|
||||
"mailauth": "^4.13.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
1887
pnpm-lock.yaml
generated
1887
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
323
readme.md
323
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 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
|
||||
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,100 +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 (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) |
|
||||
| `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
|
||||
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
|
||||
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
|
||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||
@@ -607,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.
|
||||
|
||||
147
rust/Cargo.lock
generated
147
rust/Cargo.lock
generated
@@ -274,15 +274,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -356,16 +347,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@@ -913,16 +894,6 @@ version = "0.2.181"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -1004,16 +975,19 @@ dependencies = [
|
||||
name = "mailer-bin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1021,48 +995,28 @@ name = "mailer-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"mailparse",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailer-napi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailer-security"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hickory-resolver 0.25.2",
|
||||
"ipnet",
|
||||
"mail-auth",
|
||||
"mailer-core",
|
||||
"psl",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1070,23 +1024,26 @@ name = "mailer-smtp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"hmac",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailparse",
|
||||
"pbkdf2",
|
||||
"regex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1144,66 +1101,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.16.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"napi-derive",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "2.16.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "1.0.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -1543,12 +1440,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1859,12 +1750,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -1972,6 +1857,24 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -4,7 +4,6 @@ members = [
|
||||
"crates/mailer-core",
|
||||
"crates/mailer-smtp",
|
||||
"crates/mailer-security",
|
||||
"crates/mailer-napi",
|
||||
"crates/mailer-bin",
|
||||
]
|
||||
|
||||
@@ -19,19 +18,17 @@ tokio-rustls = "0.26"
|
||||
hickory-resolver = "0.25"
|
||||
mail-auth = "0.7"
|
||||
mailparse = "0.16"
|
||||
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
|
||||
napi-derive = "2"
|
||||
ring = "0.17"
|
||||
dashmap = "6"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bytes = "1"
|
||||
regex = "1"
|
||||
base64 = "0.22"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ipnet = "2"
|
||||
rustls-pki-types = "1"
|
||||
psl = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false }
|
||||
|
||||
@@ -19,3 +19,6 @@ serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use mailer_smtp::connection::{
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
|
||||
};
|
||||
|
||||
/// mailer-bin: Rust-powered email security tools
|
||||
@@ -114,10 +114,11 @@ struct IpcEvent {
|
||||
|
||||
// --- Pending callbacks for correlation-ID based reverse calls ---
|
||||
|
||||
/// Stores oneshot senders for pending email processing and auth callbacks.
|
||||
/// Stores oneshot senders for pending email processing, auth, and SCRAM callbacks.
|
||||
struct PendingCallbacks {
|
||||
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
|
||||
}
|
||||
|
||||
impl PendingCallbacks {
|
||||
@@ -125,6 +126,7 @@ impl PendingCallbacks {
|
||||
Self {
|
||||
email: DashMap::new(),
|
||||
auth: DashMap::new(),
|
||||
scram: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
|
||||
self.auth.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
fn register_scram_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<ScramCredentialResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.scram.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Install the ring CryptoProvider for rustls TLS operations (STARTTLS, implicit TLS).
|
||||
// This must happen before any TLS connection is attempted.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
@@ -327,6 +342,7 @@ struct ManagementState {
|
||||
callbacks: Arc<PendingCallbacks>,
|
||||
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
||||
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
||||
smtp_client_manager: Arc<mailer_smtp::client::SmtpClientManager>,
|
||||
}
|
||||
|
||||
/// Run in management/IPC mode for smartrust bridge.
|
||||
@@ -349,10 +365,12 @@ fn run_management_mode() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let callbacks = Arc::new(PendingCallbacks::new());
|
||||
let smtp_client_manager = Arc::new(mailer_smtp::client::SmtpClientManager::new());
|
||||
let mut state = ManagementState {
|
||||
callbacks: callbacks.clone(),
|
||||
smtp_handle: None,
|
||||
smtp_event_rx: None,
|
||||
smtp_client_manager: smtp_client_manager.clone(),
|
||||
};
|
||||
|
||||
// We need to read stdin in a separate thread (blocking I/O)
|
||||
@@ -491,6 +509,22 @@ fn handle_smtp_event(event: ConnectionEvent) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
ConnectionEvent::ScramCredentialRequest {
|
||||
correlation_id,
|
||||
session_id,
|
||||
username,
|
||||
remote_addr,
|
||||
} => {
|
||||
emit_event(
|
||||
"scramCredentialRequest",
|
||||
serde_json::json!({
|
||||
"correlationId": correlation_id,
|
||||
"sessionId": session_id,
|
||||
"username": username,
|
||||
"remoteAddr": remote_addr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
.get("privateKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let key_type = req
|
||||
.params
|
||||
.get("keyType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("rsa");
|
||||
|
||||
match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) {
|
||||
match mailer_security::sign_dkim_auto(raw_message.as_bytes(), domain, selector, private_key, key_type) {
|
||||
Ok(header) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
@@ -822,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
handle_auth_result(req, state)
|
||||
}
|
||||
|
||||
"scramCredentialResult" => {
|
||||
handle_scram_credential_result(req, state)
|
||||
}
|
||||
|
||||
"configureRateLimits" => {
|
||||
// Rate limit configuration is set at startSmtpServer time.
|
||||
// This command allows runtime updates, but for now we acknowledge it.
|
||||
@@ -833,6 +876,28 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
}
|
||||
}
|
||||
|
||||
// --- SMTP Client commands ---
|
||||
|
||||
"sendEmail" => {
|
||||
handle_send_email(req, state).await
|
||||
}
|
||||
|
||||
"sendRawEmail" => {
|
||||
handle_send_raw_email(req, state).await
|
||||
}
|
||||
|
||||
"verifySmtpConnection" => {
|
||||
handle_verify_smtp_connection(req, state).await
|
||||
}
|
||||
|
||||
"closeSmtpPool" => {
|
||||
handle_close_smtp_pool(req, state).await
|
||||
}
|
||||
|
||||
"getSmtpPoolStatus" => {
|
||||
handle_get_smtp_pool_status(req, state)
|
||||
}
|
||||
|
||||
_ => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
@@ -985,6 +1050,56 @@ fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle scramCredentialResult IPC command — resolves a pending SCRAM credential callback.
|
||||
fn handle_scram_credential_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
let correlation_id = req
|
||||
.params
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let found = req.params.get("found").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let result = ScramCredentialResult {
|
||||
found,
|
||||
salt: req.params.get("salt")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
iterations: req.params.get("iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32),
|
||||
stored_key: req.params.get("storedKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
server_key: req.params.get("serverKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
};
|
||||
|
||||
if let Some((_, tx)) = state.callbacks.scram.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 SCRAM credential callback for correlationId: {}",
|
||||
correlation_id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SmtpServerConfig from IPC params JSON.
|
||||
fn parse_smtp_config(
|
||||
params: &serde_json::Value,
|
||||
@@ -1050,5 +1165,321 @@ fn parse_smtp_config(
|
||||
config.processing_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
// Parse additional TLS certs for SNI
|
||||
if let Some(certs_arr) = params.get("additionalTlsCerts").and_then(|v| v.as_array()) {
|
||||
for cert_val in certs_arr {
|
||||
if let (Some(domains_arr), Some(cert_pem), Some(key_pem)) = (
|
||||
cert_val.get("domains").and_then(|v| v.as_array()),
|
||||
cert_val.get("certPem").and_then(|v| v.as_str()),
|
||||
cert_val.get("keyPem").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let domains: Vec<String> = domains_arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
config.additional_tls_certs.push(mailer_smtp::config::TlsDomainCert {
|
||||
domains,
|
||||
cert_pem: cert_pem.to_string(),
|
||||
key_pem: key_pem.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMTP Client IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured email to build a MIME message from.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OutboundEmail {
|
||||
from: String,
|
||||
to: Vec<String>,
|
||||
#[serde(default)]
|
||||
cc: Vec<String>,
|
||||
#[serde(default)]
|
||||
bcc: Vec<String>,
|
||||
#[serde(default)]
|
||||
subject: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
html: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl OutboundEmail {
|
||||
/// Convert to `mailer_core::Email` for proper RFC 5322 MIME building.
|
||||
fn to_core_email(&self) -> mailer_core::Email {
|
||||
let mut email = mailer_core::Email::new(&self.from, &self.subject, &self.text);
|
||||
for addr in &self.to {
|
||||
email.add_to(addr);
|
||||
}
|
||||
for addr in &self.cc {
|
||||
email.add_cc(addr);
|
||||
}
|
||||
for addr in &self.bcc {
|
||||
email.add_bcc(addr);
|
||||
}
|
||||
if let Some(html) = &self.html {
|
||||
email.set_html(html);
|
||||
}
|
||||
for (key, value) in &self.headers {
|
||||
email.add_header(key, value);
|
||||
}
|
||||
email
|
||||
}
|
||||
|
||||
/// Build an RFC 5322 compliant message using `mailer_core::build_rfc822`.
|
||||
fn to_rfc822(&self) -> Vec<u8> {
|
||||
let email = self.to_core_email();
|
||||
match mailer_core::build_rfc822(&email) {
|
||||
Ok(msg) => msg.into_bytes(),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build RFC 822 message: {e}");
|
||||
// Fallback: minimal message
|
||||
format!(
|
||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n{}",
|
||||
self.from,
|
||||
self.to.join(", "),
|
||||
self.subject,
|
||||
self.text
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all recipients (to + cc + bcc).
|
||||
fn all_recipients(&self) -> Vec<String> {
|
||||
let mut all = self.to.clone();
|
||||
all.extend(self.cc.clone());
|
||||
all.extend(self.bcc.clone());
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle sendEmail IPC command — build MIME, optional DKIM sign, send via pool.
|
||||
async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
// Parse client config from params
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the email
|
||||
let email: OutboundEmail = match req.params.get("email").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some("Missing or invalid 'email' field".into()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Build raw message
|
||||
let mut raw_message = email.to_rfc822();
|
||||
|
||||
// Optional DKIM signing
|
||||
if let Some(dkim_val) = req.params.get("dkim") {
|
||||
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
|
||||
match mailer_security::sign_dkim_auto(
|
||||
&raw_message,
|
||||
&dkim_config.domain,
|
||||
&dkim_config.selector,
|
||||
&dkim_config.private_key,
|
||||
&dkim_config.key_type,
|
||||
) {
|
||||
Ok(header) => {
|
||||
// Prepend DKIM header to the message
|
||||
let mut signed = header.into_bytes();
|
||||
signed.extend_from_slice(&raw_message);
|
||||
raw_message = signed;
|
||||
}
|
||||
Err(e) => {
|
||||
// Log but don't fail — send unsigned
|
||||
eprintln!("DKIM signing failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_recipients = email.all_recipients();
|
||||
let sender = &email.from;
|
||||
|
||||
match state
|
||||
.smtp_client_manager
|
||||
.send_message(&config, sender, &all_recipients, &raw_message)
|
||||
.await
|
||||
{
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(serde_json::to_string(&serde_json::json!({
|
||||
"message": e.to_string(),
|
||||
"errorType": e.error_type(),
|
||||
"retryable": e.is_retryable(),
|
||||
"smtpCode": e.smtp_code(),
|
||||
}))
|
||||
.unwrap()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle sendRawEmail IPC command — send a pre-formatted message.
|
||||
async fn handle_send_raw_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
// Parse client config from params
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let envelope_from = req
|
||||
.params
|
||||
.get("envelopeFrom")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let envelope_to: Vec<String> = req
|
||||
.params
|
||||
.get("envelopeTo")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let raw_b64 = req
|
||||
.params
|
||||
.get("rawMessageBase64")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Decode base64 message
|
||||
use base64::Engine;
|
||||
let raw_message = match base64::engine::general_purpose::STANDARD.decode(raw_b64) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid base64 message: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.smtp_client_manager
|
||||
.send_message(&config, envelope_from, &envelope_to, &raw_message)
|
||||
.await
|
||||
{
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(serde_json::to_string(&serde_json::json!({
|
||||
"message": e.to_string(),
|
||||
"errorType": e.error_type(),
|
||||
"retryable": e.is_retryable(),
|
||||
"smtpCode": e.smtp_code(),
|
||||
}))
|
||||
.unwrap()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle verifySmtpConnection IPC command.
|
||||
async fn handle_verify_smtp_connection(
|
||||
req: &IpcRequest,
|
||||
state: &ManagementState,
|
||||
) -> IpcResponse {
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match state.smtp_client_manager.verify_connection(&config).await {
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle closeSmtpPool IPC command.
|
||||
async fn handle_close_smtp_pool(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
if let Some(pool_key) = req.params.get("poolKey").and_then(|v| v.as_str()) {
|
||||
state.smtp_client_manager.close_pool(pool_key).await;
|
||||
} else {
|
||||
state.smtp_client_manager.close_all_pools().await;
|
||||
}
|
||||
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"closed": true})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle getSmtpPoolStatus IPC command.
|
||||
fn handle_get_smtp_pool_status(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
let pools = state.smtp_client_manager.pool_status();
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"pools": pools})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ license.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
bytes.workspace = true
|
||||
mailparse.workspace = true
|
||||
regex.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "mailer-napi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
mailer-core = { path = "../mailer-core" }
|
||||
mailer-smtp = { path = "../mailer-smtp" }
|
||||
mailer-security = { path = "../mailer-security" }
|
||||
napi.workspace = true
|
||||
napi-derive.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
@@ -1,5 +0,0 @@
|
||||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! mailer-napi: N-API bindings exposing Rust mailer to Node.js/TypeScript.
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
/// Returns the version of the native mailer module.
|
||||
#[napi]
|
||||
pub fn get_version() -> String {
|
||||
format!(
|
||||
"mailer-napi v{} (core: {}, smtp: {}, security: {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
mailer_core::version(),
|
||||
mailer_smtp::version(),
|
||||
mailer_security::version(),
|
||||
)
|
||||
}
|
||||
@@ -7,14 +7,11 @@ license.workspace = true
|
||||
[dependencies]
|
||||
mailer-core = { path = "../mailer-core" }
|
||||
mail-auth.workspace = true
|
||||
ring.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
ipnet.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
psl.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
@@ -111,16 +111,18 @@ static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(||
|
||||
// HTML helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regexes for HTML text extraction (compiled once via LazyLock).
|
||||
static HTML_STYLE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap());
|
||||
static HTML_SCRIPT_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap());
|
||||
static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
|
||||
/// Strip HTML tags and decode common entities to produce plain text.
|
||||
fn extract_text_from_html(html: &str) -> String {
|
||||
// Remove style and script blocks first
|
||||
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
||||
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
|
||||
let no_tags = Regex::new(r"<[^>]+>").unwrap();
|
||||
|
||||
let text = no_style.replace_all(html, " ");
|
||||
let text = no_script.replace_all(&text, " ");
|
||||
let text = no_tags.replace_all(&text, " ");
|
||||
let text = HTML_STYLE_RE.replace_all(html, " ");
|
||||
let text = HTML_SCRIPT_RE.replace_all(&text, " ");
|
||||
let text = HTML_TAG_RE.replace_all(&text, " ");
|
||||
|
||||
text.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use mail_auth::common::crypto::{RsaKey, Sha256};
|
||||
use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256};
|
||||
use mail_auth::common::headers::HeaderWriter;
|
||||
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||
@@ -118,9 +118,62 @@ pub fn sign_dkim(
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463).
|
||||
///
|
||||
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||
/// * `domain` - The signing domain (d= tag)
|
||||
/// * `selector` - The DKIM selector (s= tag)
|
||||
/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format
|
||||
///
|
||||
/// Returns the DKIM-Signature header string to prepend to the message.
|
||||
pub fn sign_dkim_ed25519(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pkcs8_der: &[u8],
|
||||
) -> Result<String> {
|
||||
let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der)
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?;
|
||||
|
||||
let signature = DkimSigner::from_key(ed_key)
|
||||
.domain(domain)
|
||||
.selector(selector)
|
||||
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
|
||||
.header_canonicalization(Canonicalization::Relaxed)
|
||||
.body_canonicalization(Canonicalization::Relaxed)
|
||||
.sign(raw_message)
|
||||
.map_err(|e| SecurityError::Dkim(format!("Ed25519 DKIM signing failed: {}", e)))?;
|
||||
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` (default) or `"ed25519"`
|
||||
/// * For RSA: `private_key_pem` is a PEM-encoded RSA key
|
||||
/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key
|
||||
pub fn sign_dkim_auto(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pem: &str,
|
||||
key_type: &str,
|
||||
) -> Result<String> {
|
||||
match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => {
|
||||
// Parse PEM to DER for Ed25519
|
||||
let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?;
|
||||
sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der())
|
||||
}
|
||||
_ => sign_dkim(raw_message, domain, selector, private_key_pem),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||
///
|
||||
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
||||
/// `key_type` should be `"rsa"` or `"ed25519"`.
|
||||
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
// Extract the base64 content from PEM
|
||||
let key_b64: String = public_key_pem
|
||||
@@ -132,6 +185,24 @@ pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value with explicit key type.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` or `"ed25519"`
|
||||
pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String {
|
||||
let key_b64: String = public_key_pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
let k = match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => "ed25519",
|
||||
_ => "rsa",
|
||||
};
|
||||
|
||||
format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -149,4 +220,42 @@ mod tests {
|
||||
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_ed25519() {
|
||||
// Generate an Ed25519 key pair using mail-auth
|
||||
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
|
||||
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
|
||||
let _pub_key = ed_key.public_key();
|
||||
|
||||
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
|
||||
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
|
||||
assert!(result.is_ok());
|
||||
let header = result.unwrap();
|
||||
assert!(header.contains("a=ed25519-sha256"));
|
||||
assert!(header.contains("d=example.com"));
|
||||
assert!(header.contains("s=ed25519sel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_auto_dispatches() {
|
||||
// RSA with invalid key should error
|
||||
let msg = b"From: test@example.com\r\n\r\nBody";
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Ed25519 with invalid PEM should error
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dkim_dns_record_value_typed() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
|
||||
assert!(rsa_record.contains("k=rsa"));
|
||||
|
||||
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
|
||||
assert!(ed_record.contains("k=ed25519"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use hickory_resolver::TokioResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
|
||||
|
||||
/// Check an IP against DNSBL servers.
|
||||
///
|
||||
/// * `ip` - The IP address to check (must be IPv4)
|
||||
/// * `ip` - The IP address to check (IPv4 or IPv6)
|
||||
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||
/// * `resolver` - DNS resolver to use
|
||||
pub async fn check_dnsbl(
|
||||
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
|
||||
dnsbl_servers: &[&str],
|
||||
resolver: &TokioResolver,
|
||||
) -> Result<DnsblResult> {
|
||||
let ipv4 = match ip {
|
||||
IpAddr::V4(v4) => v4,
|
||||
IpAddr::V6(_) => {
|
||||
// IPv6 DNSBL is less common; return clean result
|
||||
return Ok(DnsblResult {
|
||||
ip: ip.to_string(),
|
||||
listed_count: 0,
|
||||
listed_on: Vec::new(),
|
||||
total_checked: 0,
|
||||
});
|
||||
}
|
||||
let reversed = match ip {
|
||||
IpAddr::V4(v4) => reverse_ipv4(v4),
|
||||
IpAddr::V6(v6) => reverse_ipv6(v6),
|
||||
};
|
||||
|
||||
let reversed = reverse_ipv4(ipv4);
|
||||
let total = dnsbl_servers.len();
|
||||
|
||||
// Query all DNSBL servers in parallel
|
||||
@@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
||||
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
||||
}
|
||||
|
||||
/// Reverse IPv6 address to nibble format for DNSBL queries.
|
||||
///
|
||||
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
|
||||
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
|
||||
fn reverse_ipv6(ip: Ipv6Addr) -> String {
|
||||
let segments = ip.segments();
|
||||
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
|
||||
full_hex
|
||||
.chars()
|
||||
.rev()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||
fn classify_ip(ip: IpAddr) -> IpType {
|
||||
@@ -272,6 +277,38 @@ mod tests {
|
||||
assert!(!is_valid_ipv4("not-an-ip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6() {
|
||||
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6_loopback() {
|
||||
let ip: Ipv6Addr = "::1".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_dnsbl_ipv6_runs() {
|
||||
// Verify IPv6 actually goes through DNSBL queries (not skipped)
|
||||
let resolver = hickory_resolver::TokioResolver::builder_tokio()
|
||||
.map(|b| b.build())
|
||||
.unwrap();
|
||||
let ip: IpAddr = "::1".parse().unwrap();
|
||||
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
|
||||
// Loopback should not be listed on any DNSBL
|
||||
assert_eq!(result.listed_count, 0);
|
||||
// But total_checked should be > 0 — proving IPv6 was actually queried
|
||||
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_dnsbl_servers() {
|
||||
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||
|
||||
@@ -9,7 +9,7 @@ pub mod spf;
|
||||
pub mod verify;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
|
||||
pub use dkim::{dkim_dns_record_value, dkim_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult};
|
||||
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||
pub use error::{Result, SecurityError};
|
||||
|
||||
@@ -13,13 +13,16 @@ hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
bytes.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
rustls-pemfile = "2"
|
||||
mailparse.workspace = true
|
||||
webpki-roots = "0.26"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
pbkdf2.workspace = true
|
||||
|
||||
170
rust/crates/mailer-smtp/src/client/config.rs
Normal file
170
rust/crates/mailer-smtp/src/client/config.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! SMTP client configuration types.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Configuration for connecting to an SMTP server.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmtpClientConfig {
|
||||
/// Target SMTP server hostname.
|
||||
pub host: String,
|
||||
|
||||
/// Target port (25 = SMTP, 465 = implicit TLS, 587 = submission).
|
||||
pub port: u16,
|
||||
|
||||
/// Use implicit TLS (port 465). If false, STARTTLS is attempted.
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
|
||||
/// Domain to use in EHLO command. Defaults to "localhost".
|
||||
#[serde(default = "default_domain")]
|
||||
pub domain: String,
|
||||
|
||||
/// Authentication credentials (optional).
|
||||
pub auth: Option<SmtpAuthConfig>,
|
||||
|
||||
/// Connection timeout in seconds. Default: 30.
|
||||
#[serde(default = "default_connection_timeout")]
|
||||
pub connection_timeout_secs: u64,
|
||||
|
||||
/// Socket read/write timeout in seconds. Default: 120.
|
||||
#[serde(default = "default_socket_timeout")]
|
||||
pub socket_timeout_secs: u64,
|
||||
|
||||
/// Pool key override. Defaults to "host:port".
|
||||
pub pool_key: Option<String>,
|
||||
|
||||
/// Maximum connections per pool. Default: 10.
|
||||
#[serde(default = "default_max_pool_connections")]
|
||||
pub max_pool_connections: usize,
|
||||
|
||||
/// Accept invalid TLS certificates (expired, self-signed, wrong hostname).
|
||||
/// Standard for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
/// Default: false.
|
||||
#[serde(default)]
|
||||
pub tls_opportunistic: bool,
|
||||
}
|
||||
|
||||
/// Authentication configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmtpAuthConfig {
|
||||
/// Username.
|
||||
pub user: String,
|
||||
/// Password.
|
||||
pub pass: String,
|
||||
/// Method: "PLAIN" or "LOGIN". Default: "PLAIN".
|
||||
#[serde(default = "default_auth_method")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// DKIM signing configuration (applied before sending).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DkimSignConfig {
|
||||
/// Signing domain (e.g. "example.com").
|
||||
pub domain: String,
|
||||
/// DKIM selector (e.g. "default" or "mta").
|
||||
pub selector: String,
|
||||
/// PEM-encoded private key (RSA or Ed25519 PKCS#8).
|
||||
pub private_key: String,
|
||||
/// Key type: "rsa" (default) or "ed25519".
|
||||
#[serde(default = "default_key_type")]
|
||||
pub key_type: String,
|
||||
}
|
||||
|
||||
fn default_key_type() -> String {
|
||||
"rsa".to_string()
|
||||
}
|
||||
|
||||
impl SmtpClientConfig {
|
||||
/// Get the effective pool key for this config.
|
||||
pub fn effective_pool_key(&self) -> String {
|
||||
self.pool_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", self.host, self.port))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_domain() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
||||
fn default_connection_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_socket_timeout() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_max_pool_connections() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_auth_method() -> String {
|
||||
"PLAIN".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_minimal_config() {
|
||||
let json = r#"{"host":"mail.example.com","port":25}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.host, "mail.example.com");
|
||||
assert_eq!(config.port, 25);
|
||||
assert!(!config.secure);
|
||||
assert_eq!(config.domain, "localhost");
|
||||
assert!(config.auth.is_none());
|
||||
assert_eq!(config.connection_timeout_secs, 30);
|
||||
assert_eq!(config.socket_timeout_secs, 120);
|
||||
assert_eq!(config.max_pool_connections, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_full_config() {
|
||||
let json = r#"{
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"domain": "myserver.com",
|
||||
"auth": { "user": "u", "pass": "p", "method": "LOGIN" },
|
||||
"connectionTimeoutSecs": 60,
|
||||
"socketTimeoutSecs": 300,
|
||||
"poolKey": "gmail",
|
||||
"maxPoolConnections": 5
|
||||
}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.host, "smtp.gmail.com");
|
||||
assert_eq!(config.port, 465);
|
||||
assert!(config.secure);
|
||||
assert_eq!(config.domain, "myserver.com");
|
||||
assert_eq!(config.connection_timeout_secs, 60);
|
||||
assert_eq!(config.socket_timeout_secs, 300);
|
||||
assert_eq!(config.effective_pool_key(), "gmail");
|
||||
assert_eq!(config.max_pool_connections, 5);
|
||||
let auth = config.auth.unwrap();
|
||||
assert_eq!(auth.user, "u");
|
||||
assert_eq!(auth.pass, "p");
|
||||
assert_eq!(auth.method, "LOGIN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_pool_key_default() {
|
||||
let json = r#"{"host":"mx.example.com","port":587}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.effective_pool_key(), "mx.example.com:587");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dkim_config_deserialize() {
|
||||
let json = r#"{"domain":"example.com","selector":"mta","privateKey":"-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"}"#;
|
||||
let dkim: DkimSignConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(dkim.domain, "example.com");
|
||||
assert_eq!(dkim.selector, "mta");
|
||||
assert!(dkim.private_key.contains("RSA PRIVATE KEY"));
|
||||
}
|
||||
}
|
||||
260
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
260
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
//! TCP/TLS connection management for the SMTP client.
|
||||
|
||||
use super::error::SmtpClientError;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_rustls::client::TlsStream;
|
||||
use tracing::debug;
|
||||
|
||||
/// A client-side SMTP stream that may be plain or TLS.
|
||||
pub enum ClientSmtpStream {
|
||||
Plain(BufReader<TcpStream>),
|
||||
Tls(BufReader<TlsStream<TcpStream>>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ClientSmtpStream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(_) => write!(f, "ClientSmtpStream::Plain"),
|
||||
ClientSmtpStream::Tls(_) => write!(f, "ClientSmtpStream::Tls"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientSmtpStream {
|
||||
/// Read a line from the stream (CRLF-terminated).
|
||||
pub async fn read_line(&mut self, buf: &mut String) -> Result<usize, SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Read error: {e}"),
|
||||
}
|
||||
}),
|
||||
ClientSmtpStream::Tls(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS read error: {e}"),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bytes to the stream.
|
||||
pub async fn write_all(&mut self, data: &[u8]) -> Result<(), SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => {
|
||||
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Write error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
ClientSmtpStream::Tls(reader) => {
|
||||
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS write error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the stream.
|
||||
pub async fn flush(&mut self) -> Result<(), SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => {
|
||||
reader.get_mut().flush().await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Flush error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
ClientSmtpStream::Tls(reader) => {
|
||||
reader.get_mut().flush().await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS flush error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume this stream and return the inner TcpStream (for STARTTLS upgrade).
|
||||
/// Only works on Plain streams; returns an error on TLS streams.
|
||||
pub fn into_tcp_stream(self) -> Result<TcpStream, SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => Ok(reader.into_inner()),
|
||||
ClientSmtpStream::Tls(_) => Err(SmtpClientError::TlsError {
|
||||
message: "Cannot extract TcpStream from an already-TLS stream".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to an SMTP server via plain TCP.
|
||||
pub async fn connect_plain(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Connecting to {}:{} (plain)", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
let stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||
})?
|
||||
.map_err(|e| SmtpClientError::ConnectionError {
|
||||
message: format!("Failed to connect to {addr}: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(ClientSmtpStream::Plain(BufReader::new(stream)))
|
||||
}
|
||||
|
||||
/// Connect to an SMTP server via implicit TLS (port 465).
|
||||
pub async fn connect_tls(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_secs: u64,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let tcp_stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||
})?
|
||||
.map_err(|e| SmtpClientError::ConnectionError {
|
||||
message: format!("Failed to connect to {addr}: {e}"),
|
||||
})?;
|
||||
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
/// Upgrade a plain TCP connection to TLS (STARTTLS).
|
||||
pub async fn upgrade_to_tls(
|
||||
stream: ClientSmtpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
||||
let tcp_stream = stream.into_tcp_stream()?;
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
/// A TLS certificate verifier that accepts any certificate.
|
||||
/// Used for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
#[derive(Debug)]
|
||||
struct OpportunisticVerifier;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls_pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls_pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls_pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the TLS handshake on a TCP stream using webpki-roots.
|
||||
/// When `tls_opportunistic` is true, certificate verification is skipped
|
||||
/// (standard for MTA-to-MTA delivery per RFC 7435).
|
||||
async fn perform_tls_handshake(
|
||||
tcp_stream: TcpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
|
||||
let tls_config = if tls_opportunistic {
|
||||
debug!("Using opportunistic TLS (no cert verification) for {}", hostname);
|
||||
rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(OpportunisticVerifier))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
|
||||
SmtpClientError::TlsError {
|
||||
message: format!("Invalid server name '{hostname}': {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
let tls_stream = connector
|
||||
.connect(server_name, tcp_stream)
|
||||
.await
|
||||
.map_err(|e| SmtpClientError::TlsError {
|
||||
message: format!("TLS handshake with {hostname} failed: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_plain_refused() {
|
||||
// Connecting to a port that's not listening should fail
|
||||
let result = connect_plain("127.0.0.1", 19999, 2).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, SmtpClientError::ConnectionError { .. }));
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_tls_refused() {
|
||||
let result = connect_tls("127.0.0.1", 19998, 2, false).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_timeout() {
|
||||
// 192.0.2.1 is TEST-NET, should time out
|
||||
let result = connect_plain("192.0.2.1", 25, 1).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
// May be timeout or connection error depending on network
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
}
|
||||
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! SMTP client error types.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Errors that can occur during SMTP client operations.
|
||||
#[derive(Debug, thiserror::Error, Serialize)]
|
||||
pub enum SmtpClientError {
|
||||
#[error("Connection error: {message}")]
|
||||
ConnectionError { message: String },
|
||||
|
||||
#[error("Timeout: {message}")]
|
||||
TimeoutError { message: String },
|
||||
|
||||
#[error("TLS error: {message}")]
|
||||
TlsError { message: String },
|
||||
|
||||
#[error("Authentication failed: {message}")]
|
||||
AuthenticationError { message: String },
|
||||
|
||||
#[error("Protocol error ({code}): {message}")]
|
||||
ProtocolError { code: u16, message: String },
|
||||
|
||||
#[error("Pool exhausted: {message}")]
|
||||
PoolExhausted { message: String },
|
||||
|
||||
#[error("Invalid configuration: {message}")]
|
||||
ConfigError { message: String },
|
||||
}
|
||||
|
||||
impl SmtpClientError {
|
||||
/// Whether this error is retryable (temporary failure).
|
||||
/// Permanent failures (5xx, auth failures) are not retryable.
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
SmtpClientError::ConnectionError { .. } => true,
|
||||
SmtpClientError::TimeoutError { .. } => true,
|
||||
SmtpClientError::TlsError { .. } => false,
|
||||
SmtpClientError::AuthenticationError { .. } => false,
|
||||
SmtpClientError::ProtocolError { code, .. } => *code >= 400 && *code < 500,
|
||||
SmtpClientError::PoolExhausted { .. } => true,
|
||||
SmtpClientError::ConfigError { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error type as a string for IPC serialization.
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
SmtpClientError::ConnectionError { .. } => "connection",
|
||||
SmtpClientError::TimeoutError { .. } => "timeout",
|
||||
SmtpClientError::TlsError { .. } => "tls",
|
||||
SmtpClientError::AuthenticationError { .. } => "authentication",
|
||||
SmtpClientError::ProtocolError { .. } => "protocol",
|
||||
SmtpClientError::PoolExhausted { .. } => "pool_exhausted",
|
||||
SmtpClientError::ConfigError { .. } => "config",
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMTP code if this is a protocol error.
|
||||
pub fn smtp_code(&self) -> Option<u16> {
|
||||
match self {
|
||||
SmtpClientError::ProtocolError { code, .. } => Some(*code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_retryable_errors() {
|
||||
assert!(SmtpClientError::ConnectionError {
|
||||
message: "refused".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::TimeoutError {
|
||||
message: "timed out".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::PoolExhausted {
|
||||
message: "full".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::ProtocolError {
|
||||
code: 421,
|
||||
message: "try later".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::ProtocolError {
|
||||
code: 450,
|
||||
message: "mailbox busy".into()
|
||||
}
|
||||
.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_retryable_errors() {
|
||||
assert!(!SmtpClientError::AuthenticationError {
|
||||
message: "bad creds".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::TlsError {
|
||||
message: "cert invalid".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "no such user".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ProtocolError {
|
||||
code: 554,
|
||||
message: "rejected".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ConfigError {
|
||||
message: "bad config".into()
|
||||
}
|
||||
.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_type_strings() {
|
||||
assert_eq!(
|
||||
SmtpClientError::ConnectionError {
|
||||
message: "x".into()
|
||||
}
|
||||
.error_type(),
|
||||
"connection"
|
||||
);
|
||||
assert_eq!(
|
||||
SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "x".into()
|
||||
}
|
||||
.error_type(),
|
||||
"protocol"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smtp_code() {
|
||||
assert_eq!(
|
||||
SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "x".into()
|
||||
}
|
||||
.smtp_code(),
|
||||
Some(550)
|
||||
);
|
||||
assert_eq!(
|
||||
SmtpClientError::ConnectionError {
|
||||
message: "x".into()
|
||||
}
|
||||
.smtp_code(),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! SMTP client module for outbound email delivery.
|
||||
//!
|
||||
//! Provides connection pooling, SMTP protocol, TLS, and authentication
|
||||
//! for sending outbound emails through remote SMTP servers.
|
||||
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod pool;
|
||||
pub mod protocol;
|
||||
|
||||
// Re-export key types for convenience.
|
||||
pub use config::{DkimSignConfig, SmtpAuthConfig, SmtpClientConfig};
|
||||
pub use error::SmtpClientError;
|
||||
pub use pool::{SmtpClientManager, SmtpSendResult, SmtpVerifyResult};
|
||||
pub use protocol::{dot_stuff, EhloCapabilities, SmtpClientResponse};
|
||||
515
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
515
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
//! Connection pooling for the SMTP client.
|
||||
//!
|
||||
//! Manages reusable connections per destination `host:port`.
|
||||
|
||||
use super::config::SmtpClientConfig;
|
||||
use super::connection::{connect_plain, connect_tls, ClientSmtpStream};
|
||||
use super::error::SmtpClientError;
|
||||
use super::protocol::{self, EhloCapabilities};
|
||||
use dashmap::DashMap;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Maximum age of a pooled connection (5 minutes).
|
||||
const MAX_CONNECTION_AGE_SECS: u64 = 300;
|
||||
|
||||
/// Maximum idle time before a connection is reaped (30 seconds).
|
||||
const MAX_IDLE_SECS: u64 = 30;
|
||||
|
||||
/// Maximum messages per pooled connection before it's recycled.
|
||||
const MAX_MESSAGES_PER_CONNECTION: u32 = 100;
|
||||
|
||||
/// A pooled SMTP connection.
|
||||
pub struct PooledConnection {
|
||||
pub stream: ClientSmtpStream,
|
||||
pub capabilities: EhloCapabilities,
|
||||
pub created_at: Instant,
|
||||
pub last_used: Instant,
|
||||
pub message_count: u32,
|
||||
pub idle: bool,
|
||||
}
|
||||
|
||||
/// Check if a pooled connection is stale (too old, too many messages, or idle too long).
|
||||
fn is_connection_stale(conn: &PooledConnection) -> bool {
|
||||
conn.created_at.elapsed().as_secs() > MAX_CONNECTION_AGE_SECS
|
||||
|| conn.message_count >= MAX_MESSAGES_PER_CONNECTION
|
||||
|| (conn.idle && conn.last_used.elapsed().as_secs() > MAX_IDLE_SECS)
|
||||
}
|
||||
|
||||
/// Per-destination connection pool.
|
||||
pub struct ConnectionPool {
|
||||
connections: Vec<PooledConnection>,
|
||||
max_connections: usize,
|
||||
config: SmtpClientConfig,
|
||||
}
|
||||
|
||||
impl ConnectionPool {
|
||||
fn new(config: SmtpClientConfig) -> Self {
|
||||
let max_connections = config.max_pool_connections;
|
||||
Self {
|
||||
connections: Vec::new(),
|
||||
max_connections,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an idle connection or create a new one.
|
||||
async fn acquire(&mut self) -> Result<PooledConnection, SmtpClientError> {
|
||||
// Remove stale connections first
|
||||
self.cleanup_stale();
|
||||
|
||||
// Find an idle connection
|
||||
if let Some(idx) = self
|
||||
.connections
|
||||
.iter()
|
||||
.position(|c| c.idle && !is_connection_stale(c))
|
||||
{
|
||||
let mut conn = self.connections.remove(idx);
|
||||
conn.idle = false;
|
||||
conn.last_used = Instant::now();
|
||||
debug!(
|
||||
"Reusing pooled connection (age={}s, msgs={})",
|
||||
conn.created_at.elapsed().as_secs(),
|
||||
conn.message_count
|
||||
);
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
if self.connections.len() >= self.max_connections {
|
||||
return Err(SmtpClientError::PoolExhausted {
|
||||
message: format!(
|
||||
"Pool for {} is at max capacity ({})",
|
||||
self.config.effective_pool_key(),
|
||||
self.max_connections
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new connection
|
||||
self.create_connection().await
|
||||
}
|
||||
|
||||
/// Return a connection to the pool (or close it if it's expired).
|
||||
fn release(&mut self, mut conn: PooledConnection) {
|
||||
conn.message_count += 1;
|
||||
conn.last_used = Instant::now();
|
||||
conn.idle = true;
|
||||
|
||||
// Don't return if it's stale
|
||||
if is_connection_stale(&conn) || self.connections.len() >= self.max_connections {
|
||||
debug!("Discarding stale/excess pooled connection");
|
||||
// Drop the connection (stream will be closed)
|
||||
return;
|
||||
}
|
||||
|
||||
self.connections.push(conn);
|
||||
}
|
||||
|
||||
/// Create a fresh SMTP connection and complete the handshake.
|
||||
async fn create_connection(&self) -> Result<PooledConnection, SmtpClientError> {
|
||||
let mut stream = if self.config.secure {
|
||||
connect_tls(
|
||||
&self.config.host,
|
||||
self.config.port,
|
||||
self.config.connection_timeout_secs,
|
||||
self.config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
connect_plain(
|
||||
&self.config.host,
|
||||
self.config.port,
|
||||
self.config.connection_timeout_secs,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Read greeting
|
||||
protocol::read_greeting(&mut stream, self.config.socket_timeout_secs).await?;
|
||||
|
||||
// Send EHLO
|
||||
let mut capabilities =
|
||||
protocol::send_ehlo(&mut stream, &self.config.domain, self.config.socket_timeout_secs)
|
||||
.await?;
|
||||
|
||||
// STARTTLS if available and not already secure
|
||||
if !self.config.secure && capabilities.starttls {
|
||||
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
||||
stream =
|
||||
super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
|
||||
|
||||
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
||||
capabilities = protocol::send_ehlo(
|
||||
&mut stream,
|
||||
&self.config.domain,
|
||||
self.config.socket_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Authenticate if credentials provided
|
||||
if let Some(auth) = &self.config.auth {
|
||||
protocol::authenticate(
|
||||
&mut stream,
|
||||
auth,
|
||||
&capabilities,
|
||||
self.config.socket_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"New SMTP connection to {} established",
|
||||
self.config.effective_pool_key()
|
||||
);
|
||||
|
||||
Ok(PooledConnection {
|
||||
stream,
|
||||
capabilities,
|
||||
created_at: Instant::now(),
|
||||
last_used: Instant::now(),
|
||||
message_count: 0,
|
||||
idle: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_stale(&mut self) {
|
||||
self.connections.retain(|c| !is_connection_stale(c));
|
||||
}
|
||||
|
||||
/// Number of connections in the pool.
|
||||
fn total(&self) -> usize {
|
||||
self.connections.len()
|
||||
}
|
||||
|
||||
/// Number of idle connections.
|
||||
fn idle_count(&self) -> usize {
|
||||
self.connections.iter().filter(|c| c.idle).count()
|
||||
}
|
||||
|
||||
/// Close all connections.
|
||||
fn close_all(&mut self) {
|
||||
self.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Status report for a single pool.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PoolStatus {
|
||||
pub total: usize,
|
||||
pub active: usize,
|
||||
pub idle: usize,
|
||||
}
|
||||
|
||||
/// Manages connection pools for multiple SMTP destinations.
|
||||
pub struct SmtpClientManager {
|
||||
pools: DashMap<String, Arc<Mutex<ConnectionPool>>>,
|
||||
}
|
||||
|
||||
impl SmtpClientManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pools: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a pool for the given config.
|
||||
fn get_pool(&self, config: &SmtpClientConfig) -> Arc<Mutex<ConnectionPool>> {
|
||||
let key = config.effective_pool_key();
|
||||
self.pools
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(ConnectionPool::new(config.clone()))))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Acquire a connection from the pool, send a message, and release it.
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
config: &SmtpClientConfig,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
message: &[u8],
|
||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||
let pool_arc = self.get_pool(config);
|
||||
let mut pool = pool_arc.lock().await;
|
||||
|
||||
let mut conn = pool.acquire().await?;
|
||||
drop(pool); // Release the pool lock while we do network I/O
|
||||
|
||||
// Reset server state if reusing a connection that has already sent messages
|
||||
if conn.message_count > 0 {
|
||||
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
||||
}
|
||||
|
||||
// Perform the SMTP transaction (use pipelining if server supports it)
|
||||
let pipelining = conn.capabilities.pipelining;
|
||||
let result =
|
||||
Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
|
||||
|
||||
// Re-acquire the pool lock and release the connection
|
||||
let mut pool = pool_arc.lock().await;
|
||||
match &result {
|
||||
Ok(_) => pool.release(conn),
|
||||
Err(_) => {
|
||||
// Don't return failed connections to the pool
|
||||
debug!("Discarding connection after send failure");
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Perform the SMTP send transaction on a connected stream.
|
||||
async fn perform_send(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
message: &[u8],
|
||||
config: &SmtpClientConfig,
|
||||
pipelining: bool,
|
||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||
let timeout_secs = config.socket_timeout_secs;
|
||||
|
||||
let (accepted, rejected) = if pipelining {
|
||||
// Use pipelined envelope: MAIL FROM + all RCPT TO in one batch
|
||||
let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope(
|
||||
stream, sender, recipients, timeout_secs,
|
||||
).await?;
|
||||
(acc, rej)
|
||||
} else {
|
||||
// Sequential: MAIL FROM, then each RCPT TO
|
||||
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
|
||||
for rcpt in recipients {
|
||||
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(accepted, rejected)
|
||||
};
|
||||
|
||||
// If no recipients were accepted, fail
|
||||
if accepted.is_empty() {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "All recipients were rejected".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// DATA
|
||||
let data_resp = protocol::send_data(stream, message, timeout_secs).await?;
|
||||
|
||||
// Extract message ID from the response if present
|
||||
let message_id = data_resp
|
||||
.lines
|
||||
.iter()
|
||||
.find_map(|line| {
|
||||
// Look for a pattern like "queued as XXXX" or message-id
|
||||
if line.contains("queued") || line.contains("id=") {
|
||||
Some(line.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SmtpSendResult {
|
||||
accepted,
|
||||
rejected,
|
||||
message_id,
|
||||
response: data_resp.full_message(),
|
||||
envelope: SmtpEnvelope {
|
||||
from: sender.to_string(),
|
||||
to: recipients.to_vec(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify connectivity to an SMTP server (connect, EHLO, QUIT).
|
||||
pub async fn verify_connection(
|
||||
&self,
|
||||
config: &SmtpClientConfig,
|
||||
) -> Result<SmtpVerifyResult, SmtpClientError> {
|
||||
let mut stream = if config.secure {
|
||||
connect_tls(
|
||||
&config.host,
|
||||
config.port,
|
||||
config.connection_timeout_secs,
|
||||
config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
connect_plain(
|
||||
&config.host,
|
||||
config.port,
|
||||
config.connection_timeout_secs,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let greeting = protocol::read_greeting(&mut stream, config.socket_timeout_secs).await?;
|
||||
let caps =
|
||||
protocol::send_ehlo(&mut stream, &config.domain, config.socket_timeout_secs).await?;
|
||||
let _ = protocol::send_quit(&mut stream, config.socket_timeout_secs).await;
|
||||
|
||||
Ok(SmtpVerifyResult {
|
||||
reachable: true,
|
||||
greeting: Some(greeting.full_message()),
|
||||
capabilities: Some(caps.extensions),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get status of all pools.
|
||||
pub fn pool_status(&self) -> std::collections::HashMap<String, PoolStatus> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
for entry in self.pools.iter() {
|
||||
let key = entry.key().clone();
|
||||
// Try to get the lock without blocking — if locked, report as active
|
||||
match entry.value().try_lock() {
|
||||
Ok(pool) => {
|
||||
let total = pool.total();
|
||||
let idle = pool.idle_count();
|
||||
result.insert(
|
||||
key,
|
||||
PoolStatus {
|
||||
total,
|
||||
active: total - idle,
|
||||
idle,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// Pool is in use; report as busy
|
||||
result.insert(
|
||||
key,
|
||||
PoolStatus {
|
||||
total: 0,
|
||||
active: 1,
|
||||
idle: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Close a specific pool.
|
||||
pub async fn close_pool(&self, key: &str) {
|
||||
if let Some(pool_ref) = self.pools.get(key) {
|
||||
let mut pool = pool_ref.lock().await;
|
||||
pool.close_all();
|
||||
}
|
||||
self.pools.remove(key);
|
||||
}
|
||||
|
||||
/// Close all pools.
|
||||
pub async fn close_all_pools(&self) {
|
||||
let keys: Vec<String> = self.pools.iter().map(|e| e.key().clone()).collect();
|
||||
for key in keys {
|
||||
self.close_pool(&key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of sending an email via SMTP.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpSendResult {
|
||||
pub accepted: Vec<String>,
|
||||
pub rejected: Vec<String>,
|
||||
#[serde(rename = "messageId")]
|
||||
pub message_id: Option<String>,
|
||||
pub response: String,
|
||||
pub envelope: SmtpEnvelope,
|
||||
}
|
||||
|
||||
/// SMTP envelope (sender + recipients).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpEnvelope {
|
||||
pub from: String,
|
||||
pub to: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of verifying an SMTP connection.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpVerifyResult {
|
||||
pub reachable: bool,
|
||||
pub greeting: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pool_status_serialization() {
|
||||
let status = PoolStatus {
|
||||
total: 5,
|
||||
active: 2,
|
||||
idle: 3,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"total\":5"));
|
||||
assert!(json.contains("\"active\":2"));
|
||||
assert!(json.contains("\"idle\":3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_result_serialization() {
|
||||
let result = SmtpSendResult {
|
||||
accepted: vec!["a@b.com".into()],
|
||||
rejected: vec![],
|
||||
message_id: Some("abc123".into()),
|
||||
response: "250 OK".into(),
|
||||
envelope: SmtpEnvelope {
|
||||
from: "from@test.com".into(),
|
||||
to: vec!["a@b.com".into()],
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"messageId\":\"abc123\""));
|
||||
assert!(json.contains("\"accepted\":[\"a@b.com\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_result_serialization() {
|
||||
let result = SmtpVerifyResult {
|
||||
reachable: true,
|
||||
greeting: Some("220 mail.example.com".into()),
|
||||
capabilities: Some(vec!["SIZE 10485760".into(), "STARTTLS".into()]),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"reachable\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smtp_client_manager_new() {
|
||||
let mgr = SmtpClientManager::new();
|
||||
assert!(mgr.pool_status().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_close_all_empty() {
|
||||
let mgr = SmtpClientManager::new();
|
||||
mgr.close_all_pools().await;
|
||||
assert!(mgr.pool_status().is_empty());
|
||||
}
|
||||
}
|
||||
568
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
568
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
@@ -0,0 +1,568 @@
|
||||
//! SMTP client protocol engine.
|
||||
//!
|
||||
//! Implements the SMTP command/response flow for sending outbound email.
|
||||
|
||||
use super::config::SmtpAuthConfig;
|
||||
use super::connection::ClientSmtpStream;
|
||||
use super::error::SmtpClientError;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
/// Parsed SMTP response (from the remote server).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpClientResponse {
|
||||
pub code: u16,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SmtpClientResponse {
|
||||
pub fn is_success(&self) -> bool {
|
||||
self.code >= 200 && self.code < 300
|
||||
}
|
||||
|
||||
pub fn is_positive_intermediate(&self) -> bool {
|
||||
self.code >= 300 && self.code < 400
|
||||
}
|
||||
|
||||
pub fn is_temp_error(&self) -> bool {
|
||||
self.code >= 400 && self.code < 500
|
||||
}
|
||||
|
||||
pub fn is_perm_error(&self) -> bool {
|
||||
self.code >= 500
|
||||
}
|
||||
|
||||
/// Full response text (all lines joined).
|
||||
pub fn full_message(&self) -> String {
|
||||
self.lines.join(" ")
|
||||
}
|
||||
|
||||
/// Convert to a protocol error if this is an error response.
|
||||
pub fn to_error(&self) -> SmtpClientError {
|
||||
SmtpClientError::ProtocolError {
|
||||
code: self.code,
|
||||
message: self.full_message(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server capabilities parsed from EHLO response.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EhloCapabilities {
|
||||
pub extensions: Vec<String>,
|
||||
pub max_size: Option<u64>,
|
||||
pub starttls: bool,
|
||||
pub auth_methods: Vec<String>,
|
||||
pub pipelining: bool,
|
||||
pub eight_bit_mime: bool,
|
||||
}
|
||||
|
||||
/// Read a multi-line SMTP response from the server.
|
||||
pub async fn read_response(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let mut lines = Vec::new();
|
||||
let mut code: u16;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
stream.read_line(&mut line),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Timeout reading SMTP response after {timeout_secs}s"),
|
||||
})??;
|
||||
|
||||
if n == 0 {
|
||||
return Err(SmtpClientError::ConnectionError {
|
||||
message: "Connection closed while reading response".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against unbounded lines from malicious servers (RFC 5321 §4.5.3.1.4 says 512 max)
|
||||
if line.len() > 4096 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Response line too long ({} bytes, max 4096)", line.len()),
|
||||
});
|
||||
}
|
||||
|
||||
let line = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
|
||||
if line.len() < 3 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Invalid response line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the 3-digit code
|
||||
let parsed_code: u16 = line[..3].parse().map_err(|_| SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Invalid response code in: {line}"),
|
||||
})?;
|
||||
code = parsed_code;
|
||||
|
||||
// Text after the code (skip the separator character)
|
||||
let text = if line.len() > 4 { &line[4..] } else { "" };
|
||||
lines.push(text.to_string());
|
||||
|
||||
// Check for continuation: "250-" means more lines, "250 " means last line
|
||||
if line.len() >= 4 && line.as_bytes()[3] == b'-' {
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug!("SMTP response: {} {}", code, lines.join(" | "));
|
||||
Ok(SmtpClientResponse { code, lines })
|
||||
}
|
||||
|
||||
/// Read the server greeting (first response after connect).
|
||||
pub async fn read_greeting(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = read_response(stream, timeout_secs).await?;
|
||||
if resp.code == 220 {
|
||||
Ok(resp)
|
||||
} else {
|
||||
Err(SmtpClientError::ProtocolError {
|
||||
code: resp.code,
|
||||
message: format!("Unexpected greeting: {}", resp.full_message()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw command and read the response.
|
||||
async fn send_command(
|
||||
stream: &mut ClientSmtpStream,
|
||||
command: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
debug!("SMTP C: {}", command);
|
||||
stream
|
||||
.write_all(format!("{command}\r\n").as_bytes())
|
||||
.await?;
|
||||
stream.flush().await?;
|
||||
read_response(stream, timeout_secs).await
|
||||
}
|
||||
|
||||
/// Send EHLO and parse capabilities.
|
||||
pub async fn send_ehlo(
|
||||
stream: &mut ClientSmtpStream,
|
||||
domain: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<EhloCapabilities, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("EHLO {domain}"), timeout_secs).await?;
|
||||
|
||||
if !resp.is_success() {
|
||||
// Fall back to HELO
|
||||
let helo_resp = send_command(stream, &format!("HELO {domain}"), timeout_secs).await?;
|
||||
if !helo_resp.is_success() {
|
||||
return Err(helo_resp.to_error());
|
||||
}
|
||||
return Ok(EhloCapabilities::default());
|
||||
}
|
||||
|
||||
let mut caps = EhloCapabilities::default();
|
||||
|
||||
// First line is the greeting, remaining lines are capabilities
|
||||
for line in resp.lines.iter().skip(1) {
|
||||
let upper = line.to_uppercase();
|
||||
if upper.starts_with("SIZE ") {
|
||||
caps.max_size = upper[5..].trim().parse().ok();
|
||||
} else if upper == "STARTTLS" {
|
||||
caps.starttls = true;
|
||||
} else if upper.starts_with("AUTH ") {
|
||||
caps.auth_methods = upper[5..]
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
} else if upper == "PIPELINING" {
|
||||
caps.pipelining = true;
|
||||
} else if upper == "8BITMIME" {
|
||||
caps.eight_bit_mime = true;
|
||||
}
|
||||
caps.extensions.push(line.clone());
|
||||
}
|
||||
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Send STARTTLS command (does not perform the TLS handshake itself).
|
||||
pub async fn send_starttls(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
let resp = send_command(stream, "STARTTLS", timeout_secs).await?;
|
||||
if resp.code != 220 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: resp.code,
|
||||
message: format!("STARTTLS rejected: {}", resp.full_message()),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using AUTH PLAIN.
|
||||
pub async fn send_auth_plain(
|
||||
stream: &mut ClientSmtpStream,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// AUTH PLAIN sends \0user\0pass in base64
|
||||
let credentials = format!("\x00{user}\x00{pass}");
|
||||
let encoded = BASE64.encode(credentials.as_bytes());
|
||||
let resp = send_command(stream, &format!("AUTH PLAIN {encoded}"), timeout_secs).await?;
|
||||
|
||||
if resp.code != 235 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!("AUTH PLAIN failed ({}): {}", resp.code, resp.full_message()),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using AUTH LOGIN.
|
||||
pub async fn send_auth_login(
|
||||
stream: &mut ClientSmtpStream,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// Step 1: Send AUTH LOGIN
|
||||
let resp = send_command(stream, "AUTH LOGIN", timeout_secs).await?;
|
||||
if resp.code != 334 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN challenge failed ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Send base64 username
|
||||
let user_b64 = BASE64.encode(user.as_bytes());
|
||||
let resp = send_command(stream, &user_b64, timeout_secs).await?;
|
||||
if resp.code != 334 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN username rejected ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Send base64 password
|
||||
let pass_b64 = BASE64.encode(pass.as_bytes());
|
||||
let resp = send_command(stream, &pass_b64, timeout_secs).await?;
|
||||
if resp.code != 235 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN password rejected ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using the configured method.
|
||||
pub async fn authenticate(
|
||||
stream: &mut ClientSmtpStream,
|
||||
auth: &SmtpAuthConfig,
|
||||
_caps: &EhloCapabilities,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
match auth.method.to_uppercase().as_str() {
|
||||
"LOGIN" => send_auth_login(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||
_ => send_auth_plain(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send MAIL FROM.
|
||||
pub async fn send_mail_from(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("MAIL FROM:<{sender}>"), timeout_secs).await?;
|
||||
if !resp.is_success() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send RCPT TO. Returns per-recipient success/failure.
|
||||
pub async fn send_rcpt_to(
|
||||
stream: &mut ClientSmtpStream,
|
||||
recipient: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("RCPT TO:<{recipient}>"), timeout_secs).await?;
|
||||
// We don't fail the entire send on per-recipient errors;
|
||||
// the caller decides based on the response code.
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send MAIL FROM + RCPT TO commands in a single pipelined batch.
|
||||
///
|
||||
/// Writes all envelope commands at once, then reads responses in order.
|
||||
/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`.
|
||||
pub async fn send_pipelined_envelope(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
timeout_secs: u64,
|
||||
) -> Result<(bool, Vec<String>, Vec<String>), SmtpClientError> {
|
||||
// Build the full pipelined command batch
|
||||
let mut batch = format!("MAIL FROM:<{sender}>\r\n");
|
||||
for rcpt in recipients {
|
||||
batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n"));
|
||||
}
|
||||
|
||||
// Send all commands at once
|
||||
debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len());
|
||||
stream.write_all(batch.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Read MAIL FROM response
|
||||
let mail_resp = read_response(stream, timeout_secs).await?;
|
||||
if !mail_resp.is_success() {
|
||||
return Err(mail_resp.to_error());
|
||||
}
|
||||
|
||||
// Read RCPT TO responses
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
for rcpt in recipients {
|
||||
match read_response(stream, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((true, accepted, rejected))
|
||||
}
|
||||
|
||||
/// Send DATA command, followed by the message body with dot-stuffing.
|
||||
pub async fn send_data(
|
||||
stream: &mut ClientSmtpStream,
|
||||
message: &[u8],
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
// Send DATA command
|
||||
let resp = send_command(stream, "DATA", timeout_secs).await?;
|
||||
if !resp.is_positive_intermediate() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
|
||||
// Send the message body with dot-stuffing
|
||||
let stuffed = dot_stuff(message);
|
||||
stream.write_all(&stuffed).await?;
|
||||
|
||||
// Send terminator: CRLF.CRLF
|
||||
// If the message doesn't end with CRLF, add one
|
||||
if !stuffed.ends_with(b"\r\n") {
|
||||
stream.write_all(b"\r\n").await?;
|
||||
}
|
||||
stream.write_all(b".\r\n").await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Read final response
|
||||
let final_resp = read_response(stream, timeout_secs).await?;
|
||||
if !final_resp.is_success() {
|
||||
return Err(final_resp.to_error());
|
||||
}
|
||||
|
||||
Ok(final_resp)
|
||||
}
|
||||
|
||||
/// Send RSET command to reset the server state between messages on a reused connection.
|
||||
pub async fn send_rset(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
let resp = send_command(stream, "RSET", timeout_secs).await?;
|
||||
if !resp.is_success() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send QUIT command.
|
||||
pub async fn send_quit(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// Best-effort QUIT — ignore errors since we're closing anyway
|
||||
let _ = send_command(stream, "QUIT", timeout_secs).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply SMTP dot-stuffing to a message body.
|
||||
///
|
||||
/// Any line starting with a period gets an extra period prepended.
|
||||
/// Also normalizes bare LF to CRLF.
|
||||
pub fn dot_stuff(data: &[u8]) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(data.len() + data.len() / 40);
|
||||
let mut at_line_start = true;
|
||||
|
||||
for i in 0..data.len() {
|
||||
let byte = data[i];
|
||||
|
||||
// Normalize bare LF to CRLF
|
||||
if byte == b'\n' && (i == 0 || data[i - 1] != b'\r') {
|
||||
result.push(b'\r');
|
||||
result.push(b'\n');
|
||||
at_line_start = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dot-stuff: add extra dot at start of line
|
||||
if at_line_start && byte == b'.' {
|
||||
result.push(b'.');
|
||||
}
|
||||
|
||||
result.push(byte);
|
||||
at_line_start = byte == b'\n';
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_basic() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"Hello\r\n.World\r\n"),
|
||||
b"Hello\r\n..World\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_leading_dot() {
|
||||
assert_eq!(dot_stuff(b".starts with dot\r\n"), b"..starts with dot\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_multiple_dots() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"ok\r\n.line1\r\n..line2\r\n"),
|
||||
b"ok\r\n..line1\r\n...line2\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_bare_lf() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"line1\nline2\n"),
|
||||
b"line1\r\nline2\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_bare_lf_with_dot() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"ok\n.dotline\n"),
|
||||
b"ok\r\n..dotline\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_no_change() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"Hello World\r\nNo dots here\r\n"),
|
||||
b"Hello World\r\nNo dots here\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_empty() {
|
||||
assert_eq!(dot_stuff(b""), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_is_success() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 250,
|
||||
lines: vec!["OK".into()],
|
||||
};
|
||||
assert!(resp.is_success());
|
||||
assert!(!resp.is_temp_error());
|
||||
assert!(!resp.is_perm_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_temp_error() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 450,
|
||||
lines: vec!["Mailbox busy".into()],
|
||||
};
|
||||
assert!(!resp.is_success());
|
||||
assert!(resp.is_temp_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_perm_error() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 550,
|
||||
lines: vec!["No such user".into()],
|
||||
};
|
||||
assert!(!resp.is_success());
|
||||
assert!(resp.is_perm_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_positive_intermediate() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 354,
|
||||
lines: vec!["Start mail input".into()],
|
||||
};
|
||||
assert!(resp.is_positive_intermediate());
|
||||
assert!(!resp.is_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_full_message() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 250,
|
||||
lines: vec!["OK".into(), "SIZE 10485760".into()],
|
||||
};
|
||||
assert_eq!(resp.full_message(), "OK SIZE 10485760");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_capabilities_default() {
|
||||
let caps = EhloCapabilities::default();
|
||||
assert!(!caps.starttls);
|
||||
assert!(!caps.pipelining);
|
||||
assert!(!caps.eight_bit_mime);
|
||||
assert!(caps.auth_methods.is_empty());
|
||||
assert!(caps.max_size.is_none());
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub enum SmtpCommand {
|
||||
pub enum AuthMechanism {
|
||||
Plain,
|
||||
Login,
|
||||
ScramSha256,
|
||||
}
|
||||
|
||||
/// Errors that can occur during command parsing.
|
||||
@@ -218,6 +219,7 @@ fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||
"PLAIN" => AuthMechanism::Plain,
|
||||
"LOGIN" => AuthMechanism::Login,
|
||||
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
|
||||
other => {
|
||||
return Err(ParseError::SyntaxError(format!(
|
||||
"unsupported AUTH mechanism: {other}"
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-domain TLS certificate for SNI-based cert selection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsDomainCert {
|
||||
/// Domain names this certificate covers (matched against SNI hostname).
|
||||
pub domains: Vec<String>,
|
||||
/// Certificate chain in PEM format.
|
||||
pub cert_pem: String,
|
||||
/// Private key in PEM format.
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
/// Configuration for an SMTP server instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpServerConfig {
|
||||
@@ -11,10 +22,13 @@ pub struct SmtpServerConfig {
|
||||
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.
|
||||
/// TLS certificate chain in PEM format (default cert).
|
||||
pub tls_cert_pem: Option<String>,
|
||||
/// TLS private key in PEM format.
|
||||
/// TLS private key in PEM format (default key).
|
||||
pub tls_key_pem: Option<String>,
|
||||
/// Additional per-domain TLS certificates for SNI-based selection.
|
||||
#[serde(default)]
|
||||
pub additional_tls_certs: Vec<TlsDomainCert>,
|
||||
/// Maximum message size in bytes.
|
||||
pub max_message_size: u64,
|
||||
/// Maximum number of concurrent connections.
|
||||
@@ -43,6 +57,7 @@ impl Default for SmtpServerConfig {
|
||||
secure_port: None,
|
||||
tls_cert_pem: None,
|
||||
tls_key_pem: None,
|
||||
additional_tls_certs: Vec::new(),
|
||||
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||
max_connections: 100,
|
||||
max_recipients: 100,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig;
|
||||
use crate::data::{DataAccumulator, DataAction};
|
||||
use crate::rate_limiter::RateLimiter;
|
||||
use crate::response::{build_capabilities, SmtpResponse};
|
||||
use crate::scram::{ScramCredentials, ScramServer};
|
||||
use crate::session::{AuthState, SmtpSession};
|
||||
use crate::validation;
|
||||
|
||||
@@ -52,6 +53,13 @@ pub enum ConnectionEvent {
|
||||
password: String,
|
||||
remote_addr: String,
|
||||
},
|
||||
/// A SCRAM credential request — Rust needs stored credentials from TS.
|
||||
ScramCredentialRequest {
|
||||
correlation_id: String,
|
||||
session_id: String,
|
||||
username: String,
|
||||
remote_addr: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How email data is transported from Rust to TS.
|
||||
@@ -81,6 +89,16 @@ pub struct AuthResult {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of TS returning SCRAM credentials for a user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScramCredentialResult {
|
||||
pub found: bool,
|
||||
pub salt: Option<Vec<u8>>,
|
||||
pub iterations: Option<u32>,
|
||||
pub stored_key: Option<Vec<u8>>,
|
||||
pub server_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Abstraction over plain and TLS streams.
|
||||
pub enum SmtpStream {
|
||||
Plain(BufReader<TcpStream>),
|
||||
@@ -133,6 +151,14 @@ impl SmtpStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the internal buffer has unread data (pipelined commands).
|
||||
pub fn has_buffered_data(&self) -> bool {
|
||||
match self {
|
||||
SmtpStream::Plain(reader) => !reader.buffer().is_empty(),
|
||||
SmtpStream::Tls(reader) => !reader.buffer().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwrap to get the raw TcpStream for STARTTLS upgrade.
|
||||
/// Only works on Plain streams.
|
||||
pub fn into_tcp_stream(self) -> Option<TcpStream> {
|
||||
@@ -212,7 +238,7 @@ pub async fn handle_connection(
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
// Process command
|
||||
// Process the first command
|
||||
let response = process_line(
|
||||
&line,
|
||||
&mut session,
|
||||
@@ -227,59 +253,123 @@ pub async fn handle_connection(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Check for pipelined commands in the buffer.
|
||||
// Collect pipelinable responses into a batch for single write.
|
||||
let mut response_batch: Vec<u8> = Vec::new();
|
||||
let mut should_break = false;
|
||||
let mut starttls_signal = false;
|
||||
|
||||
match response {
|
||||
LineResult::Response(resp) => {
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
}
|
||||
LineResult::Quit(resp) => {
|
||||
let _ = stream.write_all(&resp.to_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
break;
|
||||
should_break = true;
|
||||
}
|
||||
LineResult::StartTlsSignal => {
|
||||
// Send 220 Ready response
|
||||
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
// Extract TCP stream and upgrade
|
||||
if let Some(tcp_stream) = stream.into_tcp_stream() {
|
||||
if let Some(acceptor) = &tls_acceptor {
|
||||
match acceptor.accept(tcp_stream).await {
|
||||
Ok(tls_stream) => {
|
||||
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
||||
session.secure = true;
|
||||
// Client must re-EHLO after STARTTLS
|
||||
session.state = crate::state::SmtpState::Connected;
|
||||
session.client_hostname = None;
|
||||
session.esmtp = false;
|
||||
session.auth_state = AuthState::None;
|
||||
session.envelope = Default::default();
|
||||
debug!(session_id = %session.id, "TLS upgrade successful");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Already TLS — shouldn't happen
|
||||
break;
|
||||
}
|
||||
starttls_signal = true;
|
||||
}
|
||||
LineResult::NoResponse => {}
|
||||
LineResult::Disconnect => {
|
||||
should_break = true;
|
||||
}
|
||||
}
|
||||
|
||||
if should_break {
|
||||
break;
|
||||
}
|
||||
|
||||
// Process additional pipelined commands from the buffer
|
||||
if !starttls_signal {
|
||||
while stream.has_buffered_data() {
|
||||
let mut next_line = String::new();
|
||||
match stream.read_line(&mut next_line, 4096).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
let next_response = process_line(
|
||||
&next_line,
|
||||
&mut session,
|
||||
&mut stream,
|
||||
&config,
|
||||
&rate_limiter,
|
||||
&event_tx,
|
||||
callback_register.as_ref(),
|
||||
&tls_acceptor,
|
||||
&authenticator,
|
||||
&resolver,
|
||||
)
|
||||
.await;
|
||||
|
||||
match next_response {
|
||||
LineResult::Response(resp) => {
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
}
|
||||
LineResult::Quit(resp) => {
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
should_break = true;
|
||||
break;
|
||||
}
|
||||
LineResult::StartTlsSignal | LineResult::Disconnect => {
|
||||
// Non-pipelinable: flush batch and handle
|
||||
starttls_signal = matches!(next_response, LineResult::StartTlsSignal);
|
||||
should_break = matches!(next_response, LineResult::Disconnect);
|
||||
break;
|
||||
}
|
||||
LineResult::NoResponse => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the accumulated response batch in one write
|
||||
if !response_batch.is_empty() {
|
||||
if stream.write_all(&response_batch).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if should_break {
|
||||
break;
|
||||
}
|
||||
|
||||
if starttls_signal {
|
||||
// Send 220 Ready response
|
||||
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
// Extract TCP stream and upgrade
|
||||
if let Some(tcp_stream) = stream.into_tcp_stream() {
|
||||
if let Some(acceptor) = &tls_acceptor {
|
||||
match acceptor.accept(tcp_stream).await {
|
||||
Ok(tls_stream) => {
|
||||
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
||||
session.secure = true;
|
||||
session.state = crate::state::SmtpState::Connected;
|
||||
session.client_hostname = None;
|
||||
session.esmtp = false;
|
||||
session.auth_state = AuthState::None;
|
||||
session.envelope = Default::default();
|
||||
debug!(session_id = %session.id, "TLS upgrade successful");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync {
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<AuthResult>;
|
||||
|
||||
/// Register a callback for SCRAM credential lookup and return a receiver.
|
||||
fn register_scram_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<ScramCredentialResult>;
|
||||
}
|
||||
|
||||
/// Process a single input line from the client.
|
||||
@@ -406,16 +502,29 @@ async fn process_line(
|
||||
mechanism,
|
||||
initial_response,
|
||||
} => {
|
||||
handle_auth(
|
||||
mechanism,
|
||||
initial_response,
|
||||
session,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
if matches!(mechanism, AuthMechanism::ScramSha256) {
|
||||
handle_auth_scram(
|
||||
initial_response,
|
||||
session,
|
||||
stream,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
handle_auth(
|
||||
mechanism,
|
||||
initial_response,
|
||||
session,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
SmtpCommand::Help(_) => {
|
||||
@@ -832,6 +941,217 @@ async fn handle_auth(
|
||||
))
|
||||
}
|
||||
}
|
||||
AuthMechanism::ScramSha256 => {
|
||||
// SCRAM is handled separately in process_line; this should not be reached.
|
||||
LineResult::Response(SmtpResponse::not_implemented())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle AUTH SCRAM-SHA-256 — full exchange in a single async function.
|
||||
///
|
||||
/// SCRAM is a multi-step challenge-response protocol:
|
||||
/// 1. Client sends client-first-message (in initial_response or after 334)
|
||||
/// 2. Server requests SCRAM credentials from TS
|
||||
/// 3. Server sends server-first-message (334 challenge)
|
||||
/// 4. Client sends client-final-message (proof)
|
||||
/// 5. Server verifies proof and responds with 235 or 535
|
||||
async fn handle_auth_scram(
|
||||
initial_response: Option<String>,
|
||||
session: &mut SmtpSession,
|
||||
stream: &mut SmtpStream,
|
||||
config: &SmtpServerConfig,
|
||||
rate_limiter: &RateLimiter,
|
||||
event_tx: &mpsc::Sender<ConnectionEvent>,
|
||||
callback_registry: &dyn CallbackRegistry,
|
||||
) -> LineResult {
|
||||
if !config.auth_enabled {
|
||||
return LineResult::Response(SmtpResponse::not_implemented());
|
||||
}
|
||||
|
||||
if session.is_authenticated() {
|
||||
return LineResult::Response(SmtpResponse::bad_sequence("Already authenticated"));
|
||||
}
|
||||
|
||||
if !session.state.can_auth() {
|
||||
return LineResult::Response(SmtpResponse::bad_sequence("Send EHLO first"));
|
||||
}
|
||||
|
||||
// Step 1: Get client-first-message
|
||||
let client_first_b64 = match initial_response {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => {
|
||||
// No initial response — send empty 334 challenge
|
||||
let resp = SmtpResponse::auth_challenge("");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
// Read client-first-message
|
||||
let mut line = String::new();
|
||||
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
|
||||
match timeout(socket_timeout, stream.read_line(&mut line, 4096)).await {
|
||||
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
let trimmed = line.trim().to_string();
|
||||
if trimmed == "*" {
|
||||
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
// Decode base64 client-first-message
|
||||
let client_first_bytes = match BASE64.decode(client_first_b64.as_bytes()) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
|
||||
}
|
||||
};
|
||||
let client_first = match String::from_utf8(client_first_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
|
||||
}
|
||||
};
|
||||
|
||||
// Parse client-first-message
|
||||
let mut scram = match ScramServer::from_client_first(&client_first) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "SCRAM client-first-message parse error");
|
||||
return LineResult::Response(SmtpResponse::param_error(
|
||||
"Invalid SCRAM client-first-message",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Request SCRAM credentials from TS
|
||||
let correlation_id = uuid::Uuid::new_v4().to_string();
|
||||
let rx = callback_registry.register_scram_callback(&correlation_id);
|
||||
|
||||
let event = ConnectionEvent::ScramCredentialRequest {
|
||||
correlation_id: correlation_id.clone(),
|
||||
session_id: session.id.clone(),
|
||||
username: scram.username.clone(),
|
||||
remote_addr: session.remote_addr.clone(),
|
||||
};
|
||||
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
|
||||
// Wait for credentials from TS
|
||||
let cred_timeout = Duration::from_secs(5);
|
||||
let cred_result = match timeout(cred_timeout, rx).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(_)) => {
|
||||
warn!(correlation_id = %correlation_id, "SCRAM credential callback dropped");
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(correlation_id = %correlation_id, "SCRAM credential request timed out");
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
};
|
||||
|
||||
if !cred_result.found {
|
||||
// User not found — fail auth (don't reveal that user doesn't exist)
|
||||
session.auth_state = AuthState::None;
|
||||
let exceeded = session.record_auth_failure(config.max_auth_failures);
|
||||
if exceeded {
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
return LineResult::Response(SmtpResponse::auth_failed());
|
||||
}
|
||||
|
||||
let creds = ScramCredentials {
|
||||
salt: cred_result.salt.unwrap_or_default(),
|
||||
iterations: cred_result.iterations.unwrap_or(4096),
|
||||
stored_key: cred_result.stored_key.unwrap_or_default(),
|
||||
server_key: cred_result.server_key.unwrap_or_default(),
|
||||
};
|
||||
|
||||
// Step 3: Generate and send server-first-message
|
||||
let server_first = scram.server_first_message(creds);
|
||||
let server_first_b64 = BASE64.encode(server_first.as_bytes());
|
||||
|
||||
let challenge = SmtpResponse::auth_challenge(&server_first_b64);
|
||||
if stream.write_all(&challenge.to_bytes()).await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
|
||||
// Step 4: Read client-final-message
|
||||
let mut client_final_line = String::new();
|
||||
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
|
||||
match timeout(socket_timeout, stream.read_line(&mut client_final_line, 4096)).await {
|
||||
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
|
||||
let client_final_b64 = client_final_line.trim();
|
||||
|
||||
// Cancel if *
|
||||
if client_final_b64 == "*" {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
|
||||
}
|
||||
|
||||
// Decode base64 client-final-message
|
||||
let client_final_bytes = match BASE64.decode(client_final_b64.as_bytes()) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
|
||||
}
|
||||
};
|
||||
let client_final = match String::from_utf8(client_final_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 5: Verify proof
|
||||
match scram.process_client_final(&client_final) {
|
||||
Ok(server_final) => {
|
||||
let server_final_b64 = BASE64.encode(server_final.as_bytes());
|
||||
session.auth_state = AuthState::Authenticated {
|
||||
username: scram.username.clone(),
|
||||
};
|
||||
LineResult::Response(SmtpResponse::new(
|
||||
235,
|
||||
format!("2.7.0 Authentication successful {}", server_final_b64),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "SCRAM proof verification failed");
|
||||
session.auth_state = AuthState::None;
|
||||
let exceeded = session.record_auth_failure(config.max_auth_failures);
|
||||
if exceeded {
|
||||
if !rate_limiter.check_auth_failure(&session.remote_addr) {
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
LineResult::Response(SmtpResponse::auth_failed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
//! - TCP/TLS server (`server`)
|
||||
//! - Connection handling (`connection`)
|
||||
|
||||
pub mod client;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod data;
|
||||
pub mod rate_limiter;
|
||||
pub mod response;
|
||||
pub mod scram;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn build_capabilities(
|
||||
caps.push("STARTTLS".to_string());
|
||||
}
|
||||
if auth_available {
|
||||
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||
caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string());
|
||||
}
|
||||
caps
|
||||
}
|
||||
@@ -253,7 +253,7 @@ mod tests {
|
||||
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(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ mod tests {
|
||||
// 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()));
|
||||
assert!(!caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
|
||||
//!
|
||||
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
|
||||
//! a challenge-response protocol that avoids transmitting cleartext passwords.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Pre-computed SCRAM credentials for a user (derived from password).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScramCredentials {
|
||||
pub salt: Vec<u8>,
|
||||
pub iterations: u32,
|
||||
pub stored_key: Vec<u8>,
|
||||
pub server_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Server-side SCRAM state machine.
|
||||
pub struct ScramServer {
|
||||
/// Username extracted from client-first-message.
|
||||
pub username: String,
|
||||
/// Full combined nonce (client + server).
|
||||
combined_nonce: String,
|
||||
/// Server nonce portion (used in tests for verification).
|
||||
#[allow(dead_code)]
|
||||
server_nonce: String,
|
||||
/// Stored credentials (set after TS responds).
|
||||
credentials: Option<ScramCredentials>,
|
||||
/// The server-first-message (for auth message construction).
|
||||
server_first: String,
|
||||
/// The client-first-message-bare (for auth message construction).
|
||||
client_first_bare: String,
|
||||
}
|
||||
|
||||
impl ScramServer {
|
||||
/// Process the client-first-message.
|
||||
///
|
||||
/// Parses the client nonce and username, generates a server nonce,
|
||||
/// and returns a partial state that needs credentials to produce the
|
||||
/// server-first-message.
|
||||
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
|
||||
// client-first-message = gs2-header client-first-message-bare
|
||||
// gs2-header = "n,," (no channel binding)
|
||||
// client-first-message-bare = "n=username,r=nonce"
|
||||
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
|
||||
rest
|
||||
} else if let Some(rest) = client_first.strip_prefix("y,,") {
|
||||
rest
|
||||
} else {
|
||||
return Err("Invalid SCRAM gs2-header".into());
|
||||
};
|
||||
|
||||
let mut username = String::new();
|
||||
let mut client_nonce = String::new();
|
||||
|
||||
for part in bare.split(',') {
|
||||
if let Some(val) = part.strip_prefix("n=") {
|
||||
username = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
client_nonce = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if username.is_empty() || client_nonce.is_empty() {
|
||||
return Err("Missing username or nonce in client-first-message".into());
|
||||
}
|
||||
|
||||
// Generate server nonce
|
||||
let server_nonce: String = (0..24)
|
||||
.map(|_| {
|
||||
let idx = (rand_byte() as usize) % 62;
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
|
||||
|
||||
Ok(ScramServer {
|
||||
username,
|
||||
combined_nonce,
|
||||
server_nonce,
|
||||
credentials: None,
|
||||
server_first: String::new(),
|
||||
client_first_bare: bare.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the credentials and produce the server-first-message.
|
||||
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
|
||||
let salt_b64 = BASE64.encode(&creds.salt);
|
||||
let server_first = format!(
|
||||
"r={},s={},i={}",
|
||||
self.combined_nonce, salt_b64, creds.iterations
|
||||
);
|
||||
|
||||
self.server_first = server_first.clone();
|
||||
self.credentials = Some(creds);
|
||||
server_first
|
||||
}
|
||||
|
||||
/// Process the client-final-message and verify the proof.
|
||||
///
|
||||
/// Returns the server-final-message (containing ServerSignature) on success,
|
||||
/// or an error string on failure.
|
||||
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
|
||||
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
|
||||
|
||||
// Parse client-final-message
|
||||
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
|
||||
let mut channel_binding = String::new();
|
||||
let mut nonce = String::new();
|
||||
let mut proof_b64 = String::new();
|
||||
|
||||
for part in client_final.split(',') {
|
||||
if let Some(val) = part.strip_prefix("c=") {
|
||||
channel_binding = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
nonce = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("p=") {
|
||||
proof_b64 = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify nonce matches
|
||||
if nonce != self.combined_nonce {
|
||||
return Err("Nonce mismatch".into());
|
||||
}
|
||||
|
||||
// Build the client-final-message-without-proof
|
||||
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
|
||||
|
||||
// Complete the auth message
|
||||
let auth_message = format!(
|
||||
"{},{},{}",
|
||||
self.client_first_bare, self.server_first, client_final_without_proof
|
||||
);
|
||||
|
||||
// Verify client proof
|
||||
let client_proof = BASE64.decode(proof_b64.as_bytes())
|
||||
.map_err(|_| "Invalid base64 in client proof")?;
|
||||
|
||||
// ClientSignature = HMAC(StoredKey, AuthMessage)
|
||||
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
|
||||
|
||||
// ClientKey = ClientProof XOR ClientSignature
|
||||
if client_proof.len() != client_signature.len() {
|
||||
return Err("Client proof length mismatch".into());
|
||||
}
|
||||
let client_key: Vec<u8> = client_proof
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let computed_stored_key = sha256(&client_key);
|
||||
|
||||
// Verify: computed StoredKey must match the stored StoredKey
|
||||
if computed_stored_key != creds.stored_key {
|
||||
return Err("Authentication failed".into());
|
||||
}
|
||||
|
||||
// Generate ServerSignature for mutual authentication
|
||||
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
|
||||
let server_sig_b64 = BASE64.encode(&server_signature);
|
||||
|
||||
Ok(format!("v={}", server_sig_b64))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
|
||||
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
|
||||
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
// ClientKey = HMAC(SaltedPassword, "Client Key")
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
// ServerKey = HMAC(SaltedPassword, "Server Key")
|
||||
let server_key = hmac_sha256(&salted_password, b"Server Key");
|
||||
|
||||
ScramCredentials {
|
||||
salt: salt.to_vec(),
|
||||
iterations,
|
||||
stored_key,
|
||||
server_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sha256(data: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Simple random byte using system randomness.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let state = RandomState::new();
|
||||
let mut hasher = state.build_hasher();
|
||||
hasher.write_u64(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64);
|
||||
hasher.finish() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scram_full_exchange() {
|
||||
let password = "pencil";
|
||||
let salt = b"test-salt-1234";
|
||||
let iterations = 4096;
|
||||
|
||||
// Pre-compute server-side credentials from password
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
// 1. Client sends client-first-message
|
||||
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
assert_eq!(server.username, "user");
|
||||
|
||||
// 2. Server responds with server-first-message
|
||||
let server_first = server.server_first_message(creds.clone());
|
||||
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
|
||||
assert!(server_first.contains("s="));
|
||||
assert!(server_first.contains("i=4096"));
|
||||
|
||||
// 3. Client computes proof
|
||||
// SaltedPassword
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
|
||||
// 4. Server verifies proof
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
|
||||
let server_final = result.unwrap();
|
||||
assert!(server_final.starts_with("v="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scram_wrong_password() {
|
||||
let password = "pencil";
|
||||
let wrong_password = "wrong";
|
||||
let salt = b"test-salt";
|
||||
let iterations = 4096;
|
||||
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
let client_first = "n,,n=user,r=clientnonce123";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
let server_first = server.server_first_message(creds);
|
||||
|
||||
// Client computes proof with wrong password
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
wrong_password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=clientnonce123";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scram_credentials() {
|
||||
let creds = compute_scram_credentials("password", b"salt", 4096);
|
||||
assert_eq!(creds.salt, b"salt");
|
||||
assert_eq!(creds.iterations, 4096);
|
||||
assert_eq!(creds.stored_key.len(), 32);
|
||||
assert_eq!(creds.server_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_client_first() {
|
||||
assert!(ScramServer::from_client_first("invalid").is_err());
|
||||
assert!(ScramServer::from_client_first("n,,").is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||
use hickory_resolver::TokioResolver;
|
||||
use mailer_security::MessageAuthenticator;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufReader;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -263,6 +264,69 @@ async fn accept_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// SNI-based certificate resolver that selects the appropriate TLS certificate
|
||||
/// based on the client's requested hostname.
|
||||
struct SniCertResolver {
|
||||
/// Domain -> certified key mapping.
|
||||
certs: HashMap<String, Arc<rustls::sign::CertifiedKey>>,
|
||||
/// Default certificate for non-matching SNI or missing SNI.
|
||||
default: Arc<rustls::sign::CertifiedKey>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SniCertResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SniCertResolver")
|
||||
.field("domains", &self.certs.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::server::ResolvesServerCert for SniCertResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
client_hello: rustls::server::ClientHello<'_>,
|
||||
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
if let Some(sni) = client_hello.server_name() {
|
||||
let sni_lower = sni.to_lowercase();
|
||||
if let Some(key) = self.certs.get(&sni_lower) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
Some(self.default.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PEM cert+key pair into a `CertifiedKey`.
|
||||
fn parse_certified_key(
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> Result<rustls::sign::CertifiedKey, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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());
|
||||
}
|
||||
|
||||
let key: PrivateKeyDer<'static> = {
|
||||
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||
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 signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
|
||||
}
|
||||
|
||||
/// Build a TLS acceptor from PEM cert/key strings.
|
||||
fn build_tls_acceptor(
|
||||
config: &SmtpServerConfig,
|
||||
@@ -311,9 +375,42 @@ fn build_tls_acceptor(
|
||||
.ok_or("No private key found in PEM")?
|
||||
};
|
||||
|
||||
let tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
// If additional TLS certs are configured, use SNI-based resolution
|
||||
let tls_config = if config.additional_tls_certs.is_empty() {
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?
|
||||
} else {
|
||||
// Build default certified key
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key));
|
||||
|
||||
// Build per-domain certs
|
||||
let mut domain_certs = HashMap::new();
|
||||
for domain_cert in &config.additional_tls_certs {
|
||||
match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) {
|
||||
Ok(ck) => {
|
||||
let ck = Arc::new(ck);
|
||||
for domain in &domain_cert.domains {
|
||||
domain_certs.insert(domain.to_lowercase(), ck.clone());
|
||||
}
|
||||
info!("SNI cert loaded for domains: {:?}", domain_cert.domains);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolver = SniCertResolver {
|
||||
certs: domain_certs,
|
||||
default: default_ck,
|
||||
};
|
||||
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(resolver))
|
||||
};
|
||||
|
||||
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get version from deno.json
|
||||
VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
|
||||
BINARY_DIR="dist/binaries"
|
||||
|
||||
echo "================================================"
|
||||
echo " MAILER Compilation Script"
|
||||
echo " Version: ${VERSION}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Compiling for all supported platforms..."
|
||||
echo ""
|
||||
|
||||
# Clean up old binaries and create fresh directory
|
||||
rm -rf "$BINARY_DIR"
|
||||
mkdir -p "$BINARY_DIR"
|
||||
echo "→ Cleaned old binaries from $BINARY_DIR"
|
||||
echo ""
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Compiling for Linux x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-x64" \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Compiling for Linux ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-arm64" \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ Compiling for macOS x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-x64" \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
echo " ✓ macOS x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ Compiling for macOS ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-arm64" \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
echo " ✓ macOS ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Compiling for Windows x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-windows-x64.exe" \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
echo " ✓ Windows x86_64 complete"
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo " Compilation Summary"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
ls -lh "$BINARY_DIR/" | tail -n +2
|
||||
echo ""
|
||||
echo "✓ All binaries compiled successfully!"
|
||||
echo ""
|
||||
echo "Binary location: $BINARY_DIR/"
|
||||
echo ""
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
hostname?: string;
|
||||
tlsEnabled?: boolean;
|
||||
authRequired?: boolean;
|
||||
timeout?: number;
|
||||
testCertPath?: string;
|
||||
testKeyPath?: string;
|
||||
maxConnections?: number;
|
||||
size?: number;
|
||||
maxRecipients?: number;
|
||||
}
|
||||
|
||||
export interface ITestServer {
|
||||
server: any;
|
||||
smtpServer: any;
|
||||
port: number;
|
||||
hostname: string;
|
||||
config: ITestServerConfig;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a test SMTP server with the given configuration.
|
||||
*
|
||||
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||
* which is not installed (pre-existing issue).
|
||||
*/
|
||||
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||
throw new Error(
|
||||
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a test SMTP server
|
||||
*/
|
||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
if (!testServer || !testServer.smtpServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||
await testServer.smtpServer.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping test server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an available port for testing
|
||||
*/
|
||||
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
||||
for (let port = startPort; port < startPort + 1000; port++) {
|
||||
if (await isPortFree(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available ports found starting from ${startPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
export function createTestEmail(options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: any[];
|
||||
} = {}): any {
|
||||
return {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html || '<p>This is a test email</p>',
|
||||
attachments: options.attachments || [],
|
||||
date: new Date(),
|
||||
messageId: `<${Date.now()}@test.example.com>`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple test server for custom protocol testing
|
||||
*/
|
||||
export interface ISimpleTestServer {
|
||||
server: any;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export async function createTestServer(options: {
|
||||
onConnection?: (socket: any) => void | Promise<void>;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}): Promise<ISimpleTestServer> {
|
||||
const hostname = options.hostname || 'localhost';
|
||||
const port = options.port || await getAvailablePort();
|
||||
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
if (options.onConnection) {
|
||||
const result = options.onConnection(socket);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch(error => {
|
||||
console.error('Error in onConnection handler:', error);
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
server,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { smtpClientMod } from '../../ts/mail/delivery/index.js';
|
||||
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../ts/mail/core/classes.email.js';
|
||||
|
||||
/**
|
||||
* Create a test SMTP client
|
||||
*/
|
||||
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
||||
const defaultOptions: ISmtpClientOptions = {
|
||||
host: options.host || 'localhost',
|
||||
port: options.port || 2525,
|
||||
secure: options.secure || false,
|
||||
auth: options.auth,
|
||||
connectionTimeout: options.connectionTimeout || 5000,
|
||||
socketTimeout: options.socketTimeout || 5000,
|
||||
maxConnections: options.maxConnections || 5,
|
||||
maxMessages: options.maxMessages || 100,
|
||||
debug: options.debug || false,
|
||||
tls: options.tls || {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
return smtpClientMod.createSmtpClient(defaultOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email using SMTP client
|
||||
*/
|
||||
export async function sendTestEmail(
|
||||
client: SmtpClient,
|
||||
options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
const mailOptions = {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: mailOptions.from,
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
text: mailOptions.text,
|
||||
html: mailOptions.html
|
||||
});
|
||||
return client.sendMail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP client connection
|
||||
*/
|
||||
export async function testClientConnection(
|
||||
host: string,
|
||||
port: number,
|
||||
timeout: number = 5000
|
||||
): Promise<boolean> {
|
||||
const client = createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
connectionTimeout: timeout
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.verify();
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (client.close) {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authenticated SMTP client
|
||||
*/
|
||||
export function createAuthenticatedClient(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
|
||||
): SmtpClient {
|
||||
return createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
method: authMethod
|
||||
},
|
||||
secure: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TLS-enabled SMTP client
|
||||
*/
|
||||
export function createTlsClient(
|
||||
host: string,
|
||||
port: number,
|
||||
options: {
|
||||
secure?: boolean;
|
||||
rejectUnauthorized?: boolean;
|
||||
} = {}
|
||||
): SmtpClient {
|
||||
return createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
secure: options.secure || false,
|
||||
tls: {
|
||||
rejectUnauthorized: options.rejectUnauthorized || false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test client pool status
|
||||
*/
|
||||
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
||||
if (typeof client.getPoolStatus === 'function') {
|
||||
return client.getPoolStatus();
|
||||
}
|
||||
|
||||
// Fallback for clients without pool status
|
||||
return {
|
||||
size: 1,
|
||||
available: 1,
|
||||
pending: 0,
|
||||
connecting: 0,
|
||||
active: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multiple emails concurrently
|
||||
*/
|
||||
export async function sendConcurrentEmails(
|
||||
client: SmtpClient,
|
||||
count: number,
|
||||
emailOptions: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
} = {}
|
||||
): Promise<any[]> {
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
promises.push(
|
||||
sendTestEmail(client, {
|
||||
...emailOptions,
|
||||
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure client throughput
|
||||
*/
|
||||
export async function measureClientThroughput(
|
||||
client: SmtpClient,
|
||||
duration: number = 10000,
|
||||
emailOptions: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
} = {}
|
||||
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
|
||||
const startTime = Date.now();
|
||||
let totalSent = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
while (Date.now() - startTime < duration) {
|
||||
try {
|
||||
await sendTestEmail(client, emailOptions);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
}
|
||||
totalSent++;
|
||||
}
|
||||
|
||||
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
||||
const throughput = totalSent / actualDuration;
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
successCount,
|
||||
errorCount,
|
||||
throughput
|
||||
};
|
||||
}
|
||||
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();
|
||||
@@ -1,195 +1,46 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
|
||||
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
|
||||
/**
|
||||
* Test email authentication systems: SPF and DMARC
|
||||
* Test email authentication systems: SPF parsing
|
||||
*/
|
||||
|
||||
// SPF Verifier Tests
|
||||
tap.test('SPF Verifier - should parse SPF record', async () => {
|
||||
const spfVerifier = new SpfVerifier();
|
||||
|
||||
|
||||
// Test valid SPF record parsing
|
||||
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
|
||||
const parsedRecord = spfVerifier.parseSpfRecord(record);
|
||||
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('spf1');
|
||||
expect(parsedRecord.mechanisms.length).toEqual(5);
|
||||
|
||||
|
||||
// Check specific mechanisms
|
||||
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
|
||||
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
|
||||
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
|
||||
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
|
||||
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
|
||||
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
|
||||
|
||||
|
||||
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
|
||||
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
|
||||
|
||||
|
||||
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
|
||||
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
|
||||
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-spf-record';
|
||||
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
// DMARC Verifier Tests
|
||||
tap.test('DMARC Verifier - should parse DMARC record', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
|
||||
// Test valid DMARC record parsing
|
||||
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
|
||||
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('DMARC1');
|
||||
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
|
||||
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
|
||||
expect(parsedRecord.pct).toEqual(50);
|
||||
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
|
||||
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
|
||||
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-dmarc-record';
|
||||
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
|
||||
// Test email domains with DMARC alignment
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC alignment',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test when both SPF and DKIM pass with alignment
|
||||
const dmarcResult = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'example.com', result: true }, // SPF - aligned and passed
|
||||
{ domain: 'example.com', result: true } // DKIM - aligned and passed
|
||||
);
|
||||
|
||||
expect(dmarcResult).toBeTruthy();
|
||||
expect(dmarcResult.spfPassed).toEqual(true);
|
||||
expect(dmarcResult.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult.spfDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.dkimDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.action).toEqual('pass');
|
||||
|
||||
// Test when neither SPF nor DKIM is aligned
|
||||
const dmarcResult2 = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
|
||||
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
|
||||
);
|
||||
|
||||
// Without a DNS manager, no DMARC record will be found
|
||||
|
||||
expect(dmarcResult2).toBeTruthy();
|
||||
expect(dmarcResult2.spfPassed).toEqual(true);
|
||||
expect(dmarcResult2.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
||||
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
||||
|
||||
// Without a DMARC record, the default action is 'pass'
|
||||
expect(dmarcResult2.hasDmarc).toEqual(false);
|
||||
expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE);
|
||||
expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE);
|
||||
expect(dmarcResult2.action).toEqual('pass');
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
|
||||
// Create test email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC policy application',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test pass action
|
||||
const passResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: true,
|
||||
dkimDomainAligned: true,
|
||||
spfPassed: true,
|
||||
dkimPassed: true,
|
||||
policyEvaluated: DmarcPolicy.NONE,
|
||||
actualPolicy: DmarcPolicy.NONE,
|
||||
appliedPercentage: 100,
|
||||
action: 'pass',
|
||||
details: 'DMARC passed'
|
||||
};
|
||||
|
||||
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
|
||||
expect(passApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(false);
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
|
||||
|
||||
// Test quarantine action
|
||||
const quarantineResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.QUARANTINE,
|
||||
actualPolicy: DmarcPolicy.QUARANTINE,
|
||||
appliedPercentage: 100,
|
||||
action: 'quarantine',
|
||||
details: 'DMARC failed, policy=quarantine'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
|
||||
expect(quarantineApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
expect(email.headers['X-Spam-Flag']).toEqual('YES');
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
|
||||
|
||||
// Test reject action
|
||||
const rejectResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.REJECT,
|
||||
actualPolicy: DmarcPolicy.REJECT,
|
||||
appliedPercentage: 100,
|
||||
action: 'reject',
|
||||
details: 'DMARC failed, policy=reject'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
|
||||
expect(rejectApplied).toEqual(false);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
295
test/test.mta.delivery.node.ts
Normal file
295
test/test.mta.delivery.node.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
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 * as net from 'net';
|
||||
import * as dns from 'dns';
|
||||
|
||||
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 bridgeAvailable = false;
|
||||
let mockSmtpServer: net.Server;
|
||||
|
||||
/**
|
||||
* Create a minimal mock SMTP server that accepts any email.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Store original resolveMx so we can restore it
|
||||
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
|
||||
|
||||
tap.test('setup - start server and mock SMTP', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10425],
|
||||
hostname: 'test.mta.local',
|
||||
domains: [
|
||||
{ domain: 'mta-test.com', dnsMode: 'forward' },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'mta-route',
|
||||
priority: 10,
|
||||
match: { recipients: '*@mta-test.com' },
|
||||
action: { type: 'deliver' },
|
||||
},
|
||||
{
|
||||
name: 'process-route',
|
||||
priority: 20,
|
||||
match: { recipients: '*@process-test.com' },
|
||||
action: {
|
||||
type: 'process',
|
||||
options: {
|
||||
contentScanning: true,
|
||||
scanners: [{ type: 'spam' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mockSmtpServer = await createMockSmtpServer(10426);
|
||||
});
|
||||
|
||||
tap.test('MX resolution for a public domain', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
|
||||
const resolver = new dns.promises.Resolver();
|
||||
try {
|
||||
const records = await resolver.resolveMx('gmail.com');
|
||||
expect(records).toBeTruthy();
|
||||
expect(records.length).toBeGreaterThan(0);
|
||||
// Each record should have exchange and priority
|
||||
for (const rec of records) {
|
||||
expect(typeof rec.exchange).toEqual('string');
|
||||
expect(typeof rec.priority).toEqual('number');
|
||||
}
|
||||
console.log(`Resolved ${records.length} MX records for gmail.com`);
|
||||
} catch (err) {
|
||||
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('group recipients by domain', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Test the grouping logic directly
|
||||
const recipients = [
|
||||
'alice@example.com',
|
||||
'bob@example.com',
|
||||
'carol@other.org',
|
||||
'dave@example.com',
|
||||
'eve@other.org',
|
||||
];
|
||||
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const rcpt of recipients) {
|
||||
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||||
if (!domain) continue;
|
||||
const list = groups.get(domain) || [];
|
||||
list.push(rcpt);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
|
||||
expect(groups.size).toEqual(2);
|
||||
expect(groups.get('example.com')!.length).toEqual(3);
|
||||
expect(groups.get('other.org')!.length).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
|
||||
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: 'recipient@target-domain.com',
|
||||
subject: 'MTA Delivery Test',
|
||||
text: 'Testing MTA delivery with MX resolution.',
|
||||
});
|
||||
|
||||
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
|
||||
// But the real test is the delivery system's handleMtaDelivery, which we test
|
||||
// by sending through the server's outbound path with the mock MX.
|
||||
|
||||
// Direct test: resolve MX then send
|
||||
const resolver = new dns.promises.Resolver();
|
||||
const mxRecords = await resolver.resolveMx('target-domain.com');
|
||||
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
|
||||
|
||||
// Send via the resolved MX host to the mock SMTP server on port 10425
|
||||
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
|
||||
// We test the sendOutboundEmail path directly with the mock MX host.
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted.length).toBeGreaterThan(0);
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
|
||||
// Restore original resolveMx
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
});
|
||||
|
||||
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: 'recipient@unreachable-domain.com',
|
||||
subject: 'Connection Refused MX Test',
|
||||
text: 'This should fail — no server at the target.',
|
||||
});
|
||||
|
||||
// Send to a port that nothing is listening on
|
||||
try {
|
||||
await server.sendOutboundEmail('127.0.0.1', 59789, email);
|
||||
throw new Error('Expected sendOutboundEmail to fail');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message.length).toBeGreaterThan(0);
|
||||
console.log(`Got expected error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('MTA delivery with multiple recipients across domains', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Mock MX to return 127.0.0.1 for all domains
|
||||
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: ['alice@domain-a.com', 'bob@domain-b.com'],
|
||||
subject: 'Multi-Domain MTA Test',
|
||||
text: 'Testing delivery to multiple domains.',
|
||||
});
|
||||
|
||||
// Send to each recipient's domain individually (simulating MTA behavior)
|
||||
for (const recipient of email.to) {
|
||||
const singleEmail = new Email({
|
||||
from: email.from,
|
||||
to: recipient,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
});
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
|
||||
expect(result.accepted.length).toEqual(1);
|
||||
}
|
||||
|
||||
// Restore original resolveMx
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
});
|
||||
|
||||
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Resolve real MX records for task.vc
|
||||
const resolver = new dns.promises.Resolver();
|
||||
const mxRecords = await resolver.resolveMx('task.vc');
|
||||
expect(mxRecords.length).toBeGreaterThan(0);
|
||||
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
|
||||
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
|
||||
|
||||
const email = new Email({
|
||||
from: 'test@mta-test.com',
|
||||
to: 'hello@task.vc',
|
||||
subject: `MTA E2E Test — ${new Date().toISOString()}`,
|
||||
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
|
||||
});
|
||||
|
||||
const result = await server.sendOutboundEmail(mxHost, 25, email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted).toBeTruthy();
|
||||
expect(result.accepted.length).toEqual(1);
|
||||
expect(result.accepted[0]).toEqual('hello@task.vc');
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server and mock SMTP', async () => {
|
||||
// Restore MX resolver in case it wasn't restored
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
|
||||
// Force-close mock server (destroy all open sockets)
|
||||
if (mockSmtpServer) {
|
||||
mockSmtpServer.close();
|
||||
}
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,141 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
|
||||
|
||||
tap.test('RateLimiter - should be instantiable', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
expect(limiter).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 5,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 5 requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 6th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 3 requests for key1
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.isAllowed('key1')).toEqual(true);
|
||||
}
|
||||
|
||||
// 4th request for key1 should be denied
|
||||
expect(limiter.isAllowed('key1')).toEqual(false);
|
||||
|
||||
// But key2 should still be allowed
|
||||
expect(limiter.isAllowed('key2')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should refill tokens over time', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100, // Short period for testing
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should support burst allowance', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100,
|
||||
perKey: true,
|
||||
burstTokens: 2, // Allow 2 extra tokens for bursts
|
||||
initialTokens: 4 // Start with max + burst tokens
|
||||
});
|
||||
|
||||
// Should allow 4 requests (2 regular + 2 burst)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 5th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
|
||||
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should return correct stats', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Make some requests
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
|
||||
// Get stats
|
||||
const stats = limiter.getStats('test');
|
||||
|
||||
expect(stats.remaining).toEqual(7);
|
||||
expect(stats.limit).toEqual(10);
|
||||
expect(stats.allowed).toEqual(3);
|
||||
expect(stats.denied).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should reset limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Reset
|
||||
limiter.reset('test');
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import type { IBridgeResilienceConfig } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
// Use fast backoff settings for testing
|
||||
const TEST_CONFIG: Partial<IBridgeResilienceConfig> = {
|
||||
maxRestartAttempts: 3,
|
||||
healthCheckIntervalMs: 60_000, // long interval so health checks don't interfere
|
||||
restartBackoffBaseMs: 100,
|
||||
restartBackoffMaxMs: 500,
|
||||
healthCheckTimeoutMs: 2_000,
|
||||
};
|
||||
|
||||
tap.test('Resilience - should start in Idle state', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
expect(bridge.state).toEqual(BridgeState.Idle);
|
||||
});
|
||||
|
||||
tap.test('Resilience - state transitions: Idle -> Starting -> Running', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
transitions.push(evt);
|
||||
});
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('WARNING: Rust binary not available — skipping resilience start tests');
|
||||
return;
|
||||
}
|
||||
|
||||
// We should have seen Idle -> Starting -> Running
|
||||
expect(transitions.length).toBeGreaterThanOrEqual(2);
|
||||
expect(transitions[0].oldState).toEqual(BridgeState.Idle);
|
||||
expect(transitions[0].newState).toEqual(BridgeState.Starting);
|
||||
expect(transitions[1].oldState).toEqual(BridgeState.Starting);
|
||||
expect(transitions[1].newState).toEqual(BridgeState.Running);
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
});
|
||||
|
||||
tap.test('Resilience - deliberate stop transitions to Stopped', async () => {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
transitions.push(evt);
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
// Deliberate stop should NOT trigger restart
|
||||
// Wait a bit to ensure no restart happens
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
bridge.removeAllListeners('stateChange');
|
||||
});
|
||||
|
||||
tap.test('Resilience - commands throw descriptive errors when not running', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
// Idle state
|
||||
try {
|
||||
await bridge.ping();
|
||||
expect(true).toBeFalse(); // Should not reach
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toInclude('not been started');
|
||||
}
|
||||
|
||||
// Stopped state
|
||||
const ok = await bridge.start();
|
||||
if (ok) {
|
||||
await bridge.stop();
|
||||
try {
|
||||
await bridge.ping();
|
||||
expect(true).toBeFalse();
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toInclude('stopped');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Resilience - restart after stop and fresh start', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('SKIP: Rust binary not available');
|
||||
return;
|
||||
}
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
|
||||
// Stop
|
||||
await bridge.stop();
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
// Start again
|
||||
const ok2 = await bridge.start();
|
||||
expect(ok2).toBeTrue();
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
|
||||
// Commands should work
|
||||
const pong = await bridge.ping();
|
||||
expect(pong).toBeTrue();
|
||||
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
tap.test('Resilience - stateChange events emitted correctly', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const events: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
events.push(evt);
|
||||
});
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('SKIP: Rust binary not available');
|
||||
return;
|
||||
}
|
||||
|
||||
await bridge.stop();
|
||||
|
||||
// Verify the full lifecycle: Idle->Starting->Running->Stopped
|
||||
const stateSequence = events.map(e => e.newState);
|
||||
expect(stateSequence).toContain(BridgeState.Starting);
|
||||
expect(stateSequence).toContain(BridgeState.Running);
|
||||
expect(stateSequence).toContain(BridgeState.Stopped);
|
||||
|
||||
bridge.removeAllListeners('stateChange');
|
||||
});
|
||||
|
||||
tap.test('Resilience - configure sets resilience parameters', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure({
|
||||
maxRestartAttempts: 10,
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
// Just verify no errors — config is private, but we can verify
|
||||
// by the behavior in other tests
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
expect(bridge).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('Resilience - resetInstance creates fresh singleton', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
const bridge1 = RustSecurityBridge.getInstance();
|
||||
RustSecurityBridge.resetInstance();
|
||||
const bridge2 = RustSecurityBridge.getInstance();
|
||||
// They should be different instances (we can't compare directly since
|
||||
// resetInstance nulls the static, and getInstance creates new)
|
||||
expect(bridge2.state).toEqual(BridgeState.Idle);
|
||||
});
|
||||
|
||||
tap.test('Resilience - cleanup', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,154 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
||||
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
|
||||
/**
|
||||
* Compatibility tests for the legacy SMTP client facade
|
||||
*/
|
||||
|
||||
tap.test('verify backward compatibility - client creation', async () => {
|
||||
// Create test configuration
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
connectionTimeout: 10000,
|
||||
domain: 'test.example.com'
|
||||
};
|
||||
|
||||
// Create SMTP client instance using legacy constructor
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Verify instance was created correctly
|
||||
expect(smtpClient).toBeTruthy();
|
||||
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
||||
});
|
||||
|
||||
tap.test('verify backward compatibility - methods exist', async () => {
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Verify all expected methods exist
|
||||
expect(typeof smtpClient.sendMail === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.verify === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.isConnected === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.getPoolStatus === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.updateOptions === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.close === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.on === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.off === 'function').toBeTruthy();
|
||||
expect(typeof smtpClient.emit === 'function').toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('verify backward compatibility - options update', async () => {
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Test option updates don't throw
|
||||
expect(() => smtpClient.updateOptions({
|
||||
host: 'new-smtp.example.com',
|
||||
port: 465,
|
||||
secure: true
|
||||
})).not.toThrow();
|
||||
|
||||
expect(() => smtpClient.updateOptions({
|
||||
debug: true,
|
||||
connectionTimeout: 5000
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
tap.test('verify backward compatibility - connection failure handling', async () => {
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'nonexistent.invalid.domain',
|
||||
port: 587,
|
||||
secure: false,
|
||||
connectionTimeout: 1000 // Short timeout for faster test
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// verify() should return false for invalid hosts
|
||||
const isValid = await smtpClient.verify();
|
||||
expect(isValid).toBeFalsy();
|
||||
|
||||
// sendMail should fail gracefully for invalid hosts
|
||||
const email = new Email({
|
||||
from: 'test@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Email',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result.success).toBeFalsy();
|
||||
expect(result.error).toBeTruthy();
|
||||
} catch (error) {
|
||||
// Connection errors are expected for invalid domains
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('verify backward compatibility - pool status', async () => {
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
pool: true,
|
||||
maxConnections: 5
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Get pool status
|
||||
const status = smtpClient.getPoolStatus();
|
||||
expect(status).toBeTruthy();
|
||||
expect(typeof status.total === 'number').toBeTruthy();
|
||||
expect(typeof status.active === 'number').toBeTruthy();
|
||||
expect(typeof status.idle === 'number').toBeTruthy();
|
||||
expect(typeof status.pending === 'number').toBeTruthy();
|
||||
|
||||
// Initially should have no connections
|
||||
expect(status.total).toEqual(0);
|
||||
expect(status.active).toEqual(0);
|
||||
expect(status.idle).toEqual(0);
|
||||
expect(status.pending).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('verify backward compatibility - event handling', async () => {
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Test event listener methods don't throw
|
||||
const testListener = () => {};
|
||||
|
||||
expect(() => smtpClient.on('test', testListener)).not.toThrow();
|
||||
expect(() => smtpClient.off('test', testListener)).not.toThrow();
|
||||
expect(() => smtpClient.emit('test')).not.toThrow();
|
||||
});
|
||||
|
||||
tap.test('clean up after compatibility tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
107
test/test.smtp.client.rust.node.ts
Normal file
107
test/test.smtp.client.rust.node.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import type { ISmtpSendOptions, ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
let bridge: RustSecurityBridge;
|
||||
|
||||
tap.test('Rust SMTP Client - should start bridge', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('WARNING: Rust binary not available — skipping Rust SMTP client tests');
|
||||
console.log('Build it with: cd rust && cargo build --release');
|
||||
}
|
||||
expect(typeof ok).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('Rust SMTP Client - getSmtpPoolStatus returns valid structure', async () => {
|
||||
if (!bridge.running) {
|
||||
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');
|
||||
});
|
||||
|
||||
tap.test('Rust SMTP Client - verifySmtpConnection with unreachable host', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Use a non-routable IP to test connection failure handling
|
||||
const result = await bridge.verifySmtpConnection({
|
||||
host: '192.0.2.1', // TEST-NET-1 (RFC 5737) - guaranteed unreachable
|
||||
port: 25,
|
||||
secure: false,
|
||||
domain: 'test.example.com',
|
||||
});
|
||||
// If it returns rather than throwing, reachable should be false
|
||||
expect(result.reachable).toBeFalse();
|
||||
} catch (err) {
|
||||
// Connection errors are expected for unreachable hosts
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message || String(err)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Rust SMTP Client - sendEmail with connection refused error', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const opts: ISmtpSendOptions = {
|
||||
host: '127.0.0.1',
|
||||
port: 52599, // random high port - should be refused
|
||||
secure: false,
|
||||
domain: 'test.example.com',
|
||||
email: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Test email',
|
||||
text: 'This is a test.',
|
||||
},
|
||||
connectionTimeoutSecs: 5,
|
||||
socketTimeoutSecs: 5,
|
||||
};
|
||||
|
||||
try {
|
||||
await bridge.sendOutboundEmail(opts);
|
||||
// Should not succeed — no server is listening
|
||||
throw new Error('Expected sendEmail to fail on connection refused');
|
||||
} catch (err) {
|
||||
// We expect a connection error
|
||||
expect(err).toBeTruthy();
|
||||
const msg = err.message || String(err);
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Rust SMTP Client - closeSmtpPool cleans up', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
// Should not throw, even when no pools exist
|
||||
await bridge.closeSmtpPool();
|
||||
|
||||
// Verify pool status is empty after close
|
||||
const status = await bridge.getSmtpPoolStatus();
|
||||
expect(status.pools).toBeTruthy();
|
||||
const poolKeys = Object.keys(status.pools);
|
||||
expect(poolKeys.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Rust SMTP Client - stop bridge', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
await bridge.stop();
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,191 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
||||
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
|
||||
/**
|
||||
* Tests for the SMTP client class
|
||||
*/
|
||||
tap.test('verify SMTP client initialization', async () => {
|
||||
// Create test configuration
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
connectionTimeout: 10000,
|
||||
domain: 'test.example.com'
|
||||
};
|
||||
|
||||
// Create SMTP client instance
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Verify instance was created correctly
|
||||
expect(smtpClient).toBeTruthy();
|
||||
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
||||
});
|
||||
|
||||
tap.test('test SMTP client configuration update', async () => {
|
||||
// Create test configuration
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false
|
||||
};
|
||||
|
||||
// Create SMTP client instance
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Update configuration
|
||||
smtpClient.updateOptions({
|
||||
host: 'new-smtp.example.com',
|
||||
port: 465,
|
||||
secure: true
|
||||
});
|
||||
|
||||
// Can't directly test private fields, but we can verify it doesn't throw
|
||||
expect(() => smtpClient.updateOptions({
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
// Mocked SMTP server for testing
|
||||
class MockSmtpServer {
|
||||
private responses: Map<string, string>;
|
||||
|
||||
constructor() {
|
||||
this.responses = new Map();
|
||||
|
||||
// Default responses
|
||||
this.responses.set('connect', '220 smtp.example.com ESMTP ready');
|
||||
this.responses.set('EHLO', '250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 HELP');
|
||||
this.responses.set('MAIL FROM', '250 OK');
|
||||
this.responses.set('RCPT TO', '250 OK');
|
||||
this.responses.set('DATA', '354 Start mail input; end with <CRLF>.<CRLF>');
|
||||
this.responses.set('data content', '250 OK: message accepted');
|
||||
this.responses.set('QUIT', '221 Bye');
|
||||
}
|
||||
|
||||
public setResponse(command: string, response: string): void {
|
||||
this.responses.set(command, response);
|
||||
}
|
||||
|
||||
public getResponse(command: string): string {
|
||||
if (command.startsWith('MAIL FROM')) {
|
||||
return this.responses.get('MAIL FROM') || '250 OK';
|
||||
} else if (command.startsWith('RCPT TO')) {
|
||||
return this.responses.get('RCPT TO') || '250 OK';
|
||||
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
||||
return this.responses.get('EHLO') || '250 OK';
|
||||
} else if (command === 'DATA') {
|
||||
return this.responses.get('DATA') || '354 Start mail input; end with <CRLF>.<CRLF>';
|
||||
} else if (command.includes('Content-Type')) {
|
||||
return this.responses.get('data content') || '250 OK: message accepted';
|
||||
} else if (command === 'QUIT') {
|
||||
return this.responses.get('QUIT') || '221 Bye';
|
||||
}
|
||||
|
||||
return this.responses.get(command) || '250 OK';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This test validates the SMTP client public interface
|
||||
*/
|
||||
tap.test('verify SMTP client email delivery functionality with mock', async () => {
|
||||
// Create a test email
|
||||
const testEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Test Email',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Create SMTP client options
|
||||
const options: ISmtpClientOptions = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
domain: 'test.example.com',
|
||||
auth: {
|
||||
user: 'testuser',
|
||||
pass: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
// Create SMTP client instance
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Test public methods exist and have correct signatures
|
||||
expect(typeof smtpClient.sendMail).toEqual('function');
|
||||
expect(typeof smtpClient.verify).toEqual('function');
|
||||
expect(typeof smtpClient.isConnected).toEqual('function');
|
||||
expect(typeof smtpClient.getPoolStatus).toEqual('function');
|
||||
expect(typeof smtpClient.updateOptions).toEqual('function');
|
||||
expect(typeof smtpClient.close).toEqual('function');
|
||||
|
||||
// Test connection status before any operation
|
||||
expect(smtpClient.isConnected()).toBeFalsy();
|
||||
|
||||
// Test pool status
|
||||
const poolStatus = smtpClient.getPoolStatus();
|
||||
expect(poolStatus).toBeTruthy();
|
||||
expect(typeof poolStatus.active).toEqual('number');
|
||||
expect(typeof poolStatus.idle).toEqual('number');
|
||||
expect(typeof poolStatus.total).toEqual('number');
|
||||
|
||||
// Since we can't connect to a real server, we'll skip the actual send test
|
||||
// and just verify the client was created correctly
|
||||
expect(smtpClient).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('test SMTP client error handling with mock', async () => {
|
||||
// Create SMTP client instance
|
||||
const smtpClient = smtpClientMod.createSmtpClient({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with valid email (Email class might allow any string)
|
||||
const testEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Test Email',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test event listener methods
|
||||
const mockListener = () => {};
|
||||
smtpClient.on('test-event', mockListener);
|
||||
smtpClient.off('test-event', mockListener);
|
||||
|
||||
// Test update options
|
||||
smtpClient.updateOptions({
|
||||
auth: {
|
||||
user: 'newuser',
|
||||
pass: 'newpass'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify client is still functional
|
||||
expect(smtpClient.isConnected()).toBeFalsy();
|
||||
|
||||
// Test close on a non-connected client
|
||||
await smtpClient.close();
|
||||
expect(smtpClient.isConnected()).toBeFalsy();
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '2.3.2',
|
||||
version: '5.1.1',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* MTA error classes for SMTP client operations
|
||||
*/
|
||||
|
||||
export class MtaConnectionError extends Error {
|
||||
public code: string;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any) {
|
||||
super(message);
|
||||
this.name = 'MtaConnectionError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
} else {
|
||||
this.code = 'CONNECTION_ERROR';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError {
|
||||
return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT');
|
||||
}
|
||||
static refused(host: string, port: number): MtaConnectionError {
|
||||
return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED');
|
||||
}
|
||||
static dnsError(host: string, err?: any): MtaConnectionError {
|
||||
const errMsg = typeof err === 'string' ? err : err?.message || '';
|
||||
return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export class MtaAuthenticationError extends Error {
|
||||
public code: string;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any) {
|
||||
super(message);
|
||||
this.name = 'MtaAuthenticationError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
} else {
|
||||
this.code = 'AUTH_ERROR';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
static invalidCredentials(host?: string, user?: string): MtaAuthenticationError {
|
||||
const detail = host && user ? `${user}@${host}` : host || user || '';
|
||||
return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS');
|
||||
}
|
||||
}
|
||||
|
||||
export class MtaDeliveryError extends Error {
|
||||
public code: string;
|
||||
public responseCode?: number;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any, responseCode?: number) {
|
||||
super(message);
|
||||
this.name = 'MtaDeliveryError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
this.responseCode = responseCode;
|
||||
} else {
|
||||
this.code = 'DELIVERY_ERROR';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
static temporary(message: string, ...args: any[]): MtaDeliveryError {
|
||||
return new MtaDeliveryError(message, 'TEMPORARY');
|
||||
}
|
||||
static permanent(message: string, ...args: any[]): MtaDeliveryError {
|
||||
return new MtaDeliveryError(message, 'PERMANENT');
|
||||
}
|
||||
}
|
||||
|
||||
export class MtaConfigurationError extends Error {
|
||||
public code: string;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any) {
|
||||
super(message);
|
||||
this.name = 'MtaConfigurationError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
} else {
|
||||
this.code = 'CONFIG_ERROR';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MtaTimeoutError extends Error {
|
||||
public code: string;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any) {
|
||||
super(message);
|
||||
this.name = 'MtaTimeoutError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
} else {
|
||||
this.code = 'TIMEOUT';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError {
|
||||
const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs;
|
||||
return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT');
|
||||
}
|
||||
}
|
||||
|
||||
export class MtaProtocolError extends Error {
|
||||
public code: string;
|
||||
public details?: any;
|
||||
constructor(message: string, detailsOrCode?: any) {
|
||||
super(message);
|
||||
this.name = 'MtaProtocolError';
|
||||
if (typeof detailsOrCode === 'string') {
|
||||
this.code = detailsOrCode;
|
||||
} else {
|
||||
this.code = 'PROTOCOL_ERROR';
|
||||
this.details = detailsOrCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { logger } from '../../logger.js';
|
||||
import { type EmailProcessingMode } from '../routing/classes.email.config.js';
|
||||
import { type EmailProcessingMode } from './interfaces.js';
|
||||
import type { IEmailRoute } from '../routing/interfaces.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
const dns = plugins.dns;
|
||||
|
||||
/**
|
||||
* Delivery status enumeration
|
||||
*/
|
||||
@@ -117,7 +116,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
* Create a new multi-mode delivery system
|
||||
* @param queue Unified delivery queue
|
||||
* @param options Delivery options
|
||||
* @param emailServer Optional reference to unified email server for SmtpClient access
|
||||
* @param emailServer Optional reference to unified email server for outbound delivery
|
||||
*/
|
||||
constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions, emailServer?: UnifiedEmailServer) {
|
||||
super();
|
||||
@@ -433,228 +432,169 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
*/
|
||||
private async handleForwardDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `Forward delivery for item ${item.id}`);
|
||||
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const route = item.route;
|
||||
|
||||
|
||||
// Get target server information
|
||||
const targetServer = route?.action.forward?.host;
|
||||
const targetPort = route?.action.forward?.port || 25;
|
||||
const useTls = false; // TLS configuration can be enhanced later
|
||||
|
||||
|
||||
if (!targetServer) {
|
||||
throw new Error('No target server configured for forward mode');
|
||||
}
|
||||
|
||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
|
||||
|
||||
|
||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}`);
|
||||
|
||||
try {
|
||||
// Get SMTP client from email server if available
|
||||
if (!this.emailServer) {
|
||||
// Fall back to raw socket implementation if no email server
|
||||
logger.log('warn', 'No email server available, falling back to raw socket implementation');
|
||||
return this.handleForwardDeliveryLegacy(item);
|
||||
throw new Error('No email server available for forward delivery');
|
||||
}
|
||||
|
||||
// Get SMTP client from UnifiedEmailServer
|
||||
const smtpClient = this.emailServer.getSmtpClient(targetServer, targetPort);
|
||||
|
||||
// Apply DKIM signing if configured in the route
|
||||
if (item.route?.action.options?.mtaOptions?.dkimSign) {
|
||||
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
|
||||
}
|
||||
|
||||
// Send the email using SmtpClient
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
if (result.success) {
|
||||
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
||||
|
||||
return {
|
||||
targetServer: targetServer,
|
||||
targetPort: targetPort,
|
||||
recipients: result.acceptedRecipients.length,
|
||||
messageId: result.messageId,
|
||||
rejectedRecipients: result.rejectedRecipients
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.error?.message || 'Failed to forward email');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy forward delivery using raw sockets (fallback)
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleForwardDeliveryLegacy(item: IQueueItem): Promise<any> {
|
||||
const email = item.processingResult as Email;
|
||||
const route = item.route;
|
||||
|
||||
// Get target server information
|
||||
const targetServer = route?.action.forward?.host;
|
||||
const targetPort = route?.action.forward?.port || 25;
|
||||
const useTls = false; // TLS configuration can be enhanced later
|
||||
|
||||
if (!targetServer) {
|
||||
throw new Error('No target server configured for forward mode');
|
||||
}
|
||||
|
||||
// Create a socket connection to the target server
|
||||
const socket = new net.Socket();
|
||||
|
||||
// Set timeout
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
try {
|
||||
// Connect to the target server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Handle connection events
|
||||
socket.on('connect', () => {
|
||||
logger.log('debug', `Connected to ${targetServer}:${targetPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
reject(new Error(`Connection timeout to ${targetServer}:${targetPort}`));
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`Connection error to ${targetServer}:${targetPort}: ${err.message}`));
|
||||
});
|
||||
|
||||
// Connect to the server
|
||||
socket.connect({
|
||||
host: targetServer,
|
||||
port: targetPort
|
||||
});
|
||||
|
||||
// Build DKIM options from route config
|
||||
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
||||
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
|
||||
: undefined;
|
||||
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
||||
|
||||
// Build auth options from route forward config
|
||||
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
||||
|
||||
// Send via Rust SMTP client
|
||||
const result = await this.emailServer.sendOutboundEmail(targetServer, targetPort, email, {
|
||||
auth,
|
||||
dkimDomain,
|
||||
dkimSelector,
|
||||
});
|
||||
|
||||
// Send EHLO
|
||||
await this.smtpCommand(socket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Start TLS if required
|
||||
if (useTls) {
|
||||
await this.smtpCommand(socket, 'STARTTLS');
|
||||
|
||||
// Upgrade to TLS
|
||||
const tlsSocket = await this.upgradeTls(socket, targetServer);
|
||||
|
||||
// Send EHLO again after STARTTLS
|
||||
await this.smtpCommand(tlsSocket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Use tlsSocket for remaining commands
|
||||
return this.completeSMTPExchange(tlsSocket, email, route);
|
||||
}
|
||||
|
||||
// Complete the SMTP exchange
|
||||
return this.completeSMTPExchange(socket, email, route);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the SMTP exchange after connection and initial setup
|
||||
* @param socket Network socket
|
||||
* @param email Email to send
|
||||
* @param rule Domain rule
|
||||
*/
|
||||
private async completeSMTPExchange(socket: net.Socket | tls.TLSSocket, email: Email, route: any): Promise<any> {
|
||||
try {
|
||||
// Authenticate if credentials provided
|
||||
if (route?.action?.forward?.auth?.user && route?.action?.forward?.auth?.pass) {
|
||||
// Send AUTH LOGIN
|
||||
await this.smtpCommand(socket, 'AUTH LOGIN');
|
||||
|
||||
// Send username (base64)
|
||||
const username = Buffer.from(route.action.forward.auth.user).toString('base64');
|
||||
await this.smtpCommand(socket, username);
|
||||
|
||||
// Send password (base64)
|
||||
const password = Buffer.from(route.action.forward.auth.pass).toString('base64');
|
||||
await this.smtpCommand(socket, password);
|
||||
}
|
||||
|
||||
// Send MAIL FROM
|
||||
await this.smtpCommand(socket, `MAIL FROM:<${email.from}>`);
|
||||
|
||||
// Send RCPT TO for each recipient
|
||||
for (const recipient of email.getAllRecipients()) {
|
||||
await this.smtpCommand(socket, `RCPT TO:<${recipient}>`);
|
||||
}
|
||||
|
||||
// Send DATA
|
||||
await this.smtpCommand(socket, 'DATA');
|
||||
|
||||
// Send email content (simplified)
|
||||
const emailContent = await this.getFormattedEmail(email);
|
||||
await this.smtpData(socket, emailContent);
|
||||
|
||||
// Send QUIT
|
||||
await this.smtpCommand(socket, 'QUIT');
|
||||
|
||||
// Close the connection
|
||||
socket.end();
|
||||
|
||||
logger.log('info', `Email forwarded successfully to ${route?.action?.forward?.host}:${route?.action?.forward?.port || 25}`);
|
||||
|
||||
|
||||
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
||||
|
||||
return {
|
||||
targetServer: route?.action?.forward?.host,
|
||||
targetPort: route?.action?.forward?.port || 25,
|
||||
recipients: email.getAllRecipients().length
|
||||
targetServer,
|
||||
targetPort,
|
||||
recipients: result.accepted.length,
|
||||
messageId: result.messageId,
|
||||
rejectedRecipients: result.rejected,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain, sorted by priority (lowest first).
|
||||
* Falls back to the domain itself as an A record per RFC 5321.
|
||||
*/
|
||||
private async resolveMxForDomain(domain: string): Promise<Array<{ exchange: string; priority: number }>> {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
try {
|
||||
const mxRecords = await resolver.resolveMx(domain);
|
||||
return mxRecords.sort((a, b) => a.priority - b.priority);
|
||||
} catch (err) {
|
||||
logger.log('warn', `No MX records for ${domain}, falling back to A record`);
|
||||
return [{ exchange: domain, priority: 0 }];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group recipient addresses by their domain part.
|
||||
*/
|
||||
private groupRecipientsByDomain(recipients: string[]): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const rcpt of recipients) {
|
||||
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||||
if (!domain) continue;
|
||||
const list = groups.get(domain) || [];
|
||||
list.push(rcpt);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for MTA mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleMtaDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `MTA delivery for item ${item.id}`);
|
||||
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const route = item.route;
|
||||
|
||||
try {
|
||||
// Apply DKIM signing if configured in the route
|
||||
if (item.route?.action.options?.mtaOptions?.dkimSign) {
|
||||
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
|
||||
}
|
||||
|
||||
// In a full implementation, this would use the MTA service
|
||||
// For now, we'll simulate a successful delivery
|
||||
|
||||
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
|
||||
|
||||
// Note: The MTA implementation would handle actual local delivery
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
||||
throw error;
|
||||
|
||||
if (!this.emailServer) {
|
||||
throw new Error('No email server available for MTA delivery');
|
||||
}
|
||||
|
||||
// Build DKIM options from route config
|
||||
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
||||
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
|
||||
: undefined;
|
||||
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
||||
|
||||
const allRecipients = email.getAllRecipients();
|
||||
if (allRecipients.length === 0) {
|
||||
throw new Error('No recipients specified for MTA delivery');
|
||||
}
|
||||
|
||||
const domainGroups = this.groupRecipientsByDomain(allRecipients);
|
||||
const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
|
||||
|
||||
for (const [domain, recipients] of domainGroups) {
|
||||
const mxHosts = await this.resolveMxForDomain(domain);
|
||||
let delivered = false;
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const mx of mxHosts) {
|
||||
try {
|
||||
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
||||
|
||||
// Create a temporary Email scoped to this domain's recipients
|
||||
const domainEmail = new Email({
|
||||
from: email.from,
|
||||
to: recipients.filter(r => email.to.includes(r)),
|
||||
cc: recipients.filter(r => (email.cc || []).includes(r)),
|
||||
bcc: recipients.filter(r => (email.bcc || []).includes(r)),
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
|
||||
const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
|
||||
dkimDomain,
|
||||
dkimSelector,
|
||||
});
|
||||
|
||||
results.push({
|
||||
domain,
|
||||
success: true,
|
||||
accepted: result.accepted,
|
||||
rejected: result.rejected,
|
||||
});
|
||||
delivered = true;
|
||||
logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
lastError = err.message;
|
||||
logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!delivered) {
|
||||
results.push({ domain, success: false, error: lastError });
|
||||
logger.log('error', `MTA: all MX hosts failed for ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const allFailed = results.every(r => !r.success);
|
||||
if (allFailed) {
|
||||
const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
|
||||
throw new Error(`MTA delivery failed for all domains: ${summary}`);
|
||||
}
|
||||
|
||||
return {
|
||||
recipients: allRecipients.length,
|
||||
domainResults: results,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -726,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
|
||||
}
|
||||
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
scanned: !!route?.action.options?.contentScanning,
|
||||
transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0),
|
||||
dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim)
|
||||
};
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
|
||||
|
||||
// After scanning + transformations, deliver via MTA
|
||||
return await this.handleMtaDelivery(item);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email: ${error.message}`);
|
||||
throw error;
|
||||
@@ -790,210 +724,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format email for SMTP transmission
|
||||
* @param email Email to format
|
||||
*/
|
||||
private async getFormattedEmail(email: Email): Promise<string> {
|
||||
// This is a simplified implementation
|
||||
// In a full implementation, this would use proper MIME formatting
|
||||
|
||||
let content = '';
|
||||
|
||||
// Add headers
|
||||
content += `From: ${email.from}\r\n`;
|
||||
content += `To: ${email.to.join(', ')}\r\n`;
|
||||
content += `Subject: ${email.subject}\r\n`;
|
||||
|
||||
// Add additional headers
|
||||
for (const [name, value] of Object.entries(email.headers || {})) {
|
||||
content += `${name}: ${value}\r\n`;
|
||||
}
|
||||
|
||||
// Add content type for multipart
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`;
|
||||
content += `MIME-Version: 1.0\r\n`;
|
||||
content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add text part
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
|
||||
// Add HTML part if present
|
||||
if (email.html) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/html; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.html}\r\n`;
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of email.attachments) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`;
|
||||
content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
content += `Content-Transfer-Encoding: base64\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add base64 encoded content
|
||||
const base64Content = attachment.content.toString('base64');
|
||||
|
||||
// Split into lines of 76 characters
|
||||
for (let i = 0; i < base64Content.length; i += 76) {
|
||||
content += base64Content.substring(i, i + 76) + '\r\n';
|
||||
}
|
||||
}
|
||||
|
||||
// End boundary
|
||||
content += `--${boundary}--\r\n`;
|
||||
} else {
|
||||
// Simple email with just text
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP command and wait for response
|
||||
* @param socket Socket connection
|
||||
* @param command SMTP command to send
|
||||
*/
|
||||
private async smtpCommand(socket: net.Socket, command: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (data: Buffer) => {
|
||||
const response = data.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2' || response.charAt(0) === '3') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP command timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send command
|
||||
socket.write(command + '\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP DATA command with content
|
||||
* @param socket Socket connection
|
||||
* @param data Email content to send
|
||||
*/
|
||||
private async smtpData(socket: net.Socket, data: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (responseData: Buffer) => {
|
||||
const response = responseData.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP data timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send data and end with CRLF.CRLF
|
||||
socket.write(data + '\r\n.\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade socket to TLS
|
||||
* @param socket Socket connection
|
||||
* @param hostname Target hostname for TLS
|
||||
*/
|
||||
private async upgradeTls(socket: net.Socket, hostname: string): Promise<tls.TLSSocket> {
|
||||
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
socket,
|
||||
servername: hostname,
|
||||
rejectUnauthorized: this.options.verifyCertificates,
|
||||
minVersion: this.options.tlsMinVersion as tls.SecureVersion
|
||||
};
|
||||
|
||||
const tlsSocket = tls.connect(tlsOptions);
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
resolve(tlsSocket);
|
||||
});
|
||||
|
||||
tlsSocket.once('error', (err) => {
|
||||
reject(new Error(`TLS error: ${err.message}`));
|
||||
});
|
||||
|
||||
tlsSocket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
tlsSocket.once('timeout', () => {
|
||||
reject(new Error('TLS connection timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery time statistics
|
||||
*/
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import { EmailSignJob } from './classes.emailsignjob.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||
import type { ISmtpSendResult } from './smtpclient/interfaces.js';
|
||||
|
||||
// Configuration options for email sending
|
||||
export interface IEmailSendOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number; // in milliseconds
|
||||
connectionTimeout?: number; // in milliseconds
|
||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// Email delivery status
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
SENDING = 'sending',
|
||||
DELIVERED = 'delivered',
|
||||
FAILED = 'failed',
|
||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
||||
}
|
||||
|
||||
// Detailed information about delivery attempts
|
||||
export interface DeliveryInfo {
|
||||
status: DeliveryStatus;
|
||||
attempts: number;
|
||||
error?: Error;
|
||||
lastAttempt?: Date;
|
||||
nextAttempt?: Date;
|
||||
mxServer?: string;
|
||||
deliveryTime?: Date;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export class EmailSendJob {
|
||||
emailServerRef: UnifiedEmailServer;
|
||||
private email: Email;
|
||||
private mxServers: string[] = [];
|
||||
private currentMxIndex = 0;
|
||||
private options: IEmailSendOptions;
|
||||
public deliveryInfo: DeliveryInfo;
|
||||
|
||||
constructor(emailServerRef: UnifiedEmailServer, emailArg: Email, options: IEmailSendOptions = {}) {
|
||||
this.email = emailArg;
|
||||
this.emailServerRef = emailServerRef;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
maxRetries: options.maxRetries || 3,
|
||||
retryDelay: options.retryDelay || 30000, // 30 seconds
|
||||
connectionTimeout: options.connectionTimeout || 60000, // 60 seconds
|
||||
tlsOptions: options.tlsOptions || {},
|
||||
debugMode: options.debugMode || false
|
||||
};
|
||||
|
||||
// Initialize delivery info
|
||||
this.deliveryInfo = {
|
||||
status: DeliveryStatus.PENDING,
|
||||
attempts: 0,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email to its recipients
|
||||
*/
|
||||
async send(): Promise<DeliveryStatus> {
|
||||
try {
|
||||
// Check if the email is valid before attempting to send
|
||||
this.validateEmail();
|
||||
|
||||
// Resolve MX records for the recipient domain
|
||||
await this.resolveMxRecords();
|
||||
|
||||
// Try to send the email
|
||||
return await this.attemptDelivery();
|
||||
} catch (error) {
|
||||
this.log(`Critical error in send process: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for potential future retry or analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email before sending
|
||||
*/
|
||||
private validateEmail(): void {
|
||||
if (!this.email.to || this.email.to.length === 0) {
|
||||
throw new Error('No recipients specified');
|
||||
}
|
||||
|
||||
if (!this.email.from) {
|
||||
throw new Error('No sender specified');
|
||||
}
|
||||
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (!fromDomain) {
|
||||
throw new Error('Invalid sender domain');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for the recipient domain
|
||||
*/
|
||||
private async resolveMxRecords(): Promise<void> {
|
||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
||||
if (!domain) {
|
||||
throw new Error('Invalid recipient domain');
|
||||
}
|
||||
|
||||
this.log(`Resolving MX records for domain: ${domain}`);
|
||||
try {
|
||||
const addresses = await this.resolveMx(domain);
|
||||
|
||||
// Sort by priority (lowest number = highest priority)
|
||||
addresses.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
this.mxServers = addresses.map(mx => mx.exchange);
|
||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
||||
|
||||
if (this.mxServers.length === 0) {
|
||||
throw new Error(`No MX records found for domain: ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to deliver the email with retries
|
||||
*/
|
||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.deliveryInfo.attempts++;
|
||||
this.deliveryInfo.lastAttempt = new Date();
|
||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
||||
|
||||
try {
|
||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
||||
|
||||
// Try each MX server in order of priority
|
||||
while (this.currentMxIndex < this.mxServers.length) {
|
||||
const currentMx = this.mxServers[this.currentMxIndex];
|
||||
this.deliveryInfo.mxServer = currentMx;
|
||||
|
||||
try {
|
||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
||||
await this.connectAndSend(currentMx);
|
||||
|
||||
// If we get here, email was sent successfully
|
||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
||||
this.deliveryInfo.deliveryTime = new Date();
|
||||
this.log(`Email delivered successfully to ${currentMx}`);
|
||||
|
||||
// Record delivery for sender reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
|
||||
// Save successful email record
|
||||
await this.saveSuccess();
|
||||
return DeliveryStatus.DELIVERED;
|
||||
} catch (error) {
|
||||
this.log(`Failed to deliver to ${currentMx}: ${error.message}`);
|
||||
this.currentMxIndex++;
|
||||
|
||||
// If this MX server failed, try the next one
|
||||
if (this.currentMxIndex >= this.mxServers.length) {
|
||||
throw error; // No more MX servers to try
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('All MX servers failed');
|
||||
} catch (error) {
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Check if this is a permanent failure
|
||||
if (this.isPermanentFailure(error)) {
|
||||
this.log('Permanent failure detected, not retrying');
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
|
||||
// Record permanent failure for bounce management
|
||||
this.recordDeliveryEvent('bounced', true);
|
||||
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// This is a temporary failure
|
||||
if (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`);
|
||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
||||
this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay);
|
||||
|
||||
// Record temporary failure for monitoring
|
||||
this.recordDeliveryEvent('deferred');
|
||||
|
||||
// Reset MX server index for next retry
|
||||
this.currentMxIndex = 0;
|
||||
|
||||
// Wait before retrying
|
||||
await this.delay(this.options.retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a specific MX server and send the email using SmtpClient
|
||||
*/
|
||||
private async connectAndSend(mxServer: string): Promise<void> {
|
||||
this.log(`Connecting to ${mxServer}:25`);
|
||||
|
||||
try {
|
||||
// Check if IP warmup is enabled and get an IP to use
|
||||
let localAddress: string | undefined = undefined;
|
||||
try {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
const bestIP = this.emailServerRef.getBestIPForSending({
|
||||
from: this.email.from,
|
||||
to: this.email.getAllRecipients(),
|
||||
domain: fromDomain,
|
||||
isTransactional: this.email.priority === 'high'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
||||
localAddress = bestIP;
|
||||
|
||||
// Record the send for warm-up tracking
|
||||
this.emailServerRef.recordIPSend(bestIP);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Error selecting IP address: ${error.message}`);
|
||||
}
|
||||
|
||||
// Get SMTP client from UnifiedEmailServer
|
||||
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
||||
|
||||
// Sign the email with DKIM if available
|
||||
let signedEmail = this.email;
|
||||
try {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) {
|
||||
// Convert email to RFC822 format for signing
|
||||
const emailMessage = this.email.toRFC822String();
|
||||
|
||||
// Create sign job with proper options
|
||||
const emailSignJob = new EmailSignJob(this.emailServerRef, {
|
||||
domain: fromDomain,
|
||||
selector: 'default', // Using default selector
|
||||
headers: {}, // Headers will be extracted from emailMessage
|
||||
body: emailMessage
|
||||
});
|
||||
|
||||
// Get the DKIM signature header
|
||||
const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage);
|
||||
|
||||
// Add the signature to the email
|
||||
if (signatureHeader) {
|
||||
// For now, we'll use the email as-is since SmtpClient will handle DKIM
|
||||
this.log(`Email ready for DKIM signing for domain: ${fromDomain}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to prepare DKIM: ${error.message}`);
|
||||
}
|
||||
|
||||
// Send the email using SmtpClient
|
||||
const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail);
|
||||
|
||||
if (result.success) {
|
||||
this.log(`Email sent successfully: ${result.response}`);
|
||||
|
||||
// Record the send for reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
} else {
|
||||
throw new Error(result.error?.message || 'Failed to send email');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to send email via ${mxServer}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record delivery event for monitoring
|
||||
*/
|
||||
private recordDeliveryEvent(
|
||||
eventType: 'delivered' | 'bounced' | 'deferred',
|
||||
isHardBounce: boolean = false
|
||||
): void {
|
||||
try {
|
||||
const domain = this.email.getFromDomain();
|
||||
if (domain) {
|
||||
if (eventType === 'delivered') {
|
||||
this.emailServerRef.recordDelivery(domain);
|
||||
} else if (eventType === 'bounced') {
|
||||
// Get the receiving domain for bounce recording
|
||||
let receivingDomain = null;
|
||||
const primaryRecipient = this.email.getPrimaryRecipient();
|
||||
if (primaryRecipient) {
|
||||
receivingDomain = primaryRecipient.split('@')[1];
|
||||
}
|
||||
|
||||
if (receivingDomain) {
|
||||
this.emailServerRef.recordBounce(
|
||||
domain,
|
||||
receivingDomain,
|
||||
isHardBounce ? 'hard' : 'soft',
|
||||
this.deliveryInfo.error?.message || 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to record delivery event: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error represents a permanent failure
|
||||
*/
|
||||
private isPermanentFailure(error: Error): boolean {
|
||||
const permanentFailurePatterns = [
|
||||
'User unknown',
|
||||
'No such user',
|
||||
'Mailbox not found',
|
||||
'Invalid recipient',
|
||||
'Account disabled',
|
||||
'Account suspended',
|
||||
'Domain not found',
|
||||
'No such domain',
|
||||
'Invalid domain',
|
||||
'Relay access denied',
|
||||
'Access denied',
|
||||
'Blacklisted',
|
||||
'Blocked',
|
||||
'550', // Permanent failure SMTP code
|
||||
'551',
|
||||
'552',
|
||||
'553',
|
||||
'554'
|
||||
];
|
||||
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
return permanentFailurePatterns.some(pattern =>
|
||||
errorMessage.includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain
|
||||
*/
|
||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses || []);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message with timestamp
|
||||
*/
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
this.deliveryInfo.logs.push(logEntry);
|
||||
|
||||
if (this.options.debugMode) {
|
||||
console.log(`[EmailSendJob] ${logEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save successful email to storage
|
||||
*/
|
||||
private async saveSuccess(): Promise<void> {
|
||||
try {
|
||||
// Use the existing email storage path
|
||||
const emailContent = this.email.toRFC822String();
|
||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
|
||||
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
|
||||
|
||||
await plugins.smartfs.directory(paths.sentEmailsDir).recursive().create();
|
||||
await plugins.smartfs.file(filePath).write(emailContent);
|
||||
|
||||
// Also save delivery info
|
||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
|
||||
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
||||
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||||
|
||||
this.log(`Email saved to ${fileName}`);
|
||||
} catch (error) {
|
||||
this.log(`Failed to save email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save failed email to storage
|
||||
*/
|
||||
private async saveFailed(): Promise<void> {
|
||||
try {
|
||||
// Use the existing email storage path
|
||||
const emailContent = this.email.toRFC822String();
|
||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
|
||||
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
|
||||
|
||||
await plugins.smartfs.directory(paths.failedEmailsDir).recursive().create();
|
||||
await plugins.smartfs.file(filePath).write(emailContent);
|
||||
|
||||
// Also save delivery info with error details
|
||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
|
||||
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
||||
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||||
|
||||
this.log(`Failed email saved to ${fileName}`);
|
||||
} catch (error) {
|
||||
this.log(`Failed to save failed email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay for specified milliseconds
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
interface Headers {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface IEmailSignJobOptions {
|
||||
domain: string;
|
||||
selector: string;
|
||||
headers: Headers;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class EmailSignJob {
|
||||
emailServerRef: UnifiedEmailServer;
|
||||
jobOptions: IEmailSignJobOptions;
|
||||
|
||||
constructor(emailServerRef: UnifiedEmailServer, options: IEmailSignJobOptions) {
|
||||
this.emailServerRef = emailServerRef;
|
||||
this.jobOptions = options;
|
||||
}
|
||||
|
||||
async loadPrivateKey(): Promise<string> {
|
||||
const keyInfo = await this.emailServerRef.dkimCreator.readDKIMKeys(this.jobOptions.domain);
|
||||
return keyInfo.privateKey;
|
||||
}
|
||||
|
||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||
const privateKey = await this.loadPrivateKey();
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const signResult = await bridge.signDkim({
|
||||
rawMessage: emailMessage,
|
||||
domain: this.jobOptions.domain,
|
||||
selector: this.jobOptions.selector,
|
||||
privateKey,
|
||||
});
|
||||
return signResult.header;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
|
||||
/**
|
||||
* Configures email server storage settings
|
||||
* @param emailServer Reference to the unified email server
|
||||
* @param options Configuration options containing storage paths
|
||||
*/
|
||||
export async function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): Promise<void> {
|
||||
// Extract the receivedEmailsPath if available
|
||||
if (options?.emailPortConfig?.receivedEmailsPath) {
|
||||
const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath;
|
||||
|
||||
// Ensure the directory exists
|
||||
await plugins.smartfs.directory(receivedEmailsPath).recursive().create();
|
||||
|
||||
// Set path for received emails
|
||||
if (emailServer) {
|
||||
// Storage paths are now handled by the unified email server system
|
||||
await plugins.smartfs.directory(paths.receivedEmailsDir).recursive().create();
|
||||
|
||||
console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure email server with port and storage settings
|
||||
* @param emailServer Reference to the unified email server
|
||||
* @param config Configuration settings for email server
|
||||
*/
|
||||
export async function configureEmailServer(
|
||||
emailServer: UnifiedEmailServer,
|
||||
config: {
|
||||
ports?: number[];
|
||||
hostname?: string;
|
||||
tls?: {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
caPath?: string;
|
||||
};
|
||||
storagePath?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (!emailServer) {
|
||||
console.error('Email server not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure the email server with updated options
|
||||
const serverOptions = {
|
||||
ports: config.ports || [25, 587, 465],
|
||||
hostname: config.hostname || 'localhost',
|
||||
tls: config.tls
|
||||
};
|
||||
|
||||
// Update the email server options
|
||||
emailServer.updateOptions(serverOptions);
|
||||
|
||||
console.log(`Configured email server on ports ${serverOptions.ports.join(', ')}`);
|
||||
|
||||
// Set up storage path if provided
|
||||
if (config.storagePath) {
|
||||
await configureEmailStorage(emailServer, {
|
||||
emailPortConfig: {
|
||||
receivedEmailsPath: config.storagePath
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for rate limiter
|
||||
*/
|
||||
export interface IRateLimitConfig {
|
||||
/** Maximum tokens per period */
|
||||
maxPerPeriod: number;
|
||||
|
||||
/** Time period in milliseconds */
|
||||
periodMs: number;
|
||||
|
||||
/** Whether to apply per domain/key (vs globally) */
|
||||
perKey: boolean;
|
||||
|
||||
/** Initial token count (defaults to max) */
|
||||
initialTokens?: number;
|
||||
|
||||
/** Grace tokens to allow occasional bursts */
|
||||
burstTokens?: number;
|
||||
|
||||
/** Apply global limit in addition to per-key limits */
|
||||
useGlobalLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bucket for an individual key
|
||||
*/
|
||||
interface TokenBucket {
|
||||
/** Current number of tokens */
|
||||
tokens: number;
|
||||
|
||||
/** Last time tokens were refilled */
|
||||
lastRefill: number;
|
||||
|
||||
/** Total allowed requests */
|
||||
allowed: number;
|
||||
|
||||
/** Total denied requests */
|
||||
denied: number;
|
||||
|
||||
/** Error count for blocking decisions */
|
||||
errors: number;
|
||||
|
||||
/** Timestamp of first error in current window */
|
||||
firstErrorTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter using token bucket algorithm
|
||||
* Provides more sophisticated rate limiting with burst handling
|
||||
*/
|
||||
export class RateLimiter {
|
||||
/** Rate limit configuration */
|
||||
private config: IRateLimitConfig;
|
||||
|
||||
/** Token buckets per key */
|
||||
private buckets: Map<string, TokenBucket> = new Map();
|
||||
|
||||
/** Global bucket for non-keyed rate limiting */
|
||||
private globalBucket: TokenBucket;
|
||||
|
||||
/**
|
||||
* Create a new rate limiter
|
||||
* @param config Rate limiter configuration
|
||||
*/
|
||||
constructor(config: IRateLimitConfig) {
|
||||
// Set defaults
|
||||
this.config = {
|
||||
maxPerPeriod: config.maxPerPeriod,
|
||||
periodMs: config.periodMs,
|
||||
perKey: config.perKey ?? true,
|
||||
initialTokens: config.initialTokens ?? config.maxPerPeriod,
|
||||
burstTokens: config.burstTokens ?? 0,
|
||||
useGlobalLimit: config.useGlobalLimit ?? false
|
||||
};
|
||||
|
||||
// Initialize global bucket
|
||||
this.globalBucket = {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0,
|
||||
errors: 0,
|
||||
firstErrorTime: 0
|
||||
};
|
||||
|
||||
// Log initialization
|
||||
logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed under rate limits
|
||||
* @param key Key to check rate limit for (e.g. domain, user, IP)
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether the request is allowed
|
||||
*/
|
||||
public isAllowed(key: string = 'global', cost: number = 1): boolean {
|
||||
// If using global bucket directly, just check that
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
return this.checkBucket(this.globalBucket, cost);
|
||||
}
|
||||
|
||||
// Get the key-specific bucket
|
||||
const bucket = this.getBucket(key);
|
||||
|
||||
// If we also need to check global limit
|
||||
if (this.config.useGlobalLimit) {
|
||||
// Both key bucket and global bucket must have tokens
|
||||
return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost);
|
||||
} else {
|
||||
// Only need to check the key-specific bucket
|
||||
return this.checkBucket(bucket, cost);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bucket has enough tokens and consume them
|
||||
* @param bucket The token bucket to check
|
||||
* @param cost Token cost
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
private checkBucket(bucket: TokenBucket, cost: number): boolean {
|
||||
// Refill tokens based on elapsed time
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Check if we have enough tokens
|
||||
if (bucket.tokens >= cost) {
|
||||
// Use tokens
|
||||
bucket.tokens -= cost;
|
||||
bucket.allowed++;
|
||||
return true;
|
||||
} else {
|
||||
// Rate limit exceeded
|
||||
bucket.denied++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume tokens for a request (if available)
|
||||
* @param key Key to consume tokens for
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
public consume(key: string = 'global', cost: number = 1): boolean {
|
||||
const isAllowed = this.isAllowed(key, cost);
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remaining tokens for a key
|
||||
* @param key Key to check
|
||||
* @returns Number of remaining tokens
|
||||
*/
|
||||
public getRemainingTokens(key: string = 'global'): number {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
return bucket.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific key
|
||||
* @param key Key to get stats for
|
||||
* @returns Rate limit statistics
|
||||
*/
|
||||
public getStats(key: string = 'global'): {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
resetIn: number;
|
||||
allowed: number;
|
||||
denied: number;
|
||||
} {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Calculate time until next token
|
||||
const resetIn = bucket.tokens < this.config.maxPerPeriod ?
|
||||
Math.ceil(this.config.periodMs / this.config.maxPerPeriod) :
|
||||
0;
|
||||
|
||||
return {
|
||||
remaining: bucket.tokens,
|
||||
limit: this.config.maxPerPeriod,
|
||||
resetIn,
|
||||
allowed: bucket.allowed,
|
||||
denied: bucket.denied
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a token bucket for a key
|
||||
* @param key The rate limit key
|
||||
* @returns Token bucket
|
||||
*/
|
||||
private getBucket(key: string): TokenBucket {
|
||||
if (!this.config.perKey || key === 'global') {
|
||||
return this.globalBucket;
|
||||
}
|
||||
|
||||
if (!this.buckets.has(key)) {
|
||||
// Create new bucket
|
||||
this.buckets.set(key, {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0,
|
||||
errors: 0,
|
||||
firstErrorTime: 0
|
||||
});
|
||||
}
|
||||
|
||||
return this.buckets.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refill tokens in a bucket based on elapsed time
|
||||
* @param bucket Token bucket to refill
|
||||
*/
|
||||
private refillBucket(bucket: TokenBucket): void {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bucket.lastRefill;
|
||||
|
||||
// Calculate how many tokens to add
|
||||
const rate = this.config.maxPerPeriod / this.config.periodMs;
|
||||
const tokensToAdd = elapsedMs * rate;
|
||||
|
||||
if (tokensToAdd >= 0.1) { // Allow for partial token refills
|
||||
// Add tokens, but don't exceed the normal maximum (without burst)
|
||||
// This ensures burst tokens are only used for bursts and don't refill
|
||||
const normalMax = this.config.maxPerPeriod;
|
||||
bucket.tokens = Math.min(
|
||||
// Don't exceed max + burst
|
||||
this.config.maxPerPeriod + (this.config.burstTokens || 0),
|
||||
// Don't exceed normal max when refilling
|
||||
Math.min(normalMax, bucket.tokens + tokensToAdd)
|
||||
);
|
||||
|
||||
// Update last refill time
|
||||
bucket.lastRefill = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limits for a specific key
|
||||
* @param key Key to reset
|
||||
*/
|
||||
public reset(key: string = 'global'): void {
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
} else if (this.buckets.has(key)) {
|
||||
const bucket = this.buckets.get(key);
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all rate limiters
|
||||
*/
|
||||
public resetAll(): void {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
|
||||
for (const bucket of this.buckets.values()) {
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old buckets to prevent memory leaks
|
||||
* @param maxAge Maximum age in milliseconds
|
||||
*/
|
||||
public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [key, bucket] of this.buckets.entries()) {
|
||||
if (now - bucket.lastRefill > maxAge) {
|
||||
this.buckets.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error for a key (e.g., IP address) and determine if blocking is needed
|
||||
* RFC 5321 Section 4.5.4.1 suggests limiting errors to prevent abuse
|
||||
*
|
||||
* @param key Key to record error for (typically an IP address)
|
||||
* @param errorWindow Time window for error tracking in ms (default: 5 minutes)
|
||||
* @param errorThreshold Maximum errors before blocking (default: 10)
|
||||
* @returns true if the key should be blocked due to excessive errors
|
||||
*/
|
||||
public recordError(key: string, errorWindow: number = 5 * 60 * 1000, errorThreshold: number = 10): boolean {
|
||||
const bucket = this.getBucket(key);
|
||||
const now = Date.now();
|
||||
|
||||
// Reset error count if the time window has expired
|
||||
if (bucket.firstErrorTime === 0 || now - bucket.firstErrorTime > errorWindow) {
|
||||
bucket.errors = 0;
|
||||
bucket.firstErrorTime = now;
|
||||
}
|
||||
|
||||
// Increment error count
|
||||
bucket.errors++;
|
||||
|
||||
// Log error tracking
|
||||
logger.log('debug', `Error recorded for ${key}: ${bucket.errors}/${errorThreshold} in window`);
|
||||
|
||||
// Check if threshold exceeded
|
||||
if (bucket.errors >= errorThreshold) {
|
||||
logger.log('warn', `Error threshold exceeded for ${key}: ${bucket.errors} errors`);
|
||||
return true; // Should block
|
||||
}
|
||||
|
||||
return false; // Continue allowing
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,5 @@
|
||||
// Email delivery components
|
||||
export * from './classes.emailsignjob.js';
|
||||
export * from './classes.delivery.queue.js';
|
||||
export * from './classes.delivery.system.js';
|
||||
|
||||
// Handle exports with naming conflicts
|
||||
export { EmailSendJob } from './classes.emailsendjob.js';
|
||||
export { DeliveryStatus } from './classes.delivery.system.js';
|
||||
|
||||
// Rate limiter exports - fix naming conflict
|
||||
export { RateLimiter } from './classes.ratelimiter.js';
|
||||
export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
||||
|
||||
// Unified rate limiter
|
||||
export * from './classes.unified.rate.limiter.js';
|
||||
|
||||
// SMTP client and configuration
|
||||
export * from './classes.mta.config.js';
|
||||
|
||||
// Import and export SMTP modules as namespaces to avoid conflicts
|
||||
import * as smtpClientMod from './smtpclient/index.js';
|
||||
|
||||
export { smtpClientMod };
|
||||
@@ -2,8 +2,6 @@
|
||||
* SMTP and email delivery interface definitions
|
||||
*/
|
||||
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
|
||||
/**
|
||||
* SMTP session state enumeration
|
||||
*/
|
||||
@@ -167,125 +165,3 @@ export interface ISmtpAuth {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
export interface ISmtpServerOptions {
|
||||
/**
|
||||
* Port to listen on
|
||||
*/
|
||||
port: number;
|
||||
|
||||
/**
|
||||
* TLS private key (PEM format)
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* TLS certificate (PEM format)
|
||||
*/
|
||||
cert: string;
|
||||
|
||||
/**
|
||||
* Server hostname for SMTP banner
|
||||
*/
|
||||
hostname?: string;
|
||||
|
||||
/**
|
||||
* Host address to bind to (defaults to all interfaces)
|
||||
*/
|
||||
host?: string;
|
||||
|
||||
/**
|
||||
* Secure port for dedicated TLS connections
|
||||
*/
|
||||
securePort?: number;
|
||||
|
||||
/**
|
||||
* CA certificates for TLS (PEM format)
|
||||
*/
|
||||
ca?: string;
|
||||
|
||||
/**
|
||||
* Maximum size of messages in bytes
|
||||
*/
|
||||
maxSize?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of concurrent connections
|
||||
*/
|
||||
maxConnections?: number;
|
||||
|
||||
/**
|
||||
* Authentication options
|
||||
*/
|
||||
auth?: {
|
||||
/**
|
||||
* Whether authentication is required
|
||||
*/
|
||||
required: boolean;
|
||||
|
||||
/**
|
||||
* Allowed authentication methods
|
||||
*/
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
|
||||
*/
|
||||
socketTimeout?: number;
|
||||
|
||||
/**
|
||||
* Initial connection timeout in milliseconds (default: 30 seconds / 30000ms)
|
||||
*/
|
||||
connectionTimeout?: number;
|
||||
|
||||
/**
|
||||
* Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms)
|
||||
* For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly
|
||||
*/
|
||||
cleanupInterval?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of recipients allowed per message (default: 100)
|
||||
*/
|
||||
maxRecipients?: number;
|
||||
|
||||
/**
|
||||
* Maximum message size in bytes (default: 10MB / 10485760 bytes)
|
||||
* This is advertised in the EHLO SIZE extension
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute)
|
||||
* This controls how long to wait for the complete email data
|
||||
*/
|
||||
dataTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of SMTP transaction
|
||||
*/
|
||||
export interface ISmtpTransactionResult {
|
||||
/**
|
||||
* Whether the transaction was successful
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Error message if failed
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* Message ID if successful
|
||||
*/
|
||||
messageId?: string;
|
||||
|
||||
/**
|
||||
* Resulting email if successful
|
||||
*/
|
||||
email?: Email;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Authentication Handler
|
||||
* Authentication mechanisms implementation
|
||||
*/
|
||||
|
||||
import { AUTH_METHODS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpAuthOptions,
|
||||
ISmtpClientOptions,
|
||||
ISmtpResponse,
|
||||
IOAuth2Options
|
||||
} from './interfaces.js';
|
||||
import {
|
||||
encodeAuthPlain,
|
||||
encodeAuthLogin,
|
||||
generateOAuth2String,
|
||||
isSuccessCode
|
||||
} from './utils/helpers.js';
|
||||
import { logAuthentication, logDebug } from './utils/logging.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
|
||||
export class AuthHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
private commandHandler: CommandHandler;
|
||||
|
||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
||||
this.options = options;
|
||||
this.commandHandler = commandHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using the configured method
|
||||
*/
|
||||
public async authenticate(connection: ISmtpConnection): Promise<void> {
|
||||
if (!this.options.auth) {
|
||||
logDebug('No authentication configured', this.options);
|
||||
return;
|
||||
}
|
||||
|
||||
const authOptions = this.options.auth;
|
||||
const capabilities = connection.capabilities;
|
||||
|
||||
if (!capabilities || capabilities.authMethods.size === 0) {
|
||||
throw new Error('Server does not support authentication');
|
||||
}
|
||||
|
||||
// Determine authentication method
|
||||
const method = this.selectAuthMethod(authOptions, capabilities.authMethods);
|
||||
|
||||
logAuthentication('start', method, this.options);
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case AUTH_METHODS.PLAIN:
|
||||
await this.authenticatePlain(connection, authOptions);
|
||||
break;
|
||||
case AUTH_METHODS.LOGIN:
|
||||
await this.authenticateLogin(connection, authOptions);
|
||||
break;
|
||||
case AUTH_METHODS.OAUTH2:
|
||||
await this.authenticateOAuth2(connection, authOptions);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported authentication method: ${method}`);
|
||||
}
|
||||
|
||||
logAuthentication('success', method, this.options);
|
||||
} catch (error) {
|
||||
logAuthentication('failure', method, this.options, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using AUTH PLAIN
|
||||
*/
|
||||
private async authenticatePlain(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.user || !auth.pass) {
|
||||
throw new Error('Username and password required for PLAIN authentication');
|
||||
}
|
||||
|
||||
const credentials = encodeAuthPlain(auth.user, auth.pass);
|
||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.PLAIN, credentials);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`PLAIN authentication failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using AUTH LOGIN
|
||||
*/
|
||||
private async authenticateLogin(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.user || !auth.pass) {
|
||||
throw new Error('Username and password required for LOGIN authentication');
|
||||
}
|
||||
|
||||
// Step 1: Send AUTH LOGIN
|
||||
let response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.LOGIN);
|
||||
|
||||
if (response.code !== 334) {
|
||||
throw new Error(`LOGIN authentication initiation failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Step 2: Send username
|
||||
const encodedUser = encodeAuthLogin(auth.user);
|
||||
response = await this.commandHandler.sendCommand(connection, encodedUser);
|
||||
|
||||
if (response.code !== 334) {
|
||||
throw new Error(`LOGIN username failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Send password
|
||||
const encodedPass = encodeAuthLogin(auth.pass);
|
||||
response = await this.commandHandler.sendCommand(connection, encodedPass);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`LOGIN password failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using OAuth2
|
||||
*/
|
||||
private async authenticateOAuth2(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.oauth2) {
|
||||
throw new Error('OAuth2 configuration required for OAUTH2 authentication');
|
||||
}
|
||||
|
||||
let accessToken = auth.oauth2.accessToken;
|
||||
|
||||
// Refresh token if needed
|
||||
if (!accessToken || this.isTokenExpired(auth.oauth2)) {
|
||||
accessToken = await this.refreshOAuth2Token(auth.oauth2);
|
||||
}
|
||||
|
||||
const authString = generateOAuth2String(auth.oauth2.user, accessToken);
|
||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.OAUTH2, authString);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`OAUTH2 authentication failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate authentication method
|
||||
*/
|
||||
private selectAuthMethod(auth: ISmtpAuthOptions, serverMethods: Set<string>): string {
|
||||
// If method is explicitly specified, use it
|
||||
if (auth.method && auth.method !== 'AUTO') {
|
||||
const method = auth.method === 'OAUTH2' ? AUTH_METHODS.OAUTH2 : auth.method;
|
||||
if (serverMethods.has(method)) {
|
||||
return method;
|
||||
}
|
||||
throw new Error(`Requested authentication method ${auth.method} not supported by server`);
|
||||
}
|
||||
|
||||
// Auto-select based on available credentials and server support
|
||||
if (auth.oauth2 && serverMethods.has(AUTH_METHODS.OAUTH2)) {
|
||||
return AUTH_METHODS.OAUTH2;
|
||||
}
|
||||
|
||||
if (auth.user && auth.pass) {
|
||||
// Prefer PLAIN over LOGIN for simplicity
|
||||
if (serverMethods.has(AUTH_METHODS.PLAIN)) {
|
||||
return AUTH_METHODS.PLAIN;
|
||||
}
|
||||
if (serverMethods.has(AUTH_METHODS.LOGIN)) {
|
||||
return AUTH_METHODS.LOGIN;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No compatible authentication method found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth2 token is expired
|
||||
*/
|
||||
private isTokenExpired(oauth2: IOAuth2Options): boolean {
|
||||
if (!oauth2.expires) {
|
||||
return false; // No expiry information, assume valid
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const buffer = 300000; // 5 minutes buffer
|
||||
|
||||
return oauth2.expires < (now + buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth2 access token
|
||||
*/
|
||||
private async refreshOAuth2Token(oauth2: IOAuth2Options): Promise<string> {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you would make an HTTP request to the OAuth2 provider
|
||||
logDebug('OAuth2 token refresh required', this.options);
|
||||
|
||||
if (!oauth2.refreshToken) {
|
||||
throw new Error('Refresh token required for OAuth2 token refresh');
|
||||
}
|
||||
|
||||
// TODO: Implement actual OAuth2 token refresh
|
||||
// For now, throw an error to indicate this needs to be implemented
|
||||
throw new Error('OAuth2 token refresh not implemented. Please provide a valid access token.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authentication configuration
|
||||
*/
|
||||
public validateAuthConfig(auth: ISmtpAuthOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (auth.method === 'OAUTH2' || auth.oauth2) {
|
||||
if (!auth.oauth2) {
|
||||
errors.push('OAuth2 configuration required when using OAUTH2 method');
|
||||
} else {
|
||||
if (!auth.oauth2.user) errors.push('OAuth2 user required');
|
||||
if (!auth.oauth2.clientId) errors.push('OAuth2 clientId required');
|
||||
if (!auth.oauth2.clientSecret) errors.push('OAuth2 clientSecret required');
|
||||
if (!auth.oauth2.refreshToken && !auth.oauth2.accessToken) {
|
||||
errors.push('OAuth2 refreshToken or accessToken required');
|
||||
}
|
||||
}
|
||||
} else if (auth.method === 'PLAIN' || auth.method === 'LOGIN' || (!auth.method && (auth.user || auth.pass))) {
|
||||
if (!auth.user) errors.push('Username required for basic authentication');
|
||||
if (!auth.pass) errors.push('Password required for basic authentication');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Command Handler
|
||||
* SMTP command sending and response parsing
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpResponse,
|
||||
ISmtpClientOptions,
|
||||
ISmtpCapabilities
|
||||
} from './interfaces.js';
|
||||
import {
|
||||
parseSmtpResponse,
|
||||
parseEhloResponse,
|
||||
formatCommand,
|
||||
isSuccessCode
|
||||
} from './utils/helpers.js';
|
||||
import { logCommand, logDebug } from './utils/logging.js';
|
||||
|
||||
export class CommandHandler extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private responseBuffer: string = '';
|
||||
private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null;
|
||||
private commandTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send EHLO command and parse capabilities
|
||||
*/
|
||||
public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise<ISmtpCapabilities> {
|
||||
const hostname = domain || this.options.domain || 'localhost';
|
||||
const command = `${SMTP_COMMANDS.EHLO} ${hostname}`;
|
||||
|
||||
const response = await this.sendCommand(connection, command);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`EHLO failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const capabilities = parseEhloResponse(response.raw);
|
||||
connection.capabilities = capabilities;
|
||||
|
||||
logDebug('EHLO capabilities parsed', this.options, { capabilities });
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MAIL FROM command
|
||||
*/
|
||||
public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise<ISmtpResponse> {
|
||||
// Handle empty return path for bounce messages
|
||||
const command = fromAddress === ''
|
||||
? `${SMTP_COMMANDS.MAIL_FROM}:<>`
|
||||
: `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send RCPT TO command
|
||||
*/
|
||||
public async sendRcptTo(connection: ISmtpConnection, toAddress: string): Promise<ISmtpResponse> {
|
||||
const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send DATA command
|
||||
*/
|
||||
public async sendData(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email data content
|
||||
*/
|
||||
public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise<ISmtpResponse> {
|
||||
// Normalize line endings to CRLF
|
||||
let data = emailData.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n');
|
||||
|
||||
// Ensure email data ends with CRLF
|
||||
if (!data.endsWith(LINE_ENDINGS.CRLF)) {
|
||||
data += LINE_ENDINGS.CRLF;
|
||||
}
|
||||
|
||||
// Perform dot stuffing (escape lines starting with a dot)
|
||||
data = data.replace(/\r\n\./g, '\r\n..');
|
||||
|
||||
// Add termination sequence
|
||||
data += '.' + LINE_ENDINGS.CRLF;
|
||||
|
||||
return this.sendRawData(connection, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send RSET command
|
||||
*/
|
||||
public async sendRset(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.RSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send NOOP command
|
||||
*/
|
||||
public async sendNoop(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.NOOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send QUIT command
|
||||
*/
|
||||
public async sendQuit(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.QUIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send STARTTLS command
|
||||
*/
|
||||
public async sendStartTls(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send AUTH command
|
||||
*/
|
||||
public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise<ISmtpResponse> {
|
||||
const command = credentials ?
|
||||
`${SMTP_COMMANDS.AUTH} ${method} ${credentials}` :
|
||||
`${SMTP_COMMANDS.AUTH} ${method}`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic SMTP command
|
||||
*/
|
||||
public async sendCommand(connection: ISmtpConnection, command: string): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.pendingCommand) {
|
||||
reject(new Error('Another command is already pending'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCommand = { resolve, reject, command };
|
||||
|
||||
// Set command timeout
|
||||
const timeout = 30000; // 30 seconds
|
||||
this.commandTimeout = setTimeout(() => {
|
||||
this.pendingCommand = null;
|
||||
this.commandTimeout = null;
|
||||
reject(new Error(`Command timeout: ${command}`));
|
||||
}, timeout);
|
||||
|
||||
// Set up data handler
|
||||
const dataHandler = (data: Buffer) => {
|
||||
this.handleIncomingData(data.toString());
|
||||
};
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = () => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
if (this.commandTimeout) {
|
||||
clearTimeout(this.commandTimeout);
|
||||
this.commandTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Send command
|
||||
const formattedCommand = command.endsWith(LINE_ENDINGS.CRLF) ? command : formatCommand(command);
|
||||
|
||||
logCommand(command, undefined, this.options);
|
||||
logDebug(`Sending command: ${command}`, this.options);
|
||||
|
||||
connection.socket.write(formattedCommand, (error) => {
|
||||
if (error) {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Override resolve/reject to include cleanup
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
logCommand(command, response, this.options);
|
||||
originalResolve(response);
|
||||
};
|
||||
|
||||
this.pendingCommand.reject = (error: Error) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalReject(error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send raw data without command formatting
|
||||
*/
|
||||
public async sendRawData(connection: ISmtpConnection, data: string): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.pendingCommand) {
|
||||
reject(new Error('Another command is already pending'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCommand = { resolve, reject, command: 'DATA_CONTENT' };
|
||||
|
||||
// Set data timeout
|
||||
const timeout = 60000; // 60 seconds for data
|
||||
this.commandTimeout = setTimeout(() => {
|
||||
this.pendingCommand = null;
|
||||
this.commandTimeout = null;
|
||||
reject(new Error('Data transmission timeout'));
|
||||
}, timeout);
|
||||
|
||||
// Set up data handler
|
||||
const dataHandler = (chunk: Buffer) => {
|
||||
this.handleIncomingData(chunk.toString());
|
||||
};
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = () => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
if (this.commandTimeout) {
|
||||
clearTimeout(this.commandTimeout);
|
||||
this.commandTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Override resolve/reject to include cleanup
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalResolve(response);
|
||||
};
|
||||
|
||||
this.pendingCommand.reject = (error: Error) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalReject(error);
|
||||
};
|
||||
|
||||
// Send data
|
||||
connection.socket.write(data, (error) => {
|
||||
if (error) {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server greeting
|
||||
*/
|
||||
public async waitForGreeting(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 30000; // 30 seconds
|
||||
let timeoutHandler: NodeJS.Timeout;
|
||||
|
||||
const dataHandler = (data: Buffer) => {
|
||||
this.responseBuffer += data.toString();
|
||||
|
||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
||||
clearTimeout(timeoutHandler);
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
|
||||
const response = parseSmtpResponse(this.responseBuffer);
|
||||
this.responseBuffer = '';
|
||||
|
||||
if (isSuccessCode(response.code)) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`Server greeting failed: ${response.message}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
timeoutHandler = setTimeout(() => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
reject(new Error('Greeting timeout'));
|
||||
}, timeout);
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private handleIncomingData(data: string): void {
|
||||
if (!this.pendingCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.responseBuffer += data;
|
||||
|
||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
||||
const response = parseSmtpResponse(this.responseBuffer);
|
||||
this.responseBuffer = '';
|
||||
|
||||
if (isSuccessCode(response.code) || (response.code >= 300 && response.code < 400) || response.code >= 400) {
|
||||
this.pendingCommand.resolve(response);
|
||||
} else {
|
||||
this.pendingCommand.reject(new Error(`Command failed: ${response.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isCompleteResponse(buffer: string): boolean {
|
||||
// Check if we have a complete response
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
|
||||
if (lines.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check the last non-empty line
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.length > 0) {
|
||||
// Response is complete if line starts with "XXX " (space after code)
|
||||
return /^\d{3} /.test(line);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Connection Manager
|
||||
* Connection pooling and lifecycle management
|
||||
*/
|
||||
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { DEFAULTS, CONNECTION_STATES } from './constants.js';
|
||||
import type {
|
||||
ISmtpClientOptions,
|
||||
ISmtpConnection,
|
||||
IConnectionPoolStatus,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { logConnection, logDebug } from './utils/logging.js';
|
||||
import { generateConnectionId } from './utils/helpers.js';
|
||||
|
||||
export class ConnectionManager extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private connections: Map<string, ISmtpConnection> = new Map();
|
||||
private pendingConnections: Set<string> = new Set();
|
||||
private idleTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.setupIdleCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection
|
||||
*/
|
||||
public async getConnection(): Promise<ISmtpConnection> {
|
||||
// Try to reuse an idle connection if pooling is enabled
|
||||
if (this.options.pool) {
|
||||
const idleConnection = this.findIdleConnection();
|
||||
if (idleConnection) {
|
||||
const connectionId = this.getConnectionId(idleConnection) || 'unknown';
|
||||
logDebug('Reusing idle connection', this.options, { connectionId });
|
||||
return idleConnection;
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
if (this.getActiveConnectionCount() >= (this.options.maxConnections || DEFAULTS.MAX_CONNECTIONS)) {
|
||||
throw new Error('Maximum number of connections reached');
|
||||
}
|
||||
}
|
||||
|
||||
return this.createConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection
|
||||
*/
|
||||
public async createConnection(): Promise<ISmtpConnection> {
|
||||
const connectionId = generateConnectionId();
|
||||
|
||||
try {
|
||||
this.pendingConnections.add(connectionId);
|
||||
logConnection('connecting', this.options, { connectionId });
|
||||
|
||||
const socket = await this.establishSocket();
|
||||
const connection: ISmtpConnection = {
|
||||
socket,
|
||||
state: CONNECTION_STATES.CONNECTED as ConnectionState,
|
||||
options: this.options,
|
||||
secure: this.options.secure || false,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
this.setupSocketHandlers(socket, connectionId);
|
||||
this.connections.set(connectionId, connection);
|
||||
this.pendingConnections.delete(connectionId);
|
||||
|
||||
logConnection('connected', this.options, { connectionId });
|
||||
this.emit('connection', connection);
|
||||
|
||||
return connection;
|
||||
} catch (error) {
|
||||
this.pendingConnections.delete(connectionId);
|
||||
logConnection('error', this.options, { connectionId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a connection back to the pool or close it
|
||||
*/
|
||||
public releaseConnection(connection: ISmtpConnection): void {
|
||||
const connectionId = this.getConnectionId(connection);
|
||||
|
||||
if (!connectionId || !this.connections.has(connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.pool && this.shouldReuseConnection(connection)) {
|
||||
// Return to pool
|
||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
||||
connection.lastActivity = new Date();
|
||||
logDebug('Connection returned to pool', this.options, { connectionId });
|
||||
} else {
|
||||
// Close connection
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific connection
|
||||
*/
|
||||
public closeConnection(connection: ISmtpConnection): void {
|
||||
const connectionId = this.getConnectionId(connection);
|
||||
|
||||
if (connectionId) {
|
||||
this.connections.delete(connectionId);
|
||||
}
|
||||
|
||||
connection.state = CONNECTION_STATES.CLOSING as ConnectionState;
|
||||
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logDebug('Error closing connection', this.options, { error });
|
||||
}
|
||||
|
||||
logConnection('disconnected', this.options, { connectionId });
|
||||
this.emit('disconnect', connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections
|
||||
*/
|
||||
public closeAllConnections(): void {
|
||||
logDebug('Closing all connections', this.options);
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
|
||||
this.connections.clear();
|
||||
this.pendingConnections.clear();
|
||||
|
||||
if (this.idleTimeout) {
|
||||
clearInterval(this.idleTimeout);
|
||||
this.idleTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection pool status
|
||||
*/
|
||||
public getPoolStatus(): IConnectionPoolStatus {
|
||||
const total = this.connections.size;
|
||||
const active = Array.from(this.connections.values())
|
||||
.filter(conn => conn.state === CONNECTION_STATES.BUSY).length;
|
||||
const idle = total - active;
|
||||
const pending = this.pendingConnections.size;
|
||||
|
||||
return { total, active, idle, pending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection activity timestamp
|
||||
*/
|
||||
public updateActivity(connection: ISmtpConnection): void {
|
||||
connection.lastActivity = new Date();
|
||||
}
|
||||
|
||||
private async establishSocket(): Promise<net.Socket | tls.TLSSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
let socket: net.Socket | tls.TLSSocket;
|
||||
|
||||
if (this.options.secure) {
|
||||
// Direct TLS connection
|
||||
socket = tls.connect({
|
||||
host: this.options.host,
|
||||
port: this.options.port,
|
||||
...this.options.tls
|
||||
});
|
||||
} else {
|
||||
// Plain connection
|
||||
socket = new net.Socket();
|
||||
socket.connect(this.options.port, this.options.host);
|
||||
}
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
// For TLS connections, we need to wait for 'secureConnect' instead of 'connect'
|
||||
const successEvent = this.options.secure ? 'secureConnect' : 'connect';
|
||||
|
||||
socket.once(successEvent, () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupSocketHandlers(socket: net.Socket | tls.TLSSocket, connectionId: string): void {
|
||||
const socketTimeout = this.options.socketTimeout || DEFAULTS.SOCKET_TIMEOUT;
|
||||
|
||||
socket.setTimeout(socketTimeout);
|
||||
|
||||
socket.on('timeout', () => {
|
||||
logDebug('Socket timeout', this.options, { connectionId });
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logConnection('error', this.options, { connectionId, error });
|
||||
this.connections.delete(connectionId);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(connectionId);
|
||||
logDebug('Socket closed', this.options, { connectionId });
|
||||
});
|
||||
}
|
||||
|
||||
private findIdleConnection(): ISmtpConnection | null {
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.state === CONNECTION_STATES.READY) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private shouldReuseConnection(connection: ISmtpConnection): boolean {
|
||||
const maxMessages = this.options.maxMessages || DEFAULTS.MAX_MESSAGES;
|
||||
const maxAge = 300000; // 5 minutes
|
||||
const age = Date.now() - connection.createdAt.getTime();
|
||||
|
||||
return connection.messageCount < maxMessages &&
|
||||
age < maxAge &&
|
||||
!connection.socket.destroyed;
|
||||
}
|
||||
|
||||
private getActiveConnectionCount(): number {
|
||||
return this.connections.size + this.pendingConnections.size;
|
||||
}
|
||||
|
||||
private getConnectionId(connection: ISmtpConnection): string | null {
|
||||
for (const [id, conn] of this.connections.entries()) {
|
||||
if (conn === connection) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setupIdleCleanup(): void {
|
||||
if (!this.options.pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupInterval = DEFAULTS.POOL_IDLE_TIMEOUT;
|
||||
|
||||
this.idleTimeout = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const connectionsToClose: ISmtpConnection[] = [];
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
const idleTime = now - connection.lastActivity.getTime();
|
||||
|
||||
if (connection.state === CONNECTION_STATES.READY && idleTime > cleanupInterval) {
|
||||
connectionsToClose.push(connection);
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of connectionsToClose) {
|
||||
logDebug('Closing idle connection', this.options);
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
}, cleanupInterval);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Constants and Error Codes
|
||||
* All constants, error codes, and enums for SMTP client operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* SMTP response codes
|
||||
*/
|
||||
export const SMTP_CODES = {
|
||||
// Positive completion replies
|
||||
SERVICE_READY: 220,
|
||||
SERVICE_CLOSING: 221,
|
||||
AUTHENTICATION_SUCCESSFUL: 235,
|
||||
REQUESTED_ACTION_OK: 250,
|
||||
USER_NOT_LOCAL: 251,
|
||||
CANNOT_VERIFY_USER: 252,
|
||||
|
||||
// Positive intermediate replies
|
||||
START_MAIL_INPUT: 354,
|
||||
|
||||
// Transient negative completion replies
|
||||
SERVICE_NOT_AVAILABLE: 421,
|
||||
MAILBOX_BUSY: 450,
|
||||
LOCAL_ERROR: 451,
|
||||
INSUFFICIENT_STORAGE: 452,
|
||||
UNABLE_TO_ACCOMMODATE: 455,
|
||||
|
||||
// Permanent negative completion replies
|
||||
SYNTAX_ERROR: 500,
|
||||
SYNTAX_ERROR_PARAMETERS: 501,
|
||||
COMMAND_NOT_IMPLEMENTED: 502,
|
||||
BAD_SEQUENCE: 503,
|
||||
PARAMETER_NOT_IMPLEMENTED: 504,
|
||||
MAILBOX_UNAVAILABLE: 550,
|
||||
USER_NOT_LOCAL_TRY_FORWARD: 551,
|
||||
EXCEEDED_STORAGE: 552,
|
||||
MAILBOX_NAME_NOT_ALLOWED: 553,
|
||||
TRANSACTION_FAILED: 554
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SMTP command names
|
||||
*/
|
||||
export const SMTP_COMMANDS = {
|
||||
HELO: 'HELO',
|
||||
EHLO: 'EHLO',
|
||||
MAIL_FROM: 'MAIL FROM',
|
||||
RCPT_TO: 'RCPT TO',
|
||||
DATA: 'DATA',
|
||||
RSET: 'RSET',
|
||||
NOOP: 'NOOP',
|
||||
QUIT: 'QUIT',
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Authentication methods
|
||||
*/
|
||||
export const AUTH_METHODS = {
|
||||
PLAIN: 'PLAIN',
|
||||
LOGIN: 'LOGIN',
|
||||
OAUTH2: 'XOAUTH2',
|
||||
CRAM_MD5: 'CRAM-MD5'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common SMTP extensions
|
||||
*/
|
||||
export const SMTP_EXTENSIONS = {
|
||||
PIPELINING: 'PIPELINING',
|
||||
SIZE: 'SIZE',
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH',
|
||||
EIGHT_BIT_MIME: '8BITMIME',
|
||||
CHUNKING: 'CHUNKING',
|
||||
ENHANCED_STATUS_CODES: 'ENHANCEDSTATUSCODES',
|
||||
DSN: 'DSN'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
CONNECTION_TIMEOUT: 60000, // 60 seconds
|
||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
||||
COMMAND_TIMEOUT: 30000, // 30 seconds
|
||||
MAX_CONNECTIONS: 5,
|
||||
MAX_MESSAGES: 100,
|
||||
PORT_SMTP: 25,
|
||||
PORT_SUBMISSION: 587,
|
||||
PORT_SMTPS: 465,
|
||||
RETRY_ATTEMPTS: 3,
|
||||
RETRY_DELAY: 1000,
|
||||
POOL_IDLE_TIMEOUT: 30000 // 30 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error types for classification
|
||||
*/
|
||||
export enum SmtpErrorType {
|
||||
CONNECTION_ERROR = 'CONNECTION_ERROR',
|
||||
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
|
||||
PROTOCOL_ERROR = 'PROTOCOL_ERROR',
|
||||
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
|
||||
TLS_ERROR = 'TLS_ERROR',
|
||||
SYNTAX_ERROR = 'SYNTAX_ERROR',
|
||||
MAILBOX_ERROR = 'MAILBOX_ERROR',
|
||||
QUOTA_ERROR = 'QUOTA_ERROR',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expressions for parsing
|
||||
*/
|
||||
export const REGEX_PATTERNS = {
|
||||
EMAIL_ADDRESS: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
RESPONSE_CODE: /^(\d{3})([ -])(.*)/,
|
||||
ENHANCED_STATUS: /^(\d\.\d\.\d)\s/,
|
||||
AUTH_CAPABILITIES: /AUTH\s+(.+)/i,
|
||||
SIZE_EXTENSION: /SIZE\s+(\d+)/i
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Line endings and separators
|
||||
*/
|
||||
export const LINE_ENDINGS = {
|
||||
CRLF: '\r\n',
|
||||
LF: '\n',
|
||||
CR: '\r'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Connection states for internal use
|
||||
*/
|
||||
export const CONNECTION_STATES = {
|
||||
DISCONNECTED: 'disconnected',
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
AUTHENTICATED: 'authenticated',
|
||||
READY: 'ready',
|
||||
BUSY: 'busy',
|
||||
CLOSING: 'closing',
|
||||
ERROR: 'error'
|
||||
} as const;
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Factory
|
||||
* Factory function for client creation and dependency injection
|
||||
*/
|
||||
|
||||
import { SmtpClient } from './smtp-client.js';
|
||||
import { ConnectionManager } from './connection-manager.js';
|
||||
import { CommandHandler } from './command-handler.js';
|
||||
import { AuthHandler } from './auth-handler.js';
|
||||
import { TlsHandler } from './tls-handler.js';
|
||||
import { SmtpErrorHandler } from './error-handler.js';
|
||||
import type { ISmtpClientOptions } from './interfaces.js';
|
||||
import { validateClientOptions } from './utils/validation.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
/**
|
||||
* Create a complete SMTP client with all components
|
||||
*/
|
||||
export function createSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
// Validate options
|
||||
const errors = validateClientOptions(options);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid client options: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
const clientOptions: ISmtpClientOptions = {
|
||||
connectionTimeout: DEFAULTS.CONNECTION_TIMEOUT,
|
||||
socketTimeout: DEFAULTS.SOCKET_TIMEOUT,
|
||||
maxConnections: DEFAULTS.MAX_CONNECTIONS,
|
||||
maxMessages: DEFAULTS.MAX_MESSAGES,
|
||||
pool: false,
|
||||
secure: false,
|
||||
debug: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Create handlers
|
||||
const errorHandler = new SmtpErrorHandler(clientOptions);
|
||||
const connectionManager = new ConnectionManager(clientOptions);
|
||||
const commandHandler = new CommandHandler(clientOptions);
|
||||
const authHandler = new AuthHandler(clientOptions, commandHandler);
|
||||
const tlsHandler = new TlsHandler(clientOptions, commandHandler);
|
||||
|
||||
// Create and return SMTP client
|
||||
return new SmtpClient({
|
||||
options: clientOptions,
|
||||
connectionManager,
|
||||
commandHandler,
|
||||
authHandler,
|
||||
tlsHandler,
|
||||
errorHandler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client with connection pooling enabled
|
||||
*/
|
||||
export function createPooledSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: true,
|
||||
maxConnections: options.maxConnections || DEFAULTS.MAX_CONNECTIONS,
|
||||
maxMessages: options.maxMessages || DEFAULTS.MAX_MESSAGES
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client for high-volume sending
|
||||
*/
|
||||
export function createBulkSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: true,
|
||||
maxConnections: Math.max(options.maxConnections || 10, 10),
|
||||
maxMessages: Math.max(options.maxMessages || 1000, 1000),
|
||||
connectionTimeout: options.connectionTimeout || 30000,
|
||||
socketTimeout: options.socketTimeout || 120000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client for transactional emails
|
||||
*/
|
||||
export function createTransactionalSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: false, // Use fresh connections for transactional emails
|
||||
maxConnections: 1,
|
||||
maxMessages: 1,
|
||||
connectionTimeout: options.connectionTimeout || 10000,
|
||||
socketTimeout: options.socketTimeout || 30000
|
||||
});
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Error Handler
|
||||
* Error classification and recovery strategies
|
||||
*/
|
||||
|
||||
import { SmtpErrorType } from './constants.js';
|
||||
import type { ISmtpResponse, ISmtpErrorContext, ISmtpClientOptions } from './interfaces.js';
|
||||
import { logDebug } from './utils/logging.js';
|
||||
|
||||
export class SmtpErrorHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type based on response or error
|
||||
*/
|
||||
public classifyError(error: Error | ISmtpResponse, context?: ISmtpErrorContext): SmtpErrorType {
|
||||
logDebug('Classifying error', this.options, { errorMessage: error instanceof Error ? error.message : String(error), context });
|
||||
|
||||
// Handle Error objects
|
||||
if (error instanceof Error) {
|
||||
return this.classifyErrorByMessage(error);
|
||||
}
|
||||
|
||||
// Handle SMTP response codes
|
||||
if (typeof error === 'object' && 'code' in error) {
|
||||
return this.classifyErrorByCode(error.code);
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error is retryable
|
||||
*/
|
||||
public isRetryable(errorType: SmtpErrorType, response?: ISmtpResponse): boolean {
|
||||
switch (errorType) {
|
||||
case SmtpErrorType.CONNECTION_ERROR:
|
||||
case SmtpErrorType.TIMEOUT_ERROR:
|
||||
return true;
|
||||
|
||||
case SmtpErrorType.PROTOCOL_ERROR:
|
||||
// Only retry on temporary failures (4xx codes)
|
||||
return response ? response.code >= 400 && response.code < 500 : false;
|
||||
|
||||
case SmtpErrorType.AUTHENTICATION_ERROR:
|
||||
case SmtpErrorType.TLS_ERROR:
|
||||
case SmtpErrorType.SYNTAX_ERROR:
|
||||
case SmtpErrorType.MAILBOX_ERROR:
|
||||
case SmtpErrorType.QUOTA_ERROR:
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry delay for error type
|
||||
*/
|
||||
public getRetryDelay(attempt: number, errorType: SmtpErrorType): number {
|
||||
const baseDelay = 1000; // 1 second
|
||||
const maxDelay = 30000; // 30 seconds
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||
const jitter = Math.random() * 0.1 * delay; // 10% jitter
|
||||
|
||||
return Math.floor(delay + jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced error with context
|
||||
*/
|
||||
public createError(
|
||||
message: string,
|
||||
errorType: SmtpErrorType,
|
||||
context?: ISmtpErrorContext,
|
||||
originalError?: Error
|
||||
): Error {
|
||||
const error = new Error(message);
|
||||
(error as any).type = errorType;
|
||||
(error as any).context = context;
|
||||
(error as any).originalError = originalError;
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
private classifyErrorByMessage(error: Error): SmtpErrorType {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes('timeout') || message.includes('etimedout')) {
|
||||
return SmtpErrorType.TIMEOUT_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('connect') || message.includes('econnrefused') ||
|
||||
message.includes('enotfound') || message.includes('enetunreach')) {
|
||||
return SmtpErrorType.CONNECTION_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('tls') || message.includes('ssl') ||
|
||||
message.includes('certificate') || message.includes('handshake')) {
|
||||
return SmtpErrorType.TLS_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('auth')) {
|
||||
return SmtpErrorType.AUTHENTICATION_ERROR;
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
private classifyErrorByCode(code: number): SmtpErrorType {
|
||||
if (code >= 500) {
|
||||
// Permanent failures
|
||||
if (code === 550 || code === 551 || code === 553) {
|
||||
return SmtpErrorType.MAILBOX_ERROR;
|
||||
}
|
||||
if (code === 552) {
|
||||
return SmtpErrorType.QUOTA_ERROR;
|
||||
}
|
||||
if (code === 500 || code === 501 || code === 502 || code === 504) {
|
||||
return SmtpErrorType.SYNTAX_ERROR;
|
||||
}
|
||||
return SmtpErrorType.PROTOCOL_ERROR;
|
||||
}
|
||||
|
||||
if (code >= 400) {
|
||||
// Temporary failures
|
||||
if (code === 450 || code === 451 || code === 452) {
|
||||
return SmtpErrorType.QUOTA_ERROR;
|
||||
}
|
||||
return SmtpErrorType.PROTOCOL_ERROR;
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Module Exports
|
||||
* Modular SMTP client implementation for robust email delivery
|
||||
*/
|
||||
|
||||
// Main client class and factory
|
||||
export * from './smtp-client.js';
|
||||
export * from './create-client.js';
|
||||
|
||||
// Core handlers
|
||||
export * from './connection-manager.js';
|
||||
export * from './command-handler.js';
|
||||
export * from './auth-handler.js';
|
||||
export * from './tls-handler.js';
|
||||
export * from './error-handler.js';
|
||||
|
||||
// Interfaces and types
|
||||
export * from './interfaces.js';
|
||||
export * from './constants.js';
|
||||
|
||||
// Utilities
|
||||
export * from './utils/validation.js';
|
||||
export * from './utils/logging.js';
|
||||
export * from './utils/helpers.js';
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Interfaces and Types
|
||||
* All interface definitions for the modular SMTP client
|
||||
*/
|
||||
|
||||
import type * as tls from 'node:tls';
|
||||
import type * as net from 'node:net';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
|
||||
/**
|
||||
* SMTP client connection options
|
||||
*/
|
||||
export interface ISmtpClientOptions {
|
||||
/** Hostname of the SMTP server */
|
||||
host: string;
|
||||
|
||||
/** Port to connect to */
|
||||
port: number;
|
||||
|
||||
/** Whether to use TLS for the connection */
|
||||
secure?: boolean;
|
||||
|
||||
/** Connection timeout in milliseconds */
|
||||
connectionTimeout?: number;
|
||||
|
||||
/** Socket timeout in milliseconds */
|
||||
socketTimeout?: number;
|
||||
|
||||
/** Domain name for EHLO command */
|
||||
domain?: string;
|
||||
|
||||
/** Authentication options */
|
||||
auth?: ISmtpAuthOptions;
|
||||
|
||||
/** TLS options */
|
||||
tls?: tls.ConnectionOptions;
|
||||
|
||||
/** Maximum number of connections in pool */
|
||||
pool?: boolean;
|
||||
maxConnections?: number;
|
||||
maxMessages?: number;
|
||||
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
|
||||
/** Proxy settings */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication options for SMTP
|
||||
*/
|
||||
export interface ISmtpAuthOptions {
|
||||
/** Username */
|
||||
user?: string;
|
||||
|
||||
/** Password */
|
||||
pass?: string;
|
||||
|
||||
/** OAuth2 settings */
|
||||
oauth2?: IOAuth2Options;
|
||||
|
||||
/** Authentication method preference */
|
||||
method?: 'PLAIN' | 'LOGIN' | 'OAUTH2' | 'AUTO';
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 authentication options
|
||||
*/
|
||||
export interface IOAuth2Options {
|
||||
/** OAuth2 user identifier */
|
||||
user: string;
|
||||
|
||||
/** OAuth2 client ID */
|
||||
clientId: string;
|
||||
|
||||
/** OAuth2 client secret */
|
||||
clientSecret: string;
|
||||
|
||||
/** OAuth2 refresh token */
|
||||
refreshToken: string;
|
||||
|
||||
/** OAuth2 access token */
|
||||
accessToken?: string;
|
||||
|
||||
/** Token expiry time */
|
||||
expires?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an email send operation
|
||||
*/
|
||||
export interface ISmtpSendResult {
|
||||
/** Whether the send was successful */
|
||||
success: boolean;
|
||||
|
||||
/** Message ID from server */
|
||||
messageId?: string;
|
||||
|
||||
/** List of accepted recipients */
|
||||
acceptedRecipients: string[];
|
||||
|
||||
/** List of rejected recipients */
|
||||
rejectedRecipients: string[];
|
||||
|
||||
/** Error information if failed */
|
||||
error?: Error;
|
||||
|
||||
/** Server response */
|
||||
response?: string;
|
||||
|
||||
/** Envelope information */
|
||||
envelope?: ISmtpEnvelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP envelope information
|
||||
*/
|
||||
export interface ISmtpEnvelope {
|
||||
/** Sender address */
|
||||
from: string;
|
||||
|
||||
/** Recipient addresses */
|
||||
to: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection pool status
|
||||
*/
|
||||
export interface IConnectionPoolStatus {
|
||||
/** Total connections in pool */
|
||||
total: number;
|
||||
|
||||
/** Active connections */
|
||||
active: number;
|
||||
|
||||
/** Idle connections */
|
||||
idle: number;
|
||||
|
||||
/** Pending connection requests */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP command response
|
||||
*/
|
||||
export interface ISmtpResponse {
|
||||
/** Response code */
|
||||
code: number;
|
||||
|
||||
/** Response message */
|
||||
message: string;
|
||||
|
||||
/** Enhanced status code */
|
||||
enhancedCode?: string;
|
||||
|
||||
/** Raw response */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
AUTHENTICATED = 'authenticated',
|
||||
READY = 'ready',
|
||||
BUSY = 'busy',
|
||||
CLOSING = 'closing',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP capabilities
|
||||
*/
|
||||
export interface ISmtpCapabilities {
|
||||
/** Supported extensions */
|
||||
extensions: Set<string>;
|
||||
|
||||
/** Maximum message size */
|
||||
maxSize?: number;
|
||||
|
||||
/** Supported authentication methods */
|
||||
authMethods: Set<string>;
|
||||
|
||||
/** Support for pipelining */
|
||||
pipelining: boolean;
|
||||
|
||||
/** Support for STARTTLS */
|
||||
starttls: boolean;
|
||||
|
||||
/** Support for 8BITMIME */
|
||||
eightBitMime: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal connection interface
|
||||
*/
|
||||
export interface ISmtpConnection {
|
||||
/** Socket connection */
|
||||
socket: net.Socket | tls.TLSSocket;
|
||||
|
||||
/** Connection state */
|
||||
state: ConnectionState;
|
||||
|
||||
/** Server capabilities */
|
||||
capabilities?: ISmtpCapabilities;
|
||||
|
||||
/** Connection options */
|
||||
options: ISmtpClientOptions;
|
||||
|
||||
/** Whether connection is secure */
|
||||
secure: boolean;
|
||||
|
||||
/** Connection creation time */
|
||||
createdAt: Date;
|
||||
|
||||
/** Last activity time */
|
||||
lastActivity: Date;
|
||||
|
||||
/** Number of messages sent */
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error context for detailed error reporting
|
||||
*/
|
||||
export interface ISmtpErrorContext {
|
||||
/** Command that caused the error */
|
||||
command?: string;
|
||||
|
||||
/** Server response */
|
||||
response?: ISmtpResponse;
|
||||
|
||||
/** Connection state */
|
||||
connectionState?: ConnectionState;
|
||||
|
||||
/** Additional context data */
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Core Implementation
|
||||
* Main client class with delegation to handlers
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
import type {
|
||||
ISmtpClientOptions,
|
||||
ISmtpSendResult,
|
||||
ISmtpConnection,
|
||||
IConnectionPoolStatus,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { CONNECTION_STATES, SmtpErrorType } from './constants.js';
|
||||
import type { ConnectionManager } from './connection-manager.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
import type { AuthHandler } from './auth-handler.js';
|
||||
import type { TlsHandler } from './tls-handler.js';
|
||||
import type { SmtpErrorHandler } from './error-handler.js';
|
||||
import { validateSender, validateRecipients } from './utils/validation.js';
|
||||
import { logEmailSend, logPerformance, logDebug } from './utils/logging.js';
|
||||
|
||||
interface ISmtpClientDependencies {
|
||||
options: ISmtpClientOptions;
|
||||
connectionManager: ConnectionManager;
|
||||
commandHandler: CommandHandler;
|
||||
authHandler: AuthHandler;
|
||||
tlsHandler: TlsHandler;
|
||||
errorHandler: SmtpErrorHandler;
|
||||
}
|
||||
|
||||
export class SmtpClient extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private connectionManager: ConnectionManager;
|
||||
private commandHandler: CommandHandler;
|
||||
private authHandler: AuthHandler;
|
||||
private tlsHandler: TlsHandler;
|
||||
private errorHandler: SmtpErrorHandler;
|
||||
private isShuttingDown: boolean = false;
|
||||
|
||||
constructor(dependencies: ISmtpClientDependencies) {
|
||||
super();
|
||||
|
||||
this.options = dependencies.options;
|
||||
this.connectionManager = dependencies.connectionManager;
|
||||
this.commandHandler = dependencies.commandHandler;
|
||||
this.authHandler = dependencies.authHandler;
|
||||
this.tlsHandler = dependencies.tlsHandler;
|
||||
this.errorHandler = dependencies.errorHandler;
|
||||
|
||||
this.setupEventForwarding();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*/
|
||||
public async sendMail(email: Email): Promise<ISmtpSendResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Extract clean email addresses without display names for SMTP operations
|
||||
const fromAddress = email.getFromAddress();
|
||||
const recipients = email.getToAddresses();
|
||||
const ccRecipients = email.getCcAddresses();
|
||||
const bccRecipients = email.getBccAddresses();
|
||||
|
||||
// Combine all recipients for SMTP operations
|
||||
const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients];
|
||||
|
||||
// Validate email addresses
|
||||
if (!validateSender(fromAddress)) {
|
||||
throw new Error(`Invalid sender address: ${fromAddress}`);
|
||||
}
|
||||
|
||||
const recipientErrors = validateRecipients(allRecipients);
|
||||
if (recipientErrors.length > 0) {
|
||||
throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`);
|
||||
}
|
||||
|
||||
logEmailSend('start', allRecipients, this.options);
|
||||
|
||||
let connection: ISmtpConnection | null = null;
|
||||
const result: ISmtpSendResult = {
|
||||
success: false,
|
||||
acceptedRecipients: [],
|
||||
rejectedRecipients: [],
|
||||
envelope: {
|
||||
from: fromAddress,
|
||||
to: allRecipients
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Get connection
|
||||
connection = await this.connectionManager.getConnection();
|
||||
connection.state = CONNECTION_STATES.BUSY as ConnectionState;
|
||||
|
||||
// Wait for greeting if new connection
|
||||
if (!connection.capabilities) {
|
||||
await this.commandHandler.waitForGreeting(connection);
|
||||
}
|
||||
|
||||
// Perform EHLO
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
|
||||
// Upgrade to TLS if needed
|
||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
||||
await this.tlsHandler.upgradeToTLS(connection);
|
||||
// Re-send EHLO after TLS upgrade
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
}
|
||||
|
||||
// Authenticate if needed
|
||||
if (this.options.auth) {
|
||||
await this.authHandler.authenticate(connection);
|
||||
}
|
||||
|
||||
// Send MAIL FROM
|
||||
const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress);
|
||||
if (mailFromResponse.code >= 400) {
|
||||
throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`);
|
||||
}
|
||||
|
||||
// Send RCPT TO for each recipient (includes TO, CC, and BCC)
|
||||
for (const recipient of allRecipients) {
|
||||
try {
|
||||
const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient);
|
||||
if (rcptResponse.code >= 400) {
|
||||
result.rejectedRecipients.push(recipient);
|
||||
logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse });
|
||||
} else {
|
||||
result.acceptedRecipients.push(recipient);
|
||||
}
|
||||
} catch (error) {
|
||||
result.rejectedRecipients.push(recipient);
|
||||
logDebug(`Recipient error: ${recipient}`, this.options, { error });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any accepted recipients
|
||||
if (result.acceptedRecipients.length === 0) {
|
||||
throw new Error('All recipients were rejected');
|
||||
}
|
||||
|
||||
// Send DATA command
|
||||
const dataResponse = await this.commandHandler.sendData(connection);
|
||||
if (dataResponse.code !== 354) {
|
||||
throw new Error(`DATA command failed: ${dataResponse.message}`);
|
||||
}
|
||||
|
||||
// Send email content
|
||||
const emailData = await this.formatEmailData(email);
|
||||
const sendResponse = await this.commandHandler.sendDataContent(connection, emailData);
|
||||
|
||||
if (sendResponse.code >= 400) {
|
||||
throw new Error(`Email data rejected: ${sendResponse.message}`);
|
||||
}
|
||||
|
||||
// Success
|
||||
result.success = true;
|
||||
result.messageId = this.extractMessageId(sendResponse.message);
|
||||
result.response = sendResponse.message;
|
||||
|
||||
connection.messageCount++;
|
||||
logEmailSend('success', recipients, this.options, {
|
||||
messageId: result.messageId,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Classify error and determine if we should retry
|
||||
const errorType = this.errorHandler.classifyError(result.error);
|
||||
result.error = this.errorHandler.createError(
|
||||
result.error.message,
|
||||
errorType,
|
||||
{ command: 'SEND_MAIL' },
|
||||
result.error
|
||||
);
|
||||
|
||||
logEmailSend('failure', recipients, this.options, {
|
||||
error: result.error,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
|
||||
} finally {
|
||||
// Release connection
|
||||
if (connection) {
|
||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
||||
this.connectionManager.updateActivity(connection);
|
||||
this.connectionManager.releaseConnection(connection);
|
||||
}
|
||||
|
||||
logPerformance('sendMail', Date.now() - startTime, this.options);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to SMTP server
|
||||
*/
|
||||
public async verify(): Promise<boolean> {
|
||||
let connection: ISmtpConnection | null = null;
|
||||
|
||||
try {
|
||||
connection = await this.connectionManager.createConnection();
|
||||
await this.commandHandler.waitForGreeting(connection);
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
|
||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
||||
await this.tlsHandler.upgradeToTLS(connection);
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
}
|
||||
|
||||
if (this.options.auth) {
|
||||
await this.authHandler.authenticate(connection);
|
||||
}
|
||||
|
||||
await this.commandHandler.sendQuit(connection);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logDebug('Connection verification failed', this.options, { error });
|
||||
return false;
|
||||
|
||||
} finally {
|
||||
if (connection) {
|
||||
this.connectionManager.closeConnection(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
const status = this.connectionManager.getPoolStatus();
|
||||
return status.total > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection pool status
|
||||
*/
|
||||
public getPoolStatus(): IConnectionPoolStatus {
|
||||
return this.connectionManager.getPoolStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client options
|
||||
*/
|
||||
public updateOptions(newOptions: Partial<ISmtpClientOptions>): void {
|
||||
this.options = { ...this.options, ...newOptions };
|
||||
logDebug('Client options updated', this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections and shutdown client
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
logDebug('Shutting down SMTP client', this.options);
|
||||
|
||||
try {
|
||||
this.connectionManager.closeAllConnections();
|
||||
this.emit('close');
|
||||
} catch (error) {
|
||||
logDebug('Error during client shutdown', this.options, { error });
|
||||
}
|
||||
}
|
||||
|
||||
private async formatEmailData(email: Email): Promise<string> {
|
||||
// Convert Email object to raw SMTP data
|
||||
const headers: string[] = [];
|
||||
|
||||
// Required headers
|
||||
headers.push(`From: ${email.from}`);
|
||||
headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`);
|
||||
headers.push(`Subject: ${email.subject || ''}`);
|
||||
headers.push(`Date: ${new Date().toUTCString()}`);
|
||||
headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`);
|
||||
|
||||
// Optional headers
|
||||
if (email.cc) {
|
||||
const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc;
|
||||
headers.push(`Cc: ${cc}`);
|
||||
}
|
||||
|
||||
if (email.bcc) {
|
||||
const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc;
|
||||
headers.push(`Bcc: ${bcc}`);
|
||||
}
|
||||
|
||||
// Content headers
|
||||
if (email.html && email.text) {
|
||||
// Multipart message
|
||||
const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`;
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
headers.push('MIME-Version: 1.0');
|
||||
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
email.text,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
email.html,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n');
|
||||
|
||||
return headers.join('\r\n') + '\r\n\r\n' + body;
|
||||
} else if (email.html) {
|
||||
headers.push('Content-Type: text/html; charset=utf-8');
|
||||
headers.push('MIME-Version: 1.0');
|
||||
return headers.join('\r\n') + '\r\n\r\n' + email.html;
|
||||
} else {
|
||||
headers.push('Content-Type: text/plain; charset=utf-8');
|
||||
headers.push('MIME-Version: 1.0');
|
||||
return headers.join('\r\n') + '\r\n\r\n' + (email.text || '');
|
||||
}
|
||||
}
|
||||
|
||||
private extractMessageId(response: string): string | undefined {
|
||||
// Try to extract message ID from server response
|
||||
const match = response.match(/queued as ([^\s]+)/i) ||
|
||||
response.match(/id=([^\s]+)/i) ||
|
||||
response.match(/Message-ID: <([^>]+)>/i);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
private setupEventForwarding(): void {
|
||||
// Forward events from connection manager
|
||||
this.connectionManager.on('connection', (connection) => {
|
||||
this.emit('connection', connection);
|
||||
});
|
||||
|
||||
this.connectionManager.on('disconnect', (connection) => {
|
||||
this.emit('disconnect', connection);
|
||||
});
|
||||
|
||||
this.connectionManager.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* SMTP Client TLS Handler
|
||||
* TLS and STARTTLS client functionality
|
||||
*/
|
||||
|
||||
import * as tls from 'node:tls';
|
||||
import * as net from 'node:net';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpClientOptions,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { CONNECTION_STATES } from './constants.js';
|
||||
import { logTLS, logDebug } from './utils/logging.js';
|
||||
import { isSuccessCode } from './utils/helpers.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
|
||||
export class TlsHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
private commandHandler: CommandHandler;
|
||||
|
||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
||||
this.options = options;
|
||||
this.commandHandler = commandHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade connection to TLS using STARTTLS
|
||||
*/
|
||||
public async upgradeToTLS(connection: ISmtpConnection): Promise<void> {
|
||||
if (connection.secure) {
|
||||
logDebug('Connection already secure', this.options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if STARTTLS is supported
|
||||
if (!connection.capabilities?.starttls) {
|
||||
throw new Error('Server does not support STARTTLS');
|
||||
}
|
||||
|
||||
logTLS('starttls_start', this.options);
|
||||
|
||||
try {
|
||||
// Send STARTTLS command
|
||||
const response = await this.commandHandler.sendStartTls(connection);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`STARTTLS command failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Upgrade the socket to TLS
|
||||
await this.performTLSUpgrade(connection);
|
||||
|
||||
// Clear capabilities as they may have changed after TLS
|
||||
connection.capabilities = undefined;
|
||||
connection.secure = true;
|
||||
|
||||
logTLS('starttls_success', this.options);
|
||||
|
||||
} catch (error) {
|
||||
logTLS('starttls_failure', this.options, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a direct TLS connection
|
||||
*/
|
||||
public async createTLSConnection(host: string, port: number): Promise<tls.TLSSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
host,
|
||||
port,
|
||||
...this.options.tls,
|
||||
// Default TLS options for email
|
||||
secureProtocol: 'TLS_method',
|
||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
||||
};
|
||||
|
||||
logTLS('tls_connected', this.options, { host, port });
|
||||
|
||||
const socket = tls.connect(tlsOptions);
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`TLS connection timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
|
||||
if (!socket.authorized && this.options.tls?.rejectUnauthorized !== false) {
|
||||
socket.destroy();
|
||||
reject(new Error(`TLS certificate verification failed: ${socket.authorizationError}`));
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug('TLS connection established', this.options, {
|
||||
authorized: socket.authorized,
|
||||
protocol: socket.getProtocol(),
|
||||
cipher: socket.getCipher()
|
||||
});
|
||||
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TLS certificate
|
||||
*/
|
||||
public validateCertificate(socket: tls.TLSSocket): boolean {
|
||||
if (!socket.authorized) {
|
||||
logDebug('TLS certificate not authorized', this.options, {
|
||||
error: socket.authorizationError
|
||||
});
|
||||
|
||||
// Allow self-signed certificates if explicitly configured
|
||||
if (this.options.tls?.rejectUnauthorized === false) {
|
||||
logDebug('Accepting unauthorized certificate (rejectUnauthorized: false)', this.options);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const cert = socket.getPeerCertificate();
|
||||
if (!cert) {
|
||||
logDebug('No peer certificate available', this.options);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional certificate validation
|
||||
const now = new Date();
|
||||
if (cert.valid_from && new Date(cert.valid_from) > now) {
|
||||
logDebug('Certificate not yet valid', this.options, { validFrom: cert.valid_from });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cert.valid_to && new Date(cert.valid_to) < now) {
|
||||
logDebug('Certificate expired', this.options, { validTo: cert.valid_to });
|
||||
return false;
|
||||
}
|
||||
|
||||
logDebug('TLS certificate validated', this.options, {
|
||||
subject: cert.subject,
|
||||
issuer: cert.issuer,
|
||||
validFrom: cert.valid_from,
|
||||
validTo: cert.valid_to
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TLS connection information
|
||||
*/
|
||||
public getTLSInfo(socket: tls.TLSSocket): any {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: socket.authorized,
|
||||
authorizationError: socket.authorizationError,
|
||||
protocol: socket.getProtocol(),
|
||||
cipher: socket.getCipher(),
|
||||
peerCertificate: socket.getPeerCertificate(),
|
||||
alpnProtocol: socket.alpnProtocol
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TLS upgrade is required or recommended
|
||||
*/
|
||||
public shouldUseTLS(connection: ISmtpConnection): boolean {
|
||||
// Already secure
|
||||
if (connection.secure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct TLS connection configured
|
||||
if (this.options.secure) {
|
||||
return false; // Already handled in connection establishment
|
||||
}
|
||||
|
||||
// STARTTLS available and not explicitly disabled
|
||||
if (connection.capabilities?.starttls) {
|
||||
return this.options.tls !== null && this.options.tls !== undefined; // Use TLS if configured
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async performTLSUpgrade(connection: ISmtpConnection): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const plainSocket = connection.socket as net.Socket;
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
socket: plainSocket,
|
||||
host: this.options.host,
|
||||
...this.options.tls,
|
||||
// Default TLS options for STARTTLS
|
||||
secureProtocol: 'TLS_method',
|
||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
||||
};
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
reject(new Error(`TLS upgrade timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
// Create TLS socket from existing connection
|
||||
const tlsSocket = tls.connect(tlsOptions);
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
|
||||
// Validate certificate if required
|
||||
if (!this.validateCertificate(tlsSocket)) {
|
||||
tlsSocket.destroy();
|
||||
reject(new Error('TLS certificate validation failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the socket in the connection
|
||||
connection.socket = tlsSocket;
|
||||
connection.secure = true;
|
||||
|
||||
logDebug('STARTTLS upgrade completed', this.options, {
|
||||
protocol: tlsSocket.getProtocol(),
|
||||
cipher: tlsSocket.getCipher()
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
tlsSocket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Helper Functions
|
||||
* Protocol helper functions and utilities
|
||||
*/
|
||||
|
||||
import { SMTP_CODES, REGEX_PATTERNS, LINE_ENDINGS } from '../constants.js';
|
||||
import type { ISmtpResponse, ISmtpCapabilities } from '../interfaces.js';
|
||||
|
||||
/**
|
||||
* Parse SMTP server response
|
||||
*/
|
||||
export function parseSmtpResponse(data: string): ISmtpResponse {
|
||||
const lines = data.trim().split(/\r?\n/);
|
||||
const firstLine = lines[0];
|
||||
const match = firstLine.match(REGEX_PATTERNS.RESPONSE_CODE);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
code: 500,
|
||||
message: 'Invalid server response',
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10);
|
||||
const separator = match[2];
|
||||
const message = lines.map(line => line.substring(4)).join(' ');
|
||||
|
||||
// Check for enhanced status code
|
||||
const enhancedMatch = message.match(REGEX_PATTERNS.ENHANCED_STATUS);
|
||||
const enhancedCode = enhancedMatch ? enhancedMatch[1] : undefined;
|
||||
|
||||
return {
|
||||
code,
|
||||
message: enhancedCode ? message.substring(enhancedCode.length + 1) : message,
|
||||
enhancedCode,
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EHLO response and extract capabilities
|
||||
*/
|
||||
export function parseEhloResponse(response: string): ISmtpCapabilities {
|
||||
const lines = response.trim().split(/\r?\n/);
|
||||
const capabilities: ISmtpCapabilities = {
|
||||
extensions: new Set(),
|
||||
authMethods: new Set(),
|
||||
pipelining: false,
|
||||
starttls: false,
|
||||
eightBitMime: false
|
||||
};
|
||||
|
||||
for (const line of lines.slice(1)) { // Skip first line (greeting)
|
||||
const extensionLine = line.substring(4); // Remove "250-" or "250 "
|
||||
const parts = extensionLine.split(/\s+/);
|
||||
const extension = parts[0].toUpperCase();
|
||||
|
||||
capabilities.extensions.add(extension);
|
||||
|
||||
switch (extension) {
|
||||
case 'PIPELINING':
|
||||
capabilities.pipelining = true;
|
||||
break;
|
||||
case 'STARTTLS':
|
||||
capabilities.starttls = true;
|
||||
break;
|
||||
case '8BITMIME':
|
||||
capabilities.eightBitMime = true;
|
||||
break;
|
||||
case 'SIZE':
|
||||
if (parts[1]) {
|
||||
capabilities.maxSize = parseInt(parts[1], 10);
|
||||
}
|
||||
break;
|
||||
case 'AUTH':
|
||||
// Parse authentication methods
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
capabilities.authMethods.add(parts[i].toUpperCase());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format SMTP command with proper line ending
|
||||
*/
|
||||
export function formatCommand(command: string, ...args: string[]): string {
|
||||
const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
|
||||
return fullCommand + LINE_ENDINGS.CRLF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode authentication string for AUTH PLAIN
|
||||
*/
|
||||
export function encodeAuthPlain(username: string, password: string): string {
|
||||
const authString = `\0${username}\0${password}`;
|
||||
return Buffer.from(authString, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode authentication string for AUTH LOGIN
|
||||
*/
|
||||
export function encodeAuthLogin(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth2 authentication string
|
||||
*/
|
||||
export function generateOAuth2String(username: string, accessToken: string): string {
|
||||
const authString = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`;
|
||||
return Buffer.from(authString, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates success
|
||||
*/
|
||||
export function isSuccessCode(code: number): boolean {
|
||||
return code >= 200 && code < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates temporary failure
|
||||
*/
|
||||
export function isTemporaryFailure(code: number): boolean {
|
||||
return code >= 400 && code < 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates permanent failure
|
||||
*/
|
||||
export function isPermanentFailure(code: number): boolean {
|
||||
return code >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape email address for SMTP commands
|
||||
*/
|
||||
export function escapeEmailAddress(email: string): string {
|
||||
return `<${email.trim()}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email address from angle brackets
|
||||
*/
|
||||
export function extractEmailAddress(email: string): string {
|
||||
const match = email.match(/^<(.+)>$/);
|
||||
return match ? match[1] : email.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique connection ID
|
||||
*/
|
||||
export function generateConnectionId(): string {
|
||||
return `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timeout duration for human readability
|
||||
*/
|
||||
export function formatTimeout(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${Math.round(milliseconds / 1000)}s`;
|
||||
} else {
|
||||
return `${Math.round(milliseconds / 60000)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize email data size
|
||||
*/
|
||||
export function validateEmailSize(emailData: string, maxSize?: number): boolean {
|
||||
const size = Buffer.byteLength(emailData, 'utf8');
|
||||
return !maxSize || size <= maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean sensitive data from logs
|
||||
*/
|
||||
export function sanitizeForLogging(data: any): any {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sanitized = { ...data };
|
||||
const sensitiveFields = ['password', 'pass', 'accessToken', 'refreshToken', 'clientSecret'];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (field in sanitized) {
|
||||
sanitized[field] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
*/
|
||||
export function calculateBackoffDelay(attempt: number, baseDelay: number = 1000): number {
|
||||
return Math.min(baseDelay * Math.pow(2, attempt - 1), 30000); // Max 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse enhanced status code
|
||||
*/
|
||||
export function parseEnhancedStatusCode(code: string): { class: number; subject: number; detail: number } | null {
|
||||
const match = code.match(/^(\d)\.(\d)\.(\d)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
class: parseInt(match[1], 10),
|
||||
subject: parseInt(match[2], 10),
|
||||
detail: parseInt(match[3], 10)
|
||||
};
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Logging Utilities
|
||||
* Client-side logging utilities for SMTP operations
|
||||
*/
|
||||
|
||||
import { logger } from '../../../../logger.js';
|
||||
import type { ISmtpResponse, ISmtpClientOptions } from '../interfaces.js';
|
||||
|
||||
export interface ISmtpClientLogData {
|
||||
component: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
secure?: boolean;
|
||||
command?: string;
|
||||
response?: ISmtpResponse;
|
||||
error?: Error;
|
||||
connectionId?: string;
|
||||
messageId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log SMTP client connection events
|
||||
*/
|
||||
export function logConnection(
|
||||
event: 'connecting' | 'connected' | 'disconnected' | 'error',
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
secure: options.secure,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
logger.info('SMTP client connecting', logData);
|
||||
break;
|
||||
case 'connected':
|
||||
logger.info('SMTP client connected', logData);
|
||||
break;
|
||||
case 'disconnected':
|
||||
logger.info('SMTP client disconnected', logData);
|
||||
break;
|
||||
case 'error':
|
||||
logger.error('SMTP client connection error', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log SMTP command execution
|
||||
*/
|
||||
export function logCommand(
|
||||
command: string,
|
||||
response?: ISmtpResponse,
|
||||
options?: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
command,
|
||||
response,
|
||||
host: options?.host,
|
||||
port: options?.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (response && response.code >= 400) {
|
||||
logger.warn('SMTP command failed', logData);
|
||||
} else {
|
||||
logger.debug('SMTP command executed', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
export function logAuthentication(
|
||||
event: 'start' | 'success' | 'failure',
|
||||
method: string,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event: `auth_${event}`,
|
||||
authMethod: method,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'start':
|
||||
logger.debug('SMTP authentication started', logData);
|
||||
break;
|
||||
case 'success':
|
||||
logger.info('SMTP authentication successful', logData);
|
||||
break;
|
||||
case 'failure':
|
||||
logger.error('SMTP authentication failed', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log TLS/STARTTLS events
|
||||
*/
|
||||
export function logTLS(
|
||||
event: 'starttls_start' | 'starttls_success' | 'starttls_failure' | 'tls_connected',
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (event.includes('failure')) {
|
||||
logger.error('SMTP TLS operation failed', logData);
|
||||
} else {
|
||||
logger.info('SMTP TLS operation', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log email sending events
|
||||
*/
|
||||
export function logEmailSend(
|
||||
event: 'start' | 'success' | 'failure',
|
||||
recipients: string[],
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event: `send_${event}`,
|
||||
recipientCount: recipients.length,
|
||||
recipients: recipients.slice(0, 5), // Only log first 5 recipients for privacy
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'start':
|
||||
logger.info('SMTP email send started', logData);
|
||||
break;
|
||||
case 'success':
|
||||
logger.info('SMTP email send successful', logData);
|
||||
break;
|
||||
case 'failure':
|
||||
logger.error('SMTP email send failed', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance metrics
|
||||
*/
|
||||
export function logPerformance(
|
||||
operation: string,
|
||||
duration: number,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
operation,
|
||||
duration,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (duration > 10000) { // Log slow operations (>10s)
|
||||
logger.warn('SMTP slow operation detected', logData);
|
||||
} else {
|
||||
logger.debug('SMTP operation performance', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information (only when debug is enabled)
|
||||
*/
|
||||
export function logDebug(
|
||||
message: string,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
if (!options.debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client-debug',
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
logger.debug(`[SMTP Client Debug] ${message}`, logData);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* SMTP Client Validation Utilities
|
||||
* Input validation functions for SMTP client operations
|
||||
*/
|
||||
|
||||
import { REGEX_PATTERNS } from '../constants.js';
|
||||
import type { ISmtpClientOptions, ISmtpAuthOptions } from '../interfaces.js';
|
||||
|
||||
/**
|
||||
* Validate email address format
|
||||
* Supports RFC-compliant addresses including empty return paths for bounces
|
||||
*/
|
||||
export function validateEmailAddress(email: string): boolean {
|
||||
if (typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = email.trim();
|
||||
|
||||
// Handle empty return path for bounce messages (RFC 5321)
|
||||
if (trimmed === '' || trimmed === '<>') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle display name formats
|
||||
const angleMatch = trimmed.match(/<([^>]+)>/);
|
||||
if (angleMatch) {
|
||||
return REGEX_PATTERNS.EMAIL_ADDRESS.test(angleMatch[1]);
|
||||
}
|
||||
|
||||
// Regular email validation
|
||||
return REGEX_PATTERNS.EMAIL_ADDRESS.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SMTP client options
|
||||
*/
|
||||
export function validateClientOptions(options: ISmtpClientOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required fields
|
||||
if (!options.host || typeof options.host !== 'string') {
|
||||
errors.push('Host is required and must be a string');
|
||||
}
|
||||
|
||||
if (!options.port || typeof options.port !== 'number' || options.port < 1 || options.port > 65535) {
|
||||
errors.push('Port must be a number between 1 and 65535');
|
||||
}
|
||||
|
||||
// Optional field validation
|
||||
if (options.connectionTimeout !== undefined) {
|
||||
if (typeof options.connectionTimeout !== 'number' || options.connectionTimeout < 1000) {
|
||||
errors.push('Connection timeout must be a number >= 1000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.socketTimeout !== undefined) {
|
||||
if (typeof options.socketTimeout !== 'number' || options.socketTimeout < 1000) {
|
||||
errors.push('Socket timeout must be a number >= 1000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxConnections !== undefined) {
|
||||
if (typeof options.maxConnections !== 'number' || options.maxConnections < 1) {
|
||||
errors.push('Max connections must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxMessages !== undefined) {
|
||||
if (typeof options.maxMessages !== 'number' || options.maxMessages < 1) {
|
||||
errors.push('Max messages must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate authentication options
|
||||
if (options.auth) {
|
||||
errors.push(...validateAuthOptions(options.auth));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authentication options
|
||||
*/
|
||||
export function validateAuthOptions(auth: ISmtpAuthOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (auth.method && !['PLAIN', 'LOGIN', 'OAUTH2', 'AUTO'].includes(auth.method)) {
|
||||
errors.push('Invalid authentication method');
|
||||
}
|
||||
|
||||
// For basic auth, require user and pass
|
||||
if ((auth.user || auth.pass) && (!auth.user || !auth.pass)) {
|
||||
errors.push('Both user and pass are required for basic authentication');
|
||||
}
|
||||
|
||||
// For OAuth2, validate required fields
|
||||
if (auth.oauth2) {
|
||||
const oauth = auth.oauth2;
|
||||
if (!oauth.user || !oauth.clientId || !oauth.clientSecret || !oauth.refreshToken) {
|
||||
errors.push('OAuth2 requires user, clientId, clientSecret, and refreshToken');
|
||||
}
|
||||
|
||||
if (oauth.user && !validateEmailAddress(oauth.user)) {
|
||||
errors.push('OAuth2 user must be a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hostname format
|
||||
*/
|
||||
export function validateHostname(hostname: string): boolean {
|
||||
if (!hostname || typeof hostname !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic hostname validation (allow IP addresses and domain names)
|
||||
const hostnameRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$|^(?:\d{1,3}\.){3}\d{1,3}$|^\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$/;
|
||||
return hostnameRegex.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate port number
|
||||
*/
|
||||
export function validatePort(port: number): boolean {
|
||||
return typeof port === 'number' && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate domain name for EHLO
|
||||
*/
|
||||
export function validateAndSanitizeDomain(domain: string): string {
|
||||
if (!domain || typeof domain !== 'string') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
const sanitized = domain.trim().toLowerCase();
|
||||
if (validateHostname(sanitized)) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate recipient list
|
||||
*/
|
||||
export function validateRecipients(recipients: string | string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const recipientList = Array.isArray(recipients) ? recipients : [recipients];
|
||||
|
||||
for (const recipient of recipientList) {
|
||||
if (!validateEmailAddress(recipient)) {
|
||||
errors.push(`Invalid email address: ${recipient}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sender address
|
||||
*/
|
||||
export function validateSender(sender: string): boolean {
|
||||
return validateEmailAddress(sender);
|
||||
}
|
||||
157
ts/mail/routing/classes.dkim.manager.ts
Normal file
157
ts/mail/routing/classes.dkim.manager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { logger } from '../../logger.js';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
import { DomainRegistry } from './classes.domain.registry.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
|
||||
/** External DcRouter interface shape used by DkimManager */
|
||||
interface DcRouter {
|
||||
storageManager: any;
|
||||
dnsServer?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages DKIM key setup, rotation, and signing for all configured domains
|
||||
*/
|
||||
export class DkimManager {
|
||||
private dkimKeys: Map<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
private dkimCreator: DKIMCreator,
|
||||
private domainRegistry: DomainRegistry,
|
||||
private dcRouter: DcRouter,
|
||||
private rustBridge: RustSecurityBridge,
|
||||
) {}
|
||||
|
||||
async setupDkimForDomains(): Promise<void> {
|
||||
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||
|
||||
if (domainConfigs.length === 0) {
|
||||
logger.log('warn', 'No domains configured for DKIM');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const domainConfig of domainConfigs) {
|
||||
const domain = domainConfig.domain;
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
|
||||
try {
|
||||
let keyPair: { privateKey: string; publicKey: string };
|
||||
|
||||
try {
|
||||
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
||||
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
keyPair = await this.dkimCreator.createDKIMKeys();
|
||||
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
||||
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
||||
}
|
||||
|
||||
this.dkimKeys.set(domain, keyPair.privateKey);
|
||||
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkAndRotateDkimKeys(): Promise<void> {
|
||||
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||
|
||||
for (const domainConfig of domainConfigs) {
|
||||
const domain = domainConfig.domain;
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
|
||||
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
|
||||
const keySize = domainConfig.dkim?.keySize || 2048;
|
||||
|
||||
if (!rotateKeys) {
|
||||
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
|
||||
|
||||
if (needsRotation) {
|
||||
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
|
||||
|
||||
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
|
||||
|
||||
domainConfig.dkim = {
|
||||
...domainConfig.dkim,
|
||||
selector: newSelector
|
||||
};
|
||||
|
||||
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
||||
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
|
||||
const publicKeyBase64 = keyPair.publicKey
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||
|
||||
this.dcRouter.dnsServer.registerHandler(
|
||||
`${newSelector}._domainkey.${domain}`,
|
||||
['TXT'],
|
||||
() => ({
|
||||
name: `${newSelector}._domainkey.${domain}`,
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: ttl,
|
||||
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
|
||||
})
|
||||
);
|
||||
|
||||
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
|
||||
|
||||
await this.dcRouter.storageManager.set(
|
||||
`/email/dkim/${domain}/public.key`,
|
||||
keyPair.publicKey
|
||||
);
|
||||
}
|
||||
|
||||
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
|
||||
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
|
||||
});
|
||||
|
||||
} else {
|
||||
logger.log('debug', `DKIM keys for ${domain} are up to date`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
|
||||
|
||||
const signResult = await this.rustBridge.signDkim({
|
||||
rawMessage: rawEmail,
|
||||
domain,
|
||||
selector,
|
||||
privateKey,
|
||||
keyType,
|
||||
});
|
||||
|
||||
if (signResult.header) {
|
||||
email.addHeader('DKIM-Signature', signResult.header);
|
||||
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
getDkimKey(domain: string): string | undefined {
|
||||
return this.dkimKeys.get(domain);
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
|
||||
/**
|
||||
* Interface for DNS record information
|
||||
*/
|
||||
export interface IDnsRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
dnsSecEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS lookup options
|
||||
*/
|
||||
export interface IDnsLookupOptions {
|
||||
/** Cache time to live in milliseconds, 0 to disable caching */
|
||||
cacheTtl?: number;
|
||||
/** Timeout for DNS queries in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS verification result
|
||||
*/
|
||||
export interface IDnsVerificationResult {
|
||||
record: string;
|
||||
found: boolean;
|
||||
valid: boolean;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for DNS-related operations, including record lookups, verification, and generation
|
||||
*/
|
||||
export class DNSManager {
|
||||
public dkimCreator: DKIMCreator;
|
||||
private cache: Map<string, { data: any; expires: number }> = new Map();
|
||||
private defaultOptions: IDnsLookupOptions = {
|
||||
cacheTtl: 300000, // 5 minutes
|
||||
timeout: 5000 // 5 seconds
|
||||
};
|
||||
|
||||
constructor(dkimCreatorArg: DKIMCreator, options?: IDnsLookupOptions) {
|
||||
this.dkimCreator = dkimCreatorArg;
|
||||
|
||||
if (options) {
|
||||
this.defaultOptions = {
|
||||
...this.defaultOptions,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the DNS records directory exists
|
||||
plugins.fs.mkdirSync(paths.dnsRecordsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup MX records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of MX records sorted by priority
|
||||
*/
|
||||
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `mx:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
||||
|
||||
// Sort by priority
|
||||
records.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up MX records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup TXT records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of TXT records
|
||||
*/
|
||||
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `txt:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<string[][]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up TXT records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find specific TXT record by subdomain and prefix
|
||||
* @param domain Base domain
|
||||
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
||||
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
||||
* @param options Lookup options
|
||||
* @returns Matching TXT record or null if not found
|
||||
*/
|
||||
public async findTxtRecord(
|
||||
domain: string,
|
||||
subdomain: string = '',
|
||||
prefix: string = '',
|
||||
options?: IDnsLookupOptions
|
||||
): Promise<string | null> {
|
||||
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
||||
|
||||
try {
|
||||
const records = await this.lookupTxt(fullDomain, options);
|
||||
|
||||
for (const recordArray of records) {
|
||||
// TXT records can be split into chunks, join them
|
||||
const record = recordArray.join('');
|
||||
|
||||
if (!prefix || record.startsWith(prefix)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Domain might not exist or no TXT records
|
||||
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid SPF record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'SPF',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
||||
|
||||
if (spfRecord) {
|
||||
result.found = true;
|
||||
result.value = spfRecord;
|
||||
|
||||
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
||||
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
||||
result.valid = isValid;
|
||||
|
||||
if (!isValid) {
|
||||
result.error = 'SPF record format is invalid';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No SPF record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying SPF: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DKIM record
|
||||
* @param domain Domain to verify
|
||||
* @param selector DKIM selector (usually "mta" in our case)
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DKIM',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dkimSelector = `${selector}._domainkey`;
|
||||
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
||||
|
||||
if (dkimRecord) {
|
||||
result.found = true;
|
||||
result.value = dkimRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasP = dkimRecord.includes('p=');
|
||||
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DKIM record is missing required fields';
|
||||
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
||||
result.valid = false;
|
||||
result.error = 'DKIM record has invalid public key format';
|
||||
}
|
||||
} else {
|
||||
result.error = `No DKIM record found for selector ${selector}`;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DKIM: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DMARC record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DMARC',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dmarcDomain = `_dmarc.${domain}`;
|
||||
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
||||
|
||||
if (dmarcRecord) {
|
||||
result.found = true;
|
||||
result.value = dmarcRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasPolicy = dmarcRecord.includes('p=');
|
||||
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DMARC record is missing required fields';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No DMARC record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DMARC: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
||||
* @param domain Domain to check
|
||||
* @param dkimSelector DKIM selector
|
||||
* @returns Object with verification results for each record type
|
||||
*/
|
||||
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
|
||||
spf: IDnsVerificationResult;
|
||||
dkim: IDnsVerificationResult;
|
||||
dmarc: IDnsVerificationResult;
|
||||
}> {
|
||||
const [spf, dkim, dmarc] = await Promise.all([
|
||||
this.verifySpfRecord(domain),
|
||||
this.verifyDkimRecord(domain, dkimSelector),
|
||||
this.verifyDmarcRecord(domain)
|
||||
]);
|
||||
|
||||
return { spf, dkim, dmarc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended SPF record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the SPF record
|
||||
* @returns Generated SPF record
|
||||
*/
|
||||
public generateSpfRecord(domain: string, options: {
|
||||
includeMx?: boolean;
|
||||
includeA?: boolean;
|
||||
includeIps?: string[];
|
||||
includeSpf?: string[];
|
||||
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
includeMx = true,
|
||||
includeA = true,
|
||||
includeIps = [],
|
||||
includeSpf = [],
|
||||
policy = 'softfail'
|
||||
} = options;
|
||||
|
||||
let value = 'v=spf1';
|
||||
|
||||
if (includeMx) {
|
||||
value += ' mx';
|
||||
}
|
||||
|
||||
if (includeA) {
|
||||
value += ' a';
|
||||
}
|
||||
|
||||
// Add IP addresses
|
||||
for (const ip of includeIps) {
|
||||
if (ip.includes(':')) {
|
||||
value += ` ip6:${ip}`;
|
||||
} else {
|
||||
value += ` ip4:${ip}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add includes
|
||||
for (const include of includeSpf) {
|
||||
value += ` include:${include}`;
|
||||
}
|
||||
|
||||
// Add policy
|
||||
const policyMap = {
|
||||
'none': '?all',
|
||||
'neutral': '~all',
|
||||
'softfail': '~all',
|
||||
'fail': '-all',
|
||||
'reject': '-all'
|
||||
};
|
||||
|
||||
value += ` ${policyMap[policy]}`;
|
||||
|
||||
return {
|
||||
name: domain,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended DMARC record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the DMARC record
|
||||
* @returns Generated DMARC record
|
||||
*/
|
||||
public generateDmarcRecord(domain: string, options: {
|
||||
policy?: 'none' | 'quarantine' | 'reject';
|
||||
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
|
||||
pct?: number;
|
||||
rua?: string;
|
||||
ruf?: string;
|
||||
daysInterval?: number;
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
policy = 'none',
|
||||
subdomainPolicy,
|
||||
pct = 100,
|
||||
rua,
|
||||
ruf,
|
||||
daysInterval = 1
|
||||
} = options;
|
||||
|
||||
let value = 'v=DMARC1; p=' + policy;
|
||||
|
||||
if (subdomainPolicy) {
|
||||
value += `; sp=${subdomainPolicy}`;
|
||||
}
|
||||
|
||||
if (pct !== 100) {
|
||||
value += `; pct=${pct}`;
|
||||
}
|
||||
|
||||
if (rua) {
|
||||
value += `; rua=mailto:${rua}`;
|
||||
}
|
||||
|
||||
if (ruf) {
|
||||
value += `; ruf=mailto:${ruf}`;
|
||||
}
|
||||
|
||||
if (daysInterval !== 1) {
|
||||
value += `; ri=${daysInterval * 86400}`;
|
||||
}
|
||||
|
||||
// Add reporting format and ADKIM/ASPF alignment
|
||||
value += '; fo=1; adkim=r; aspf=r';
|
||||
|
||||
return {
|
||||
name: `_dmarc.${domain}`,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save DNS record recommendations to a file
|
||||
* @param domain Domain name
|
||||
* @param records DNS records to save
|
||||
*/
|
||||
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
|
||||
try {
|
||||
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
|
||||
await plugins.smartfs.file(filePath).write(JSON.stringify(records, null, 2));
|
||||
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key value
|
||||
* @param key Cache key
|
||||
* @returns Cached value or undefined if not found or expired
|
||||
*/
|
||||
private getFromCache<T>(key: string): T | undefined {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
// Remove expired entry
|
||||
if (cached) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache key value
|
||||
* @param key Cache key
|
||||
* @param data Data to cache
|
||||
* @param ttl TTL in milliseconds
|
||||
*/
|
||||
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
|
||||
if (ttl <= 0) return; // Don't cache if TTL is disabled
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expires: Date.now() + ttl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the DNS cache
|
||||
* @param key Optional specific key to clear, or all cache if not provided
|
||||
*/
|
||||
public clearCache(key?: string): void {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveMx
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to MX records
|
||||
*/
|
||||
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveTxt
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to TXT records
|
||||
*/
|
||||
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveTxt(domain, (err, records) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(records);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all recommended DNS records for proper email authentication
|
||||
* @param domain Domain to generate records for
|
||||
* @returns Array of recommended DNS records
|
||||
*/
|
||||
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
|
||||
const records: IDnsRecord[] = [];
|
||||
|
||||
// Get DKIM record (already created by DKIMCreator)
|
||||
try {
|
||||
// Call the DKIM creator directly
|
||||
const dkimRecord = await this.dkimCreator.getDNSRecordForDomain(domain);
|
||||
records.push(dkimRecord);
|
||||
} catch (error) {
|
||||
console.error(`Error getting DKIM record for ${domain}:`, error);
|
||||
}
|
||||
|
||||
// Generate SPF record
|
||||
const spfRecord = this.generateSpfRecord(domain, {
|
||||
includeMx: true,
|
||||
includeA: true,
|
||||
policy: 'softfail'
|
||||
});
|
||||
records.push(spfRecord);
|
||||
|
||||
// Generate DMARC record
|
||||
const dmarcRecord = this.generateDmarcRecord(domain, {
|
||||
policy: 'none', // Start with monitoring mode
|
||||
rua: `dmarc@${domain}` // Replace with appropriate report address
|
||||
});
|
||||
records.push(dmarcRecord);
|
||||
|
||||
// Save recommendations
|
||||
await this.saveDnsRecommendations(domain, records);
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
175
ts/mail/routing/classes.email.action.executor.ts
Normal file
175
ts/mail/routing/classes.email.action.executor.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import type { IEmailAction, IEmailContext } from './interfaces.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import { BounceManager } from '../core/classes.bouncemanager.js';
|
||||
import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js';
|
||||
import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
/**
|
||||
* Dependencies injected from UnifiedEmailServer to avoid circular imports
|
||||
*/
|
||||
export interface IActionExecutorDeps {
|
||||
sendOutboundEmail: (host: string, port: number, email: Email, options?: {
|
||||
auth?: { user: string; pass: string };
|
||||
dkimDomain?: string;
|
||||
dkimSelector?: string;
|
||||
tlsOpportunistic?: boolean;
|
||||
}) => Promise<ISmtpSendResult>;
|
||||
bounceManager: BounceManager;
|
||||
deliveryQueue: UnifiedDeliveryQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes email routing actions (forward, process, deliver, reject)
|
||||
*/
|
||||
export class EmailActionExecutor {
|
||||
constructor(private deps: IActionExecutorDeps) {}
|
||||
|
||||
async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||
switch (action.type) {
|
||||
case 'forward':
|
||||
await this.handleForwardAction(action, email, context);
|
||||
break;
|
||||
case 'process':
|
||||
await this.handleProcessAction(action, email, context);
|
||||
break;
|
||||
case 'deliver':
|
||||
await this.handleDeliverAction(action, email, context);
|
||||
break;
|
||||
case 'reject':
|
||||
await this.handleRejectAction(action, email, context);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${(action as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||
if (!action.forward) {
|
||||
throw new Error('Forward action requires forward configuration');
|
||||
}
|
||||
|
||||
const { host, port = 25, auth, addHeaders } = action.forward;
|
||||
|
||||
logger.log('info', `Forwarding email to ${host}:${port}`);
|
||||
|
||||
// Add forwarding headers
|
||||
if (addHeaders) {
|
||||
for (const [key, value] of Object.entries(addHeaders)) {
|
||||
email.headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Add standard forwarding headers
|
||||
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
|
||||
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
||||
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Send email via Rust SMTP client
|
||||
await this.deps.sendOutboundEmail(host, port, email, {
|
||||
auth: auth as { user: string; pass: string } | undefined,
|
||||
});
|
||||
|
||||
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_FORWARDING,
|
||||
message: 'Email forwarded successfully',
|
||||
ipAddress: context.session.remoteAddress,
|
||||
details: {
|
||||
sessionId: context.session.id,
|
||||
routeName: context.session.matchedRoute?.name,
|
||||
targetHost: host,
|
||||
targetPort: port,
|
||||
recipients: email.to
|
||||
},
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_FORWARDING,
|
||||
message: 'Email forwarding failed',
|
||||
ipAddress: context.session.remoteAddress,
|
||||
details: {
|
||||
sessionId: context.session.id,
|
||||
routeName: context.session.matchedRoute?.name,
|
||||
targetHost: host,
|
||||
targetPort: port,
|
||||
error: error.message
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
// Handle as bounce
|
||||
for (const recipient of email.getAllRecipients()) {
|
||||
await this.deps.bounceManager.processSmtpFailure(recipient, error.message, {
|
||||
sender: email.from,
|
||||
originalEmailId: email.headers['Message-ID'] as string
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||
logger.log('info', `Processing email with action options`);
|
||||
|
||||
// Apply scanning if requested
|
||||
if (action.process?.scan) {
|
||||
logger.log('info', 'Content scanning requested');
|
||||
}
|
||||
|
||||
// Queue for delivery
|
||||
const queue = action.process?.queue || 'normal';
|
||||
await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
|
||||
|
||||
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
||||
}
|
||||
|
||||
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||
logger.log('info', `Delivering email locally`);
|
||||
|
||||
// Queue for local delivery
|
||||
await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
|
||||
|
||||
logger.log('info', 'Email queued for local delivery');
|
||||
}
|
||||
|
||||
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
|
||||
const code = action.reject?.code || 550;
|
||||
const message = action.reject?.message || 'Message rejected';
|
||||
|
||||
logger.log('info', `Rejecting email with code ${code}: ${message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email rejected by routing rule',
|
||||
ipAddress: context.session.remoteAddress,
|
||||
details: {
|
||||
sessionId: context.session.id,
|
||||
routeName: context.session.matchedRoute?.name,
|
||||
rejectCode: code,
|
||||
rejectMessage: message,
|
||||
from: _email.from,
|
||||
to: _email.to
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
// Throw error with SMTP code and message
|
||||
const error = new Error(message);
|
||||
(error as any).responseCode = code;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { EmailProcessingMode } from '../delivery/interfaces.js';
|
||||
|
||||
// Re-export EmailProcessingMode type
|
||||
export type { EmailProcessingMode };
|
||||
|
||||
|
||||
/**
|
||||
* Domain rule interface for pattern-based routing
|
||||
*/
|
||||
export interface IDomainRule {
|
||||
// Domain pattern (e.g., "*@example.com", "*@*.example.net")
|
||||
pattern: string;
|
||||
|
||||
// Handling mode for this pattern
|
||||
mode: EmailProcessingMode;
|
||||
|
||||
// Forward mode configuration
|
||||
target?: {
|
||||
server: string;
|
||||
port?: number;
|
||||
useTls?: boolean;
|
||||
authentication?: {
|
||||
user?: string;
|
||||
pass?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// MTA mode configuration
|
||||
mtaOptions?: IMtaOptions;
|
||||
|
||||
// Process mode configuration
|
||||
contentScanning?: boolean;
|
||||
scanners?: IContentScanner[];
|
||||
transformations?: ITransformation[];
|
||||
|
||||
// Rate limits for this domain
|
||||
rateLimits?: {
|
||||
maxMessagesPerMinute?: number;
|
||||
maxRecipientsPerMessage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MTA options interface
|
||||
*/
|
||||
export interface IMtaOptions {
|
||||
domain?: string;
|
||||
allowLocalDelivery?: boolean;
|
||||
localDeliveryPath?: string;
|
||||
dkimSign?: boolean;
|
||||
dkimOptions?: {
|
||||
domainName: string;
|
||||
keySelector: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
smtpBanner?: string;
|
||||
maxConnections?: number;
|
||||
connTimeout?: number;
|
||||
spoolDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content scanner interface
|
||||
*/
|
||||
export interface IContentScanner {
|
||||
type: 'spam' | 'virus' | 'attachment';
|
||||
threshold?: number;
|
||||
action: 'tag' | 'reject';
|
||||
blockedExtensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformation interface
|
||||
*/
|
||||
export interface ITransformation {
|
||||
type: string;
|
||||
header?: string;
|
||||
value?: string;
|
||||
domains?: string[];
|
||||
append?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,7 @@ export * from './classes.email.router.js';
|
||||
export * from './classes.unified.email.server.js';
|
||||
export * from './classes.dns.manager.js';
|
||||
export * from './interfaces.js';
|
||||
export * from './classes.domain.registry.js';
|
||||
export * from './classes.domain.registry.js';
|
||||
export * from './classes.email.action.executor.js';
|
||||
export * from './classes.dkim.manager.js';
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export class DKIMCreator {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a DKIM key pair - changed to public for API access
|
||||
// Create an RSA DKIM key pair - changed to public for API access
|
||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
@@ -126,6 +126,16 @@ export class DKIMCreator {
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Create an Ed25519 DKIM key pair (RFC 8463)
|
||||
public async createEd25519Keys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('ed25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Store a DKIM key pair - uses storage manager if available, else disk
|
||||
public async storeDKIMKeys(
|
||||
privateKey: string,
|
||||
@@ -176,8 +186,11 @@ export class DKIMCreator {
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
|
||||
|
||||
// Now generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `mta._domainkey.${domainArg}`,
|
||||
@@ -375,8 +388,11 @@ export class DKIMCreator {
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
|
||||
|
||||
// Generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
/**
|
||||
* Result of a DKIM verification
|
||||
*/
|
||||
export interface IDkimVerificationResult {
|
||||
isValid: boolean;
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
status?: string;
|
||||
details?: any;
|
||||
errorMessage?: string;
|
||||
signatureFields?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DKIM verifier — delegates to the Rust security bridge.
|
||||
*/
|
||||
export class DKIMVerifier {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Verify DKIM signature for an email via Rust bridge
|
||||
*/
|
||||
public async verify(
|
||||
emailData: string,
|
||||
options: {
|
||||
useCache?: boolean;
|
||||
returnDetails?: boolean;
|
||||
} = {}
|
||||
): Promise<IDkimVerificationResult> {
|
||||
try {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const results = await bridge.verifyDkim(emailData);
|
||||
const first = results[0];
|
||||
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: first?.is_valid ?? false,
|
||||
domain: first?.domain ?? undefined,
|
||||
selector: first?.selector ?? undefined,
|
||||
status: first?.status ?? 'none',
|
||||
details: options.returnDetails ? results : undefined,
|
||||
};
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`,
|
||||
details: { selector: result.selector, status: result.status },
|
||||
domain: result.domain || 'unknown',
|
||||
success: result.isValid
|
||||
});
|
||||
|
||||
logger.log(result.isValid ? 'info' : 'warn',
|
||||
`DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `DKIM verification failed: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification error`,
|
||||
details: { error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'temperror',
|
||||
errorMessage: `Verification error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** No-op — Rust bridge handles its own caching */
|
||||
public clearCache(): void {}
|
||||
|
||||
/** Always 0 — cache is managed by the Rust side */
|
||||
public getCacheSize(): number {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
|
||||
/**
|
||||
* DMARC policy types
|
||||
*/
|
||||
export enum DmarcPolicy {
|
||||
NONE = 'none',
|
||||
QUARANTINE = 'quarantine',
|
||||
REJECT = 'reject'
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC alignment modes
|
||||
*/
|
||||
export enum DmarcAlignment {
|
||||
RELAXED = 'r',
|
||||
STRICT = 's'
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC record fields
|
||||
*/
|
||||
export interface DmarcRecord {
|
||||
// Required fields
|
||||
version: string;
|
||||
policy: DmarcPolicy;
|
||||
|
||||
// Optional fields
|
||||
subdomainPolicy?: DmarcPolicy;
|
||||
pct?: number;
|
||||
adkim?: DmarcAlignment;
|
||||
aspf?: DmarcAlignment;
|
||||
reportInterval?: number;
|
||||
failureOptions?: string;
|
||||
reportUriAggregate?: string[];
|
||||
reportUriForensic?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC verification result
|
||||
*/
|
||||
export interface DmarcResult {
|
||||
hasDmarc: boolean;
|
||||
record?: DmarcRecord;
|
||||
spfDomainAligned: boolean;
|
||||
dkimDomainAligned: boolean;
|
||||
spfPassed: boolean;
|
||||
dkimPassed: boolean;
|
||||
policyEvaluated: DmarcPolicy;
|
||||
actualPolicy: DmarcPolicy;
|
||||
appliedPercentage: number;
|
||||
action: 'pass' | 'quarantine' | 'reject';
|
||||
details: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for verifying and enforcing DMARC policies
|
||||
*/
|
||||
export class DmarcVerifier {
|
||||
// DNS Manager reference for verifying records
|
||||
private dnsManager?: any;
|
||||
|
||||
constructor(dnsManager?: any) {
|
||||
this.dnsManager = dnsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DMARC record from a TXT record string
|
||||
* @param record DMARC TXT record string
|
||||
* @returns Parsed DMARC record or null if invalid
|
||||
*/
|
||||
public parseDmarcRecord(record: string): DmarcRecord | null {
|
||||
if (!record.startsWith('v=DMARC1')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize record with default values
|
||||
const dmarcRecord: DmarcRecord = {
|
||||
version: 'DMARC1',
|
||||
policy: DmarcPolicy.NONE,
|
||||
pct: 100,
|
||||
adkim: DmarcAlignment.RELAXED,
|
||||
aspf: DmarcAlignment.RELAXED
|
||||
};
|
||||
|
||||
// Split the record into tag/value pairs
|
||||
const parts = record.split(';').map(part => part.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || !part.includes('=')) continue;
|
||||
|
||||
const [tag, value] = part.split('=').map(p => p.trim());
|
||||
|
||||
// Process based on tag
|
||||
switch (tag.toLowerCase()) {
|
||||
case 'v':
|
||||
dmarcRecord.version = value;
|
||||
break;
|
||||
case 'p':
|
||||
dmarcRecord.policy = value as DmarcPolicy;
|
||||
break;
|
||||
case 'sp':
|
||||
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
|
||||
break;
|
||||
case 'pct':
|
||||
const pctValue = parseInt(value, 10);
|
||||
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
|
||||
dmarcRecord.pct = pctValue;
|
||||
}
|
||||
break;
|
||||
case 'adkim':
|
||||
dmarcRecord.adkim = value as DmarcAlignment;
|
||||
break;
|
||||
case 'aspf':
|
||||
dmarcRecord.aspf = value as DmarcAlignment;
|
||||
break;
|
||||
case 'ri':
|
||||
const interval = parseInt(value, 10);
|
||||
if (!isNaN(interval) && interval > 0) {
|
||||
dmarcRecord.reportInterval = interval;
|
||||
}
|
||||
break;
|
||||
case 'fo':
|
||||
dmarcRecord.failureOptions = value;
|
||||
break;
|
||||
case 'rua':
|
||||
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
|
||||
if (uri.startsWith('mailto:')) {
|
||||
return uri.substring(7).trim();
|
||||
}
|
||||
return uri.trim();
|
||||
});
|
||||
break;
|
||||
case 'ruf':
|
||||
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
|
||||
if (uri.startsWith('mailto:')) {
|
||||
return uri.substring(7).trim();
|
||||
}
|
||||
return uri.trim();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure subdomain policy is set if not explicitly provided
|
||||
if (!dmarcRecord.subdomainPolicy) {
|
||||
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
|
||||
}
|
||||
|
||||
return dmarcRecord;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
|
||||
record,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domains are aligned according to DMARC policy
|
||||
* @param headerDomain Domain from header (From)
|
||||
* @param authDomain Domain from authentication (SPF, DKIM)
|
||||
* @param alignment Alignment mode
|
||||
* @returns Whether the domains are aligned
|
||||
*/
|
||||
private isDomainAligned(
|
||||
headerDomain: string,
|
||||
authDomain: string,
|
||||
alignment: DmarcAlignment
|
||||
): boolean {
|
||||
if (!headerDomain || !authDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For strict alignment, domains must match exactly
|
||||
if (alignment === DmarcAlignment.STRICT) {
|
||||
return headerDomain.toLowerCase() === authDomain.toLowerCase();
|
||||
}
|
||||
|
||||
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
|
||||
// or the same as the header domain
|
||||
const headerParts = headerDomain.toLowerCase().split('.');
|
||||
const authParts = authDomain.toLowerCase().split('.');
|
||||
|
||||
// Ensures we have at least two parts (domain and TLD)
|
||||
if (headerParts.length < 2 || authParts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get organizational domain (last two parts)
|
||||
const headerOrgDomain = headerParts.slice(-2).join('.');
|
||||
const authOrgDomain = authParts.slice(-2).join('.');
|
||||
|
||||
return headerOrgDomain === authOrgDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from an email address
|
||||
* @param email Email address
|
||||
* @returns Domain part of the email
|
||||
*/
|
||||
private getDomainFromEmail(email: string): string {
|
||||
if (!email) return '';
|
||||
|
||||
// Handle name + email format: "John Doe <john@example.com>"
|
||||
const matches = email.match(/<([^>]+)>/);
|
||||
const address = matches ? matches[1] : email;
|
||||
|
||||
const parts = address.split('@');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DMARC verification should be applied based on percentage
|
||||
* @param record DMARC record
|
||||
* @returns Whether DMARC verification should be applied
|
||||
*/
|
||||
private shouldApplyDmarc(record: DmarcRecord): boolean {
|
||||
if (record.pct === undefined || record.pct === 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Apply DMARC randomly based on percentage
|
||||
const random = Math.floor(Math.random() * 100) + 1;
|
||||
return random <= record.pct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the action to take based on DMARC policy
|
||||
* @param policy DMARC policy
|
||||
* @returns Action to take
|
||||
*/
|
||||
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
|
||||
switch (policy) {
|
||||
case DmarcPolicy.REJECT:
|
||||
return 'reject';
|
||||
case DmarcPolicy.QUARANTINE:
|
||||
return 'quarantine';
|
||||
case DmarcPolicy.NONE:
|
||||
default:
|
||||
return 'pass';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify DMARC for an incoming email
|
||||
* @param email Email to verify
|
||||
* @param spfResult SPF verification result
|
||||
* @param dkimResult DKIM verification result
|
||||
* @returns DMARC verification result
|
||||
*/
|
||||
public async verify(
|
||||
email: Email,
|
||||
spfResult: { domain: string; result: boolean },
|
||||
dkimResult: { domain: string; result: boolean }
|
||||
): Promise<DmarcResult> {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
// Initialize result
|
||||
const result: DmarcResult = {
|
||||
hasDmarc: false,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: spfResult.result,
|
||||
dkimPassed: dkimResult.result,
|
||||
policyEvaluated: DmarcPolicy.NONE,
|
||||
actualPolicy: DmarcPolicy.NONE,
|
||||
appliedPercentage: 100,
|
||||
action: 'pass',
|
||||
details: 'DMARC not configured'
|
||||
};
|
||||
|
||||
try {
|
||||
// Extract From domain
|
||||
const fromHeader = email.getFromEmail();
|
||||
const fromDomain = this.getDomainFromEmail(fromHeader);
|
||||
|
||||
if (!fromDomain) {
|
||||
result.error = 'Invalid From domain';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check alignment
|
||||
result.spfDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
spfResult.domain,
|
||||
DmarcAlignment.RELAXED
|
||||
);
|
||||
|
||||
result.dkimDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
dkimResult.domain,
|
||||
DmarcAlignment.RELAXED
|
||||
);
|
||||
|
||||
// Lookup DMARC record
|
||||
const dmarcVerificationResult = this.dnsManager ?
|
||||
await this.dnsManager.verifyDmarcRecord(fromDomain) :
|
||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
||||
|
||||
// If DMARC record exists and is valid
|
||||
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
|
||||
result.hasDmarc = true;
|
||||
|
||||
// Parse DMARC record
|
||||
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
|
||||
|
||||
if (parsedRecord) {
|
||||
result.record = parsedRecord;
|
||||
result.actualPolicy = parsedRecord.policy;
|
||||
result.appliedPercentage = parsedRecord.pct || 100;
|
||||
|
||||
// Override alignment modes if specified in record
|
||||
if (parsedRecord.adkim) {
|
||||
result.dkimDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
dkimResult.domain,
|
||||
parsedRecord.adkim
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedRecord.aspf) {
|
||||
result.spfDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
spfResult.domain,
|
||||
parsedRecord.aspf
|
||||
);
|
||||
}
|
||||
|
||||
// Determine DMARC compliance
|
||||
const spfAligned = result.spfPassed && result.spfDomainAligned;
|
||||
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
|
||||
|
||||
// Email passes DMARC if either SPF or DKIM passes with alignment
|
||||
const dmarcPass = spfAligned || dkimAligned;
|
||||
|
||||
// Use record percentage to determine if policy should be applied
|
||||
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
|
||||
|
||||
if (!dmarcPass) {
|
||||
// DMARC failed, apply policy
|
||||
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
|
||||
result.action = this.determineAction(result.policyEvaluated);
|
||||
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
|
||||
} else {
|
||||
result.policyEvaluated = DmarcPolicy.NONE;
|
||||
result.action = 'pass';
|
||||
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
|
||||
}
|
||||
} else {
|
||||
result.error = 'Invalid DMARC record format';
|
||||
result.details = 'DMARC record invalid';
|
||||
}
|
||||
} else {
|
||||
// No DMARC record found or invalid
|
||||
result.details = dmarcVerificationResult.error || 'No DMARC record found';
|
||||
}
|
||||
|
||||
// Log the DMARC verification
|
||||
securityLogger.logEvent({
|
||||
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DMARC,
|
||||
message: result.details,
|
||||
domain: fromDomain,
|
||||
details: {
|
||||
fromDomain,
|
||||
spfDomain: spfResult.domain,
|
||||
dkimDomain: dkimResult.domain,
|
||||
spfPassed: result.spfPassed,
|
||||
dkimPassed: result.dkimPassed,
|
||||
spfAligned: result.spfDomainAligned,
|
||||
dkimAligned: result.dkimDomainAligned,
|
||||
dmarcPolicy: result.policyEvaluated,
|
||||
action: result.action
|
||||
},
|
||||
success: result.action === 'pass'
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error verifying DMARC: ${error.message}`, {
|
||||
error: error.message,
|
||||
emailId: email.getMessageId()
|
||||
});
|
||||
|
||||
result.error = `DMARC verification error: ${error.message}`;
|
||||
|
||||
// Log error
|
||||
securityLogger.logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DMARC,
|
||||
message: `DMARC verification failed with error`,
|
||||
details: {
|
||||
error: error.message,
|
||||
emailId: email.getMessageId()
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply DMARC policy to an email
|
||||
* @param email Email to apply policy to
|
||||
* @param dmarcResult DMARC verification result
|
||||
* @returns Whether the email should be accepted
|
||||
*/
|
||||
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
|
||||
// Apply action based on DMARC verification result
|
||||
switch (dmarcResult.action) {
|
||||
case 'reject':
|
||||
// Reject the email
|
||||
email.mightBeSpam = true;
|
||||
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
|
||||
emailId: email.getMessageId(),
|
||||
from: email.getFromEmail(),
|
||||
subject: email.subject
|
||||
});
|
||||
return false;
|
||||
|
||||
case 'quarantine':
|
||||
// Quarantine the email (mark as spam)
|
||||
email.mightBeSpam = true;
|
||||
|
||||
// Add spam header
|
||||
if (!email.headers['X-Spam-Flag']) {
|
||||
email.headers['X-Spam-Flag'] = 'YES';
|
||||
}
|
||||
|
||||
// Add DMARC reason header
|
||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||||
|
||||
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
|
||||
emailId: email.getMessageId(),
|
||||
from: email.getFromEmail(),
|
||||
subject: email.subject
|
||||
});
|
||||
return true;
|
||||
|
||||
case 'pass':
|
||||
default:
|
||||
// Accept the email
|
||||
// Add DMARC result header for information
|
||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end DMARC verification and policy application
|
||||
* This method should be called after SPF and DKIM verification
|
||||
* @param email Email to verify
|
||||
* @param spfResult SPF verification result
|
||||
* @param dkimResult DKIM verification result
|
||||
* @returns Whether the email should be accepted
|
||||
*/
|
||||
public async verifyAndApply(
|
||||
email: Email,
|
||||
spfResult: { domain: string; result: boolean },
|
||||
dkimResult: { domain: string; result: boolean }
|
||||
): Promise<boolean> {
|
||||
// Verify DMARC
|
||||
const dmarcResult = await this.verify(email, spfResult, dkimResult);
|
||||
|
||||
// Apply DMARC policy
|
||||
return this.applyPolicy(email, dmarcResult);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
|
||||
/**
|
||||
* SPF result qualifiers
|
||||
@@ -127,107 +123,4 @@ export class SpfVerifier {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify SPF for a given email — delegates to Rust bridge
|
||||
*/
|
||||
public async verify(
|
||||
email: Email,
|
||||
ip: string,
|
||||
heloDomain: string
|
||||
): Promise<SpfResult> {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
const mailFrom = email.from || '';
|
||||
const domain = mailFrom.split('@')[1] || '';
|
||||
|
||||
try {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const result = await bridge.checkSpf({
|
||||
ip,
|
||||
heloDomain,
|
||||
hostname: plugins.os.hostname(),
|
||||
mailFrom,
|
||||
});
|
||||
|
||||
const spfResult: SpfResult = {
|
||||
result: result.result as SpfResult['result'],
|
||||
domain: result.domain,
|
||||
ip: result.ip,
|
||||
explanation: result.explanation ?? undefined,
|
||||
};
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
|
||||
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
|
||||
domain: spfResult.domain,
|
||||
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
|
||||
success: spfResult.result === 'pass'
|
||||
});
|
||||
|
||||
return spfResult;
|
||||
} catch (error) {
|
||||
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF verification error for ${domain}`,
|
||||
domain,
|
||||
details: { ip, error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
return {
|
||||
result: 'temperror',
|
||||
explanation: `Error verifying SPF: ${error.message}`,
|
||||
domain,
|
||||
ip,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email passes SPF verification and apply headers
|
||||
*/
|
||||
public async verifyAndApply(
|
||||
email: Email,
|
||||
ip: string,
|
||||
heloDomain: string
|
||||
): Promise<boolean> {
|
||||
const result = await this.verify(email, ip, heloDomain);
|
||||
|
||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
||||
|
||||
switch (result.result) {
|
||||
case 'fail':
|
||||
email.mightBeSpam = true;
|
||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return false;
|
||||
|
||||
case 'softfail':
|
||||
email.mightBeSpam = true;
|
||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'neutral':
|
||||
case 'none':
|
||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'pass':
|
||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'temperror':
|
||||
case 'permerror':
|
||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Email security components
|
||||
export * from './classes.dkimcreator.js';
|
||||
export * from './classes.dkimverifier.js';
|
||||
export * from './classes.dmarcverifier.js';
|
||||
export * from './classes.spfverifier.js';
|
||||
@@ -21,61 +21,21 @@ export {
|
||||
util,
|
||||
}
|
||||
|
||||
// @serve.zone scope
|
||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||
|
||||
export {
|
||||
servezoneInterfaces
|
||||
}
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedserver from '@api.global/typedserver';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
typedrequest,
|
||||
typedserver,
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// @push.rocks scope
|
||||
import * as projectinfo from '@push.rocks/projectinfo';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartacme from '@push.rocks/smartacme';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartdns from '@push.rocks/smartdns';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import { SmartFs, SmartFsProviderNode } from '@push.rocks/smartfs';
|
||||
import * as smartguard from '@push.rocks/smartguard';
|
||||
import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmail from '@push.rocks/smartmail';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrule from '@push.rocks/smartrule';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
||||
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
||||
export { smartfile, SmartFs, smartlog, smartmail, smartpath, smartrust };
|
||||
|
||||
// Define SmartLog types for use in error handling
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
|
||||
// apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
|
||||
export {
|
||||
cloudflare,
|
||||
}
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC command type map — mirrors the methods in mailer-bin's management mode
|
||||
@@ -66,6 +67,72 @@ interface IContentScanResult {
|
||||
scannedElements: string[];
|
||||
}
|
||||
|
||||
// --- SMTP Client types ---
|
||||
|
||||
interface IOutboundEmail {
|
||||
from: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ISmtpSendResult {
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
messageId?: string;
|
||||
response: string;
|
||||
envelope: { from: string; to: string[] };
|
||||
}
|
||||
|
||||
interface ISmtpSendOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
secure?: boolean;
|
||||
domain?: string;
|
||||
auth?: { user: string; pass: string; method?: string };
|
||||
email: IOutboundEmail;
|
||||
dkim?: { domain: string; selector: string; privateKey: string; keyType?: string };
|
||||
connectionTimeoutSecs?: number;
|
||||
socketTimeoutSecs?: number;
|
||||
poolKey?: string;
|
||||
maxPoolConnections?: number;
|
||||
tlsOpportunistic?: boolean;
|
||||
}
|
||||
|
||||
interface ISmtpSendRawOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
secure?: boolean;
|
||||
domain?: string;
|
||||
auth?: { user: string; pass: string; method?: string };
|
||||
envelopeFrom: string;
|
||||
envelopeTo: string[];
|
||||
rawMessageBase64: string;
|
||||
poolKey?: string;
|
||||
}
|
||||
|
||||
interface ISmtpVerifyOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
secure?: boolean;
|
||||
domain?: string;
|
||||
auth?: { user: string; pass: string; method?: string };
|
||||
}
|
||||
|
||||
interface ISmtpVerifyResult {
|
||||
reachable: boolean;
|
||||
greeting?: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
interface ISmtpPoolStatus {
|
||||
pools: Record<string, { total: number; active: number; idle: number }>;
|
||||
}
|
||||
|
||||
interface IVersionInfo {
|
||||
bin: string;
|
||||
core: string;
|
||||
@@ -81,6 +148,7 @@ interface ISmtpServerConfig {
|
||||
securePort?: number;
|
||||
tlsCertPem?: string;
|
||||
tlsKeyPem?: string;
|
||||
additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
|
||||
maxMessageSize?: number;
|
||||
maxConnections?: number;
|
||||
maxRecipients?: number;
|
||||
@@ -127,6 +195,13 @@ interface IAuthRequestEvent {
|
||||
remoteAddr: string;
|
||||
}
|
||||
|
||||
interface IScramCredentialRequestEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
remoteAddr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe command map for the mailer-bin IPC bridge.
|
||||
*/
|
||||
@@ -156,7 +231,7 @@ type TMailerCommands = {
|
||||
result: IDkimVerificationResult[];
|
||||
};
|
||||
signDkim: {
|
||||
params: { rawMessage: string; domain: string; selector?: string; privateKey: string };
|
||||
params: { rawMessage: string; domain: string; selector?: string; privateKey: string; keyType?: string };
|
||||
result: { header: string; signedMessage: string };
|
||||
};
|
||||
checkSpf: {
|
||||
@@ -207,10 +282,70 @@ type TMailerCommands = {
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
scramCredentialResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
found: boolean;
|
||||
salt?: string;
|
||||
iterations?: number;
|
||||
storedKey?: string;
|
||||
serverKey?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
configureRateLimits: {
|
||||
params: IRateLimitConfig;
|
||||
result: { configured: boolean };
|
||||
};
|
||||
sendEmail: {
|
||||
params: ISmtpSendOptions;
|
||||
result: ISmtpSendResult;
|
||||
};
|
||||
sendRawEmail: {
|
||||
params: ISmtpSendRawOptions;
|
||||
result: ISmtpSendResult;
|
||||
};
|
||||
verifySmtpConnection: {
|
||||
params: ISmtpVerifyOptions;
|
||||
result: ISmtpVerifyResult;
|
||||
};
|
||||
closeSmtpPool: {
|
||||
params: { poolKey?: string };
|
||||
result: { closed: boolean };
|
||||
};
|
||||
getSmtpPoolStatus: {
|
||||
params: Record<string, never>;
|
||||
result: ISmtpPoolStatus;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum BridgeState {
|
||||
Idle = 'idle',
|
||||
Starting = 'starting',
|
||||
Running = 'running',
|
||||
Restarting = 'restarting',
|
||||
Failed = 'failed',
|
||||
Stopped = 'stopped',
|
||||
}
|
||||
|
||||
export interface IBridgeResilienceConfig {
|
||||
maxRestartAttempts: number;
|
||||
healthCheckIntervalMs: number;
|
||||
restartBackoffBaseMs: number;
|
||||
restartBackoffMaxMs: number;
|
||||
healthCheckTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RESILIENCE_CONFIG: IBridgeResilienceConfig = {
|
||||
maxRestartAttempts: 5,
|
||||
healthCheckIntervalMs: 30_000,
|
||||
restartBackoffBaseMs: 1_000,
|
||||
restartBackoffMaxMs: 30_000,
|
||||
healthCheckTimeoutMs: 5_000,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -222,14 +357,26 @@ type TMailerCommands = {
|
||||
*
|
||||
* Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC.
|
||||
* Singleton — access via `RustSecurityBridge.getInstance()`.
|
||||
*
|
||||
* Features resilience via auto-restart with exponential backoff,
|
||||
* periodic health checks, and a state machine that tracks the
|
||||
* bridge lifecycle.
|
||||
*/
|
||||
export class RustSecurityBridge {
|
||||
export class RustSecurityBridge extends EventEmitter {
|
||||
private static instance: RustSecurityBridge | null = null;
|
||||
private static _resilienceConfig: IBridgeResilienceConfig = { ...DEFAULT_RESILIENCE_CONFIG };
|
||||
|
||||
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TMailerCommands>>;
|
||||
private _running = false;
|
||||
private _state: BridgeState = BridgeState.Idle;
|
||||
private _restartAttempts = 0;
|
||||
private _restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _deliberateStop = false;
|
||||
private _smtpServerConfig: ISmtpServerConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.bridge = new plugins.smartrust.RustBridge<TMailerCommands>({
|
||||
binaryName: 'mailer-bin',
|
||||
cliArgs: ['--management'],
|
||||
@@ -252,6 +399,13 @@ export class RustSecurityBridge {
|
||||
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||
this._running = false;
|
||||
logger.log('warn', `Rust security bridge exited (code=${code}, signal=${signal})`);
|
||||
|
||||
if (this._deliberateStop) {
|
||||
this.setState(BridgeState.Stopped);
|
||||
} else if (this._state === BridgeState.Running) {
|
||||
// Unexpected exit — attempt restart
|
||||
this.attemptRestart();
|
||||
}
|
||||
});
|
||||
|
||||
this.bridge.on('stderr', (line: string) => {
|
||||
@@ -259,6 +413,10 @@ export class RustSecurityBridge {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Static configuration & singleton
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Get or create the singleton instance. */
|
||||
public static getInstance(): RustSecurityBridge {
|
||||
if (!RustSecurityBridge.instance) {
|
||||
@@ -267,11 +425,73 @@ export class RustSecurityBridge {
|
||||
return RustSecurityBridge.instance;
|
||||
}
|
||||
|
||||
/** Reset the singleton instance (for testing). */
|
||||
public static resetInstance(): void {
|
||||
if (RustSecurityBridge.instance) {
|
||||
RustSecurityBridge.instance.stopHealthCheck();
|
||||
if (RustSecurityBridge.instance._restartTimer) {
|
||||
clearTimeout(RustSecurityBridge.instance._restartTimer);
|
||||
RustSecurityBridge.instance._restartTimer = null;
|
||||
}
|
||||
RustSecurityBridge.instance.removeAllListeners();
|
||||
}
|
||||
RustSecurityBridge.instance = null;
|
||||
}
|
||||
|
||||
/** Configure resilience parameters. Can be called before or after getInstance(). */
|
||||
public static configure(config: Partial<IBridgeResilienceConfig>): void {
|
||||
RustSecurityBridge._resilienceConfig = {
|
||||
...RustSecurityBridge._resilienceConfig,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Current bridge state. */
|
||||
public get state(): BridgeState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** Whether the Rust process is currently running and accepting commands. */
|
||||
public get running(): boolean {
|
||||
return this._running;
|
||||
}
|
||||
|
||||
private setState(newState: BridgeState): void {
|
||||
const oldState = this._state;
|
||||
if (oldState === newState) return;
|
||||
this._state = newState;
|
||||
logger.log('info', `Rust bridge state: ${oldState} -> ${newState}`);
|
||||
this.emit('stateChange', { oldState, newState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a descriptive error if the bridge is not in Running state.
|
||||
* Called at the top of every command method.
|
||||
*/
|
||||
private ensureRunning(): void {
|
||||
if (this._state === BridgeState.Running && this._running) {
|
||||
return;
|
||||
}
|
||||
switch (this._state) {
|
||||
case BridgeState.Idle:
|
||||
throw new Error('Rust bridge has not been started yet. Call start() first.');
|
||||
case BridgeState.Starting:
|
||||
throw new Error('Rust bridge is still starting. Wait for start() to resolve.');
|
||||
case BridgeState.Restarting:
|
||||
throw new Error('Rust bridge is restarting after a crash. Commands will resume once it recovers.');
|
||||
case BridgeState.Failed:
|
||||
throw new Error('Rust bridge has failed after exhausting all restart attempts.');
|
||||
case BridgeState.Stopped:
|
||||
throw new Error('Rust bridge has been stopped. Call start() to restart it.');
|
||||
default:
|
||||
throw new Error(`Rust bridge is not running (state=${this._state}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -281,55 +501,195 @@ export class RustSecurityBridge {
|
||||
* @returns `true` if the binary started successfully, `false` otherwise.
|
||||
*/
|
||||
public async start(): Promise<boolean> {
|
||||
if (this._running) {
|
||||
if (this._running && this._state === BridgeState.Running) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this._deliberateStop = false;
|
||||
this._restartAttempts = 0;
|
||||
this.setState(BridgeState.Starting);
|
||||
|
||||
try {
|
||||
const ok = await this.bridge.spawn();
|
||||
this._running = ok;
|
||||
if (ok) {
|
||||
this.setState(BridgeState.Running);
|
||||
this.startHealthCheck();
|
||||
logger.log('info', 'Rust security bridge started');
|
||||
} else {
|
||||
this.setState(BridgeState.Failed);
|
||||
logger.log('warn', 'Rust security bridge failed to start (binary not found or timeout)');
|
||||
}
|
||||
return ok;
|
||||
} catch (err) {
|
||||
this.setState(BridgeState.Failed);
|
||||
logger.log('error', `Failed to start Rust security bridge: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill the Rust process. */
|
||||
/** Kill the Rust process deliberately. */
|
||||
public async stop(): Promise<void> {
|
||||
this._deliberateStop = true;
|
||||
|
||||
// Cancel any pending restart
|
||||
if (this._restartTimer) {
|
||||
clearTimeout(this._restartTimer);
|
||||
this._restartTimer = null;
|
||||
}
|
||||
|
||||
this.stopHealthCheck();
|
||||
this._smtpServerConfig = null;
|
||||
|
||||
if (!this._running) {
|
||||
this.setState(BridgeState.Stopped);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.bridge.kill();
|
||||
this._running = false;
|
||||
this.setState(BridgeState.Stopped);
|
||||
logger.log('info', 'Rust security bridge stopped');
|
||||
} catch (err) {
|
||||
logger.log('error', `Error stopping Rust security bridge: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Auto-restart with exponential backoff
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private attemptRestart(): void {
|
||||
const config = RustSecurityBridge._resilienceConfig;
|
||||
this._restartAttempts++;
|
||||
|
||||
if (this._restartAttempts > config.maxRestartAttempts) {
|
||||
logger.log('error', `Rust bridge exceeded max restart attempts (${config.maxRestartAttempts}). Giving up.`);
|
||||
this.setState(BridgeState.Failed);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(BridgeState.Restarting);
|
||||
this.stopHealthCheck();
|
||||
|
||||
const delay = Math.min(
|
||||
config.restartBackoffBaseMs * Math.pow(2, this._restartAttempts - 1),
|
||||
config.restartBackoffMaxMs,
|
||||
);
|
||||
|
||||
logger.log('info', `Rust bridge restart attempt ${this._restartAttempts}/${config.maxRestartAttempts} in ${delay}ms`);
|
||||
|
||||
this._restartTimer = setTimeout(async () => {
|
||||
this._restartTimer = null;
|
||||
|
||||
// Guard: if stop() was called while we were waiting, don't restart
|
||||
if (this._deliberateStop) {
|
||||
this.setState(BridgeState.Stopped);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await this.bridge.spawn();
|
||||
this._running = ok;
|
||||
|
||||
if (ok) {
|
||||
logger.log('info', 'Rust bridge restarted successfully');
|
||||
this._restartAttempts = 0;
|
||||
this.setState(BridgeState.Running);
|
||||
this.startHealthCheck();
|
||||
await this.restoreAfterRestart();
|
||||
} else {
|
||||
logger.log('warn', 'Rust bridge restart failed (spawn returned false)');
|
||||
this.attemptRestart();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Rust bridge restart failed: ${(err as Error).message}`);
|
||||
this.attemptRestart();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state after a successful restart:
|
||||
* - Re-send startSmtpServer command if the SMTP server was running
|
||||
*/
|
||||
private async restoreAfterRestart(): Promise<void> {
|
||||
if (this._smtpServerConfig) {
|
||||
try {
|
||||
logger.log('info', 'Restoring SMTP server after bridge restart');
|
||||
const result = await this.bridge.sendCommand('startSmtpServer', this._smtpServerConfig);
|
||||
if (result?.started) {
|
||||
logger.log('info', 'SMTP server restored after bridge restart');
|
||||
} else {
|
||||
logger.log('warn', 'SMTP server failed to restore after bridge restart');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Failed to restore SMTP server after restart: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Health check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private startHealthCheck(): void {
|
||||
this.stopHealthCheck();
|
||||
const config = RustSecurityBridge._resilienceConfig;
|
||||
|
||||
this._healthCheckTimer = setInterval(async () => {
|
||||
if (this._state !== BridgeState.Running || !this._running) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pongPromise = this.bridge.sendCommand('ping', {} as any);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Health check timeout')), config.healthCheckTimeoutMs),
|
||||
);
|
||||
const res = await Promise.race([pongPromise, timeoutPromise]);
|
||||
if (!(res as any)?.pong) {
|
||||
throw new Error('Health check: unexpected ping response');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust bridge health check failed: ${(err as Error).message}. Killing process to trigger restart.`);
|
||||
try {
|
||||
this.bridge.kill();
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
// The exit handler will trigger attemptRestart()
|
||||
}
|
||||
}, config.healthCheckIntervalMs);
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this._healthCheckTimer) {
|
||||
clearInterval(this._healthCheckTimer);
|
||||
this._healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Commands — thin typed wrappers over sendCommand
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Ping the Rust process. */
|
||||
public async ping(): Promise<boolean> {
|
||||
this.ensureRunning();
|
||||
const res = await this.bridge.sendCommand('ping', {} as any);
|
||||
return res?.pong === true;
|
||||
}
|
||||
|
||||
/** Get version information for all Rust crates. */
|
||||
public async getVersion(): Promise<IVersionInfo> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('version', {} as any);
|
||||
}
|
||||
|
||||
/** Validate an email address. */
|
||||
public async validateEmail(email: string): Promise<IValidationResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('validateEmail', { email });
|
||||
}
|
||||
|
||||
@@ -339,6 +699,7 @@ export class RustSecurityBridge {
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
}): Promise<IBounceDetection> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('detectBounce', opts);
|
||||
}
|
||||
|
||||
@@ -349,26 +710,31 @@ export class RustSecurityBridge {
|
||||
htmlBody?: string;
|
||||
attachmentNames?: string[];
|
||||
}): Promise<IContentScanResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('scanContent', opts);
|
||||
}
|
||||
|
||||
/** Check IP reputation via DNSBL. */
|
||||
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('checkIpReputation', { ip });
|
||||
}
|
||||
|
||||
/** Verify DKIM signatures on a raw email message. */
|
||||
public async verifyDkim(rawMessage: string): Promise<IDkimVerificationResult[]> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
||||
}
|
||||
|
||||
/** Sign an email with DKIM. */
|
||||
/** Sign an email with DKIM (RSA or Ed25519). */
|
||||
public async signDkim(opts: {
|
||||
rawMessage: string;
|
||||
domain: string;
|
||||
selector?: string;
|
||||
privateKey: string;
|
||||
keyType?: string;
|
||||
}): Promise<{ header: string; signedMessage: string }> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('signDkim', opts);
|
||||
}
|
||||
|
||||
@@ -379,6 +745,7 @@ export class RustSecurityBridge {
|
||||
hostname?: string;
|
||||
mailFrom: string;
|
||||
}): Promise<ISpfResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('checkSpf', opts);
|
||||
}
|
||||
|
||||
@@ -395,9 +762,44 @@ export class RustSecurityBridge {
|
||||
hostname?: string;
|
||||
mailFrom: string;
|
||||
}): Promise<IEmailSecurityResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('verifyEmail', opts);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SMTP Client — outbound email delivery via Rust
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Send a structured email via the Rust SMTP client. */
|
||||
public async sendOutboundEmail(opts: ISmtpSendOptions): Promise<ISmtpSendResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('sendEmail', opts);
|
||||
}
|
||||
|
||||
/** Send a pre-formatted raw email via the Rust SMTP client. */
|
||||
public async sendRawEmail(opts: ISmtpSendRawOptions): Promise<ISmtpSendResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('sendRawEmail', opts);
|
||||
}
|
||||
|
||||
/** Verify connectivity to an SMTP server. */
|
||||
public async verifySmtpConnection(opts: ISmtpVerifyOptions): Promise<ISmtpVerifyResult> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('verifySmtpConnection', opts);
|
||||
}
|
||||
|
||||
/** Close a specific connection pool (or all pools if no key is given). */
|
||||
public async closeSmtpPool(poolKey?: string): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('closeSmtpPool', poolKey ? { poolKey } : ({} as any));
|
||||
}
|
||||
|
||||
/** Get status of all SMTP client connection pools. */
|
||||
public async getSmtpPoolStatus(): Promise<ISmtpPoolStatus> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('getSmtpPoolStatus', {} as any);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SMTP Server lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -408,12 +810,16 @@ export class RustSecurityBridge {
|
||||
* emailReceived and authRequest that must be handled by the caller.
|
||||
*/
|
||||
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
||||
this.ensureRunning();
|
||||
this._smtpServerConfig = config;
|
||||
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
||||
return result?.started === true;
|
||||
}
|
||||
|
||||
/** Stop the Rust SMTP server. */
|
||||
public async stopSmtpServer(): Promise<void> {
|
||||
this.ensureRunning();
|
||||
this._smtpServerConfig = null;
|
||||
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
||||
}
|
||||
|
||||
@@ -428,6 +834,7 @@ export class RustSecurityBridge {
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
}): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('emailProcessingResult', opts);
|
||||
}
|
||||
|
||||
@@ -439,11 +846,29 @@ export class RustSecurityBridge {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('authResult', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SCRAM credentials back to the Rust SMTP server.
|
||||
* Values (salt, storedKey, serverKey) must be base64-encoded.
|
||||
*/
|
||||
public async sendScramCredentialResult(opts: {
|
||||
correlationId: string;
|
||||
found: boolean;
|
||||
salt?: string;
|
||||
iterations?: number;
|
||||
storedKey?: string;
|
||||
serverKey?: string;
|
||||
}): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('scramCredentialResult', opts);
|
||||
}
|
||||
|
||||
/** Update rate limit configuration at runtime. */
|
||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('configureRateLimits', config);
|
||||
}
|
||||
|
||||
@@ -467,6 +892,14 @@ export class RustSecurityBridge {
|
||||
this.bridge.on('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for scramCredentialRequest events from the Rust SMTP server.
|
||||
* The handler must call sendScramCredentialResult() with the correlationId.
|
||||
*/
|
||||
public onScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
|
||||
this.bridge.on('management:scramCredentialRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove an emailReceived event handler. */
|
||||
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.off('management:emailReceived', handler);
|
||||
@@ -476,6 +909,11 @@ export class RustSecurityBridge {
|
||||
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.off('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove a scramCredentialRequest event handler. */
|
||||
public offScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
|
||||
this.bridge.off('management:scramCredentialRequest', handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export interfaces for consumers
|
||||
@@ -494,4 +932,12 @@ export type {
|
||||
IEmailData,
|
||||
IEmailReceivedEvent,
|
||||
IAuthRequestEvent,
|
||||
IScramCredentialRequestEvent,
|
||||
IOutboundEmail,
|
||||
ISmtpSendResult,
|
||||
ISmtpSendOptions,
|
||||
ISmtpSendRawOptions,
|
||||
ISmtpVerifyOptions,
|
||||
ISmtpVerifyResult,
|
||||
ISmtpPoolStatus,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ export {
|
||||
|
||||
export {
|
||||
RustSecurityBridge,
|
||||
BridgeState,
|
||||
type IBridgeResilienceConfig,
|
||||
type IDkimVerificationResult,
|
||||
type ISpfResult,
|
||||
type IDmarcResult,
|
||||
@@ -30,4 +32,9 @@ export {
|
||||
type IBounceDetection,
|
||||
type IRustReputationResult,
|
||||
type IVersionInfo,
|
||||
type IOutboundEmail,
|
||||
type ISmtpSendResult,
|
||||
type ISmtpSendOptions,
|
||||
type ISmtpVerifyResult,
|
||||
type ISmtpPoolStatus,
|
||||
} from './classes.rustsecuritybridge.js';
|
||||
Reference in New Issue
Block a user