22 Commits

Author SHA1 Message Date
08c5145d20 v5.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 16:06:34 +00:00
0515d2ae46 feat(packaging): add package exports entry, include ts/dist_ts in package files, and add TS barrel index re-exports 2026-02-11 16:06:34 +00:00
96b4ccb7d3 v5.1.3
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 5s
2026-02-11 14:24:07 +00:00
7c0c327913 fix(docs): clarify sendEmail default behavior and document automatic MX discovery and delivery modes 2026-02-11 14:24:07 +00:00
9e722874b4 v5.1.2
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 4s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:20:19 +00:00
873af43ef2 fix(readme): adjust ASCII architecture diagram alignment in README 2026-02-11 10:20:19 +00:00
76d898b648 v5.1.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:16:30 +00:00
b422639c34 fix(release): no changes 2026-02-11 10:16:30 +00:00
c45ba2a7b4 v5.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 7s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:11:43 +00:00
b10597fd5e feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements 2026-02-11 10:11:43 +00:00
7908cbaefa v5.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 07:55:28 +00:00
526dcb4dac 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 2026-02-11 07:55:28 +00:00
cf8fcb6efa v4.1.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 5s
2026-02-11 07:36:54 +00:00
2088c9f76e fix(readme): clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README 2026-02-11 07:36:54 +00:00
7853ef67b6 v4.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 07:31:08 +00:00
f7af8c4534 feat(e2e-tests): add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions 2026-02-11 07:31:08 +00:00
a7ea1d86cb v4.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 5s
2026-02-11 07:17:05 +00:00
27bab5f345 BREAKING CHANGE(smtp-client): Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery 2026-02-11 07:17:05 +00:00
fc4877e06b v3.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 23:23:00 +00:00
36006191fc 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 2026-02-10 23:23:00 +00:00
d43fc15d8e v2.4.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:43:50 +00:00
248bfcfe78 feat(docs): document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts 2026-02-10 22:43:50 +00:00
84 changed files with 6204 additions and 11672 deletions

View File

@@ -1,5 +1,102 @@
# Changelog
## 2026-02-11 - 5.2.0 - feat(packaging)
add package exports entry, include ts/dist_ts in package files, and add TS barrel index re-exports
- package.json: add "exports" mapping "." -> "./dist_ts/index.js" to provide a module entry point
- package.json: add "ts/**/*" and "dist_ts/**/*" to "files" so TypeScript sources and built output are published
- ts/index.ts: new barrel that re-exports './00_commitinfo_data.js', './mail/index.js', and './security/index.js'
## 2026-02-11 - 5.1.3 - fix(docs)
clarify sendEmail default behavior and document automatic MX discovery and delivery modes
- Updated README to describe automatic MX record discovery and grouping behavior when using sendEmail() (MTA mode)
- Added a Delivery Modes section and API signature for sendEmail(mode) describing mta, forward, and process options
- Expanded examples to show multi-recipient delivery, explicit mode usage, and retained low-level sendOutboundEmail example
## 2026-02-11 - 5.1.2 - fix(readme)
adjust ASCII architecture diagram alignment in README
- Whitespace and alignment tweaks to the ASCII architecture diagram in readme.md
- No code or behavior changes; documentation-only edit
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartmta",
"version": "2.3.2",
"version": "5.2.0",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [
"mta",
@@ -27,6 +27,9 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"type": "module",
"exports": {
".": "./dist_ts/index.js"
},
"bin": {
"mailer": "./bin/mailer-wrapper.js"
},
@@ -44,40 +47,20 @@
"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"
},
"files": [
"ts/**/*",
"dist_ts/**/*",
"bin/",
"scripts/install-binary.js",
"dist_rust/**/*",

1887
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

388
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartmta
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. Automatic MX record discovery means you just call `sendEmail()` and smartmta figures out where to deliver. 🚀
## Issue Reporting and Security
@@ -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,44 +37,51 @@ 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
```
┌──────────────────────────────────────────────────────────────┐
│ UnifiedEmailServer
│ (orchestrates all components, emits events)
│ UnifiedEmailServer │
│ (orchestrates all components, emits events) │
├───────────┬───────────┬──────────────┬───────────────────────┤
│ Email │ Security │ Delivery │ Configuration │
│ Router │ Stack │ System │ │
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │
│ │ │ Scan │ │ │ └────────────────┘
│ │ └───────┘ │ │ │
├───────────┴───────────┴──────────────┴───────────────────────┤
│ Rust Security Bridge (smartrust IPC)
│ Rust Security Bridge (smartrust IPC) │
├──────────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
└──────────────┘ └───────────────┘ └──────────────────┘
│ Rust Acceleration Layer │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │
│ │ 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 calls `sendEmail()` (defaults to MTA mode)
2. 🔍 MTA mode automatically resolves MX records for each recipient domain, sorts by priority, and groups recipients for efficient delivery
3. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
4. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
## Usage
@@ -163,32 +170,18 @@ 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 Emails (Automatic MX Discovery)
Create and send emails using the built-in SMTP client with connection pooling:
The recommended way to send email is `sendEmail()`. It defaults to **MTA mode**, which automatically resolves MX records for each recipient domain via DNS — you don't need to know the destination mail server:
```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!',
to: ['alice@gmail.com', 'bob@company.org'],
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,75 @@ const email = new Email({
],
});
// Send it
const result = await client.sendMail(email);
console.log(`Message sent: ${result.messageId}`);
// Send — MTA mode auto-discovers MX servers for gmail.com and company.org
const emailId = await emailServer.sendEmail(email);
// Optionally specify a delivery mode explicitly
const emailId2 = await emailServer.sendEmail(email, 'mta');
```
Additional client factories are available:
In MTA mode, smartmta:
- 🔍 Resolves MX records for each recipient domain (e.g. `gmail.com`, `company.org`)
- 📊 Sorts MX hosts by priority (lowest = highest priority per RFC 5321)
- 🔄 Tries each MX host in order until delivery succeeds
- 🌐 Falls back to the domain's A record if no MX records exist
- 📦 Groups recipients by domain for efficient batch delivery
- 🔑 Signs outbound mail with DKIM automatically
### 📮 Delivery Modes
`sendEmail()` accepts a mode parameter that controls how the email is delivered:
```typescript
// Pooled client for high-throughput scenarios
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
// Optimized for bulk sending
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
// Optimized for transactional emails
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
public async sendEmail(
email: Email,
mode: EmailProcessingMode = 'mta', // 'mta' | 'forward' | 'process'
route?: IEmailRoute,
options?: {
skipSuppressionCheck?: boolean;
ipAddress?: string;
isTransactional?: boolean;
}
): Promise<string>
```
### 🔑 DKIM Signing
| Mode | Description |
|---|---|
| `mta` (default) | **Auto MX discovery** — resolves MX records via DNS, delivers directly to the recipient's mail server. No relay configuration needed. |
| `forward` | **Relay delivery** — forwards the email to a configured SMTP host (e.g. an internal mail gateway or third-party relay). |
| `process` | **Scan + deliver** — runs the content scanning / security pipeline first, then delivers via auto MX resolution. |
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
### 📬 Direct SMTP Delivery (Low-Level)
For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level `sendOutboundEmail()`:
```typescript
// Send directly to a known SMTP server (bypasses MX resolution)
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
```
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
### 🔑 DKIM Signing & Key Management
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 +280,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 +334,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 +366,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 +388,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 +448,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 +487,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 +495,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 +509,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 +553,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
View File

@@ -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"

View File

@@ -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 }

View File

@@ -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"] }

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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"

View File

@@ -1,5 +0,0 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

View File

@@ -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(),
)
}

View File

@@ -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

View File

@@ -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("&nbsp;", " ")
.replace("&lt;", "<")

View File

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

View File

@@ -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);

View File

@@ -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};

View File

@@ -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

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

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

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

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

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

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

View File

@@ -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}"

View File

@@ -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,

View File

@@ -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())
}
}
}

View File

@@ -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;

View File

@@ -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]

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

View File

@@ -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)))
}

View File

@@ -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 ""

View File

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

View File

@@ -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
};
}

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

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

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

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

View File

@@ -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();

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

View File

@@ -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();

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

View File

@@ -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();

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

View File

@@ -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();

View File

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

View File

@@ -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
ts/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './00_commitinfo_data.js';
export * from './mail/index.js';
export * from './security/index.js';

View File

@@ -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';
/**

View File

@@ -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
*/

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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;

View File

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

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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;
}
}

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

View File

@@ -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

View File

@@ -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';

View File

@@ -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}`,

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,
};

View File

@@ -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';