36 Commits

Author SHA1 Message Date
d43fc15d8e v2.4.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:43:50 +00:00
248bfcfe78 feat(docs): document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts 2026-02-10 22:43:50 +00:00
1e7c9f6822 v2.3.2
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:41:36 +00:00
f3a74a7660 fix(tests): remove large SMTP client test suites and update SmartFile API usage 2026-02-10 22:41:36 +00:00
399f5fa418 v2.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:32:17 +00:00
cd4584ec26 fix(npmextra): update .gitignore and npmextra.json to add ignore patterns, registries, and module metadata 2026-02-10 22:32:17 +00:00
f601859f8b v2.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:26:20 +00:00
eb2643de93 feat(mailer-smtp): add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation) 2026-02-10 22:26:20 +00:00
595634fb0f v2.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:04:56 +00:00
cee8a51081 fix(readme): Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates 2026-02-10 22:04:56 +00:00
f1c5546186 v2.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:00:44 +00:00
5220ee0857 feat(mailer-smtp): implement in-process SMTP server and management IPC integration 2026-02-10 22:00:44 +00:00
fc2e6d44f4 v2.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 21:19:13 +00:00
15a45089aa feat(security): migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references 2026-02-10 21:19:13 +00:00
b82468ab1e BREAKING CHANGE(rust-bridge): make Rust the primary security backend, remove all TS fallbacks
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 6s
CI / Build All Platforms (push) Failing after 4s
Phase 3 of the Rust migration: the Rust security bridge is now mandatory
and all TypeScript security fallback implementations have been removed.

- UnifiedEmailServer.start() throws if Rust bridge fails to start
- SpfVerifier gutted to thin wrapper (parseSpfRecord stays in TS)
- DKIMVerifier gutted to thin wrapper delegating to bridge.verifyDkim()
- IPReputationChecker delegates to bridge.checkIpReputation(), keeps LRU cache
- DmarcVerifier keeps alignment logic (works with pre-computed results)
- DKIM signing via bridge.signDkim() in all 4 locations
- Removed mailauth and ip packages from plugins.ts (~1,200 lines deleted)
2026-02-10 20:30:43 +00:00
ffe294643c v2.0.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-10 16:57:14 +00:00
f1071faf3d fix(docs/readme): update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram 2026-02-10 16:57:14 +00:00
6b082cee8f fix(rust-bridge): correct Email.addHeader() calls and IBounceDetection interface
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Use addHeader() instead of non-existent setHeader() for security
result headers, and align IBounceDetection with actual Rust struct
fields (bounce_type + category only).
2026-02-10 16:38:31 +00:00
9185242530 v2.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Release / build-and-release (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 7s
2026-02-10 16:25:55 +00:00
8293663619 BREAKING CHANGE(smartmta): Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler 2026-02-10 16:25:55 +00:00
199b9b79d2 feat(rust): implement mailer-core and mailer-security crates with CLI
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Rust migration Phase 1 — implements real functionality in the previously
stubbed mailer-core and mailer-security crates (38 passing tests).

mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder,
email format validation with scoring, bounce detection (14 types, 40+
regex patterns), DSN status parsing, retry delay calculation.

mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking,
DMARC verification with public suffix list, DNSBL IP reputation checking
(10 default servers, parallel queries), all powered by mail-auth 0.7.

mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign
subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC.
2026-02-10 16:06:04 +00:00
91b49182bb v1.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-10 15:54:36 +00:00
564b65c7f2 fix(deps): add workspace dependency entries for multiple crates across mailer-bin, mailer-core, and mailer-security 2026-02-10 15:54:36 +00:00
8bd8c295b0 start the path to rust 2026-02-10 15:54:09 +00:00
237dba3bab feat(rust): scaffold Rust workspace and fix TypeScript build errors
- Add @git.zone/tsrust with linux_amd64/linux_arm64 cross-compilation
- Scaffold Rust workspace with 5 crates: mailer-core, mailer-smtp, mailer-security, mailer-napi, mailer-bin
- Fix all TypeScript compilation errors for upgraded dependencies (smartfile v13, mailauth v4.13, smartproxy v23)
- Replace smartfile.fs/memory with @push.rocks/smartfs throughout codebase
- Fix .ts import extensions to .js for NodeNext module resolution
- Update DKIMSignOptions usage to match mailauth v4.13 API
- Add MTA error classes with permissive signatures for legacy SMTP client
- Replace removed DcRouter/StorageManager/deliverability imports with local interfaces
2026-02-10 15:31:31 +00:00
bcde137332 v1.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 14:44:45 +00:00
14be3cdb9a feat(mail/delivery): add error-count based blocking to rate limiter; improve test SMTP server port selection; add tsbuild scripts and devDependency; remove stale backup file 2026-02-10 14:44:45 +00:00
f262f602a0 Remove obsolete test files for SenderReputationMonitor, DcRouter, and StorageManager
- Deleted tests for SenderReputationMonitor, IPWarmupManager, and related functionality.
- Removed tests for DcRouter including health status, server statistics, and configuration requests.
- Eliminated tests for protected endpoints and admin login functionality.
- Cleared out tests for socket handler integration and unit tests.
- Removed tests for StorageManager covering memory, filesystem, and custom function backends.
2025-10-28 22:19:41 +00:00
0ac598818f refactor: update test script and replace bun-types with tsx in devDependencies 2025-10-28 21:40:10 +00:00
c7b3206140 feat(security): implement IP reputation checker and security logger
- Added IPReputationChecker class for checking and caching IP reputation scores.
- Introduced IReputationResult interface and ReputationThreshold enum for reputation scoring.
- Implemented DNSBL checks and IP information retrieval within the IP reputation checker.
- Created SecurityLogger class for logging security events with various log levels and event types.
- Defined ISecurityEvent interface for structured logging of security events.
- Updated index.ts to export new classes and types for security module.
2025-10-28 20:27:00 +00:00
17f5661636 feat(storage): add comprehensive tests for StorageManager with memory, filesystem, and custom function backends
Some checks failed
CI / Type Check & Lint (push) Failing after 3s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s
feat(email): implement EmailSendJob class for robust email delivery with retry logic and MX record resolution

feat(mail): restructure mail module exports for simplified access to core and delivery functionalities
2025-10-28 19:46:17 +00:00
6523c55516 feat: Implement Deno-native STARTTLS handler and connection wrapper
- Refactored STARTTLS implementation to use Deno's native TLS via Deno.startTls().
- Introduced ConnectionWrapper to provide a Node.js net.Socket-compatible interface for Deno.Conn and Deno.TlsConn.
- Updated TlsHandler to utilize the new STARTTLS implementation.
- Added comprehensive SMTP authentication tests for PLAIN and LOGIN mechanisms.
- Implemented rate limiting tests for SMTP server connections and commands.
- Enhanced error handling and logging throughout the STARTTLS and connection upgrade processes.
2025-10-28 18:51:33 +00:00
9cd15342e0 feat(tests): Implement ERR-02 Invalid Sequence Handling and update test migration documentation 2025-10-28 11:13:47 +00:00
0018b19164 feat(tests): Port CMD-06 RSET, SEC-06 IP Reputation, and ERR-01 Syntax Error tests
- Ported CMD-06 RSET Command tests with 8 passing tests covering transaction resets and recipient clearing.
- Ported SEC-06 IP Reputation tests with 7 passing tests validating infrastructure and legitimate traffic acceptance.
- Ported ERR-01 Syntax Error tests with 10 passing tests for handling invalid commands and syntax errors.
- Updated README files to reflect the new test statuses and coverage.
- Added detailed test cases for handling invalid sequences in ERR-02 tests.
2025-10-28 10:47:05 +00:00
7ecdd9f1e4 Add comprehensive SMTP command tests for RCPT TO, DATA, QUIT, TLS, and basic email sending
- Implement CMD-03 tests for RCPT TO command, validating recipient addresses, handling multiple recipients, and enforcing command sequence.
- Implement CMD-04 tests for DATA command, ensuring proper email content transmission, handling of dot-stuffing, large messages, and correct command sequence.
- Implement CMD-13 tests for QUIT command, verifying graceful connection termination and idempotency.
- Implement CM-01 tests for TLS connections, including STARTTLS capability and direct TLS connections.
- Implement EP-01 tests for basic email sending, covering complete SMTP transaction flow, MIME attachments, HTML emails, custom headers, and minimal emails.
2025-10-28 10:11:34 +00:00
1698df3a53 feat: Add comprehensive SMTP test suite for Deno
- Implemented SMTP client utilities in `test/helpers/smtp.client.ts` for creating test clients, sending emails, and testing connections.
- Developed SMTP protocol test utilities in `test/helpers/utils.ts` for managing TCP connections, sending commands, and handling responses.
- Created a detailed README in `test/readme.md` outlining the test framework, infrastructure, organization, and running instructions.
- Ported CMD-01: EHLO Command tests in `test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts` with multiple scenarios including valid and invalid hostnames.
- Ported CMD-02: MAIL FROM Command tests in `test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts` covering valid address acceptance, invalid address rejection, SIZE parameter support, and command sequence enforcement.
2025-10-25 15:05:11 +00:00
155 changed files with 27955 additions and 12115 deletions

View File

@@ -6,7 +6,7 @@ Pre-compiled binaries for multiple platforms.
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/mailer
npm install -g @push.rocks/smartmta
```
#### Option 2: Direct binary download

View File

@@ -84,7 +84,7 @@ jobs:
mailer --version || echo "Note: Binary execution may fail in CI environment"
echo ""
echo "Checking installed files:"
npm ls -g @serve.zone/mailer || true
npm ls -g @push.rocks/smartmta || true
- name: Publish to npm
env:
@@ -93,10 +93,10 @@ jobs:
echo "Publishing to npm registry..."
npm publish --access public
echo ""
echo "✅ Successfully published @serve.zone/mailer to npm!"
echo "✅ Successfully published @push.rocks/smartmta to npm!"
echo ""
echo "Package info:"
npm view @serve.zone/mailer
npm view @push.rocks/smartmta
- name: Verify npm package
run: |
@@ -104,10 +104,10 @@ jobs:
sleep 30
echo ""
echo "Verifying published package..."
npm view @serve.zone/mailer
npm view @push.rocks/smartmta
echo ""
echo "Testing installation from npm:"
npm install -g @serve.zone/mailer
npm install -g @push.rocks/smartmta
echo ""
echo "Package installed successfully!"
which mailer || echo "Binary location check skipped"
@@ -118,12 +118,12 @@ jobs:
echo " npm Publish Complete!"
echo "================================================"
echo ""
echo "✅ Package: @serve.zone/mailer"
echo "✅ Package: @push.rocks/smartmta"
echo "✅ Version: ${{ steps.version.outputs.version }}"
echo ""
echo "Installation:"
echo " npm install -g @serve.zone/mailer"
echo " npm install -g @push.rocks/smartmta"
echo ""
echo "Registry:"
echo " https://www.npmjs.com/package/@serve.zone/mailer"
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
echo ""

27
.gitignore vendored
View File

@@ -1,7 +1,24 @@
node_modules/
.nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
deno.lock
*.log
.env
.DS_Store
dist_*/
# AI
.claude/
.serena/
#------# custom
rust/target

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env node
/**
* MAILER npm wrapper
* This script executes the appropriate pre-compiled binary based on the current platform
*/
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { platform, arch } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Get the binary name for the current platform
*/
function getBinaryName() {
const plat = platform();
const architecture = arch();
// Map Node's platform/arch to our binary naming
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) {
console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`);
console.error('Supported platforms: Linux, macOS, Windows');
console.error('Supported architectures: x64, arm64');
process.exit(1);
}
// Construct binary name
let binaryName = `mailer-${mappedPlatform}-${mappedArch}`;
if (plat === 'win32') {
binaryName += '.exe';
}
return binaryName;
}
/**
* Execute the binary
*/
function executeBinary() {
const binaryName = getBinaryName();
const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName);
// Check if binary exists
if (!existsSync(binaryPath)) {
console.error(`Error: Binary not found at ${binaryPath}`);
console.error('This might happen if:');
console.error('1. The postinstall script failed to run');
console.error('2. The platform is not supported');
console.error('3. The package was not installed correctly');
console.error('');
console.error('Try reinstalling the package:');
console.error(' npm uninstall -g @serve.zone/mailer');
console.error(' npm install -g @serve.zone/mailer');
process.exit(1);
}
// Spawn the binary with all arguments passed through
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
shell: false
});
// Handle child process events
child.on('error', (err) => {
console.error(`Error executing mailer: ${err.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code || 0);
}
});
// Forward signals to child process
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
signals.forEach(signal => {
process.on(signal, () => {
if (!child.killed) {
child.kill(signal);
}
});
});
}
// Execute
executeBinary();

View File

@@ -1,5 +1,106 @@
# Changelog
## 2026-02-10 - 2.4.0 - feat(docs)
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
## 2026-02-10 - 2.3.2 - fix(tests)
remove large SMTP client test suites and update SmartFile API usage
- Deleted ~80 test files under test/suite/ (multiple smtpclient command, connection, edge-cases, email-composition, error-handling and performance test suites)
- Updated SmartFile usage in test/test.smartmail.ts: replaced plugins.smartfile.SmartFile.fromString(...) with plugins.smartfile.SmartFileFactory.nodeFs().fromString(...)
- Removes a large set of tests to reduce test surface / simplify test runtime
## 2026-02-10 - 2.3.1 - fix(npmextra)
update .gitignore and npmextra.json to add ignore patterns, registries, and module metadata
- .gitignore: expanded ignore list for artifacts, installs, caches, builds, AI dirs, and rust target path
- npmextra.json: added npmjs registry alongside internal Verdaccio registry for @git.zone/cli release settings
- npmextra.json: added projectType and module metadata (githost, gitscope, gitrepo, description, npmPackagename, license) for @git.zone/cli and added empty @ship.zone/szci entry
## 2026-02-10 - 2.3.0 - feat(mailer-smtp)
add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation)
- Integrate mailer_security verification (DKIM/SPF/DMARC) and IP reputation checks into the Rust SMTP server; run concurrently and wrapped with a 30s timeout.
- Add MIME parsing using mailparse and an extract_mime_parts helper to extract subject, text/html bodies and attachment filenames for content scanning.
- Wire MessageAuthenticator and TokioResolver into server and connection startup; pass them into the delivery pipeline and connection handlers.
- Run content scanning (mailer_security::content_scanner), combine results (dkim/spf/dmarc, contentScan, ipReputation) into a JSON object and attach as security_results on EmailReceived events.
- Update Rust crates (Cargo.toml/Cargo.lock) to include mailparse and resolver usage and add serde::Deserialize where required; add unit tests for MIME extraction.
- Remove the TypeScript SMTP server implementation and many TS tests; replace test helper (server.loader.ts) with a stub that points tests to use the Rust SMTP server and provide small utilities (getAvailablePort/isPortFree).
## 2026-02-10 - 2.2.1 - fix(readme)
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
- Emphasizes that the SMTP server is Rust-powered (high-performance) and not a nodemailer-based TS server.
- Documents that the Rust binary (mailer-bin) is required — if unavailable UnifiedEmailServer.start() will throw an error.
- Adds installation/build note: run `pnpm build` to compile the Rust binary.
- Adds a new Rust Acceleration Layer section listing workspace crates and responsibilities (mailer-core, mailer-security, mailer-smtp, mailer-bin, mailer-napi).
- Updates project structure: marks legacy TS SMTP server as fallback/legacy, adds dist_rust output, and clarifies which operations run in Rust vs TypeScript.
## 2026-02-10 - 2.2.0 - feat(mailer-smtp)
implement in-process SMTP server and management IPC integration
- Add full SMTP protocol engine crate (mailer-smtp) with modules: command, config, connection, data, response, session, state, validation, rate_limiter and server
- Introduce SmtpServerConfig, DataAccumulator (DATA phase handling, dot-unstuffing, size limits) and SmtpResponse builder with EHLO capability construction
- Add in-process RateLimiter using DashMap and runtime-configurable RateLimitConfig
- Add TCP/TLS server start/stop API (start_server) with TlsAcceptor building from PEM and SmtpServerHandle for shutdown and status
- Integrate callback registry and oneshot-based correlation callbacks in mailer-bin management mode for email processing/auth results and JSON IPC parsing for SmtpServerConfig
- TypeScript bridge and routing updates: new IPC commands/types (startSmtpServer, stopSmtpServer, emailProcessingResult, authResult, configureRateLimits) and event handlers (emailReceived, authRequest)
- Update Cargo manifests and lockfile to add dependencies (dashmap, regex, rustls, rustls-pemfile, rustls-pki-types, uuid, serde_json, base64, etc.)
- Add comprehensive unit tests for new modules (config, data, response, session, state, rate_limiter, validation)
## 2026-02-10 - 2.1.0 - feat(security)
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
- Add Rust content scanner implementation (rust/crates/mailer-security/src/content_scanner.rs) with pattern-based detection and unit tests (~515 lines)
- Expose new IPC command 'scanContent' in mailer-bin and marshal results via JSON for the RustSecurityBridge
- Update TypeScript RustSecurityBridge with scanContent typing and method, and replace local JS detection logic (bounce/content) to call Rust bridge
- Update tests to start/stop the RustSecurityBridge and rely on Rust-based detection (test updates in test.bouncemanager.ts and test.contentscanner.ts)
- Update CI workflow messages and package references from @serve.zone/mailer to @push.rocks/smartmta
- Add regex dependency to rust mailer-security workspace (Cargo.toml / Cargo.lock updated)
## 2026-02-10 - 2.0.1 - fix(docs/readme)
update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram
- Documented RustSecurityBridge: startup/shutdown, automatic delegation, compound verifyEmail API, and individual operations
- Clarified verification APIs: SpfVerifier.verify() and DmarcVerifier.verify() examples now take an Email object as the first argument
- Updated example method names/usages: scanEmail, createEmail, evaluateRoutes, checkMessageLimit, isEmailSuppressed, DKIMCreator rotation and output formatting
- Reformatted architecture diagram and added Rust Security Bridge and expanded Rust Acceleration details
- Rate limiter example updated: renamed/standardized config keys (maxMessagesPerMinute, domains) and added additional limits (maxRecipientsPerMessage, maxConnectionsPerIP, etc.)
- DNS management documentation reorganized: UnifiedEmailServer now handles DNS record setup automatically; DNSManager usage clarified for standalone checks
- Minor wording/formatting tweaks throughout README (arrow styles, headings, test counts)
## 2026-02-10 - 2.0.0 - BREAKING CHANGE(smartmta)
Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
- Package renamed from @serve.zone/mailer to @push.rocks/smartmta (package.json, commitinfo, README and homepage/bugs/repository URLs updated) — breaking for consumers who import by package name.
- Added new compound email security API verify_email_security that runs DKIM, SPF and DMARC in a single call (rust/crates/mailer-security/src/verify.rs) and exposed it from the mailer-security crate.
- Added IPC handler "verifyEmail" in mailer-bin to call the new verify_email_security function from the Rust side.
- Refactored DKIM and SPF code to convert mail-auth outputs to serializable results (dkim_outputs_to_results and SpfResult::from_output) and wired them into the combined verifier.
- Updated TypeScript plugin exports and dependencies: added @push.rocks/smartrust and exported smartrust in ts/plugins.ts.
- Large README overhaul to reflect rebranding, install instructions, architecture and legal/company info.
## 2026-02-10 - 1.3.1 - fix(deps)
add workspace dependency entries for multiple crates across mailer-bin, mailer-core, and mailer-security
- rust/crates/mailer-bin/Cargo.toml: add clap.workspace = true
- rust/crates/mailer-core/Cargo.toml: add regex.workspace = true, base64.workspace = true, uuid.workspace = true
- rust/crates/mailer-security/Cargo.toml: add serde_json.workspace = true, tokio.workspace = true, hickory-resolver.workspace = true, ipnet.workspace = true, rustls-pki-types.workspace = true, psl.workspace = true
- Purpose: align and enable workspace-managed dependencies for the affected crates
## 2026-02-10 - 1.3.0 - feat(mail/delivery)
add error-count based blocking to rate limiter; improve test SMTP server port selection; add tsbuild scripts and devDependency; remove stale backup file
- Add TokenBucket error tracking (errors, firstErrorTime) and initialize fields for global and per-key buckets
- Introduce RateLimiter.recordError(key, window, threshold) to track errors and decide blocking when threshold exceeded
- Update test SMTP server loader to dynamically find a free port using smartnetwork and add a recordError stub to the mock server
- Add build and check scripts to package.json and add @git.zone/tsbuild to devDependencies
- Remove a large backup file (classes.emailsendjob.ts.backup) from the repo; minor whitespace and logging cleanups in SMTP server code
## 2025-10-24 - 1.2.1 - fix(mail/delivery)
Centralize runtime/plugin imports and switch modules to use plugins exports; unify EventEmitter usage; update Deno dependencies and small path/server refactors

View File

@@ -1,53 +0,0 @@
{
"name": "@serve.zone/mailer",
"version": "1.2.1",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
"dev": "deno run --allow-all mod.ts",
"compile": "deno task compile:all",
"compile:all": "bash scripts/compile-all.sh",
"test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/",
"check": "deno check mod.ts",
"fmt": "deno fmt",
"lint": "deno lint"
},
"lint": {
"rules": {
"tags": [
"recommended"
]
}
},
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"semiColons": true,
"singleQuote": true
},
"compilerOptions": {
"lib": [
"deno.window"
],
"strict": true
},
"imports": {
"@std/cli": "jsr:@std/cli@^1.0.0",
"@std/fmt": "jsr:@std/fmt@^1.0.0",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/http": "jsr:@std/http@^1.0.0",
"@std/crypto": "jsr:@std/crypto@^1.0.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@latest",
"@push.rocks/smartfile": "npm:@push.rocks/smartfile@latest",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@latest",
"@push.rocks/smartmail": "npm:@push.rocks/smartmail@^2.0.0",
"@tsclass/tsclass": "npm:@tsclass/tsclass@latest",
"lru-cache": "npm:lru-cache@^11.0.0",
"mailauth": "npm:mailauth@^4.0.0",
"uuid": "npm:uuid@^9.0.0",
"ip": "npm:ip@^2.0.0"
}
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Serve Zone
Copyright (c) 2025 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

13
mod.ts
View File

@@ -1,13 +0,0 @@
/**
* Mailer - Enterprise mail server for serve.zone
*
* Main entry point for the Deno module
*/
// When run as a script, execute the CLI
if (import.meta.main) {
await import('./ts/cli.ts');
}
// Export public API
export * from './ts/index.ts';

View File

@@ -1 +1,27 @@
{}
{
"@git.zone/tsrust": {
"targets": [
"linux_amd64",
"linux_arm64"
]
},
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartmta",
"description": "an mta implementation as TypeScript package, with network side implemented in rust",
"npmPackagename": "@push.rocks/smartmta",
"license": "MIT"
}
},
"@ship.zone/szci": {}
}

View File

@@ -1,44 +1,86 @@
{
"name": "@serve.zone/mailer",
"version": "1.2.1",
"description": "Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure",
"name": "@push.rocks/smartmta",
"version": "2.4.0",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [
"mailer",
"mta",
"smtp",
"email",
"mail server",
"mailgun",
"mail transfer agent",
"dkim",
"spf",
"dmarc",
"dns",
"cloudflare",
"daemon service",
"api",
"serve.zone"
"typescript",
"rust"
],
"homepage": "https://code.foss.global/serve.zone/mailer",
"homepage": "https://code.foss.global/push.rocks/smartmta",
"bugs": {
"url": "https://code.foss.global/serve.zone/mailer/issues"
"url": "https://code.foss.global/push.rocks/smartmta/issues"
},
"repository": {
"type": "git",
"url": "git+https://code.foss.global/serve.zone/mailer.git"
"url": "git+https://code.foss.global/push.rocks/smartmta.git"
},
"author": "Serve Zone",
"author": "Task Venture Capital GmbH",
"license": "MIT",
"type": "module",
"bin": {
"mailer": "./bin/mailer-wrapper.js"
},
"scripts": {
"postinstall": "node scripts/install-binary.js",
"prepublishOnly": "echo 'Publishing MAILER binaries to npm...'",
"test": "echo 'Tests are run with Deno: deno task test'",
"build": "echo 'no build needed'"
"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",
"@types/mailparser": "^3.4.6",
"@types/node": "^25.2.3",
"tsx": "^4.21.0"
},
"dependencies": {
"@api.global/typedrequest": "^3.2.5",
"@api.global/typedserver": "^8.3.0",
"@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.1.0",
"@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.5.0",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartfs": "^1.3.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartmail": "^2.2.0",
"@push.rocks/smartmetrics": "^2.0.10",
"@push.rocks/smartnetwork": "^4.0.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.0.3",
"@push.rocks/smartproxy": "^23.1.0",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrule": "^2.0.1",
"@push.rocks/smartrust": "^1.1.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartunique": "^3.0.9",
"@serve.zone/interfaces": "^5.0.4",
"@tsclass/tsclass": "^9.2.0",
"ip": "^2.0.1",
"lru-cache": "^11.2.5",
"mailauth": "^4.13.0",
"mailparser": "^3.9.3",
"uuid": "^13.0.0"
},
"files": [
"bin/",
"scripts/install-binary.js",
"dist_rust/**/*",
"readme.md",
"license",
"changelog.md"

10522
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
# Project Hints
## Rust Workspace
- Rust code lives in `rust/` with a Cargo workspace
- Crates: `mailer-core`, `mailer-smtp`, `mailer-security`, `mailer-napi`, `mailer-bin`
- `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`)
- Build output goes to `dist_rust/`
## Build
- `pnpm build` runs `tsbuild tsfolders && tsrust`
- Note: `tsbuild tsfolders` requires a `tsconfig.json` at the project root (currently missing - pre-existing issue)
- `tsrust` independently works and produces binaries in `dist_rust/`
## Key Dependencies (Rust)
- `tokio` - async runtime
- `tokio-rustls` - TLS
- `hickory-dns` (hickory-resolver) - DNS resolution
- `mail-auth` - DKIM/SPF/DMARC (by Stalwart Labs)
- `mailparse` - MIME parsing
- `napi` + `napi-derive` - Node.js bindings
- `ring` - crypto primitives
- `dashmap` - concurrent hash maps

859
readme.md
View File

@@ -1,361 +1,636 @@
# @serve.zone/mailer
# @push.rocks/smartmta
> Enterprise mail server with SMTP, HTTP API, and DNS management
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](license)
[![Version](https://img.shields.io/badge/version-1.0.0-green.svg)](package.json)
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Install
```bash
pnpm install @push.rocks/smartmta
# or
npm install @push.rocks/smartmta
```
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required**`smartmta` will not start without it.
## Overview
`@serve.zone/mailer` is a comprehensive mail server solution built with Deno, featuring:
`@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.
- **SMTP Server & Client** - Full-featured SMTP implementation for sending and receiving emails
- **HTTP REST API** - Mailgun-compatible API for programmatic email management
- **DNS Management** - Automatic DNS setup via Cloudflare API
- **DKIM/SPF/DMARC** - Complete email authentication and security
- **Daemon Service** - Systemd integration for production deployments
- **CLI Interface** - Command-line management of all features
### ⚡ What's Inside
## Architecture
| Module | What It Does |
|---|---|
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification |
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
| **Delivery Queue** | Persistent queue with exponential backoff retry |
| **Template Engine** | Email templates with variable substitution |
| **Domain Registry** | Multi-domain management with per-domain configuration |
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
### Technology Stack
- **Runtime**: Deno (compiles to standalone binaries)
- **Language**: TypeScript
- **Distribution**: npm (via binary wrappers)
- **Service**: systemd daemon
- **DNS**: Cloudflare API integration
### Project Structure
### 🏗️ Architecture
```
mailer/
├── bin/ # npm binary wrappers
├── scripts/ # Build scripts
├── ts/ # TypeScript source
├── mail/ # Email implementation (ported from dcrouter)
│ ├── core/ # Email classes, validation, templates
│ ├── delivery/ # SMTP client/server, queues
│ ├── routing/ # Email routing, domain config
│ └── security/ # DKIM, SPF, DMARC
├── api/ # HTTP REST API (Mailgun-compatible)
├── dns/ # DNS management + Cloudflare
├── daemon/ # Systemd service management
├── config/ # Configuration system
│ └── cli/ # Command-line interface
├── test/ # Test suite
├── deno.json # Deno configuration
├── package.json # npm metadata
└── mod.ts # Main entry point
┌──────────────────────────────────────────────────────────────┐
UnifiedEmailServer │
│ (orchestrates all components, emits events) │
├───────────┬───────────┬──────────────┬───────────────────────┤
Email Security │ Delivery Configuration │
Router │ Stack │ System │ │
┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
│Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
│Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
│ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
└──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
│ │ Scan │ │ └──────────┘ │ └────────────────┘ │
│ └───────┘ │ │ │
├───────────┴───────────┴──────────────┴───────────────────────┤
Rust Security Bridge (smartrust IPC) │
├──────────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
## Installation
**Data flow for inbound mail:**
### Via npm (recommended)
```bash
npm install -g @serve.zone/mailer
```
### From source
```bash
git clone https://code.foss.global/serve.zone/mailer
cd mailer
deno task compile
```
1. Rust SMTP server accepts the connection and handles the full SMTP protocol
2. On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
3. Rust emits an `emailReceived` event via IPC with pre-computed security results attached
4. TypeScript processes the email (routing decisions using the pre-computed results, delivery)
5. Rust sends the final SMTP response to the client
## Usage
### CLI Commands
### 🚀 Setting Up the Email Server
#### Service Management
```bash
# Start the mailer daemon
sudo mailer service start
# Stop the daemon
sudo mailer service stop
# Restart the daemon
sudo mailer service restart
# Check status
mailer service status
# Enable systemd service
sudo mailer service enable
# Disable systemd service
sudo mailer service disable
```
#### Domain Management
```bash
# Add a domain
mailer domain add example.com
# Remove a domain
mailer domain remove example.com
# List all domains
mailer domain list
```
#### DNS Management
```bash
# Auto-configure DNS via Cloudflare
mailer dns setup example.com
# Validate DNS configuration
mailer dns validate example.com
# Show required DNS records
mailer dns show example.com
```
#### Sending Email
```bash
# Send email via CLI
mailer send \\
--from sender@example.com \\
--to recipient@example.com \\
--subject "Hello" \\
--text "World"
```
#### Configuration
```bash
# Show current configuration
mailer config show
# Set configuration value
mailer config set smtpPort 25
mailer config set apiPort 8080
mailer config set hostname mail.example.com
```
### HTTP API
The mailer provides a Mailgun-compatible REST API:
#### Send Email
```bash
POST /v1/messages
Content-Type: application/json
{
"from": "sender@example.com",
"to": "recipient@example.com",
"subject": "Hello",
"text": "World",
"html": "<p>World</p>"
}
```
#### List Domains
```bash
GET /v1/domains
```
#### Manage SMTP Credentials
```bash
GET /v1/domains/:domain/credentials
POST /v1/domains/:domain/credentials
DELETE /v1/domains/:domain/credentials/:id
```
#### Email Events
```bash
GET /v1/events
```
### Programmatic Usage
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
```typescript
import { Email, SmtpClient } from '@serve.zone/mailer';
import { UnifiedEmailServer } from '@push.rocks/smartmta';
// Create an email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Hello from Mailer',
text: 'This is a test email',
html: '<p>This is a test email</p>',
const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
ports: [25, 587, 465],
hostname: 'mail.example.com',
// Multi-domain configuration
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
dkim: {
selector: 'default',
keySize: 2048,
rotateKeys: true,
rotationInterval: 90,
},
rateLimits: {
outbound: { messagesPerMinute: 100 },
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
},
},
],
// Routing rules (evaluated by priority, highest first)
routes: [
{
name: 'catch-all-forward',
priority: 10,
match: {
recipients: '*@example.com',
},
action: {
type: 'forward',
forward: {
host: 'internal-mail.example.com',
port: 25,
},
},
},
{
name: 'reject-spam-senders',
priority: 100,
match: {
senders: '*@spamdomain.com',
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Sender rejected by policy',
},
},
},
],
// Authentication settings for the SMTP server
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
},
// TLS certificates
tls: {
certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key',
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
});
// Send via SMTP
const client = new SmtpClient({
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
await emailServer.start();
```
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
### 📧 Sending Emails with the SMTP Client
Create and send emails using the built-in SMTP client with connection pooling:
```typescript
import { Email, Delivery } from '@push.rocks/smartmta';
// Create a client with connection pooling
const client = Delivery.smtpClientMod.createSmtpClient({
host: 'smtp.example.com',
port: 587,
secure: true,
secure: false, // will upgrade via STARTTLS
pool: true,
maxConnections: 5,
auth: {
user: 'username',
pass: 'password',
user: 'sender@example.com',
pass: 'your-password',
},
});
await client.sendMail(email);
// Build an email
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
cc: ['cc@example.com'],
subject: 'Hello from smartmta!',
text: 'Plain text body',
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
priority: 'high',
attachments: [
{
filename: 'report.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
// Send it
const result = await client.sendMail(email);
console.log(`Message sent: ${result.messageId}`);
```
## Configuration
Additional client factories are available:
Configuration is stored in `~/.mailer/config.json`:
```typescript
// Pooled client for high-throughput scenarios
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
```json
{
"domains": [
{
"domain": "example.com",
"dnsMode": "external-dns",
"cloudflare": {
"apiToken": "your-cloudflare-token"
}
}
],
"apiKeys": ["api-key-1", "api-key-2"],
"smtpPort": 25,
"apiPort": 8080,
"hostname": "mail.example.com"
// Optimized for bulk sending
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
// Optimized for transactional emails
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
```
### 🔑 DKIM Signing
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
```typescript
import { DKIMCreator } from '@push.rocks/smartmta';
const dkimCreator = new DKIMCreator('/path/to/keys');
// Auto-generate keys if they don't exist
await dkimCreator.handleDKIMKeysForDomain('example.com');
// Get the DNS record you need to publish
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
console.log(dnsRecord);
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
// Check if keys need rotation
const needsRotation = await dkimCreator.needsRotation('example.com', 'default', 90);
if (needsRotation) {
const newSelector = await dkimCreator.rotateDkimKeys('example.com', 'default', 2048);
console.log(`Rotated to selector: ${newSelector}`);
}
```
## DNS Setup
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.
The mailer requires the following DNS records for each domain:
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
### MX Record
```
Type: MX
Name: @
Value: mail.example.com
Priority: 10
TTL: 3600
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
```typescript
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
// SPF verification — first arg is an Email object
const spfVerifier = new SpfVerifier();
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
// domain: string, ip: string }
// DKIM verification — takes raw email content
const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent);
// DMARC verification — first arg is an Email object
const dmarcVerifier = new DmarcVerifier();
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
```
### A Record
```
Type: A
Name: mail
Value: <your-server-ip>
TTL: 3600
### 🔀 Email Routing
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
```typescript
import { EmailRouter } from '@push.rocks/smartmta';
const router = new EmailRouter([
{
name: 'admin-mail',
priority: 100,
match: {
recipients: 'admin@example.com',
authenticated: true,
},
action: {
type: 'deliver',
},
},
{
name: 'external-forward',
priority: 50,
match: {
recipients: '*@example.com',
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
},
action: {
type: 'forward',
forward: {
host: 'backend-mail.internal',
port: 25,
preserveHeaders: true,
},
},
},
{
name: 'process-with-scanning',
priority: 10,
match: {
recipients: '*@*',
},
action: {
type: 'process',
process: {
scan: true,
dkim: true,
queue: 'normal',
},
},
},
]);
// Evaluate routes against an email context
const matchedRoute = await router.evaluateRoutes(emailContext);
```
### SPF Record
```
Type: TXT
Name: @
Value: v=spf1 mx ip4:<your-server-ip> ~all
TTL: 3600
**Match criteria available:**
| Criterion | Description |
|---|---|
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
| `senders` | Glob patterns for sender addresses |
| `clientIp` | IP addresses or CIDR ranges |
| `authenticated` | Require authentication status |
| `headers` | Match specific headers (string or RegExp) |
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
| `subject` | Subject line pattern (string or RegExp) |
| `hasAttachments` | Filter by attachment presence |
### 🔍 Content Scanning
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
```typescript
import { ContentScanner } from '@push.rocks/smartmta';
const scanner = new ContentScanner({
scanSubject: true,
scanBody: true,
scanAttachments: true,
blockExecutables: true,
blockMacros: true,
minThreatScore: 30,
highThreatScore: 70,
customRules: [
{
pattern: /bitcoin.*wallet/i,
type: 'scam',
score: 80,
description: 'Cryptocurrency scam pattern',
},
],
});
const result = await scanner.scanEmail(email);
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
```
### DKIM Record
```
Type: TXT
Name: default._domainkey
Value: <dkim-public-key>
TTL: 3600
### 🌐 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: [] }
```
### DMARC Record
```
Type: TXT
Name: _dmarc
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com
TTL: 3600
### ⏱️ Rate Limiting
Hierarchical rate limiting to protect your server and maintain deliverability:
```typescript
import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({
global: {
maxMessagesPerMinute: 1000,
maxRecipientsPerMessage: 500,
maxConnectionsPerIP: 20,
maxErrorsPerIP: 10,
maxAuthFailuresPerIP: 5,
blockDuration: 600000, // 10 minutes
},
domains: {
'example.com': {
maxMessagesPerMinute: 100,
maxRecipientsPerMessage: 50,
},
},
});
// Check before sending
const allowed = rateLimiter.checkMessageLimit(
'sender@example.com',
'192.168.1.1',
recipientCount,
undefined,
'example.com'
);
if (!allowed.allowed) {
console.log(`Rate limited: ${allowed.reason}`);
}
```
Use `mailer dns setup <domain>` to automatically configure these via Cloudflare.
### 📬 Bounce Management
## Development
Automatic bounce detection (via Rust), classification, and suppression tracking:
### Prerequisites
```typescript
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
- Deno 1.40+
- Node.js 14+ (for npm distribution)
const bounceManager = new BounceManager();
### Build
// Process an SMTP failure
const bounce = await bounceManager.processSmtpFailure(
'recipient@example.com',
'550 5.1.1 User unknown',
{ originalEmailId: 'msg-123' }
);
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
```bash
# Compile for all platforms
deno task compile
// Check if an address is suppressed due to bounces
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
# Run in development mode
deno task dev
# Run tests
deno task test
# Format code
deno task fmt
# Lint code
deno task lint
// Manually manage the suppression list
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com');
```
### Ported Components
### 📝 Email Templates
The mail implementation is ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) and adapted for Deno:
Template engine with variable substitution for transactional and notification emails:
- ✅ Email core (Email, EmailValidator, BounceManager, TemplateManager)
- ✅ SMTP Server (with TLS support)
- ✅ SMTP Client (with connection pooling)
- ✅ Email routing and domain management
- ✅ DKIM signing and verification
- ✅ SPF and DMARC validation
- ✅ Delivery queues and rate limiting
```typescript
import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
## Roadmap
const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>&copy; 2026 Example Corp</p>',
});
### Phase 1 - Core Functionality (Current)
- [x] Project structure and build system
- [x] Port mail implementation from dcrouter
- [x] CLI interface
- [x] Configuration management
- [x] DNS management basics
- [ ] Cloudflare DNS integration
- [ ] HTTP REST API implementation
- [ ] Systemd service integration
// Register a template
templates.registerTemplate({
id: 'welcome',
name: 'Welcome Email',
description: 'Sent to new users',
from: 'welcome@example.com',
subject: 'Welcome, {{name}}!',
bodyHtml: '<h1>Welcome, {{name}}!</h1><p>Your account is ready.</p>',
bodyText: 'Welcome, {{name}}! Your account is ready.',
category: 'transactional',
});
### Phase 2 - Production Ready
- [ ] Comprehensive testing
- [ ] Documentation
- [ ] Performance optimization
- [ ] Security hardening
- [ ] Monitoring and logging
// Create an Email object from the template
const email = await templates.createEmail('welcome', {
to: 'newuser@example.com',
variables: { name: 'Alice' },
});
```
### Phase 3 - Advanced Features
- [ ] Webhook support
- [ ] Email templates
- [ ] Analytics and reporting
- [ ] Multi-tenancy
- [ ] Load balancing
### 🌍 DNS Management
## License
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:
MIT © Serve Zone
```typescript
const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com',
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns', // managed via Cloudflare API
},
],
// ... other config
});
## Contributing
// DNS records are set up automatically on start:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
await emailServer.start();
```
Contributions are welcome! Please see our [contributing guidelines](https://code.foss.global/serve.zone/mailer/contributing).
### 🦀 RustSecurityBridge
## Support
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:
- Documentation: https://code.foss.global/serve.zone/mailer
- Issues: https://code.foss.global/serve.zone/mailer/issues
- Email: support@serve.zone
```typescript
import { RustSecurityBridge } from '@push.rocks/smartmta';
## Acknowledgments
const bridge = RustSecurityBridge.getInstance();
await bridge.start();
- Mail implementation ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter)
- Inspired by [Mailgun](https://www.mailgun.com/) API design
- Built with [Deno](https://deno.land/)
// Compound verification: DKIM + SPF + DMARC in a single IPC call
const securityResult = await bridge.verifyEmail({
rawMessage: rawEmailString,
ip: '203.0.113.10',
heloDomain: 'sender.example.com',
mailFrom: 'user@example.com',
});
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
// Individual security operations
const dkimResults = await bridge.verifyDkim(rawEmailString);
const spfResult = await bridge.checkSpf({
ip: '203.0.113.10',
heloDomain: 'sender.example.com',
mailFrom: 'user@example.com',
});
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
// DKIM signing
const signed = await bridge.signDkim({
email: rawEmailString,
domain: 'example.com',
selector: 'default',
privateKeyPem: privateKey,
});
// Content scanning
const scanResult = await bridge.scanContent({
subject: 'Win a free iPhone!!!',
body: '<a href="http://phishing.example.com">Click here</a>',
from: 'scammer@evil.com',
});
// Bounce detection
const bounceResult = await bridge.detectBounce({
subject: 'Delivery Status Notification (Failure)',
body: '550 5.1.1 User unknown',
from: 'mailer-daemon@example.com',
});
await bridge.stop();
```
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
## 🦀 Rust Acceleration Layer
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (77 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, in-process security pipeline, rate limiting |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
### What Runs in Rust
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
| SPF validation | Rust | DNS lookups with async resolver |
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | Rust | Parallel DNS queries |
| Content scanning (text patterns) | Rust | Regex engine performance |
| Bounce detection (pattern matching) | Rust | Regex engine performance |
| Email validation & MIME building | Rust | Parsing performance |
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
| Email routing & orchestration | TypeScript | Business logic, flexibility |
| Delivery queue & retry | TypeScript | State management, persistence |
| Template rendering | TypeScript | String interpolation |
## Project Structure
```
smartmta/
├── ts/ # TypeScript source
│ ├── mail/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ └── smtpclient/ # SMTP client with connection pooling
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace
│ └── crates/
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
│ └── mailer-napi/ # N-API addon (planned)
├── test/ # Test suite
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
```
## 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.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,198 +1,24 @@
# Mailer Implementation Plan & Progress
# Rust Migration Plan
## Project Goals
## Completed Phases
Build a Deno-based mail server package (`@serve.zone/mailer`) with:
1. CLI interface similar to nupst/spark
2. SMTP server and client (ported from dcrouter)
3. HTTP REST API (Mailgun-compatible)
4. Automatic DNS management via Cloudflare
5. Systemd daemon service
6. Binary distribution via npm
### Phase 3: Rust Primary Backend (DKIM/SPF/DMARC/IP Reputation)
- Rust is the mandatory security backend — no TS fallbacks
- All DKIM signing/verification, SPF, DMARC, IP reputation through Rust bridge
## Completed Work
### Phase 5: BounceManager + ContentScanner
- BounceManager bounce detection delegated to Rust `detectBounce` IPC command
- ContentScanner pattern matching delegated to new Rust `scanContent` IPC command
- New module: `rust/crates/mailer-security/src/content_scanner.rs` (10 Rust tests)
- ~215 lines removed from BounceManager, ~350 lines removed from ContentScanner
- Binary attachment scanning (PE headers, VBA macros) stays in TS
- Custom rules (runtime-configured) stay in TS
- Net change: ~-560 TS lines, +265 Rust lines
### ✅ Phase 1: Project Structure
- [x] Created Deno-based project structure (deno.json, package.json)
- [x] Set up bin/ wrappers for npm binary distribution
- [x] Created compilation scripts (compile-all.sh)
- [x] Set up install scripts (install-binary.js)
- [x] Created TypeScript source directory structure
## Deferred
### ✅ Phase 2: Mail Implementation (Ported from dcrouter)
- [x] Copied and adapted mail/core/ (Email, EmailValidator, BounceManager, TemplateManager)
- [x] Copied and adapted mail/delivery/ (SMTP client, SMTP server, queues, rate limiting)
- [x] Copied and adapted mail/routing/ (EmailRouter, DomainRegistry, DnsManager)
- [x] Copied and adapted mail/security/ (DKIM, SPF, DMARC)
- [x] Fixed all imports from .js to .ts extensions
- [x] Created stub modules for dcrouter dependencies (storage, security, deliverability, errors)
### ✅ Phase 3: Supporting Modules
- [x] Created logger module (simple console logging)
- [x] Created paths module (project paths)
- [x] Created plugins.ts (Deno dependencies + Node.js compatibility)
- [x] Added required npm dependencies (lru-cache, mailaddress-validator, cloudflare)
### ✅ Phase 4: DNS Management
- [x] Created DnsManager class with DNS record generation
- [x] Created CloudflareClient for automatic DNS setup
- [x] Added DNS validation functionality
### ✅ Phase 5: HTTP API
- [x] Created ApiServer class with basic routing
- [x] Implemented Mailgun-compatible endpoint structure
- [x] Added authentication and rate limiting stubs
### ✅ Phase 6: Configuration Management
- [x] Created ConfigManager for JSON-based config storage
- [x] Added domain configuration support
- [x] Implemented config load/save functionality
### ✅ Phase 7: Daemon Service
- [x] Created DaemonManager to coordinate SMTP server and API server
- [x] Added start/stop functionality
- [x] Integrated with ConfigManager
### ✅ Phase 8: CLI Interface
- [x] Created MailerCli class with command routing
- [x] Implemented service commands (start/stop/restart/status/enable/disable)
- [x] Implemented domain commands (add/remove/list)
- [x] Implemented DNS commands (setup/validate/show)
- [x] Implemented send command
- [x] Implemented config commands (show/set)
- [x] Added help and version commands
### ✅ Phase 9: Documentation
- [x] Created comprehensive README.md
- [x] Documented all CLI commands
- [x] Documented HTTP API endpoints
- [x] Provided configuration examples
- [x] Documented DNS requirements
- [x] Created changelog
## Next Steps (Remaining Work)
### Testing & Debugging
1. Fix remaining import/dependency issues
2. Test compilation with `deno compile`
3. Test CLI commands end-to-end
4. Test SMTP sending/receiving
5. Test HTTP API endpoints
6. Write unit tests
### Systemd Integration
1. Create systemd service file
2. Implement service enable/disable
3. Add service status checking
4. Test daemon auto-restart
### Cloudflare Integration
1. Test actual Cloudflare API calls
2. Handle Cloudflare errors gracefully
3. Add zone detection
4. Verify DNS record creation
### Production Readiness
1. Add proper error handling throughout
2. Implement logging to files
3. Add rate limiting implementation
4. Implement API key authentication
5. Add TLS certificate management
6. Implement email queue persistence
### Advanced Features
1. Webhook support for incoming emails
2. Email template system
3. Analytics and reporting
4. SMTP credential management
5. Email event tracking
6. Bounce handling
## Known Issues
1. Some npm dependencies may need version adjustments
2. Deno crypto APIs may need adaptation for DKIM signing
3. Buffer vs Uint8Array conversions may be needed
4. Some dcrouter-specific code may need further adaptation
## File Structure Overview
```
mailer/
├── README.md ✅ Complete
├── license ✅ Complete
├── changelog.md ✅ Complete
├── deno.json ✅ Complete
├── package.json ✅ Complete
├── mod.ts ✅ Complete
├── bin/
│ └── mailer-wrapper.js ✅ Complete
├── scripts/
│ ├── compile-all.sh ✅ Complete
│ └── install-binary.js ✅ Complete
└── ts/
├── 00_commitinfo_data.ts ✅ Complete
├── index.ts ✅ Complete
├── cli.ts ✅ Complete
├── plugins.ts ✅ Complete
├── logger.ts ✅ Complete
├── paths.ts ✅ Complete
├── classes.mailer.ts ✅ Complete
├── cli/
│ ├── index.ts ✅ Complete
│ └── mailer-cli.ts ✅ Complete
├── api/
│ ├── index.ts ✅ Complete
│ ├── api-server.ts ✅ Complete
│ └── routes/ ✅ Structure ready
├── dns/
│ ├── index.ts ✅ Complete
│ ├── dns-manager.ts ✅ Complete
│ └── cloudflare-client.ts ✅ Complete
├── daemon/
│ ├── index.ts ✅ Complete
│ └── daemon-manager.ts ✅ Complete
├── config/
│ ├── index.ts ✅ Complete
│ └── config-manager.ts ✅ Complete
├── storage/
│ └── index.ts ✅ Stub complete
├── security/
│ └── index.ts ✅ Stub complete
├── deliverability/
│ └── index.ts ✅ Stub complete
├── errors/
│ └── index.ts ✅ Stub complete
└── mail/ ✅ Ported from dcrouter
├── core/ ✅ Complete
├── delivery/ ✅ Complete
├── routing/ ✅ Complete
└── security/ ✅ Complete
```
## Summary
The mailer package structure is **95% complete**. All major components have been implemented:
- Project structure and build system ✅
- Mail implementation ported from dcrouter ✅
- CLI interface ✅
- DNS management ✅
- HTTP API ✅
- Configuration system ✅
- Daemon management ✅
- Documentation ✅
**Remaining work**: Testing, debugging dependency issues, systemd integration, and production hardening.
| Component | Rationale |
|-----------|-----------|
| EmailValidator | Already thin; uses smartmail; minimal gain |
| DNS record generation | Pure string building; zero benefit from Rust |
| MIME building (`toRFC822String`) | Sync in TS, async via IPC; too much blast radius |

2
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

2425
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

37
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,37 @@
[workspace]
resolver = "2"
members = [
"crates/mailer-core",
"crates/mailer-smtp",
"crates/mailer-security",
"crates/mailer-napi",
"crates/mailer-bin",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26"
hickory-resolver = "0.25"
mail-auth = "0.7"
mailparse = "0.16"
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
napi-derive = "2"
ring = "0.17"
dashmap = "6"
thiserror = "2"
tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
regex = "1"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
ipnet = "2"
rustls-pki-types = "1"
psl = "2"
clap = { version = "4", features = ["derive"] }

View File

@@ -0,0 +1,21 @@
[package]
name = "mailer-bin"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "mailer-bin"
path = "src/main.rs"
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-smtp = { path = "../mailer-smtp" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
[package]
name = "mailer-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
mailparse.workspace = true
regex.workspace = true
base64.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,485 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
/// Type of email bounce.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BounceType {
// Hard bounces
InvalidRecipient,
DomainNotFound,
MailboxFull,
MailboxInactive,
Blocked,
SpamRelated,
PolicyRelated,
// Soft bounces
ServerUnavailable,
TemporaryFailure,
QuotaExceeded,
NetworkError,
Timeout,
// Special
AutoResponse,
ChallengeResponse,
Unknown,
}
/// Broad category of a bounce.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BounceCategory {
Hard,
Soft,
AutoResponse,
Unknown,
}
impl BounceType {
/// Get the category for this bounce type.
pub fn category(&self) -> BounceCategory {
match self {
BounceType::InvalidRecipient
| BounceType::DomainNotFound
| BounceType::MailboxFull
| BounceType::MailboxInactive
| BounceType::Blocked
| BounceType::SpamRelated
| BounceType::PolicyRelated => BounceCategory::Hard,
BounceType::ServerUnavailable
| BounceType::TemporaryFailure
| BounceType::QuotaExceeded
| BounceType::NetworkError
| BounceType::Timeout => BounceCategory::Soft,
BounceType::AutoResponse | BounceType::ChallengeResponse => {
BounceCategory::AutoResponse
}
BounceType::Unknown => BounceCategory::Unknown,
}
}
}
/// Result of bounce detection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BounceDetection {
pub bounce_type: BounceType,
pub category: BounceCategory,
}
/// Pattern set for a bounce type: compiled regexes for matching against SMTP responses.
struct BouncePatterns {
bounce_type: BounceType,
patterns: Vec<Regex>,
}
/// All bounce detection patterns, compiled once.
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = LazyLock::new(|| {
vec![
BouncePatterns {
bounce_type: BounceType::InvalidRecipient,
patterns: compile_patterns(&[
r"(?i)no such user",
r"(?i)user unknown",
r"(?i)does not exist",
r"(?i)invalid recipient",
r"(?i)unknown recipient",
r"(?i)no mailbox",
r"(?i)user not found",
r"(?i)recipient address rejected",
r"(?i)550 5\.1\.1",
]),
},
BouncePatterns {
bounce_type: BounceType::DomainNotFound,
patterns: compile_patterns(&[
r"(?i)domain not found",
r"(?i)unknown domain",
r"(?i)no such domain",
r"(?i)host not found",
r"(?i)domain invalid",
r"(?i)550 5\.1\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::MailboxFull,
patterns: compile_patterns(&[
r"(?i)mailbox full",
r"(?i)over quota",
r"(?i)quota exceeded",
r"(?i)552 5\.2\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::MailboxInactive,
patterns: compile_patterns(&[
r"(?i)mailbox disabled",
r"(?i)mailbox inactive",
r"(?i)account disabled",
r"(?i)mailbox not active",
r"(?i)account suspended",
]),
},
BouncePatterns {
bounce_type: BounceType::Blocked,
patterns: compile_patterns(&[
r"(?i)blocked",
r"(?i)rejected",
r"(?i)denied",
r"(?i)blacklisted",
r"(?i)prohibited",
r"(?i)refused",
r"(?i)550 5\.7\.",
]),
},
BouncePatterns {
bounce_type: BounceType::SpamRelated,
patterns: compile_patterns(&[
r"(?i)spam",
r"(?i)bulk mail",
r"(?i)content rejected",
r"(?i)message rejected",
r"(?i)550 5\.7\.1",
]),
},
BouncePatterns {
bounce_type: BounceType::ServerUnavailable,
patterns: compile_patterns(&[
r"(?i)server unavailable",
r"(?i)service unavailable",
r"(?i)try again later",
r"(?i)try later",
r"(?i)451 4\.3\.",
r"(?i)421 4\.3\.",
]),
},
BouncePatterns {
bounce_type: BounceType::TemporaryFailure,
patterns: compile_patterns(&[
r"(?i)temporary failure",
r"(?i)temporary error",
r"(?i)temporary problem",
r"(?i)try again",
r"(?i)451 4\.",
]),
},
BouncePatterns {
bounce_type: BounceType::QuotaExceeded,
patterns: compile_patterns(&[
r"(?i)quota temporarily exceeded",
r"(?i)mailbox temporarily full",
r"(?i)452 4\.2\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::NetworkError,
patterns: compile_patterns(&[
r"(?i)network error",
r"(?i)connection error",
r"(?i)connection timed out",
r"(?i)routing error",
r"(?i)421 4\.4\.",
]),
},
BouncePatterns {
bounce_type: BounceType::Timeout,
patterns: compile_patterns(&[
r"(?i)timed out",
r"(?i)timeout",
r"(?i)450 4\.4\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::AutoResponse,
patterns: compile_patterns(&[
r"(?i)auto[- ]reply",
r"(?i)auto[- ]response",
r"(?i)vacation",
r"(?i)out of office",
r"(?i)away from office",
r"(?i)on vacation",
r"(?i)automatic reply",
]),
},
BouncePatterns {
bounce_type: BounceType::ChallengeResponse,
patterns: compile_patterns(&[
r"(?i)challenge[- ]response",
r"(?i)verify your email",
r"(?i)confirm your email",
r"(?i)email verification",
]),
},
]
});
/// Regex for detecting bounce email subjects.
static BOUNCE_SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem")
.expect("invalid bounce subject regex")
});
/// Regex for extracting recipient from bounce messages.
static BOUNCE_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
.expect("invalid bounce recipient regex")
});
/// Regex for extracting diagnostic code.
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)")
.expect("invalid diagnostic code regex")
});
/// Regex for extracting status code.
static STATUS_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)")
.expect("invalid status code regex")
});
/// Regex for DSN original-recipient.
static DSN_ORIGINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
.expect("invalid DSN original-recipient regex")
});
/// Regex for DSN final-recipient.
static DSN_FINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
.expect("invalid DSN final-recipient regex")
});
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
patterns
.iter()
.map(|p| Regex::new(p).expect("invalid bounce pattern regex"))
.collect()
}
/// Detect bounce type from an SMTP response, diagnostic code, or status code.
pub fn detect_bounce_type(
smtp_response: Option<&str>,
diagnostic_code: Option<&str>,
status_code: Option<&str>,
) -> BounceDetection {
// Check all text sources against patterns
let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code]
.into_iter()
.flatten()
.collect();
for bp in BOUNCE_PATTERNS.iter() {
for text in &texts {
for pattern in &bp.patterns {
if pattern.is_match(text) {
return BounceDetection {
bounce_type: bp.bounce_type,
category: bp.bounce_type.category(),
};
}
}
}
}
// Fallback: parse DSN status code (class.subject.detail)
if let Some(code) = status_code {
if let Some(detection) = parse_dsn_status(code) {
return detection;
}
}
// Try to find DSN code in SMTP response
if let Some(resp) = smtp_response {
if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) {
if let Some(detection) = parse_dsn_status(code.as_str()) {
return detection;
}
}
}
BounceDetection {
bounce_type: BounceType::Unknown,
category: BounceCategory::Unknown,
}
}
/// Parse a DSN enhanced status code like "5.1.1" or "4.2.2".
fn parse_dsn_status(code: &str) -> Option<BounceDetection> {
let parts: Vec<&str> = code.split('.').collect();
if parts.len() < 2 {
return None;
}
let class: u8 = parts[0].parse().ok()?;
let subject: u8 = parts[1].parse().ok()?;
let bounce_type = match (class, subject) {
(5, 1) => BounceType::InvalidRecipient,
(5, 2) => BounceType::MailboxFull,
(5, 7) => BounceType::Blocked,
(5, _) => BounceType::PolicyRelated,
(4, 2) => BounceType::QuotaExceeded,
(4, 3) => BounceType::ServerUnavailable,
(4, 4) => BounceType::NetworkError,
(4, _) => BounceType::TemporaryFailure,
_ => return None,
};
Some(BounceDetection {
category: bounce_type.category(),
bounce_type,
})
}
/// Check if a subject line looks like a bounce notification.
pub fn is_bounce_subject(subject: &str) -> bool {
BOUNCE_SUBJECT_RE.is_match(subject)
}
/// Extract the bounced recipient email from a bounce message body.
pub fn extract_bounce_recipient(body: &str) -> Option<String> {
BOUNCE_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.or_else(|| {
DSN_FINAL_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
})
.or_else(|| {
DSN_ORIGINAL_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
})
}
/// Extract the diagnostic code from a bounce message body.
pub fn extract_diagnostic_code(body: &str) -> Option<String> {
DIAGNOSTIC_CODE_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Extract the status code from a bounce message body.
pub fn extract_status_code(body: &str) -> Option<String> {
STATUS_CODE_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Calculate retry delay using exponential backoff.
///
/// * `retry_count` - Number of retries so far (0-based)
/// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000)
/// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000)
/// * `backoff_factor` - Multiplier per retry (default 2.0)
pub fn retry_delay_ms(
retry_count: u32,
initial_delay_ms: u64,
max_delay_ms: u64,
backoff_factor: f64,
) -> u64 {
let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32);
(delay as u64).min(max_delay_ms)
}
/// Default retry delay with standard parameters.
pub fn default_retry_delay_ms(retry_count: u32) -> u64 {
retry_delay_ms(
retry_count,
15 * 60 * 1000, // 15 minutes
24 * 60 * 60 * 1000, // 24 hours
2.0,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_invalid_recipient() {
let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None);
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
assert_eq!(result.category, BounceCategory::Hard);
}
#[test]
fn test_detect_mailbox_full() {
let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None);
assert_eq!(result.bounce_type, BounceType::MailboxFull);
assert_eq!(result.category, BounceCategory::Hard);
}
#[test]
fn test_detect_temporary_failure() {
let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None);
assert_eq!(result.bounce_type, BounceType::ServerUnavailable);
assert_eq!(result.category, BounceCategory::Soft);
}
#[test]
fn test_detect_auto_response() {
let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None);
assert_eq!(result.bounce_type, BounceType::AutoResponse);
assert_eq!(result.category, BounceCategory::AutoResponse);
}
#[test]
fn test_detect_from_dsn_status() {
let result = detect_bounce_type(None, None, Some("5.1.1"));
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
let result = detect_bounce_type(None, None, Some("4.4.1"));
assert_eq!(result.bounce_type, BounceType::NetworkError);
}
#[test]
fn test_detect_unknown() {
let result = detect_bounce_type(Some("Something weird happened"), None, None);
assert_eq!(result.bounce_type, BounceType::Unknown);
}
#[test]
fn test_is_bounce_subject() {
assert!(is_bounce_subject("Mail Delivery Failure"));
assert!(is_bounce_subject("Delivery Status Notification"));
assert!(is_bounce_subject("Returned mail: see transcript for details"));
assert!(is_bounce_subject("Undeliverable: Your message"));
assert!(!is_bounce_subject("Hello World"));
assert!(!is_bounce_subject("Meeting tomorrow"));
}
#[test]
fn test_extract_bounce_recipient() {
let body = "Delivery to the following recipient failed:\n recipient: user@example.com";
assert_eq!(
extract_bounce_recipient(body),
Some("user@example.com".to_string())
);
let body = "Final-Recipient: rfc822;bounce@test.org";
assert_eq!(
extract_bounce_recipient(body),
Some("bounce@test.org".to_string())
);
}
#[test]
fn test_retry_delay() {
assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min
assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min
assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour
// Capped at 24h
assert_eq!(default_retry_delay_ms(20), 86_400_000);
}
}

View File

@@ -0,0 +1,411 @@
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::error::{MailerError, Result};
use crate::mime::build_rfc822;
use crate::validation::is_valid_email_format;
/// Email priority level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
High,
Normal,
Low,
}
impl Default for Priority {
fn default() -> Self {
Priority::Normal
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::High => write!(f, "high"),
Priority::Normal => write!(f, "normal"),
Priority::Low => write!(f, "low"),
}
}
}
/// A parsed email address with local part and domain.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EmailAddress {
pub local: String,
pub domain: String,
}
impl EmailAddress {
/// Parse an email address string like "user@example.com" or "Name <user@example.com>".
pub fn parse(input: &str) -> Result<Self> {
let addr = extract_email_address(input)
.ok_or_else(|| MailerError::InvalidEmail(input.to_string()))?;
let parts: Vec<&str> = addr.splitn(2, '@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(MailerError::InvalidEmail(input.to_string()));
}
Ok(EmailAddress {
local: parts[0].to_string(),
domain: parts[1].to_lowercase(),
})
}
/// Return the full address as "local@domain".
pub fn address(&self) -> String {
format!("{}@{}", self.local, self.domain)
}
}
impl fmt::Display for EmailAddress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.local, self.domain)
}
}
/// Extract the bare email address from a string that may contain display names or angle brackets.
/// Handles formats like:
/// - "user@example.com"
/// - "<user@example.com>"
/// - "John Doe <user@example.com>"
pub fn extract_email_address(input: &str) -> Option<String> {
let trimmed = input.trim();
// Handle null sender
if trimmed == "<>" {
return None;
}
// Try to extract from angle brackets
if let Some(start) = trimmed.find('<') {
if let Some(end) = trimmed.find('>') {
if end > start {
let addr = trimmed[start + 1..end].trim();
if !addr.is_empty() {
return Some(addr.to_string());
}
}
}
}
// No angle brackets — treat entire string as address if it contains @
if trimmed.contains('@') {
return Some(trimmed.to_string());
}
None
}
/// An email attachment.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub filename: String,
#[serde(with = "serde_bytes_base64")]
pub content: Vec<u8>,
pub content_type: String,
pub content_id: Option<String>,
}
/// Serde helper for base64-encoding Vec<u8> in JSON.
mod serde_bytes_base64 {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(data))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(deserializer)?;
STANDARD.decode(s).map_err(serde::de::Error::custom)
}
}
/// A complete email message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Email {
pub from: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub text: String,
pub html: Option<String>,
pub attachments: Vec<Attachment>,
pub headers: HashMap<String, String>,
pub priority: Priority,
pub might_be_spam: bool,
message_id: Option<String>,
envelope_from: Option<String>,
}
impl Email {
/// Create a new email with the minimum required fields.
pub fn new(from: &str, subject: &str, text: &str) -> Self {
Email {
from: from.to_string(),
to: Vec::new(),
cc: Vec::new(),
bcc: Vec::new(),
subject: subject.to_string(),
text: text.to_string(),
html: None,
attachments: Vec::new(),
headers: HashMap::new(),
priority: Priority::Normal,
might_be_spam: false,
message_id: None,
envelope_from: None,
}
}
/// Add a To recipient.
pub fn add_to(&mut self, email: &str) -> &mut Self {
self.to.push(email.to_string());
self
}
/// Add a CC recipient.
pub fn add_cc(&mut self, email: &str) -> &mut Self {
self.cc.push(email.to_string());
self
}
/// Add a BCC recipient.
pub fn add_bcc(&mut self, email: &str) -> &mut Self {
self.bcc.push(email.to_string());
self
}
/// Set the HTML body.
pub fn set_html(&mut self, html: &str) -> &mut Self {
self.html = Some(html.to_string());
self
}
/// Add an attachment.
pub fn add_attachment(&mut self, attachment: Attachment) -> &mut Self {
self.attachments.push(attachment);
self
}
/// Add a custom header.
pub fn add_header(&mut self, name: &str, value: &str) -> &mut Self {
self.headers.insert(name.to_string(), value.to_string());
self
}
/// Set email priority.
pub fn set_priority(&mut self, priority: Priority) -> &mut Self {
self.priority = priority;
self
}
/// Get the sender domain.
pub fn from_domain(&self) -> Option<String> {
EmailAddress::parse(&self.from)
.ok()
.map(|addr| addr.domain)
}
/// Get the sender address (bare email, no display name).
pub fn from_address(&self) -> Option<String> {
extract_email_address(&self.from)
}
/// Get all recipients (to + cc + bcc), deduplicated.
pub fn all_recipients(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for addr in self.to.iter().chain(self.cc.iter()).chain(self.bcc.iter()) {
let lower = addr.to_lowercase();
if seen.insert(lower) {
result.push(addr.clone());
}
}
result
}
/// Get the primary (first To) recipient.
pub fn primary_recipient(&self) -> Option<&str> {
self.to.first().map(|s| s.as_str())
}
/// Check whether this email has attachments.
pub fn has_attachments(&self) -> bool {
!self.attachments.is_empty()
}
/// Get total attachment size in bytes.
pub fn attachments_size(&self) -> usize {
self.attachments.iter().map(|a| a.content.len()).sum()
}
/// Get or generate a Message-ID.
pub fn message_id(&self) -> String {
if let Some(ref id) = self.message_id {
return id.clone();
}
let domain = self.from_domain().unwrap_or_else(|| "localhost".to_string());
let unique = uuid::Uuid::new_v4();
format!("<{}.{}@{}>", chrono_millis(), unique, domain)
}
/// Set an explicit Message-ID.
pub fn set_message_id(&mut self, id: &str) -> &mut Self {
self.message_id = Some(id.to_string());
self
}
/// Get the envelope-from (MAIL FROM), falls back to the From header address.
pub fn envelope_from(&self) -> Option<String> {
self.envelope_from
.clone()
.or_else(|| self.from_address())
}
/// Set the envelope-from address.
pub fn set_envelope_from(&mut self, addr: &str) -> &mut Self {
self.envelope_from = Some(addr.to_string());
self
}
/// Sanitize a string by removing CR/LF (header injection prevention).
pub fn sanitize_string(input: &str) -> String {
input.replace(['\r', '\n'], " ")
}
/// Validate all addresses in this email.
pub fn validate_addresses(&self) -> Vec<String> {
let mut errors = Vec::new();
if !is_valid_email_format(&self.from) {
if extract_email_address(&self.from)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid from address: {}", self.from));
}
}
for addr in &self.to {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid to address: {}", addr));
}
}
}
for addr in &self.cc {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid cc address: {}", addr));
}
}
}
for addr in &self.bcc {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid bcc address: {}", addr));
}
}
}
errors
}
/// Convert the email to RFC 5322 format.
pub fn to_rfc822(&self) -> Result<String> {
build_rfc822(self)
}
}
/// Simple epoch millis using std::time (no chrono dependency needed).
fn chrono_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_address_parse() {
let addr = EmailAddress::parse("user@example.com").unwrap();
assert_eq!(addr.local, "user");
assert_eq!(addr.domain, "example.com");
let addr = EmailAddress::parse("John Doe <john@example.com>").unwrap();
assert_eq!(addr.local, "john");
assert_eq!(addr.domain, "example.com");
let addr = EmailAddress::parse("<admin@test.org>").unwrap();
assert_eq!(addr.local, "admin");
assert_eq!(addr.domain, "test.org");
}
#[test]
fn test_extract_email_address() {
assert_eq!(
extract_email_address("John <john@example.com>"),
Some("john@example.com".to_string())
);
assert_eq!(
extract_email_address("user@example.com"),
Some("user@example.com".to_string())
);
assert_eq!(extract_email_address("<>"), None);
assert_eq!(extract_email_address("no-at-sign"), None);
}
#[test]
fn test_email_new() {
let mut email = Email::new("sender@example.com", "Test", "Hello");
email.add_to("recipient@example.com");
assert_eq!(email.from_domain(), Some("example.com".to_string()));
assert_eq!(email.all_recipients().len(), 1);
}
#[test]
fn test_all_recipients_dedup() {
let mut email = Email::new("sender@example.com", "Test", "Hello");
email.add_to("a@example.com");
email.add_cc("a@example.com"); // duplicate (case-insensitive)
email.add_bcc("b@example.com");
assert_eq!(email.all_recipients().len(), 2);
}
#[test]
fn test_sanitize_string() {
assert_eq!(Email::sanitize_string("hello\r\nworld"), "hello world");
assert_eq!(Email::sanitize_string("normal"), "normal");
}
#[test]
fn test_message_id_generation() {
let email = Email::new("sender@example.com", "Test", "Hello");
let mid = email.message_id();
assert!(mid.starts_with('<'));
assert!(mid.ends_with('>'));
assert!(mid.contains("@example.com"));
}
}

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Core error types for the mailer system.
#[derive(Debug, Error)]
pub enum MailerError {
#[error("invalid email address: {0}")]
InvalidEmail(String),
#[error("invalid email format: {0}")]
InvalidFormat(String),
#[error("missing required field: {0}")]
MissingField(String),
#[error("MIME encoding error: {0}")]
MimeError(String),
#[error("validation error: {0}")]
ValidationError(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("regex error: {0}")]
Regex(#[from] regex::Error),
}
pub type Result<T> = std::result::Result<T, MailerError>;

View File

@@ -0,0 +1,35 @@
//! mailer-core: Email model, validation, and RFC 5322 primitives.
pub mod bounce;
pub mod email;
pub mod error;
pub mod mime;
pub mod validation;
// Re-exports for convenience
pub use bounce::{
detect_bounce_type, extract_bounce_recipient, is_bounce_subject, BounceCategory,
BounceDetection, BounceType,
};
pub use email::{extract_email_address, Attachment, Email, EmailAddress, Priority};
pub use error::{MailerError, Result};
pub use mime::build_rfc822;
pub use validation::{is_valid_email_format, validate_email, EmailValidationResult};
/// Re-export mailparse for MIME parsing.
pub use mailparse;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

View File

@@ -0,0 +1,377 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crate::email::Email;
use crate::error::Result;
/// Generate a MIME boundary string.
fn generate_boundary() -> String {
let id = uuid::Uuid::new_v4();
format!("----=_Part_{}", id.as_simple())
}
/// Build an RFC 5322 compliant email message from an Email struct.
pub fn build_rfc822(email: &Email) -> Result<String> {
let mut output = String::with_capacity(4096);
let message_id = email.message_id();
// Required headers
output.push_str(&format!(
"From: {}\r\n",
Email::sanitize_string(&email.from)
));
if !email.to.is_empty() {
output.push_str(&format!(
"To: {}\r\n",
email
.to
.iter()
.map(|a| Email::sanitize_string(a))
.collect::<Vec<_>>()
.join(", ")
));
}
if !email.cc.is_empty() {
output.push_str(&format!(
"Cc: {}\r\n",
email
.cc
.iter()
.map(|a| Email::sanitize_string(a))
.collect::<Vec<_>>()
.join(", ")
));
}
output.push_str(&format!(
"Subject: {}\r\n",
Email::sanitize_string(&email.subject)
));
output.push_str(&format!("Message-ID: {}\r\n", message_id));
output.push_str(&format!("Date: {}\r\n", rfc2822_now()));
output.push_str("MIME-Version: 1.0\r\n");
// Priority headers
match email.priority {
crate::email::Priority::High => {
output.push_str("X-Priority: 1\r\n");
output.push_str("Importance: high\r\n");
}
crate::email::Priority::Low => {
output.push_str("X-Priority: 5\r\n");
output.push_str("Importance: low\r\n");
}
crate::email::Priority::Normal => {}
}
// Custom headers
for (name, value) in &email.headers {
output.push_str(&format!(
"{}: {}\r\n",
Email::sanitize_string(name),
Email::sanitize_string(value)
));
}
let has_html = email.html.is_some();
let has_attachments = !email.attachments.is_empty();
match (has_html, has_attachments) {
(false, false) => {
// Plain text only
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
}
(true, false) => {
// multipart/alternative (text + html)
let boundary = generate_boundary();
output.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
boundary
));
output.push_str("\r\n");
// Text part
output.push_str(&format!("--{}\r\n", boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
// HTML part
output.push_str(&format!("--{}\r\n", boundary));
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(email.html.as_deref().unwrap()));
output.push_str("\r\n");
output.push_str(&format!("--{}--\r\n", boundary));
}
(_, true) => {
// multipart/mixed with optional multipart/alternative inside
let mixed_boundary = generate_boundary();
output.push_str(&format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
mixed_boundary
));
output.push_str("\r\n");
if has_html {
// multipart/alternative for text+html
let alt_boundary = generate_boundary();
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
alt_boundary
));
output.push_str("\r\n");
// Text part
output.push_str(&format!("--{}\r\n", alt_boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
// HTML part
output.push_str(&format!("--{}\r\n", alt_boundary));
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(email.html.as_deref().unwrap()));
output.push_str("\r\n");
output.push_str(&format!("--{}--\r\n", alt_boundary));
} else {
// Plain text only
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
}
// Attachments
for attachment in &email.attachments {
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str(&format!(
"Content-Type: {}; name=\"{}\"\r\n",
attachment.content_type, attachment.filename
));
output.push_str("Content-Transfer-Encoding: base64\r\n");
if let Some(ref cid) = attachment.content_id {
output.push_str(&format!("Content-ID: <{}>\r\n", cid));
output.push_str("Content-Disposition: inline\r\n");
} else {
output.push_str(&format!(
"Content-Disposition: attachment; filename=\"{}\"\r\n",
attachment.filename
));
}
output.push_str("\r\n");
output.push_str(&base64_encode_wrapped(&attachment.content));
output.push_str("\r\n");
}
output.push_str(&format!("--{}--\r\n", mixed_boundary));
}
}
Ok(output)
}
/// Encode a string as quoted-printable (RFC 2045).
fn quoted_printable_encode(input: &str) -> String {
let mut output = String::with_capacity(input.len() * 2);
let mut line_len = 0;
for byte in input.bytes() {
let encoded = match byte {
// Printable ASCII that doesn't need encoding (except =)
b' '..=b'<' | b'>'..=b'~' => {
line_len += 1;
(byte as char).to_string()
}
b'\t' => {
line_len += 1;
"\t".to_string()
}
b'\r' => continue, // handled with \n
b'\n' => {
line_len = 0;
"\r\n".to_string()
}
_ => {
line_len += 3;
format!("={:02X}", byte)
}
};
// Soft line break at 76 characters
if line_len > 75 && byte != b'\n' {
output.push_str("=\r\n");
line_len = encoded.len();
}
output.push_str(&encoded);
}
output
}
/// Base64-encode binary data with 76-character line wrapping.
fn base64_encode_wrapped(data: &[u8]) -> String {
let encoded = STANDARD.encode(data);
let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2);
for (i, ch) in encoded.chars().enumerate() {
if i > 0 && i % 76 == 0 {
output.push_str("\r\n");
}
output.push(ch);
}
output
}
/// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000").
fn rfc2822_now() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Simple UTC formatting without chrono dependency
let days = now / 86400;
let time_of_day = now % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
// Calculate year/month/day from days since epoch
let (year, month, day) = days_to_ymd(days);
let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4)
let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let mon = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
dow[day_of_week],
day,
mon[(month - 1) as usize],
year,
hours,
minutes,
seconds
)
}
/// Convert days since Unix epoch to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
// Algorithm from https://howardhinnant.github.io/date_algorithms.html
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::email::Email;
#[test]
fn test_plain_text_email() {
let mut email = Email::new("sender@example.com", "Test Subject", "Hello World");
email.add_to("recipient@example.com");
email.set_message_id("<test@example.com>");
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("From: sender@example.com"));
assert!(rfc822.contains("To: recipient@example.com"));
assert!(rfc822.contains("Subject: Test Subject"));
assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8"));
assert!(rfc822.contains("Hello World"));
}
#[test]
fn test_html_email() {
let mut email = Email::new("sender@example.com", "HTML Test", "Plain text");
email.add_to("recipient@example.com");
email.set_html("<p>HTML content</p>");
email.set_message_id("<test@example.com>");
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("multipart/alternative"));
assert!(rfc822.contains("text/plain"));
assert!(rfc822.contains("text/html"));
assert!(rfc822.contains("Plain text"));
assert!(rfc822.contains("HTML content"));
}
#[test]
fn test_email_with_attachment() {
let mut email = Email::new("sender@example.com", "Attachment Test", "See attached");
email.add_to("recipient@example.com");
email.set_message_id("<test@example.com>");
email.add_attachment(crate::email::Attachment {
filename: "test.txt".to_string(),
content: b"Hello attachment".to_vec(),
content_type: "text/plain".to_string(),
content_id: None,
});
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("multipart/mixed"));
assert!(rfc822.contains("Content-Disposition: attachment"));
assert!(rfc822.contains("test.txt"));
}
#[test]
fn test_quoted_printable() {
let input = "Hello = World";
let encoded = quoted_printable_encode(input);
assert!(encoded.contains("=3D")); // = is encoded
let input = "Plain ASCII text";
let encoded = quoted_printable_encode(input);
assert_eq!(encoded, "Plain ASCII text");
}
#[test]
fn test_base64_wrapped() {
let data = vec![0u8; 100];
let encoded = base64_encode_wrapped(&data);
for line in encoded.split("\r\n") {
assert!(line.len() <= 76);
}
}
#[test]
fn test_rfc2822_date() {
let date = rfc2822_now();
// Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000"
assert!(date.contains("+0000"));
assert!(date.len() > 20);
}
}

View File

@@ -0,0 +1,178 @@
use regex::Regex;
use std::sync::LazyLock;
/// Basic email format regex — covers the vast majority of valid email addresses.
/// Does NOT attempt to match the full RFC 5321 grammar (which is impractical via regex).
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$",
)
.expect("invalid email regex")
});
/// Check whether an email address has valid syntax.
pub fn is_valid_email_format(email: &str) -> bool {
let email = email.trim();
if email.is_empty() || email.len() > 254 {
return false;
}
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[1];
let domain = parts[0];
// Local part max 64 chars
if local.is_empty() || local.len() > 64 {
return false;
}
// Domain must have at least one dot (TLD only not valid for email)
if !domain.contains('.') {
return false;
}
EMAIL_REGEX.is_match(email)
}
/// Email validation result with scoring.
#[derive(Debug, Clone)]
pub struct EmailValidationResult {
pub is_valid: bool,
pub format_valid: bool,
pub score: f64,
pub error_message: Option<String>,
}
/// Validate an email address (synchronous, format-only).
/// DNS-based validation (MX records, disposable domains) would require async and is
/// intended for the N-API bridge layer where the TypeScript side already has DNS access.
pub fn validate_email(email: &str) -> EmailValidationResult {
let format_valid = is_valid_email_format(email);
if !format_valid {
return EmailValidationResult {
is_valid: false,
format_valid: false,
score: 0.0,
error_message: Some(format!("Invalid email format: {}", email)),
};
}
// Role account detection (weight 0.1 penalty)
let local = email.split('@').next().unwrap_or("");
let is_role = is_role_account(local);
// Score: format (0.4) + assumed-mx (0.3) + assumed-not-disposable (0.2) + role (0.1)
let mut score = 0.4 + 0.3 + 0.2; // format + mx + not-disposable
if !is_role {
score += 0.1;
}
EmailValidationResult {
is_valid: score >= 0.7,
format_valid: true,
score,
error_message: None,
}
}
/// Check if a local part is a common role account.
fn is_role_account(local: &str) -> bool {
const ROLE_ACCOUNTS: &[&str] = &[
"abuse",
"admin",
"administrator",
"billing",
"compliance",
"devnull",
"dns",
"ftp",
"hostmaster",
"info",
"inoc",
"ispfeedback",
"ispsupport",
"list",
"list-request",
"maildaemon",
"mailer-daemon",
"mailerdaemon",
"marketing",
"noc",
"no-reply",
"noreply",
"null",
"phish",
"phishing",
"postmaster",
"privacy",
"registrar",
"root",
"sales",
"security",
"spam",
"support",
"sysadmin",
"tech",
"undisclosed-recipients",
"unsubscribe",
"usenet",
"uucp",
"webmaster",
"www",
];
let lower = local.to_lowercase();
ROLE_ACCOUNTS.contains(&lower.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_emails() {
assert!(is_valid_email_format("user@example.com"));
assert!(is_valid_email_format("first.last@example.com"));
assert!(is_valid_email_format("user+tag@example.com"));
assert!(is_valid_email_format("user@sub.domain.example.com"));
}
#[test]
fn test_invalid_emails() {
assert!(!is_valid_email_format(""));
assert!(!is_valid_email_format("@"));
assert!(!is_valid_email_format("user@"));
assert!(!is_valid_email_format("@domain.com"));
assert!(!is_valid_email_format("user@domain")); // no TLD
assert!(!is_valid_email_format("user @domain.com")); // space
assert!(!is_valid_email_format("user@.com")); // leading dot
}
#[test]
fn test_validate_email_scoring() {
let result = validate_email("user@example.com");
assert!(result.is_valid);
assert!(result.score >= 0.9);
let result = validate_email("postmaster@example.com");
assert!(result.is_valid);
assert!(result.score >= 0.7);
assert!(result.score < 1.0); // role account penalty
let result = validate_email("not-an-email");
assert!(!result.is_valid);
assert_eq!(result.score, 0.0);
}
#[test]
fn test_role_accounts() {
assert!(is_role_account("postmaster"));
assert!(is_role_account("abuse"));
assert!(is_role_account("noreply"));
assert!(!is_role_account("john"));
assert!(!is_role_account("alice"));
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "mailer-napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-smtp = { path = "../mailer-smtp" }
mailer-security = { path = "../mailer-security" }
napi.workspace = true
napi-derive.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
[build-dependencies]
napi-build = "2"

View File

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

View File

@@ -0,0 +1,15 @@
//! mailer-napi: N-API bindings exposing Rust mailer to Node.js/TypeScript.
use napi_derive::napi;
/// Returns the version of the native mailer module.
#[napi]
pub fn get_version() -> String {
format!(
"mailer-napi v{} (core: {}, smtp: {}, security: {})",
env!("CARGO_PKG_VERSION"),
mailer_core::version(),
mailer_smtp::version(),
mailer_security::version(),
)
}

View File

@@ -0,0 +1,20 @@
[package]
name = "mailer-security"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mail-auth.workspace = true
ring.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
hickory-resolver.workspace = true
ipnet.workspace = true
rustls-pki-types.workspace = true
psl.workspace = true
regex.workspace = true

View File

@@ -0,0 +1,515 @@
//! Content scanning for email threat detection.
//!
//! Provides pattern-based scanning of email subjects, text bodies, HTML bodies,
//! and attachment filenames for phishing, spam, malware, suspicious links,
//! script injection, and sensitive data patterns.
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentScanResult {
pub threat_score: u32,
pub threat_type: Option<String>,
pub threat_details: Option<String>,
pub scanned_elements: Vec<String>,
}
// ---------------------------------------------------------------------------
// Pattern definitions (compiled once via LazyLock)
// ---------------------------------------------------------------------------
static PHISHING_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)(?:verify|confirm|update|login).*(?:account|password|details)").unwrap(),
Regex::new(r"(?i)urgent.*(?:action|attention|required)").unwrap(),
Regex::new(r"(?i)(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)").unwrap(),
Regex::new(r"(?i)your.*(?:account).*(?:suspended|compromised|locked)").unwrap(),
Regex::new(r"(?i)\b(?:password reset|security alert|security notice)\b").unwrap(),
]
});
static SPAM_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b").unwrap(),
Regex::new(r"(?i)\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b").unwrap(),
Regex::new(r"(?i)\b(?:earn from home|make money fast|earn \$\d{3,}/day)\b").unwrap(),
Regex::new(r"(?i)\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b").unwrap(),
Regex::new(r"(?i)\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b").unwrap(),
]
});
static MALWARE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)(?:attached file|see attachment).*(?:invoice|receipt|statement|document)").unwrap(),
Regex::new(r"(?i)open.*(?:the attached|this attachment)").unwrap(),
Regex::new(r"(?i)(?:enable|allow).*(?:macros|content|editing)").unwrap(),
Regex::new(r"(?i)download.*(?:attachment|file|document)").unwrap(),
Regex::new(r"(?i)\b(?:ransomware protection|virus alert|malware detected)\b").unwrap(),
]
});
static SUSPICIOUS_LINK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)https?://bit\.ly/").unwrap(),
Regex::new(r"(?i)https?://goo\.gl/").unwrap(),
Regex::new(r"(?i)https?://t\.co/").unwrap(),
Regex::new(r"(?i)https?://tinyurl\.com/").unwrap(),
Regex::new(r"(?i)https?://(?:\d{1,3}\.){3}\d{1,3}").unwrap(),
Regex::new(r"(?i)https?://.*\.(?:xyz|top|club|gq|cf)/").unwrap(),
Regex::new(r"(?i)(?:login|account|signin|auth).*\.(?:xyz|top|club|gq|cf|tk|ml|ga|pw|ws|buzz)\b").unwrap(),
]
});
static SCRIPT_INJECTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?is)<script.*>.*</script>").unwrap(),
Regex::new(r"(?i)javascript:").unwrap(),
Regex::new(r#"(?i)on(?:click|load|mouse|error|focus|blur)=".*""#).unwrap(),
Regex::new(r"(?i)document\.(?:cookie|write|location)").unwrap(),
Regex::new(r"(?i)eval\s*\(").unwrap(),
]
});
static SENSITIVE_DATA_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b").unwrap(),
Regex::new(r"\b\d{13,16}\b").unwrap(),
Regex::new(r"\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b").unwrap(),
]
});
/// Link extraction from HTML href attributes.
static HREF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)href=["'](https?://[^"']+)["']"#).unwrap()
});
/// Executable file extensions that are considered dangerous.
static EXECUTABLE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
".exe", ".dll", ".bat", ".cmd", ".msi", ".vbs", ".ps1",
".sh", ".jar", ".py", ".com", ".scr", ".pif", ".hta", ".cpl",
".reg", ".vba", ".lnk", ".wsf", ".msp", ".mst",
]
});
/// Document extensions that may contain macros.
static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm",
".dotm", ".xlsb", ".ppam", ".potm",
]
});
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
/// Strip HTML tags and decode common entities to produce plain text.
fn extract_text_from_html(html: &str) -> String {
// Remove style and script blocks first
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
let no_tags = Regex::new(r"<[^>]+>").unwrap();
let text = no_style.replace_all(html, " ");
let text = no_script.replace_all(&text, " ");
let text = no_tags.replace_all(&text, " ");
text.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Extract all href links from HTML.
fn extract_links_from_html(html: &str) -> Vec<String> {
HREF_PATTERN
.captures_iter(html)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect()
}
// ---------------------------------------------------------------------------
// Scoring helpers
// ---------------------------------------------------------------------------
fn matches_any(text: &str, patterns: &[Regex]) -> bool {
patterns.iter().any(|p| p.is_match(text))
}
// ---------------------------------------------------------------------------
// Main scan entry point
// ---------------------------------------------------------------------------
/// Scan email content for threats.
///
/// This mirrors the TypeScript ContentScanner logic — scanning the subject,
/// text body, HTML body, and attachment filenames against predefined patterns.
/// Returns an aggregate threat score and the highest-severity threat type.
pub fn scan_content(
subject: Option<&str>,
text_body: Option<&str>,
html_body: Option<&str>,
attachment_names: &[String],
) -> ContentScanResult {
let mut score: u32 = 0;
let mut threat_type: Option<String> = None;
let mut threat_details: Option<String> = None;
let mut scanned: Vec<String> = Vec::new();
// Helper: upgrade threat info only if the new finding is more severe.
macro_rules! record {
($new_score:expr, $ttype:expr, $details:expr) => {
score += $new_score;
// Always adopt the threat type from the highest-scoring match.
threat_type = Some($ttype.to_string());
threat_details = Some($details.to_string());
};
}
// ── Subject scanning ──────────────────────────────────────────────
if let Some(subj) = subject {
scanned.push("subject".into());
if matches_any(subj, &PHISHING_PATTERNS) {
record!(25, "phishing", format!("Subject contains potential phishing indicators: {}", subj));
} else if matches_any(subj, &SPAM_PATTERNS) {
record!(15, "spam", format!("Subject contains potential spam indicators: {}", subj));
}
}
// ── Text body scanning ────────────────────────────────────────────
if let Some(text) = text_body {
scanned.push("text".into());
// Check each category and accumulate score (same order as TS)
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
if pat.is_match(text) {
score += 20;
if threat_type.as_deref() != Some("suspicious_link") {
threat_type = Some("suspicious_link".into());
threat_details = Some("Text contains suspicious links".into());
}
}
}
for pat in PHISHING_PATTERNS.iter() {
if pat.is_match(text) {
score += 25;
threat_type = Some("phishing".into());
threat_details = Some("Text contains potential phishing indicators".into());
}
}
for pat in SPAM_PATTERNS.iter() {
if pat.is_match(text) {
score += 15;
if threat_type.is_none() {
threat_type = Some("spam".into());
threat_details = Some("Text contains potential spam indicators".into());
}
}
}
for pat in MALWARE_PATTERNS.iter() {
if pat.is_match(text) {
score += 30;
threat_type = Some("malware".into());
threat_details = Some("Text contains potential malware indicators".into());
}
}
for pat in SENSITIVE_DATA_PATTERNS.iter() {
if pat.is_match(text) {
score += 25;
if threat_type.is_none() {
threat_type = Some("sensitive_data".into());
threat_details = Some("Text contains potentially sensitive data patterns".into());
}
}
}
}
// ── HTML body scanning ────────────────────────────────────────────
if let Some(html) = html_body {
scanned.push("html".into());
// Script injection check
for pat in SCRIPT_INJECTION_PATTERNS.iter() {
if pat.is_match(html) {
score += 40;
if threat_type.as_deref() != Some("xss") {
threat_type = Some("xss".into());
threat_details = Some("HTML contains potentially malicious script content".into());
}
}
}
// Extract text from HTML and scan (half score to avoid double counting)
let text_content = extract_text_from_html(html);
if !text_content.is_empty() {
let mut html_text_score: u32 = 0;
let mut html_text_type: Option<String> = None;
let mut html_text_details: Option<String> = None;
// Re-run text patterns on extracted HTML text
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 20;
html_text_type = Some("suspicious_link".into());
html_text_details = Some("Text contains suspicious links".into());
}
}
for pat in PHISHING_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 25;
html_text_type = Some("phishing".into());
html_text_details = Some("Text contains potential phishing indicators".into());
}
}
for pat in SPAM_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 15;
if html_text_type.is_none() {
html_text_type = Some("spam".into());
html_text_details = Some("Text contains potential spam indicators".into());
}
}
}
for pat in MALWARE_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 30;
html_text_type = Some("malware".into());
html_text_details = Some("Text contains potential malware indicators".into());
}
}
for pat in SENSITIVE_DATA_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 25;
if html_text_type.is_none() {
html_text_type = Some("sensitive_data".into());
html_text_details = Some("Text contains potentially sensitive data patterns".into());
}
}
}
if html_text_score > 0 {
// Add half of the text content score to avoid double counting
score += html_text_score / 2;
if let Some(t) = html_text_type {
if threat_type.is_none() || html_text_score > score {
threat_type = Some(t);
threat_details = html_text_details;
}
}
}
}
// Extract and check links from HTML
let links = extract_links_from_html(html);
if !links.is_empty() {
let mut suspicious_count = 0u32;
for link in &links {
if matches_any(link, &SUSPICIOUS_LINK_PATTERNS) {
suspicious_count += 1;
}
}
if suspicious_count > 0 {
let pct = (suspicious_count as f64 / links.len() as f64) * 100.0;
let additional = std::cmp::min(40, (pct / 2.5) as u32);
score += additional;
if additional > 20 || threat_type.is_none() {
threat_type = Some("suspicious_link".into());
threat_details = Some(format!(
"HTML contains {} suspicious links out of {} total links",
suspicious_count,
links.len()
));
}
}
}
}
// ── Attachment filename scanning ──────────────────────────────────
for name in attachment_names {
let lower = name.to_lowercase();
scanned.push(format!("attachment:{}", lower));
// Check executable extensions
for ext in EXECUTABLE_EXTENSIONS.iter() {
if lower.ends_with(ext) {
score += 70;
threat_type = Some("executable".into());
threat_details = Some(format!(
"Attachment has a potentially dangerous extension: {}",
name
));
break;
}
}
// Check macro document extensions
for ext in MACRO_DOCUMENT_EXTENSIONS.iter() {
if lower.ends_with(ext) {
// Flag macro-capable documents (lower score than executables)
score += 20;
if threat_type.is_none() {
threat_type = Some("malicious_macro".into());
threat_details = Some(format!(
"Attachment is a macro-capable document: {}",
name
));
}
break;
}
}
}
ContentScanResult {
threat_score: score,
threat_type,
threat_details,
scanned_elements: scanned,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_content() {
let result = scan_content(
Some("Project Update"),
Some("The project is on track."),
None,
&[],
);
assert_eq!(result.threat_score, 0);
assert!(result.threat_type.is_none());
}
#[test]
fn test_phishing_subject() {
let result = scan_content(
Some("URGENT: Verify your bank account details immediately"),
None,
None,
&[],
);
assert!(result.threat_score >= 25);
assert_eq!(result.threat_type.as_deref(), Some("phishing"));
}
#[test]
fn test_spam_body() {
let result = scan_content(
None,
Some("Win a million dollars in the lottery winner contest!"),
None,
&[],
);
assert!(result.threat_score >= 15);
assert_eq!(result.threat_type.as_deref(), Some("spam"));
}
#[test]
fn test_suspicious_links() {
let result = scan_content(
None,
Some("Check out https://bit.ly/2x3F5 for more info"),
None,
&[],
);
assert!(result.threat_score >= 20);
assert_eq!(result.threat_type.as_deref(), Some("suspicious_link"));
}
#[test]
fn test_script_injection() {
let result = scan_content(
None,
None,
Some("<p>Hello</p><script>document.cookie='steal';</script>"),
&[],
);
assert!(result.threat_score >= 40);
assert_eq!(result.threat_type.as_deref(), Some("xss"));
}
#[test]
fn test_executable_attachment() {
let result = scan_content(
None,
None,
None,
&["update.exe".into()],
);
assert!(result.threat_score >= 70);
assert_eq!(result.threat_type.as_deref(), Some("executable"));
}
#[test]
fn test_macro_document() {
let result = scan_content(
None,
None,
None,
&["report.docm".into()],
);
assert!(result.threat_score >= 20);
assert_eq!(result.threat_type.as_deref(), Some("malicious_macro"));
}
#[test]
fn test_malware_indicators() {
let result = scan_content(
None,
Some("Please enable macros to view this document properly."),
None,
&[],
);
assert!(result.threat_score >= 30);
assert_eq!(result.threat_type.as_deref(), Some("malware"));
}
#[test]
fn test_html_link_extraction() {
let result = scan_content(
None,
None,
Some(r#"<a href="https://bit.ly/abc">click</a> and <a href="https://t.co/xyz">here</a>"#),
&[],
);
assert!(result.threat_score > 0);
}
#[test]
fn test_compound_threats() {
let result = scan_content(
Some("URGENT: Verify your account details immediately"),
Some("Your account will be suspended unless you verify at https://bit.ly/2x3F5"),
Some(r#"<a href="https://bit.ly/2x3F5">verify</a>"#),
&["verification.exe".into()],
);
assert!(result.threat_score > 70);
}
}

View File

@@ -0,0 +1,152 @@
use mail_auth::common::crypto::{RsaKey, Sha256};
use mail_auth::common::headers::HeaderWriter;
use mail_auth::dkim::{Canonicalization, DkimSigner};
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// Result of DKIM verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DkimVerificationResult {
/// Whether the DKIM signature is valid.
pub is_valid: bool,
/// The signing domain (d= tag).
pub domain: Option<String>,
/// The selector (s= tag).
pub selector: Option<String>,
/// Result status: "pass", "fail", "permerror", "temperror", "none".
pub status: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Convert raw `mail-auth` DKIM outputs to our serializable results.
///
/// This is used internally by `verify_dkim` and by the compound `verify_email_security`.
pub fn dkim_outputs_to_results(dkim_outputs: &[DkimOutput<'_>]) -> Vec<DkimVerificationResult> {
if dkim_outputs.is_empty() {
return vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures found".to_string()),
}];
}
dkim_outputs
.iter()
.map(|output| {
let (is_valid, status, details) = match output.result() {
DkimResult::Pass => (true, "pass", None),
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
DkimResult::None => (false, "none", None),
};
let (domain, selector) = output
.signature()
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
.unwrap_or((None, None));
DkimVerificationResult {
is_valid,
domain,
selector,
status: status.to_string(),
details,
}
})
.collect()
}
/// Verify DKIM signatures on a raw email message.
///
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
/// including DNS lookups for the public key.
pub async fn verify_dkim(
raw_message: &[u8],
authenticator: &MessageAuthenticator,
) -> Result<Vec<DkimVerificationResult>> {
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?;
let dkim_outputs = authenticator.verify_dkim(&message).await;
Ok(dkim_outputs_to_results(&dkim_outputs))
}
/// Sign a raw email message with DKIM (RSA-SHA256).
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `domain` - The signing domain (d= tag)
/// * `selector` - The DKIM selector (s= tag)
/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8)
///
/// Returns the DKIM-Signature header string to prepend to the message.
pub fn sign_dkim(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pem: &str,
) -> Result<String> {
// Try PKCS#1 PEM first, then PKCS#8
let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs1)
.or_else(|_| {
// Try PKCS#8
rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs8)
})
.map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?;
let rsa_key = RsaKey::<Sha256>::from_key_der(key_der)
.map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?;
let signature = DkimSigner::from_key(rsa_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!("DKIM signing failed: {}", e)))?;
Ok(signature.to_header())
}
/// Generate a DKIM DNS TXT record value for a given public key.
///
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
// Extract the base64 content from PEM
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dkim_dns_record_value() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
let record = dkim_dns_record_value(pem);
assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p="));
assert!(record.contains("MIIBIjANBg=="));
}
#[test]
fn test_sign_dkim_invalid_key() {
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,127 @@
use mail_auth::dmarc::verify::DmarcParameters;
use mail_auth::dmarc::Policy;
use mail_auth::{
AuthenticatedMessage, DkimOutput, DmarcResult as MailAuthDmarcResult, MessageAuthenticator,
SpfOutput,
};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// DMARC policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DmarcPolicy {
None,
Quarantine,
Reject,
}
impl From<Policy> for DmarcPolicy {
fn from(p: Policy) -> Self {
match p {
Policy::None | Policy::Unspecified => DmarcPolicy::None,
Policy::Quarantine => DmarcPolicy::Quarantine,
Policy::Reject => DmarcPolicy::Reject,
}
}
}
/// DMARC verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DmarcResult {
/// Whether DMARC verification passed overall.
pub passed: bool,
/// The evaluated policy.
pub policy: DmarcPolicy,
/// The domain that was checked.
pub domain: String,
/// DKIM alignment result: "pass", "fail", etc.
pub dkim_result: String,
/// SPF alignment result: "pass", "fail", etc.
pub spf_result: String,
/// Recommended action: "pass", "quarantine", "reject".
pub action: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Check DMARC for an email, given prior DKIM and SPF results.
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `dkim_output` - DKIM verification results from `verify_dkim`
/// * `spf_output` - SPF verification output from `check_spf`
/// * `mail_from_domain` - The MAIL FROM domain (RFC 5321)
/// * `authenticator` - The MessageAuthenticator for DNS lookups
pub async fn check_dmarc<'x>(
raw_message: &'x [u8],
dkim_output: &'x [DkimOutput<'x>],
spf_output: &'x SpfOutput,
mail_from_domain: &'x str,
authenticator: &MessageAuthenticator,
) -> Result<DmarcResult> {
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DMARC check".into()))?;
let dmarc_output = authenticator
.verify_dmarc(
DmarcParameters::new(&message, dkim_output, mail_from_domain, spf_output)
.with_domain_suffix_fn(|domain| psl::domain_str(domain).unwrap_or(domain)),
)
.await;
let policy = DmarcPolicy::from(dmarc_output.policy());
let domain = dmarc_output.domain().to_string();
let dkim_result_str = dmarc_result_to_string(dmarc_output.dkim_result());
let spf_result_str = dmarc_result_to_string(dmarc_output.spf_result());
let dkim_passed = matches!(dmarc_output.dkim_result(), MailAuthDmarcResult::Pass);
let spf_passed = matches!(dmarc_output.spf_result(), MailAuthDmarcResult::Pass);
let passed = dkim_passed || spf_passed;
let action = if passed {
"pass".to_string()
} else {
match policy {
DmarcPolicy::None => "pass".to_string(), // p=none means monitor only
DmarcPolicy::Quarantine => "quarantine".to_string(),
DmarcPolicy::Reject => "reject".to_string(),
}
};
Ok(DmarcResult {
passed,
policy,
domain,
dkim_result: dkim_result_str,
spf_result: spf_result_str,
action,
details: None,
})
}
fn dmarc_result_to_string(result: &MailAuthDmarcResult) -> String {
match result {
MailAuthDmarcResult::Pass => "pass".to_string(),
MailAuthDmarcResult::Fail(err) => format!("fail: {}", err),
MailAuthDmarcResult::TempError(err) => format!("temperror: {}", err),
MailAuthDmarcResult::PermError(err) => format!("permerror: {}", err),
MailAuthDmarcResult::None => "none".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dmarc_policy_from() {
assert_eq!(DmarcPolicy::from(Policy::None), DmarcPolicy::None);
assert_eq!(
DmarcPolicy::from(Policy::Quarantine),
DmarcPolicy::Quarantine
);
assert_eq!(DmarcPolicy::from(Policy::Reject), DmarcPolicy::Reject);
}
}

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Security-related error types.
#[derive(Debug, Error)]
pub enum SecurityError {
#[error("DKIM error: {0}")]
Dkim(String),
#[error("SPF error: {0}")]
Spf(String),
#[error("DMARC error: {0}")]
Dmarc(String),
#[error("DNS resolution error: {0}")]
Dns(String),
#[error("key error: {0}")]
Key(String),
#[error("IP reputation error: {0}")]
IpReputation(String),
#[error("parse error: {0}")]
Parse(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, SecurityError>;

View File

@@ -0,0 +1,280 @@
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use crate::error::Result;
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
"zen.spamhaus.org",
"bl.spamcop.net",
"b.barracudacentral.org",
"spam.dnsbl.sorbs.net",
"dnsbl.sorbs.net",
"cbl.abuseat.org",
"xbl.spamhaus.org",
"pbl.spamhaus.org",
"dnsbl-1.uceprotect.net",
"psbl.surriel.com",
];
/// Result of a DNSBL check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsblResult {
/// IP address that was checked.
pub ip: String,
/// Number of DNSBL servers that list this IP.
pub listed_count: usize,
/// Names of DNSBL servers that list this IP.
pub listed_on: Vec<String>,
/// Total number of DNSBL servers checked.
pub total_checked: usize,
}
/// Result of a full IP reputation check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReputationResult {
/// Reputation score: 0 (worst) to 100 (best).
pub score: u8,
/// Whether the IP is considered spam source.
pub is_spam: bool,
/// IP address that was checked.
pub ip: String,
/// DNSBL results.
pub dnsbl: DnsblResult,
/// Heuristic IP type classification.
pub ip_type: IpType,
}
/// Heuristic IP type classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IpType {
Residential,
Datacenter,
Proxy,
Tor,
Vpn,
Unknown,
}
/// Risk level based on reputation score.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
/// Score < 20
High,
/// Score 20-49
Medium,
/// Score 50-79
Low,
/// Score >= 80
Trusted,
}
/// Get the risk level for a reputation score.
pub fn risk_level(score: u8) -> RiskLevel {
match score {
0..=19 => RiskLevel::High,
20..=49 => RiskLevel::Medium,
50..=79 => RiskLevel::Low,
_ => RiskLevel::Trusted,
}
}
/// Check an IP against DNSBL servers.
///
/// * `ip` - The IP address to check (must be IPv4)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use
pub async fn check_dnsbl(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<DnsblResult> {
let ipv4 = match ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => {
// IPv6 DNSBL is less common; return clean result
return Ok(DnsblResult {
ip: ip.to_string(),
listed_count: 0,
listed_on: Vec::new(),
total_checked: 0,
});
}
};
let reversed = reverse_ipv4(ipv4);
let total = dnsbl_servers.len();
// Query all DNSBL servers in parallel
let mut handles = Vec::with_capacity(total);
for &server in dnsbl_servers {
let query = format!("{}.{}", reversed, server);
let resolver = resolver.clone();
let server_name = server.to_string();
handles.push(tokio::spawn(async move {
match resolver.lookup_ip(&query).await {
Ok(_) => Some(server_name), // IP is listed
Err(_) => None, // IP is not listed (NXDOMAIN)
}
}));
}
let mut listed_on = Vec::new();
for handle in handles {
match handle.await {
Ok(Some(server)) => listed_on.push(server),
_ => {}
}
}
Ok(DnsblResult {
ip: ip.to_string(),
listed_count: listed_on.len(),
listed_on,
total_checked: total,
})
}
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
pub async fn check_reputation(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<ReputationResult> {
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
let ip_type = classify_ip(ip);
// Scoring: start at 100
let mut score: i16 = 100;
// Subtract 10 per DNSBL listing
score -= (dnsbl.listed_count as i16) * 10;
// Subtract 30 for suspicious IP types
match ip_type {
IpType::Proxy | IpType::Tor | IpType::Vpn => {
score -= 30;
}
_ => {}
}
let score = score.clamp(0, 100) as u8;
let is_spam = score < 50;
Ok(ReputationResult {
score,
is_spam,
ip: ip.to_string(),
dnsbl,
ip_type,
})
}
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
fn reverse_ipv4(ip: Ipv4Addr) -> String {
let octets = ip.octets();
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
}
/// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType {
let ip_str = ip.to_string();
// Known Tor exit node prefixes
if ip_str.starts_with("171.25.")
|| ip_str.starts_with("185.220.")
|| ip_str.starts_with("95.216.")
{
return IpType::Tor;
}
// Known VPN provider prefixes
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
return IpType::Vpn;
}
// Known proxy prefixes
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
return IpType::Proxy;
}
// Major cloud provider prefixes (datacenter)
if ip_str.starts_with("13.")
|| ip_str.starts_with("35.")
|| ip_str.starts_with("52.")
|| ip_str.starts_with("34.")
|| ip_str.starts_with("104.")
{
return IpType::Datacenter;
}
IpType::Residential
}
/// Validate an IPv4 address string.
pub fn is_valid_ipv4(ip: &str) -> bool {
ip.parse::<Ipv4Addr>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reverse_ipv4() {
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
}
#[test]
fn test_classify_ip() {
assert_eq!(
classify_ip("171.25.193.20".parse().unwrap()),
IpType::Tor
);
assert_eq!(
classify_ip("185.156.73.1".parse().unwrap()),
IpType::Vpn
);
assert_eq!(
classify_ip("34.92.1.1".parse().unwrap()),
IpType::Proxy
);
assert_eq!(
classify_ip("52.0.0.1".parse().unwrap()),
IpType::Datacenter
);
assert_eq!(
classify_ip("203.0.113.1".parse().unwrap()),
IpType::Residential
);
}
#[test]
fn test_risk_level() {
assert_eq!(risk_level(10), RiskLevel::High);
assert_eq!(risk_level(30), RiskLevel::Medium);
assert_eq!(risk_level(60), RiskLevel::Low);
assert_eq!(risk_level(90), RiskLevel::Trusted);
}
#[test]
fn test_is_valid_ipv4() {
assert!(is_valid_ipv4("1.2.3.4"));
assert!(is_valid_ipv4("255.255.255.255"));
assert!(!is_valid_ipv4("999.999.999.999"));
assert!(!is_valid_ipv4("not-an-ip"));
}
#[test]
fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
}
}

View File

@@ -0,0 +1,45 @@
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
pub mod content_scanner;
pub mod dkim;
pub mod dmarc;
pub mod error;
pub mod ip_reputation;
pub mod spf;
pub mod verify;
// Re-exports for convenience
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use verify::{verify_email_security, EmailSecurityResult};
pub use error::{Result, SecurityError};
pub use ip_reputation::{
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,
DEFAULT_DNSBL_SERVERS,
};
pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult};
// Re-export mail-auth's MessageAuthenticator for callers to construct
pub use mail_auth::MessageAuthenticator;
pub use mailer_core;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Create a MessageAuthenticator using Cloudflare DNS over TLS.
pub fn default_authenticator() -> std::result::Result<MessageAuthenticator, Box<dyn std::error::Error>> {
Ok(MessageAuthenticator::new_cloudflare_tls()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

View File

@@ -0,0 +1,167 @@
use mail_auth::spf::verify::SpfParameters;
use mail_auth::{MessageAuthenticator, SpfResult as MailAuthSpfResult};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::error::Result;
/// SPF verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpfResult {
/// The SPF result: "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none".
pub result: String,
/// The domain that was checked.
pub domain: String,
/// The IP address that was checked.
pub ip: String,
/// Optional explanation string from the SPF record.
pub explanation: Option<String>,
}
impl SpfResult {
/// Whether the SPF check passed.
pub fn passed(&self) -> bool {
self.result == "pass"
}
/// Create an `SpfResult` from a raw `mail-auth` `SpfOutput`.
///
/// Used by the compound `verify_email_security` to avoid re-doing the SPF query.
pub fn from_output(output: &mail_auth::SpfOutput, ip: IpAddr) -> Self {
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
SpfResult {
result: result_str.to_string(),
domain: output.domain().to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
}
}
}
/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address.
///
/// * `ip` - The connecting client's IP address
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
/// * `host_domain` - Your receiving server's hostname
/// * `mail_from` - The full MAIL FROM address (e.g., "sender@example.com")
pub async fn check_spf(
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
mail_from: &str,
authenticator: &MessageAuthenticator,
) -> Result<SpfResult> {
let output = authenticator
.verify_spf(SpfParameters::verify_mail_from(
ip,
helo_domain,
host_domain,
mail_from,
))
.await;
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
Ok(SpfResult {
result: result_str.to_string(),
domain: output.domain().to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
})
}
/// Check SPF for the EHLO identity (before MAIL FROM).
pub async fn check_spf_ehlo(
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
authenticator: &MessageAuthenticator,
) -> Result<SpfResult> {
let output = authenticator
.verify_spf(SpfParameters::verify_ehlo(ip, helo_domain, host_domain))
.await;
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
Ok(SpfResult {
result: result_str.to_string(),
domain: helo_domain.to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
})
}
/// Build a Received-SPF header value.
pub fn received_spf_header(result: &SpfResult) -> String {
format!(
"{} (domain of {} designates {} as permitted sender) receiver={}; client-ip={};",
result.result,
result.domain,
result.ip,
result.domain,
result.ip,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spf_result_passed() {
let result = SpfResult {
result: "pass".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
assert!(result.passed());
let result = SpfResult {
result: "fail".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
assert!(!result.passed());
}
#[test]
fn test_received_spf_header() {
let result = SpfResult {
result: "pass".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
let header = received_spf_header(&result);
assert!(header.contains("pass"));
assert!(header.contains("example.com"));
assert!(header.contains("1.2.3.4"));
}
}

View File

@@ -0,0 +1,115 @@
//! Compound email security verification.
//!
//! Runs DKIM, SPF, and DMARC verification in a single call, avoiding multiple
//! IPC round-trips and handling the internal `mail-auth` types that DMARC needs.
use mail_auth::spf::verify::SpfParameters;
use mail_auth::{AuthenticatedMessage, MessageAuthenticator};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::dkim::DkimVerificationResult;
use crate::dmarc::{check_dmarc, DmarcResult};
use crate::error::{Result, SecurityError};
use crate::spf::SpfResult;
/// Combined result of DKIM + SPF + DMARC verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSecurityResult {
pub dkim: Vec<DkimVerificationResult>,
pub spf: Option<SpfResult>,
pub dmarc: Option<DmarcResult>,
}
/// Run all email security checks (DKIM, SPF, DMARC) in one call.
///
/// This is the preferred entry point for inbound email verification because:
/// 1. DMARC requires raw `mail-auth` DKIM/SPF outputs (not our serialized types).
/// 2. A single call avoids 3 sequential IPC round-trips.
///
/// # Arguments
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `ip` - The connecting client's IP address
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
/// * `host_domain` - Your receiving server's hostname
/// * `mail_from` - The full MAIL FROM address (e.g. "sender@example.com")
/// * `authenticator` - The `MessageAuthenticator` for DNS lookups
pub async fn verify_email_security(
raw_message: &[u8],
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
mail_from: &str,
authenticator: &MessageAuthenticator,
) -> Result<EmailSecurityResult> {
// Parse the message once for all checks
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email message".into()))?;
// --- DKIM verification ---
let dkim_outputs = authenticator.verify_dkim(&message).await;
let dkim_results = crate::dkim::dkim_outputs_to_results(&dkim_outputs);
// --- SPF verification ---
let spf_output = authenticator
.verify_spf(SpfParameters::verify_mail_from(
ip,
helo_domain,
host_domain,
mail_from,
))
.await;
let spf_result = SpfResult::from_output(&spf_output, ip);
// --- DMARC verification (needs raw dkim_outputs + spf_output) ---
let mail_from_domain = mail_from
.rsplit_once('@')
.map(|(_, d)| d)
.unwrap_or(helo_domain);
let dmarc_result = check_dmarc(
raw_message,
&dkim_outputs,
&spf_output,
mail_from_domain,
authenticator,
)
.await
.ok(); // DMARC failure is non-fatal; we still return DKIM + SPF results
Ok(EmailSecurityResult {
dkim: dkim_results,
spf: Some(spf_result),
dmarc: dmarc_result,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_security_result_serialization() {
let result = EmailSecurityResult {
dkim: vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures".to_string()),
}],
spf: Some(SpfResult {
result: "none".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
}),
dmarc: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"dkim\""));
assert!(json.contains("\"spf\""));
assert!(json.contains("\"dmarc\":null"));
}
}

View File

@@ -0,0 +1,25 @@
[package]
name = "mailer-smtp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tokio-rustls.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
serde.workspace = true
serde_json = "1"
regex = "1"
uuid = { version = "1", features = ["v4"] }
base64.workspace = true
rustls-pki-types.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
mailparse.workspace = true

View File

@@ -0,0 +1,421 @@
//! SMTP command parser.
//!
//! Parses raw SMTP command lines into structured `SmtpCommand` variants.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A parsed SMTP command.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SmtpCommand {
/// EHLO with client hostname/IP
Ehlo(String),
/// HELO with client hostname/IP
Helo(String),
/// MAIL FROM with sender address and optional parameters (e.g. SIZE=12345)
MailFrom {
address: String,
params: HashMap<String, Option<String>>,
},
/// RCPT TO with recipient address and optional parameters
RcptTo {
address: String,
params: HashMap<String, Option<String>>,
},
/// DATA command — begin message body
Data,
/// RSET — reset current transaction
Rset,
/// NOOP — no operation
Noop,
/// QUIT — close connection
Quit,
/// STARTTLS — upgrade to TLS
StartTls,
/// AUTH with mechanism and optional initial response
Auth {
mechanism: AuthMechanism,
initial_response: Option<String>,
},
/// HELP with optional topic
Help(Option<String>),
/// VRFY with address or username
Vrfy(String),
/// EXPN with mailing list name
Expn(String),
}
/// Supported AUTH mechanisms.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AuthMechanism {
Plain,
Login,
}
/// Errors that can occur during command parsing.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ParseError {
#[error("empty command line")]
Empty,
#[error("unrecognized command: {0}")]
UnrecognizedCommand(String),
#[error("syntax error in parameters: {0}")]
SyntaxError(String),
#[error("missing required argument for {0}")]
MissingArgument(String),
}
/// Parse a raw SMTP command line (without trailing CRLF) into a `SmtpCommand`.
pub fn parse_command(line: &str) -> Result<SmtpCommand, ParseError> {
let line = line.trim_end_matches('\r').trim_end_matches('\n');
if line.is_empty() {
return Err(ParseError::Empty);
}
// Split into verb and the rest
let (verb, rest) = split_first_word(line);
let verb_upper = verb.to_ascii_uppercase();
match verb_upper.as_str() {
"EHLO" => {
let hostname = rest.trim();
if hostname.is_empty() {
return Err(ParseError::MissingArgument("EHLO".into()));
}
Ok(SmtpCommand::Ehlo(hostname.to_string()))
}
"HELO" => {
let hostname = rest.trim();
if hostname.is_empty() {
return Err(ParseError::MissingArgument("HELO".into()));
}
Ok(SmtpCommand::Helo(hostname.to_string()))
}
"MAIL" => parse_mail_from(rest),
"RCPT" => parse_rcpt_to(rest),
"DATA" => Ok(SmtpCommand::Data),
"RSET" => Ok(SmtpCommand::Rset),
"NOOP" => Ok(SmtpCommand::Noop),
"QUIT" => Ok(SmtpCommand::Quit),
"STARTTLS" => Ok(SmtpCommand::StartTls),
"AUTH" => parse_auth(rest),
"HELP" => {
let topic = rest.trim();
if topic.is_empty() {
Ok(SmtpCommand::Help(None))
} else {
Ok(SmtpCommand::Help(Some(topic.to_string())))
}
}
"VRFY" => {
let arg = rest.trim();
if arg.is_empty() {
return Err(ParseError::MissingArgument("VRFY".into()));
}
Ok(SmtpCommand::Vrfy(arg.to_string()))
}
"EXPN" => {
let arg = rest.trim();
if arg.is_empty() {
return Err(ParseError::MissingArgument("EXPN".into()));
}
Ok(SmtpCommand::Expn(arg.to_string()))
}
_ => Err(ParseError::UnrecognizedCommand(verb_upper)),
}
}
/// Parse `FROM:<addr> [PARAM=VALUE ...]` after "MAIL".
fn parse_mail_from(rest: &str) -> Result<SmtpCommand, ParseError> {
// Expect "FROM:" prefix (case-insensitive, whitespace-flexible)
let rest = rest.trim_start();
let rest_upper = rest.to_ascii_uppercase();
if !rest_upper.starts_with("FROM") {
return Err(ParseError::SyntaxError(
"expected FROM after MAIL".into(),
));
}
let rest = &rest[4..]; // skip "FROM"
let rest = rest.trim_start();
if !rest.starts_with(':') {
return Err(ParseError::SyntaxError(
"expected colon after MAIL FROM".into(),
));
}
let rest = &rest[1..]; // skip ':'
let rest = rest.trim_start();
parse_address_and_params(rest, "MAIL FROM").map(|(address, params)| SmtpCommand::MailFrom {
address,
params,
})
}
/// Parse `TO:<addr> [PARAM=VALUE ...]` after "RCPT".
fn parse_rcpt_to(rest: &str) -> Result<SmtpCommand, ParseError> {
let rest = rest.trim_start();
let rest_upper = rest.to_ascii_uppercase();
if !rest_upper.starts_with("TO") {
return Err(ParseError::SyntaxError("expected TO after RCPT".into()));
}
let rest = &rest[2..]; // skip "TO"
let rest = rest.trim_start();
if !rest.starts_with(':') {
return Err(ParseError::SyntaxError(
"expected colon after RCPT TO".into(),
));
}
let rest = &rest[1..]; // skip ':'
let rest = rest.trim_start();
parse_address_and_params(rest, "RCPT TO").map(|(address, params)| SmtpCommand::RcptTo {
address,
params,
})
}
/// Parse `<address> [PARAM=VALUE ...]` from the rest of a MAIL FROM or RCPT TO line.
fn parse_address_and_params(
input: &str,
context: &str,
) -> Result<(String, HashMap<String, Option<String>>), ParseError> {
if !input.starts_with('<') {
return Err(ParseError::SyntaxError(format!(
"expected '<' in {context}"
)));
}
let close_bracket = input.find('>').ok_or_else(|| {
ParseError::SyntaxError(format!("missing '>' in {context}"))
})?;
let address = input[1..close_bracket].to_string();
let remainder = &input[close_bracket + 1..];
let params = parse_params(remainder)?;
Ok((address, params))
}
/// Parse SMTP extension parameters like `SIZE=12345 BODY=8BITMIME`.
fn parse_params(input: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
let mut params = HashMap::new();
for token in input.split_whitespace() {
if let Some(eq_pos) = token.find('=') {
let key = token[..eq_pos].to_ascii_uppercase();
let value = token[eq_pos + 1..].to_string();
params.insert(key, Some(value));
} else {
params.insert(token.to_ascii_uppercase(), None);
}
}
Ok(params)
}
/// Parse AUTH command: `AUTH <mechanism> [initial-response]`.
fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
let rest = rest.trim();
if rest.is_empty() {
return Err(ParseError::MissingArgument("AUTH".into()));
}
let (mech_str, initial) = split_first_word(rest);
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
"PLAIN" => AuthMechanism::Plain,
"LOGIN" => AuthMechanism::Login,
other => {
return Err(ParseError::SyntaxError(format!(
"unsupported AUTH mechanism: {other}"
)));
}
};
let initial_response = {
let s = initial.trim();
if s.is_empty() { None } else { Some(s.to_string()) }
};
Ok(SmtpCommand::Auth {
mechanism,
initial_response,
})
}
/// Split a string into the first whitespace-delimited word and the remainder.
fn split_first_word(s: &str) -> (&str, &str) {
match s.find(char::is_whitespace) {
Some(pos) => (&s[..pos], &s[pos + 1..]),
None => (s, ""),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ehlo() {
let cmd = parse_command("EHLO mail.example.com").unwrap();
assert_eq!(cmd, SmtpCommand::Ehlo("mail.example.com".into()));
}
#[test]
fn test_ehlo_case_insensitive() {
let cmd = parse_command("ehlo MAIL.EXAMPLE.COM").unwrap();
assert_eq!(cmd, SmtpCommand::Ehlo("MAIL.EXAMPLE.COM".into()));
}
#[test]
fn test_helo() {
let cmd = parse_command("HELO example.com").unwrap();
assert_eq!(cmd, SmtpCommand::Helo("example.com".into()));
}
#[test]
fn test_ehlo_missing_arg() {
let err = parse_command("EHLO").unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn test_mail_from() {
let cmd = parse_command("MAIL FROM:<sender@example.com>").unwrap();
assert_eq!(
cmd,
SmtpCommand::MailFrom {
address: "sender@example.com".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_mail_from_with_params() {
let cmd = parse_command("MAIL FROM:<sender@example.com> SIZE=12345 BODY=8BITMIME").unwrap();
if let SmtpCommand::MailFrom { address, params } = cmd {
assert_eq!(address, "sender@example.com");
assert_eq!(params.get("SIZE"), Some(&Some("12345".into())));
assert_eq!(params.get("BODY"), Some(&Some("8BITMIME".into())));
} else {
panic!("expected MailFrom");
}
}
#[test]
fn test_mail_from_empty_address() {
let cmd = parse_command("MAIL FROM:<>").unwrap();
assert_eq!(
cmd,
SmtpCommand::MailFrom {
address: "".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_mail_from_flexible_spacing() {
let cmd = parse_command("MAIL FROM: <user@example.com>").unwrap();
if let SmtpCommand::MailFrom { address, .. } = cmd {
assert_eq!(address, "user@example.com");
} else {
panic!("expected MailFrom");
}
}
#[test]
fn test_rcpt_to() {
let cmd = parse_command("RCPT TO:<recipient@example.com>").unwrap();
assert_eq!(
cmd,
SmtpCommand::RcptTo {
address: "recipient@example.com".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_data() {
assert_eq!(parse_command("DATA").unwrap(), SmtpCommand::Data);
}
#[test]
fn test_rset() {
assert_eq!(parse_command("RSET").unwrap(), SmtpCommand::Rset);
}
#[test]
fn test_noop() {
assert_eq!(parse_command("NOOP").unwrap(), SmtpCommand::Noop);
}
#[test]
fn test_quit() {
assert_eq!(parse_command("QUIT").unwrap(), SmtpCommand::Quit);
}
#[test]
fn test_starttls() {
assert_eq!(parse_command("STARTTLS").unwrap(), SmtpCommand::StartTls);
}
#[test]
fn test_auth_plain() {
let cmd = parse_command("AUTH PLAIN dGVzdAB0ZXN0AHBhc3N3b3Jk").unwrap();
assert_eq!(
cmd,
SmtpCommand::Auth {
mechanism: AuthMechanism::Plain,
initial_response: Some("dGVzdAB0ZXN0AHBhc3N3b3Jk".into()),
}
);
}
#[test]
fn test_auth_login_no_initial() {
let cmd = parse_command("AUTH LOGIN").unwrap();
assert_eq!(
cmd,
SmtpCommand::Auth {
mechanism: AuthMechanism::Login,
initial_response: None,
}
);
}
#[test]
fn test_help() {
assert_eq!(parse_command("HELP").unwrap(), SmtpCommand::Help(None));
assert_eq!(
parse_command("HELP MAIL").unwrap(),
SmtpCommand::Help(Some("MAIL".into()))
);
}
#[test]
fn test_vrfy() {
assert_eq!(
parse_command("VRFY user@example.com").unwrap(),
SmtpCommand::Vrfy("user@example.com".into())
);
}
#[test]
fn test_expn() {
assert_eq!(
parse_command("EXPN staff").unwrap(),
SmtpCommand::Expn("staff".into())
);
}
#[test]
fn test_empty() {
assert!(matches!(parse_command(""), Err(ParseError::Empty)));
}
#[test]
fn test_unrecognized() {
let err = parse_command("FOOBAR test").unwrap_err();
assert!(matches!(err, ParseError::UnrecognizedCommand(_)));
}
#[test]
fn test_crlf_stripped() {
let cmd = parse_command("QUIT\r\n").unwrap();
assert_eq!(cmd, SmtpCommand::Quit);
}
}

View File

@@ -0,0 +1,86 @@
//! SMTP server configuration.
use serde::{Deserialize, Serialize};
/// Configuration for an SMTP server instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpServerConfig {
/// Server hostname for greeting and EHLO responses.
pub hostname: String,
/// Ports to listen on (e.g. [25, 587]).
pub ports: Vec<u16>,
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
pub secure_port: Option<u16>,
/// TLS certificate chain in PEM format.
pub tls_cert_pem: Option<String>,
/// TLS private key in PEM format.
pub tls_key_pem: Option<String>,
/// Maximum message size in bytes.
pub max_message_size: u64,
/// Maximum number of concurrent connections.
pub max_connections: u32,
/// Maximum recipients per message.
pub max_recipients: u32,
/// Connection timeout in seconds.
pub connection_timeout_secs: u64,
/// Data phase timeout in seconds.
pub data_timeout_secs: u64,
/// Whether authentication is available.
pub auth_enabled: bool,
/// Maximum authentication failures before disconnect.
pub max_auth_failures: u32,
/// Socket timeout in seconds (idle timeout for the entire connection).
pub socket_timeout_secs: u64,
/// Timeout in seconds waiting for TS to respond to email processing.
pub processing_timeout_secs: u64,
}
impl Default for SmtpServerConfig {
fn default() -> Self {
Self {
hostname: "mail.example.com".to_string(),
ports: vec![25],
secure_port: None,
tls_cert_pem: None,
tls_key_pem: None,
max_message_size: 10 * 1024 * 1024, // 10 MB
max_connections: 100,
max_recipients: 100,
connection_timeout_secs: 30,
data_timeout_secs: 60,
auth_enabled: false,
max_auth_failures: 3,
socket_timeout_secs: 300,
processing_timeout_secs: 30,
}
}
}
impl SmtpServerConfig {
/// Check if TLS is configured.
pub fn has_tls(&self) -> bool {
self.tls_cert_pem.is_some() && self.tls_key_pem.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults() {
let cfg = SmtpServerConfig::default();
assert_eq!(cfg.max_message_size, 10 * 1024 * 1024);
assert_eq!(cfg.max_connections, 100);
assert!(!cfg.has_tls());
}
#[test]
fn test_has_tls() {
let mut cfg = SmtpServerConfig::default();
cfg.tls_cert_pem = Some("cert".into());
assert!(!cfg.has_tls()); // need both
cfg.tls_key_pem = Some("key".into());
assert!(cfg.has_tls());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
//! Email DATA phase processor.
//!
//! Handles dot-unstuffing, end-of-data detection, size enforcement,
//! and streaming accumulation of email data.
/// Result of processing a chunk of DATA input.
#[derive(Debug, Clone, PartialEq)]
pub enum DataAction {
/// More data needed — continue accumulating.
Continue,
/// End-of-data detected. The complete message body is ready.
Complete,
/// Message size limit exceeded.
SizeExceeded,
}
/// Streaming email data accumulator.
///
/// Processes incoming bytes from the DATA phase, handling:
/// - CRLF line ending normalization
/// - Dot-unstuffing (RFC 5321 §4.5.2)
/// - End-of-data marker detection (`<CRLF>.<CRLF>`)
/// - Size enforcement
pub struct DataAccumulator {
/// Accumulated message bytes.
buffer: Vec<u8>,
/// Maximum allowed size in bytes. 0 = unlimited.
max_size: u64,
/// Whether we've detected end-of-data.
complete: bool,
/// Whether the current position is at the start of a line.
at_line_start: bool,
/// Partial state for cross-chunk boundary handling.
partial: PartialState,
}
/// Tracks partial sequences that span chunk boundaries.
#[derive(Debug, Clone, Copy, PartialEq)]
enum PartialState {
/// No partial sequence.
None,
/// Saw `\r`, waiting for `\n`.
Cr,
/// At line start, saw `.`, waiting to determine dot-stuffing vs end-of-data.
Dot,
/// At line start, saw `.\r`, waiting for `\n` (end-of-data) or other.
DotCr,
}
impl DataAccumulator {
/// Create a new accumulator with the given size limit.
pub fn new(max_size: u64) -> Self {
Self {
buffer: Vec::with_capacity(8192),
max_size,
complete: false,
at_line_start: true, // First byte is at start of first line
partial: PartialState::None,
}
}
/// Process a chunk of incoming data.
///
/// Returns the action to take: continue, complete, or size exceeded.
pub fn process_chunk(&mut self, chunk: &[u8]) -> DataAction {
if self.complete {
return DataAction::Complete;
}
for &byte in chunk {
match self.partial {
PartialState::None => {
if self.at_line_start && byte == b'.' {
self.partial = PartialState::Dot;
} else if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
self.at_line_start = false;
}
}
PartialState::Cr => {
if byte == b'\n' {
self.buffer.extend_from_slice(b"\r\n");
self.at_line_start = true;
self.partial = PartialState::None;
} else {
// Bare CR — emit it and process current byte
self.buffer.push(b'\r');
self.at_line_start = false;
self.partial = PartialState::None;
// Re-process current byte
if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
}
}
}
PartialState::Dot => {
if byte == b'\r' {
self.partial = PartialState::DotCr;
} else if byte == b'.' {
// Dot-unstuffing: \r\n.. → \r\n.
// Emit one dot, consume the other
self.buffer.push(b'.');
self.at_line_start = false;
self.partial = PartialState::None;
} else {
// Dot at line start but not stuffing or end-of-data
self.buffer.push(b'.');
self.buffer.push(byte);
self.at_line_start = false;
self.partial = PartialState::None;
}
}
PartialState::DotCr => {
if byte == b'\n' {
// End-of-data: <CRLF>.<CRLF>
// Remove the trailing \r\n from the buffer
// (it was part of the terminator, not the message)
if self.buffer.ends_with(b"\r\n") {
let new_len = self.buffer.len() - 2;
self.buffer.truncate(new_len);
}
self.complete = true;
return DataAction::Complete;
} else {
// Not end-of-data — emit .\r and process current byte
self.buffer.push(b'.');
self.buffer.push(b'\r');
self.at_line_start = false;
self.partial = PartialState::None;
// Re-process current byte
if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
}
}
}
}
// Check size limit
if self.max_size > 0 && self.buffer.len() as u64 > self.max_size {
return DataAction::SizeExceeded;
}
}
DataAction::Continue
}
/// Consume the accumulator and return the complete message data.
///
/// Returns `None` if end-of-data has not been detected.
pub fn into_message(self) -> Option<Vec<u8>> {
if !self.complete {
return None;
}
Some(self.buffer)
}
/// Get a reference to the accumulated data so far.
pub fn data(&self) -> &[u8] {
&self.buffer
}
/// Get the current accumulated size.
pub fn size(&self) -> usize {
self.buffer.len()
}
/// Whether end-of-data has been detected.
pub fn is_complete(&self) -> bool {
self.complete
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_message() {
let mut acc = DataAccumulator::new(0);
let data = b"Subject: Test\r\n\r\nHello world\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Subject: Test\r\n\r\nHello world");
}
#[test]
fn test_dot_unstuffing() {
let mut acc = DataAccumulator::new(0);
// A line starting with ".." should become "."
let data = b"Line 1\r\n..dot-stuffed\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Line 1\r\n.dot-stuffed");
}
#[test]
fn test_multiple_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Subject: Test\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\r\nBody line 1\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"Body line 2\r\n.\r\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Subject: Test\r\n\r\nBody line 1\r\nBody line 2");
}
#[test]
fn test_end_of_data_spanning_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Body\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b".\r"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Body");
}
#[test]
fn test_size_limit() {
let mut acc = DataAccumulator::new(10);
let data = b"This is definitely more than 10 bytes\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::SizeExceeded);
}
#[test]
fn test_not_complete() {
let mut acc = DataAccumulator::new(0);
acc.process_chunk(b"partial data");
assert!(!acc.is_complete());
assert!(acc.into_message().is_none());
}
#[test]
fn test_empty_message() {
let mut acc = DataAccumulator::new(0);
let action = acc.process_chunk(b".\r\n");
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert!(msg.is_empty());
}
#[test]
fn test_dot_not_at_line_start() {
let mut acc = DataAccumulator::new(0);
let data = b"Hello.World\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Hello.World");
}
#[test]
fn test_multiple_dots_in_line() {
let mut acc = DataAccumulator::new(0);
let data = b"...\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
// First dot at line start is dot-unstuffed, leaving ".."
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"..");
}
#[test]
fn test_crlf_dot_spanning_three_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Body\r"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\n."), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\r\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Body");
}
#[test]
fn test_bare_cr() {
let mut acc = DataAccumulator::new(0);
let data = b"Hello\rWorld\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Hello\rWorld");
}
}

View File

@@ -0,0 +1,39 @@
//! mailer-smtp: SMTP protocol engine (server + client).
//!
//! This crate provides the SMTP protocol implementation including:
//! - Command parsing (`command`)
//! - State machine (`state`)
//! - Response building (`response`)
//! - Email data accumulation (`data`)
//! - Per-connection session state (`session`)
//! - Address/input validation (`validation`)
//! - Server configuration (`config`)
//! - Rate limiting (`rate_limiter`)
//! - TCP/TLS server (`server`)
//! - Connection handling (`connection`)
pub mod command;
pub mod config;
pub mod connection;
pub mod data;
pub mod rate_limiter;
pub mod response;
pub mod server;
pub mod session;
pub mod state;
pub mod validation;
pub use mailer_core;
// Re-export key types for convenience.
pub use command::{AuthMechanism, SmtpCommand};
pub use config::SmtpServerConfig;
pub use data::{DataAccumulator, DataAction};
pub use response::SmtpResponse;
pub use session::SmtpSession;
pub use state::SmtpState;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}

View File

@@ -0,0 +1,198 @@
//! In-process SMTP rate limiter.
//!
//! Uses DashMap for lock-free concurrent access to rate counters.
//! Tracks connections per IP, messages per sender, and auth failures.
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// Rate limiter configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
/// Maximum connections per IP per window.
pub max_connections_per_ip: u32,
/// Maximum messages per sender per window.
pub max_messages_per_sender: u32,
/// Maximum auth failures per IP per window.
pub max_auth_failures_per_ip: u32,
/// Window duration in seconds.
pub window_secs: u64,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_connections_per_ip: 50,
max_messages_per_sender: 100,
max_auth_failures_per_ip: 5,
window_secs: 60,
}
}
}
/// A timestamped counter entry.
struct CounterEntry {
count: u32,
window_start: Instant,
}
/// In-process rate limiter using DashMap.
pub struct RateLimiter {
config: RateLimitConfig,
window: Duration,
connections: DashMap<String, CounterEntry>,
messages: DashMap<String, CounterEntry>,
auth_failures: DashMap<String, CounterEntry>,
}
impl RateLimiter {
/// Create a new rate limiter with the given configuration.
pub fn new(config: RateLimitConfig) -> Self {
let window = Duration::from_secs(config.window_secs);
Self {
config,
window,
connections: DashMap::new(),
messages: DashMap::new(),
auth_failures: DashMap::new(),
}
}
/// Update the configuration at runtime.
pub fn update_config(&mut self, config: RateLimitConfig) {
self.window = Duration::from_secs(config.window_secs);
self.config = config;
}
/// Check and record a new connection from an IP.
/// Returns `true` if the connection should be allowed.
pub fn check_connection(&self, ip: &str) -> bool {
self.increment_and_check(
&self.connections,
ip,
self.config.max_connections_per_ip,
)
}
/// Check and record a message from a sender.
/// Returns `true` if the message should be allowed.
pub fn check_message(&self, sender: &str) -> bool {
self.increment_and_check(
&self.messages,
sender,
self.config.max_messages_per_sender,
)
}
/// Check and record an auth failure from an IP.
/// Returns `true` if more attempts should be allowed.
pub fn check_auth_failure(&self, ip: &str) -> bool {
self.increment_and_check(
&self.auth_failures,
ip,
self.config.max_auth_failures_per_ip,
)
}
/// Increment a counter and check against the limit.
/// Returns `true` if within limits.
fn increment_and_check(
&self,
map: &DashMap<String, CounterEntry>,
key: &str,
limit: u32,
) -> bool {
let now = Instant::now();
let mut entry = map
.entry(key.to_string())
.or_insert_with(|| CounterEntry {
count: 0,
window_start: now,
});
// Reset window if expired
if now.duration_since(entry.window_start) > self.window {
entry.count = 0;
entry.window_start = now;
}
entry.count += 1;
entry.count <= limit
}
/// Clean up expired entries. Call periodically.
pub fn cleanup(&self) {
let now = Instant::now();
let window = self.window;
self.connections
.retain(|_, v| now.duration_since(v.window_start) <= window);
self.messages
.retain(|_, v| now.duration_since(v.window_start) <= window);
self.auth_failures
.retain(|_, v| now.duration_since(v.window_start) <= window);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_connections_per_ip: 3,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_connection("1.2.3.4"));
assert!(limiter.check_connection("1.2.3.4"));
assert!(limiter.check_connection("1.2.3.4"));
assert!(!limiter.check_connection("1.2.3.4")); // 4th = over limit
// Different IP is independent
assert!(limiter.check_connection("5.6.7.8"));
}
#[test]
fn test_message_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_messages_per_sender: 2,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_message("sender@example.com"));
assert!(limiter.check_message("sender@example.com"));
assert!(!limiter.check_message("sender@example.com"));
}
#[test]
fn test_auth_failure_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_auth_failures_per_ip: 2,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_auth_failure("1.2.3.4"));
assert!(limiter.check_auth_failure("1.2.3.4"));
assert!(!limiter.check_auth_failure("1.2.3.4"));
}
#[test]
fn test_cleanup() {
let limiter = RateLimiter::new(RateLimitConfig {
max_connections_per_ip: 1,
window_secs: 60,
..Default::default()
});
limiter.check_connection("1.2.3.4");
assert_eq!(limiter.connections.len(), 1);
limiter.cleanup(); // entries not expired
assert_eq!(limiter.connections.len(), 1);
}
}

View File

@@ -0,0 +1,284 @@
//! SMTP response builder.
//!
//! Constructs properly formatted SMTP response lines with status codes,
//! multiline support, and EHLO capability advertisement.
use serde::{Deserialize, Serialize};
/// An SMTP response to send to the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SmtpResponse {
/// 3-digit SMTP status code.
pub code: u16,
/// Response lines (without the status code prefix).
pub lines: Vec<String>,
}
impl SmtpResponse {
/// Create a single-line response.
pub fn new(code: u16, message: impl Into<String>) -> Self {
Self {
code,
lines: vec![message.into()],
}
}
/// Create a multiline response.
pub fn multiline(code: u16, lines: Vec<String>) -> Self {
Self { code, lines }
}
/// Format the response as bytes ready to write to the socket.
///
/// Multiline responses use `code-text` for intermediate lines
/// and `code text` for the final line (RFC 5321 §4.2).
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
if self.lines.is_empty() {
buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes());
} else if self.lines.len() == 1 {
buf.extend_from_slice(
format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(),
);
} else {
for (i, line) in self.lines.iter().enumerate() {
if i < self.lines.len() - 1 {
buf.extend_from_slice(
format!("{}-{}\r\n", self.code, line).as_bytes(),
);
} else {
buf.extend_from_slice(
format!("{} {}\r\n", self.code, line).as_bytes(),
);
}
}
}
buf
}
// --- Common response constructors ---
/// 220 Service ready greeting.
pub fn greeting(hostname: &str) -> Self {
Self::new(220, format!("{hostname} ESMTP Service Ready"))
}
/// 221 Service closing.
pub fn closing(hostname: &str) -> Self {
Self::new(221, format!("{hostname} Service closing transmission channel"))
}
/// 250 OK.
pub fn ok(message: impl Into<String>) -> Self {
Self::new(250, message)
}
/// EHLO response with capabilities.
pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self {
let mut lines = Vec::with_capacity(capabilities.len() + 1);
lines.push(format!("{hostname} greets you"));
for cap in capabilities {
lines.push(cap.clone());
}
Self::multiline(250, lines)
}
/// 235 Authentication successful.
pub fn auth_success() -> Self {
Self::new(235, "2.7.0 Authentication successful")
}
/// 334 Auth challenge (base64-encoded prompt).
pub fn auth_challenge(prompt: &str) -> Self {
Self::new(334, prompt)
}
/// 354 Start mail input.
pub fn start_data() -> Self {
Self::new(354, "Start mail input; end with <CRLF>.<CRLF>")
}
/// 421 Service not available.
pub fn service_unavailable(hostname: &str, reason: &str) -> Self {
Self::new(421, format!("{hostname} {reason}"))
}
/// 450 Temporary failure.
pub fn temp_failure(message: impl Into<String>) -> Self {
Self::new(450, message)
}
/// 451 Local error.
pub fn local_error(message: impl Into<String>) -> Self {
Self::new(451, message)
}
/// 500 Syntax error.
pub fn syntax_error() -> Self {
Self::new(500, "Syntax error, command unrecognized")
}
/// 501 Syntax error in parameters.
pub fn param_error(message: impl Into<String>) -> Self {
Self::new(501, message)
}
/// 502 Command not implemented.
pub fn not_implemented() -> Self {
Self::new(502, "Command not implemented")
}
/// 503 Bad sequence.
pub fn bad_sequence(message: impl Into<String>) -> Self {
Self::new(503, message)
}
/// 530 Authentication required.
pub fn auth_required() -> Self {
Self::new(530, "5.7.0 Authentication required")
}
/// 535 Authentication failed.
pub fn auth_failed() -> Self {
Self::new(535, "5.7.8 Authentication credentials invalid")
}
/// 550 Mailbox unavailable.
pub fn mailbox_unavailable(message: impl Into<String>) -> Self {
Self::new(550, message)
}
/// 552 Message size exceeded.
pub fn size_exceeded(max_size: u64) -> Self {
Self::new(
552,
format!("5.3.4 Message size exceeds maximum of {max_size} bytes"),
)
}
/// 554 Transaction failed.
pub fn transaction_failed(message: impl Into<String>) -> Self {
Self::new(554, message)
}
/// Check if this is a success response (2xx).
pub fn is_success(&self) -> bool {
self.code >= 200 && self.code < 300
}
/// Check if this is a temporary error (4xx).
pub fn is_temp_error(&self) -> bool {
self.code >= 400 && self.code < 500
}
/// Check if this is a permanent error (5xx).
pub fn is_perm_error(&self) -> bool {
self.code >= 500 && self.code < 600
}
}
/// Build the list of EHLO capabilities for the server.
pub fn build_capabilities(
max_size: u64,
tls_available: bool,
already_secure: bool,
auth_available: bool,
) -> Vec<String> {
let mut caps = vec![
format!("SIZE {max_size}"),
"8BITMIME".to_string(),
"PIPELINING".to_string(),
"ENHANCEDSTATUSCODES".to_string(),
"HELP".to_string(),
];
// Only advertise STARTTLS if TLS is available and not already using TLS
if tls_available && !already_secure {
caps.push("STARTTLS".to_string());
}
if auth_available {
caps.push("AUTH PLAIN LOGIN".to_string());
}
caps
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_line() {
let resp = SmtpResponse::new(250, "OK");
assert_eq!(resp.to_bytes(), b"250 OK\r\n");
}
#[test]
fn test_multiline() {
let resp = SmtpResponse::multiline(
250,
vec![
"mail.example.com greets you".into(),
"SIZE 10485760".into(),
"STARTTLS".into(),
],
);
let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n";
assert_eq!(resp.to_bytes(), expected.to_vec());
}
#[test]
fn test_greeting() {
let resp = SmtpResponse::greeting("mail.example.com");
assert_eq!(resp.code, 220);
assert!(resp.lines[0].contains("mail.example.com"));
}
#[test]
fn test_ehlo_response() {
let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()];
let resp = SmtpResponse::ehlo_response("mail.example.com", &caps);
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 3); // hostname + 2 caps
}
#[test]
fn test_status_checks() {
assert!(SmtpResponse::new(250, "OK").is_success());
assert!(SmtpResponse::new(450, "Try later").is_temp_error());
assert!(SmtpResponse::new(550, "No such user").is_perm_error());
assert!(!SmtpResponse::new(250, "OK").is_temp_error());
}
#[test]
fn test_build_capabilities() {
let caps = build_capabilities(10485760, true, false, true);
assert!(caps.contains(&"SIZE 10485760".to_string()));
assert!(caps.contains(&"STARTTLS".to_string()));
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
assert!(caps.contains(&"PIPELINING".to_string()));
}
#[test]
fn test_build_capabilities_secure() {
// When already secure, STARTTLS should NOT be advertised
let caps = build_capabilities(10485760, true, true, false);
assert!(!caps.contains(&"STARTTLS".to_string()));
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
}
#[test]
fn test_empty_response() {
let resp = SmtpResponse::multiline(250, vec![]);
assert_eq!(resp.to_bytes(), b"250 \r\n");
}
#[test]
fn test_common_responses() {
assert_eq!(SmtpResponse::start_data().code, 354);
assert_eq!(SmtpResponse::syntax_error().code, 500);
assert_eq!(SmtpResponse::not_implemented().code, 502);
assert_eq!(SmtpResponse::bad_sequence("test").code, 503);
assert_eq!(SmtpResponse::auth_required().code, 530);
assert_eq!(SmtpResponse::auth_failed().code, 535);
assert_eq!(SmtpResponse::auth_success().code, 235);
}
}

View File

@@ -0,0 +1,331 @@
//! SMTP TCP/TLS server.
//!
//! Listens on configured ports, accepts connections, and dispatches
//! them to per-connection handlers.
use crate::config::SmtpServerConfig;
use crate::connection::{
self, CallbackRegistry, ConnectionEvent, SmtpStream,
};
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
use hickory_resolver::TokioResolver;
use mailer_security::MessageAuthenticator;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::io::BufReader;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::io::BufReader as TokioBufReader;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
/// Handle for a running SMTP server.
pub struct SmtpServerHandle {
/// Shutdown signal.
shutdown: Arc<AtomicBool>,
/// Join handles for the listener tasks.
handles: Vec<tokio::task::JoinHandle<()>>,
/// Active connection count.
pub active_connections: Arc<AtomicU32>,
}
impl SmtpServerHandle {
/// Signal shutdown and wait for all listeners to stop.
pub async fn shutdown(self) {
self.shutdown.store(true, Ordering::SeqCst);
for handle in self.handles {
let _ = handle.await;
}
info!("SMTP server shut down");
}
/// Check if the server is running.
pub fn is_running(&self) -> bool {
!self.shutdown.load(Ordering::SeqCst)
}
}
/// Start the SMTP server with the given configuration.
///
/// Returns a handle that can be used to shut down the server,
/// and an event receiver for connection events (emailReceived, authRequest).
pub async fn start_server(
config: SmtpServerConfig,
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
rate_limit_config: Option<RateLimitConfig>,
) -> Result<(SmtpServerHandle, mpsc::Receiver<ConnectionEvent>), Box<dyn std::error::Error + Send + Sync>>
{
let config = Arc::new(config);
let shutdown = Arc::new(AtomicBool::new(false));
let active_connections = Arc::new(AtomicU32::new(0));
let rate_limiter = Arc::new(RateLimiter::new(
rate_limit_config.unwrap_or_default(),
));
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
// Create shared security resources for in-process email verification
let authenticator: Arc<MessageAuthenticator> = Arc::new(
mailer_security::default_authenticator()
.map_err(|e| format!("Failed to create MessageAuthenticator: {e}"))?
);
let resolver: Arc<TokioResolver> = Arc::new(
TokioResolver::builder_tokio()
.map(|b| b.build())
.map_err(|e| format!("Failed to create TokioResolver: {e}"))?
);
// Build TLS acceptor if configured
let tls_acceptor = if config.has_tls() {
Some(Arc::new(build_tls_acceptor(&config)?))
} else {
None
};
let mut handles = Vec::new();
// Start listeners on each port
for &port in &config.ports {
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
info!(port = port, "SMTP server listening (STARTTLS)");
let handle = tokio::spawn(accept_loop(
listener,
config.clone(),
shutdown.clone(),
active_connections.clone(),
rate_limiter.clone(),
event_tx.clone(),
callback_registry.clone(),
tls_acceptor.clone(),
false, // not implicit TLS
authenticator.clone(),
resolver.clone(),
));
handles.push(handle);
}
// Start implicit TLS listener if configured
if let Some(secure_port) = config.secure_port {
if tls_acceptor.is_some() {
let listener =
TcpListener::bind(format!("0.0.0.0:{secure_port}")).await?;
info!(port = secure_port, "SMTP server listening (implicit TLS)");
let handle = tokio::spawn(accept_loop(
listener,
config.clone(),
shutdown.clone(),
active_connections.clone(),
rate_limiter.clone(),
event_tx.clone(),
callback_registry.clone(),
tls_acceptor.clone(),
true, // implicit TLS
authenticator.clone(),
resolver.clone(),
));
handles.push(handle);
} else {
warn!("Secure port configured but TLS certificates not provided");
}
}
// Spawn periodic rate limiter cleanup
{
let rate_limiter = rate_limiter.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move {
let mut interval =
tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
if shutdown.load(Ordering::SeqCst) {
break;
}
rate_limiter.cleanup();
}
});
}
Ok((
SmtpServerHandle {
shutdown,
handles,
active_connections,
},
event_rx,
))
}
/// Accept loop for a single listener.
async fn accept_loop(
listener: TcpListener,
config: Arc<SmtpServerConfig>,
shutdown: Arc<AtomicBool>,
active_connections: Arc<AtomicU32>,
rate_limiter: Arc<RateLimiter>,
event_tx: mpsc::Sender<ConnectionEvent>,
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
implicit_tls: bool,
authenticator: Arc<MessageAuthenticator>,
resolver: Arc<TokioResolver>,
) {
loop {
if shutdown.load(Ordering::SeqCst) {
break;
}
// Use a short timeout to check shutdown periodically
let accept_result = tokio::time::timeout(
tokio::time::Duration::from_secs(1),
listener.accept(),
)
.await;
let (tcp_stream, peer_addr) = match accept_result {
Ok(Ok((stream, addr))) => (stream, addr),
Ok(Err(e)) => {
error!(error = %e, "Accept error");
continue;
}
Err(_) => continue, // timeout, check shutdown
};
// Check max connections
let current = active_connections.load(Ordering::SeqCst);
if current >= config.max_connections {
warn!(
current = current,
max = config.max_connections,
"Max connections reached, rejecting"
);
drop(tcp_stream);
continue;
}
let remote_addr = peer_addr.ip().to_string();
let config = config.clone();
let rate_limiter = rate_limiter.clone();
let event_tx = event_tx.clone();
let callback_registry = callback_registry.clone();
let tls_acceptor = tls_acceptor.clone();
let active_connections = active_connections.clone();
let authenticator = authenticator.clone();
let resolver = resolver.clone();
active_connections.fetch_add(1, Ordering::SeqCst);
tokio::spawn(async move {
let stream = if implicit_tls {
// Implicit TLS: wrap immediately
if let Some(acceptor) = &tls_acceptor {
match acceptor.accept(tcp_stream).await {
Ok(tls_stream) => {
SmtpStream::Tls(TokioBufReader::new(tls_stream))
}
Err(e) => {
warn!(
remote_addr = %remote_addr,
error = %e,
"Implicit TLS handshake failed"
);
active_connections.fetch_sub(1, Ordering::SeqCst);
return;
}
}
} else {
active_connections.fetch_sub(1, Ordering::SeqCst);
return;
}
} else {
SmtpStream::Plain(TokioBufReader::new(tcp_stream))
};
connection::handle_connection(
stream,
config,
rate_limiter,
event_tx,
callback_registry,
tls_acceptor,
remote_addr,
implicit_tls,
authenticator,
resolver,
)
.await;
active_connections.fetch_sub(1, Ordering::SeqCst);
});
}
}
/// Build a TLS acceptor from PEM cert/key strings.
fn build_tls_acceptor(
config: &SmtpServerConfig,
) -> Result<tokio_rustls::TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
let cert_pem = config
.tls_cert_pem
.as_ref()
.ok_or("TLS cert not configured")?;
let key_pem = config
.tls_key_pem
.as_ref()
.ok_or("TLS key not configured")?;
// Parse certificates
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());
}
// Parse private key
let key: PrivateKeyDer<'static> = {
let mut reader = BufReader::new(key_pem.as_bytes());
// Try PKCS8 first, then RSA, then EC
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 tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_defaults() {
let config = SmtpServerConfig::default();
assert!(!config.has_tls());
assert_eq!(config.ports, vec![25]);
}
}

View File

@@ -0,0 +1,206 @@
//! Per-connection SMTP session state.
//!
//! Tracks the envelope, authentication, TLS status, and counters
//! for a single SMTP connection.
use crate::state::SmtpState;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Envelope accumulator for the current mail transaction.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Envelope {
/// Sender address from MAIL FROM.
pub mail_from: String,
/// Recipient addresses from RCPT TO.
pub rcpt_to: Vec<String>,
/// Declared message size from MAIL FROM SIZE= param (if any).
pub declared_size: Option<u64>,
/// BODY parameter (e.g. "8BITMIME").
pub body_type: Option<String>,
}
/// Authentication state for the session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthState {
/// Not authenticated and not in progress.
None,
/// Waiting for AUTH credentials (LOGIN flow step).
WaitingForUsername,
/// Have username, waiting for password.
WaitingForPassword { username: String },
/// Successfully authenticated.
Authenticated { username: String },
}
impl Default for AuthState {
fn default() -> Self {
AuthState::None
}
}
/// Per-connection session state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpSession {
/// Unique session identifier.
pub id: String,
/// Current protocol state.
pub state: SmtpState,
/// Client's EHLO/HELO hostname.
pub client_hostname: Option<String>,
/// Whether the client used EHLO (vs HELO).
pub esmtp: bool,
/// Whether the connection is using TLS.
pub secure: bool,
/// Authentication state.
pub auth_state: AuthState,
/// Current transaction envelope.
pub envelope: Envelope,
/// Remote IP address.
pub remote_addr: String,
/// Number of messages sent in this session.
pub message_count: u32,
/// Number of failed auth attempts.
pub auth_failures: u32,
/// Number of invalid commands.
pub invalid_commands: u32,
/// Maximum allowed invalid commands before disconnect.
pub max_invalid_commands: u32,
}
impl SmtpSession {
/// Create a new session for a connection.
pub fn new(remote_addr: String, secure: bool) -> Self {
Self {
id: Uuid::new_v4().to_string(),
state: SmtpState::Connected,
client_hostname: None,
esmtp: false,
secure,
auth_state: AuthState::None,
envelope: Envelope::default(),
remote_addr,
message_count: 0,
auth_failures: 0,
invalid_commands: 0,
max_invalid_commands: 20,
}
}
/// Reset the current transaction (RSET), preserving connection state.
pub fn reset_transaction(&mut self) {
self.envelope = Envelope::default();
if self.state != SmtpState::Connected {
self.state = SmtpState::Greeted;
}
}
/// Reset session for a new EHLO (preserves counters and TLS).
pub fn reset_for_ehlo(&mut self, hostname: String, esmtp: bool) {
self.client_hostname = Some(hostname);
self.esmtp = esmtp;
self.envelope = Envelope::default();
self.state = SmtpState::Greeted;
// Auth state is reset on new EHLO per RFC
self.auth_state = AuthState::None;
}
/// Check if the client is authenticated.
pub fn is_authenticated(&self) -> bool {
matches!(self.auth_state, AuthState::Authenticated { .. })
}
/// Get the authenticated username, if any.
pub fn authenticated_user(&self) -> Option<&str> {
match &self.auth_state {
AuthState::Authenticated { username } => Some(username),
_ => None,
}
}
/// Record a completed message delivery.
pub fn record_message(&mut self) {
self.message_count += 1;
}
/// Record a failed auth attempt. Returns true if limit exceeded.
pub fn record_auth_failure(&mut self, max_failures: u32) -> bool {
self.auth_failures += 1;
self.auth_failures >= max_failures
}
/// Record an invalid command. Returns true if limit exceeded.
pub fn record_invalid_command(&mut self) -> bool {
self.invalid_commands += 1;
self.invalid_commands >= self.max_invalid_commands
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_session() {
let session = SmtpSession::new("127.0.0.1".into(), false);
assert_eq!(session.state, SmtpState::Connected);
assert!(!session.secure);
assert!(!session.is_authenticated());
assert!(session.client_hostname.is_none());
}
#[test]
fn test_reset_transaction() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
session.state = SmtpState::RcptTo;
session.envelope.mail_from = "sender@example.com".into();
session.envelope.rcpt_to.push("rcpt@example.com".into());
session.reset_transaction();
assert_eq!(session.state, SmtpState::Greeted);
assert!(session.envelope.mail_from.is_empty());
assert!(session.envelope.rcpt_to.is_empty());
}
#[test]
fn test_reset_for_ehlo() {
let mut session = SmtpSession::new("127.0.0.1".into(), true);
session.auth_state = AuthState::Authenticated {
username: "user".into(),
};
session.reset_for_ehlo("mail.example.com".into(), true);
assert_eq!(session.state, SmtpState::Greeted);
assert_eq!(session.client_hostname.as_deref(), Some("mail.example.com"));
assert!(session.esmtp);
assert!(!session.is_authenticated()); // Auth reset after EHLO
}
#[test]
fn test_auth_failures() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
assert!(!session.record_auth_failure(3));
assert!(!session.record_auth_failure(3));
assert!(session.record_auth_failure(3)); // 3rd failure -> limit
}
#[test]
fn test_invalid_commands() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
session.max_invalid_commands = 3;
assert!(!session.record_invalid_command());
assert!(!session.record_invalid_command());
assert!(session.record_invalid_command()); // 3rd -> limit
}
#[test]
fn test_message_count() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
assert_eq!(session.message_count, 0);
session.record_message();
session.record_message();
assert_eq!(session.message_count, 2);
}
}

View File

@@ -0,0 +1,219 @@
//! SMTP protocol state machine.
//!
//! Defines valid states and transitions for an SMTP session.
use serde::{Deserialize, Serialize};
/// SMTP session states following RFC 5321.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SmtpState {
/// Initial state — waiting for server greeting.
Connected,
/// After successful EHLO/HELO.
Greeted,
/// After MAIL FROM accepted.
MailFrom,
/// After at least one RCPT TO accepted.
RcptTo,
/// In DATA mode — accumulating message body.
Data,
/// Transaction completed — can start a new one or QUIT.
Finished,
}
/// State transition errors.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TransitionError {
#[error("cannot {action} in state {state:?}")]
InvalidTransition {
state: SmtpState,
action: &'static str,
},
}
impl SmtpState {
/// Check whether EHLO/HELO is valid in the current state.
/// EHLO/HELO can be issued at any time to reset the session.
pub fn can_ehlo(&self) -> bool {
true
}
/// Check whether MAIL FROM is valid in the current state.
pub fn can_mail_from(&self) -> bool {
matches!(self, SmtpState::Greeted | SmtpState::Finished)
}
/// Check whether RCPT TO is valid in the current state.
pub fn can_rcpt_to(&self) -> bool {
matches!(self, SmtpState::MailFrom | SmtpState::RcptTo)
}
/// Check whether DATA is valid in the current state.
pub fn can_data(&self) -> bool {
matches!(self, SmtpState::RcptTo)
}
/// Check whether STARTTLS is valid in the current state.
/// Only before a transaction starts.
pub fn can_starttls(&self) -> bool {
matches!(self, SmtpState::Connected | SmtpState::Greeted | SmtpState::Finished)
}
/// Check whether AUTH is valid in the current state.
/// Only after EHLO and before a transaction starts.
pub fn can_auth(&self) -> bool {
matches!(self, SmtpState::Greeted | SmtpState::Finished)
}
/// Transition to Greeted state (after EHLO/HELO).
pub fn transition_ehlo(&self) -> Result<SmtpState, TransitionError> {
// EHLO is always valid — it resets the session.
Ok(SmtpState::Greeted)
}
/// Transition to MailFrom state (after MAIL FROM accepted).
pub fn transition_mail_from(&self) -> Result<SmtpState, TransitionError> {
if self.can_mail_from() {
Ok(SmtpState::MailFrom)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "MAIL FROM",
})
}
}
/// Transition to RcptTo state (after RCPT TO accepted).
pub fn transition_rcpt_to(&self) -> Result<SmtpState, TransitionError> {
if self.can_rcpt_to() {
Ok(SmtpState::RcptTo)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "RCPT TO",
})
}
}
/// Transition to Data state (after DATA command accepted).
pub fn transition_data(&self) -> Result<SmtpState, TransitionError> {
if self.can_data() {
Ok(SmtpState::Data)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "DATA",
})
}
}
/// Transition to Finished state (after end-of-data).
pub fn transition_finished(&self) -> Result<SmtpState, TransitionError> {
if *self == SmtpState::Data {
Ok(SmtpState::Finished)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "finish DATA",
})
}
}
/// Reset to Greeted state (after RSET command).
pub fn transition_rset(&self) -> Result<SmtpState, TransitionError> {
match self {
SmtpState::Connected => Err(TransitionError::InvalidTransition {
state: *self,
action: "RSET",
}),
_ => Ok(SmtpState::Greeted),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let state = SmtpState::Connected;
assert!(!state.can_mail_from());
assert!(!state.can_rcpt_to());
assert!(!state.can_data());
assert!(state.can_starttls());
assert!(state.can_ehlo());
}
#[test]
fn test_ehlo_always_valid() {
for state in [
SmtpState::Connected,
SmtpState::Greeted,
SmtpState::MailFrom,
SmtpState::RcptTo,
SmtpState::Data,
SmtpState::Finished,
] {
assert!(state.can_ehlo());
assert!(state.transition_ehlo().is_ok());
}
}
#[test]
fn test_normal_flow() {
let state = SmtpState::Connected;
let state = state.transition_ehlo().unwrap();
assert_eq!(state, SmtpState::Greeted);
let state = state.transition_mail_from().unwrap();
assert_eq!(state, SmtpState::MailFrom);
let state = state.transition_rcpt_to().unwrap();
assert_eq!(state, SmtpState::RcptTo);
// Multiple RCPT TO
let state = state.transition_rcpt_to().unwrap();
assert_eq!(state, SmtpState::RcptTo);
let state = state.transition_data().unwrap();
assert_eq!(state, SmtpState::Data);
let state = state.transition_finished().unwrap();
assert_eq!(state, SmtpState::Finished);
// New transaction
let state = state.transition_mail_from().unwrap();
assert_eq!(state, SmtpState::MailFrom);
}
#[test]
fn test_invalid_transitions() {
assert!(SmtpState::Connected.transition_mail_from().is_err());
assert!(SmtpState::Connected.transition_rcpt_to().is_err());
assert!(SmtpState::Connected.transition_data().is_err());
assert!(SmtpState::Greeted.transition_rcpt_to().is_err());
assert!(SmtpState::Greeted.transition_data().is_err());
assert!(SmtpState::MailFrom.transition_data().is_err());
}
#[test]
fn test_rset() {
let state = SmtpState::RcptTo;
let state = state.transition_rset().unwrap();
assert_eq!(state, SmtpState::Greeted);
// RSET from Connected is invalid (no EHLO yet)
assert!(SmtpState::Connected.transition_rset().is_err());
}
#[test]
fn test_starttls_validity() {
assert!(SmtpState::Connected.can_starttls());
assert!(SmtpState::Greeted.can_starttls());
assert!(!SmtpState::MailFrom.can_starttls());
assert!(!SmtpState::RcptTo.can_starttls());
assert!(!SmtpState::Data.can_starttls());
assert!(SmtpState::Finished.can_starttls());
}
}

View File

@@ -0,0 +1,169 @@
//! SMTP-level validation utilities.
//!
//! Address parsing, EHLO hostname validation, and header injection detection.
use regex::Regex;
use std::sync::LazyLock;
/// Regex for basic email address format validation.
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
});
/// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal).
/// Currently unused in favor of a more permissive check, but available
/// for strict validation if needed.
#[allow(dead_code)]
static EHLO_RE: LazyLock<Regex> = LazyLock::new(|| {
// Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words
Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap()
});
/// Validate an email address for basic SMTP format.
///
/// Returns `true` if the address has a valid-looking format.
/// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`.
pub fn is_valid_smtp_address(address: &str) -> bool {
// Empty address is valid for MAIL FROM (bounce)
if address.is_empty() {
return true;
}
EMAIL_RE.is_match(address)
}
/// Validate an EHLO/HELO hostname.
///
/// Returns `true` if the hostname looks syntactically valid.
/// We are permissive because real-world SMTP clients send all kinds of values.
pub fn is_valid_ehlo_hostname(hostname: &str) -> bool {
if hostname.is_empty() {
return false;
}
// Be permissive — most SMTP servers accept anything non-empty.
// Only reject obviously malicious patterns.
if hostname.len() > 255 {
return false;
}
if contains_header_injection(hostname) {
return false;
}
// Must not contain null bytes
if hostname.contains('\0') {
return false;
}
true
}
/// Check for SMTP header injection attempts.
///
/// Returns `true` if the input contains characters that could be used
/// for header injection (bare CR/LF).
pub fn contains_header_injection(input: &str) -> bool {
input.contains('\r') || input.contains('\n')
}
/// Validate the size parameter from MAIL FROM.
///
/// Returns the parsed size if valid and within the max, or an error message.
pub fn validate_size_param(value: &str, max_size: u64) -> Result<u64, String> {
let size: u64 = value
.parse()
.map_err(|_| format!("invalid SIZE value: {value}"))?;
if size > max_size {
return Err(format!(
"message size {size} exceeds maximum {max_size}"
));
}
Ok(size)
}
/// Extract the domain part from an email address.
pub fn extract_domain(address: &str) -> Option<&str> {
if address.is_empty() {
return None;
}
address.rsplit_once('@').map(|(_, domain)| domain)
}
/// Normalize an email address by lowercasing the domain part.
pub fn normalize_address(address: &str) -> String {
if address.is_empty() {
return String::new();
}
match address.rsplit_once('@') {
Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()),
None => address.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(is_valid_smtp_address("user@example.com"));
assert!(is_valid_smtp_address("user+tag@sub.example.com"));
assert!(is_valid_smtp_address("a@b.c"));
}
#[test]
fn test_empty_address_valid() {
assert!(is_valid_smtp_address(""));
}
#[test]
fn test_invalid_email() {
assert!(!is_valid_smtp_address("no-at-sign"));
assert!(!is_valid_smtp_address("@no-local.com"));
assert!(!is_valid_smtp_address("user@"));
assert!(!is_valid_smtp_address("user@nodot"));
assert!(!is_valid_smtp_address("has space@example.com"));
}
#[test]
fn test_valid_ehlo() {
assert!(is_valid_ehlo_hostname("mail.example.com"));
assert!(is_valid_ehlo_hostname("localhost"));
assert!(is_valid_ehlo_hostname("[127.0.0.1]"));
assert!(is_valid_ehlo_hostname("[IPv6:::1]"));
}
#[test]
fn test_invalid_ehlo() {
assert!(!is_valid_ehlo_hostname(""));
assert!(!is_valid_ehlo_hostname("host\r\nname"));
assert!(!is_valid_ehlo_hostname(&"a".repeat(256)));
}
#[test]
fn test_header_injection() {
assert!(contains_header_injection("test\r\nBcc: evil@evil.com"));
assert!(contains_header_injection("test\ninjection"));
assert!(contains_header_injection("test\rinjection"));
assert!(!contains_header_injection("normal text"));
}
#[test]
fn test_size_param() {
assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345));
assert!(validate_size_param("99999999", 1_000).is_err());
assert!(validate_size_param("notanumber", 1_000).is_err());
}
#[test]
fn test_extract_domain() {
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
assert_eq!(extract_domain(""), None);
assert_eq!(extract_domain("nodomain"), None);
}
#[test]
fn test_normalize_address() {
assert_eq!(
normalize_address("User@EXAMPLE.COM"),
"User@example.com"
);
assert_eq!(normalize_address(""), "");
}
}

View File

@@ -0,0 +1,148 @@
import * as plugins from '../../ts/plugins.js';
export interface ITestServerConfig {
port: number;
hostname?: string;
tlsEnabled?: boolean;
authRequired?: boolean;
timeout?: number;
testCertPath?: string;
testKeyPath?: string;
maxConnections?: number;
size?: number;
maxRecipients?: number;
}
export interface ITestServer {
server: any;
smtpServer: any;
port: number;
hostname: string;
config: ITestServerConfig;
startTime: number;
}
/**
* Starts a test SMTP server with the given configuration.
*
* NOTE: The TS SMTP server implementation was removed in Phase 7B
* (replaced by the Rust SMTP server). This stub preserves the interface
* for smtpclient tests that import it, but those tests require `node-forge`
* which is not installed (pre-existing issue).
*/
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
throw new Error(
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
);
}
/**
* Stops a test SMTP server
*/
export async function stopTestServer(testServer: ITestServer): Promise<void> {
if (!testServer || !testServer.smtpServer) {
return;
}
try {
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
await testServer.smtpServer.close();
}
} catch (error) {
console.error('Error stopping test server:', error);
throw error;
}
}
/**
* Get an available port for testing
*/
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
for (let port = startPort; port < startPort + 1000; port++) {
if (await isPortFree(port)) {
return port;
}
}
throw new Error(`No available ports found starting from ${startPort}`);
}
/**
* Check if a port is free
*/
async function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = plugins.net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on('error', () => resolve(false));
});
}
/**
* Create test email data
*/
export function createTestEmail(options: {
from?: string;
to?: string | string[];
subject?: string;
text?: string;
html?: string;
attachments?: any[];
} = {}): any {
return {
from: options.from || 'test@example.com',
to: options.to || 'recipient@example.com',
subject: options.subject || 'Test Email',
text: options.text || 'This is a test email',
html: options.html || '<p>This is a test email</p>',
attachments: options.attachments || [],
date: new Date(),
messageId: `<${Date.now()}@test.example.com>`
};
}
/**
* Simple test server for custom protocol testing
*/
export interface ISimpleTestServer {
server: any;
hostname: string;
port: number;
}
export async function createTestServer(options: {
onConnection?: (socket: any) => void | Promise<void>;
port?: number;
hostname?: string;
}): Promise<ISimpleTestServer> {
const hostname = options.hostname || 'localhost';
const port = options.port || await getAvailablePort();
const server = plugins.net.createServer((socket) => {
if (options.onConnection) {
const result = options.onConnection(socket);
if (result && typeof result.then === 'function') {
result.catch(error => {
console.error('Error in onConnection handler:', error);
socket.destroy();
});
}
}
});
return new Promise((resolve, reject) => {
server.listen(port, hostname, () => {
resolve({
server,
hostname,
port
});
});
server.on('error', reject);
});
}

209
test/helpers/smtp.client.ts Normal file
View File

@@ -0,0 +1,209 @@
import { smtpClientMod } from '../../ts/mail/delivery/index.js';
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
import { Email } from '../../ts/mail/core/classes.email.js';
/**
* Create a test SMTP client
*/
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
const defaultOptions: ISmtpClientOptions = {
host: options.host || 'localhost',
port: options.port || 2525,
secure: options.secure || false,
auth: options.auth,
connectionTimeout: options.connectionTimeout || 5000,
socketTimeout: options.socketTimeout || 5000,
maxConnections: options.maxConnections || 5,
maxMessages: options.maxMessages || 100,
debug: options.debug || false,
tls: options.tls || {
rejectUnauthorized: false
}
};
return smtpClientMod.createSmtpClient(defaultOptions);
}
/**
* Send test email using SMTP client
*/
export async function sendTestEmail(
client: SmtpClient,
options: {
from?: string;
to?: string | string[];
subject?: string;
text?: string;
html?: string;
} = {}
): Promise<any> {
const mailOptions = {
from: options.from || 'test@example.com',
to: options.to || 'recipient@example.com',
subject: options.subject || 'Test Email',
text: options.text || 'This is a test email',
html: options.html
};
const email = new Email({
from: mailOptions.from,
to: mailOptions.to,
subject: mailOptions.subject,
text: mailOptions.text,
html: mailOptions.html
});
return client.sendMail(email);
}
/**
* Test SMTP client connection
*/
export async function testClientConnection(
host: string,
port: number,
timeout: number = 5000
): Promise<boolean> {
const client = createTestSmtpClient({
host,
port,
connectionTimeout: timeout
});
try {
const result = await client.verify();
return result;
} catch (error) {
throw error;
} finally {
if (client.close) {
await client.close();
}
}
}
/**
* Create authenticated SMTP client
*/
export function createAuthenticatedClient(
host: string,
port: number,
username: string,
password: string,
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
): SmtpClient {
return createTestSmtpClient({
host,
port,
auth: {
user: username,
pass: password,
method: authMethod
},
secure: false
});
}
/**
* Create TLS-enabled SMTP client
*/
export function createTlsClient(
host: string,
port: number,
options: {
secure?: boolean;
rejectUnauthorized?: boolean;
} = {}
): SmtpClient {
return createTestSmtpClient({
host,
port,
secure: options.secure || false,
tls: {
rejectUnauthorized: options.rejectUnauthorized || false
}
});
}
/**
* Test client pool status
*/
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
if (typeof client.getPoolStatus === 'function') {
return client.getPoolStatus();
}
// Fallback for clients without pool status
return {
size: 1,
available: 1,
pending: 0,
connecting: 0,
active: 0
};
}
/**
* Send multiple emails concurrently
*/
export async function sendConcurrentEmails(
client: SmtpClient,
count: number,
emailOptions: {
from?: string;
to?: string;
subject?: string;
text?: string;
} = {}
): Promise<any[]> {
const promises = [];
for (let i = 0; i < count; i++) {
promises.push(
sendTestEmail(client, {
...emailOptions,
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
})
);
}
return Promise.all(promises);
}
/**
* Measure client throughput
*/
export async function measureClientThroughput(
client: SmtpClient,
duration: number = 10000,
emailOptions: {
from?: string;
to?: string;
subject?: string;
text?: string;
} = {}
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
const startTime = Date.now();
let totalSent = 0;
let successCount = 0;
let errorCount = 0;
while (Date.now() - startTime < duration) {
try {
await sendTestEmail(client, emailOptions);
successCount++;
} catch (error) {
errorCount++;
}
totalSent++;
}
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
const throughput = totalSent / actualDuration;
return {
totalSent,
successCount,
errorCount,
throughput
};
}

311
test/helpers/utils.ts Normal file
View File

@@ -0,0 +1,311 @@
import * as plugins from '../../ts/plugins.js';
/**
* Test result interface
*/
export interface ITestResult {
success: boolean;
duration: number;
message?: string;
error?: string;
details?: any;
}
/**
* Test configuration interface
*/
export interface ITestConfig {
host: string;
port: number;
timeout: number;
fromAddress?: string;
toAddress?: string;
[key: string]: any;
}
/**
* Connect to SMTP server and get greeting
*/
export async function connectToSmtp(host: string, port: number, timeout: number = 5000): Promise<plugins.net.Socket> {
return new Promise((resolve, reject) => {
const socket = plugins.net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`Connection timeout after ${timeout}ms`));
}, timeout);
socket.once('connect', () => {
clearTimeout(timer);
resolve(socket);
});
socket.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
/**
* Send SMTP command and wait for response
*/
export async function sendSmtpCommand(
socket: plugins.net.Socket,
command: string,
expectedCode?: string,
timeout: number = 5000
): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = '';
let timer: NodeJS.Timeout;
const onData = (data: Buffer) => {
buffer += data.toString();
// Check if we have a complete response
if (buffer.includes('\r\n')) {
clearTimeout(timer);
socket.removeListener('data', onData);
if (expectedCode && !buffer.startsWith(expectedCode)) {
reject(new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`));
} else {
resolve(buffer);
}
}
};
timer = setTimeout(() => {
socket.removeListener('data', onData);
reject(new Error(`Command timeout after ${timeout}ms`));
}, timeout);
socket.on('data', onData);
socket.write(command + '\r\n');
});
}
/**
* Wait for SMTP greeting
*/
export async function waitForGreeting(socket: plugins.net.Socket, timeout: number = 5000): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = '';
let timer: NodeJS.Timeout;
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('220')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve(buffer);
}
};
timer = setTimeout(() => {
socket.removeListener('data', onData);
reject(new Error(`Greeting timeout after ${timeout}ms`));
}, timeout);
socket.on('data', onData);
});
}
/**
* Perform SMTP handshake
*/
export async function performSmtpHandshake(
socket: plugins.net.Socket,
hostname: string = 'test.example.com'
): Promise<string[]> {
const capabilities: string[] = [];
// Wait for greeting
await waitForGreeting(socket);
// Send EHLO
const ehloResponse = await sendSmtpCommand(socket, `EHLO ${hostname}`, '250');
// Parse capabilities
const lines = ehloResponse.split('\r\n');
for (const line of lines) {
if (line.startsWith('250-') || line.startsWith('250 ')) {
const capability = line.substring(4).trim();
if (capability) {
capabilities.push(capability);
}
}
}
return capabilities;
}
/**
* Create multiple concurrent connections
*/
export async function createConcurrentConnections(
host: string,
port: number,
count: number,
timeout: number = 5000
): Promise<plugins.net.Socket[]> {
const connectionPromises = [];
for (let i = 0; i < count; i++) {
connectionPromises.push(connectToSmtp(host, port, timeout));
}
return Promise.all(connectionPromises);
}
/**
* Close SMTP connection gracefully
*/
export async function closeSmtpConnection(socket: plugins.net.Socket): Promise<void> {
try {
await sendSmtpCommand(socket, 'QUIT', '221');
} catch {
// Ignore errors during QUIT
}
socket.destroy();
}
/**
* Generate random email content
*/
export function generateRandomEmail(size: number = 1024): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 \r\n';
let content = '';
for (let i = 0; i < size; i++) {
content += chars.charAt(Math.floor(Math.random() * chars.length));
}
return content;
}
/**
* Create MIME message
*/
export function createMimeMessage(options: {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
attachments?: Array<{ filename: string; content: string; contentType: string }>;
}): string {
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
const date = new Date().toUTCString();
let message = '';
message += `From: ${options.from}\r\n`;
message += `To: ${options.to}\r\n`;
message += `Subject: ${options.subject}\r\n`;
message += `Date: ${date}\r\n`;
message += `MIME-Version: 1.0\r\n`;
if (options.attachments && options.attachments.length > 0) {
message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
message += '\r\n';
// Text part
if (options.text) {
message += `--${boundary}\r\n`;
message += 'Content-Type: text/plain; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.text + '\r\n';
}
// HTML part
if (options.html) {
message += `--${boundary}\r\n`;
message += 'Content-Type: text/html; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.html + '\r\n';
}
// Attachments
for (const attachment of options.attachments) {
message += `--${boundary}\r\n`;
message += `Content-Type: ${attachment.contentType}\r\n`;
message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
message += 'Content-Transfer-Encoding: base64\r\n';
message += '\r\n';
message += Buffer.from(attachment.content).toString('base64') + '\r\n';
}
message += `--${boundary}--\r\n`;
} else if (options.html && options.text) {
const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`;
message += '\r\n';
// Text part
message += `--${altBoundary}\r\n`;
message += 'Content-Type: text/plain; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.text + '\r\n';
// HTML part
message += `--${altBoundary}\r\n`;
message += 'Content-Type: text/html; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.html + '\r\n';
message += `--${altBoundary}--\r\n`;
} else if (options.html) {
message += 'Content-Type: text/html; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.html;
} else {
message += 'Content-Type: text/plain; charset=utf-8\r\n';
message += 'Content-Transfer-Encoding: 8bit\r\n';
message += '\r\n';
message += options.text || '';
}
return message;
}
/**
* Measure operation time
*/
export async function measureTime<T>(operation: () => Promise<T>): Promise<{ result: T; duration: number }> {
const startTime = Date.now();
const result = await operation();
const duration = Date.now() - startTime;
return { result, duration };
}
/**
* Retry operation with exponential backoff
*/
export async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, i);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}

443
test/readme.md Normal file
View File

@@ -0,0 +1,443 @@
# DCRouter SMTP Test Suite
```
test/
├── readme.md # This file
├── helpers/
│ ├── server.loader.ts # SMTP server lifecycle management
│ ├── utils.ts # Common test utilities
│ └── smtp.client.ts # Test SMTP client utilities
└── suite/
├── smtpserver_commands/ # SMTP command tests (CMD)
├── smtpserver_connection/ # Connection management tests (CM)
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
├── smtpserver_email-processing/ # Email processing tests (EP)
├── smtpserver_error-handling/ # Error handling tests (ERR)
├── smtpserver_performance/ # Performance tests (PERF)
├── smtpserver_reliability/ # Reliability tests (REL)
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
└── smtpserver_security/ # Security tests (SEC)
```
## Test ID Convention
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
Examples:
- `test.cmd-01.ehlo-command.ts` - EHLO command test
- `test.cm-01.tls-connection.ts` - TLS connection test
- `test.sec-01.authentication.ts` - Authentication test
## Test Categories
### 1. Connection Management (CM)
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
| ID | Test Description | Priority | Implementation |
|-------|-------------------------------------------|----------|----------------|
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
### 2. SMTP Commands (CMD)
Tests for validating proper SMTP protocol command implementation.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
### 3. Email Processing (EP)
Tests for validating email content handling, parsing, and delivery.
| ID | Test Description | Priority | Implementation |
|-------|-------------------------------------------|----------|----------------|
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
### 4. Security (SEC)
Tests for validating security features and protections.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
### 5. Error Handling (ERR)
Tests for validating proper error handling and recovery.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
### 6. Performance (PERF)
Tests for validating performance characteristics and benchmarks.
| ID | Test Description | Priority | Implementation |
|---------|------------------------------------------|----------|----------------|
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
### 7. Reliability (REL)
Tests for validating system reliability and stability.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
### 8. Edge Cases (EDGE)
Tests for validating handling of unusual or extreme scenarios.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
### 9. RFC Compliance (RFC)
Tests for validating compliance with SMTP-related RFCs.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
## SMTP Client Test Suite
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
### Client Test Organization
```
test/
└── suite/
├── smtpclient_connection/ # Client connection management tests (CCM)
├── smtpclient_commands/ # Client command execution tests (CCMD)
├── smtpclient_email-composition/ # Email composition tests (CEP)
├── smtpclient_security/ # Client security tests (CSEC)
├── smtpclient_error-handling/ # Client error handling tests (CERR)
├── smtpclient_performance/ # Client performance tests (CPERF)
├── smtpclient_reliability/ # Client reliability tests (CREL)
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
```
### 10. Client Connection Management (CCM)
Tests for validating how the SMTP client establishes and manages connections to servers.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
### 11. Client Command Execution (CCMD)
Tests for validating how the client sends SMTP commands and processes responses.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
### 12. Client Email Composition (CEP)
Tests for validating email composition, formatting, and encoding.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
### 13. Client Security (CSEC)
Tests for client-side security features and protections.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
### 14. Client Error Handling (CERR)
Tests for how the client handles various error conditions.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
### 15. Client Performance (CPERF)
Tests for client performance characteristics and optimization.
| ID | Test Description | Priority | Implementation |
|----------|-------------------------------------------|----------|----------------|
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
### 16. Client Reliability (CREL)
Tests for client reliability and resilience.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
### 17. Client Edge Cases (CEDGE)
Tests for unusual scenarios and edge cases.
| ID | Test Description | Priority | Implementation |
|----------|-------------------------------------------|----------|----------------|
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
### 18. Client RFC Compliance (CRFC)
Tests for RFC compliance from the client perspective.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
## Running SMTP Client Tests
### Run All Client Tests
```bash
cd dcrouter
pnpm test test/suite/smtpclient_*
```
### Run Specific Client Test Category
```bash
# Run all client connection tests
pnpm test test/suite/smtpclient_connection
# Run all client security tests
pnpm test test/suite/smtpclient_security
```
### Run Single Client Test File
```bash
# Run basic TCP connection test
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
# Run AUTH mechanisms test
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
```
## Client Performance Benchmarks
Expected performance metrics for production-ready SMTP client:
- **Sending Rate**: >100 emails per second (with connection pooling)
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
- **Memory Usage**: <500MB for 1000 concurrent email operations
- **DNS Cache Hit Rate**: >90% for repeated domains
- **Retry Success Rate**: >95% for temporary failures
- **Large Attachment Support**: Files up to 25MB without performance degradation
- **Queue Processing**: >1000 emails/minute with persistent queue
## Client Security Requirements
All client security tests must pass for production deployment:
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
- **Certificate Validation**: Proper certificate chain validation
- **DKIM Signing**: Automatic DKIM signature generation
- **Credential Security**: No plaintext password storage
- **Injection Prevention**: Protection against header/command injection
## Client Production Readiness Criteria
### Production Gate 1: Core Functionality (>95% tests passing)
- Basic connection establishment
- Command execution and response parsing
- Email composition and sending
- Error handling and recovery
### Production Gate 2: Advanced Features (>90% tests passing)
- Connection pooling and reuse
- Authentication mechanisms
- TLS/STARTTLS support
- Retry logic and resilience
### Production Gate 3: Enterprise Ready (>85% tests passing)
- High-volume sending capabilities
- Advanced security features
- Full RFC compliance
- Performance under load
## Key Differences: Server vs Client Tests
| Aspect | Server Tests | Client Tests |
|--------|--------------|--------------|
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
## Test Implementation Priority
1. **Critical** (implement first):
- Basic connection and command sending
- Authentication mechanisms
- Error handling and retry logic
- TLS/Security features
2. **High Priority** (implement second):
- Connection pooling
- Email composition and MIME
- Performance optimization
- RFC compliance
3. **Medium Priority** (implement third):
- Advanced features (OAuth2, etc.)
- Edge case handling
- Extended performance tests
- Additional RFC extensions
4. **Low Priority** (implement last):
- Proxy support
- Certificate pinning
- Unusual scenarios
- Optional RFC features

207
test/test.bouncemanager.ts Normal file
View File

@@ -0,0 +1,207 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js';
import { Email } from '../ts/mail/core/classes.email.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
tap.test('setup - start Rust security bridge', async () => {
const bridge = RustSecurityBridge.getInstance();
const ok = await bridge.start();
expect(ok).toEqual(true);
});
/**
* Test the BounceManager class
*/
tap.test('BounceManager - should be instantiable', async () => {
const bounceManager = new BounceManager();
expect(bounceManager).toBeTruthy();
});
tap.test('BounceManager - should process basic bounce categories', async () => {
const bounceManager = new BounceManager();
// Test hard bounce detection
const hardBounce = await bounceManager.processBounce({
recipient: 'invalid@example.com',
sender: 'sender@example.com',
smtpResponse: 'user unknown',
domain: 'example.com'
});
expect(hardBounce.bounceCategory).toEqual(BounceCategory.HARD);
// Test soft bounce detection
const softBounce = await bounceManager.processBounce({
recipient: 'valid@example.com',
sender: 'sender@example.com',
smtpResponse: 'server unavailable',
domain: 'example.com'
});
expect(softBounce.bounceCategory).toEqual(BounceCategory.SOFT);
// Test auto-response detection
const autoResponse = await bounceManager.processBounce({
recipient: 'away@example.com',
sender: 'sender@example.com',
smtpResponse: 'auto-reply: out of office',
domain: 'example.com'
});
expect(autoResponse.bounceCategory).toEqual(BounceCategory.AUTO_RESPONSE);
});
tap.test('BounceManager - should add and check suppression list entries', async () => {
const bounceManager = new BounceManager();
// Add to suppression list permanently
bounceManager.addToSuppressionList('permanent@example.com', 'Test hard bounce', undefined);
// Add to suppression list temporarily (5 seconds)
const expireTime = Date.now() + 5000;
bounceManager.addToSuppressionList('temporary@example.com', 'Test soft bounce', expireTime);
// Check suppression status
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(true);
expect(bounceManager.isEmailSuppressed('notsuppressed@example.com')).toEqual(false);
// Get suppression info
const info = bounceManager.getSuppressionInfo('permanent@example.com');
expect(info).toBeTruthy();
expect(info.reason).toEqual('Test hard bounce');
expect(info.expiresAt).toBeUndefined();
// Verify temporary suppression info
const tempInfo = bounceManager.getSuppressionInfo('temporary@example.com');
expect(tempInfo).toBeTruthy();
expect(tempInfo.reason).toEqual('Test soft bounce');
expect(tempInfo.expiresAt).toEqual(expireTime);
// Wait for expiration (6 seconds)
await new Promise(resolve => setTimeout(resolve, 6000));
// Verify permanent suppression is still active
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
// Verify temporary suppression has expired
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(false);
});
tap.test('BounceManager - should process SMTP failures correctly', async () => {
const bounceManager = new BounceManager();
const result = await bounceManager.processSmtpFailure(
'recipient@example.com',
'550 5.1.1 User unknown',
{
sender: 'sender@example.com',
statusCode: '550'
}
);
expect(result.bounceType).toEqual(BounceType.INVALID_RECIPIENT);
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
// Check that the email was added to the suppression list
expect(bounceManager.isEmailSuppressed('recipient@example.com')).toEqual(true);
});
tap.test('BounceManager - should process bounce emails correctly', async () => {
const bounceManager = new BounceManager();
// Create a mock bounce email
const bounceEmail = new Email({
from: 'mailer-daemon@example.com',
subject: 'Mail delivery failed: returning message to sender',
text: `
This message was created automatically by mail delivery software.
A message that you sent could not be delivered to one or more of its recipients.
The following address(es) failed:
recipient@example.com
mailbox is full
------ This is a copy of the message, including all the headers. ------
Original-Recipient: rfc822;recipient@example.com
Final-Recipient: rfc822;recipient@example.com
Status: 5.2.2
diagnostic-code: smtp; 552 5.2.2 Mailbox full
`,
to: 'sender@example.com' // Bounce emails are typically sent back to the original sender
});
const result = await bounceManager.processBounceEmail(bounceEmail);
expect(result).toBeTruthy();
expect(result.bounceType).toEqual(BounceType.MAILBOX_FULL);
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
expect(result.recipient).toEqual('recipient@example.com');
});
tap.test('BounceManager - should handle retries for soft bounces', async () => {
const bounceManager = new BounceManager({
retryStrategy: {
maxRetries: 2,
initialDelay: 100, // 100ms for test
maxDelay: 1000,
backoffFactor: 2
}
});
// First attempt
const result1 = await bounceManager.processBounce({
recipient: 'retry@example.com',
sender: 'sender@example.com',
bounceType: BounceType.SERVER_UNAVAILABLE,
bounceCategory: BounceCategory.SOFT,
domain: 'example.com'
});
// Email should be suppressed temporarily
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
expect(result1.retryCount).toEqual(1);
expect(result1.nextRetryTime).toBeGreaterThan(Date.now());
// Second attempt
const result2 = await bounceManager.processBounce({
recipient: 'retry@example.com',
sender: 'sender@example.com',
bounceType: BounceType.SERVER_UNAVAILABLE,
bounceCategory: BounceCategory.SOFT,
domain: 'example.com',
retryCount: 1
});
expect(result2.retryCount).toEqual(2);
// Third attempt (should convert to hard bounce)
const result3 = await bounceManager.processBounce({
recipient: 'retry@example.com',
sender: 'sender@example.com',
bounceType: BounceType.SERVER_UNAVAILABLE,
bounceCategory: BounceCategory.SOFT,
domain: 'example.com',
retryCount: 2
});
// Should now be a hard bounce after max retries
expect(result3.bounceCategory).toEqual(BounceCategory.HARD);
// Email should be suppressed permanently
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
const info = bounceManager.getSuppressionInfo('retry@example.com');
expect(info.expiresAt).toBeUndefined(); // Permanent
});
tap.test('cleanup - stop Rust security bridge', async () => {
await RustSecurityBridge.getInstance().stop();
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

175
test/test.config.md Normal file
View File

@@ -0,0 +1,175 @@
# DCRouter Test Configuration
## Running Tests
### Run All Tests
```bash
cd dcrouter
pnpm test
```
### Run Specific Category
```bash
# Run all connection tests
tsx test/run-category.ts connection
# Run all security tests
tsx test/run-category.ts security
# Run all performance tests
tsx test/run-category.ts performance
```
### Run Individual Test File
```bash
# Run TLS connection test
tsx test/suite/connection/test.tls-connection.ts
# Run authentication test
tsx test/suite/security/test.authentication.ts
```
### Run Tests with Verbose Output
```bash
# All tests with verbose logging
pnpm test -- --verbose
# Individual test with verbose
tsx test/suite/connection/test.tls-connection.ts --verbose
```
## Test Server Configuration
Each test file starts its own SMTP server with specific configuration. Common configurations:
### Basic Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost'
});
```
### TLS-Enabled Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
tlsEnabled: true
});
```
### Authenticated Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
authRequired: true
});
```
### High-Performance Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
maxConnections: 1000,
size: 50 * 1024 * 1024 // 50MB
});
```
## Port Allocation
Tests use different ports to avoid conflicts:
- Connection tests: 2525-2530
- Command tests: 2531-2540
- Email processing: 2541-2550
- Security tests: 2551-2560
- Performance tests: 2561-2570
- Edge cases: 2571-2580
- RFC compliance: 2581-2590
## Test Utilities
### Server Lifecycle
All tests follow this pattern:
```typescript
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
let testServer;
tap.test('setup', async () => {
testServer = await startTestServer({ port: 2525 });
});
// Your tests here...
tap.test('cleanup', async () => {
await stopTestServer(testServer);
});
tap.start();
```
### SMTP Client Testing
```typescript
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
const client = createTestSmtpClient({
host: 'localhost',
port: 2525
});
```
### Low-Level SMTP Testing
```typescript
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
const socket = await connectToSmtp('localhost', 2525);
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
```
## Performance Benchmarks
Expected minimums for production:
- Throughput: >10 emails/second
- Concurrent connections: >100
- Memory increase: <2% under load
- Connection time: <5000ms
- Error rate: <5%
## Debugging Failed Tests
### Enable Verbose Logging
```bash
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
```
### Check Server Logs
Tests output server logs to console. Look for:
- 🚀 Server start messages
- 📧 Email processing logs
- ❌ Error messages
- ✅ Success confirmations
### Common Issues
1. **Port Already in Use**
- Tests use unique ports
- Check for orphaned processes: `lsof -i :2525`
- Kill process: `kill -9 <PID>`
2. **TLS Certificate Errors**
- Tests use self-signed certificates
- Production should use real certificates
3. **Timeout Errors**
- Increase timeout in test configuration
- Check network connectivity
- Verify server started successfully
4. **Authentication Failures**
- Test servers may not validate credentials
- Check authRequired configuration
- Verify AUTH mechanisms supported

276
test/test.contentscanner.ts Normal file
View File

@@ -0,0 +1,276 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
import { Email } from '../ts/mail/core/classes.email.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
tap.test('setup - start Rust security bridge', async () => {
const bridge = RustSecurityBridge.getInstance();
const ok = await bridge.start();
expect(ok).toEqual(true);
});
// Test instantiation
tap.test('ContentScanner - should be instantiable', async () => {
const scanner = ContentScanner.getInstance({
scanBody: true,
scanSubject: true,
scanAttachments: true
});
expect(scanner).toBeTruthy();
});
// Test singleton pattern
tap.test('ContentScanner - should use singleton pattern', async () => {
const scanner1 = ContentScanner.getInstance();
const scanner2 = ContentScanner.getInstance();
// Both instances should be the same object
expect(scanner1 === scanner2).toEqual(true);
});
// Test clean email can be correctly distinguished from high-risk email
tap.test('ContentScanner - should distinguish between clean and suspicious emails', async () => {
// Create an instance with a higher minimum threat score
const scanner = new ContentScanner({
minThreatScore: 50 // Higher threshold to consider clean
});
// Create a truly clean email with no potentially sensitive data patterns
const cleanEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Project Update',
text: 'The project is on track. Let me know if you have questions.',
html: '<p>The project is on track. Let me know if you have questions.</p>'
});
// Create a highly suspicious email
const suspiciousEmail = new Email({
from: 'admin@bank-fake.com',
to: 'victim@example.com',
subject: 'URGENT: Your account needs verification now!',
text: 'Click here to verify your account or it will be suspended: https://bit.ly/12345',
html: '<p>Click here to verify your account or it will be suspended: <a href="https://bit.ly/12345">click here</a></p>'
});
// Test both emails
const cleanResult = await scanner.scanEmail(cleanEmail);
const suspiciousResult = await scanner.scanEmail(suspiciousEmail);
console.log('Clean vs Suspicious results:', {
cleanScore: cleanResult.threatScore,
suspiciousScore: suspiciousResult.threatScore
});
// Verify the scanner can distinguish between them
// Suspicious email should have a significantly higher score
expect(suspiciousResult.threatScore > cleanResult.threatScore + 40).toEqual(true);
// Verify clean email scans all expected elements
expect(cleanResult.scannedElements.length > 0).toEqual(true);
});
// Test phishing detection in subject
tap.test('ContentScanner - should detect phishing in subject', async () => {
// Create a dedicated scanner for this test
const scanner = new ContentScanner({
scanSubject: true,
scanBody: true,
scanAttachments: false,
customRules: []
});
const email = new Email({
from: 'security@bank-account-verify.com',
to: 'victim@example.com',
subject: 'URGENT: Verify your bank account details immediately',
text: 'Your account will be suspended. Please verify your details.',
html: '<p>Your account will be suspended. Please verify your details.</p>'
});
const result = await scanner.scanEmail(email);
console.log('Phishing email scan result:', result);
// We only care that it detected something suspicious
expect(result.threatScore >= 20).toEqual(true);
// Check if any threat was detected (specific type may vary)
expect(result.threatType).toBeTruthy();
});
// Test malware indicators in body
tap.test('ContentScanner - should detect malware indicators in body', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'invoice@company.com',
to: 'recipient@example.com',
subject: 'Your invoice',
text: 'Please see the attached invoice. You need to enable macros to view this document properly.',
html: '<p>Please see the attached invoice. You need to enable macros to view this document properly.</p>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType === ThreatCategory.MALWARE || result.threatType).toBeTruthy();
expect(result.threatScore >= 30).toEqual(true);
});
// Test suspicious link detection
tap.test('ContentScanner - should detect suspicious links', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'newsletter@example.com',
to: 'recipient@example.com',
subject: 'Weekly Newsletter',
text: 'Check our latest offer at https://bit.ly/2x3F5 and https://t.co/abc123',
html: '<p>Check our latest offer at <a href="https://bit.ly/2x3F5">here</a> and <a href="https://t.co/abc123">here</a></p>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.SUSPICIOUS_LINK);
expect(result.threatScore >= 30).toEqual(true);
});
// Test script injection detection
tap.test('ContentScanner - should detect script injection', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'newsletter@example.com',
to: 'recipient@example.com',
subject: 'Newsletter',
text: 'Check our website',
html: '<p>Check our website</p><script>document.cookie="session="+localStorage.getItem("token");</script>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.XSS);
expect(result.threatScore >= 40).toEqual(true);
});
// Test executable attachment detection
tap.test('ContentScanner - should detect executable attachments', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Software Update',
text: 'Please install the attached software update.',
attachments: [{
filename: 'update.exe',
content: Buffer.from('MZ...fake executable content...'),
contentType: 'application/octet-stream'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.EXECUTABLE);
expect(result.threatScore >= 70).toEqual(true);
});
// Test macro document detection
tap.test('ContentScanner - should detect macro documents', async () => {
// Create a mock Office document with macro indicators
const fakeDocContent = Buffer.from('Document content...vbaProject.bin...Auto_Open...DocumentOpen...Microsoft VBA...');
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Financial Report',
text: 'Please review the attached financial report.',
attachments: [{
filename: 'report.docm',
content: fakeDocContent,
contentType: 'application/vnd.ms-word.document.macroEnabled.12'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.MALICIOUS_MACRO);
expect(result.threatScore >= 60).toEqual(true);
});
// Test compound threat detection (multiple indicators)
tap.test('ContentScanner - should detect compound threats', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'security@bank-verify.com',
to: 'victim@example.com',
subject: 'URGENT: Verify your account details immediately',
text: 'Your account will be suspended unless you verify your details at https://bit.ly/2x3F5',
html: '<p>Your account will be suspended unless you verify your details <a href="https://bit.ly/2x3F5">here</a>.</p>',
attachments: [{
filename: 'verification.exe',
content: Buffer.from('MZ...fake executable content...'),
contentType: 'application/octet-stream'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatScore > 70).toEqual(true); // Should have a high score due to multiple threats
});
// Test custom rules
tap.test('ContentScanner - should apply custom rules', async () => {
// Create a scanner with custom rules
const scanner = new ContentScanner({
customRules: [
{
pattern: /CUSTOM_PATTERN_FOR_TESTING/,
type: ThreatCategory.CUSTOM_RULE,
score: 50,
description: 'Custom pattern detected'
}
]
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test Custom Rule',
text: 'This message contains CUSTOM_PATTERN_FOR_TESTING that should be detected.'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.CUSTOM_RULE);
expect(result.threatScore >= 50).toEqual(true);
});
// Test threat level classification
tap.test('ContentScanner - should classify threat levels correctly', async () => {
expect(ContentScanner.getThreatLevel(10)).toEqual('none');
expect(ContentScanner.getThreatLevel(25)).toEqual('low');
expect(ContentScanner.getThreatLevel(50)).toEqual('medium');
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
});
tap.test('cleanup - stop Rust security bridge', async () => {
await RustSecurityBridge.getInstance().stop();
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env tsx
/**
* Test DNS server configuration and record registration
*/
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// Test DNS configuration
const testDnsConfig = {
udpPort: 5353, // Use non-privileged port for testing
httpsPort: 8443,
httpsKey: './test/fixtures/test-key.pem',
httpsCert: './test/fixtures/test-cert.pem',
dnssecZone: 'test.example.com',
records: [
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
]
};
tap.test('DNS server configuration - should extract records correctly', async () => {
const { records, ...dnsServerOptions } = testDnsConfig;
expect(dnsServerOptions.udpPort).toEqual(5353);
expect(dnsServerOptions.httpsPort).toEqual(8443);
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
expect(records).toBeArray();
expect(records.length).toEqual(6);
});
tap.test('DNS server configuration - should handle record parsing', async () => {
const parseDnsRecordData = (type: string, value: string): any => {
switch (type) {
case 'A':
return value;
case 'MX':
const [priority, exchange] = value.split(' ');
return { priority: parseInt(priority), exchange };
case 'TXT':
return value;
case 'NS':
return value;
default:
return value;
}
};
// Test A record parsing
const aRecord = parseDnsRecordData('A', '192.168.1.1');
expect(aRecord).toEqual('192.168.1.1');
// Test MX record parsing
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
expect(mxRecord).toHaveProperty('priority', 10);
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
// Test TXT record parsing
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
});
tap.test('DNS server configuration - should group records by domain', async () => {
const records = testDnsConfig.records;
const recordsByDomain = new Map<string, typeof records>();
for (const record of records) {
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
if (!recordsByDomain.has(pattern)) {
recordsByDomain.set(pattern, []);
}
recordsByDomain.get(pattern)!.push(record);
}
// Check grouping
expect(recordsByDomain.size).toBeGreaterThan(0);
// Verify each group has records
for (const [pattern, domainRecords] of recordsByDomain) {
expect(domainRecords.length).toBeGreaterThan(0);
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
}
});
tap.test('DNS server configuration - should extract unique record types', async () => {
const records = testDnsConfig.records;
const recordTypes = [...new Set(records.map(r => r.type))];
expect(recordTypes).toContain('A');
expect(recordTypes).toContain('MX');
expect(recordTypes).toContain('TXT');
expect(recordTypes).toContain('NS');
console.log('Unique record types:', recordTypes.join(', '));
});
tap.test('DNS server - mock handler registration', async () => {
// Mock DNS server for testing
const mockDnsServer = {
handlers: new Map<string, any>(),
registerHandler: function(pattern: string, types: string[], handler: Function) {
this.handlers.set(pattern, { types, handler });
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
}
};
// Simulate record registration
const records = testDnsConfig.records;
const recordsByDomain = new Map<string, typeof records>();
for (const record of records) {
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
if (!recordsByDomain.has(pattern)) {
recordsByDomain.set(pattern, []);
}
recordsByDomain.get(pattern)!.push(record);
}
// Register handlers
for (const [domainPattern, domainRecords] of recordsByDomain) {
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
const matchingRecord = domainRecords.find(
r => r.name === question.name && r.type === question.type
);
return matchingRecord || null;
});
}
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
});
tap.start({
throwOnError: true
});

View File

@@ -0,0 +1,377 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { type IEmailRoute } from '../ts/mail/routing/interfaces.js';
import { EmailRouter } from '../ts/mail/routing/classes.email.router.js';
import { Email } from '../ts/mail/core/classes.email.js';
tap.test('Email Integration - Route-based forwarding scenario', async () => {
// Define routes with match/action pattern
const routes: IEmailRoute[] = [
{
name: 'office-relay',
priority: 100,
match: {
clientIp: '192.168.0.0/16'
},
action: {
type: 'forward',
forward: {
host: 'internal.mail.example.com',
port: 25
}
}
},
{
name: 'company-mail',
priority: 50,
match: {
recipients: '*@mycompany.com'
},
action: {
type: 'process',
process: {
scan: true,
dkim: true,
queue: 'normal'
}
}
},
{
name: 'admin-priority',
priority: 90,
match: {
recipients: 'admin@mycompany.com'
},
action: {
type: 'process',
process: {
scan: true,
dkim: true,
queue: 'priority'
}
}
},
{
name: 'spam-reject',
priority: 80,
match: {
senders: '*@spammer.com'
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Sender blocked'
}
}
},
{
name: 'default-reject',
priority: 1,
match: {
recipients: '*'
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Relay denied'
}
}
}
];
// Create email router with routes
const emailRouter = new EmailRouter(routes);
// Test route priority sorting
const sortedRoutes = emailRouter.getRoutes();
expect(sortedRoutes[0].name).toEqual('office-relay'); // Highest priority (100)
expect(sortedRoutes[1].name).toEqual('admin-priority'); // Priority 90
expect(sortedRoutes[2].name).toEqual('spam-reject'); // Priority 80
expect(sortedRoutes[sortedRoutes.length - 1].name).toEqual('default-reject'); // Lowest priority (1)
// Test route evaluation with different scenarios
const testCases = [
{
description: 'Office relay scenario (IP-based)',
email: new Email({
from: 'user@external.com',
to: 'anyone@anywhere.com',
subject: 'Test from office',
text: 'Test message'
}),
session: {
id: 'test-1',
remoteAddress: '192.168.1.100'
},
expectedRoute: 'office-relay'
},
{
description: 'Admin priority mail',
email: new Email({
from: 'user@external.com',
to: 'admin@mycompany.com',
subject: 'Important admin message',
text: 'Admin message content'
}),
session: {
id: 'test-2',
remoteAddress: '10.0.0.1'
},
expectedRoute: 'admin-priority'
},
{
description: 'Company mail processing',
email: new Email({
from: 'partner@partner.com',
to: 'sales@mycompany.com',
subject: 'Business proposal',
text: 'Business content'
}),
session: {
id: 'test-3',
remoteAddress: '203.0.113.1'
},
expectedRoute: 'company-mail'
},
{
description: 'Spam rejection',
email: new Email({
from: 'bad@spammer.com',
to: 'victim@mycompany.com',
subject: 'Spam message',
text: 'Spam content'
}),
session: {
id: 'test-4',
remoteAddress: '203.0.113.2'
},
expectedRoute: 'spam-reject'
},
{
description: 'Default rejection',
email: new Email({
from: 'unknown@unknown.com',
to: 'random@random.com',
subject: 'Random message',
text: 'Random content'
}),
session: {
id: 'test-5',
remoteAddress: '203.0.113.3'
},
expectedRoute: 'default-reject'
}
];
for (const testCase of testCases) {
const context = {
email: testCase.email,
session: testCase.session as any
};
const matchedRoute = await emailRouter.evaluateRoutes(context);
expect(matchedRoute).not.toEqual(null);
expect(matchedRoute?.name).toEqual(testCase.expectedRoute);
console.log(`${testCase.description}: Matched route '${matchedRoute?.name}'`);
}
});
tap.test('Email Integration - CIDR IP matching', async () => {
const routes: IEmailRoute[] = [
{
name: 'internal-network',
match: { clientIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] },
action: { type: 'deliver' }
},
{
name: 'specific-subnet',
priority: 10,
match: { clientIp: '192.168.1.0/24' },
action: { type: 'forward', forward: { host: 'subnet-mail.com', port: 25 } }
}
];
const emailRouter = new EmailRouter(routes);
const testIps = [
{ ip: '192.168.1.100', expectedRoute: 'specific-subnet' }, // More specific match
{ ip: '192.168.2.100', expectedRoute: 'internal-network' }, // General internal
{ ip: '10.5.10.20', expectedRoute: 'internal-network' },
{ ip: '172.16.5.10', expectedRoute: 'internal-network' }
];
for (const testCase of testIps) {
const context = {
email: new Email({ from: 'test@test.com', to: 'user@test.com', subject: 'Test', text: 'Test' }),
session: { id: 'test', remoteAddress: testCase.ip } as any
};
const route = await emailRouter.evaluateRoutes(context);
expect(route?.name).toEqual(testCase.expectedRoute);
console.log(`✓ IP ${testCase.ip}: Matched route '${route?.name}'`);
}
});
tap.test('Email Integration - Authentication-based routing', async () => {
const routes: IEmailRoute[] = [
{
name: 'authenticated-relay',
priority: 100,
match: { authenticated: true },
action: {
type: 'forward',
forward: { host: 'relay.example.com', port: 587 }
}
},
{
name: 'unauthenticated-local',
match: {
authenticated: false,
recipients: '*@localserver.com'
},
action: { type: 'deliver' }
},
{
name: 'unauthenticated-reject',
match: { authenticated: false },
action: {
type: 'reject',
reject: { code: 550, message: 'Authentication required' }
}
}
];
const emailRouter = new EmailRouter(routes);
// Test authenticated user
const authContext = {
email: new Email({ from: 'user@anywhere.com', to: 'dest@anywhere.com', subject: 'Test', text: 'Test' }),
session: {
id: 'auth-test',
remoteAddress: '203.0.113.1',
authenticated: true,
authenticatedUser: 'user@anywhere.com'
} as any
};
const authRoute = await emailRouter.evaluateRoutes(authContext);
expect(authRoute?.name).toEqual('authenticated-relay');
// Test unauthenticated local delivery
const localContext = {
email: new Email({ from: 'external@external.com', to: 'user@localserver.com', subject: 'Test', text: 'Test' }),
session: {
id: 'local-test',
remoteAddress: '203.0.113.2',
authenticated: false
} as any
};
const localRoute = await emailRouter.evaluateRoutes(localContext);
expect(localRoute?.name).toEqual('unauthenticated-local');
// Test unauthenticated rejection
const rejectContext = {
email: new Email({ from: 'external@external.com', to: 'user@external.com', subject: 'Test', text: 'Test' }),
session: {
id: 'reject-test',
remoteAddress: '203.0.113.3',
authenticated: false
} as any
};
const rejectRoute = await emailRouter.evaluateRoutes(rejectContext);
expect(rejectRoute?.name).toEqual('unauthenticated-reject');
console.log('✓ Authentication-based routing works correctly');
});
tap.test('Email Integration - Pattern caching performance', async () => {
const routes: IEmailRoute[] = [
{
name: 'complex-pattern',
match: {
recipients: ['*@domain1.com', '*@domain2.com', 'admin@*.domain3.com'],
senders: 'partner-*@*.partner.net'
},
action: { type: 'forward', forward: { host: 'partner-relay.com', port: 25 } }
}
];
const emailRouter = new EmailRouter(routes);
const email = new Email({
from: 'partner-sales@us.partner.net',
to: 'admin@sales.domain3.com',
subject: 'Test',
text: 'Test'
});
const context = {
email,
session: { id: 'perf-test', remoteAddress: '10.0.0.1' } as any
};
// First evaluation - should populate cache
const start1 = Date.now();
const route1 = await emailRouter.evaluateRoutes(context);
const time1 = Date.now() - start1;
// Second evaluation - should use cache
const start2 = Date.now();
const route2 = await emailRouter.evaluateRoutes(context);
const time2 = Date.now() - start2;
expect(route1?.name).toEqual('complex-pattern');
expect(route2?.name).toEqual('complex-pattern');
// Cache should make second evaluation faster (though this is timing-dependent)
console.log(`✓ Pattern caching: First evaluation: ${time1}ms, Second: ${time2}ms`);
});
tap.test('Email Integration - Route update functionality', async () => {
const initialRoutes: IEmailRoute[] = [
{
name: 'test-route',
match: { recipients: '*@test.com' },
action: { type: 'deliver' }
}
];
const emailRouter = new EmailRouter(initialRoutes);
// Test initial configuration
expect(emailRouter.getRoutes().length).toEqual(1);
expect(emailRouter.getRoutes()[0].name).toEqual('test-route');
// Update routes
const newRoutes: IEmailRoute[] = [
{
name: 'updated-route',
match: { recipients: '*@updated.com' },
action: { type: 'forward', forward: { host: 'new-server.com', port: 25 } }
},
{
name: 'additional-route',
match: { recipients: '*@additional.com' },
action: { type: 'reject', reject: { code: 550, message: 'Blocked' } }
}
];
emailRouter.updateRoutes(newRoutes);
// Verify routes were updated
expect(emailRouter.getRoutes().length).toEqual(2);
expect(emailRouter.getRoutes()[0].name).toEqual('updated-route');
expect(emailRouter.getRoutes()[1].name).toEqual('additional-route');
console.log('✓ Route update functionality works correctly');
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

283
test/test.email.router.ts Normal file
View File

@@ -0,0 +1,283 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmailRouter, type IEmailRoute, type IEmailContext } from '../ts/mail/routing/index.js';
import { Email } from '../ts/mail/core/classes.email.js';
tap.test('EmailRouter - should create and manage routes', async () => {
const router = new EmailRouter([]);
// Test initial state
expect(router.getRoutes()).toEqual([]);
// Add some test routes
const routes: IEmailRoute[] = [
{
name: 'forward-example',
priority: 10,
match: {
recipients: '*@example.com'
},
action: {
type: 'forward',
forward: {
host: 'mail.example.com',
port: 25
}
}
},
{
name: 'reject-spam',
priority: 20,
match: {
senders: '*@spammer.com'
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Spam not allowed'
}
}
}
];
router.updateRoutes(routes);
expect(router.getRoutes().length).toEqual(2);
});
tap.test('EmailRouter - should evaluate routes based on priority', async () => {
const router = new EmailRouter([]);
const routes: IEmailRoute[] = [
{
name: 'low-priority',
priority: 5,
match: {
recipients: '*@test.com'
},
action: {
type: 'deliver'
}
},
{
name: 'high-priority',
priority: 10,
match: {
recipients: 'admin@test.com'
},
action: {
type: 'process',
process: {
scan: true
}
}
}
];
router.updateRoutes(routes);
// Create test context
const email = new Email({
from: 'sender@example.com',
to: 'admin@test.com',
subject: 'Test email',
text: 'Test email content'
});
const context: IEmailContext = {
email,
session: {
id: 'test-session',
remoteAddress: '192.168.1.1',
matchedRoute: null
} as any
};
const route = await router.evaluateRoutes(context);
expect(route).not.toEqual(null);
expect(route?.name).toEqual('high-priority');
});
tap.test('EmailRouter - should match recipient patterns', async () => {
const router = new EmailRouter([]);
const routes: IEmailRoute[] = [
{
name: 'exact-match',
match: {
recipients: 'admin@example.com'
},
action: {
type: 'forward',
forward: {
host: 'admin-server.com',
port: 25
}
}
},
{
name: 'wildcard-match',
match: {
recipients: '*@example.com'
},
action: {
type: 'deliver'
}
}
];
router.updateRoutes(routes);
// Test exact match
const email1 = new Email({
from: 'sender@test.com',
to: 'admin@example.com',
subject: 'Admin email',
text: 'Admin email content'
});
const context1: IEmailContext = {
email: email1,
session: { id: 'test1', remoteAddress: '10.0.0.1' } as any
};
const route1 = await router.evaluateRoutes(context1);
expect(route1?.name).toEqual('exact-match');
// Test wildcard match
const email2 = new Email({
from: 'sender@test.com',
to: 'user@example.com',
subject: 'User email',
text: 'User email content'
});
const context2: IEmailContext = {
email: email2,
session: { id: 'test2', remoteAddress: '10.0.0.2' } as any
};
const route2 = await router.evaluateRoutes(context2);
expect(route2?.name).toEqual('wildcard-match');
});
tap.test('EmailRouter - should match IP ranges with CIDR notation', async () => {
const router = new EmailRouter([]);
const routes: IEmailRoute[] = [
{
name: 'internal-network',
match: {
clientIp: '10.0.0.0/24'
},
action: {
type: 'deliver'
}
},
{
name: 'external-network',
match: {
clientIp: ['192.168.1.0/24', '172.16.0.0/16']
},
action: {
type: 'process',
process: {
scan: true
}
}
}
];
router.updateRoutes(routes);
// Test internal network match
const email = new Email({
from: 'internal@company.com',
to: 'user@company.com',
subject: 'Internal email',
text: 'Internal email content'
});
const context1: IEmailContext = {
email,
session: { id: 'test1', remoteAddress: '10.0.0.15' } as any
};
const route1 = await router.evaluateRoutes(context1);
expect(route1?.name).toEqual('internal-network');
// Test external network match
const context2: IEmailContext = {
email,
session: { id: 'test2', remoteAddress: '192.168.1.100' } as any
};
const route2 = await router.evaluateRoutes(context2);
expect(route2?.name).toEqual('external-network');
});
tap.test('EmailRouter - should handle authentication matching', async () => {
const router = new EmailRouter([]);
const routes: IEmailRoute[] = [
{
name: 'authenticated-users',
match: {
authenticated: true
},
action: {
type: 'deliver'
}
},
{
name: 'unauthenticated-users',
match: {
authenticated: false
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Authentication required'
}
}
}
];
router.updateRoutes(routes);
const email = new Email({
from: 'user@example.com',
to: 'recipient@test.com',
subject: 'Test',
text: 'Test content'
});
// Test authenticated session
const context1: IEmailContext = {
email,
session: {
id: 'test1',
remoteAddress: '10.0.0.1',
authenticated: true,
authenticatedUser: 'user@example.com'
} as any
};
const route1 = await router.evaluateRoutes(context1);
expect(route1?.name).toEqual('authenticated-users');
// Test unauthenticated session
const context2: IEmailContext = {
email,
session: {
id: 'test2',
remoteAddress: '10.0.0.2',
authenticated: false
} as any
};
const route2 = await router.evaluateRoutes(context2);
expect(route2?.name).toEqual('unauthenticated-users');
});
export default tap.start();

195
test/test.emailauth.ts Normal file
View File

@@ -0,0 +1,195 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js';
import { Email } from '../ts/mail/core/classes.email.js';
/**
* Test email authentication systems: SPF and DMARC
*/
// SPF Verifier Tests
tap.test('SPF Verifier - should parse SPF record', async () => {
const spfVerifier = new SpfVerifier();
// Test valid SPF record parsing
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
const parsedRecord = spfVerifier.parseSpfRecord(record);
expect(parsedRecord).toBeTruthy();
expect(parsedRecord.version).toEqual('spf1');
expect(parsedRecord.mechanisms.length).toEqual(5);
// Check specific mechanisms
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
// Test invalid record
const invalidRecord = 'not-a-spf-record';
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
expect(invalidParsed).toBeNull();
});
// DMARC Verifier Tests
tap.test('DMARC Verifier - should parse DMARC record', async () => {
const dmarcVerifier = new DmarcVerifier();
// Test valid DMARC record parsing
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
expect(parsedRecord).toBeTruthy();
expect(parsedRecord.version).toEqual('DMARC1');
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
expect(parsedRecord.pct).toEqual(50);
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
// Test invalid record
const invalidRecord = 'not-a-dmarc-record';
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
expect(invalidParsed).toBeNull();
});
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
const dmarcVerifier = new DmarcVerifier();
// Test email domains with DMARC alignment
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.net',
subject: 'Test DMARC alignment',
text: 'This is a test email'
});
// Test when both SPF and DKIM pass with alignment
const dmarcResult = await dmarcVerifier.verify(
email,
{ domain: 'example.com', result: true }, // SPF - aligned and passed
{ domain: 'example.com', result: true } // DKIM - aligned and passed
);
expect(dmarcResult).toBeTruthy();
expect(dmarcResult.spfPassed).toEqual(true);
expect(dmarcResult.dkimPassed).toEqual(true);
expect(dmarcResult.spfDomainAligned).toEqual(true);
expect(dmarcResult.dkimDomainAligned).toEqual(true);
expect(dmarcResult.action).toEqual('pass');
// Test when neither SPF nor DKIM is aligned
const dmarcResult2 = await dmarcVerifier.verify(
email,
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
);
// Without a DNS manager, no DMARC record will be found
expect(dmarcResult2).toBeTruthy();
expect(dmarcResult2.spfPassed).toEqual(true);
expect(dmarcResult2.dkimPassed).toEqual(true);
expect(dmarcResult2.spfDomainAligned).toEqual(false);
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
// Without a DMARC record, the default action is 'pass'
expect(dmarcResult2.hasDmarc).toEqual(false);
expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE);
expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE);
expect(dmarcResult2.action).toEqual('pass');
});
tap.test('DMARC Verifier - should apply policy correctly', async () => {
const dmarcVerifier = new DmarcVerifier();
// Create test email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.net',
subject: 'Test DMARC policy application',
text: 'This is a test email'
});
// Test pass action
const passResult: any = {
hasDmarc: true,
spfDomainAligned: true,
dkimDomainAligned: true,
spfPassed: true,
dkimPassed: true,
policyEvaluated: DmarcPolicy.NONE,
actualPolicy: DmarcPolicy.NONE,
appliedPercentage: 100,
action: 'pass',
details: 'DMARC passed'
};
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
expect(passApplied).toEqual(true);
expect(email.mightBeSpam).toEqual(false);
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
// Test quarantine action
const quarantineResult: any = {
hasDmarc: true,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: false,
dkimPassed: false,
policyEvaluated: DmarcPolicy.QUARANTINE,
actualPolicy: DmarcPolicy.QUARANTINE,
appliedPercentage: 100,
action: 'quarantine',
details: 'DMARC failed, policy=quarantine'
};
// Reset email spam flag
email.mightBeSpam = false;
email.headers = {};
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
expect(quarantineApplied).toEqual(true);
expect(email.mightBeSpam).toEqual(true);
expect(email.headers['X-Spam-Flag']).toEqual('YES');
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
// Test reject action
const rejectResult: any = {
hasDmarc: true,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: false,
dkimPassed: false,
policyEvaluated: DmarcPolicy.REJECT,
actualPolicy: DmarcPolicy.REJECT,
appliedPercentage: 100,
action: 'reject',
details: 'DMARC failed, policy=reject'
};
// Reset email spam flag
email.mightBeSpam = false;
email.headers = {};
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
expect(rejectApplied).toEqual(false);
expect(email.mightBeSpam).toEqual(true);
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,116 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { IPReputationChecker, ReputationThreshold } from '../ts/security/classes.ipreputationchecker.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
let bridge: RustSecurityBridge;
// Start the Rust bridge before tests
tap.test('setup - start Rust security bridge', async () => {
bridge = RustSecurityBridge.getInstance();
const ok = await bridge.start();
expect(ok).toEqual(true);
});
// Test instantiation
tap.test('IPReputationChecker - should be instantiable', async () => {
const checker = IPReputationChecker.getInstance({
enableLocalCache: false
});
expect(checker).toBeTruthy();
});
// Test singleton pattern
tap.test('IPReputationChecker - should use singleton pattern', async () => {
const checker1 = IPReputationChecker.getInstance();
const checker2 = IPReputationChecker.getInstance();
expect(checker1 === checker2).toEqual(true);
});
// Test IP validation
tap.test('IPReputationChecker - should validate IP address format', async () => {
const checker = IPReputationChecker.getInstance();
// Invalid IP should fail with error
const invalidResult = await checker.checkReputation('invalid.ip');
expect(invalidResult.error).toBeTruthy();
});
// Test reputation check via Rust bridge
tap.test('IPReputationChecker - should check IP reputation via Rust', async () => {
const testInstance = new IPReputationChecker({
enableLocalCache: false,
maxCacheSize: 10
});
// Check a public IP (Google DNS) — should get a result with a score
const result = await testInstance.checkReputation('8.8.8.8');
expect(result).toBeTruthy();
expect(result.score).toBeGreaterThan(0);
expect(result.score).toBeLessThanOrEqual(100);
expect(typeof result.isSpam).toEqual('boolean');
expect(typeof result.isProxy).toEqual('boolean');
expect(typeof result.isTor).toEqual('boolean');
expect(typeof result.isVPN).toEqual('boolean');
expect(result.timestamp).toBeGreaterThan(0);
});
// Test caching behavior
tap.test('IPReputationChecker - should cache reputation results', async () => {
const testInstance = new IPReputationChecker({
enableLocalCache: false,
maxCacheSize: 10
});
const ip = '1.1.1.1';
// First check should add to cache
const result1 = await testInstance.checkReputation(ip);
expect(result1).toBeTruthy();
// Verify it's in cache
const hasInCache = (testInstance as any).reputationCache.has(ip);
expect(hasInCache).toEqual(true);
// Call again, should use cache
const result2 = await testInstance.checkReputation(ip);
expect(result2).toBeTruthy();
// Results should be identical (from cache)
expect(result1.score).toEqual(result2.score);
expect(result1.isSpam).toEqual(result2.isSpam);
});
// Test risk level classification
tap.test('IPReputationChecker - should classify risk levels correctly', async () => {
expect(IPReputationChecker.getRiskLevel(10)).toEqual('high');
expect(IPReputationChecker.getRiskLevel(30)).toEqual('medium');
expect(IPReputationChecker.getRiskLevel(60)).toEqual('low');
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
});
// Test error handling for error result
tap.test('IPReputationChecker - should handle errors gracefully', async () => {
const testInstance = new IPReputationChecker({
enableLocalCache: false,
maxCacheSize: 5
});
// Invalid format should return error result with neutral score
const result = await testInstance.checkReputation('not-an-ip');
expect(result.score).toEqual(50);
expect(result.error).toBeTruthy();
expect(result.isSpam).toEqual(false);
});
// Stop bridge
tap.test('cleanup - stop Rust security bridge', async () => {
await bridge.stop();
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

141
test/test.ratelimiter.ts Normal file
View File

@@ -0,0 +1,141 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
tap.test('RateLimiter - should be instantiable', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 10,
periodMs: 1000,
perKey: true
});
expect(limiter).toBeTruthy();
});
tap.test('RateLimiter - should allow requests within rate limit', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 5,
periodMs: 1000,
perKey: true
});
// Should allow 5 requests
for (let i = 0; i < 5; i++) {
expect(limiter.isAllowed('test')).toEqual(true);
}
// 6th request should be denied
expect(limiter.isAllowed('test')).toEqual(false);
});
tap.test('RateLimiter - should enforce per-key limits', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 3,
periodMs: 1000,
perKey: true
});
// Should allow 3 requests for key1
for (let i = 0; i < 3; i++) {
expect(limiter.isAllowed('key1')).toEqual(true);
}
// 4th request for key1 should be denied
expect(limiter.isAllowed('key1')).toEqual(false);
// But key2 should still be allowed
expect(limiter.isAllowed('key2')).toEqual(true);
});
tap.test('RateLimiter - should refill tokens over time', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 2,
periodMs: 100, // Short period for testing
perKey: true
});
// Use all tokens
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(false);
// Wait for refill
await new Promise(resolve => setTimeout(resolve, 150));
// Should have tokens again
expect(limiter.isAllowed('test')).toEqual(true);
});
tap.test('RateLimiter - should support burst allowance', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 2,
periodMs: 100,
perKey: true,
burstTokens: 2, // Allow 2 extra tokens for bursts
initialTokens: 4 // Start with max + burst tokens
});
// Should allow 4 requests (2 regular + 2 burst)
for (let i = 0; i < 4; i++) {
expect(limiter.isAllowed('test')).toEqual(true);
}
// 5th request should be denied
expect(limiter.isAllowed('test')).toEqual(false);
// Wait for refill
await new Promise(resolve => setTimeout(resolve, 150));
// Should have 2 tokens again (rate-limited to normal max, not burst)
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
// 3rd request after refill should fail (only normal max is refilled, not burst)
expect(limiter.isAllowed('test')).toEqual(false);
});
tap.test('RateLimiter - should return correct stats', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 10,
periodMs: 1000,
perKey: true
});
// Make some requests
limiter.isAllowed('test');
limiter.isAllowed('test');
limiter.isAllowed('test');
// Get stats
const stats = limiter.getStats('test');
expect(stats.remaining).toEqual(7);
expect(stats.limit).toEqual(10);
expect(stats.allowed).toEqual(3);
expect(stats.denied).toEqual(0);
});
tap.test('RateLimiter - should reset limits', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 3,
periodMs: 1000,
perKey: true
});
// Use all tokens
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(false);
// Reset
limiter.reset('test');
// Should have tokens again
expect(limiter.isAllowed('test')).toEqual(true);
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,136 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
let bridge: RustSecurityBridge;
tap.test('RustSecurityBridge - should get singleton instance', async () => {
bridge = RustSecurityBridge.getInstance();
expect(bridge).toBeTruthy();
expect(bridge).toEqual(RustSecurityBridge.getInstance()); // same instance
});
tap.test('RustSecurityBridge - should start the Rust binary', async () => {
const ok = await bridge.start();
if (!ok) {
console.log('WARNING: Rust binary not available — skipping bridge tests');
console.log('Build it with: cd rust && cargo build --release');
}
// We accept both true and false — the binary may not be built yet
expect(typeof ok).toEqual('boolean');
});
tap.test('RustSecurityBridge - ping should return pong', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const pong = await bridge.ping();
expect(pong).toBeTrue();
});
tap.test('RustSecurityBridge - version should return crate versions', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const version = await bridge.getVersion();
expect(version.bin).toBeTruthy();
expect(version.core).toBeTruthy();
expect(version.security).toBeTruthy();
});
tap.test('RustSecurityBridge - validateEmail with valid address', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const result = await bridge.validateEmail('test@example.com');
expect(result.valid).toBeTrue();
expect(result.formatValid).toBeTrue();
});
tap.test('RustSecurityBridge - validateEmail with invalid address', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const result = await bridge.validateEmail('not-an-email');
expect(result.formatValid).toBeFalse();
});
tap.test('RustSecurityBridge - detectBounce with known SMTP response', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const result = await bridge.detectBounce({
smtpResponse: '550 5.1.1 User unknown',
});
expect(result.bounce_type).toBeTruthy();
expect(result.category).toBeTruthy();
});
tap.test('RustSecurityBridge - checkIpReputation', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
// Use a well-known IP that should NOT be on blacklists
const result = await bridge.checkIpReputation('8.8.8.8');
expect(result.ip).toEqual('8.8.8.8');
expect(typeof result.score).toEqual('number');
expect(result.score).toBeGreaterThan(0);
});
tap.test('RustSecurityBridge - verifyDkim with unsigned message', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
const results = await bridge.verifyDkim(rawMessage);
expect(results.length).toBeGreaterThan(0);
expect(results[0].status).toEqual('none'); // no DKIM signature
});
tap.test('RustSecurityBridge - verifyEmail compound call', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
const result = await bridge.verifyEmail({
rawMessage,
ip: '93.184.216.34', // example.com IP
heloDomain: 'example.com',
hostname: 'mail.test.local',
mailFrom: 'test@example.com',
});
expect(result.dkim).toBeTruthy();
expect(result.dkim.length).toBeGreaterThan(0);
expect(result.spf).toBeTruthy();
// DMARC may or may not be present depending on DNS
});
tap.test('RustSecurityBridge - should stop gracefully', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
await bridge.stop();
expect(bridge.running).toBeFalse();
});
tap.test('RustSecurityBridge - commands should fail when bridge is stopped', async () => {
// Bridge should not be running now
expect(bridge.running).toBeFalse();
try {
await bridge.ping();
// If we get here, the bridge auto-restarted or something unexpected
expect(true).toBeFalse(); // Should not reach here
} catch (err) {
expect(err).toBeTruthy(); // Expected: bridge not running error
}
});
export default tap.start();

248
test/test.smartmail.ts Normal file
View File

@@ -0,0 +1,248 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
// Import the components we want to test
import { EmailValidator } from '../ts/mail/core/classes.emailvalidator.js';
import { TemplateManager } from '../ts/mail/core/classes.templatemanager.js';
import { Email } from '../ts/mail/core/classes.email.js';
// Ensure test directories exist
paths.ensureDirectories();
tap.test('EmailValidator - should validate email formats correctly', async (tools) => {
const validator = new EmailValidator();
// Test valid email formats
expect(validator.isValidFormat('user@example.com')).toEqual(true);
expect(validator.isValidFormat('firstname.lastname@example.com')).toEqual(true);
expect(validator.isValidFormat('user+tag@example.com')).toEqual(true);
// Test invalid email formats
expect(validator.isValidFormat('user@')).toEqual(false);
expect(validator.isValidFormat('@example.com')).toEqual(false);
expect(validator.isValidFormat('user@example')).toEqual(false);
expect(validator.isValidFormat('user.example.com')).toEqual(false);
});
tap.test('EmailValidator - should perform comprehensive validation', async (tools) => {
const validator = new EmailValidator();
// Test basic validation (syntax-only)
const basicResult = await validator.validate('user@example.com', { checkSyntaxOnly: true });
expect(basicResult.isValid).toEqual(true);
expect(basicResult.details.formatValid).toEqual(true);
// We can't reliably test MX validation in all environments, but the function should run
const mxResult = await validator.validate('user@example.com', { checkMx: true });
expect(typeof mxResult.isValid).toEqual('boolean');
expect(typeof mxResult.hasMx).toEqual('boolean');
});
tap.test('EmailValidator - should detect invalid emails', async (tools) => {
const validator = new EmailValidator();
const invalidResult = await validator.validate('invalid@@example.com', { checkSyntaxOnly: true });
expect(invalidResult.isValid).toEqual(false);
expect(invalidResult.details.formatValid).toEqual(false);
});
tap.test('TemplateManager - should register and retrieve templates', async (tools) => {
const templateManager = new TemplateManager({
from: 'test@example.com'
});
// Register a custom template
templateManager.registerTemplate({
id: 'test-template',
name: 'Test Template',
description: 'A test template',
from: 'test@example.com',
subject: 'Test Subject: {{name}}',
bodyHtml: '<p>Hello, {{name}}!</p>',
bodyText: 'Hello, {{name}}!',
category: 'test'
});
// Get the template back
const template = templateManager.getTemplate('test-template');
expect(template).toBeTruthy();
expect(template.id).toEqual('test-template');
expect(template.subject).toEqual('Test Subject: {{name}}');
// List templates
const templates = templateManager.listTemplates();
expect(templates.length > 0).toEqual(true);
expect(templates.some(t => t.id === 'test-template')).toEqual(true);
});
tap.test('TemplateManager - should create email from template', async (tools) => {
const templateManager = new TemplateManager({
from: 'test@example.com'
});
// Register a template
templateManager.registerTemplate({
id: 'welcome-test',
name: 'Welcome Test',
description: 'A welcome test template',
from: 'welcome@example.com',
subject: 'Welcome, {{name}}!',
bodyHtml: '<p>Hello, {{name}}! Welcome to our service.</p>',
bodyText: 'Hello, {{name}}! Welcome to our service.',
category: 'test'
});
// Create email from template
const email = await templateManager.createEmail('welcome-test', {
name: 'John Doe'
});
expect(email).toBeTruthy();
expect(email.from).toEqual('welcome@example.com');
expect(email.getSubjectWithVariables()).toEqual('Welcome, John Doe!');
expect(email.getHtmlWithVariables()?.indexOf('Hello, John Doe!') > -1).toEqual(true);
});
tap.test('Email - should handle template variables', async (tools) => {
// Create email with variables
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Hello {{name}}!',
text: 'Welcome, {{name}}! Your order #{{orderId}} has been processed.',
html: '<p>Welcome, <strong>{{name}}</strong>! Your order #{{orderId}} has been processed.</p>',
variables: {
name: 'John Doe',
orderId: '12345'
}
});
// Test variable substitution
expect(email.getSubjectWithVariables()).toEqual('Hello John Doe!');
expect(email.getTextWithVariables()).toEqual('Welcome, John Doe! Your order #12345 has been processed.');
expect(email.getHtmlWithVariables().indexOf('<strong>John Doe</strong>') > -1).toEqual(true);
// Test with additional variables
const additionalVars = {
name: 'Jane Smith', // Override existing variable
status: 'shipped' // Add new variable
};
expect(email.getSubjectWithVariables(additionalVars)).toEqual('Hello Jane Smith!');
// Add a new variable
email.setVariable('trackingNumber', 'TRK123456');
expect(email.getTextWithVariables().indexOf('12345') > -1).toEqual(true);
// Update multiple variables at once
email.setVariables({
orderId: '67890',
status: 'delivered'
});
expect(email.getTextWithVariables().indexOf('67890') > -1).toEqual(true);
});
tap.test('Email and Smartmail compatibility - should convert between formats', async (tools) => {
// Create a Smartmail instance
const smartmail = new plugins.smartmail.Smartmail({
from: 'smartmail@example.com',
subject: 'Test Subject',
body: '<p>This is a test email.</p>',
creationObjectRef: {
orderId: '12345'
}
});
// Add recipient and attachment
smartmail.addRecipient('recipient@example.com');
const attachment = plugins.smartfile.SmartFileFactory.nodeFs().fromString(
'test.txt',
'This is a test attachment',
'utf8',
);
smartmail.addAttachment(attachment);
// Convert to Email
const resolvedSmartmail = await smartmail;
const email = Email.fromSmartmail(resolvedSmartmail);
// Verify first conversion (Smartmail to Email)
expect(email.from).toEqual('smartmail@example.com');
expect(email.to.indexOf('recipient@example.com') > -1).toEqual(true);
expect(email.subject).toEqual('Test Subject');
expect(email.html?.indexOf('This is a test email') > -1).toEqual(true);
expect(email.attachments.length).toEqual(1);
// Convert back to Smartmail
const convertedSmartmail = await email.toSmartmail();
// Verify second conversion (Email back to Smartmail) with simplified assertions
expect(convertedSmartmail.options.from).toEqual('smartmail@example.com');
expect(Array.isArray(convertedSmartmail.options.to)).toEqual(true);
expect(convertedSmartmail.options.to.length).toEqual(1);
expect(convertedSmartmail.getSubject()).toEqual('Test Subject');
expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toEqual(true);
expect(convertedSmartmail.attachments.length).toEqual(1);
});
tap.test('Email - should validate email addresses', async (tools) => {
// Attempt to create an email with invalid addresses
let errorThrown = false;
try {
const email = new Email({
from: 'invalid-email',
to: 'recipient@example.com',
subject: 'Test',
text: 'Test'
});
} catch (error) {
errorThrown = true;
expect(error.message.indexOf('Invalid sender email address') > -1).toEqual(true);
}
expect(errorThrown).toEqual(true);
// Attempt with invalid recipient
errorThrown = false;
try {
const email = new Email({
from: 'sender@example.com',
to: 'invalid-recipient',
subject: 'Test',
text: 'Test'
});
} catch (error) {
errorThrown = true;
expect(error.message.indexOf('Invalid recipient email address') > -1).toEqual(true);
}
expect(errorThrown).toEqual(true);
// Valid email should not throw
let validEmail: Email;
try {
validEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test',
text: 'Test'
});
expect(validEmail).toBeTruthy();
expect(validEmail.from).toEqual('sender@example.com');
} catch (error) {
expect(error === undefined).toEqual(true); // This should not happen
}
});
tap.test('stop', async () => {
tap.stopForcefully();
})
export default tap.start();

View File

@@ -0,0 +1,154 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { smtpClientMod } from '../ts/mail/delivery/index.js';
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
import { Email } from '../ts/mail/core/classes.email.js';
/**
* Compatibility tests for the legacy SMTP client facade
*/
tap.test('verify backward compatibility - client creation', async () => {
// Create test configuration
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false,
connectionTimeout: 10000,
domain: 'test.example.com'
};
// Create SMTP client instance using legacy constructor
const smtpClient = smtpClientMod.createSmtpClient(options);
// Verify instance was created correctly
expect(smtpClient).toBeTruthy();
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
});
tap.test('verify backward compatibility - methods exist', async () => {
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false
};
const smtpClient = smtpClientMod.createSmtpClient(options);
// Verify all expected methods exist
expect(typeof smtpClient.sendMail === 'function').toBeTruthy();
expect(typeof smtpClient.verify === 'function').toBeTruthy();
expect(typeof smtpClient.isConnected === 'function').toBeTruthy();
expect(typeof smtpClient.getPoolStatus === 'function').toBeTruthy();
expect(typeof smtpClient.updateOptions === 'function').toBeTruthy();
expect(typeof smtpClient.close === 'function').toBeTruthy();
expect(typeof smtpClient.on === 'function').toBeTruthy();
expect(typeof smtpClient.off === 'function').toBeTruthy();
expect(typeof smtpClient.emit === 'function').toBeTruthy();
});
tap.test('verify backward compatibility - options update', async () => {
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false
};
const smtpClient = smtpClientMod.createSmtpClient(options);
// Test option updates don't throw
expect(() => smtpClient.updateOptions({
host: 'new-smtp.example.com',
port: 465,
secure: true
})).not.toThrow();
expect(() => smtpClient.updateOptions({
debug: true,
connectionTimeout: 5000
})).not.toThrow();
});
tap.test('verify backward compatibility - connection failure handling', async () => {
const options: ISmtpClientOptions = {
host: 'nonexistent.invalid.domain',
port: 587,
secure: false,
connectionTimeout: 1000 // Short timeout for faster test
};
const smtpClient = smtpClientMod.createSmtpClient(options);
// verify() should return false for invalid hosts
const isValid = await smtpClient.verify();
expect(isValid).toBeFalsy();
// sendMail should fail gracefully for invalid hosts
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: 'Test Email',
text: 'This is a test email'
});
try {
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalsy();
expect(result.error).toBeTruthy();
} catch (error) {
// Connection errors are expected for invalid domains
expect(error).toBeTruthy();
}
});
tap.test('verify backward compatibility - pool status', async () => {
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false,
pool: true,
maxConnections: 5
};
const smtpClient = smtpClientMod.createSmtpClient(options);
// Get pool status
const status = smtpClient.getPoolStatus();
expect(status).toBeTruthy();
expect(typeof status.total === 'number').toBeTruthy();
expect(typeof status.active === 'number').toBeTruthy();
expect(typeof status.idle === 'number').toBeTruthy();
expect(typeof status.pending === 'number').toBeTruthy();
// Initially should have no connections
expect(status.total).toEqual(0);
expect(status.active).toEqual(0);
expect(status.idle).toEqual(0);
expect(status.pending).toEqual(0);
});
tap.test('verify backward compatibility - event handling', async () => {
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false
};
const smtpClient = smtpClientMod.createSmtpClient(options);
// Test event listener methods don't throw
const testListener = () => {};
expect(() => smtpClient.on('test', testListener)).not.toThrow();
expect(() => smtpClient.off('test', testListener)).not.toThrow();
expect(() => smtpClient.emit('test')).not.toThrow();
});
tap.test('clean up after compatibility tests', async () => {
// No-op - just to make sure everything is cleaned up properly
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

191
test/test.smtp.client.ts Normal file
View File

@@ -0,0 +1,191 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import { smtpClientMod } from '../ts/mail/delivery/index.js';
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
import { Email } from '../ts/mail/core/classes.email.js';
/**
* Tests for the SMTP client class
*/
tap.test('verify SMTP client initialization', async () => {
// Create test configuration
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false,
connectionTimeout: 10000,
domain: 'test.example.com'
};
// Create SMTP client instance
const smtpClient = smtpClientMod.createSmtpClient(options);
// Verify instance was created correctly
expect(smtpClient).toBeTruthy();
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
});
tap.test('test SMTP client configuration update', async () => {
// Create test configuration
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false
};
// Create SMTP client instance
const smtpClient = smtpClientMod.createSmtpClient(options);
// Update configuration
smtpClient.updateOptions({
host: 'new-smtp.example.com',
port: 465,
secure: true
});
// Can't directly test private fields, but we can verify it doesn't throw
expect(() => smtpClient.updateOptions({
tls: {
rejectUnauthorized: false
}
})).not.toThrow();
});
// Mocked SMTP server for testing
class MockSmtpServer {
private responses: Map<string, string>;
constructor() {
this.responses = new Map();
// Default responses
this.responses.set('connect', '220 smtp.example.com ESMTP ready');
this.responses.set('EHLO', '250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 HELP');
this.responses.set('MAIL FROM', '250 OK');
this.responses.set('RCPT TO', '250 OK');
this.responses.set('DATA', '354 Start mail input; end with <CRLF>.<CRLF>');
this.responses.set('data content', '250 OK: message accepted');
this.responses.set('QUIT', '221 Bye');
}
public setResponse(command: string, response: string): void {
this.responses.set(command, response);
}
public getResponse(command: string): string {
if (command.startsWith('MAIL FROM')) {
return this.responses.get('MAIL FROM') || '250 OK';
} else if (command.startsWith('RCPT TO')) {
return this.responses.get('RCPT TO') || '250 OK';
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
return this.responses.get('EHLO') || '250 OK';
} else if (command === 'DATA') {
return this.responses.get('DATA') || '354 Start mail input; end with <CRLF>.<CRLF>';
} else if (command.includes('Content-Type')) {
return this.responses.get('data content') || '250 OK: message accepted';
} else if (command === 'QUIT') {
return this.responses.get('QUIT') || '221 Bye';
}
return this.responses.get(command) || '250 OK';
}
}
/**
* This test validates the SMTP client public interface
*/
tap.test('verify SMTP client email delivery functionality with mock', async () => {
// Create a test email
const testEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test Email',
text: 'This is a test email'
});
// Create SMTP client options
const options: ISmtpClientOptions = {
host: 'smtp.example.com',
port: 587,
secure: false,
domain: 'test.example.com',
auth: {
user: 'testuser',
pass: 'testpass'
}
};
// Create SMTP client instance
const smtpClient = smtpClientMod.createSmtpClient(options);
// Test public methods exist and have correct signatures
expect(typeof smtpClient.sendMail).toEqual('function');
expect(typeof smtpClient.verify).toEqual('function');
expect(typeof smtpClient.isConnected).toEqual('function');
expect(typeof smtpClient.getPoolStatus).toEqual('function');
expect(typeof smtpClient.updateOptions).toEqual('function');
expect(typeof smtpClient.close).toEqual('function');
// Test connection status before any operation
expect(smtpClient.isConnected()).toBeFalsy();
// Test pool status
const poolStatus = smtpClient.getPoolStatus();
expect(poolStatus).toBeTruthy();
expect(typeof poolStatus.active).toEqual('number');
expect(typeof poolStatus.idle).toEqual('number');
expect(typeof poolStatus.total).toEqual('number');
// Since we can't connect to a real server, we'll skip the actual send test
// and just verify the client was created correctly
expect(smtpClient).toBeTruthy();
});
tap.test('test SMTP client error handling with mock', async () => {
// Create SMTP client instance
const smtpClient = smtpClientMod.createSmtpClient({
host: 'smtp.example.com',
port: 587,
secure: false
});
// Test with valid email (Email class might allow any string)
const testEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test Email',
text: 'This is a test email'
});
// Test event listener methods
const mockListener = () => {};
smtpClient.on('test-event', mockListener);
smtpClient.off('test-event', mockListener);
// Test update options
smtpClient.updateOptions({
auth: {
user: 'newuser',
pass: 'newpass'
}
});
// Verify client is still functional
expect(smtpClient.isConnected()).toBeFalsy();
// Test close on a non-connected client
await smtpClient.close();
expect(smtpClient.isConnected()).toBeFalsy();
});
// Final clean-up test
tap.test('clean up after tests', async () => {
// No-op - just to make sure everything is cleaned up properly
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -2,7 +2,7 @@
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/mailer',
version: '1.2.1',
description: 'Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure'
name: '@push.rocks/smartmta',
version: '2.4.0',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
}

View File

@@ -1,73 +0,0 @@
/**
* API Server
* HTTP REST API compatible with Mailgun
*/
import * as plugins from '../plugins.ts';
export interface IApiServerOptions {
port: number;
apiKeys: string[];
}
export class ApiServer {
private server: Deno.HttpServer | null = null;
constructor(private options: IApiServerOptions) {}
/**
* Start the API server
*/
async start(): Promise<void> {
console.log(`[ApiServer] Starting on port ${this.options.port}...`);
this.server = Deno.serve({ port: this.options.port }, (req) => {
return this.handleRequest(req);
});
}
/**
* Stop the API server
*/
async stop(): Promise<void> {
console.log('[ApiServer] Stopping...');
if (this.server) {
await this.server.shutdown();
this.server = null;
}
}
/**
* Handle incoming HTTP request
*/
private async handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
// Basic routing
if (url.pathname === '/v1/messages' && req.method === 'POST') {
return this.handleSendEmail(req);
}
if (url.pathname === '/v1/domains' && req.method === 'GET') {
return this.handleListDomains(req);
}
return new Response('Not Found', { status: 404 });
}
private async handleSendEmail(req: Request): Promise<Response> {
// TODO: Implement email sending
return new Response(JSON.stringify({ message: 'Email queued' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
private async handleListDomains(req: Request): Promise<Response> {
// TODO: Implement domain listing
return new Response(JSON.stringify({ domains: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@@ -1,7 +0,0 @@
/**
* HTTP REST API module
* Mailgun-compatible API for sending and receiving emails
*/
export * from './api-server.ts';
export * from './routes/index.ts';

View File

@@ -1,10 +0,0 @@
/**
* API Routes
* Route handlers for the REST API
*/
// TODO: Implement route handlers
// - POST /v1/messages - Send email
// - GET/POST/DELETE /v1/domains - Domain management
// - GET/POST /v1/domains/:domain/credentials - SMTP credentials
// - GET /v1/events - Email events and logs

View File

@@ -1,26 +0,0 @@
/**
* Mailer class stub
* Main mailer application class (replaces DcRouter from dcrouter)
*/
import { StorageManager } from './storage/index.ts';
import type { IMailerConfig } from './config/config-manager.ts';
export interface IMailerOptions {
config?: IMailerConfig;
dnsNsDomains?: string[];
dnsScopes?: string[];
}
export class Mailer {
public storageManager: StorageManager;
public options?: IMailerOptions;
constructor(options?: IMailerOptions) {
this.options = options;
this.storageManager = new StorageManager();
}
}
// Export type alias for compatibility
export type DcRouter = Mailer;

View File

@@ -1,10 +0,0 @@
/**
* CLI entry point
* Main command-line interface
*/
import { MailerCli } from './cli/mailer-cli.ts';
// Create and run CLI
const cli = new MailerCli();
await cli.parseAndExecute(Deno.args);

View File

@@ -1,6 +0,0 @@
/**
* CLI module
* Command-line interface for mailer
*/
export * from './mailer-cli.ts';

View File

@@ -1,387 +0,0 @@
/**
* Mailer CLI
* Main command-line interface implementation
*/
import { DaemonManager } from '../daemon/daemon-manager.ts';
import { ConfigManager } from '../config/config-manager.ts';
import { DnsManager } from '../dns/dns-manager.ts';
import { CloudflareClient } from '../dns/cloudflare-client.ts';
import { Email } from '../mail/core/index.ts';
import { commitinfo } from '../00_commitinfo_data.ts';
export class MailerCli {
private configManager: ConfigManager;
private daemonManager: DaemonManager;
private dnsManager: DnsManager;
constructor() {
this.configManager = new ConfigManager();
this.daemonManager = new DaemonManager();
this.dnsManager = new DnsManager();
}
/**
* Parse and execute CLI commands
*/
async parseAndExecute(args: string[]): Promise<void> {
// Get command
const command = args[2] || 'help';
const subcommand = args[3];
const commandArgs = args.slice(4);
try {
switch (command) {
case 'service':
await this.handleServiceCommand(subcommand, commandArgs);
break;
case 'domain':
await this.handleDomainCommand(subcommand, commandArgs);
break;
case 'dns':
await this.handleDnsCommand(subcommand, commandArgs);
break;
case 'send':
await this.handleSendCommand(commandArgs);
break;
case 'config':
await this.handleConfigCommand(subcommand, commandArgs);
break;
case 'version':
case '--version':
case '-v':
this.showVersion();
break;
case 'help':
case '--help':
case '-h':
default:
this.showHelp();
break;
}
} catch (error) {
console.error(`Error: ${error.message}`);
Deno.exit(1);
}
}
/**
* Handle service commands (daemon control)
*/
private async handleServiceCommand(subcommand: string, args: string[]): Promise<void> {
switch (subcommand) {
case 'start':
console.log('Starting mailer daemon...');
await this.daemonManager.start();
break;
case 'stop':
console.log('Stopping mailer daemon...');
await this.daemonManager.stop();
break;
case 'restart':
console.log('Restarting mailer daemon...');
await this.daemonManager.stop();
await new Promise(resolve => setTimeout(resolve, 2000));
await this.daemonManager.start();
break;
case 'status':
console.log('Checking mailer daemon status...');
// TODO: Implement status check
break;
case 'enable':
console.log('Enabling mailer service (systemd)...');
// TODO: Implement systemd enable
break;
case 'disable':
console.log('Disabling mailer service (systemd)...');
// TODO: Implement systemd disable
break;
default:
console.log('Usage: mailer service {start|stop|restart|status|enable|disable}');
break;
}
}
/**
* Handle domain management commands
*/
private async handleDomainCommand(subcommand: string, args: string[]): Promise<void> {
const config = await this.configManager.load();
switch (subcommand) {
case 'add': {
const domain = args[0];
if (!domain) {
console.error('Error: Domain name required');
console.log('Usage: mailer domain add <domain>');
Deno.exit(1);
}
config.domains.push({
domain,
dnsMode: 'external-dns',
});
await this.configManager.save(config);
console.log(`✓ Domain ${domain} added`);
break;
}
case 'remove': {
const domain = args[0];
if (!domain) {
console.error('Error: Domain name required');
console.log('Usage: mailer domain remove <domain>');
Deno.exit(1);
}
config.domains = config.domains.filter(d => d.domain !== domain);
await this.configManager.save(config);
console.log(`✓ Domain ${domain} removed`);
break;
}
case 'list':
console.log('Configured domains:');
if (config.domains.length === 0) {
console.log(' (none)');
} else {
for (const domain of config.domains) {
console.log(` - ${domain.domain} (${domain.dnsMode})`);
}
}
break;
default:
console.log('Usage: mailer domain {add|remove|list} [domain]');
break;
}
}
/**
* Handle DNS commands
*/
private async handleDnsCommand(subcommand: string, args: string[]): Promise<void> {
const domain = args[0];
if (!domain && subcommand !== 'help') {
console.error('Error: Domain name required');
console.log('Usage: mailer dns {setup|validate|show} <domain>');
Deno.exit(1);
}
switch (subcommand) {
case 'setup': {
console.log(`Setting up DNS for ${domain}...`);
const config = await this.configManager.load();
const domainConfig = config.domains.find(d => d.domain === domain);
if (!domainConfig) {
console.error(`Error: Domain ${domain} not configured. Add it first with: mailer domain add ${domain}`);
Deno.exit(1);
}
if (!domainConfig.cloudflare?.apiToken) {
console.error('Error: Cloudflare API token not configured');
console.log('Set it with: mailer config set cloudflare.apiToken <token>');
Deno.exit(1);
}
const cloudflare = new CloudflareClient({ apiToken: domainConfig.cloudflare.apiToken });
const records = this.dnsManager.getRequiredRecords(domain, config.hostname);
await cloudflare.createRecords(domain, records);
console.log(`✓ DNS records created for ${domain}`);
break;
}
case 'validate': {
console.log(`Validating DNS for ${domain}...`);
const result = await this.dnsManager.validateDomain(domain);
if (result.valid) {
console.log(`✓ DNS configuration is valid`);
} else {
console.log(`✗ DNS configuration has errors:`);
for (const error of result.errors) {
console.log(` - ${error}`);
}
}
if (result.warnings.length > 0) {
console.log('Warnings:');
for (const warning of result.warnings) {
console.log(` - ${warning}`);
}
}
break;
}
case 'show': {
console.log(`Required DNS records for ${domain}:`);
const config = await this.configManager.load();
const records = this.dnsManager.getRequiredRecords(domain, config.hostname);
for (const record of records) {
console.log(`\n${record.type} Record:`);
console.log(` Name: ${record.name}`);
console.log(` Value: ${record.value}`);
if (record.priority) console.log(` Priority: ${record.priority}`);
if (record.ttl) console.log(` TTL: ${record.ttl}`);
}
break;
}
default:
console.log('Usage: mailer dns {setup|validate|show} <domain>');
break;
}
}
/**
* Handle send command
*/
private async handleSendCommand(args: string[]): Promise<void> {
console.log('Sending email...');
// Parse basic arguments
const from = args[args.indexOf('--from') + 1];
const to = args[args.indexOf('--to') + 1];
const subject = args[args.indexOf('--subject') + 1];
const text = args[args.indexOf('--text') + 1];
if (!from || !to || !subject || !text) {
console.error('Error: Missing required arguments');
console.log('Usage: mailer send --from <email> --to <email> --subject <subject> --text <text>');
Deno.exit(1);
}
const email = new Email({
from,
to,
subject,
text,
});
console.log(`✓ Email created: ${email.toString()}`);
// TODO: Actually send the email via SMTP client
console.log('TODO: Implement actual sending');
}
/**
* Handle config commands
*/
private async handleConfigCommand(subcommand: string, args: string[]): Promise<void> {
const config = await this.configManager.load();
switch (subcommand) {
case 'show':
console.log('Current configuration:');
console.log(JSON.stringify(config, null, 2));
break;
case 'set': {
const key = args[0];
const value = args[1];
if (!key || !value) {
console.error('Error: Key and value required');
console.log('Usage: mailer config set <key> <value>');
Deno.exit(1);
}
// Simple key-value setting (can be enhanced)
if (key === 'smtpPort') config.smtpPort = parseInt(value);
else if (key === 'apiPort') config.apiPort = parseInt(value);
else if (key === 'hostname') config.hostname = value;
else {
console.error(`Error: Unknown config key: ${key}`);
Deno.exit(1);
}
await this.configManager.save(config);
console.log(`✓ Configuration updated: ${key} = ${value}`);
break;
}
default:
console.log('Usage: mailer config {show|set} [key] [value]');
break;
}
}
/**
* Show version information
*/
private showVersion(): void {
console.log(`${commitinfo.name} v${commitinfo.version}`);
console.log(commitinfo.description);
}
/**
* Show help information
*/
private showHelp(): void {
console.log(`
${commitinfo.name} v${commitinfo.version}
${commitinfo.description}
Usage: mailer <command> [options]
Commands:
service <action> Daemon service control
start Start the mailer daemon
stop Stop the mailer daemon
restart Restart the mailer daemon
status Show daemon status
enable Enable systemd service
disable Disable systemd service
domain <action> [domain] Domain management
add <domain> Add a domain
remove <domain> Remove a domain
list List all domains
dns <action> <domain> DNS management
setup <domain> Auto-configure DNS via Cloudflare
validate <domain> Validate DNS configuration
show <domain> Show required DNS records
send [options] Send an email
--from <email> Sender email address
--to <email> Recipient email address
--subject <subject> Email subject
--text <text> Email body text
config <action> Configuration management
show Show current configuration
set <key> <value> Set configuration value
version, -v, --version Show version information
help, -h, --help Show this help message
Examples:
mailer service start Start the mailer daemon
mailer domain add example.com Add example.com domain
mailer dns setup example.com Setup DNS for example.com
mailer send --from sender@example.com --to recipient@example.com \\
--subject "Hello" --text "World"
For more information, visit:
https://code.foss.global/serve.zone/mailer
`);
}
}

View File

@@ -1,83 +0,0 @@
/**
* Configuration Manager
* Handles configuration storage and retrieval
*/
import * as plugins from '../plugins.ts';
export interface IMailerConfig {
domains: IDomainConfig[];
apiKeys: string[];
smtpPort: number;
apiPort: number;
hostname: string;
}
export interface IDomainConfig {
domain: string;
dnsMode: 'forward' | 'internal-dns' | 'external-dns';
cloudflare?: {
apiToken: string;
};
}
export class ConfigManager {
private configPath: string;
private config: IMailerConfig | null = null;
constructor(configPath?: string) {
this.configPath = configPath || plugins.path.join(Deno.env.get('HOME') || '/root', '.mailer', 'config.json');
}
/**
* Load configuration from disk
*/
async load(): Promise<IMailerConfig> {
try {
const data = await Deno.readTextFile(this.configPath);
this.config = JSON.parse(data);
return this.config!;
} catch (error) {
// Return default config if file doesn't exist
this.config = this.getDefaultConfig();
return this.config;
}
}
/**
* Save configuration to disk
*/
async save(config: IMailerConfig): Promise<void> {
this.config = config;
// Ensure directory exists
const dir = plugins.path.dirname(this.configPath);
await Deno.mkdir(dir, { recursive: true });
// Write config
await Deno.writeTextFile(this.configPath, JSON.stringify(config, null, 2));
}
/**
* Get current configuration
*/
getConfig(): IMailerConfig {
if (!this.config) {
throw new Error('Configuration not loaded. Call load() first.');
}
return this.config;
}
/**
* Get default configuration
*/
private getDefaultConfig(): IMailerConfig {
return {
domains: [],
apiKeys: [],
smtpPort: 25,
apiPort: 8080,
hostname: 'localhost',
};
}
}

View File

@@ -1,6 +0,0 @@
/**
* Configuration module
* Configuration management and secure storage
*/
export * from './config-manager.ts';

View File

@@ -1,57 +0,0 @@
/**
* Daemon Manager
* Manages the background mailer service
*/
import { SmtpServer } from '../mail/delivery/placeholder.ts';
import { ApiServer } from '../api/api-server.ts';
import { ConfigManager } from '../config/config-manager.ts';
export class DaemonManager {
private smtpServer: SmtpServer | null = null;
private apiServer: ApiServer | null = null;
private configManager: ConfigManager;
constructor() {
this.configManager = new ConfigManager();
}
/**
* Start the daemon
*/
async start(): Promise<void> {
console.log('[Daemon] Starting mailer daemon...');
// Load configuration
const config = await this.configManager.load();
// Start SMTP server
this.smtpServer = new SmtpServer({ port: config.smtpPort, hostname: config.hostname });
await this.smtpServer.start();
// Start API server
this.apiServer = new ApiServer({ port: config.apiPort, apiKeys: config.apiKeys });
await this.apiServer.start();
console.log('[Daemon] Mailer daemon started successfully');
console.log(`[Daemon] SMTP server: ${config.hostname}:${config.smtpPort}`);
console.log(`[Daemon] API server: http://${config.hostname}:${config.apiPort}`);
}
/**
* Stop the daemon
*/
async stop(): Promise<void> {
console.log('[Daemon] Stopping mailer daemon...');
if (this.smtpServer) {
await this.smtpServer.stop();
}
if (this.apiServer) {
await this.apiServer.stop();
}
console.log('[Daemon] Mailer daemon stopped');
}
}

View File

@@ -1,6 +0,0 @@
/**
* Daemon module
* Background service for SMTP server and API server
*/
export * from './daemon-manager.ts';

View File

@@ -1,36 +0,0 @@
/**
* Deliverability module stub
* IP warmup and sender reputation monitoring
*/
export interface IIPWarmupConfig {
enabled: boolean;
initialLimit: number;
maxLimit: number;
incrementPerDay: number;
}
export interface IReputationMonitorConfig {
enabled: boolean;
checkInterval: number;
}
export class IPWarmupManager {
constructor(config: IIPWarmupConfig) {
// Stub implementation
}
async getCurrentLimit(ip: string): Promise<number> {
return 1000; // Stub: return high limit
}
}
export class SenderReputationMonitor {
constructor(config: IReputationMonitorConfig) {
// Stub implementation
}
async checkReputation(domain: string): Promise<{ score: number; issues: string[] }> {
return { score: 100, issues: [] };
}
}

View File

@@ -1,37 +0,0 @@
/**
* Cloudflare DNS Client
* Automatic DNS record management via Cloudflare API
*/
import * as plugins from '../plugins.ts';
import type { IDnsRecord } from './dns-manager.ts';
export interface ICloudflareConfig {
apiToken: string;
email?: string;
}
export class CloudflareClient {
constructor(private config: ICloudflareConfig) {}
/**
* Create DNS records for a domain
*/
async createRecords(domain: string, records: IDnsRecord[]): Promise<void> {
console.log(`[CloudflareClient] Would create ${records.length} DNS records for ${domain}`);
// TODO: Implement actual Cloudflare API integration using @apiclient.xyz/cloudflare
for (const record of records) {
console.log(` - ${record.type} ${record.name} -> ${record.value}`);
}
}
/**
* Verify DNS records exist
*/
async verifyRecords(domain: string, records: IDnsRecord[]): Promise<boolean> {
console.log(`[CloudflareClient] Would verify ${records.length} DNS records for ${domain}`);
// TODO: Implement actual verification
return true;
}
}

View File

@@ -1,68 +0,0 @@
/**
* DNS Manager
* Handles DNS record management and validation for email domains
*/
import * as plugins from '../plugins.ts';
export interface IDnsRecord {
type: 'MX' | 'TXT' | 'A' | 'AAAA';
name: string;
value: string;
priority?: number;
ttl?: number;
}
export interface IDnsValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
requiredRecords: IDnsRecord[];
}
export class DnsManager {
/**
* Get required DNS records for a domain
*/
getRequiredRecords(domain: string, mailServerIp: string): IDnsRecord[] {
return [
{
type: 'MX',
name: domain,
value: `mail.${domain}`,
priority: 10,
ttl: 3600,
},
{
type: 'A',
name: `mail.${domain}`,
value: mailServerIp,
ttl: 3600,
},
{
type: 'TXT',
name: domain,
value: `v=spf1 mx ip4:${mailServerIp} ~all`,
ttl: 3600,
},
// TODO: Add DKIM and DMARC records
];
}
/**
* Validate DNS configuration for a domain
*/
async validateDomain(domain: string): Promise<IDnsValidationResult> {
const result: IDnsValidationResult = {
valid: true,
errors: [],
warnings: [],
requiredRecords: [],
};
// TODO: Implement actual DNS validation
console.log(`[DnsManager] Would validate DNS for ${domain}`);
return result;
}
}

View File

@@ -1,7 +0,0 @@
/**
* DNS management module
* DNS validation and Cloudflare integration for automatic DNS setup
*/
export * from './dns-manager.ts';
export * from './cloudflare-client.ts';

View File

@@ -1,24 +1,119 @@
/**
* Error types module stub
* MTA error classes for SMTP client operations
*/
export class SmtpError extends Error {
constructor(message: string, public code?: number) {
export class MtaConnectionError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'SmtpError';
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 AuthenticationError extends Error {
constructor(message: string) {
export class MtaAuthenticationError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'AuthenticationError';
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 RateLimitError extends Error {
constructor(message: string) {
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 = 'RateLimitError';
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;
}
}
}

View File

@@ -1,13 +0,0 @@
/**
* @serve.zone/mailer
* Enterprise mail server with SMTP, HTTP API, and DNS management
*/
// Export public API
export * from './mail/core/index.ts';
export * from './mail/delivery/index.ts';
export * from './mail/routing/index.ts';
export * from './api/index.ts';
export * from './config/index.ts';
// DNS exports are included in mail/routing, so we skip './dns/index.ts' to avoid duplication

View File

@@ -1,11 +1,91 @@
/**
* Logger module
* Simple logging for mailer
*/
import * as plugins from './plugins.js';
import { randomUUID } from 'node:crypto';
export const logger = {
log: (level: string, message: string, ...args: any[]) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`, ...args);
},
// Map NODE_ENV to valid TEnvironment
const nodeEnv = process.env.NODE_ENV || 'production';
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
'development': 'local',
'test': 'test',
'staging': 'staging',
'production': 'production'
};
// Default Smartlog instance
const baseLogger = new plugins.smartlog.Smartlog({
logContext: {
environment: envMap[nodeEnv] || 'production',
runtime: 'node',
zone: 'serve.zone',
}
});
// Extended logger compatible with the original enhanced logger API
class StandardLogger {
private defaultContext: Record<string, any> = {};
private correlationId: string | null = null;
constructor() {}
// Log methods
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
const combinedContext = {
...this.defaultContext,
...context
};
if (this.correlationId) {
combinedContext.correlation_id = this.correlationId;
}
baseLogger.log(level, message, combinedContext);
}
public error(message: string, context: Record<string, any> = {}) {
this.log('error', message, context);
}
public warn(message: string, context: Record<string, any> = {}) {
this.log('warn', message, context);
}
public info(message: string, context: Record<string, any> = {}) {
this.log('info', message, context);
}
public success(message: string, context: Record<string, any> = {}) {
this.log('success', message, context);
}
public debug(message: string, context: Record<string, any> = {}) {
this.log('debug', message, context);
}
// Context management
public setContext(context: Record<string, any>, overwrite: boolean = false) {
if (overwrite) {
this.defaultContext = context;
} else {
this.defaultContext = {
...this.defaultContext,
...context
};
}
}
// Correlation ID management
public setCorrelationId(id: string | null = null): string {
this.correlationId = id || randomUUID();
return this.correlationId;
}
public getCorrelationId(): string | null {
return this.correlationId;
}
public clearCorrelationId(): void {
this.correlationId = null;
}
}
// Export a singleton instance
export const logger = new StandardLogger();

View File

@@ -1,9 +1,10 @@
import * as plugins from '../../plugins.ts';
import * as paths from '../../paths.ts';
import { logger } from '../../logger.ts';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.ts';
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import { LRUCache } from 'lru-cache';
import type { Email } from './classes.email.ts';
import type { Email } from './classes.email.js';
/**
* Bounce types for categorizing the reasons for bounces
@@ -63,112 +64,6 @@ export interface BounceRecord {
nextRetryTime?: number;
}
/**
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
*/
const BOUNCE_PATTERNS = {
// Hard bounce patterns
[BounceType.INVALID_RECIPIENT]: [
/no such user/i,
/user unknown/i,
/does not exist/i,
/invalid recipient/i,
/unknown recipient/i,
/no mailbox/i,
/user not found/i,
/recipient address rejected/i,
/550 5\.1\.1/i
],
[BounceType.DOMAIN_NOT_FOUND]: [
/domain not found/i,
/unknown domain/i,
/no such domain/i,
/host not found/i,
/domain invalid/i,
/550 5\.1\.2/i
],
[BounceType.MAILBOX_FULL]: [
/mailbox full/i,
/over quota/i,
/quota exceeded/i,
/552 5\.2\.2/i
],
[BounceType.MAILBOX_INACTIVE]: [
/mailbox disabled/i,
/mailbox inactive/i,
/account disabled/i,
/mailbox not active/i,
/account suspended/i
],
[BounceType.BLOCKED]: [
/blocked/i,
/rejected/i,
/denied/i,
/blacklisted/i,
/prohibited/i,
/refused/i,
/550 5\.7\./i
],
[BounceType.SPAM_RELATED]: [
/spam/i,
/bulk mail/i,
/content rejected/i,
/message rejected/i,
/550 5\.7\.1/i
],
// Soft bounce patterns
[BounceType.SERVER_UNAVAILABLE]: [
/server unavailable/i,
/service unavailable/i,
/try again later/i,
/try later/i,
/451 4\.3\./i,
/421 4\.3\./i
],
[BounceType.TEMPORARY_FAILURE]: [
/temporary failure/i,
/temporary error/i,
/temporary problem/i,
/try again/i,
/451 4\./i
],
[BounceType.QUOTA_EXCEEDED]: [
/quota temporarily exceeded/i,
/mailbox temporarily full/i,
/452 4\.2\.2/i
],
[BounceType.NETWORK_ERROR]: [
/network error/i,
/connection error/i,
/connection timed out/i,
/routing error/i,
/421 4\.4\./i
],
[BounceType.TIMEOUT]: [
/timed out/i,
/timeout/i,
/450 4\.4\.2/i
],
// Auto-responses
[BounceType.AUTO_RESPONSE]: [
/auto[- ]reply/i,
/auto[- ]response/i,
/vacation/i,
/out of office/i,
/away from office/i,
/on vacation/i,
/automatic reply/i
],
[BounceType.CHALLENGE_RESPONSE]: [
/challenge[- ]response/i,
/verify your email/i,
/confirm your email/i,
/email verification/i
]
};
/**
* Retry strategy configuration for soft bounces
*/
@@ -269,16 +164,16 @@ export class BounceManager {
nextRetryTime: bounceData.nextRetryTime
};
// Determine bounce type and category if not provided
// Determine bounce type and category via Rust bridge if not provided
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
const bounceInfo = this.detectBounceType(
bounce.smtpResponse || '',
bounce.diagnosticCode || '',
bounce.statusCode || ''
);
bounce.bounceType = bounceInfo.type;
bounce.bounceCategory = bounceInfo.category;
const bridge = RustSecurityBridge.getInstance();
const rustResult = await bridge.detectBounce({
smtpResponse: bounce.smtpResponse,
diagnosticCode: bounce.diagnosticCode,
statusCode: bounce.statusCode,
});
bounce.bounceType = rustResult.bounce_type as BounceType;
bounce.bounceCategory = rustResult.category as BounceCategory;
}
// Process the bounce based on category
@@ -647,13 +542,12 @@ export class BounceManager {
if (this.storageManager) {
// Use storage manager
await this.storageManager.set('/email/bounces/suppression-list.tson', suppressionData);
await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
} else {
// Fall back to filesystem
plugins.smartfile.memory.toFsSync(
suppressionData,
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson')
);
await plugins.smartfs.file(
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
).write(suppressionData);
}
} catch (error) {
logger.log('error', `Failed to save suppression list: ${error.message}`);
@@ -670,13 +564,13 @@ export class BounceManager {
if (this.storageManager) {
// Try to load from storage manager first
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.tson');
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
if (suppressionData) {
entries = JSON.parse(suppressionData);
} else {
// Check if data exists in filesystem and migrate
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson');
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
if (plugins.fs.existsSync(suppressionPath)) {
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
@@ -688,7 +582,7 @@ export class BounceManager {
}
} else {
// No storage manager, use filesystem directly
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson');
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
if (plugins.fs.existsSync(suppressionPath)) {
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
@@ -732,21 +626,21 @@ export class BounceManager {
if (this.storageManager) {
// Use storage manager
await this.storageManager.set(`/email/bounces/records/${bounce.id}.tson`, bounceData);
await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData);
} else {
// Fall back to filesystem
const bouncePath = plugins.path.join(
paths.dataDir,
'emails',
'bounces',
`${bounce.id}.tson`
`${bounce.id}.json`
);
// Ensure directory exists
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
plugins.smartfile.fs.ensureDirSync(bounceDir);
await plugins.smartfs.directory(bounceDir).recursive().create();
plugins.smartfile.memory.toFsSync(bounceData, bouncePath);
await plugins.smartfs.file(bouncePath).write(bounceData);
}
} catch (error) {
logger.log('error', `Failed to save bounce record: ${error.message}`);
@@ -792,134 +686,6 @@ export class BounceManager {
return this.bounceCache.get(email.toLowerCase()) || null;
}
/**
* Analyze SMTP response and diagnostic codes to determine bounce type
* @param smtpResponse SMTP response string
* @param diagnosticCode Diagnostic code from bounce
* @param statusCode Status code from bounce
* @returns Detected bounce type and category
*/
private detectBounceType(
smtpResponse: string,
diagnosticCode: string,
statusCode: string
): {
type: BounceType;
category: BounceCategory;
} {
// Combine all text for comprehensive pattern matching
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
// Check for auto-responses first
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
return {
type: BounceType.AUTO_RESPONSE,
category: BounceCategory.AUTO_RESPONSE
};
}
// Check for hard bounces
for (const bounceType of [
BounceType.INVALID_RECIPIENT,
BounceType.DOMAIN_NOT_FOUND,
BounceType.MAILBOX_FULL,
BounceType.MAILBOX_INACTIVE,
BounceType.BLOCKED,
BounceType.SPAM_RELATED,
BounceType.POLICY_RELATED
]) {
if (this.matchesPattern(fullText, bounceType)) {
return {
type: bounceType,
category: BounceCategory.HARD
};
}
}
// Check for soft bounces
for (const bounceType of [
BounceType.SERVER_UNAVAILABLE,
BounceType.TEMPORARY_FAILURE,
BounceType.QUOTA_EXCEEDED,
BounceType.NETWORK_ERROR,
BounceType.TIMEOUT
]) {
if (this.matchesPattern(fullText, bounceType)) {
return {
type: bounceType,
category: BounceCategory.SOFT
};
}
}
// Handle DSN (Delivery Status Notification) status codes
if (statusCode) {
// Format: class.subject.detail
const parts = statusCode.split('.');
if (parts.length >= 2) {
const statusClass = parts[0];
const statusSubject = parts[1];
// 5.X.X is permanent failure (hard bounce)
if (statusClass === '5') {
// Try to determine specific type based on subject
if (statusSubject === '1') {
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
} else if (statusSubject === '2') {
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
} else if (statusSubject === '7') {
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
} else {
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
}
}
// 4.X.X is temporary failure (soft bounce)
if (statusClass === '4') {
// Try to determine specific type based on subject
if (statusSubject === '2') {
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
} else if (statusSubject === '3') {
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
} else if (statusSubject === '4') {
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
} else {
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
}
}
}
}
// Default to unknown
return {
type: BounceType.UNKNOWN,
category: BounceCategory.UNKNOWN
};
}
/**
* Check if text matches any pattern for a bounce type
* @param text Text to check against patterns
* @param bounceType Bounce type to get patterns for
* @returns Whether the text matches any pattern
*/
private matchesPattern(text: string, bounceType: BounceType): boolean {
const patterns = BOUNCE_PATTERNS[bounceType];
if (!patterns) {
return false;
}
for (const pattern of patterns) {
if (pattern.test(text)) {
return true;
}
}
return false;
}
/**
* Get all known hard bounced addresses
* @returns Array of hard bounced email addresses

View File

@@ -1,5 +1,5 @@
import * as plugins from '../../plugins.ts';
import { EmailValidator } from './classes.emailvalidator.ts';
import * as plugins from '../../plugins.js';
import { EmailValidator } from './classes.emailvalidator.js';
export interface IAttachment {
filename: string;
@@ -614,10 +614,11 @@ export class Email {
// Add attachments
for (const attachment of this.attachments) {
const smartAttachment = await plugins.smartfile.SmartFile.fromBuffer(
attachment.filename,
attachment.content
);
const smartAttachment = new plugins.smartfile.SmartFile({
path: attachment.filename,
contentBuffer: attachment.content,
base: process.cwd(),
});
// Set content type if available
if (attachment.contentType) {

View File

@@ -1,5 +1,5 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logger.ts';
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js';
import { LRUCache } from 'lru-cache';
export interface IEmailValidationResult {

View File

@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts';
import * as paths from '../../paths.ts';
import { logger } from '../../logger.ts';
import { Email, type IEmailOptions, type IAttachment } from './classes.email.ts';
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import { logger } from '../../logger.js';
import { Email, type IEmailOptions, type IAttachment } from './classes.email.js';
/**
* Email template type definition
@@ -291,7 +291,7 @@ export class TemplateManager {
// Get all JSON files
const files = plugins.fs.readdirSync(directory)
.filter(file => file.endsWith('.tson'));
.filter(file => file.endsWith('.json'));
for (const file of files) {
try {

View File

@@ -1,10 +1,5 @@
/**
* Mail core module
* Email classes, validation, templates, and bounce management
*/
// Core email components
export * from './classes.email.ts';
export * from './classes.emailvalidator.ts';
export * from './classes.templatemanager.ts';
export * from './classes.bouncemanager.ts';
export * from './classes.email.js';
export * from './classes.emailvalidator.js';
export * from './classes.templatemanager.js';
export * from './classes.bouncemanager.js';

View File

@@ -1,7 +1,10 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logger.ts';
import { type EmailProcessingMode } from '../routing/classes.email.config.ts';
import type { IEmailRoute } from '../routing/interfaces.ts';
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { logger } from '../../logger.js';
import { type EmailProcessingMode } from '../routing/classes.email.config.js';
import type { IEmailRoute } from '../routing/interfaces.js';
/**
* Queue item status
@@ -71,7 +74,7 @@ export interface IQueueStats {
/**
* A unified queue for all email modes
*/
export class UnifiedDeliveryQueue extends plugins.EventEmitter {
export class UnifiedDeliveryQueue extends EventEmitter {
private options: Required<IQueueOptions>;
private queue: Map<string, IQueueItem> = new Map();
private checkTimer?: NodeJS.Timeout;
@@ -423,7 +426,7 @@ export class UnifiedDeliveryQueue extends plugins.EventEmitter {
*/
private async persistItem(item: IQueueItem): Promise<void> {
try {
const filePath = path.join(this.options.persistentPath, `${item.id}.tson`);
const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
} catch (error) {
logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
@@ -437,7 +440,7 @@ export class UnifiedDeliveryQueue extends plugins.EventEmitter {
*/
private async removeItemFromDisk(id: string): Promise<void> {
try {
const filePath = path.join(this.options.persistentPath, `${id}.tson`);
const filePath = path.join(this.options.persistentPath, `${id}.json`);
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
@@ -459,7 +462,7 @@ export class UnifiedDeliveryQueue extends plugins.EventEmitter {
}
// Get all JSON files
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.tson'));
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json'));
// Load each file
for (const file of files) {

View File

@@ -1,14 +1,18 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logger.ts';
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import * as net from 'node:net';
import * as tls from 'node:tls';
import { logger } from '../../logger.js';
import {
SecurityLogger,
SecurityLogLevel,
SecurityEventType
} from '../../security/index.ts';
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.ts';
import type { Email } from '../core/classes.email.ts';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
import type { SmtpClient } from './smtpclient/smtp-client.ts';
} from '../../security/index.js';
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
import type { Email } from '../core/classes.email.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import type { SmtpClient } from './smtpclient/smtp-client.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
/**
* Delivery status enumeration
@@ -97,7 +101,7 @@ export interface IDeliveryStats {
/**
* Handles delivery for all email processing modes
*/
export class MultiModeDeliverySystem extends plugins.EventEmitter {
export class MultiModeDeliverySystem extends EventEmitter {
private queue: UnifiedDeliveryQueue;
private options: Required<IMultiModeDeliveryOptions>;
private stats: IDeliveryStats;
@@ -761,28 +765,23 @@ export class MultiModeDeliverySystem extends plugins.EventEmitter {
// Ensure DKIM keys exist for the domain
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
// Get the private key
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
// Convert Email to raw format for signing
const rawEmail = email.toRFC822String();
// Sign the email
const signResult = await plugins.dkimSign(rawEmail, {
canonicalization: 'relaxed/relaxed',
algorithm: 'rsa-sha256',
signTime: new Date(),
signatureData: [
{
signingDomain: domainName,
selector: keySelector,
privateKey: (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey,
algorithm: 'rsa-sha256',
canonicalization: 'relaxed/relaxed'
}
]
// Sign via Rust bridge
const bridge = RustSecurityBridge.getInstance();
const signResult = await bridge.signDkim({
rawMessage: rawEmail,
domain: domainName,
selector: keySelector,
privateKey: dkimPrivateKey,
});
// Add the DKIM-Signature header to the email
if (signResult.signatures) {
email.addHeader('DKIM-Signature', signResult.signatures);
if (signResult.header) {
email.addHeader('DKIM-Signature', signResult.header);
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
}
} catch (error) {

View File

@@ -1,10 +1,10 @@
import * as plugins from '../../plugins.ts';
import * as paths from '../../paths.ts';
import { Email } from '../core/classes.email.ts';
import { EmailSignJob } from './classes.emailsignjob.ts';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
import type { SmtpClient } from './smtpclient/smtp-client.ts';
import type { ISmtpSendResult } from './smtpclient/interfaces.ts';
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import { Email } from '../core/classes.email.js';
import { EmailSignJob } from './classes.emailsignjob.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import type { SmtpClient } from './smtpclient/smtp-client.js';
import type { ISmtpSendResult } from './smtpclient/interfaces.js';
// Configuration options for email sending
export interface IEmailSendOptions {
@@ -400,13 +400,13 @@ export class EmailSendJob {
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
await plugins.smartfile.fs.ensureDir(paths.sentEmailsDir);
await plugins.smartfile.memory.toFs(emailContent, filePath);
await plugins.smartfs.directory(paths.sentEmailsDir).recursive().create();
await plugins.smartfs.file(filePath).write(emailContent);
// Also save delivery info
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.tson`;
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
this.log(`Email saved to ${fileName}`);
} catch (error) {
@@ -424,13 +424,13 @@ export class EmailSendJob {
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
await plugins.smartfile.fs.ensureDir(paths.failedEmailsDir);
await plugins.smartfile.memory.toFs(emailContent, filePath);
await plugins.smartfs.directory(paths.failedEmailsDir).recursive().create();
await plugins.smartfs.file(filePath).write(emailContent);
// Also save delivery info with error details
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.tson`;
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
this.log(`Failed email saved to ${fileName}`);
} catch (error) {

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.ts';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
import * as plugins from '../../plugins.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
interface Headers {
[key: string]: string;
@@ -27,41 +28,14 @@ export class EmailSignJob {
}
public async getSignatureHeader(emailMessage: string): Promise<string> {
const signResult = await plugins.dkimSign(emailMessage, {
// Optional, default canonicalization, default is "relaxed/relaxed"
canonicalization: 'relaxed/relaxed', // c=
// Optional, default signing and hashing algorithm
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
algorithm: 'rsa-sha256',
// Optional, default is current time
signTime: new Date(), // t=
// Keys for one or more signatures
// Different signatures can use different algorithms (mostly useful when
// you want to sign a message both with RSA and Ed25519)
signatureData: [
{
signingDomain: this.jobOptions.domain, // d=
selector: this.jobOptions.selector, // s=
// supported key types: RSA, Ed25519
privateKey: await this.loadPrivateKey(), // k=
// Optional algorithm, default is derived from the key.
// Overrides whatever was set in parent object
algorithm: 'rsa-sha256',
// Optional signature specifc canonicalization, overrides whatever was set in parent object
canonicalization: 'relaxed/relaxed', // c=
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
// Do not use though. This is available only for compatibility testing.
// maxBodyLength: 12345
},
],
const privateKey = await this.loadPrivateKey();
const bridge = RustSecurityBridge.getInstance();
const signResult = await bridge.signDkim({
rawMessage: emailMessage,
domain: this.jobOptions.domain,
selector: this.jobOptions.selector,
privateKey,
});
const signature = signResult.signatures;
return signature;
return signResult.header;
}
}

View File

@@ -1,24 +1,24 @@
import * as plugins from '../../plugins.ts';
import * as paths from '../../paths.ts';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
/**
* Configures email server storage settings
* @param emailServer Reference to the unified email server
* @param options Configuration options containing storage paths
*/
export function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): void {
export async function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): Promise<void> {
// Extract the receivedEmailsPath if available
if (options?.emailPortConfig?.receivedEmailsPath) {
const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath;
// Ensure the directory exists
plugins.smartfile.fs.ensureDirSync(receivedEmailsPath);
await plugins.smartfs.directory(receivedEmailsPath).recursive().create();
// Set path for received emails
if (emailServer) {
// Storage paths are now handled by the unified email server system
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
await plugins.smartfs.directory(paths.receivedEmailsDir).recursive().create();
console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`);
}
@@ -30,7 +30,7 @@ export function configureEmailStorage(emailServer: UnifiedEmailServer, options:
* @param emailServer Reference to the unified email server
* @param config Configuration settings for email server
*/
export function configureEmailServer(
export async function configureEmailServer(
emailServer: UnifiedEmailServer,
config: {
ports?: number[];
@@ -42,7 +42,7 @@ export function configureEmailServer(
};
storagePath?: string;
}
): boolean {
): Promise<boolean> {
if (!emailServer) {
console.error('Email server not available');
return false;
@@ -62,7 +62,7 @@ export function configureEmailServer(
// Set up storage path if provided
if (config.storagePath) {
configureEmailStorage(emailServer, {
await configureEmailStorage(emailServer, {
emailPortConfig: {
receivedEmailsPath: config.storagePath
}

View File

@@ -1,4 +1,4 @@
import { logger } from '../../logger.ts';
import { logger } from '../../logger.js';
/**
* Configuration options for rate limiter
@@ -38,6 +38,12 @@ interface TokenBucket {
/** Total denied requests */
denied: number;
/** Error count for blocking decisions */
errors: number;
/** Timestamp of first error in current window */
firstErrorTime: number;
}
/**
@@ -74,7 +80,9 @@ export class RateLimiter {
tokens: this.config.initialTokens,
lastRefill: Date.now(),
allowed: 0,
denied: 0
denied: 0,
errors: 0,
firstErrorTime: 0
};
// Log initialization
@@ -196,7 +204,9 @@ export class RateLimiter {
tokens: this.config.initialTokens,
lastRefill: Date.now(),
allowed: 0,
denied: 0
denied: 0,
errors: 0,
firstErrorTime: 0
});
}
@@ -278,4 +288,38 @@ export class RateLimiter {
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
}
}
/**
* Record an error for a key (e.g., IP address) and determine if blocking is needed
* RFC 5321 Section 4.5.4.1 suggests limiting errors to prevent abuse
*
* @param key Key to record error for (typically an IP address)
* @param errorWindow Time window for error tracking in ms (default: 5 minutes)
* @param errorThreshold Maximum errors before blocking (default: 10)
* @returns true if the key should be blocked due to excessive errors
*/
public recordError(key: string, errorWindow: number = 5 * 60 * 1000, errorThreshold: number = 10): boolean {
const bucket = this.getBucket(key);
const now = Date.now();
// Reset error count if the time window has expired
if (bucket.firstErrorTime === 0 || now - bucket.firstErrorTime > errorWindow) {
bucket.errors = 0;
bucket.firstErrorTime = now;
}
// Increment error count
bucket.errors++;
// Log error tracking
logger.log('debug', `Error recorded for ${key}: ${bucket.errors}/${errorThreshold} in window`);
// Check if threshold exceeded
if (bucket.errors >= errorThreshold) {
logger.log('warn', `Error threshold exceeded for ${key}: ${bucket.errors} errors`);
return true; // Should block
}
return false; // Continue allowing
}
}

View File

@@ -1,10 +1,11 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logger.ts';
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js';
import {
SecurityLogger,
SecurityLogLevel,
SecurityEventType
} from '../../security/index.ts';
} from '../../security/index.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import {
MtaConnectionError,
@@ -13,10 +14,10 @@ import {
MtaConfigurationError,
MtaTimeoutError,
MtaProtocolError
} from '../../errors/index.ts';
} from '../../errors/index.js';
import { Email } from '../core/classes.email.ts';
import type { EmailProcessingMode } from './interfaces.ts';
import { Email } from '../core/classes.email.js';
import type { EmailProcessingMode } from './interfaces.js';
// Custom error type extension
interface NodeNetworkError extends Error {
@@ -845,28 +846,19 @@ export class SmtpClient {
try {
logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`);
// Format email for DKIM signing
const { dkimSign } = plugins;
const emailContent = await this.getFormattedEmail(email);
// Sign email
const signOptions = {
domainName: this.options.dkim.domain,
keySelector: this.options.dkim.selector,
// Sign via Rust bridge
const bridge = RustSecurityBridge.getInstance();
const signResult = await bridge.signDkim({
rawMessage: emailContent,
domain: this.options.dkim.domain,
selector: this.options.dkim.selector,
privateKey: this.options.dkim.privateKey,
headerFieldNames: this.options.dkim.headers || [
'from', 'to', 'subject', 'date', 'message-id'
]
};
});
const signedEmail = await dkimSign(emailContent, signOptions);
// Replace headers in original email
const dkimHeader = signedEmail.substring(0, signedEmail.indexOf('\r\n\r\n')).split('\r\n')
.find(line => line.startsWith('DKIM-Signature: '));
if (dkimHeader) {
email.addHeader('DKIM-Signature', dkimHeader.substring('DKIM-Signature: '.length));
if (signResult.header) {
email.addHeader('DKIM-Signature', signResult.header);
}
logger.log('debug', 'DKIM signature applied successfully');

View File

@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logger.ts';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.ts';
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
/**
* Interface for rate limit configuration
@@ -83,7 +84,7 @@ export interface IRateLimitResult {
/**
* Unified rate limiter for all email processing modes
*/
export class UnifiedRateLimiter extends plugins.EventEmitter {
export class UnifiedRateLimiter extends EventEmitter {
private config: IHierarchicalRateLimits;
private counters: Map<string, ILimitCounter> = new Map();
private patternCounters: Map<string, ILimitCounter> = new Map();

View File

@@ -1,24 +1,23 @@
// Email delivery components
export * from './classes.emailsignjob.ts';
export * from './classes.delivery.queue.ts';
export * from './classes.delivery.system.ts';
export * from './classes.emailsignjob.js';
export * from './classes.delivery.queue.js';
export * from './classes.delivery.system.js';
// Handle exports with naming conflicts
export { EmailSendJob } from './classes.emailsendjob.ts';
export { DeliveryStatus } from './classes.delivery.system.ts';
export { EmailSendJob } from './classes.emailsendjob.js';
export { DeliveryStatus } from './classes.delivery.system.js';
// Rate limiter exports - fix naming conflict
export { RateLimiter } from './classes.ratelimiter.ts';
export type { IRateLimitConfig } from './classes.ratelimiter.ts';
export { RateLimiter } from './classes.ratelimiter.js';
export type { IRateLimitConfig } from './classes.ratelimiter.js';
// Unified rate limiter
export * from './classes.unified.rate.limiter.ts';
export * from './classes.unified.rate.limiter.js';
// SMTP client and configuration
export * from './classes.mta.config.ts';
export * from './classes.mta.config.js';
// Import and export SMTP modules as namespaces to avoid conflicts
import * as smtpClientMod from './smtpclient/index.ts';
import * as smtpServerMod from './smtpserver/index.ts';
import * as smtpClientMod from './smtpclient/index.js';
export { smtpClientMod, smtpServerMod };
export { smtpClientMod };

Some files were not shown because too many files have changed in this diff Show More