Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d898b648 | |||
| b422639c34 | |||
| c45ba2a7b4 | |||
| b10597fd5e | |||
| 7908cbaefa | |||
| 526dcb4dac | |||
| cf8fcb6efa | |||
| 2088c9f76e |
42
changelog.md
42
changelog.md
@@ -1,5 +1,47 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-11 - 5.1.1 - fix(release)
|
||||||
|
no changes
|
||||||
|
|
||||||
|
- No files changed in this commit.
|
||||||
|
- Current package version remains 5.1.0 (from package.json).
|
||||||
|
|
||||||
|
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
|
||||||
|
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
|
||||||
|
|
||||||
|
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
|
||||||
|
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
|
||||||
|
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
|
||||||
|
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
|
||||||
|
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
|
||||||
|
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
|
||||||
|
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
|
||||||
|
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
|
||||||
|
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
|
||||||
|
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
|
||||||
|
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
|
||||||
|
|
||||||
|
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
|
||||||
|
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
|
||||||
|
|
||||||
|
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
|
||||||
|
- Removed ts/errors/index.ts — MTA-specific error classes removed
|
||||||
|
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
|
||||||
|
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
|
||||||
|
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
|
||||||
|
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
|
||||||
|
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
|
||||||
|
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
|
||||||
|
|
||||||
|
## 2026-02-11 - 4.1.1 - fix(readme)
|
||||||
|
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
|
||||||
|
|
||||||
|
- Changed IPC description to JSON-over-stdin/stdout (clarifies communication format between Rust and TypeScript)
|
||||||
|
- Added Rust SMTP client entry and documented outbound mail data flow (TypeScript -> Rust signing/delivery -> result back)
|
||||||
|
- Expanded testing instructions with commands for building Rust binary and running unit/E2E tests
|
||||||
|
- Updated architecture diagram labels and Rust crate/module descriptions (mailer-smtp now includes client; test counts noted)
|
||||||
|
- Documentation-only changes; no source code behavior modified
|
||||||
|
|
||||||
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
|
## 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
|
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "4.1.0",
|
"version": "5.1.1",
|
||||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
|
|||||||
318
readme.md
318
readme.md
@@ -18,14 +18,14 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
|||||||
|
|
||||||
## Overview
|
## 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
|
### ⚡ What's Inside
|
||||||
|
|
||||||
| Module | What It Does |
|
| Module | What It Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
| **Rust SMTP Server** | High-performance SMTP engine 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 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 |
|
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||||
| **SPF** | Full SPF record validation via Rust |
|
| **SPF** | Full SPF record validation via Rust |
|
||||||
| **DMARC** | Policy enforcement and verification |
|
| **DMARC** | Policy enforcement and verification |
|
||||||
@@ -37,8 +37,7 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
|||||||
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
||||||
| **Template Engine** | Email templates with variable substitution |
|
| **Template Engine** | Email templates with variable substitution |
|
||||||
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
||||||
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
|
| **DNS Manager** | Automatic DNS record management (MX, SPF, DKIM, DMARC) |
|
||||||
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
|
||||||
|
|
||||||
### 🏗️ Architecture
|
### 🏗️ Architecture
|
||||||
|
|
||||||
@@ -52,9 +51,9 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
|||||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||||
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
|
||||||
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
|
||||||
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
│ │ │ Scan │ │ │ └────────────────┘ │
|
||||||
│ │ └───────┘ │ │ │
|
│ │ └───────┘ │ │ │
|
||||||
├───────────┴───────────┴──────────────┴───────────────────────┤
|
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||||
│ Rust Security Bridge (smartrust IPC) │
|
│ Rust Security Bridge (smartrust IPC) │
|
||||||
@@ -63,18 +62,25 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
|||||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||||
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||||
|
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
|
||||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Data flow for inbound mail:**
|
**Data flow for inbound mail:**
|
||||||
|
|
||||||
1. Rust SMTP server accepts the connection and handles the full SMTP protocol
|
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
|
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
|
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)
|
4. 🔀 TypeScript processes the email (routing decisions using the pre-computed results, delivery)
|
||||||
5. Rust sends the final SMTP response to the client
|
5. ✅ Rust sends the final SMTP response to the client
|
||||||
|
|
||||||
|
**Data flow for outbound mail:**
|
||||||
|
|
||||||
|
1. 📝 TypeScript constructs the email and resolves DKIM keys for the sender domain
|
||||||
|
2. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
|
||||||
|
3. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -163,32 +169,19 @@ await emailServer.start();
|
|||||||
|
|
||||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||||
|
|
||||||
### 📧 Sending Emails with the SMTP Client
|
### 📧 Sending Outbound Emails
|
||||||
|
|
||||||
Create and send emails using the built-in SMTP client with connection pooling:
|
All outbound email delivery goes through the Rust SMTP client, accessed via `UnifiedEmailServer.sendOutboundEmail()`. The Rust client handles connection pooling, TLS negotiation, and DKIM signing automatically:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Email, Delivery } from '@push.rocks/smartmta';
|
import { Email, UnifiedEmailServer } 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build an email
|
// Build an email
|
||||||
const email = new Email({
|
const email = new Email({
|
||||||
from: 'sender@example.com',
|
from: 'sender@example.com',
|
||||||
to: ['recipient@example.com'],
|
to: ['recipient@example.com'],
|
||||||
cc: ['cc@example.com'],
|
cc: ['cc@example.com'],
|
||||||
subject: 'Hello from smartmta!',
|
subject: 'Hello from smartmta! 🚀',
|
||||||
text: 'Plain text body',
|
text: 'Plain text body',
|
||||||
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
@@ -201,32 +194,32 @@ const email = new Email({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send it
|
// Send via the Rust SMTP client (connection pooling, TLS, DKIM signing)
|
||||||
const result = await client.sendMail(email);
|
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
|
||||||
console.log(`Message sent: ${result.messageId}`);
|
auth: { user: 'sender@example.com', pass: 'your-password' },
|
||||||
|
dkimDomain: 'example.com',
|
||||||
|
dkimSelector: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Accepted: ${result.accepted.join(', ')}`);
|
||||||
|
console.log(`Response: ${result.response}`);
|
||||||
|
// -> Accepted: recipient@example.com
|
||||||
|
// -> Response: 2.0.0 Ok: queued
|
||||||
```
|
```
|
||||||
|
|
||||||
Additional client factories are available:
|
The `sendOutboundEmail` method:
|
||||||
|
- 🔑 Automatically resolves DKIM keys from the `DKIMCreator` for the specified domain
|
||||||
|
- 🔗 Uses connection pooling in Rust — reuses TCP/TLS connections across sends
|
||||||
|
- ⏱️ Configurable connection and socket timeouts via `outbound` options on the server
|
||||||
|
|
||||||
```typescript
|
### 🔑 DKIM Signing & Key Management
|
||||||
// Pooled client for high-throughput scenarios
|
|
||||||
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
|
||||||
|
|
||||||
// Optimized for bulk sending
|
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:
|
||||||
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
|
|
||||||
|
|
||||||
// Optimized for transactional emails
|
|
||||||
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔑 DKIM Signing
|
|
||||||
|
|
||||||
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DKIMCreator } from '@push.rocks/smartmta';
|
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
|
// Auto-generate keys if they don't exist
|
||||||
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
||||||
@@ -244,30 +237,34 @@ if (needsRotation) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the Rust security bridge's `signDkim()` method for maximum performance.
|
When `UnifiedEmailServer.start()` is called:
|
||||||
|
- DKIM keys are generated or loaded for every configured domain
|
||||||
|
- Signing is applied to all outbound mail via the Rust security bridge
|
||||||
|
- Key rotation is checked automatically based on your `rotationInterval` config
|
||||||
|
|
||||||
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
### 🛡️ 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
|
```typescript
|
||||||
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// SPF verification — first arg is an Email object
|
// SPF verification
|
||||||
const spfVerifier = new SpfVerifier();
|
const spfVerifier = new SpfVerifier();
|
||||||
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none', domain, ip }
|
||||||
// domain: string, ip: string }
|
|
||||||
|
|
||||||
// DKIM verification — takes raw email content
|
// DKIM verification
|
||||||
const dkimVerifier = new DKIMVerifier();
|
const dkimVerifier = new DKIMVerifier();
|
||||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
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 dmarcVerifier = new DmarcVerifier();
|
||||||
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||||
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
// -> { action: 'pass' | 'quarantine' | 'reject', policy, spfDomainAligned, dkimDomainAligned }
|
||||||
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔀 Email Routing
|
### 🔀 Email Routing
|
||||||
@@ -326,7 +323,16 @@ const router = new EmailRouter([
|
|||||||
const matchedRoute = await router.evaluateRoutes(emailContext);
|
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 |
|
| Criterion | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -339,52 +345,6 @@ const matchedRoute = await router.evaluateRoutes(emailContext);
|
|||||||
| `subject` | Subject line pattern (string or RegExp) |
|
| `subject` | Subject line pattern (string or RegExp) |
|
||||||
| `hasAttachments` | Filter by attachment presence |
|
| `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
|
### ⏱️ Rate Limiting
|
||||||
|
|
||||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||||
@@ -445,7 +405,7 @@ const bounce = await bounceManager.processSmtpFailure(
|
|||||||
// Check if an address is suppressed due to bounces
|
// Check if an address is suppressed due to bounces
|
||||||
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
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.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||||
bounceManager.removeFromSuppressionList('recovered@example.com');
|
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||||
```
|
```
|
||||||
@@ -484,7 +444,7 @@ const email = await templates.createEmail('welcome', {
|
|||||||
|
|
||||||
### 🌍 DNS Management
|
### 🌍 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
|
```typescript
|
||||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||||
@@ -506,99 +466,43 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
|||||||
await emailServer.start();
|
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
|
## 🦀 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 |
|
| Crate | Status | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
|
| `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-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
|
||||||
| `mailer-smtp` | ✅ Complete (77 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, in-process security pipeline, rate limiting |
|
| `mailer-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 — security, content scanning, SMTP server lifecycle |
|
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — wires everything together |
|
||||||
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
|
|
||||||
|
|
||||||
### What Runs in Rust
|
### What Runs Where
|
||||||
|
|
||||||
| Operation | Runs In | Why |
|
| Operation | Runs In | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
|
| SMTP server (port listening, protocol, TLS) | 🦀 Rust | Performance, memory safety, zero-copy parsing |
|
||||||
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
|
| SMTP client (outbound delivery, connection pooling) | 🦀 Rust | Connection management, TLS negotiation |
|
||||||
| SPF validation | Rust | DNS lookups with async resolver |
|
| DKIM signing & verification | 🦀 Rust | Crypto-heavy, benefits from native speed |
|
||||||
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
|
| SPF validation | 🦀 Rust | DNS lookups with async resolver |
|
||||||
| IP reputation / DNSBL | Rust | Parallel DNS queries |
|
| DMARC policy checking | 🦀 Rust | Integrates with SPF/DKIM results |
|
||||||
| Content scanning (text patterns) | Rust | Regex engine performance |
|
| IP reputation / DNSBL | 🦀 Rust | Parallel DNS queries |
|
||||||
| Bounce detection (pattern matching) | Rust | Regex engine performance |
|
| Content scanning (text patterns) | 🦀 Rust | Regex engine performance |
|
||||||
| Email validation & MIME building | Rust | Parsing performance |
|
| Bounce detection (pattern matching) | 🦀 Rust | Regex engine performance |
|
||||||
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
|
| Email validation & MIME building | 🦀 Rust | Parsing performance |
|
||||||
| Email routing & orchestration | TypeScript | Business logic, flexibility |
|
| Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
|
||||||
| Delivery queue & retry | TypeScript | State management, persistence |
|
| Delivery queue & retry | 🟦 TypeScript | State management, persistence |
|
||||||
| Template rendering | TypeScript | String interpolation |
|
| Template rendering | 🟦 TypeScript | String interpolation |
|
||||||
|
| Domain & DNS management | 🟦 TypeScript | API integrations |
|
||||||
|
|
||||||
## Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
smartmta/
|
smartmta/
|
||||||
├── ts/ # TypeScript source
|
├── ts/ # TypeScript source
|
||||||
│ ├── mail/
|
│ ├── mail/
|
||||||
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||||
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
|
||||||
│ │ │ └── smtpclient/ # SMTP client with connection pooling
|
|
||||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||||
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||||
@@ -606,14 +510,56 @@ smartmta/
|
|||||||
│ └── crates/
|
│ └── crates/
|
||||||
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
|
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
|
||||||
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
|
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
|
||||||
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
|
│ ├── mailer-smtp/ # Full SMTP server + client (TCP/TLS, rate limiting, pooling)
|
||||||
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
|
│ └── mailer-bin/ # CLI + smartrust IPC bridge
|
||||||
│ └── mailer-napi/ # N-API addon (planned)
|
├── test/ # Test suite (116 TypeScript + 154 Rust tests)
|
||||||
├── test/ # Test suite
|
|
||||||
├── dist_ts/ # Compiled TypeScript output
|
├── dist_ts/ # Compiled TypeScript output
|
||||||
└── dist_rust/ # Compiled Rust binaries
|
└── 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
|
## 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.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|||||||
4
rust/Cargo.lock
generated
4
rust/Cargo.lock
generated
@@ -982,6 +982,7 @@ dependencies = [
|
|||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
"mailer-smtp",
|
"mailer-smtp",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1025,15 +1026,18 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
|
"hmac",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
"mailparse",
|
"mailparse",
|
||||||
|
"pbkdf2",
|
||||||
"regex",
|
"regex",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
|||||||
@@ -29,3 +29,6 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
rustls-pki-types = "1"
|
rustls-pki-types = "1"
|
||||||
psl = "2"
|
psl = "2"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
pbkdf2 = { version = "0.12", default-features = false }
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ hickory-resolver.workspace = true
|
|||||||
dashmap.workspace = true
|
dashmap.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use mailer_smtp::connection::{
|
use mailer_smtp::connection::{
|
||||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// mailer-bin: Rust-powered email security tools
|
/// mailer-bin: Rust-powered email security tools
|
||||||
@@ -114,10 +114,11 @@ struct IpcEvent {
|
|||||||
|
|
||||||
// --- Pending callbacks for correlation-ID based reverse calls ---
|
// --- 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 {
|
struct PendingCallbacks {
|
||||||
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||||
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||||
|
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PendingCallbacks {
|
impl PendingCallbacks {
|
||||||
@@ -125,6 +126,7 @@ impl PendingCallbacks {
|
|||||||
Self {
|
Self {
|
||||||
email: DashMap::new(),
|
email: DashMap::new(),
|
||||||
auth: DashMap::new(),
|
auth: DashMap::new(),
|
||||||
|
scram: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
|
|||||||
self.auth.insert(correlation_id.to_string(), tx);
|
self.auth.insert(correlation_id.to_string(), tx);
|
||||||
rx
|
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() {
|
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();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
if cli.management {
|
if cli.management {
|
||||||
@@ -494,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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
|||||||
.get("privateKey")
|
.get("privateKey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.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 {
|
Ok(header) => IpcResponse {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
success: true,
|
success: true,
|
||||||
@@ -825,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
|||||||
handle_auth_result(req, state)
|
handle_auth_result(req, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"scramCredentialResult" => {
|
||||||
|
handle_scram_credential_result(req, state)
|
||||||
|
}
|
||||||
|
|
||||||
"configureRateLimits" => {
|
"configureRateLimits" => {
|
||||||
// Rate limit configuration is set at startSmtpServer time.
|
// Rate limit configuration is set at startSmtpServer time.
|
||||||
// This command allows runtime updates, but for now we acknowledge it.
|
// This command allows runtime updates, but for now we acknowledge it.
|
||||||
@@ -1010,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.
|
/// Parse SmtpServerConfig from IPC params JSON.
|
||||||
fn parse_smtp_config(
|
fn parse_smtp_config(
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
@@ -1075,6 +1165,27 @@ fn parse_smtp_config(
|
|||||||
config.processing_timeout_secs = timeout;
|
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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,11 +1298,12 @@ async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResp
|
|||||||
// Optional DKIM signing
|
// Optional DKIM signing
|
||||||
if let Some(dkim_val) = req.params.get("dkim") {
|
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()) {
|
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
|
||||||
match mailer_security::sign_dkim(
|
match mailer_security::sign_dkim_auto(
|
||||||
&raw_message,
|
&raw_message,
|
||||||
&dkim_config.domain,
|
&dkim_config.domain,
|
||||||
&dkim_config.selector,
|
&dkim_config.selector,
|
||||||
&dkim_config.private_key,
|
&dkim_config.private_key,
|
||||||
|
&dkim_config.key_type,
|
||||||
) {
|
) {
|
||||||
Ok(header) => {
|
Ok(header) => {
|
||||||
// Prepend DKIM header to the message
|
// Prepend DKIM header to the message
|
||||||
|
|||||||
@@ -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::common::headers::HeaderWriter;
|
||||||
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||||
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||||
@@ -118,9 +118,62 @@ pub fn sign_dkim(
|
|||||||
Ok(signature.to_header())
|
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.
|
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||||
///
|
///
|
||||||
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
/// 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 {
|
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||||
// Extract the base64 content from PEM
|
// Extract the base64 content from PEM
|
||||||
let key_b64: String = public_key_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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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");
|
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_dkim_ed25519() {
|
||||||
|
// Generate an Ed25519 key pair using mail-auth
|
||||||
|
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
|
||||||
|
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
|
||||||
|
let _pub_key = ed_key.public_key();
|
||||||
|
|
||||||
|
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
|
||||||
|
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let header = result.unwrap();
|
||||||
|
assert!(header.contains("a=ed25519-sha256"));
|
||||||
|
assert!(header.contains("d=example.com"));
|
||||||
|
assert!(header.contains("s=ed25519sel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_dkim_auto_dispatches() {
|
||||||
|
// RSA with invalid key should error
|
||||||
|
let msg = b"From: test@example.com\r\n\r\nBody";
|
||||||
|
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
// Ed25519 with invalid PEM should error
|
||||||
|
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dkim_dns_record_value_typed() {
|
||||||
|
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||||
|
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
|
||||||
|
assert!(rsa_record.contains("k=rsa"));
|
||||||
|
|
||||||
|
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
|
||||||
|
assert!(ed_record.contains("k=ed25519"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use hickory_resolver::TokioResolver;
|
use hickory_resolver::TokioResolver;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
|
|||||||
|
|
||||||
/// Check an IP against DNSBL servers.
|
/// 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)
|
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||||
/// * `resolver` - DNS resolver to use
|
/// * `resolver` - DNS resolver to use
|
||||||
pub async fn check_dnsbl(
|
pub async fn check_dnsbl(
|
||||||
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
|
|||||||
dnsbl_servers: &[&str],
|
dnsbl_servers: &[&str],
|
||||||
resolver: &TokioResolver,
|
resolver: &TokioResolver,
|
||||||
) -> Result<DnsblResult> {
|
) -> Result<DnsblResult> {
|
||||||
let ipv4 = match ip {
|
let reversed = match ip {
|
||||||
IpAddr::V4(v4) => v4,
|
IpAddr::V4(v4) => reverse_ipv4(v4),
|
||||||
IpAddr::V6(_) => {
|
IpAddr::V6(v6) => reverse_ipv6(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 = reverse_ipv4(ipv4);
|
|
||||||
let total = dnsbl_servers.len();
|
let total = dnsbl_servers.len();
|
||||||
|
|
||||||
// Query all DNSBL servers in parallel
|
// 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])
|
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.
|
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||||
/// Same heuristics as the TypeScript IPReputationChecker.
|
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||||
fn classify_ip(ip: IpAddr) -> IpType {
|
fn classify_ip(ip: IpAddr) -> IpType {
|
||||||
@@ -272,6 +277,38 @@ mod tests {
|
|||||||
assert!(!is_valid_ipv4("not-an-ip"));
|
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]
|
#[test]
|
||||||
fn test_default_dnsbl_servers() {
|
fn test_default_dnsbl_servers() {
|
||||||
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pub mod spf;
|
|||||||
pub mod verify;
|
pub mod verify;
|
||||||
|
|
||||||
// Re-exports for convenience
|
// 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 dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||||
pub use verify::{verify_email_security, EmailSecurityResult};
|
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||||
pub use error::{Result, SecurityError};
|
pub use error::{Result, SecurityError};
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
|
|||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
mailparse.workspace = true
|
mailparse.workspace = true
|
||||||
webpki-roots = "0.26"
|
webpki-roots = "0.26"
|
||||||
|
sha2.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
|
pbkdf2.workspace = true
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ pub struct SmtpClientConfig {
|
|||||||
/// Maximum connections per pool. Default: 10.
|
/// Maximum connections per pool. Default: 10.
|
||||||
#[serde(default = "default_max_pool_connections")]
|
#[serde(default = "default_max_pool_connections")]
|
||||||
pub max_pool_connections: usize,
|
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.
|
/// Authentication configuration.
|
||||||
@@ -60,8 +66,15 @@ pub struct DkimSignConfig {
|
|||||||
pub domain: String,
|
pub domain: String,
|
||||||
/// DKIM selector (e.g. "default" or "mta").
|
/// DKIM selector (e.g. "default" or "mta").
|
||||||
pub selector: String,
|
pub selector: String,
|
||||||
/// PEM-encoded RSA private key.
|
/// PEM-encoded private key (RSA or Ed25519 PKCS#8).
|
||||||
pub private_key: String,
|
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 {
|
impl SmtpClientConfig {
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ pub async fn connect_tls(
|
|||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
tls_opportunistic: bool,
|
||||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||||
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
||||||
let addr = format!("{host}:{port}");
|
let addr = format!("{host}:{port}");
|
||||||
@@ -130,7 +131,7 @@ pub async fn connect_tls(
|
|||||||
message: format!("Failed to connect to {addr}: {e}"),
|
message: format!("Failed to connect to {addr}: {e}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let tls_stream = perform_tls_handshake(tcp_stream, host).await?;
|
let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
|
||||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,24 +139,77 @@ pub async fn connect_tls(
|
|||||||
pub async fn upgrade_to_tls(
|
pub async fn upgrade_to_tls(
|
||||||
stream: ClientSmtpStream,
|
stream: ClientSmtpStream,
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
|
tls_opportunistic: bool,
|
||||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||||
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
||||||
let tcp_stream = stream.into_tcp_stream()?;
|
let tcp_stream = stream.into_tcp_stream()?;
|
||||||
let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?;
|
let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
|
||||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
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.
|
/// 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(
|
async fn perform_tls_handshake(
|
||||||
tcp_stream: TcpStream,
|
tcp_stream: TcpStream,
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
|
tls_opportunistic: bool,
|
||||||
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
|
) -> 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();
|
let mut root_store = rustls::RootCertStore::empty();
|
||||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
rustls::ClientConfig::builder()
|
||||||
let tls_config = rustls::ClientConfig::builder()
|
|
||||||
.with_root_certificates(root_store)
|
.with_root_certificates(root_store)
|
||||||
.with_no_client_auth();
|
.with_no_client_auth()
|
||||||
|
};
|
||||||
|
|
||||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
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| {
|
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
|
||||||
@@ -190,7 +244,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_connect_tls_refused() {
|
async fn test_connect_tls_refused() {
|
||||||
let result = connect_tls("127.0.0.1", 19998, 2).await;
|
let result = connect_tls("127.0.0.1", 19998, 2, false).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ impl ConnectionPool {
|
|||||||
&self.config.host,
|
&self.config.host,
|
||||||
self.config.port,
|
self.config.port,
|
||||||
self.config.connection_timeout_secs,
|
self.config.connection_timeout_secs,
|
||||||
|
self.config.tls_opportunistic,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +140,7 @@ impl ConnectionPool {
|
|||||||
if !self.config.secure && capabilities.starttls {
|
if !self.config.secure && capabilities.starttls {
|
||||||
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
||||||
stream =
|
stream =
|
||||||
super::connection::upgrade_to_tls(stream, &self.config.host).await?;
|
super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
|
||||||
|
|
||||||
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
||||||
capabilities = protocol::send_ehlo(
|
capabilities = protocol::send_ehlo(
|
||||||
@@ -244,9 +245,10 @@ impl SmtpClientManager {
|
|||||||
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the SMTP transaction
|
// Perform the SMTP transaction (use pipelining if server supports it)
|
||||||
|
let pipelining = conn.capabilities.pipelining;
|
||||||
let result =
|
let result =
|
||||||
Self::perform_send(&mut conn.stream, sender, recipients, message, config).await;
|
Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
|
||||||
|
|
||||||
// Re-acquire the pool lock and release the connection
|
// Re-acquire the pool lock and release the connection
|
||||||
let mut pool = pool_arc.lock().await;
|
let mut pool = pool_arc.lock().await;
|
||||||
@@ -268,13 +270,20 @@ impl SmtpClientManager {
|
|||||||
recipients: &[String],
|
recipients: &[String],
|
||||||
message: &[u8],
|
message: &[u8],
|
||||||
config: &SmtpClientConfig,
|
config: &SmtpClientConfig,
|
||||||
|
pipelining: bool,
|
||||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||||
let timeout_secs = config.socket_timeout_secs;
|
let timeout_secs = config.socket_timeout_secs;
|
||||||
|
|
||||||
// MAIL FROM
|
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?;
|
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||||
|
|
||||||
// RCPT TO for each recipient
|
|
||||||
let mut accepted = Vec::new();
|
let mut accepted = Vec::new();
|
||||||
let mut rejected = Vec::new();
|
let mut rejected = Vec::new();
|
||||||
|
|
||||||
@@ -292,6 +301,8 @@ impl SmtpClientManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(accepted, rejected)
|
||||||
|
};
|
||||||
|
|
||||||
// If no recipients were accepted, fail
|
// If no recipients were accepted, fail
|
||||||
if accepted.is_empty() {
|
if accepted.is_empty() {
|
||||||
@@ -339,6 +350,7 @@ impl SmtpClientManager {
|
|||||||
&config.host,
|
&config.host,
|
||||||
config.port,
|
config.port,
|
||||||
config.connection_timeout_secs,
|
config.connection_timeout_secs,
|
||||||
|
config.tls_opportunistic,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -318,6 +318,54 @@ pub async fn send_rcpt_to(
|
|||||||
Ok(resp)
|
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.
|
/// Send DATA command, followed by the message body with dot-stuffing.
|
||||||
pub async fn send_data(
|
pub async fn send_data(
|
||||||
stream: &mut ClientSmtpStream,
|
stream: &mut ClientSmtpStream,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub enum SmtpCommand {
|
|||||||
pub enum AuthMechanism {
|
pub enum AuthMechanism {
|
||||||
Plain,
|
Plain,
|
||||||
Login,
|
Login,
|
||||||
|
ScramSha256,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors that can occur during command parsing.
|
/// 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() {
|
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||||
"PLAIN" => AuthMechanism::Plain,
|
"PLAIN" => AuthMechanism::Plain,
|
||||||
"LOGIN" => AuthMechanism::Login,
|
"LOGIN" => AuthMechanism::Login,
|
||||||
|
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
|
||||||
other => {
|
other => {
|
||||||
return Err(ParseError::SyntaxError(format!(
|
return Err(ParseError::SyntaxError(format!(
|
||||||
"unsupported AUTH mechanism: {other}"
|
"unsupported AUTH mechanism: {other}"
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// Configuration for an SMTP server instance.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SmtpServerConfig {
|
pub struct SmtpServerConfig {
|
||||||
@@ -11,10 +22,13 @@ pub struct SmtpServerConfig {
|
|||||||
pub ports: Vec<u16>,
|
pub ports: Vec<u16>,
|
||||||
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
||||||
pub secure_port: Option<u16>,
|
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>,
|
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>,
|
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.
|
/// Maximum message size in bytes.
|
||||||
pub max_message_size: u64,
|
pub max_message_size: u64,
|
||||||
/// Maximum number of concurrent connections.
|
/// Maximum number of concurrent connections.
|
||||||
@@ -43,6 +57,7 @@ impl Default for SmtpServerConfig {
|
|||||||
secure_port: None,
|
secure_port: None,
|
||||||
tls_cert_pem: None,
|
tls_cert_pem: None,
|
||||||
tls_key_pem: None,
|
tls_key_pem: None,
|
||||||
|
additional_tls_certs: Vec::new(),
|
||||||
max_message_size: 10 * 1024 * 1024, // 10 MB
|
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||||
max_connections: 100,
|
max_connections: 100,
|
||||||
max_recipients: 100,
|
max_recipients: 100,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig;
|
|||||||
use crate::data::{DataAccumulator, DataAction};
|
use crate::data::{DataAccumulator, DataAction};
|
||||||
use crate::rate_limiter::RateLimiter;
|
use crate::rate_limiter::RateLimiter;
|
||||||
use crate::response::{build_capabilities, SmtpResponse};
|
use crate::response::{build_capabilities, SmtpResponse};
|
||||||
|
use crate::scram::{ScramCredentials, ScramServer};
|
||||||
use crate::session::{AuthState, SmtpSession};
|
use crate::session::{AuthState, SmtpSession};
|
||||||
use crate::validation;
|
use crate::validation;
|
||||||
|
|
||||||
@@ -52,6 +53,13 @@ pub enum ConnectionEvent {
|
|||||||
password: String,
|
password: String,
|
||||||
remote_addr: 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.
|
/// How email data is transported from Rust to TS.
|
||||||
@@ -81,6 +89,16 @@ pub struct AuthResult {
|
|||||||
pub message: Option<String>,
|
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.
|
/// Abstraction over plain and TLS streams.
|
||||||
pub enum SmtpStream {
|
pub enum SmtpStream {
|
||||||
Plain(BufReader<TcpStream>),
|
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.
|
/// Unwrap to get the raw TcpStream for STARTTLS upgrade.
|
||||||
/// Only works on Plain streams.
|
/// Only works on Plain streams.
|
||||||
pub fn into_tcp_stream(self) -> Option<TcpStream> {
|
pub fn into_tcp_stream(self) -> Option<TcpStream> {
|
||||||
@@ -212,7 +238,7 @@ pub async fn handle_connection(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) => {
|
Ok(Ok(_)) => {
|
||||||
// Process command
|
// Process the first command
|
||||||
let response = process_line(
|
let response = process_line(
|
||||||
&line,
|
&line,
|
||||||
&mut session,
|
&mut session,
|
||||||
@@ -227,21 +253,92 @@ pub async fn handle_connection(
|
|||||||
)
|
)
|
||||||
.await;
|
.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 {
|
match response {
|
||||||
LineResult::Response(resp) => {
|
LineResult::Response(resp) => {
|
||||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
response_batch.extend_from_slice(&resp.to_bytes());
|
||||||
|
}
|
||||||
|
LineResult::Quit(resp) => {
|
||||||
|
let _ = stream.write_all(&resp.to_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
should_break = true;
|
||||||
|
}
|
||||||
|
LineResult::StartTlsSignal => {
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
if stream.flush().await.is_err() {
|
if stream.flush().await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LineResult::Quit(resp) => {
|
|
||||||
let _ = stream.write_all(&resp.to_bytes()).await;
|
if should_break {
|
||||||
let _ = stream.flush().await;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
LineResult::StartTlsSignal => {
|
|
||||||
|
if starttls_signal {
|
||||||
// Send 220 Ready response
|
// Send 220 Ready response
|
||||||
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
||||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||||
@@ -257,7 +354,6 @@ pub async fn handle_connection(
|
|||||||
Ok(tls_stream) => {
|
Ok(tls_stream) => {
|
||||||
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
||||||
session.secure = true;
|
session.secure = true;
|
||||||
// Client must re-EHLO after STARTTLS
|
|
||||||
session.state = crate::state::SmtpState::Connected;
|
session.state = crate::state::SmtpState::Connected;
|
||||||
session.client_hostname = None;
|
session.client_hostname = None;
|
||||||
session.esmtp = false;
|
session.esmtp = false;
|
||||||
@@ -274,12 +370,6 @@ pub async fn handle_connection(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Already TLS — shouldn't happen
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LineResult::NoResponse => {}
|
|
||||||
LineResult::Disconnect => {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
correlation_id: &str,
|
correlation_id: &str,
|
||||||
) -> oneshot::Receiver<AuthResult>;
|
) -> 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.
|
/// Process a single input line from the client.
|
||||||
@@ -406,6 +502,18 @@ async fn process_line(
|
|||||||
mechanism,
|
mechanism,
|
||||||
initial_response,
|
initial_response,
|
||||||
} => {
|
} => {
|
||||||
|
if matches!(mechanism, AuthMechanism::ScramSha256) {
|
||||||
|
handle_auth_scram(
|
||||||
|
initial_response,
|
||||||
|
session,
|
||||||
|
stream,
|
||||||
|
config,
|
||||||
|
rate_limiter,
|
||||||
|
event_tx,
|
||||||
|
callback_registry,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
handle_auth(
|
handle_auth(
|
||||||
mechanism,
|
mechanism,
|
||||||
initial_response,
|
initial_response,
|
||||||
@@ -417,6 +525,7 @@ async fn process_line(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SmtpCommand::Help(_) => {
|
SmtpCommand::Help(_) => {
|
||||||
LineResult::Response(SmtpResponse::new(
|
LineResult::Response(SmtpResponse::new(
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod connection;
|
|||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
|
pub mod scram;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ pub fn build_capabilities(
|
|||||||
caps.push("STARTTLS".to_string());
|
caps.push("STARTTLS".to_string());
|
||||||
}
|
}
|
||||||
if auth_available {
|
if auth_available {
|
||||||
caps.push("AUTH PLAIN LOGIN".to_string());
|
caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string());
|
||||||
}
|
}
|
||||||
caps
|
caps
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ mod tests {
|
|||||||
let caps = build_capabilities(10485760, true, false, true);
|
let caps = build_capabilities(10485760, true, false, true);
|
||||||
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
||||||
assert!(caps.contains(&"STARTTLS".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()));
|
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ mod tests {
|
|||||||
// When already secure, STARTTLS should NOT be advertised
|
// When already secure, STARTTLS should NOT be advertised
|
||||||
let caps = build_capabilities(10485760, true, true, false);
|
let caps = build_capabilities(10485760, true, true, false);
|
||||||
assert!(!caps.contains(&"STARTTLS".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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
|
||||||
|
//!
|
||||||
|
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
|
||||||
|
//! a challenge-response protocol that avoids transmitting cleartext passwords.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Pre-computed SCRAM credentials for a user (derived from password).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScramCredentials {
|
||||||
|
pub salt: Vec<u8>,
|
||||||
|
pub iterations: u32,
|
||||||
|
pub stored_key: Vec<u8>,
|
||||||
|
pub server_key: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side SCRAM state machine.
|
||||||
|
pub struct ScramServer {
|
||||||
|
/// Username extracted from client-first-message.
|
||||||
|
pub username: String,
|
||||||
|
/// Full combined nonce (client + server).
|
||||||
|
combined_nonce: String,
|
||||||
|
/// Server nonce portion (used in tests for verification).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
server_nonce: String,
|
||||||
|
/// Stored credentials (set after TS responds).
|
||||||
|
credentials: Option<ScramCredentials>,
|
||||||
|
/// The server-first-message (for auth message construction).
|
||||||
|
server_first: String,
|
||||||
|
/// The client-first-message-bare (for auth message construction).
|
||||||
|
client_first_bare: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScramServer {
|
||||||
|
/// Process the client-first-message.
|
||||||
|
///
|
||||||
|
/// Parses the client nonce and username, generates a server nonce,
|
||||||
|
/// and returns a partial state that needs credentials to produce the
|
||||||
|
/// server-first-message.
|
||||||
|
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
|
||||||
|
// client-first-message = gs2-header client-first-message-bare
|
||||||
|
// gs2-header = "n,," (no channel binding)
|
||||||
|
// client-first-message-bare = "n=username,r=nonce"
|
||||||
|
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
|
||||||
|
rest
|
||||||
|
} else if let Some(rest) = client_first.strip_prefix("y,,") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
return Err("Invalid SCRAM gs2-header".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut username = String::new();
|
||||||
|
let mut client_nonce = String::new();
|
||||||
|
|
||||||
|
for part in bare.split(',') {
|
||||||
|
if let Some(val) = part.strip_prefix("n=") {
|
||||||
|
username = val.to_string();
|
||||||
|
} else if let Some(val) = part.strip_prefix("r=") {
|
||||||
|
client_nonce = val.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.is_empty() || client_nonce.is_empty() {
|
||||||
|
return Err("Missing username or nonce in client-first-message".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate server nonce
|
||||||
|
let server_nonce: String = (0..24)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = (rand_byte() as usize) % 62;
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
|
||||||
|
|
||||||
|
Ok(ScramServer {
|
||||||
|
username,
|
||||||
|
combined_nonce,
|
||||||
|
server_nonce,
|
||||||
|
credentials: None,
|
||||||
|
server_first: String::new(),
|
||||||
|
client_first_bare: bare.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the credentials and produce the server-first-message.
|
||||||
|
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
|
||||||
|
let salt_b64 = BASE64.encode(&creds.salt);
|
||||||
|
let server_first = format!(
|
||||||
|
"r={},s={},i={}",
|
||||||
|
self.combined_nonce, salt_b64, creds.iterations
|
||||||
|
);
|
||||||
|
|
||||||
|
self.server_first = server_first.clone();
|
||||||
|
self.credentials = Some(creds);
|
||||||
|
server_first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the client-final-message and verify the proof.
|
||||||
|
///
|
||||||
|
/// Returns the server-final-message (containing ServerSignature) on success,
|
||||||
|
/// or an error string on failure.
|
||||||
|
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
|
||||||
|
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
|
||||||
|
|
||||||
|
// Parse client-final-message
|
||||||
|
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
|
||||||
|
let mut channel_binding = String::new();
|
||||||
|
let mut nonce = String::new();
|
||||||
|
let mut proof_b64 = String::new();
|
||||||
|
|
||||||
|
for part in client_final.split(',') {
|
||||||
|
if let Some(val) = part.strip_prefix("c=") {
|
||||||
|
channel_binding = val.to_string();
|
||||||
|
} else if let Some(val) = part.strip_prefix("r=") {
|
||||||
|
nonce = val.to_string();
|
||||||
|
} else if let Some(val) = part.strip_prefix("p=") {
|
||||||
|
proof_b64 = val.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify nonce matches
|
||||||
|
if nonce != self.combined_nonce {
|
||||||
|
return Err("Nonce mismatch".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the client-final-message-without-proof
|
||||||
|
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
|
||||||
|
|
||||||
|
// Complete the auth message
|
||||||
|
let auth_message = format!(
|
||||||
|
"{},{},{}",
|
||||||
|
self.client_first_bare, self.server_first, client_final_without_proof
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify client proof
|
||||||
|
let client_proof = BASE64.decode(proof_b64.as_bytes())
|
||||||
|
.map_err(|_| "Invalid base64 in client proof")?;
|
||||||
|
|
||||||
|
// ClientSignature = HMAC(StoredKey, AuthMessage)
|
||||||
|
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
|
||||||
|
|
||||||
|
// ClientKey = ClientProof XOR ClientSignature
|
||||||
|
if client_proof.len() != client_signature.len() {
|
||||||
|
return Err("Client proof length mismatch".into());
|
||||||
|
}
|
||||||
|
let client_key: Vec<u8> = client_proof
|
||||||
|
.iter()
|
||||||
|
.zip(client_signature.iter())
|
||||||
|
.map(|(a, b)| a ^ b)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// StoredKey = H(ClientKey)
|
||||||
|
let computed_stored_key = sha256(&client_key);
|
||||||
|
|
||||||
|
// Verify: computed StoredKey must match the stored StoredKey
|
||||||
|
if computed_stored_key != creds.stored_key {
|
||||||
|
return Err("Authentication failed".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ServerSignature for mutual authentication
|
||||||
|
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
|
||||||
|
let server_sig_b64 = BASE64.encode(&server_signature);
|
||||||
|
|
||||||
|
Ok(format!("v={}", server_sig_b64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
|
||||||
|
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
|
||||||
|
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
|
||||||
|
let mut salted_password = [0u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
password.as_bytes(),
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
&mut salted_password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ClientKey = HMAC(SaltedPassword, "Client Key")
|
||||||
|
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||||
|
|
||||||
|
// StoredKey = H(ClientKey)
|
||||||
|
let stored_key = sha256(&client_key);
|
||||||
|
|
||||||
|
// ServerKey = HMAC(SaltedPassword, "Server Key")
|
||||||
|
let server_key = hmac_sha256(&salted_password, b"Server Key");
|
||||||
|
|
||||||
|
ScramCredentials {
|
||||||
|
salt: salt.to_vec(),
|
||||||
|
iterations,
|
||||||
|
stored_key,
|
||||||
|
server_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||||
|
mac.update(data);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hasher.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple random byte using system randomness.
|
||||||
|
fn rand_byte() -> u8 {
|
||||||
|
use std::collections::hash_map::RandomState;
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
let state = RandomState::new();
|
||||||
|
let mut hasher = state.build_hasher();
|
||||||
|
hasher.write_u64(std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos() as u64);
|
||||||
|
hasher.finish() as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scram_full_exchange() {
|
||||||
|
let password = "pencil";
|
||||||
|
let salt = b"test-salt-1234";
|
||||||
|
let iterations = 4096;
|
||||||
|
|
||||||
|
// Pre-compute server-side credentials from password
|
||||||
|
let creds = compute_scram_credentials(password, salt, iterations);
|
||||||
|
|
||||||
|
// 1. Client sends client-first-message
|
||||||
|
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||||
|
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||||
|
assert_eq!(server.username, "user");
|
||||||
|
|
||||||
|
// 2. Server responds with server-first-message
|
||||||
|
let server_first = server.server_first_message(creds.clone());
|
||||||
|
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
|
||||||
|
assert!(server_first.contains("s="));
|
||||||
|
assert!(server_first.contains("i=4096"));
|
||||||
|
|
||||||
|
// 3. Client computes proof
|
||||||
|
// SaltedPassword
|
||||||
|
let mut salted_password = [0u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
password.as_bytes(),
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
&mut salted_password,
|
||||||
|
);
|
||||||
|
|
||||||
|
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||||
|
let stored_key = sha256(&client_key);
|
||||||
|
|
||||||
|
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||||
|
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||||
|
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||||
|
|
||||||
|
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||||
|
let client_proof: Vec<u8> = client_key
|
||||||
|
.iter()
|
||||||
|
.zip(client_signature.iter())
|
||||||
|
.map(|(a, b)| a ^ b)
|
||||||
|
.collect();
|
||||||
|
let proof_b64 = BASE64.encode(&client_proof);
|
||||||
|
|
||||||
|
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||||
|
|
||||||
|
// 4. Server verifies proof
|
||||||
|
let result = server.process_client_final(&client_final);
|
||||||
|
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
|
||||||
|
let server_final = result.unwrap();
|
||||||
|
assert!(server_final.starts_with("v="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scram_wrong_password() {
|
||||||
|
let password = "pencil";
|
||||||
|
let wrong_password = "wrong";
|
||||||
|
let salt = b"test-salt";
|
||||||
|
let iterations = 4096;
|
||||||
|
|
||||||
|
let creds = compute_scram_credentials(password, salt, iterations);
|
||||||
|
|
||||||
|
let client_first = "n,,n=user,r=clientnonce123";
|
||||||
|
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||||
|
let server_first = server.server_first_message(creds);
|
||||||
|
|
||||||
|
// Client computes proof with wrong password
|
||||||
|
let mut salted_password = [0u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
wrong_password.as_bytes(),
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
&mut salted_password,
|
||||||
|
);
|
||||||
|
|
||||||
|
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||||
|
let stored_key = sha256(&client_key);
|
||||||
|
|
||||||
|
let client_first_bare = "n=user,r=clientnonce123";
|
||||||
|
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||||
|
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||||
|
|
||||||
|
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||||
|
let client_proof: Vec<u8> = client_key
|
||||||
|
.iter()
|
||||||
|
.zip(client_signature.iter())
|
||||||
|
.map(|(a, b)| a ^ b)
|
||||||
|
.collect();
|
||||||
|
let proof_b64 = BASE64.encode(&client_proof);
|
||||||
|
|
||||||
|
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||||
|
let result = server.process_client_final(&client_final);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_scram_credentials() {
|
||||||
|
let creds = compute_scram_credentials("password", b"salt", 4096);
|
||||||
|
assert_eq!(creds.salt, b"salt");
|
||||||
|
assert_eq!(creds.iterations, 4096);
|
||||||
|
assert_eq!(creds.stored_key.len(), 32);
|
||||||
|
assert_eq!(creds.server_key.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_client_first() {
|
||||||
|
assert!(ScramServer::from_client_first("invalid").is_err());
|
||||||
|
assert!(ScramServer::from_client_first("n,,").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
|||||||
use hickory_resolver::TokioResolver;
|
use hickory_resolver::TokioResolver;
|
||||||
use mailer_security::MessageAuthenticator;
|
use mailer_security::MessageAuthenticator;
|
||||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
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.
|
/// Build a TLS acceptor from PEM cert/key strings.
|
||||||
fn build_tls_acceptor(
|
fn build_tls_acceptor(
|
||||||
config: &SmtpServerConfig,
|
config: &SmtpServerConfig,
|
||||||
@@ -311,9 +375,42 @@ fn build_tls_acceptor(
|
|||||||
.ok_or("No private key found in PEM")?
|
.ok_or("No private key found in PEM")?
|
||||||
};
|
};
|
||||||
|
|
||||||
let tls_config = rustls::ServerConfig::builder()
|
// 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_no_client_auth()
|
||||||
.with_single_cert(certs, key)?;
|
.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)))
|
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
|
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
|
// SPF Verifier Tests
|
||||||
@@ -41,153 +39,6 @@ tap.test('SPF Verifier - should parse SPF record', async () => {
|
|||||||
expect(invalidParsed).toBeNull();
|
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 () => {
|
tap.test('stop', async () => {
|
||||||
await tap.stopForcefully();
|
await tap.stopForcefully();
|
||||||
});
|
});
|
||||||
|
|||||||
295
test/test.mta.delivery.node.ts
Normal file
295
test/test.mta.delivery.node.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as dns from 'dns';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
let mockSmtpServer: net.Server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal mock SMTP server that accepts any email.
|
||||||
|
*/
|
||||||
|
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer((socket) => {
|
||||||
|
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||||
|
|
||||||
|
let inData = false;
|
||||||
|
let dataBuffer = '';
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
const input = chunk.toString();
|
||||||
|
|
||||||
|
if (inData) {
|
||||||
|
dataBuffer += input;
|
||||||
|
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||||
|
inData = false;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||||
|
for (const line of lines) {
|
||||||
|
const cmd = line.toUpperCase();
|
||||||
|
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||||
|
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||||
|
} else if (cmd.startsWith('MAIL FROM')) {
|
||||||
|
socket.write('250 2.1.0 Ok\r\n');
|
||||||
|
} else if (cmd.startsWith('RCPT TO')) {
|
||||||
|
socket.write('250 2.1.5 Ok\r\n');
|
||||||
|
} else if (cmd === 'DATA') {
|
||||||
|
inData = true;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||||
|
} else if (cmd === 'QUIT') {
|
||||||
|
socket.write('221 2.0.0 Bye\r\n');
|
||||||
|
socket.end();
|
||||||
|
} else if (cmd === 'RSET') {
|
||||||
|
socket.write('250 2.0.0 Ok\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.listen(port, '127.0.0.1', () => {
|
||||||
|
resolve(srv);
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original resolveMx so we can restore it
|
||||||
|
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
|
||||||
|
|
||||||
|
tap.test('setup - start server and mock SMTP', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10425],
|
||||||
|
hostname: 'test.mta.local',
|
||||||
|
domains: [
|
||||||
|
{ domain: 'mta-test.com', dnsMode: 'forward' },
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'mta-route',
|
||||||
|
priority: 10,
|
||||||
|
match: { recipients: '*@mta-test.com' },
|
||||||
|
action: { type: 'deliver' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process-route',
|
||||||
|
priority: 20,
|
||||||
|
match: { recipients: '*@process-test.com' },
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
options: {
|
||||||
|
contentScanning: true,
|
||||||
|
scanners: [{ type: 'spam' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSmtpServer = await createMockSmtpServer(10426);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MX resolution for a public domain', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
|
||||||
|
const resolver = new dns.promises.Resolver();
|
||||||
|
try {
|
||||||
|
const records = await resolver.resolveMx('gmail.com');
|
||||||
|
expect(records).toBeTruthy();
|
||||||
|
expect(records.length).toBeGreaterThan(0);
|
||||||
|
// Each record should have exchange and priority
|
||||||
|
for (const rec of records) {
|
||||||
|
expect(typeof rec.exchange).toEqual('string');
|
||||||
|
expect(typeof rec.priority).toEqual('number');
|
||||||
|
}
|
||||||
|
console.log(`Resolved ${records.length} MX records for gmail.com`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('group recipients by domain', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
// Test the grouping logic directly
|
||||||
|
const recipients = [
|
||||||
|
'alice@example.com',
|
||||||
|
'bob@example.com',
|
||||||
|
'carol@other.org',
|
||||||
|
'dave@example.com',
|
||||||
|
'eve@other.org',
|
||||||
|
];
|
||||||
|
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
for (const rcpt of recipients) {
|
||||||
|
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||||||
|
if (!domain) continue;
|
||||||
|
const list = groups.get(domain) || [];
|
||||||
|
list.push(rcpt);
|
||||||
|
groups.set(domain, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(groups.size).toEqual(2);
|
||||||
|
expect(groups.get('example.com')!.length).toEqual(3);
|
||||||
|
expect(groups.get('other.org')!.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
|
||||||
|
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||||
|
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@mta-test.com',
|
||||||
|
to: 'recipient@target-domain.com',
|
||||||
|
subject: 'MTA Delivery Test',
|
||||||
|
text: 'Testing MTA delivery with MX resolution.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
|
||||||
|
// But the real test is the delivery system's handleMtaDelivery, which we test
|
||||||
|
// by sending through the server's outbound path with the mock MX.
|
||||||
|
|
||||||
|
// Direct test: resolve MX then send
|
||||||
|
const resolver = new dns.promises.Resolver();
|
||||||
|
const mxRecords = await resolver.resolveMx('target-domain.com');
|
||||||
|
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
|
||||||
|
|
||||||
|
// Send via the resolved MX host to the mock SMTP server on port 10425
|
||||||
|
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
|
||||||
|
// We test the sendOutboundEmail path directly with the mock MX host.
|
||||||
|
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.accepted.length).toBeGreaterThan(0);
|
||||||
|
expect(result.response).toInclude('2.0.0');
|
||||||
|
|
||||||
|
// Restore original resolveMx
|
||||||
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@mta-test.com',
|
||||||
|
to: 'recipient@unreachable-domain.com',
|
||||||
|
subject: 'Connection Refused MX Test',
|
||||||
|
text: 'This should fail — no server at the target.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to a port that nothing is listening on
|
||||||
|
try {
|
||||||
|
await server.sendOutboundEmail('127.0.0.1', 59789, email);
|
||||||
|
throw new Error('Expected sendOutboundEmail to fail');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message.length).toBeGreaterThan(0);
|
||||||
|
console.log(`Got expected error: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MTA delivery with multiple recipients across domains', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
// Mock MX to return 127.0.0.1 for all domains
|
||||||
|
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||||
|
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@mta-test.com',
|
||||||
|
to: ['alice@domain-a.com', 'bob@domain-b.com'],
|
||||||
|
subject: 'Multi-Domain MTA Test',
|
||||||
|
text: 'Testing delivery to multiple domains.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to each recipient's domain individually (simulating MTA behavior)
|
||||||
|
for (const recipient of email.to) {
|
||||||
|
const singleEmail = new Email({
|
||||||
|
from: email.from,
|
||||||
|
to: recipient,
|
||||||
|
subject: email.subject,
|
||||||
|
text: email.text,
|
||||||
|
});
|
||||||
|
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
|
||||||
|
expect(result.accepted.length).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original resolveMx
|
||||||
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
|
||||||
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||||
|
|
||||||
|
// Resolve real MX records for task.vc
|
||||||
|
const resolver = new dns.promises.Resolver();
|
||||||
|
const mxRecords = await resolver.resolveMx('task.vc');
|
||||||
|
expect(mxRecords.length).toBeGreaterThan(0);
|
||||||
|
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
|
||||||
|
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'test@mta-test.com',
|
||||||
|
to: 'hello@task.vc',
|
||||||
|
subject: `MTA E2E Test — ${new Date().toISOString()}`,
|
||||||
|
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await server.sendOutboundEmail(mxHost, 25, email);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.accepted).toBeTruthy();
|
||||||
|
expect(result.accepted.length).toEqual(1);
|
||||||
|
expect(result.accepted[0]).toEqual('hello@task.vc');
|
||||||
|
expect(result.response).toInclude('2.0.0');
|
||||||
|
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server and mock SMTP', async () => {
|
||||||
|
// Restore MX resolver in case it wasn't restored
|
||||||
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||||
|
|
||||||
|
// Force-close mock server (destroy all open sockets)
|
||||||
|
if (mockSmtpServer) {
|
||||||
|
mockSmtpServer.close();
|
||||||
|
}
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should be instantiable', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 10,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(limiter).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 5,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 5 requests
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6th request should be denied
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 3,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 3 requests for key1
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
expect(limiter.isAllowed('key1')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4th request for key1 should be denied
|
|
||||||
expect(limiter.isAllowed('key1')).toEqual(false);
|
|
||||||
|
|
||||||
// But key2 should still be allowed
|
|
||||||
expect(limiter.isAllowed('key2')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should refill tokens over time', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 2,
|
|
||||||
periodMs: 100, // Short period for testing
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use all tokens
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Wait for refill
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Should have tokens again
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should support burst allowance', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 2,
|
|
||||||
periodMs: 100,
|
|
||||||
perKey: true,
|
|
||||||
burstTokens: 2, // Allow 2 extra tokens for bursts
|
|
||||||
initialTokens: 4 // Start with max + burst tokens
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 4 requests (2 regular + 2 burst)
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5th request should be denied
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Wait for refill
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
|
|
||||||
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should return correct stats', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 10,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make some requests
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
|
|
||||||
// Get stats
|
|
||||||
const stats = limiter.getStats('test');
|
|
||||||
|
|
||||||
expect(stats.remaining).toEqual(7);
|
|
||||||
expect(stats.limit).toEqual(10);
|
|
||||||
expect(stats.allowed).toEqual(3);
|
|
||||||
expect(stats.denied).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should reset limits', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 3,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use all tokens
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
limiter.reset('test');
|
|
||||||
|
|
||||||
// Should have tokens again
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '4.1.0',
|
version: '5.1.1',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* MTA error classes for SMTP client operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class MtaConnectionError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaConnectionError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'CONNECTION_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError {
|
|
||||||
return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT');
|
|
||||||
}
|
|
||||||
static refused(host: string, port: number): MtaConnectionError {
|
|
||||||
return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED');
|
|
||||||
}
|
|
||||||
static dnsError(host: string, err?: any): MtaConnectionError {
|
|
||||||
const errMsg = typeof err === 'string' ? err : err?.message || '';
|
|
||||||
return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaAuthenticationError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaAuthenticationError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'AUTH_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static invalidCredentials(host?: string, user?: string): MtaAuthenticationError {
|
|
||||||
const detail = host && user ? `${user}@${host}` : host || user || '';
|
|
||||||
return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaDeliveryError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public responseCode?: number;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any, responseCode?: number) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaDeliveryError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
this.responseCode = responseCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'DELIVERY_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static temporary(message: string, ...args: any[]): MtaDeliveryError {
|
|
||||||
return new MtaDeliveryError(message, 'TEMPORARY');
|
|
||||||
}
|
|
||||||
static permanent(message: string, ...args: any[]): MtaDeliveryError {
|
|
||||||
return new MtaDeliveryError(message, 'PERMANENT');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaConfigurationError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaConfigurationError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'CONFIG_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaTimeoutError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaTimeoutError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'TIMEOUT';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError {
|
|
||||||
const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs;
|
|
||||||
return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaProtocolError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaProtocolError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'PROTOCOL_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { logger } from '../../logger.js';
|
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';
|
import type { IEmailRoute } from '../routing/interfaces.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.js';
|
} from '../../security/index.js';
|
||||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.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 { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
const dns = plugins.dns;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivery status enumeration
|
* Delivery status enumeration
|
||||||
*/
|
*/
|
||||||
@@ -480,6 +482,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Default handler for MTA mode delivery
|
||||||
* @param item Queue item
|
* @param item Queue item
|
||||||
@@ -488,33 +520,83 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
logger.log('info', `MTA delivery for item ${item.id}`);
|
logger.log('info', `MTA delivery for item ${item.id}`);
|
||||||
|
|
||||||
const email = item.processingResult as Email;
|
const email = item.processingResult as Email;
|
||||||
const route = item.route;
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
// Apply DKIM signing if configured in the route
|
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
||||||
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
|
// Create a temporary Email scoped to this domain's recipients
|
||||||
// For now, we'll simulate a successful delivery
|
const domainEmail = new Email({
|
||||||
|
from: email.from,
|
||||||
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
|
to: recipients.filter(r => email.to.includes(r)),
|
||||||
|
cc: recipients.filter(r => (email.cc || []).includes(r)),
|
||||||
// Note: The MTA implementation would handle actual local delivery
|
bcc: recipients.filter(r => (email.bcc || []).includes(r)),
|
||||||
|
|
||||||
// Simulate successful delivery
|
|
||||||
return {
|
|
||||||
recipients: email.getAllRecipients().length,
|
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign
|
text: email.text,
|
||||||
};
|
html: email.html,
|
||||||
} catch (error: any) {
|
});
|
||||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
|
||||||
throw error;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default handler for process mode delivery
|
* Default handler for process mode delivery
|
||||||
* @param item Queue item
|
* @param item Queue item
|
||||||
@@ -584,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
|
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
|
||||||
|
|
||||||
// Simulate successful delivery
|
// After scanning + transformations, deliver via MTA
|
||||||
return {
|
return await this.handleMtaDelivery(item);
|
||||||
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)
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.log('error', `Failed to process email: ${error.message}`);
|
logger.log('error', `Failed to process email: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
157
ts/mail/routing/classes.dkim.manager.ts
Normal file
157
ts/mail/routing/classes.dkim.manager.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
|
import { DomainRegistry } from './classes.domain.registry.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../core/classes.email.js';
|
||||||
|
|
||||||
|
/** External DcRouter interface shape used by DkimManager */
|
||||||
|
interface DcRouter {
|
||||||
|
storageManager: any;
|
||||||
|
dnsServer?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages DKIM key setup, rotation, and signing for all configured domains
|
||||||
|
*/
|
||||||
|
export class DkimManager {
|
||||||
|
private dkimKeys: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dkimCreator: DKIMCreator,
|
||||||
|
private domainRegistry: DomainRegistry,
|
||||||
|
private dcRouter: DcRouter,
|
||||||
|
private rustBridge: RustSecurityBridge,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setupDkimForDomains(): Promise<void> {
|
||||||
|
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||||
|
|
||||||
|
if (domainConfigs.length === 0) {
|
||||||
|
logger.log('warn', 'No domains configured for DKIM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const domainConfig of domainConfigs) {
|
||||||
|
const domain = domainConfig.domain;
|
||||||
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let keyPair: { privateKey: string; publicKey: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
||||||
|
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
keyPair = await this.dkimCreator.createDKIMKeys();
|
||||||
|
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
||||||
|
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dkimKeys.set(domain, keyPair.privateKey);
|
||||||
|
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndRotateDkimKeys(): Promise<void> {
|
||||||
|
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||||
|
|
||||||
|
for (const domainConfig of domainConfigs) {
|
||||||
|
const domain = domainConfig.domain;
|
||||||
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
|
||||||
|
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
|
||||||
|
const keySize = domainConfig.dkim?.keySize || 2048;
|
||||||
|
|
||||||
|
if (!rotateKeys) {
|
||||||
|
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
|
||||||
|
|
||||||
|
if (needsRotation) {
|
||||||
|
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
|
||||||
|
|
||||||
|
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
|
||||||
|
|
||||||
|
domainConfig.dkim = {
|
||||||
|
...domainConfig.dkim,
|
||||||
|
selector: newSelector
|
||||||
|
};
|
||||||
|
|
||||||
|
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
||||||
|
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
|
||||||
|
const publicKeyBase64 = keyPair.publicKey
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||||
|
|
||||||
|
this.dcRouter.dnsServer.registerHandler(
|
||||||
|
`${newSelector}._domainkey.${domain}`,
|
||||||
|
['TXT'],
|
||||||
|
() => ({
|
||||||
|
name: `${newSelector}._domainkey.${domain}`,
|
||||||
|
type: 'TXT',
|
||||||
|
class: 'IN',
|
||||||
|
ttl: ttl,
|
||||||
|
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
|
||||||
|
|
||||||
|
await this.dcRouter.storageManager.set(
|
||||||
|
`/email/dkim/${domain}/public.key`,
|
||||||
|
keyPair.publicKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
|
||||||
|
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
logger.log('debug', `DKIM keys for ${domain} are up to date`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||||
|
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||||
|
const rawEmail = email.toRFC822String();
|
||||||
|
|
||||||
|
// Detect key type from PEM header
|
||||||
|
const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
|
||||||
|
|
||||||
|
const signResult = await this.rustBridge.signDkim({
|
||||||
|
rawMessage: rawEmail,
|
||||||
|
domain,
|
||||||
|
selector,
|
||||||
|
privateKey,
|
||||||
|
keyType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signResult.header) {
|
||||||
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
|
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDkimKey(domain: string): string | undefined {
|
||||||
|
return this.dkimKeys.get(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
ts/mail/routing/classes.email.action.executor.ts
Normal file
175
ts/mail/routing/classes.email.action.executor.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import {
|
||||||
|
SecurityLogger,
|
||||||
|
SecurityLogLevel,
|
||||||
|
SecurityEventType
|
||||||
|
} from '../../security/index.js';
|
||||||
|
import type { IEmailAction, IEmailContext } from './interfaces.js';
|
||||||
|
import { Email } from '../core/classes.email.js';
|
||||||
|
import { BounceManager } from '../core/classes.bouncemanager.js';
|
||||||
|
import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js';
|
||||||
|
import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies injected from UnifiedEmailServer to avoid circular imports
|
||||||
|
*/
|
||||||
|
export interface IActionExecutorDeps {
|
||||||
|
sendOutboundEmail: (host: string, port: number, email: Email, options?: {
|
||||||
|
auth?: { user: string; pass: string };
|
||||||
|
dkimDomain?: string;
|
||||||
|
dkimSelector?: string;
|
||||||
|
tlsOpportunistic?: boolean;
|
||||||
|
}) => Promise<ISmtpSendResult>;
|
||||||
|
bounceManager: BounceManager;
|
||||||
|
deliveryQueue: UnifiedDeliveryQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes email routing actions (forward, process, deliver, reject)
|
||||||
|
*/
|
||||||
|
export class EmailActionExecutor {
|
||||||
|
constructor(private deps: IActionExecutorDeps) {}
|
||||||
|
|
||||||
|
async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'forward':
|
||||||
|
await this.handleForwardAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'process':
|
||||||
|
await this.handleProcessAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'deliver':
|
||||||
|
await this.handleDeliverAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'reject':
|
||||||
|
await this.handleRejectAction(action, email, context);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${(action as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
if (!action.forward) {
|
||||||
|
throw new Error('Forward action requires forward configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, port = 25, auth, addHeaders } = action.forward;
|
||||||
|
|
||||||
|
logger.log('info', `Forwarding email to ${host}:${port}`);
|
||||||
|
|
||||||
|
// Add forwarding headers
|
||||||
|
if (addHeaders) {
|
||||||
|
for (const [key, value] of Object.entries(addHeaders)) {
|
||||||
|
email.headers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard forwarding headers
|
||||||
|
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
|
||||||
|
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
||||||
|
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send email via Rust SMTP client
|
||||||
|
await this.deps.sendOutboundEmail(host, port, email, {
|
||||||
|
auth: auth as { user: string; pass: string } | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.INFO,
|
||||||
|
type: SecurityEventType.EMAIL_FORWARDING,
|
||||||
|
message: 'Email forwarded successfully',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
targetHost: host,
|
||||||
|
targetPort: port,
|
||||||
|
recipients: email.to
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.ERROR,
|
||||||
|
type: SecurityEventType.EMAIL_FORWARDING,
|
||||||
|
message: 'Email forwarding failed',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
targetHost: host,
|
||||||
|
targetPort: port,
|
||||||
|
error: error.message
|
||||||
|
},
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle as bounce
|
||||||
|
for (const recipient of email.getAllRecipients()) {
|
||||||
|
await this.deps.bounceManager.processSmtpFailure(recipient, error.message, {
|
||||||
|
sender: email.from,
|
||||||
|
originalEmailId: email.headers['Message-ID'] as string
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
logger.log('info', `Processing email with action options`);
|
||||||
|
|
||||||
|
// Apply scanning if requested
|
||||||
|
if (action.process?.scan) {
|
||||||
|
logger.log('info', 'Content scanning requested');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for delivery
|
||||||
|
const queue = action.process?.queue || 'normal';
|
||||||
|
await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
|
||||||
|
|
||||||
|
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
logger.log('info', `Delivering email locally`);
|
||||||
|
|
||||||
|
// Queue for local delivery
|
||||||
|
await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
|
||||||
|
|
||||||
|
logger.log('info', 'Email queued for local delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
const code = action.reject?.code || 550;
|
||||||
|
const message = action.reject?.message || 'Message rejected';
|
||||||
|
|
||||||
|
logger.log('info', `Rejecting email with code ${code}: ${message}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.WARN,
|
||||||
|
type: SecurityEventType.EMAIL_PROCESSING,
|
||||||
|
message: 'Email rejected by routing rule',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
rejectCode: code,
|
||||||
|
rejectMessage: message,
|
||||||
|
from: _email.from,
|
||||||
|
to: _email.to
|
||||||
|
},
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throw error with SMTP code and message
|
||||||
|
const error = new Error(message);
|
||||||
|
(error as any).responseCode = code;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { EmailProcessingMode } from '../delivery/interfaces.js';
|
|
||||||
|
|
||||||
// Re-export EmailProcessingMode type
|
|
||||||
export type { EmailProcessingMode };
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain rule interface for pattern-based routing
|
|
||||||
*/
|
|
||||||
export interface IDomainRule {
|
|
||||||
// Domain pattern (e.g., "*@example.com", "*@*.example.net")
|
|
||||||
pattern: string;
|
|
||||||
|
|
||||||
// Handling mode for this pattern
|
|
||||||
mode: EmailProcessingMode;
|
|
||||||
|
|
||||||
// Forward mode configuration
|
|
||||||
target?: {
|
|
||||||
server: string;
|
|
||||||
port?: number;
|
|
||||||
useTls?: boolean;
|
|
||||||
authentication?: {
|
|
||||||
user?: string;
|
|
||||||
pass?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// MTA mode configuration
|
|
||||||
mtaOptions?: IMtaOptions;
|
|
||||||
|
|
||||||
// Process mode configuration
|
|
||||||
contentScanning?: boolean;
|
|
||||||
scanners?: IContentScanner[];
|
|
||||||
transformations?: ITransformation[];
|
|
||||||
|
|
||||||
// Rate limits for this domain
|
|
||||||
rateLimits?: {
|
|
||||||
maxMessagesPerMinute?: number;
|
|
||||||
maxRecipientsPerMessage?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MTA options interface
|
|
||||||
*/
|
|
||||||
export interface IMtaOptions {
|
|
||||||
domain?: string;
|
|
||||||
allowLocalDelivery?: boolean;
|
|
||||||
localDeliveryPath?: string;
|
|
||||||
dkimSign?: boolean;
|
|
||||||
dkimOptions?: {
|
|
||||||
domainName: string;
|
|
||||||
keySelector: string;
|
|
||||||
privateKey?: string;
|
|
||||||
};
|
|
||||||
smtpBanner?: string;
|
|
||||||
maxConnections?: number;
|
|
||||||
connTimeout?: number;
|
|
||||||
spoolDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Content scanner interface
|
|
||||||
*/
|
|
||||||
export interface IContentScanner {
|
|
||||||
type: 'spam' | 'virus' | 'attachment';
|
|
||||||
threshold?: number;
|
|
||||||
action: 'tag' | 'reject';
|
|
||||||
blockedExtensions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformation interface
|
|
||||||
*/
|
|
||||||
export interface ITransformation {
|
|
||||||
type: string;
|
|
||||||
header?: string;
|
|
||||||
value?: string;
|
|
||||||
domains?: string[];
|
|
||||||
append?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.js';
|
} from '../../security/index.js';
|
||||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import { EmailRouter } from './classes.email.router.js';
|
import { EmailRouter } from './classes.email.router.js';
|
||||||
@@ -23,6 +22,10 @@ import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.de
|
|||||||
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
||||||
import { SmtpState } from '../delivery/interfaces.js';
|
import { SmtpState } from '../delivery/interfaces.js';
|
||||||
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
||||||
|
import { EmailActionExecutor } from './classes.email.action.executor.js';
|
||||||
|
import { DkimManager } from './classes.dkim.manager.js';
|
||||||
|
|
||||||
|
|
||||||
/** External DcRouter interface shape used by UnifiedEmailServer */
|
/** External DcRouter interface shape used by UnifiedEmailServer */
|
||||||
interface DcRouter {
|
interface DcRouter {
|
||||||
storageManager: any;
|
storageManager: any;
|
||||||
@@ -160,12 +163,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Add components needed for sending and securing emails
|
// Add components needed for sending and securing emails
|
||||||
public dkimCreator: DKIMCreator;
|
public dkimCreator: DKIMCreator;
|
||||||
private rustBridge: RustSecurityBridge;
|
private rustBridge: RustSecurityBridge;
|
||||||
private ipReputationChecker: IPReputationChecker;
|
|
||||||
private bounceManager: BounceManager;
|
private bounceManager: BounceManager;
|
||||||
public deliveryQueue: UnifiedDeliveryQueue;
|
public deliveryQueue: UnifiedDeliveryQueue;
|
||||||
public deliverySystem: MultiModeDeliverySystem;
|
public deliverySystem: MultiModeDeliverySystem;
|
||||||
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
||||||
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
|
|
||||||
|
// Extracted subsystems
|
||||||
|
private actionExecutor: EmailActionExecutor;
|
||||||
|
private dkimManager: DkimManager;
|
||||||
|
|
||||||
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -188,13 +193,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Initialize DKIM creator with storage manager
|
// Initialize DKIM creator with storage manager
|
||||||
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager);
|
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager);
|
||||||
|
|
||||||
// Initialize IP reputation checker with storage manager
|
|
||||||
this.ipReputationChecker = IPReputationChecker.getInstance({
|
|
||||||
enableLocalCache: true,
|
|
||||||
enableDNSBL: true,
|
|
||||||
enableIPInfo: true
|
|
||||||
}, dcRouter.storageManager);
|
|
||||||
|
|
||||||
// Initialize bounce manager with storage manager
|
// Initialize bounce manager with storage manager
|
||||||
this.bounceManager = new BounceManager({
|
this.bounceManager = new BounceManager({
|
||||||
maxCacheSize: 10000,
|
maxCacheSize: 10000,
|
||||||
@@ -247,6 +245,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
||||||
|
|
||||||
|
// Initialize action executor
|
||||||
|
this.actionExecutor = new EmailActionExecutor({
|
||||||
|
sendOutboundEmail: this.sendOutboundEmail.bind(this),
|
||||||
|
bounceManager: this.bounceManager,
|
||||||
|
deliveryQueue: this.deliveryQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize DKIM manager
|
||||||
|
this.dkimManager = new DkimManager(this.dkimCreator, this.domainRegistry, dcRouter, this.rustBridge);
|
||||||
|
|
||||||
// Initialize statistics
|
// Initialize statistics
|
||||||
this.stats = {
|
this.stats = {
|
||||||
startTime: new Date(),
|
startTime: new Date(),
|
||||||
@@ -277,6 +285,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
auth?: { user: string; pass: string };
|
auth?: { user: string; pass: string };
|
||||||
dkimDomain?: string;
|
dkimDomain?: string;
|
||||||
dkimSelector?: string;
|
dkimSelector?: string;
|
||||||
|
tlsOpportunistic?: boolean;
|
||||||
}): Promise<ISmtpSendResult> {
|
}): Promise<ISmtpSendResult> {
|
||||||
// Build DKIM config if domain has keys
|
// Build DKIM config if domain has keys
|
||||||
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
|
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
|
||||||
@@ -313,6 +322,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
|
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
|
||||||
poolKey: `${host}:${port}`,
|
poolKey: `${host}:${port}`,
|
||||||
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
||||||
|
tlsOpportunistic: options?.tlsOpportunistic ?? (port === 25),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,70 +333,62 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize the delivery queue
|
await this.startDeliveryPipeline();
|
||||||
|
await this.startRustBridge();
|
||||||
|
await this.initializeDkimAndDns();
|
||||||
|
this.registerBridgeEventHandlers();
|
||||||
|
await this.startSmtpServer();
|
||||||
|
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||||
|
this.emit('started');
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startDeliveryPipeline(): Promise<void> {
|
||||||
await this.deliveryQueue.initialize();
|
await this.deliveryQueue.initialize();
|
||||||
logger.log('info', 'Email delivery queue initialized');
|
logger.log('info', 'Email delivery queue initialized');
|
||||||
|
|
||||||
// Start the delivery system
|
|
||||||
await this.deliverySystem.start();
|
await this.deliverySystem.start();
|
||||||
logger.log('info', 'Email delivery system started');
|
logger.log('info', 'Email delivery system started');
|
||||||
|
}
|
||||||
|
|
||||||
// Start Rust security bridge — required for all security operations
|
private async startRustBridge(): Promise<void> {
|
||||||
const bridgeOk = await this.rustBridge.start();
|
const bridgeOk = await this.rustBridge.start();
|
||||||
if (!bridgeOk) {
|
if (!bridgeOk) {
|
||||||
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
|
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
|
||||||
}
|
}
|
||||||
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
||||||
|
|
||||||
// Listen for bridge state changes to propagate resilience events
|
|
||||||
this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
|
this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
|
||||||
if (newState === 'failed') this.emit('bridgeFailed');
|
if (newState === 'failed') this.emit('bridgeFailed');
|
||||||
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
||||||
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Set up DKIM for all domains
|
private async initializeDkimAndDns(): Promise<void> {
|
||||||
await this.setupDkimForDomains();
|
await this.dkimManager.setupDkimForDomains();
|
||||||
logger.log('info', 'DKIM configuration completed for all domains');
|
logger.log('info', 'DKIM configuration completed for all domains');
|
||||||
|
|
||||||
// Create DNS manager and ensure all DNS records are created
|
|
||||||
const dnsManager = new DnsManager(this.dcRouter);
|
const dnsManager = new DnsManager(this.dcRouter);
|
||||||
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
|
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
|
||||||
logger.log('info', 'DNS records ensured for all configured domains');
|
logger.log('info', 'DNS records ensured for all configured domains');
|
||||||
|
|
||||||
// Apply per-domain rate limits
|
|
||||||
this.applyDomainRateLimits();
|
this.applyDomainRateLimits();
|
||||||
logger.log('info', 'Per-domain rate limits configured');
|
logger.log('info', 'Per-domain rate limits configured');
|
||||||
|
|
||||||
// Check and rotate DKIM keys if needed
|
await this.dkimManager.checkAndRotateDkimKeys();
|
||||||
await this.checkAndRotateDkimKeys();
|
|
||||||
logger.log('info', 'DKIM key rotation check completed');
|
logger.log('info', 'DKIM key rotation check completed');
|
||||||
|
|
||||||
// Ensure we have the necessary TLS options
|
|
||||||
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
|
||||||
|
|
||||||
// Prepare the certificate and key if available
|
|
||||||
let tlsCertPem: string | undefined;
|
|
||||||
let tlsKeyPem: string | undefined;
|
|
||||||
|
|
||||||
if (hasTlsConfig) {
|
|
||||||
try {
|
|
||||||
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
|
||||||
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
|
||||||
logger.log('info', 'TLS certificates loaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start Rust SMTP server ---
|
private registerBridgeEventHandlers(): void {
|
||||||
// Register event handlers for email reception and auth
|
|
||||||
this.rustBridge.onEmailReceived(async (data) => {
|
this.rustBridge.onEmailReceived(async (data) => {
|
||||||
try {
|
try {
|
||||||
await this.handleRustEmailReceived(data);
|
await this.handleRustEmailReceived(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||||
// Send rejection back to Rust (may fail if bridge is restarting)
|
|
||||||
try {
|
try {
|
||||||
await this.rustBridge.sendEmailProcessingResult({
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
correlationId: data.correlationId,
|
correlationId: data.correlationId,
|
||||||
@@ -417,7 +419,38 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine which ports need STARTTLS and which need implicit TLS
|
this.rustBridge.onScramCredentialRequest(async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleScramCredentialRequest(data);
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('error', `Error handling SCRAM credential request: ${(err as Error).message}`);
|
||||||
|
try {
|
||||||
|
await this.rustBridge.sendScramCredentialResult({
|
||||||
|
correlationId: data.correlationId,
|
||||||
|
found: false,
|
||||||
|
});
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startSmtpServer(): Promise<void> {
|
||||||
|
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
||||||
|
let tlsCertPem: string | undefined;
|
||||||
|
let tlsKeyPem: string | undefined;
|
||||||
|
|
||||||
|
if (hasTlsConfig) {
|
||||||
|
try {
|
||||||
|
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||||
|
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||||
|
logger.log('info', 'TLS certificates loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
||||||
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||||
|
|
||||||
@@ -449,12 +482,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
||||||
logger.log('info', 'UnifiedEmailServer started successfully');
|
|
||||||
this.emit('started');
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -512,8 +539,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an emailReceived event from the Rust SMTP server.
|
* Handle an emailReceived event from the Rust SMTP server.
|
||||||
* Decodes the email data, processes it through the routing system,
|
|
||||||
* and sends back the result via the correlation-ID callback.
|
|
||||||
*/
|
*/
|
||||||
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
||||||
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
||||||
@@ -588,7 +613,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an authRequest event from the Rust SMTP server.
|
* Handle an authRequest event from the Rust SMTP server.
|
||||||
* Validates credentials and sends back the result.
|
|
||||||
*/
|
*/
|
||||||
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
||||||
const { correlationId, username, password, remoteAddr } = data;
|
const { correlationId, username, password, remoteAddr } = data;
|
||||||
@@ -616,6 +640,53 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a SCRAM credential request from the Rust SMTP server.
|
||||||
|
* Computes SCRAM-SHA-256 credentials from the stored password for the given user.
|
||||||
|
*/
|
||||||
|
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
||||||
|
const { correlationId, username, remoteAddr } = data;
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
|
||||||
|
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
|
||||||
|
|
||||||
|
const users = this.options.auth?.users || [];
|
||||||
|
const matched = users.find(u => u.username === username);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
await this.rustBridge.sendScramCredentialResult({
|
||||||
|
correlationId,
|
||||||
|
found: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SCRAM-SHA-256 credentials from plaintext password
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const iterations = 4096;
|
||||||
|
|
||||||
|
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
|
||||||
|
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
|
||||||
|
|
||||||
|
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
|
||||||
|
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
|
||||||
|
|
||||||
|
// StoredKey = SHA256(ClientKey)
|
||||||
|
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
|
||||||
|
|
||||||
|
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
|
||||||
|
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
|
||||||
|
|
||||||
|
await this.rustBridge.sendScramCredentialResult({
|
||||||
|
correlationId,
|
||||||
|
found: true,
|
||||||
|
salt: salt.toString('base64'),
|
||||||
|
iterations,
|
||||||
|
storedKey: storedKey.toString('base64'),
|
||||||
|
serverKey: serverKey.toString('base64'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
|
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
|
||||||
* or falling back to IPC call if no pre-computed results are available.
|
* or falling back to IPC call if no pre-computed results are available.
|
||||||
@@ -740,14 +811,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First check if this is a bounce notification email
|
// First check if this is a bounce notification email
|
||||||
// Look for common bounce notification subject patterns
|
|
||||||
const subject = email.subject || '';
|
const subject = email.subject || '';
|
||||||
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
||||||
|
|
||||||
if (isBounceLike) {
|
if (isBounceLike) {
|
||||||
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
|
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
|
||||||
|
|
||||||
// Try to process as a bounce
|
|
||||||
const isBounce = await this.processBounceNotification(email);
|
const isBounce = await this.processBounceNotification(email);
|
||||||
|
|
||||||
if (isBounce) {
|
if (isBounce) {
|
||||||
@@ -763,7 +832,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
const route = await this.emailRouter.evaluateRoutes(context);
|
const route = await this.emailRouter.evaluateRoutes(context);
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
// No matching route - reject
|
|
||||||
throw new Error('No matching route for email');
|
throw new Error('No matching route for email');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,221 +839,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
session.matchedRoute = route;
|
session.matchedRoute = route;
|
||||||
|
|
||||||
// Execute action based on route
|
// Execute action based on route
|
||||||
await this.executeAction(route.action, email, context);
|
await this.actionExecutor.executeAction(route.action, email, context);
|
||||||
|
|
||||||
// Return the processed email
|
// Return the processed email
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute action based on route configuration
|
|
||||||
*/
|
|
||||||
private 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle forward action
|
|
||||||
*/
|
|
||||||
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.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.bounceManager.processSmtpFailure(recipient, error.message, {
|
|
||||||
sender: email.from,
|
|
||||||
originalEmailId: email.headers['Message-ID'] as string
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle process action
|
|
||||||
*/
|
|
||||||
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) {
|
|
||||||
// Use existing content scanner
|
|
||||||
// Note: ContentScanner integration would go here
|
|
||||||
logger.log('info', 'Content scanning requested');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: DKIM signing will be applied at delivery time to ensure signature validity
|
|
||||||
|
|
||||||
// Queue for delivery
|
|
||||||
const queue = action.process?.queue || 'normal';
|
|
||||||
await this.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
|
|
||||||
|
|
||||||
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deliver action
|
|
||||||
*/
|
|
||||||
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
logger.log('info', `Delivering email locally`);
|
|
||||||
|
|
||||||
// Queue for local delivery
|
|
||||||
await this.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
|
|
||||||
|
|
||||||
logger.log('info', 'Email queued for local delivery');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle reject action
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up DKIM configuration for all domains
|
|
||||||
*/
|
|
||||||
private 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 {
|
|
||||||
// Check if DKIM keys already exist for this domain
|
|
||||||
let keyPair: { privateKey: string; publicKey: string };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to read existing keys
|
|
||||||
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
|
||||||
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
// Generate new keys if they don't exist
|
|
||||||
keyPair = await this.dkimCreator.createDKIMKeys();
|
|
||||||
// Store them for future use
|
|
||||||
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
|
||||||
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the private key for signing
|
|
||||||
this.dkimKeys.set(domain, keyPair.privateKey);
|
|
||||||
|
|
||||||
// DNS record creation is now handled by DnsManager
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply per-domain rate limits from domain configurations
|
* Apply per-domain rate limits from domain configurations
|
||||||
*/
|
*/
|
||||||
@@ -997,12 +856,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
const domain = domainConfig.domain;
|
const domain = domainConfig.domain;
|
||||||
const rateLimitConfig: any = {};
|
const rateLimitConfig: any = {};
|
||||||
|
|
||||||
// Convert domain-specific rate limits to the format expected by UnifiedRateLimiter
|
|
||||||
if (domainConfig.rateLimits.outbound) {
|
if (domainConfig.rateLimits.outbound) {
|
||||||
if (domainConfig.rateLimits.outbound.messagesPerMinute) {
|
if (domainConfig.rateLimits.outbound.messagesPerMinute) {
|
||||||
rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute;
|
rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute;
|
||||||
}
|
}
|
||||||
// Note: messagesPerHour and messagesPerDay would need additional implementation in rate limiter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domainConfig.rateLimits.inbound) {
|
if (domainConfig.rateLimits.inbound) {
|
||||||
@@ -1017,7 +874,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the rate limits if we have any
|
|
||||||
if (Object.keys(rateLimitConfig).length > 0) {
|
if (Object.keys(rateLimitConfig).length > 0) {
|
||||||
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
|
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
|
||||||
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
|
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
|
||||||
@@ -1026,88 +882,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check and rotate DKIM keys if needed
|
|
||||||
*/
|
|
||||||
private 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 {
|
|
||||||
// Check if keys need rotation
|
|
||||||
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
|
|
||||||
|
|
||||||
if (needsRotation) {
|
|
||||||
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
|
|
||||||
|
|
||||||
// Rotate the keys
|
|
||||||
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
|
|
||||||
|
|
||||||
// Update the domain config with new selector
|
|
||||||
domainConfig.dkim = {
|
|
||||||
...domainConfig.dkim,
|
|
||||||
selector: newSelector
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-register DNS handler for new selector if internal-dns mode
|
|
||||||
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
|
||||||
// Get new public key
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Register new selector
|
|
||||||
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}`);
|
|
||||||
|
|
||||||
// Store the updated public key in storage
|
|
||||||
await this.dcRouter.storageManager.set(
|
|
||||||
`/email/dkim/${domain}/public.key`,
|
|
||||||
keyPair.publicKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old keys after grace period (async, don't wait)
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate SmartProxy routes for email ports
|
* Generate SmartProxy routes for email ports
|
||||||
*/
|
*/
|
||||||
@@ -1121,26 +895,24 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
const actualPortMapping = portMapping || defaultPortMapping;
|
const actualPortMapping = portMapping || defaultPortMapping;
|
||||||
|
|
||||||
// Generate routes for each configured port
|
|
||||||
for (const externalPort of this.options.ports) {
|
for (const externalPort of this.options.ports) {
|
||||||
const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
|
const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
|
||||||
|
|
||||||
let routeName = 'email-route';
|
let routeName = 'email-route';
|
||||||
let tlsMode = 'passthrough';
|
let tlsMode = 'passthrough';
|
||||||
|
|
||||||
// Configure based on port
|
|
||||||
switch (externalPort) {
|
switch (externalPort) {
|
||||||
case 25:
|
case 25:
|
||||||
routeName = 'smtp-route';
|
routeName = 'smtp-route';
|
||||||
tlsMode = 'passthrough'; // STARTTLS
|
tlsMode = 'passthrough';
|
||||||
break;
|
break;
|
||||||
case 587:
|
case 587:
|
||||||
routeName = 'submission-route';
|
routeName = 'submission-route';
|
||||||
tlsMode = 'passthrough'; // STARTTLS
|
tlsMode = 'passthrough';
|
||||||
break;
|
break;
|
||||||
case 465:
|
case 465:
|
||||||
routeName = 'smtps-route';
|
routeName = 'smtps-route';
|
||||||
tlsMode = 'terminate'; // Implicit TLS
|
tlsMode = 'terminate';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
routeName = `email-port-${externalPort}-route`;
|
routeName = `email-port-${externalPort}-route`;
|
||||||
@@ -1171,7 +943,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Update server configuration
|
* Update server configuration
|
||||||
*/
|
*/
|
||||||
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
||||||
// Stop the server if changing ports
|
|
||||||
const portsChanged = options.ports &&
|
const portsChanged = options.ports &&
|
||||||
(!this.options.ports ||
|
(!this.options.ports ||
|
||||||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
||||||
@@ -1182,15 +953,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
this.start();
|
this.start();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update options without restart
|
|
||||||
this.options = { ...this.options, ...options };
|
this.options = { ...this.options, ...options };
|
||||||
|
|
||||||
// Update domain registry if domains changed
|
|
||||||
if (options.domains) {
|
if (options.domains) {
|
||||||
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
|
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email router if routes changed
|
|
||||||
if (options.routes) {
|
if (options.routes) {
|
||||||
this.emailRouter.updateRoutes(options.routes);
|
this.emailRouter.updateRoutes(options.routes);
|
||||||
}
|
}
|
||||||
@@ -1219,21 +987,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
return this.domainRegistry;
|
return this.domainRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update email routes dynamically
|
|
||||||
*/
|
|
||||||
public updateRoutes(routes: IEmailRoute[]): void {
|
|
||||||
this.emailRouter.setRoutes(routes);
|
|
||||||
logger.log('info', `Updated email routes with ${routes.length} routes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an email through the delivery system
|
* Send an email through the delivery system
|
||||||
* @param email The email to send
|
|
||||||
* @param mode The processing mode to use
|
|
||||||
* @param rule Optional rule to apply
|
|
||||||
* @param options Optional sending options
|
|
||||||
* @returns The ID of the queued email
|
|
||||||
*/
|
*/
|
||||||
public async sendEmail(
|
public async sendEmail(
|
||||||
email: Email,
|
email: Email,
|
||||||
@@ -1248,7 +1003,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
|
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate the email
|
|
||||||
if (!email.from) {
|
if (!email.from) {
|
||||||
throw new Error('Email must have a sender address');
|
throw new Error('Email must have a sender address');
|
||||||
}
|
}
|
||||||
@@ -1257,12 +1011,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
throw new Error('Email must have at least one recipient');
|
throw new Error('Email must have at least one recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any recipients are on the suppression list (unless explicitly skipped)
|
// Check if any recipients are on the suppression list
|
||||||
if (!options?.skipSuppressionCheck) {
|
if (!options?.skipSuppressionCheck) {
|
||||||
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
|
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
|
||||||
|
|
||||||
if (suppressedRecipients.length > 0) {
|
if (suppressedRecipients.length > 0) {
|
||||||
// Filter out suppressed recipients
|
|
||||||
const originalCount = email.to.length;
|
const originalCount = email.to.length;
|
||||||
const suppressed = suppressedRecipients.map(recipient => {
|
const suppressed = suppressedRecipients.map(recipient => {
|
||||||
const info = this.getSuppressionInfo(recipient);
|
const info = this.getSuppressionInfo(recipient);
|
||||||
@@ -1275,26 +1028,21 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
|
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
|
||||||
|
|
||||||
// If all recipients are suppressed, throw an error
|
|
||||||
if (suppressedRecipients.length === originalCount) {
|
if (suppressedRecipients.length === originalCount) {
|
||||||
throw new Error('All recipients are on the suppression list');
|
throw new Error('All recipients are on the suppression list');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter the recipients list to only include non-suppressed addresses
|
|
||||||
email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
|
email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the sender domain has DKIM keys and sign the email if needed
|
// Sign with DKIM if configured
|
||||||
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
||||||
const domain = email.from.split('@')[1];
|
const domain = email.from.split('@')[1];
|
||||||
await this.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
|
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique ID for this email
|
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
|
|
||||||
// Queue the email for delivery
|
|
||||||
await this.deliveryQueue.enqueue(email, mode, route);
|
await this.deliveryQueue.enqueue(email, mode, route);
|
||||||
|
|
||||||
logger.log('info', `Email queued with ID: ${id}`);
|
logger.log('info', `Email queued with ID: ${id}`);
|
||||||
@@ -1305,51 +1053,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -----------------------------------------------------------------------
|
||||||
* Handle DKIM signing for an email
|
// Bounce/suppression methods
|
||||||
* @param email The email to sign
|
// -----------------------------------------------------------------------
|
||||||
* @param domain The domain to sign with
|
|
||||||
* @param selector The DKIM selector
|
|
||||||
*/
|
|
||||||
private async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure we have DKIM keys for this domain
|
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
|
||||||
|
|
||||||
// Get the private key
|
|
||||||
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
|
||||||
|
|
||||||
// Convert Email to raw format for signing
|
|
||||||
const rawEmail = email.toRFC822String();
|
|
||||||
|
|
||||||
// Sign the email via Rust bridge
|
|
||||||
const signResult = await this.rustBridge.signDkim({
|
|
||||||
rawMessage: rawEmail,
|
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
privateKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
// Continue without DKIM rather than failing the send
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a bounce notification email
|
|
||||||
* @param bounceEmail The email containing bounce notification information
|
|
||||||
* @returns Processed bounce record or null if not a bounce
|
|
||||||
*/
|
|
||||||
public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
|
public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
|
||||||
logger.log('info', 'Processing potential bounce notification email');
|
logger.log('info', 'Processing potential bounce notification email');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process as a bounce notification (no conversion needed anymore)
|
|
||||||
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
|
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
|
||||||
|
|
||||||
if (bounceRecord) {
|
if (bounceRecord) {
|
||||||
@@ -1358,10 +1069,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
bounceCategory: bounceRecord.bounceCategory
|
bounceCategory: bounceRecord.bounceCategory
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify any registered listeners about the bounce
|
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Log security event
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
@@ -1387,10 +1096,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
message: 'Failed to process bounce notification',
|
message: 'Failed to process bounce notification',
|
||||||
details: {
|
details: { error: error.message, subject: bounceEmail.subject },
|
||||||
error: error.message,
|
|
||||||
subject: bounceEmail.subject
|
|
||||||
},
|
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1398,41 +1104,22 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an SMTP failure as a bounce
|
|
||||||
* @param recipient Recipient email that failed
|
|
||||||
* @param smtpResponse SMTP error response
|
|
||||||
* @param options Additional options for bounce processing
|
|
||||||
* @returns Processed bounce record
|
|
||||||
*/
|
|
||||||
public async processSmtpFailure(
|
public async processSmtpFailure(
|
||||||
recipient: string,
|
recipient: string,
|
||||||
smtpResponse: string,
|
smtpResponse: string,
|
||||||
options: {
|
options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record<string, string> } = {}
|
||||||
sender?: string;
|
|
||||||
originalEmailId?: string;
|
|
||||||
statusCode?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
} = {}
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
|
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process the SMTP failure through the bounce manager
|
const bounceRecord = await this.bounceManager.processSmtpFailure(recipient, smtpResponse, options);
|
||||||
const bounceRecord = await this.bounceManager.processSmtpFailure(
|
|
||||||
recipient,
|
|
||||||
smtpResponse,
|
|
||||||
options
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
|
logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
|
||||||
bounceType: bounceRecord.bounceType
|
bounceType: bounceRecord.bounceType
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify any registered listeners about the bounce
|
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Log security event
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
@@ -1455,11 +1142,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
message: 'Failed to process SMTP failure',
|
message: 'Failed to process SMTP failure',
|
||||||
details: {
|
details: { recipient, smtpResponse, error: error.message },
|
||||||
recipient,
|
|
||||||
smtpResponse,
|
|
||||||
error: error.message
|
|
||||||
},
|
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1467,85 +1150,36 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an email address is suppressed (has bounced previously)
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Whether the email is suppressed
|
|
||||||
*/
|
|
||||||
public isEmailSuppressed(email: string): boolean {
|
public isEmailSuppressed(email: string): boolean {
|
||||||
return this.bounceManager.isEmailSuppressed(email);
|
return this.bounceManager.isEmailSuppressed(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number } | null {
|
||||||
* Get suppression information for an email
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Suppression information or null if not suppressed
|
|
||||||
*/
|
|
||||||
public getSuppressionInfo(email: string): {
|
|
||||||
reason: string;
|
|
||||||
timestamp: number;
|
|
||||||
expiresAt?: number;
|
|
||||||
} | null {
|
|
||||||
return this.bounceManager.getSuppressionInfo(email);
|
return this.bounceManager.getSuppressionInfo(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public getBounceHistory(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory } | null {
|
||||||
* Get bounce history information for an email
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Bounce history or null if no bounces
|
|
||||||
*/
|
|
||||||
public getBounceHistory(email: string): {
|
|
||||||
lastBounce: number;
|
|
||||||
count: number;
|
|
||||||
type: BounceType;
|
|
||||||
category: BounceCategory;
|
|
||||||
} | null {
|
|
||||||
return this.bounceManager.getBounceInfo(email);
|
return this.bounceManager.getBounceInfo(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all suppressed email addresses
|
|
||||||
* @returns Array of suppressed email addresses
|
|
||||||
*/
|
|
||||||
public getSuppressionList(): string[] {
|
public getSuppressionList(): string[] {
|
||||||
return this.bounceManager.getSuppressionList();
|
return this.bounceManager.getSuppressionList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all hard bounced email addresses
|
|
||||||
* @returns Array of hard bounced email addresses
|
|
||||||
*/
|
|
||||||
public getHardBouncedAddresses(): string[] {
|
public getHardBouncedAddresses(): string[] {
|
||||||
return this.bounceManager.getHardBouncedAddresses();
|
return this.bounceManager.getHardBouncedAddresses();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an email to the suppression list
|
|
||||||
* @param email Email address to suppress
|
|
||||||
* @param reason Reason for suppression
|
|
||||||
* @param expiresAt Optional expiration time (undefined for permanent)
|
|
||||||
*/
|
|
||||||
public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
|
public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
|
||||||
this.bounceManager.addToSuppressionList(email, reason, expiresAt);
|
this.bounceManager.addToSuppressionList(email, reason, expiresAt);
|
||||||
logger.log('info', `Added ${email} to suppression list: ${reason}`);
|
logger.log('info', `Added ${email} to suppression list: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an email from the suppression list
|
|
||||||
* @param email Email address to remove from suppression
|
|
||||||
*/
|
|
||||||
public removeFromSuppressionList(email: string): void {
|
public removeFromSuppressionList(email: string): void {
|
||||||
this.bounceManager.removeFromSuppressionList(email);
|
this.bounceManager.removeFromSuppressionList(email);
|
||||||
logger.log('info', `Removed ${email} from suppression list`);
|
logger.log('info', `Removed ${email} from suppression list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Record email bounce
|
|
||||||
* @param domain Sending domain
|
|
||||||
* @param receivingDomain Receiving domain that bounced
|
|
||||||
* @param bounceType Type of bounce (hard/soft)
|
|
||||||
* @param reason Bounce reason
|
|
||||||
*/
|
|
||||||
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
||||||
const bounceRecord = {
|
const bounceRecord = {
|
||||||
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
@@ -1566,7 +1200,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the rate limiter instance
|
* Get the rate limiter instance
|
||||||
* @returns The unified rate limiter
|
|
||||||
*/
|
*/
|
||||||
public getRateLimiter(): UnifiedRateLimiter {
|
public getRateLimiter(): UnifiedRateLimiter {
|
||||||
return this.rateLimiter;
|
return this.rateLimiter;
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export * from './classes.unified.email.server.js';
|
|||||||
export * from './classes.dns.manager.js';
|
export * from './classes.dns.manager.js';
|
||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
export * from './classes.domain.registry.js';
|
export * from './classes.domain.registry.js';
|
||||||
|
export * from './classes.email.action.executor.js';
|
||||||
|
export * from './classes.dkim.manager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class DKIMCreator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a DKIM key pair - changed to public for API access
|
// Create an RSA DKIM key pair - changed to public for API access
|
||||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||||
modulusLength: 2048,
|
modulusLength: 2048,
|
||||||
@@ -126,6 +126,16 @@ export class DKIMCreator {
|
|||||||
return { privateKey, publicKey };
|
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
|
// Store a DKIM key pair - uses storage manager if available, else disk
|
||||||
public async storeDKIMKeys(
|
public async storeDKIMKeys(
|
||||||
privateKey: string,
|
privateKey: string,
|
||||||
@@ -176,8 +186,11 @@ export class DKIMCreator {
|
|||||||
.replace(pemFooter, '')
|
.replace(pemFooter, '')
|
||||||
.replace(/\n/g, '');
|
.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
|
// 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 {
|
return {
|
||||||
name: `mta._domainkey.${domainArg}`,
|
name: `mta._domainkey.${domainArg}`,
|
||||||
@@ -375,8 +388,11 @@ export class DKIMCreator {
|
|||||||
.replace(pemFooter, '')
|
.replace(pemFooter, '')
|
||||||
.replace(/\n/g, '');
|
.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
|
// 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 {
|
return {
|
||||||
name: `${selector}._domainkey.${domain}`,
|
name: `${selector}._domainkey.${domain}`,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { logger } from '../../logger.js';
|
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a DKIM verification
|
|
||||||
*/
|
|
||||||
export interface IDkimVerificationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
domain?: string;
|
|
||||||
selector?: string;
|
|
||||||
status?: string;
|
|
||||||
details?: any;
|
|
||||||
errorMessage?: string;
|
|
||||||
signatureFields?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DKIM verifier — delegates to the Rust security bridge.
|
|
||||||
*/
|
|
||||||
export class DKIMVerifier {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify DKIM signature for an email via Rust bridge
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
emailData: string,
|
|
||||||
options: {
|
|
||||||
useCache?: boolean;
|
|
||||||
returnDetails?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<IDkimVerificationResult> {
|
|
||||||
try {
|
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
|
||||||
const results = await bridge.verifyDkim(emailData);
|
|
||||||
const first = results[0];
|
|
||||||
|
|
||||||
const result: IDkimVerificationResult = {
|
|
||||||
isValid: first?.is_valid ?? false,
|
|
||||||
domain: first?.domain ?? undefined,
|
|
||||||
selector: first?.selector ?? undefined,
|
|
||||||
status: first?.status ?? 'none',
|
|
||||||
details: options.returnDetails ? results : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`,
|
|
||||||
details: { selector: result.selector, status: result.status },
|
|
||||||
domain: result.domain || 'unknown',
|
|
||||||
success: result.isValid
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log(result.isValid ? 'info' : 'warn',
|
|
||||||
`DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `DKIM verification failed: ${error.message}`);
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification error`,
|
|
||||||
details: { error: error.message },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
status: 'temperror',
|
|
||||||
errorMessage: `Verification error: ${error.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No-op — Rust bridge handles its own caching */
|
|
||||||
public clearCache(): void {}
|
|
||||||
|
|
||||||
/** Always 0 — cache is managed by the Rust side */
|
|
||||||
public getCacheSize(): number {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
import { logger } from '../../logger.js';
|
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
|
||||||
import type { Email } from '../core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC policy types
|
|
||||||
*/
|
|
||||||
export enum DmarcPolicy {
|
|
||||||
NONE = 'none',
|
|
||||||
QUARANTINE = 'quarantine',
|
|
||||||
REJECT = 'reject'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC alignment modes
|
|
||||||
*/
|
|
||||||
export enum DmarcAlignment {
|
|
||||||
RELAXED = 'r',
|
|
||||||
STRICT = 's'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC record fields
|
|
||||||
*/
|
|
||||||
export interface DmarcRecord {
|
|
||||||
// Required fields
|
|
||||||
version: string;
|
|
||||||
policy: DmarcPolicy;
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
subdomainPolicy?: DmarcPolicy;
|
|
||||||
pct?: number;
|
|
||||||
adkim?: DmarcAlignment;
|
|
||||||
aspf?: DmarcAlignment;
|
|
||||||
reportInterval?: number;
|
|
||||||
failureOptions?: string;
|
|
||||||
reportUriAggregate?: string[];
|
|
||||||
reportUriForensic?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC verification result
|
|
||||||
*/
|
|
||||||
export interface DmarcResult {
|
|
||||||
hasDmarc: boolean;
|
|
||||||
record?: DmarcRecord;
|
|
||||||
spfDomainAligned: boolean;
|
|
||||||
dkimDomainAligned: boolean;
|
|
||||||
spfPassed: boolean;
|
|
||||||
dkimPassed: boolean;
|
|
||||||
policyEvaluated: DmarcPolicy;
|
|
||||||
actualPolicy: DmarcPolicy;
|
|
||||||
appliedPercentage: number;
|
|
||||||
action: 'pass' | 'quarantine' | 'reject';
|
|
||||||
details: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for verifying and enforcing DMARC policies
|
|
||||||
*/
|
|
||||||
export class DmarcVerifier {
|
|
||||||
// DNS Manager reference for verifying records
|
|
||||||
private dnsManager?: any;
|
|
||||||
|
|
||||||
constructor(dnsManager?: any) {
|
|
||||||
this.dnsManager = dnsManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a DMARC record from a TXT record string
|
|
||||||
* @param record DMARC TXT record string
|
|
||||||
* @returns Parsed DMARC record or null if invalid
|
|
||||||
*/
|
|
||||||
public parseDmarcRecord(record: string): DmarcRecord | null {
|
|
||||||
if (!record.startsWith('v=DMARC1')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initialize record with default values
|
|
||||||
const dmarcRecord: DmarcRecord = {
|
|
||||||
version: 'DMARC1',
|
|
||||||
policy: DmarcPolicy.NONE,
|
|
||||||
pct: 100,
|
|
||||||
adkim: DmarcAlignment.RELAXED,
|
|
||||||
aspf: DmarcAlignment.RELAXED
|
|
||||||
};
|
|
||||||
|
|
||||||
// Split the record into tag/value pairs
|
|
||||||
const parts = record.split(';').map(part => part.trim());
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part || !part.includes('=')) continue;
|
|
||||||
|
|
||||||
const [tag, value] = part.split('=').map(p => p.trim());
|
|
||||||
|
|
||||||
// Process based on tag
|
|
||||||
switch (tag.toLowerCase()) {
|
|
||||||
case 'v':
|
|
||||||
dmarcRecord.version = value;
|
|
||||||
break;
|
|
||||||
case 'p':
|
|
||||||
dmarcRecord.policy = value as DmarcPolicy;
|
|
||||||
break;
|
|
||||||
case 'sp':
|
|
||||||
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
|
|
||||||
break;
|
|
||||||
case 'pct':
|
|
||||||
const pctValue = parseInt(value, 10);
|
|
||||||
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
|
|
||||||
dmarcRecord.pct = pctValue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'adkim':
|
|
||||||
dmarcRecord.adkim = value as DmarcAlignment;
|
|
||||||
break;
|
|
||||||
case 'aspf':
|
|
||||||
dmarcRecord.aspf = value as DmarcAlignment;
|
|
||||||
break;
|
|
||||||
case 'ri':
|
|
||||||
const interval = parseInt(value, 10);
|
|
||||||
if (!isNaN(interval) && interval > 0) {
|
|
||||||
dmarcRecord.reportInterval = interval;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'fo':
|
|
||||||
dmarcRecord.failureOptions = value;
|
|
||||||
break;
|
|
||||||
case 'rua':
|
|
||||||
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
|
|
||||||
if (uri.startsWith('mailto:')) {
|
|
||||||
return uri.substring(7).trim();
|
|
||||||
}
|
|
||||||
return uri.trim();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'ruf':
|
|
||||||
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
|
|
||||||
if (uri.startsWith('mailto:')) {
|
|
||||||
return uri.substring(7).trim();
|
|
||||||
}
|
|
||||||
return uri.trim();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure subdomain policy is set if not explicitly provided
|
|
||||||
if (!dmarcRecord.subdomainPolicy) {
|
|
||||||
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dmarcRecord;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
|
|
||||||
record,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if domains are aligned according to DMARC policy
|
|
||||||
* @param headerDomain Domain from header (From)
|
|
||||||
* @param authDomain Domain from authentication (SPF, DKIM)
|
|
||||||
* @param alignment Alignment mode
|
|
||||||
* @returns Whether the domains are aligned
|
|
||||||
*/
|
|
||||||
private isDomainAligned(
|
|
||||||
headerDomain: string,
|
|
||||||
authDomain: string,
|
|
||||||
alignment: DmarcAlignment
|
|
||||||
): boolean {
|
|
||||||
if (!headerDomain || !authDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For strict alignment, domains must match exactly
|
|
||||||
if (alignment === DmarcAlignment.STRICT) {
|
|
||||||
return headerDomain.toLowerCase() === authDomain.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
|
|
||||||
// or the same as the header domain
|
|
||||||
const headerParts = headerDomain.toLowerCase().split('.');
|
|
||||||
const authParts = authDomain.toLowerCase().split('.');
|
|
||||||
|
|
||||||
// Ensures we have at least two parts (domain and TLD)
|
|
||||||
if (headerParts.length < 2 || authParts.length < 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get organizational domain (last two parts)
|
|
||||||
const headerOrgDomain = headerParts.slice(-2).join('.');
|
|
||||||
const authOrgDomain = authParts.slice(-2).join('.');
|
|
||||||
|
|
||||||
return headerOrgDomain === authOrgDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract domain from an email address
|
|
||||||
* @param email Email address
|
|
||||||
* @returns Domain part of the email
|
|
||||||
*/
|
|
||||||
private getDomainFromEmail(email: string): string {
|
|
||||||
if (!email) return '';
|
|
||||||
|
|
||||||
// Handle name + email format: "John Doe <john@example.com>"
|
|
||||||
const matches = email.match(/<([^>]+)>/);
|
|
||||||
const address = matches ? matches[1] : email;
|
|
||||||
|
|
||||||
const parts = address.split('@');
|
|
||||||
return parts.length > 1 ? parts[1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if DMARC verification should be applied based on percentage
|
|
||||||
* @param record DMARC record
|
|
||||||
* @returns Whether DMARC verification should be applied
|
|
||||||
*/
|
|
||||||
private shouldApplyDmarc(record: DmarcRecord): boolean {
|
|
||||||
if (record.pct === undefined || record.pct === 100) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply DMARC randomly based on percentage
|
|
||||||
const random = Math.floor(Math.random() * 100) + 1;
|
|
||||||
return random <= record.pct;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the action to take based on DMARC policy
|
|
||||||
* @param policy DMARC policy
|
|
||||||
* @returns Action to take
|
|
||||||
*/
|
|
||||||
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
|
|
||||||
switch (policy) {
|
|
||||||
case DmarcPolicy.REJECT:
|
|
||||||
return 'reject';
|
|
||||||
case DmarcPolicy.QUARANTINE:
|
|
||||||
return 'quarantine';
|
|
||||||
case DmarcPolicy.NONE:
|
|
||||||
default:
|
|
||||||
return 'pass';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify DMARC for an incoming email
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param spfResult SPF verification result
|
|
||||||
* @param dkimResult DKIM verification result
|
|
||||||
* @returns DMARC verification result
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
email: Email,
|
|
||||||
spfResult: { domain: string; result: boolean },
|
|
||||||
dkimResult: { domain: string; result: boolean }
|
|
||||||
): Promise<DmarcResult> {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
|
|
||||||
// Initialize result
|
|
||||||
const result: DmarcResult = {
|
|
||||||
hasDmarc: false,
|
|
||||||
spfDomainAligned: false,
|
|
||||||
dkimDomainAligned: false,
|
|
||||||
spfPassed: spfResult.result,
|
|
||||||
dkimPassed: dkimResult.result,
|
|
||||||
policyEvaluated: DmarcPolicy.NONE,
|
|
||||||
actualPolicy: DmarcPolicy.NONE,
|
|
||||||
appliedPercentage: 100,
|
|
||||||
action: 'pass',
|
|
||||||
details: 'DMARC not configured'
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract From domain
|
|
||||||
const fromHeader = email.getFromEmail();
|
|
||||||
const fromDomain = this.getDomainFromEmail(fromHeader);
|
|
||||||
|
|
||||||
if (!fromDomain) {
|
|
||||||
result.error = 'Invalid From domain';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check alignment
|
|
||||||
result.spfDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
spfResult.domain,
|
|
||||||
DmarcAlignment.RELAXED
|
|
||||||
);
|
|
||||||
|
|
||||||
result.dkimDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
dkimResult.domain,
|
|
||||||
DmarcAlignment.RELAXED
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lookup DMARC record
|
|
||||||
const dmarcVerificationResult = this.dnsManager ?
|
|
||||||
await this.dnsManager.verifyDmarcRecord(fromDomain) :
|
|
||||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
|
||||||
|
|
||||||
// If DMARC record exists and is valid
|
|
||||||
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
|
|
||||||
result.hasDmarc = true;
|
|
||||||
|
|
||||||
// Parse DMARC record
|
|
||||||
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
|
|
||||||
|
|
||||||
if (parsedRecord) {
|
|
||||||
result.record = parsedRecord;
|
|
||||||
result.actualPolicy = parsedRecord.policy;
|
|
||||||
result.appliedPercentage = parsedRecord.pct || 100;
|
|
||||||
|
|
||||||
// Override alignment modes if specified in record
|
|
||||||
if (parsedRecord.adkim) {
|
|
||||||
result.dkimDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
dkimResult.domain,
|
|
||||||
parsedRecord.adkim
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedRecord.aspf) {
|
|
||||||
result.spfDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
spfResult.domain,
|
|
||||||
parsedRecord.aspf
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine DMARC compliance
|
|
||||||
const spfAligned = result.spfPassed && result.spfDomainAligned;
|
|
||||||
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
|
|
||||||
|
|
||||||
// Email passes DMARC if either SPF or DKIM passes with alignment
|
|
||||||
const dmarcPass = spfAligned || dkimAligned;
|
|
||||||
|
|
||||||
// Use record percentage to determine if policy should be applied
|
|
||||||
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
|
|
||||||
|
|
||||||
if (!dmarcPass) {
|
|
||||||
// DMARC failed, apply policy
|
|
||||||
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
|
|
||||||
result.action = this.determineAction(result.policyEvaluated);
|
|
||||||
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
|
|
||||||
} else {
|
|
||||||
result.policyEvaluated = DmarcPolicy.NONE;
|
|
||||||
result.action = 'pass';
|
|
||||||
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'Invalid DMARC record format';
|
|
||||||
result.details = 'DMARC record invalid';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No DMARC record found or invalid
|
|
||||||
result.details = dmarcVerificationResult.error || 'No DMARC record found';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the DMARC verification
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DMARC,
|
|
||||||
message: result.details,
|
|
||||||
domain: fromDomain,
|
|
||||||
details: {
|
|
||||||
fromDomain,
|
|
||||||
spfDomain: spfResult.domain,
|
|
||||||
dkimDomain: dkimResult.domain,
|
|
||||||
spfPassed: result.spfPassed,
|
|
||||||
dkimPassed: result.dkimPassed,
|
|
||||||
spfAligned: result.spfDomainAligned,
|
|
||||||
dkimAligned: result.dkimDomainAligned,
|
|
||||||
dmarcPolicy: result.policyEvaluated,
|
|
||||||
action: result.action
|
|
||||||
},
|
|
||||||
success: result.action === 'pass'
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error verifying DMARC: ${error.message}`, {
|
|
||||||
error: error.message,
|
|
||||||
emailId: email.getMessageId()
|
|
||||||
});
|
|
||||||
|
|
||||||
result.error = `DMARC verification error: ${error.message}`;
|
|
||||||
|
|
||||||
// Log error
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DMARC,
|
|
||||||
message: `DMARC verification failed with error`,
|
|
||||||
details: {
|
|
||||||
error: error.message,
|
|
||||||
emailId: email.getMessageId()
|
|
||||||
},
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply DMARC policy to an email
|
|
||||||
* @param email Email to apply policy to
|
|
||||||
* @param dmarcResult DMARC verification result
|
|
||||||
* @returns Whether the email should be accepted
|
|
||||||
*/
|
|
||||||
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
|
|
||||||
// Apply action based on DMARC verification result
|
|
||||||
switch (dmarcResult.action) {
|
|
||||||
case 'reject':
|
|
||||||
// Reject the email
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
|
|
||||||
emailId: email.getMessageId(),
|
|
||||||
from: email.getFromEmail(),
|
|
||||||
subject: email.subject
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
|
|
||||||
case 'quarantine':
|
|
||||||
// Quarantine the email (mark as spam)
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
|
|
||||||
// Add spam header
|
|
||||||
if (!email.headers['X-Spam-Flag']) {
|
|
||||||
email.headers['X-Spam-Flag'] = 'YES';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add DMARC reason header
|
|
||||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
|
||||||
|
|
||||||
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
|
|
||||||
emailId: email.getMessageId(),
|
|
||||||
from: email.getFromEmail(),
|
|
||||||
subject: email.subject
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'pass':
|
|
||||||
default:
|
|
||||||
// Accept the email
|
|
||||||
// Add DMARC result header for information
|
|
||||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End-to-end DMARC verification and policy application
|
|
||||||
* This method should be called after SPF and DKIM verification
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param spfResult SPF verification result
|
|
||||||
* @param dkimResult DKIM verification result
|
|
||||||
* @returns Whether the email should be accepted
|
|
||||||
*/
|
|
||||||
public async verifyAndApply(
|
|
||||||
email: Email,
|
|
||||||
spfResult: { domain: string; result: boolean },
|
|
||||||
dkimResult: { domain: string; result: boolean }
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Verify DMARC
|
|
||||||
const dmarcResult = await this.verify(email, spfResult, dkimResult);
|
|
||||||
|
|
||||||
// Apply DMARC policy
|
|
||||||
return this.applyPolicy(email, dmarcResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { logger } from '../../logger.js';
|
import { 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
|
* SPF result qualifiers
|
||||||
@@ -127,107 +123,4 @@ export class SpfVerifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SPF for a given email — delegates to Rust bridge
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
email: Email,
|
|
||||||
ip: string,
|
|
||||||
heloDomain: string
|
|
||||||
): Promise<SpfResult> {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
const mailFrom = email.from || '';
|
|
||||||
const domain = mailFrom.split('@')[1] || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
|
||||||
const result = await bridge.checkSpf({
|
|
||||||
ip,
|
|
||||||
heloDomain,
|
|
||||||
hostname: plugins.os.hostname(),
|
|
||||||
mailFrom,
|
|
||||||
});
|
|
||||||
|
|
||||||
const spfResult: SpfResult = {
|
|
||||||
result: result.result as SpfResult['result'],
|
|
||||||
domain: result.domain,
|
|
||||||
ip: result.ip,
|
|
||||||
explanation: result.explanation ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
|
|
||||||
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
|
|
||||||
type: SecurityEventType.SPF,
|
|
||||||
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
|
|
||||||
domain: spfResult.domain,
|
|
||||||
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
|
|
||||||
success: spfResult.result === 'pass'
|
|
||||||
});
|
|
||||||
|
|
||||||
return spfResult;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
|
|
||||||
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.SPF,
|
|
||||||
message: `SPF verification error for ${domain}`,
|
|
||||||
domain,
|
|
||||||
details: { ip, error: error.message },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: 'temperror',
|
|
||||||
explanation: `Error verifying SPF: ${error.message}`,
|
|
||||||
domain,
|
|
||||||
ip,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if email passes SPF verification and apply headers
|
|
||||||
*/
|
|
||||||
public async verifyAndApply(
|
|
||||||
email: Email,
|
|
||||||
ip: string,
|
|
||||||
heloDomain: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const result = await this.verify(email, ip, heloDomain);
|
|
||||||
|
|
||||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
|
||||||
|
|
||||||
switch (result.result) {
|
|
||||||
case 'fail':
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return false;
|
|
||||||
|
|
||||||
case 'softfail':
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'neutral':
|
|
||||||
case 'none':
|
|
||||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'pass':
|
|
||||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'temperror':
|
|
||||||
case 'permerror':
|
|
||||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Email security components
|
// Email security components
|
||||||
export * from './classes.dkimcreator.js';
|
export * from './classes.dkimcreator.js';
|
||||||
export * from './classes.dkimverifier.js';
|
|
||||||
export * from './classes.dmarcverifier.js';
|
|
||||||
export * from './classes.spfverifier.js';
|
export * from './classes.spfverifier.js';
|
||||||
@@ -95,11 +95,12 @@ interface ISmtpSendOptions {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
auth?: { user: string; pass: string; method?: string };
|
auth?: { user: string; pass: string; method?: string };
|
||||||
email: IOutboundEmail;
|
email: IOutboundEmail;
|
||||||
dkim?: { domain: string; selector: string; privateKey: string };
|
dkim?: { domain: string; selector: string; privateKey: string; keyType?: string };
|
||||||
connectionTimeoutSecs?: number;
|
connectionTimeoutSecs?: number;
|
||||||
socketTimeoutSecs?: number;
|
socketTimeoutSecs?: number;
|
||||||
poolKey?: string;
|
poolKey?: string;
|
||||||
maxPoolConnections?: number;
|
maxPoolConnections?: number;
|
||||||
|
tlsOpportunistic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ISmtpSendRawOptions {
|
interface ISmtpSendRawOptions {
|
||||||
@@ -147,6 +148,7 @@ interface ISmtpServerConfig {
|
|||||||
securePort?: number;
|
securePort?: number;
|
||||||
tlsCertPem?: string;
|
tlsCertPem?: string;
|
||||||
tlsKeyPem?: string;
|
tlsKeyPem?: string;
|
||||||
|
additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
|
||||||
maxMessageSize?: number;
|
maxMessageSize?: number;
|
||||||
maxConnections?: number;
|
maxConnections?: number;
|
||||||
maxRecipients?: number;
|
maxRecipients?: number;
|
||||||
@@ -193,6 +195,13 @@ interface IAuthRequestEvent {
|
|||||||
remoteAddr: string;
|
remoteAddr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IScramCredentialRequestEvent {
|
||||||
|
correlationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
username: string;
|
||||||
|
remoteAddr: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe command map for the mailer-bin IPC bridge.
|
* Type-safe command map for the mailer-bin IPC bridge.
|
||||||
*/
|
*/
|
||||||
@@ -222,7 +231,7 @@ type TMailerCommands = {
|
|||||||
result: IDkimVerificationResult[];
|
result: IDkimVerificationResult[];
|
||||||
};
|
};
|
||||||
signDkim: {
|
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 };
|
result: { header: string; signedMessage: string };
|
||||||
};
|
};
|
||||||
checkSpf: {
|
checkSpf: {
|
||||||
@@ -273,6 +282,17 @@ type TMailerCommands = {
|
|||||||
};
|
};
|
||||||
result: { resolved: boolean };
|
result: { resolved: boolean };
|
||||||
};
|
};
|
||||||
|
scramCredentialResult: {
|
||||||
|
params: {
|
||||||
|
correlationId: string;
|
||||||
|
found: boolean;
|
||||||
|
salt?: string;
|
||||||
|
iterations?: number;
|
||||||
|
storedKey?: string;
|
||||||
|
serverKey?: string;
|
||||||
|
};
|
||||||
|
result: { resolved: boolean };
|
||||||
|
};
|
||||||
configureRateLimits: {
|
configureRateLimits: {
|
||||||
params: IRateLimitConfig;
|
params: IRateLimitConfig;
|
||||||
result: { configured: boolean };
|
result: { configured: boolean };
|
||||||
@@ -706,12 +726,13 @@ export class RustSecurityBridge extends EventEmitter {
|
|||||||
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sign an email with DKIM. */
|
/** Sign an email with DKIM (RSA or Ed25519). */
|
||||||
public async signDkim(opts: {
|
public async signDkim(opts: {
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
selector?: string;
|
selector?: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
|
keyType?: string;
|
||||||
}): Promise<{ header: string; signedMessage: string }> {
|
}): Promise<{ header: string; signedMessage: string }> {
|
||||||
this.ensureRunning();
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('signDkim', opts);
|
return this.bridge.sendCommand('signDkim', opts);
|
||||||
@@ -829,6 +850,22 @@ export class RustSecurityBridge extends EventEmitter {
|
|||||||
await this.bridge.sendCommand('authResult', opts);
|
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. */
|
/** Update rate limit configuration at runtime. */
|
||||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||||
this.ensureRunning();
|
this.ensureRunning();
|
||||||
@@ -855,6 +892,14 @@ export class RustSecurityBridge extends EventEmitter {
|
|||||||
this.bridge.on('management:authRequest', handler);
|
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. */
|
/** Remove an emailReceived event handler. */
|
||||||
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||||
this.bridge.off('management:emailReceived', handler);
|
this.bridge.off('management:emailReceived', handler);
|
||||||
@@ -864,6 +909,11 @@ export class RustSecurityBridge extends EventEmitter {
|
|||||||
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||||
this.bridge.off('management:authRequest', handler);
|
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
|
// Re-export interfaces for consumers
|
||||||
@@ -882,6 +932,7 @@ export type {
|
|||||||
IEmailData,
|
IEmailData,
|
||||||
IEmailReceivedEvent,
|
IEmailReceivedEvent,
|
||||||
IAuthRequestEvent,
|
IAuthRequestEvent,
|
||||||
|
IScramCredentialRequestEvent,
|
||||||
IOutboundEmail,
|
IOutboundEmail,
|
||||||
ISmtpSendResult,
|
ISmtpSendResult,
|
||||||
ISmtpSendOptions,
|
ISmtpSendOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user