Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 117d16fd7f | |||
| 5d3163b1aa | |||
| 476de2f59e | |||
| 65ecd94540 | |||
| 04e73c366c | |||
| 8851d61466 | |||
| b465b01790 | |||
| 6ed3252485 | |||
| fc88555790 | |||
| 4eb2fe7934 | |||
| 438242df07 | |||
| 1bb48b2530 | |||
| 3e76662933 | |||
| efb49b67c6 |
@@ -24,4 +24,4 @@
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-14 - 5.3.3 - fix(build)
|
||||
migrate project config to .smartconfig.json and refresh package dependencies
|
||||
|
||||
- replace npmextra.json with .smartconfig.json for build and release configuration
|
||||
- update readme hints to reference the new smart config file
|
||||
- bump build and runtime dependencies including tsrust, smartfs, smartlog, smartrust, tsclass, and mailparser
|
||||
|
||||
## 2026-04-14 - 5.3.2 - fix(mail)
|
||||
align queue, outbound hostname, and DKIM selector behavior across the mail server APIs
|
||||
|
||||
- return the actual delivery queue item id from sendEmail() and add queue inspection/stat APIs on UnifiedEmailServer
|
||||
- use outbound.hostname for outbound SMTP identity while keeping hostname as the advertised public server hostname
|
||||
- fix DKIM selector handling so DNS record names, key storage, signing, and rotation stay selector-aware
|
||||
- harden storage manager integration with a shared typed interface and capability checks across mail and security components
|
||||
|
||||
## 2026-03-02 - 5.3.1 - fix(mail)
|
||||
add periodic cleanup timers and proper shutdown handling for bounce manager and delivery queue; avoid mutating maps during iteration and prune stale rate-limiter stats to prevent memory growth
|
||||
|
||||
- BounceManager: add cleanupInterval to periodically remove bounce records older than 7 days and log removals; add stop() to clear the interval and prevent leaks
|
||||
- UnifiedDeliveryQueue: introduce cleanupTimer started in startProcessing() and cleared in stopProcessing(); cleanupOldItems now collects IDs first to avoid mutating the Map while iterating and logs cleaned items; shutdown now relies on stopProcessing to clear timers
|
||||
- UnifiedRateLimiter: prune stale stats.byIp and stats.byPattern entries for IPs/patterns that no longer have active counters or blocks to reduce memory usage and keep stats accurate
|
||||
- Auto-cleanup tasks log errors rather than throwing to avoid crashing processing loops
|
||||
|
||||
## 2026-02-26 - 5.3.0 - feat(mailer-bin)
|
||||
use mimalloc as the global allocator for mailer-bin
|
||||
|
||||
- Add mimalloc dependency to workspace Cargo.toml
|
||||
- Enable workspace mimalloc in rust/crates/mailer-bin/Cargo.toml
|
||||
- Register mimalloc as the #[global_allocator] in mailer-bin/src/main.rs
|
||||
- Update Cargo.lock with new mimalloc and libmimalloc-sys entries
|
||||
|
||||
## 2026-02-26 - 5.2.6 - fix(postinstall)
|
||||
remove legacy postinstall binary installer and packaging entry
|
||||
|
||||
- Deleted scripts/install-binary.js (legacy postinstall script that downloaded platform-specific binaries).
|
||||
- Removed reference to scripts/install-binary.js from package.json "files" array so the installer is no longer included in published packages.
|
||||
- This prevents automatic binary downloads during npm install and reduces package size; recommend a patch version bump.
|
||||
|
||||
## 2026-02-26 - 5.2.5 - fix(package)
|
||||
remove CLI bin wrapper and exclude bin/ from published files
|
||||
|
||||
- Removed "bin" entry from package.json (mailer wrapper)
|
||||
- Removed "bin/" from files array to prevent including CLI wrapper in published package
|
||||
|
||||
## 2026-02-26 - 5.2.4 - fix(repo)
|
||||
no changes detected — no version bump required
|
||||
|
||||
- git diff contains no changes
|
||||
- package.json version is 5.2.3
|
||||
- no files modified — no release required
|
||||
|
||||
## 2026-02-26 - 5.2.3 - fix(delivery)
|
||||
prevent throttle reset timer from firing after stop and avoid scheduling duplicate timers
|
||||
|
||||
|
||||
+11
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "5.2.3",
|
||||
"version": "5.3.3",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
@@ -30,39 +30,34 @@
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"bin": {
|
||||
"mailer": "./bin/mailer-wrapper.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "tstest test/ --logfile --verbose --timeout 60",
|
||||
"build": "tsbuild tsfolders && tsrust",
|
||||
"check": "tsbuild check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsrust": "^1.3.0",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tsbuild": "4.1.2",
|
||||
"@git.zone/tsrust": "^1.3.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.6.0",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.3.1",
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartrust": "^1.2.1",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"mailparser": "^3.9.3",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"mailparser": "^3.9.8",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist_ts/**/*",
|
||||
"bin/",
|
||||
"scripts/install-binary.js",
|
||||
"dist_rust/**/*",
|
||||
"readme.md",
|
||||
"license",
|
||||
|
||||
Generated
+1763
-1591
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@
|
||||
- `mailer-bin` is the binary target that `tsrust` builds for cross-compilation
|
||||
- `mailer-napi` is a cdylib for N-API bindings (not built by tsrust, needs separate napi-rs build pipeline)
|
||||
- tsrust only supports binary targets (looks for `src/main.rs` or `[[bin]]` entries)
|
||||
- Cross-compilation targets: `linux_amd64`, `linux_arm64` (configured in `npmextra.json`)
|
||||
- Cross-compilation targets: `linux_amd64`, `linux_arm64` (configured in `.smartconfig.json`)
|
||||
- Build output goes to `dist_rust/`
|
||||
|
||||
## Build
|
||||
|
||||
@@ -95,6 +95,8 @@ import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
|
||||
ports: [25, 587, 465],
|
||||
|
||||
// Public SMTP hostname used for greeting/banner and as the default outbound identity
|
||||
hostname: 'mail.example.com',
|
||||
|
||||
// Multi-domain configuration
|
||||
@@ -160,6 +162,16 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
keyPath: '/etc/ssl/mail.key',
|
||||
},
|
||||
|
||||
outbound: {
|
||||
// Optional override for outbound EHLO/HELO identity
|
||||
hostname: 'smtp-out.example.com',
|
||||
},
|
||||
|
||||
queue: {
|
||||
storageType: 'disk',
|
||||
persistentPath: '/var/lib/smartmta/email-queue',
|
||||
},
|
||||
|
||||
maxMessageSize: 25 * 1024 * 1024, // 25 MB
|
||||
maxClients: 500,
|
||||
});
|
||||
@@ -169,6 +181,8 @@ await emailServer.start();
|
||||
```
|
||||
|
||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||
>
|
||||
> `hostname` is the public SMTP identity for greetings and outbound delivery by default. It is not a bind address.
|
||||
|
||||
### 📧 Sending Emails (Automatic MX Discovery)
|
||||
|
||||
@@ -201,6 +215,8 @@ const emailId = await emailServer.sendEmail(email);
|
||||
const emailId2 = await emailServer.sendEmail(email, 'mta');
|
||||
```
|
||||
|
||||
`sendEmail()` returns the delivery queue item ID, which you can later use with queue/status APIs.
|
||||
|
||||
In MTA mode, smartmta:
|
||||
- 🔍 Resolves MX records for each recipient domain (e.g. `gmail.com`, `company.org`)
|
||||
- 📊 Sorts MX hosts by priority (lowest = highest priority per RFC 5321)
|
||||
@@ -254,6 +270,7 @@ 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
|
||||
- 🪪 Uses `outbound.hostname` as the SMTP identity when configured, otherwise falls back to `hostname`
|
||||
|
||||
### 🔑 DKIM Signing & Key Management
|
||||
|
||||
|
||||
Generated
+20
@@ -894,6 +894,16 @@ version = "0.2.181"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -982,6 +992,7 @@ dependencies = [
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"mimalloc",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1063,6 +1074,15 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
||||
@@ -32,3 +32,4 @@ clap = { version = "4", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false }
|
||||
mimalloc = "0.1"
|
||||
|
||||
@@ -22,3 +22,4 @@ dashmap.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
mimalloc = { workspace = true }
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! 2. **Management mode** (`--management`) — JSON-over-stdin/stdout IPC for
|
||||
//! integration with `@push.rocks/smartrust` from TypeScript
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MAILER npm postinstall script
|
||||
* Downloads the appropriate binary for the current platform from GitHub releases
|
||||
*/
|
||||
|
||||
import { platform, arch } from 'os';
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const streamPipeline = promisify(pipeline);
|
||||
|
||||
// Configuration
|
||||
const REPO_BASE = 'https://code.foss.global/serve.zone/mailer';
|
||||
const VERSION = process.env.npm_package_version || '1.0.0';
|
||||
|
||||
function getBinaryInfo() {
|
||||
const plat = platform();
|
||||
const architecture = arch();
|
||||
|
||||
const platformMap = {
|
||||
'darwin': 'macos',
|
||||
'linux': 'linux',
|
||||
'win32': 'windows'
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x64',
|
||||
'arm64': 'arm64'
|
||||
};
|
||||
|
||||
const mappedPlatform = platformMap[plat];
|
||||
const mappedArch = archMap[architecture];
|
||||
|
||||
if (!mappedPlatform || !mappedArch) {
|
||||
return { supported: false, platform: plat, arch: architecture };
|
||||
}
|
||||
|
||||
let binaryName = `mailer-${mappedPlatform}-${mappedArch}`;
|
||||
if (plat === 'win32') {
|
||||
binaryName += '.exe';
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: mappedPlatform,
|
||||
arch: mappedArch,
|
||||
binaryName,
|
||||
originalPlatform: plat
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFile(url, destination) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Downloading from: ${url}`);
|
||||
|
||||
// Follow redirects
|
||||
const download = (url, redirectCount = 0) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
console.log(`Following redirect to: ${response.headers.location}`);
|
||||
download(response.headers.location, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||
let downloadedSize = 0;
|
||||
let lastProgress = 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
|
||||
// Only log every 10% to reduce noise
|
||||
if (progress >= lastProgress + 10) {
|
||||
console.log(`Download progress: ${progress}%`);
|
||||
lastProgress = progress;
|
||||
}
|
||||
});
|
||||
|
||||
const file = createWriteStream(destination);
|
||||
|
||||
pipeline(response, file, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Download complete!');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
};
|
||||
|
||||
download(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('===========================================');
|
||||
console.log(' MAILER - Binary Installation');
|
||||
console.log('===========================================');
|
||||
console.log('');
|
||||
|
||||
const binaryInfo = getBinaryInfo();
|
||||
|
||||
if (!binaryInfo.supported) {
|
||||
console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`);
|
||||
console.error('');
|
||||
console.error('Supported platforms:');
|
||||
console.error(' • Linux (x64, arm64)');
|
||||
console.error(' • macOS (x64, arm64)');
|
||||
console.error(' • Windows (x64)');
|
||||
console.error('');
|
||||
console.error('If you believe your platform should be supported, please file an issue:');
|
||||
console.error(' https://code.foss.global/serve.zone/mailer/issues');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Platform: ${binaryInfo.platform} (${binaryInfo.originalPlatform})`);
|
||||
console.log(`Architecture: ${binaryInfo.arch}`);
|
||||
console.log(`Binary: ${binaryInfo.binaryName}`);
|
||||
console.log(`Version: ${VERSION}`);
|
||||
console.log('');
|
||||
|
||||
// Create dist/binaries directory if it doesn't exist
|
||||
const binariesDir = join(__dirname, '..', 'dist', 'binaries');
|
||||
if (!existsSync(binariesDir)) {
|
||||
console.log('Creating binaries directory...');
|
||||
mkdirSync(binariesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const binaryPath = join(binariesDir, binaryInfo.binaryName);
|
||||
|
||||
// Check if binary already exists and skip download
|
||||
if (existsSync(binaryPath)) {
|
||||
console.log('✓ Binary already exists, skipping download');
|
||||
} else {
|
||||
// Construct download URL
|
||||
// Try release URL first, fall back to raw branch if needed
|
||||
const releaseUrl = `${REPO_BASE}/releases/download/v${VERSION}/${binaryInfo.binaryName}`;
|
||||
const fallbackUrl = `${REPO_BASE}/raw/branch/main/dist/binaries/${binaryInfo.binaryName}`;
|
||||
|
||||
console.log('Downloading platform-specific binary...');
|
||||
console.log('This may take a moment depending on your connection speed.');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Try downloading from release
|
||||
await downloadFile(releaseUrl, binaryPath);
|
||||
} catch (err) {
|
||||
console.log(`Release download failed: ${err.message}`);
|
||||
console.log('Trying fallback URL...');
|
||||
|
||||
try {
|
||||
// Try fallback URL
|
||||
await downloadFile(fallbackUrl, binaryPath);
|
||||
} catch (fallbackErr) {
|
||||
console.error(`❌ Error: Failed to download binary`);
|
||||
console.error(` Primary URL: ${releaseUrl}`);
|
||||
console.error(` Fallback URL: ${fallbackUrl}`);
|
||||
console.error('');
|
||||
console.error('This might be because:');
|
||||
console.error('1. The release has not been created yet');
|
||||
console.error('2. Network connectivity issues');
|
||||
console.error('3. The version specified does not exist');
|
||||
console.error('');
|
||||
console.error('You can try:');
|
||||
console.error('1. Installing from source: https://code.foss.global/serve.zone/mailer');
|
||||
console.error('2. Downloading the binary manually from the releases page');
|
||||
|
||||
// Clean up partial download
|
||||
if (existsSync(binaryPath)) {
|
||||
unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Binary downloaded successfully`);
|
||||
}
|
||||
|
||||
// On Unix-like systems, ensure the binary is executable
|
||||
if (binaryInfo.originalPlatform !== 'win32') {
|
||||
try {
|
||||
console.log('Setting executable permissions...');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
console.log('✓ Binary permissions updated');
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Warning: Could not set executable permissions: ${err.message}`);
|
||||
console.error(' You may need to manually run:');
|
||||
console.error(` chmod +x ${binaryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('✅ MAILER installation completed successfully!');
|
||||
console.log('');
|
||||
console.log('You can now use MAILER by running:');
|
||||
console.log(' mailer --help');
|
||||
console.log('');
|
||||
console.log('For initial setup, run:');
|
||||
console.log(' sudo mailer service enable');
|
||||
console.log('');
|
||||
console.log('===========================================');
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
main().catch(err => {
|
||||
console.error(`❌ Installation failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const serversToCleanup: UnifiedEmailServer[] = [];
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => {
|
||||
storageMap.set(key, value);
|
||||
},
|
||||
list: async (prefix: string) => Array.from(storageMap.keys()).filter((key) => key.startsWith(prefix)),
|
||||
delete: async (key: string) => {
|
||||
storageMap.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('UnifiedEmailServer.sendEmail returns the actual queue item id', async () => {
|
||||
const server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10025],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [{ domain: 'example.com', dnsMode: 'forward' }],
|
||||
routes: [],
|
||||
});
|
||||
serversToCleanup.push(server);
|
||||
|
||||
const route = {
|
||||
name: 'test-deliver-route',
|
||||
match: { recipients: '*@*' },
|
||||
action: { type: 'deliver' as const },
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
subject: 'Queue ID contract',
|
||||
text: 'hello',
|
||||
});
|
||||
|
||||
const queueId = await server.sendEmail(email, 'mta', route);
|
||||
const queuedItem = server.getQueueItem(queueId);
|
||||
|
||||
expect(queuedItem).toBeTruthy();
|
||||
expect(queuedItem?.id).toEqual(queueId);
|
||||
expect(server.getQueueStats().queueSize).toEqual(1);
|
||||
expect(server.getQueueItems().map((item) => item.id)).toContain(queueId);
|
||||
});
|
||||
|
||||
tap.test('UnifiedEmailServer.sendOutboundEmail uses outbound.hostname when configured', async () => {
|
||||
const server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10026],
|
||||
hostname: 'mail.example.com',
|
||||
outbound: {
|
||||
hostname: 'outbound.example.com',
|
||||
},
|
||||
domains: [{ domain: 'example.com', dnsMode: 'forward' }],
|
||||
routes: [],
|
||||
});
|
||||
serversToCleanup.push(server);
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
subject: 'Outbound hostname contract',
|
||||
text: 'hello',
|
||||
});
|
||||
|
||||
let capturedOptions: any;
|
||||
(server as any).rustBridge.sendOutboundEmail = async (options: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
accepted: ['recipient@example.net'],
|
||||
rejected: [],
|
||||
messageId: 'test-message-id',
|
||||
response: '250 2.0.0 queued',
|
||||
envelope: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await server.sendOutboundEmail('smtp.target.example', 25, email);
|
||||
|
||||
expect(capturedOptions).toBeTruthy();
|
||||
expect(capturedOptions.domain).toEqual('outbound.example.com');
|
||||
});
|
||||
|
||||
tap.test('DKIMCreator returns selector-aligned DNS record names', async () => {
|
||||
const tempDir = path.join(os.tmpdir(), `smartmta-dkim-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const creator = new DKIMCreator(tempDir);
|
||||
|
||||
await creator.createAndStoreDKIMKeys('example.com');
|
||||
const defaultRecord = await creator.getDNSRecordForDomain('example.com');
|
||||
expect(defaultRecord.name).toEqual('default._domainkey.example.com');
|
||||
|
||||
await creator.createAndStoreDKIMKeysForSelector('example.org', 'selector1');
|
||||
const selectorRecord = await creator.getDNSRecordForDomain('example.org', 'selector1');
|
||||
expect(selectorRecord.name).toEqual('selector1._domainkey.example.org');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
for (const server of serversToCleanup) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -99,7 +99,7 @@ tap.test('setup - start server and mock SMTP', async () => {
|
||||
type: 'process',
|
||||
options: {
|
||||
contentScanning: true,
|
||||
scanners: [{ type: 'spam' }],
|
||||
scanners: [{ type: 'spam', action: 'tag' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '5.2.3',
|
||||
version: '5.3.3',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
@@ -88,7 +89,10 @@ export class BounceManager {
|
||||
|
||||
// Store of bounced emails
|
||||
private bounceStore: BounceRecord[] = [];
|
||||
|
||||
|
||||
// Periodic cleanup timer for old bounce records
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
// Cache of recently bounced email addresses to avoid sending to known bad addresses
|
||||
private bounceCache: LRUCache<string, {
|
||||
lastBounce: number;
|
||||
@@ -104,13 +108,13 @@ export class BounceManager {
|
||||
expiresAt?: number; // undefined means permanent
|
||||
}> = new Map();
|
||||
|
||||
private storageManager?: any; // StorageManager instance
|
||||
private storageManager?: IStorageManagerLike;
|
||||
|
||||
constructor(options?: {
|
||||
retryStrategy?: Partial<RetryStrategy>;
|
||||
maxCacheSize?: number;
|
||||
cacheTTL?: number;
|
||||
storageManager?: any;
|
||||
storageManager?: IStorageManagerLike;
|
||||
}) {
|
||||
// Set retry strategy with defaults
|
||||
if (options?.retryStrategy) {
|
||||
@@ -135,6 +139,15 @@ export class BounceManager {
|
||||
this.loadSuppressionList().catch(error => {
|
||||
logger.log('error', `Failed to load suppression list on startup: ${error.message}`);
|
||||
});
|
||||
|
||||
// Start periodic cleanup of old bounce records (every 1 hour, removes records older than 7 days)
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const removed = this.clearOldBounceRecords(sevenDaysAgo);
|
||||
if (removed > 0) {
|
||||
logger.log('info', `Auto-cleanup removed ${removed} old bounce records`);
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,7 +553,7 @@ export class BounceManager {
|
||||
try {
|
||||
const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
|
||||
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
// Use storage manager
|
||||
await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
|
||||
} else {
|
||||
@@ -562,7 +575,7 @@ export class BounceManager {
|
||||
let entries = null;
|
||||
let needsMigration = false;
|
||||
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['get'])) {
|
||||
// Try to load from storage manager first
|
||||
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
|
||||
|
||||
@@ -624,7 +637,7 @@ export class BounceManager {
|
||||
try {
|
||||
const bounceData = JSON.stringify(bounce, null, 2);
|
||||
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
// Use storage manager
|
||||
await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData);
|
||||
} else {
|
||||
@@ -717,7 +730,7 @@ export class BounceManager {
|
||||
*/
|
||||
public clearOldBounceRecords(olderThan: number): number {
|
||||
let removed = 0;
|
||||
|
||||
|
||||
this.bounceStore = this.bounceStore.filter(bounce => {
|
||||
if (bounce.timestamp < olderThan) {
|
||||
removed++;
|
||||
@@ -725,7 +738,17 @@ export class BounceManager {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bounce manager and clear cleanup timers
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface IQueueItem {
|
||||
id: string;
|
||||
processingMode: EmailProcessingMode;
|
||||
processingResult: any;
|
||||
route: IEmailRoute;
|
||||
route?: IEmailRoute;
|
||||
status: QueueItemStatus;
|
||||
attempts: number;
|
||||
nextAttempt: Date;
|
||||
@@ -78,6 +78,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
private options: Required<IQueueOptions>;
|
||||
private queue: Map<string, IQueueItem> = new Map();
|
||||
private checkTimer?: NodeJS.Timeout;
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
private stats: IQueueStats;
|
||||
private processing: boolean = false;
|
||||
private totalProcessed: number = 0;
|
||||
@@ -158,8 +159,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
}
|
||||
|
||||
|
||||
this.checkTimer = setInterval(() => this.processQueue(), this.options.checkInterval);
|
||||
|
||||
// Start periodic cleanup of delivered/failed items (every 30 minutes)
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
}
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupOldItems(24 * 60 * 60 * 1000).catch((err) => {
|
||||
logger.log('error', `Auto-cleanup failed: ${err.message}`);
|
||||
});
|
||||
}, 30 * 60 * 1000);
|
||||
|
||||
this.processing = true;
|
||||
this.stats.processingActive = true;
|
||||
this.emit('processingStarted');
|
||||
@@ -174,7 +186,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
clearInterval(this.checkTimer);
|
||||
this.checkTimer = undefined;
|
||||
}
|
||||
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
this.stats.processingActive = false;
|
||||
this.emit('processingStopped');
|
||||
@@ -221,7 +237,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
* @param mode Processing mode
|
||||
* @param route Email route
|
||||
*/
|
||||
public async enqueue(processingResult: any, mode: EmailProcessingMode, route: IEmailRoute): Promise<string> {
|
||||
public async enqueue(processingResult: any, mode: EmailProcessingMode, route?: IEmailRoute): Promise<string> {
|
||||
// Check if queue is full
|
||||
if (this.queue.size >= this.options.maxQueueSize) {
|
||||
throw new Error('Queue is full');
|
||||
@@ -268,6 +284,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
public getItem(id: string): IQueueItem | undefined {
|
||||
return this.queue.get(id);
|
||||
}
|
||||
|
||||
public listItems(): IQueueItem[] {
|
||||
return Array.from(this.queue.values()).map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as being processed
|
||||
@@ -590,19 +610,24 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
*/
|
||||
public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - maxAge);
|
||||
let removedCount = 0;
|
||||
|
||||
// Find old items
|
||||
|
||||
// Collect IDs first to avoid modifying the Map during iteration
|
||||
const idsToRemove: string[] = [];
|
||||
for (const item of this.queue.values()) {
|
||||
if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) {
|
||||
// Remove item
|
||||
await this.removeItem(item.id);
|
||||
removedCount++;
|
||||
idsToRemove.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Cleaned up ${removedCount} old items`);
|
||||
return removedCount;
|
||||
|
||||
// Remove collected items
|
||||
for (const id of idsToRemove) {
|
||||
await this.removeItem(id);
|
||||
}
|
||||
|
||||
if (idsToRemove.length > 0) {
|
||||
logger.log('info', `Cleaned up ${idsToRemove.length} old items from delivery queue`);
|
||||
}
|
||||
return idsToRemove.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,15 +636,9 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
public async shutdown(): Promise<void> {
|
||||
logger.log('info', 'Shutting down UnifiedDeliveryQueue');
|
||||
|
||||
// Stop processing
|
||||
// Stop processing (clears both check and cleanup timers)
|
||||
this.stopProcessing();
|
||||
|
||||
// Clear the check timer to prevent memory leaks
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
this.checkTimer = undefined;
|
||||
}
|
||||
|
||||
// If using disk storage, make sure all items are persisted
|
||||
if (this.options.storageType === 'disk') {
|
||||
const pendingWrites: Promise<void>[] = [];
|
||||
@@ -642,4 +661,4 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
this.emit('shutdown');
|
||||
logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,21 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
this.domainCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clean stale stats.byIp entries for IPs that no longer have active counters or blocks
|
||||
for (const ip of Object.keys(this.stats.byIp)) {
|
||||
if (!this.ipCounters.has(ip) && !(this.config.blocks && ip in this.config.blocks)) {
|
||||
delete this.stats.byIp[ip];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean stale stats.byPattern entries for patterns that no longer have active counters
|
||||
for (const pattern of Object.keys(this.stats.byPattern)) {
|
||||
if (!this.patternCounters.has(pattern)) {
|
||||
delete this.stats.byPattern[pattern];
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
// Export all mail modules for simplified imports
|
||||
export * from './interfaces.storage.js';
|
||||
export * from './routing/index.js';
|
||||
export * from './security/index.js';
|
||||
|
||||
@@ -14,4 +15,4 @@ import { Email } from './core/classes.email.js';
|
||||
// Re-export commonly used classes
|
||||
export {
|
||||
Email,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface IStorageManagerLike {
|
||||
get?(key: string): Promise<string | null>;
|
||||
set?(key: string, value: string): Promise<void>;
|
||||
list?(prefix: string): Promise<string[]>;
|
||||
delete?(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function hasStorageManagerMethods<T extends keyof IStorageManagerLike>(
|
||||
storageManager: IStorageManagerLike | undefined,
|
||||
methods: T[],
|
||||
): storageManager is IStorageManagerLike & Required<Pick<IStorageManagerLike, T>> {
|
||||
return !!storageManager && methods.every((method) => typeof storageManager[method] === 'function');
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { logger } from '../../logger.js';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.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;
|
||||
storageManager?: IStorageManagerLike;
|
||||
dnsServer?: any;
|
||||
}
|
||||
|
||||
@@ -39,11 +40,19 @@ export class DkimManager {
|
||||
let keyPair: { privateKey: string; publicKey: string };
|
||||
|
||||
try {
|
||||
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
||||
keyPair = selector === 'default'
|
||||
? await this.dkimCreator.readDKIMKeys(domain)
|
||||
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
|
||||
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
keyPair = await this.dkimCreator.createDKIMKeys();
|
||||
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
||||
} catch {
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(
|
||||
domain,
|
||||
selector,
|
||||
domainConfig.dkim?.keySize || 2048,
|
||||
);
|
||||
keyPair = selector === 'default'
|
||||
? await this.dkimCreator.readDKIMKeys(domain)
|
||||
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
|
||||
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
||||
}
|
||||
|
||||
@@ -106,10 +115,12 @@ export class DkimManager {
|
||||
|
||||
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
|
||||
);
|
||||
if (hasStorageManagerMethods(this.dcRouter.storageManager, ['set'])) {
|
||||
await this.dcRouter.storageManager.set(
|
||||
`/email/dkim/${domain}/public.key`,
|
||||
keyPair.publicKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
|
||||
@@ -127,8 +138,10 @@ export class DkimManager {
|
||||
|
||||
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(domain, selector);
|
||||
const { privateKey } = selector === 'default'
|
||||
? await this.dkimCreator.readDKIMKeys(domain)
|
||||
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Detect key type from PEM header
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IEmailDomainConfig } from './interfaces.js';
|
||||
import type { IStorageManagerLike } from '../interfaces.storage.js';
|
||||
import { logger } from '../../logger.js';
|
||||
/** External DcRouter interface shape used by DnsManager */
|
||||
interface IDcRouterLike {
|
||||
@@ -8,12 +9,6 @@ interface IDcRouterLike {
|
||||
options?: { dnsNsDomains?: string[]; dnsScopes?: string[] };
|
||||
}
|
||||
|
||||
/** External StorageManager interface shape used by DnsManager */
|
||||
interface IStorageManagerLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS validation result
|
||||
*/
|
||||
@@ -528,7 +523,7 @@ export class DnsManager {
|
||||
|
||||
try {
|
||||
// Get DKIM DNS record from DKIMCreator
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain);
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||
|
||||
// For internal-dns domains, register the DNS handler
|
||||
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
||||
@@ -570,4 +565,4 @@ export class DnsManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
|
||||
import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
|
||||
@@ -9,7 +10,7 @@ import type { Email } from '../core/classes.email.js';
|
||||
export class EmailRouter extends EventEmitter {
|
||||
private routes: IEmailRoute[];
|
||||
private patternCache: Map<string, boolean> = new Map();
|
||||
private storageManager?: any; // StorageManager instance
|
||||
private storageManager?: IStorageManagerLike;
|
||||
private persistChanges: boolean;
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,7 @@ export class EmailRouter extends EventEmitter {
|
||||
* @param options Router options
|
||||
*/
|
||||
constructor(routes: IEmailRoute[], options?: {
|
||||
storageManager?: any;
|
||||
storageManager?: IStorageManagerLike;
|
||||
persistChanges?: boolean;
|
||||
}) {
|
||||
super();
|
||||
@@ -27,7 +28,7 @@ export class EmailRouter extends EventEmitter {
|
||||
this.persistChanges = options?.persistChanges ?? !!this.storageManager;
|
||||
|
||||
// If storage manager is provided, try to load persisted routes
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['get'])) {
|
||||
this.loadRoutes({ merge: true }).catch(error => {
|
||||
console.error(`Failed to load persisted routes: ${error.message}`);
|
||||
});
|
||||
@@ -394,7 +395,7 @@ export class EmailRouter extends EventEmitter {
|
||||
* Save current routes to storage
|
||||
*/
|
||||
public async saveRoutes(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
this.emit('persistenceWarning', 'Cannot save routes: StorageManager not configured');
|
||||
return;
|
||||
}
|
||||
@@ -425,7 +426,7 @@ export class EmailRouter extends EventEmitter {
|
||||
merge?: boolean; // Merge with existing routes
|
||||
replace?: boolean; // Replace existing routes
|
||||
}): Promise<IEmailRoute[]> {
|
||||
if (!this.storageManager) {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['get'])) {
|
||||
this.emit('persistenceWarning', 'Cannot load routes: StorageManager not configured');
|
||||
return [];
|
||||
}
|
||||
@@ -572,4 +573,4 @@ export class EmailRouter extends EventEmitter {
|
||||
public getRoute(name: string): IEmailRoute | undefined {
|
||||
return this.routes.find(r => r.name === name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,18 @@ import {
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { IEmailReceivedEvent, IAuthRequestEvent } from '../../security/classes.rustsecuritybridge.js';
|
||||
import { EmailRouter } from './classes.email.router.js';
|
||||
import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
||||
import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import { DomainRegistry } from './classes.domain.registry.js';
|
||||
import { DnsManager } from './classes.dns.manager.js';
|
||||
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
|
||||
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
|
||||
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
|
||||
import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueItem, type IQueueOptions, type IQueueStats } from '../delivery/classes.delivery.queue.js';
|
||||
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
||||
import { SmtpState } from '../delivery/interfaces.js';
|
||||
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
||||
@@ -28,7 +29,7 @@ import { DkimManager } from './classes.dkim.manager.js';
|
||||
|
||||
/** External DcRouter interface shape used by UnifiedEmailServer */
|
||||
interface DcRouter {
|
||||
storageManager: any;
|
||||
storageManager: IStorageManagerLike;
|
||||
dnsServer?: any;
|
||||
options?: any;
|
||||
}
|
||||
@@ -49,11 +50,14 @@ export interface IExtendedSmtpSession extends ISmtpSession {
|
||||
export interface IUnifiedEmailServerOptions {
|
||||
// Base server options
|
||||
ports: number[];
|
||||
/** Public SMTP hostname used for greeting/banner and as the default outbound identity. */
|
||||
hostname: string;
|
||||
domains: IEmailDomainConfig[]; // Domain configurations
|
||||
banner?: string;
|
||||
debug?: boolean;
|
||||
useSocketHandler?: boolean; // Use socket-handler mode instead of port listening
|
||||
/** Persist router changes back into storage when a storage manager is available. */
|
||||
persistRoutes?: boolean;
|
||||
|
||||
// Authentication options
|
||||
auth?: {
|
||||
@@ -92,6 +96,8 @@ export interface IUnifiedEmailServerOptions {
|
||||
|
||||
// Outbound settings
|
||||
outbound?: {
|
||||
/** Override the SMTP identity used for outbound delivery. Defaults to `hostname`. */
|
||||
hostname?: string;
|
||||
maxConnections?: number;
|
||||
connectionTimeout?: number;
|
||||
socketTimeout?: number;
|
||||
@@ -99,6 +105,9 @@ export interface IUnifiedEmailServerOptions {
|
||||
defaultFrom?: string;
|
||||
};
|
||||
|
||||
// Delivery queue
|
||||
queue?: IQueueOptions;
|
||||
|
||||
// Rate limiting (global limits, can be overridden per domain)
|
||||
rateLimits?: IHierarchicalRateLimits;
|
||||
}
|
||||
@@ -206,7 +215,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
// Initialize email router with routes and storage manager
|
||||
this.emailRouter = new EmailRouter(options.routes || [], {
|
||||
storageManager: dcRouter.storageManager,
|
||||
persistChanges: true
|
||||
persistChanges: options.persistRoutes ?? hasStorageManagerMethods(dcRouter.storageManager, ['get', 'set'])
|
||||
});
|
||||
|
||||
// Initialize rate limiter
|
||||
@@ -226,7 +235,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
storageType: 'memory', // Default to memory storage
|
||||
maxRetries: 3,
|
||||
baseRetryDelay: 300000, // 5 minutes
|
||||
maxRetryDelay: 3600000 // 1 hour
|
||||
maxRetryDelay: 3600000, // 1 hour
|
||||
...options.queue,
|
||||
};
|
||||
|
||||
this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
|
||||
@@ -277,6 +287,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
// We'll create the SMTP servers during the start() method
|
||||
}
|
||||
|
||||
private getAdvertisedHostname(): string {
|
||||
return this.options.hostname;
|
||||
}
|
||||
|
||||
private getOutboundHostname(): string {
|
||||
return this.options.outbound?.hostname || this.options.hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an outbound email via the Rust SMTP client.
|
||||
* Uses connection pooling in the Rust binary for efficiency.
|
||||
@@ -314,7 +332,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
domain: this.options.hostname,
|
||||
domain: this.getOutboundHostname(),
|
||||
auth: options?.auth,
|
||||
email: outboundEmail,
|
||||
dkim,
|
||||
@@ -455,7 +473,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||
|
||||
const started = await this.rustBridge.startSmtpServer({
|
||||
hostname: this.options.hostname,
|
||||
hostname: this.getAdvertisedHostname(),
|
||||
ports: smtpPorts,
|
||||
securePort: securePort,
|
||||
tlsCertPem,
|
||||
@@ -518,6 +536,9 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
logger.log('info', 'Email delivery queue shut down');
|
||||
}
|
||||
|
||||
this.bounceManager.stop();
|
||||
logger.log('info', 'Bounce manager stopped');
|
||||
|
||||
// Close all Rust SMTP client connection pools
|
||||
try {
|
||||
await this.rustBridge.closeSmtpPool();
|
||||
@@ -973,6 +994,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
this.emailRouter.updateRoutes(routes);
|
||||
}
|
||||
|
||||
public getEmailRoutes(): IEmailRoute[] {
|
||||
return this.emailRouter.getRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
@@ -980,6 +1005,22 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
public getQueueStats(): IQueueStats {
|
||||
return this.deliveryQueue.getStats();
|
||||
}
|
||||
|
||||
public getQueueItems(): IQueueItem[] {
|
||||
return this.deliveryQueue.listItems();
|
||||
}
|
||||
|
||||
public getQueueItem(id: string): IQueueItem | undefined {
|
||||
return this.deliveryQueue.getItem(id);
|
||||
}
|
||||
|
||||
public getDeliveryStats(): IDeliveryStats {
|
||||
return this.deliverySystem.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain registry
|
||||
*/
|
||||
@@ -1039,11 +1080,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
// Sign with DKIM if configured
|
||||
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
||||
const domain = email.from.split('@')[1];
|
||||
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
|
||||
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'default');
|
||||
}
|
||||
|
||||
const id = plugins.uuid.v4();
|
||||
await this.deliveryQueue.enqueue(email, mode, route);
|
||||
const id = await this.deliveryQueue.enqueue(email, mode, route);
|
||||
|
||||
logger.log('info', `Email queued with ID: ${id}`);
|
||||
return id;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { type IStorageManagerLike, hasStorageManagerMethods } from '../interfaces.storage.js';
|
||||
|
||||
import { Email } from '../core/classes.email.js';
|
||||
// MtaService reference removed
|
||||
@@ -24,13 +25,47 @@ export interface IDkimKeyMetadata {
|
||||
|
||||
export class DKIMCreator {
|
||||
private keysDir: string;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
private storageManager?: IStorageManagerLike;
|
||||
|
||||
constructor(keysDir = paths.keysDir, storageManager?: any) {
|
||||
constructor(keysDir = paths.keysDir, storageManager?: IStorageManagerLike) {
|
||||
this.keysDir = keysDir;
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
private async writeKeyPairToFilesystem(
|
||||
privateKeyPath: string,
|
||||
publicKeyPath: string,
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
): Promise<void> {
|
||||
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
||||
}
|
||||
|
||||
private async storeLegacyKeysToStorage(domain: string, privateKey: string, publicKey: string): Promise<void> {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey),
|
||||
]);
|
||||
}
|
||||
|
||||
private async storeSelectorKeysToStorage(
|
||||
domain: string,
|
||||
selector: string,
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
): Promise<void> {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey),
|
||||
]);
|
||||
}
|
||||
|
||||
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
|
||||
return {
|
||||
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
|
||||
@@ -51,6 +86,20 @@ export class DKIMCreator {
|
||||
}
|
||||
}
|
||||
|
||||
public async handleDKIMKeysForSelector(domainArg: string, selector: string = 'default', keySize: number = 2048): Promise<void> {
|
||||
if (selector === 'default') {
|
||||
await this.handleDKIMKeysForDomain(domainArg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.readDKIMKeysForSelector(domainArg, selector);
|
||||
} catch {
|
||||
console.log(`No DKIM keys found for ${domainArg}/${selector}. Generating...`);
|
||||
await this.createAndStoreDKIMKeysForSelector(domainArg, selector, keySize);
|
||||
}
|
||||
}
|
||||
|
||||
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
|
||||
const domain = email.from.split('@')[1];
|
||||
await this.handleDKIMKeysForDomain(domain);
|
||||
@@ -59,7 +108,7 @@ export class DKIMCreator {
|
||||
// Read DKIM keys - always use storage manager, migrate from filesystem if needed
|
||||
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
|
||||
// Try to read from storage manager first
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['get', 'set'])) {
|
||||
try {
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
this.storageManager.get(`/email/dkim/${domainArg}/private.key`),
|
||||
@@ -87,10 +136,7 @@ export class DKIMCreator {
|
||||
|
||||
// Migrate to storage manager
|
||||
console.log(`Migrating DKIM keys for ${domainArg} from filesystem to StorageManager`);
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domainArg}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domainArg}/public.key`, publicKey)
|
||||
]);
|
||||
await this.storeLegacyKeysToStorage(domainArg, privateKey, publicKey);
|
||||
|
||||
return { privateKey, publicKey };
|
||||
} catch (error) {
|
||||
@@ -116,9 +162,9 @@ export class DKIMCreator {
|
||||
}
|
||||
|
||||
// Create an RSA DKIM key pair - changed to public for API access
|
||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
public async createDKIMKeys(keySize: number = 2048): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
modulusLength: keySize,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||||
});
|
||||
@@ -136,75 +182,58 @@ export class DKIMCreator {
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Store a DKIM key pair - uses storage manager if available, else disk
|
||||
public async storeDKIMKeys(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
privateKeyPath: string,
|
||||
publicKeyPath: string
|
||||
): Promise<void> {
|
||||
// Store in storage manager if available
|
||||
if (this.storageManager) {
|
||||
// Extract domain from path (e.g., /path/to/keys/example.com-private.pem -> example.com)
|
||||
const match = privateKeyPath.match(/\/([^\/]+)-private\.pem$/);
|
||||
if (match) {
|
||||
const domain = match[1];
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Also store to filesystem for backward compatibility
|
||||
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
||||
}
|
||||
|
||||
// Create a DKIM key pair and store it to disk - changed to public for API access
|
||||
public async createAndStoreDKIMKeys(domain: string): Promise<void> {
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys();
|
||||
public async createAndStoreDKIMKeys(domain: string, keySize: number = 2048): Promise<void> {
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
|
||||
const keyPaths = await this.getKeyPathsForDomain(domain);
|
||||
await this.storeDKIMKeys(
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPaths.privateKeyPath,
|
||||
keyPaths.publicKeyPath
|
||||
);
|
||||
await this.storeLegacyKeysToStorage(domain, privateKey, publicKey);
|
||||
await this.writeKeyPairToFilesystem(keyPaths.privateKeyPath, keyPaths.publicKeyPath, privateKey, publicKey);
|
||||
await this.saveKeyMetadata({
|
||||
domain,
|
||||
selector: 'default',
|
||||
createdAt: Date.now(),
|
||||
keySize,
|
||||
});
|
||||
console.log(`DKIM keys for ${domain} created and stored.`);
|
||||
}
|
||||
|
||||
public async createAndStoreDKIMKeysForSelector(
|
||||
domain: string,
|
||||
selector: string,
|
||||
keySize: number = 2048,
|
||||
): Promise<void> {
|
||||
if (selector === 'default') {
|
||||
await this.createAndStoreDKIMKeys(domain, keySize);
|
||||
return;
|
||||
}
|
||||
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
|
||||
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
|
||||
await this.storeSelectorKeysToStorage(domain, selector, privateKey, publicKey);
|
||||
await this.writeKeyPairToFilesystem(keyPaths.privateKeyPath, keyPaths.publicKeyPath, privateKey, publicKey);
|
||||
await this.saveKeyMetadata({
|
||||
domain,
|
||||
selector,
|
||||
createdAt: Date.now(),
|
||||
keySize,
|
||||
});
|
||||
console.log(`DKIM keys for ${domain}/${selector} created and stored.`);
|
||||
}
|
||||
|
||||
// Changed to public for API access
|
||||
public async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
|
||||
await this.handleDKIMKeysForDomain(domainArg);
|
||||
const keys = await this.readDKIMKeys(domainArg);
|
||||
|
||||
// Remove the PEM header and footer and newlines
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||
const pemFooter = '-----END PUBLIC KEY-----';
|
||||
const keyContents = keys.publicKey
|
||||
.replace(pemHeader, '')
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
|
||||
|
||||
// Now generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `mta._domainkey.${domainArg}`,
|
||||
type: 'TXT',
|
||||
dnsSecEnabled: null,
|
||||
value: dnsRecordValue,
|
||||
};
|
||||
public async getDNSRecordForDomain(
|
||||
domainArg: string,
|
||||
selector: string = 'default',
|
||||
): Promise<plugins.tsclass.network.IDnsRecord> {
|
||||
await this.handleDKIMKeysForSelector(domainArg, selector);
|
||||
return this.getDNSRecordForSelector(domainArg, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DKIM key metadata for a domain
|
||||
*/
|
||||
private async getKeyMetadata(domain: string, selector: string = 'default'): Promise<IDkimKeyMetadata | null> {
|
||||
if (!this.storageManager) {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['get'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -222,7 +251,7 @@ export class DKIMCreator {
|
||||
* Save DKIM key metadata
|
||||
*/
|
||||
private async saveKeyMetadata(metadata: IDkimKeyMetadata): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -259,30 +288,16 @@ export class DKIMCreator {
|
||||
const newSelector = `key${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// Create new keys with custom key size
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: keySize,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||||
});
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
|
||||
|
||||
// Store new keys with new selector
|
||||
const newKeyPaths = await this.getKeyPathsForSelector(domain, newSelector);
|
||||
|
||||
// Store in storage manager if available
|
||||
if (this.storageManager) {
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/public.key`, publicKey)
|
||||
]);
|
||||
}
|
||||
await this.storeSelectorKeysToStorage(domain, newSelector, privateKey, publicKey);
|
||||
|
||||
// Also store to filesystem
|
||||
await this.storeDKIMKeys(
|
||||
privateKey,
|
||||
publicKey,
|
||||
newKeyPaths.privateKeyPath,
|
||||
newKeyPaths.publicKeyPath
|
||||
);
|
||||
await this.writeKeyPairToFilesystem(newKeyPaths.privateKeyPath, newKeyPaths.publicKeyPath, privateKey, publicKey);
|
||||
|
||||
// Save metadata for new keys
|
||||
const metadata: IDkimKeyMetadata = {
|
||||
@@ -320,7 +335,7 @@ export class DKIMCreator {
|
||||
*/
|
||||
public async readDKIMKeysForSelector(domain: string, selector: string): Promise<{ privateKey: string; publicKey: string }> {
|
||||
// Try to read from storage manager first
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['get', 'set'])) {
|
||||
try {
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
this.storageManager.get(`/email/dkim/${domain}/${selector}/private.key`),
|
||||
@@ -330,6 +345,10 @@ export class DKIMCreator {
|
||||
if (privateKey && publicKey) {
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
if (selector === 'default') {
|
||||
return await this.readDKIMKeys(domain);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall through to migration check
|
||||
}
|
||||
@@ -347,10 +366,7 @@ export class DKIMCreator {
|
||||
|
||||
// Migrate to storage manager
|
||||
console.log(`Migrating DKIM keys for ${domain}/${selector} from filesystem to StorageManager`);
|
||||
await Promise.all([
|
||||
this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey),
|
||||
this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey)
|
||||
]);
|
||||
await this.storeSelectorKeysToStorage(domain, selector, privateKey, publicKey);
|
||||
|
||||
return { privateKey, publicKey };
|
||||
} catch (error) {
|
||||
@@ -361,6 +377,9 @@ export class DKIMCreator {
|
||||
}
|
||||
} else {
|
||||
// No storage manager, use filesystem directly
|
||||
if (selector === 'default') {
|
||||
return this.readDKIMKeys(domain);
|
||||
}
|
||||
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
|
||||
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||||
readFile(keyPaths.privateKeyPath),
|
||||
@@ -406,7 +425,8 @@ export class DKIMCreator {
|
||||
* Clean up old DKIM keys after grace period
|
||||
*/
|
||||
public async cleanupOldKeys(domain: string, gracePeriodDays: number = 30): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
if (!hasStorageManagerMethods(this.storageManager, ['get', 'list', 'delete'])) {
|
||||
console.log(`StorageManager for ${domain} does not support list/delete. Skipping DKIM cleanup.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,7 +456,11 @@ export class DKIMCreator {
|
||||
console.warn(`Failed to delete old key files: ${error.message}`);
|
||||
}
|
||||
|
||||
// Delete metadata
|
||||
// Delete selector-specific storage keys and metadata
|
||||
await Promise.all([
|
||||
this.storageManager.delete(`/email/dkim/${domain}/${metadata.selector}/private.key`),
|
||||
this.storageManager.delete(`/email/dkim/${domain}/${metadata.selector}/public.key`),
|
||||
]);
|
||||
await this.storageManager.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -444,4 +468,4 @@ export class DKIMCreator {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { hasStorageManagerMethods, type IStorageManagerLike } from '../mail/interfaces.storage.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { RustSecurityBridge } from './classes.rustsecuritybridge.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
@@ -66,7 +67,7 @@ export class IPReputationChecker {
|
||||
private static instance: IPReputationChecker;
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any;
|
||||
private storageManager?: IStorageManagerLike;
|
||||
|
||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
@@ -80,7 +81,7 @@ export class IPReputationChecker {
|
||||
enableIPInfo: true
|
||||
};
|
||||
|
||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
||||
constructor(options: IIPReputationOptions = {}, storageManager?: IStorageManagerLike) {
|
||||
this.options = {
|
||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||
...options
|
||||
@@ -100,7 +101,7 @@ export class IPReputationChecker {
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: IStorageManagerLike): IPReputationChecker {
|
||||
if (!IPReputationChecker.instance) {
|
||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
||||
}
|
||||
@@ -219,7 +220,7 @@ export class IPReputationChecker {
|
||||
|
||||
const cacheData = JSON.stringify(entries);
|
||||
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['set'])) {
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
||||
} else {
|
||||
@@ -239,7 +240,7 @@ export class IPReputationChecker {
|
||||
let cacheData: string | null = null;
|
||||
let fromFilesystem = false;
|
||||
|
||||
if (this.storageManager) {
|
||||
if (hasStorageManagerMethods(this.storageManager, ['get', 'set'])) {
|
||||
try {
|
||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
||||
|
||||
@@ -302,7 +303,7 @@ export class IPReputationChecker {
|
||||
}
|
||||
}
|
||||
|
||||
public updateStorageManager(storageManager: any): void {
|
||||
public updateStorageManager(storageManager: IStorageManagerLike): void {
|
||||
this.storageManager = storageManager;
|
||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user