56 Commits

Author SHA1 Message Date
76d898b648 v5.1.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:16:30 +00:00
b422639c34 fix(release): no changes 2026-02-11 10:16:30 +00:00
c45ba2a7b4 v5.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 7s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:11:43 +00:00
b10597fd5e feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements 2026-02-11 10:11:43 +00:00
7908cbaefa v5.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 07:55:28 +00:00
526dcb4dac BREAKING CHANGE(mail): remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests 2026-02-11 07:55:28 +00:00
cf8fcb6efa v4.1.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 5s
2026-02-11 07:36:54 +00:00
2088c9f76e fix(readme): clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README 2026-02-11 07:36:54 +00:00
7853ef67b6 v4.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 07:31:08 +00:00
f7af8c4534 feat(e2e-tests): add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions 2026-02-11 07:31:08 +00:00
a7ea1d86cb v4.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 5s
2026-02-11 07:17:05 +00:00
27bab5f345 BREAKING CHANGE(smtp-client): Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery 2026-02-11 07:17:05 +00:00
fc4877e06b v3.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 23:23:00 +00:00
36006191fc BREAKING CHANGE(security): implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies 2026-02-10 23:23:00 +00:00
d43fc15d8e v2.4.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:43:50 +00:00
248bfcfe78 feat(docs): document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts 2026-02-10 22:43:50 +00:00
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
d7f37afc30 1.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 34s
CI / Build Test (Current Platform) (push) Failing after 38s
Publish to npm / npm-publish (push) Failing after 59s
CI / Build All Platforms (push) Successful in 2m2s
Release / build-and-release (push) Successful in 2m4s
2025-10-24 10:00:25 +00:00
27b6bb779e 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 2025-10-24 10:00:25 +00:00
d4778d15fc 1.2.0
Some checks failed
CI / Build All Platforms (push) Failing after 11s
CI / Build Test (Current Platform) (push) Failing after 13s
CI / Type Check & Lint (push) Failing after 18s
Publish to npm / npm-publish (push) Failing after 12s
Release / build-and-release (push) Failing after 12s
2025-10-24 08:19:56 +00:00
269bafc1bf feat(plugins): Add smartmail, mailauth and uuid to Deno dependencies and export them from plugins; include local dev permissions file 2025-10-24 08:19:56 +00:00
d6a1cf5bf4 1.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 3s
2025-10-24 08:13:59 +00:00
e49becec95 feat(ci): Add CI, release and npm-publish automation; introduce release template and local settings 2025-10-24 08:13:59 +00:00
166 changed files with 30609 additions and 19745 deletions

View File

@@ -0,0 +1,26 @@
## MAILER {{VERSION}}
Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @push.rocks/smartmta
```
#### Option 2: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.

84
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,84 @@
name: CI
on:
push:
branches:
- main
- 'migration/**'
pull_request:
branches:
- main
jobs:
check:
name: Type Check & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Check TypeScript types
run: deno check mod.ts
- name: Lint code
run: deno lint
continue-on-error: true
- name: Format check
run: deno fmt --check
continue-on-error: true
build:
name: Build Test (Current Platform)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Compile for current platform
run: |
echo "Testing compilation for Linux x86_64..."
deno compile --allow-all --no-check \
--output mailer-test \
--target x86_64-unknown-linux-gnu mod.ts
- name: Test binary execution
run: |
chmod +x mailer-test
./mailer-test --version
./mailer-test help
build-all:
name: Build All Platforms
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Compile all platform binaries
run: bash scripts/compile-all.sh
- name: Upload all binaries as artifact
uses: actions/upload-artifact@v3
with:
name: mailer-binaries.zip
path: dist/binaries/*
retention-days: 30

View File

@@ -0,0 +1,129 @@
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
npm-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4
with:
node-version: '18.x'
registry-url: 'https://registry.npmjs.org/'
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Publishing version: $VERSION"
- name: Verify deno.json version matches tag
run: |
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "deno.json version: $DENO_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
exit 1
fi
- name: Compile binaries for npm package
run: |
echo "Compiling binaries for npm package..."
deno task compile
echo ""
echo "Binary sizes:"
ls -lh dist/binaries/
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS
cat SHA256SUMS
cd ../..
- name: Sync package.json version
run: |
VERSION="${{ steps.version.outputs.version_number }}"
echo "Syncing package.json to version ${VERSION}..."
npm version ${VERSION} --no-git-tag-version --allow-same-version
echo "package.json version: $(grep '"version"' package.json | head -1)"
- name: Create npm package
run: |
echo "Creating npm package..."
npm pack
echo ""
echo "Package created:"
ls -lh *.tgz
- name: Test local installation
run: |
echo "Testing local package installation..."
PACKAGE_FILE=$(ls *.tgz)
npm install -g ${PACKAGE_FILE}
echo ""
echo "Testing mailer command:"
mailer --version || echo "Note: Binary execution may fail in CI environment"
echo ""
echo "Checking installed files:"
npm ls -g @push.rocks/smartmta || true
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "Publishing to npm registry..."
npm publish --access public
echo ""
echo "✅ Successfully published @push.rocks/smartmta to npm!"
echo ""
echo "Package info:"
npm view @push.rocks/smartmta
- name: Verify npm package
run: |
echo "Waiting for npm propagation..."
sleep 30
echo ""
echo "Verifying published package..."
npm view @push.rocks/smartmta
echo ""
echo "Testing installation from npm:"
npm install -g @push.rocks/smartmta
echo ""
echo "Package installed successfully!"
which mailer || echo "Binary location check skipped"
- name: Publish Summary
run: |
echo "================================================"
echo " npm Publish Complete!"
echo "================================================"
echo ""
echo "✅ Package: @push.rocks/smartmta"
echo "✅ Version: ${{ steps.version.outputs.version }}"
echo ""
echo "Installation:"
echo " npm install -g @push.rocks/smartmta"
echo ""
echo "Registry:"
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
echo ""

View File

@@ -0,0 +1,249 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify deno.json version matches tag
run: |
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "deno.json version: $DENO_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
exit 1
fi
- name: Compile binaries for all platforms
run: |
echo "================================================"
echo " MAILER Release Compilation"
echo " Version: ${{ steps.version.outputs.version }}"
echo "================================================"
echo ""
# Clean up old binaries and create fresh directory
rm -rf dist/binaries
mkdir -p dist/binaries
echo "→ Cleaned old binaries from dist/binaries"
echo ""
# Linux x86_64
echo "→ Compiling for Linux x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/mailer-linux-x64 \
--target x86_64-unknown-linux-gnu mod.ts
echo " ✓ Linux x86_64 complete"
# Linux ARM64
echo "→ Compiling for Linux ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/mailer-linux-arm64 \
--target aarch64-unknown-linux-gnu mod.ts
echo " ✓ Linux ARM64 complete"
# macOS x86_64
echo "→ Compiling for macOS x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/mailer-macos-x64 \
--target x86_64-apple-darwin mod.ts
echo " ✓ macOS x86_64 complete"
# macOS ARM64
echo "→ Compiling for macOS ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/mailer-macos-arm64 \
--target aarch64-apple-darwin mod.ts
echo " ✓ macOS ARM64 complete"
# Windows x86_64
echo "→ Compiling for Windows x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/mailer-windows-x64.exe \
--target x86_64-pc-windows-msvc mod.ts
echo " ✓ Windows x86_64 complete"
echo ""
echo "All binaries compiled successfully!"
ls -lh dist/binaries/
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Extract changelog for this version
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Check if CHANGELOG.md exists
if [ ! -f CHANGELOG.md ]; then
echo "No CHANGELOG.md found, using default release notes"
cat > /tmp/release_notes.md << EOF
## MAILER $VERSION
Pre-compiled binaries for multiple platforms.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash
\`\`\`
Or download the binary for your platform and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in SHA256SUMS.txt
EOF
else
# Try to extract section for this version from CHANGELOG.md
# This is a simple extraction - adjust based on your CHANGELOG format
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
## MAILER $VERSION
See CHANGELOG.md for full details.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash
\`\`\`
EOF
fi
echo "Release notes:"
cat /tmp/release_notes.md
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$EXISTING_RELEASE_ID"
echo "Existing release deleted"
sleep 2
else
echo "No existing release found, proceeding with creation"
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_NOTES=$(cat /tmp/release_notes.md)
# Create the release
echo "Creating release for $VERSION..."
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"MAILER $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
echo "Release created with ID: $RELEASE_ID"
# Upload binaries as release assets
for binary in dist/binaries/*; do
filename=$(basename "$binary")
echo "Uploading $filename..."
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$binary" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$RELEASE_ID/assets?name=$filename"
done
echo "All assets uploaded successfully"
- name: Clean up old releases
run: |
echo "Cleaning up old releases (keeping only last 3)..."
# Fetch all releases sorted by creation date
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
# Delete old releases
if [ -n "$RELEASES" ]; then
echo "Found releases to delete:"
for release_id in $RELEASES; do
echo " Deleting release ID: $release_id"
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/mailer/releases/$release_id"
done
echo "Old releases deleted successfully"
else
echo "No old releases to delete (less than 4 releases total)"
fi
echo ""
- name: Release Summary
run: |
echo "================================================"
echo " Release ${{ steps.version.outputs.version }} Complete!"
echo "================================================"
echo ""
echo "Binaries published:"
ls -lh dist/binaries/
echo ""
echo "Release URL:"
echo "https://code.foss.global/serve.zone/mailer/releases/tag/${{ steps.version.outputs.version }}"
echo ""
echo "Installation command:"
echo "curl -sSL https://code.foss.global/serve.zone/mailer/raw/branch/main/install.sh | sudo bash"
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,203 @@
# Changelog
## 2026-02-11 - 5.1.1 - fix(release)
no changes
- No files changed in this commit.
- Current package version remains 5.1.0 (from package.json).
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
- Removed ts/errors/index.ts — MTA-specific error classes removed
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
## 2026-02-11 - 4.1.1 - fix(readme)
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
- Changed IPC description to JSON-over-stdin/stdout (clarifies communication format between Rust and TypeScript)
- Added Rust SMTP client entry and documented outbound mail data flow (TypeScript -> Rust signing/delivery -> result back)
- Expanded testing instructions with commands for building Rust binary and running unit/E2E tests
- Updated architecture diagram labels and Rust crate/module descriptions (mailer-smtp now includes client; test counts noted)
- Documentation-only changes; no source code behavior modified
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
- Tests will skip when the Rust bridge or server cannot start (binary build required)
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
- Introduce a Rust SMTP client crate with connection handling, TLS, protocol engine, and connection pooling (new modules: connection, pool, protocol, error, config).
- Add IPC handlers and management commands in the Rust binary: sendEmail, sendRawEmail, verifySmtpConnection, closeSmtpPool, getSmtpPoolStatus and integrate a SmtpClientManager into the runtime.
- Update TypeScript bridge (RustSecurityBridge) with new types and methods (ISmtpSendOptions, ISmtpSendResult, verifySmtpConnection, sendOutboundEmail, sendRawEmail, getSmtpPoolStatus, closeSmtpPool) and rework UnifiedEmailServer to use the Rust bridge for outbound delivery and DKIM signing.
- Remove the previous TypeScript SMTP client implementation and associated tests/utilities (many ts/mail/delivery/smtpclient modules and tests deleted) in favor of the Rust implementation.
- Bump dependencies and cargo config: @push.rocks/smartrust to ^1.2.0 in package.json and add/require crates (uuid, base64, webpki-roots) in Rust Cargo files.
## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security)
implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies
- RustSecurityBridge now extends EventEmitter and includes a BridgeState state machine, IBridgeResilienceConfig with DEFAULT_RESILIENCE_CONFIG, auto-restart with exponential backoff, periodic health checks, restart/restore logic, and descriptive ensureRunning() guards on command methods.
- Added static methods: resetInstance() (test-friendly) and configure(...) to tweak resilience settings at runtime.
- Added stateChange events and logging for lifecycle transitions; new tests added for resilience: test/test.rustsecuritybridge.resilience.node.ts.
- Removed the TypeScript SMTP test helper (test/helpers/server.loader.ts), the DNSManager (ts/mail/routing/classes.dnsmanager.ts), and many deliverability-related interfaces/implementations (IP warmup manager and sender reputation monitor) from unified email server.
- Removed public types ISmtpServerOptions and ISmtpTransactionResult from ts/mail/delivery/interfaces.ts, which is a breaking API change for consumers relying on those types.
- Removed unused dependencies from package.json: ip and mailauth.
## 2026-02-10 - 2.4.0 - feat(docs)
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
## 2026-02-10 - 2.3.2 - fix(tests)
remove large SMTP client test suites and update SmartFile API usage
- 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
- Centralized Node and third-party imports in ts/plugins.ts and re-exported commonly used utilities (net, tls, dns, fs, smartfile, smartdns, smartmail, mailauth, uuid, ip, LRUCache, etc).
- Replaced direct EventEmitter / Node built-in imports with plugins.EventEmitter across delivery, smtpclient, routing and the unified email server to standardize runtime integration.
- Updated deno.json dependency map: added @push.rocks/smartfile, @push.rocks/smartdns, @tsclass/tsclass and ip; reordered lru-cache entry.
- Stopped exporting ./dns/index.ts from ts/index.ts (DNS is available via mail/routing) to avoid duplicate exports.
- Added keysDir alias and dnsRecordsDir in ts/paths.ts and small path-related fixes.
- Added a placeholder SmtpServer and other minor delivery/smtpserver refactors and sanitizations.
- Added a local .claude/settings.local.json for development permissions (local-only configuration).
## 2025-10-24 - 1.2.0 - feat(plugins)
Add smartmail, mailauth and uuid to Deno dependencies and export them from plugins; include local dev permissions file
- Add @push.rocks/smartmail, mailauth and uuid to deno.json dependencies so email parsing, DKIM and UUID utilities are available.
- Export smartmail, mailauth and uuid from ts/plugins.ts for centralized access across the codebase.
- Add .claude/settings.local.json to define local development permissions (tooling/dev environment configuration).
## 2025-10-24 - 1.1.0 - feat(ci)
Add CI, release and npm-publish automation; introduce release template and local settings
- Add CI workflow (.gitea/workflows/ci.yml) to run TypeScript checks, linting, formatting and platform compilation tests
- Add release workflow (.gitea/workflows/release.yml) to compile binaries for multiple platforms, generate checksums, create/update Gitea releases and upload assets
- Add npm publish workflow (.gitea/workflows/npm-publish.yml) to build package from a tag and publish precompiled binaries to npm (tag-triggered)
- Add a Gitea release template (.gitea/release-template.md) describing platforms, checksums and installation options
- Add local tool permission settings (.claude/settings.local.json) used by local tooling
- No API or runtime source changes — this commit is focused on CI/CD, release automation and packaging
## 2025-10-24 - 1.0.1 - fix(dev)
Add local development settings file to grant tooling permissions

View File

@@ -1,47 +0,0 @@
{
"name": "@serve.zone/mailer",
"version": "1.0.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",
"lru-cache": "npm:lru-cache@^11.0.0",
"mailaddress-validator": "npm:mailaddress-validator@^1.0.11"
}
}

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,64 @@
{
"name": "@serve.zone/mailer",
"version": "1.0.1",
"description": "Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure",
"name": "@push.rocks/smartmta",
"version": "5.1.1",
"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": {
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartfs": "^1.3.1",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartmail": "^2.2.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartrust": "^1.2.0",
"@tsclass/tsclass": "^9.2.0",
"lru-cache": "^11.2.5",
"mailparser": "^3.9.3",
"uuid": "^13.0.0"
},
"files": [
"bin/",
"scripts/install-binary.js",
"dist_rust/**/*",
"readme.md",
"license",
"changelog.md"

8651
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

767
readme.md
View File

@@ -1,361 +1,582 @@
# @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 engine runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via JSON-over-stdin/stdout 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 in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| **Rust SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation, DKIM signing — all in Rust |
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification |
| **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 (MX, SPF, DKIM, DMARC) |
### 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 │ │ │ Retry │ │ │ DKIMCreator │ │
└──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
│ │ Scan │ │ │ └────────────────┘ │
│ └───────┘ │ │ │
├───────────┴───────────┴──────────────┴───────────────────────┤
Rust Security Bridge (smartrust IPC) │
├──────────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
## Installation
**Data flow for inbound mail:**
### Via npm (recommended)
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
```bash
npm install -g @serve.zone/mailer
```
**Data flow for outbound mail:**
### From source
```bash
git clone https://code.foss.global/serve.zone/mailer
cd mailer
deno task compile
```
1. 📝 TypeScript constructs the email and resolves DKIM keys for the sender domain
2. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
3. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
## Usage
### CLI Commands
### 🚀 Setting Up the Email Server
#### Service Management
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
```bash
# Start the mailer daemon
sudo mailer service start
```typescript
import { UnifiedEmailServer } from '@push.rocks/smartmta';
# Stop the daemon
sudo mailer service stop
const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
ports: [25, 587, 465],
hostname: 'mail.example.com',
# Restart the daemon
sudo mailer service restart
// 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 },
},
},
],
# Check status
mailer service status
// 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',
},
},
},
],
# Enable systemd service
sudo mailer service enable
// Authentication settings for the SMTP server
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
},
# Disable systemd service
sudo mailer service disable
// TLS certificates
tls: {
certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key',
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
});
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
await emailServer.start();
```
#### Domain Management
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
```bash
# Add a domain
mailer domain add example.com
### 📧 Sending Outbound Emails
# Remove a domain
mailer domain remove example.com
All outbound email delivery goes through the Rust SMTP client, accessed via `UnifiedEmailServer.sendOutboundEmail()`. The Rust client handles connection pooling, TLS negotiation, and DKIM signing automatically:
# List all domains
mailer domain list
```typescript
import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
// Build an email
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
cc: ['cc@example.com'],
subject: 'Hello from smartmta! 🚀',
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 via the Rust SMTP client (connection pooling, TLS, DKIM signing)
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
auth: { user: 'sender@example.com', pass: 'your-password' },
dkimDomain: 'example.com',
dkimSelector: 'default',
});
console.log(`Accepted: ${result.accepted.join(', ')}`);
console.log(`Response: ${result.response}`);
// -> Accepted: recipient@example.com
// -> Response: 2.0.0 Ok: queued
```
#### DNS Management
The `sendOutboundEmail` method:
- 🔑 Automatically resolves DKIM keys from the `DKIMCreator` for the specified domain
- 🔗 Uses connection pooling in Rust — reuses TCP/TLS connections across sends
- ⏱️ Configurable connection and socket timeouts via `outbound` options on the server
```bash
# Auto-configure DNS via Cloudflare
mailer dns setup example.com
### 🔑 DKIM Signing & Key Management
# Validate DNS configuration
mailer dns validate example.com
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by the Rust SMTP client during outbound delivery:
# Show required DNS records
mailer dns show example.com
```
```typescript
import { DKIMCreator } from '@push.rocks/smartmta';
#### Sending Email
const dkimCreator = new DKIMCreator('/path/to/keys', storageManager);
```bash
# Send email via CLI
mailer send \\
--from sender@example.com \\
--to recipient@example.com \\
--subject "Hello" \\
--text "World"
```
// Auto-generate keys if they don't exist
await dkimCreator.handleDKIMKeysForDomain('example.com');
#### Configuration
// 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=...' }
```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>"
// 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}`);
}
```
#### List Domains
When `UnifiedEmailServer.start()` is called:
- DKIM keys are generated or loaded for every configured domain
- Signing is applied to all outbound mail via the Rust security bridge
- Key rotation is checked automatically based on your `rotationInterval` config
```bash
GET /v1/domains
```
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
#### Manage SMTP Credentials
All verification is powered by the Rust binary. For inbound mail, `UnifiedEmailServer` runs the full security pipeline **automatically** — DKIM, SPF, DMARC, content scanning, and IP reputation in a single Rust pass. Results are attached as headers (`Received-SPF`, `X-DKIM-Result`, `X-DMARC-Result`).
```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
You can also use the individual verifiers directly:
```typescript
import { Email, SmtpClient } from '@serve.zone/mailer';
import { DKIMVerifier, SpfVerifier, DmarcVerifier } 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>',
});
// SPF verification
const spfVerifier = new SpfVerifier();
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none', domain, ip }
// Send via SMTP
const client = new SmtpClient({
host: 'smtp.example.com',
port: 587,
secure: true,
auth: {
user: 'username',
pass: 'password',
// DKIM verification
const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent);
// -> [{ is_valid: true, domain: 'example.com', selector: 'default', status: 'pass' }]
// DMARC verification
const dmarcVerifier = new DmarcVerifier();
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// -> { action: 'pass' | 'quarantine' | 'reject', policy, spfDomainAligned, dkimDomainAligned }
```
### 🔀 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);
```
#### Route Action Types
| Action | Description |
|---|---|
| `forward` | Forward the email to another SMTP server via the Rust SMTP client |
| `deliver` | Queue for local MTA delivery |
| `process` | Queue for processing (with optional content scanning and DKIM signing) |
| `reject` | Reject with a configurable SMTP error code and message |
#### Match Criteria
| Criterion | Description |
|---|---|
| `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 |
### ⏱️ 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,
},
},
});
await client.sendMail(email);
```
// Check before sending
const allowed = rateLimiter.checkMessageLimit(
'sender@example.com',
'192.168.1.1',
recipientCount,
undefined,
'example.com'
);
## Configuration
Configuration is stored in `~/.mailer/config.json`:
```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"
if (!allowed.allowed) {
console.log(`Rate limited: ${allowed.reason}`);
}
```
## DNS Setup
### 📬 Bounce Management
The mailer requires the following DNS records for each domain:
Automatic bounce detection (via Rust), classification, and suppression tracking:
### MX Record
```
Type: MX
Name: @
Value: mail.example.com
Priority: 10
TTL: 3600
```typescript
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
const bounceManager = new BounceManager();
// 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', ... }
// Check if an address is suppressed due to bounces
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
// Manage the suppression list
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com');
```
### A Record
```
Type: A
Name: mail
Value: <your-server-ip>
TTL: 3600
### 📝 Email Templates
Template engine with variable substitution for transactional and notification emails:
```typescript
import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>&copy; 2026 Example Corp</p>',
});
// 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',
});
// Create an Email object from the template
const email = await templates.createEmail('welcome', {
to: 'newuser@example.com',
variables: { name: 'Alice' },
});
```
### SPF Record
```
Type: TXT
Name: @
Value: v=spf1 mx ip4:<your-server-ip> ~all
TTL: 3600
### 🌍 DNS Management
When `UnifiedEmailServer.start()` is called, it automatically ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains:
```typescript
const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com',
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns', // managed via Cloudflare API
},
],
// ... other config
});
// 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();
```
### DKIM Record
## 🦀 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 four crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (106 tests) | Full SMTP protocol engine — TCP/TLS server + client, STARTTLS, AUTH, pipelining, connection pooling, in-process security pipeline |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — wires everything together |
### What Runs Where
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | 🦀 Rust | Performance, memory safety, zero-copy parsing |
| SMTP client (outbound delivery, connection pooling) | 🦀 Rust | Connection management, TLS negotiation |
| DKIM signing & verification | 🦀 Rust | Crypto-heavy, benefits from native speed |
| SPF validation | 🦀 Rust | DNS lookups with async resolver |
| DMARC policy checking | 🦀 Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | 🦀 Rust | Parallel DNS queries |
| Content scanning (text patterns) | 🦀 Rust | Regex engine performance |
| Bounce detection (pattern matching) | 🦀 Rust | Regex engine performance |
| Email validation & MIME building | 🦀 Rust | Parsing performance |
| Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
| Delivery queue & retry | 🟦 TypeScript | State management, persistence |
| Template rendering | 🟦 TypeScript | String interpolation |
| Domain & DNS management | 🟦 TypeScript | API integrations |
## 📁 Project Structure
```
Type: TXT
Name: default._domainkey
Value: <dkim-public-key>
TTL: 3600
smartmta/
├── ts/ # TypeScript source
│ ├── mail/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
│ │ ├── 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 + client (TCP/TLS, rate limiting, pooling)
│ └── mailer-bin/ # CLI + smartrust IPC bridge
├── test/ # Test suite (116 TypeScript + 154 Rust tests)
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
```
### DMARC Record
```
Type: TXT
Name: _dmarc
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com
TTL: 3600
```
## 🧪 Testing
Use `mailer dns setup <domain>` to automatically configure these via Cloudflare.
## Development
### Prerequisites
- Deno 1.40+
- Node.js 14+ (for npm distribution)
### Build
The project has comprehensive test coverage with both unit and end-to-end tests:
```bash
# Compile for all platforms
deno task compile
# Build Rust binary first
pnpm build
# Run in development mode
deno task dev
# Run all tests
pnpm test
# Run tests
deno task test
# Format code
deno task fmt
# Lint code
deno task lint
# Run specific test files
tstest test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
tstest test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
tstest test/test.e2e.routing-actions.node.ts --verbose --timeout 60
tstest test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
```
### Ported Components
**E2E tests** exercise the full pipeline — starting `UnifiedEmailServer`, connecting via raw TCP sockets, sending SMTP transactions, verifying routing actions, and testing outbound delivery through a mock SMTP receiver.
The mail implementation is ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) and adapted for Deno:
## API Reference
- ✅ 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
### Exported Classes (top-level)
## Roadmap
| Class | Description |
|---|---|
| `UnifiedEmailServer` | 🎯 Main entry point — orchestrates SMTP server, routing, security, and delivery |
| `Email` | Email message class with validation, attachments, headers, and RFC 822 serialization |
| `EmailRouter` | Pattern-based route matching and evaluation engine |
| `DomainRegistry` | Multi-domain configuration manager |
| `DnsManager` | Automatic DNS record management |
| `DKIMCreator` | DKIM key generation, storage, rotation |
| `DKIMVerifier` | DKIM signature verification (delegates to Rust) |
| `SpfVerifier` | SPF record validation (delegates to Rust) |
| `DmarcVerifier` | DMARC policy enforcement (delegates to Rust) |
### 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
### Namespaced Exports
### Phase 2 - Production Ready
- [ ] Comprehensive testing
- [ ] Documentation
- [ ] Performance optimization
- [ ] Security hardening
- [ ] Monitoring and logging
| Namespace | Classes |
|---|---|
| `Core` | `Email`, `EmailValidator`, `TemplateManager`, `BounceManager` |
| `Delivery` | `UnifiedDeliveryQueue`, `MultiModeDeliverySystem`, `DeliveryStatus`, `UnifiedRateLimiter` |
### Phase 3 - Advanced Features
- [ ] Webhook support
- [ ] Email templates
- [ ] Analytics and reporting
- [ ] Multi-tenancy
- [ ] Load balancing
## License and Legal Information
## License
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
MIT © Serve Zone
**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.
## Contributing
### Trademarks
Contributions are welcome! Please see our [contributing guidelines](https://code.foss.global/serve.zone/mailer/contributing).
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.
## Support
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.
- Documentation: https://code.foss.global/serve.zone/mailer
- Issues: https://code.foss.global/serve.zone/mailer/issues
- Email: support@serve.zone
### Company Information
## Acknowledgments
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
- 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/)
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"

2328
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,34 @@
[workspace]
resolver = "2"
members = [
"crates/mailer-core",
"crates/mailer-smtp",
"crates/mailer-security",
"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"
dashmap = "6"
thiserror = "2"
tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
rustls-pki-types = "1"
psl = "2"
clap = { version = "4", features = ["derive"] }
sha2 = "0.10"
hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false }

View File

@@ -0,0 +1,24 @@
[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
base64.workspace = true
uuid.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "mailer-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.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,17 @@
[package]
name = "mailer-security"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mail-auth.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
hickory-resolver.workspace = true
rustls-pki-types.workspace = true
psl.workspace = true
regex.workspace = true

View File

@@ -0,0 +1,517 @@
//! 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
// ---------------------------------------------------------------------------
/// Regexes for HTML text extraction (compiled once via LazyLock).
static HTML_STYLE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap());
static HTML_SCRIPT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap());
static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
/// Strip HTML tags and decode common entities to produce plain text.
fn extract_text_from_html(html: &str) -> String {
let text = HTML_STYLE_RE.replace_all(html, " ");
let text = HTML_SCRIPT_RE.replace_all(&text, " ");
let text = HTML_TAG_RE.replace_all(&text, " ");
text.replace("&nbsp;", " ")
.replace("&lt;", "<")
.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,261 @@
use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256};
use mail_auth::common::headers::HeaderWriter;
use mail_auth::dkim::{Canonicalization, DkimSigner};
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
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())
}
/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463).
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `domain` - The signing domain (d= tag)
/// * `selector` - The DKIM selector (s= tag)
/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format
///
/// Returns the DKIM-Signature header string to prepend to the message.
pub fn sign_dkim_ed25519(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pkcs8_der: &[u8],
) -> Result<String> {
let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der)
.map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?;
let signature = DkimSigner::from_key(ed_key)
.domain(domain)
.selector(selector)
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
.header_canonicalization(Canonicalization::Relaxed)
.body_canonicalization(Canonicalization::Relaxed)
.sign(raw_message)
.map_err(|e| SecurityError::Dkim(format!("Ed25519 DKIM signing failed: {}", e)))?;
Ok(signature.to_header())
}
/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`.
///
/// * `key_type` - `"rsa"` (default) or `"ed25519"`
/// * For RSA: `private_key_pem` is a PEM-encoded RSA key
/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key
pub fn sign_dkim_auto(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pem: &str,
key_type: &str,
) -> Result<String> {
match key_type.to_lowercase().as_str() {
"ed25519" => {
// Parse PEM to DER for Ed25519
let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?;
sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der())
}
_ => sign_dkim(raw_message, domain, selector, private_key_pem),
}
}
/// Generate a DKIM DNS TXT record value for a given public key.
///
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
/// `key_type` should be `"rsa"` or `"ed25519"`.
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
// Extract the base64 content from PEM
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
}
/// Generate a DKIM DNS TXT record value with explicit key type.
///
/// * `key_type` - `"rsa"` or `"ed25519"`
pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String {
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
let k = match key_type.to_lowercase().as_str() {
"ed25519" => "ed25519",
_ => "rsa",
};
format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64)
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
}
#[test]
fn test_sign_dkim_ed25519() {
// Generate an Ed25519 key pair using mail-auth
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
let _pub_key = ed_key.public_key();
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
assert!(result.is_ok());
let header = result.unwrap();
assert!(header.contains("a=ed25519-sha256"));
assert!(header.contains("d=example.com"));
assert!(header.contains("s=ed25519sel"));
}
#[test]
fn test_sign_dkim_auto_dispatches() {
// RSA with invalid key should error
let msg = b"From: test@example.com\r\n\r\nBody";
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
assert!(result.is_err());
// Ed25519 with invalid PEM should error
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
assert!(result.is_err());
}
#[test]
fn test_dkim_dns_record_value_typed() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
assert!(rsa_record.contains("k=rsa"));
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
assert!(ed_record.contains("k=ed25519"));
}
}

View File

@@ -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,317 @@
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
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 (IPv4 or IPv6)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use
pub async fn check_dnsbl(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<DnsblResult> {
let reversed = match ip {
IpAddr::V4(v4) => reverse_ipv4(v4),
IpAddr::V6(v6) => reverse_ipv6(v6),
};
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])
}
/// Reverse IPv6 address to nibble format for DNSBL queries.
///
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
fn reverse_ipv6(ip: Ipv6Addr) -> String {
let segments = ip.segments();
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
full_hex
.chars()
.rev()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(".")
}
/// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType {
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_reverse_ipv6() {
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
);
}
#[test]
fn test_reverse_ipv6_loopback() {
let ip: Ipv6Addr = "::1".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
);
}
#[tokio::test]
async fn test_check_dnsbl_ipv6_runs() {
// Verify IPv6 actually goes through DNSBL queries (not skipped)
let resolver = hickory_resolver::TokioResolver::builder_tokio()
.map(|b| b.build())
.unwrap();
let ip: IpAddr = "::1".parse().unwrap();
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
// Loopback should not be listed on any DNSBL
assert_eq!(result.listed_count, 0);
// But total_checked should be > 0 — proving IPv6 was actually queried
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
}
#[test]
fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
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_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use verify::{verify_email_security, EmailSecurityResult};
pub use error::{Result, SecurityError};
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,28 @@
[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
serde.workspace = true
serde_json.workspace = true
regex.workspace = true
uuid.workspace = true
base64.workspace = true
rustls-pki-types.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
mailparse.workspace = true
webpki-roots = "0.26"
sha2.workspace = true
hmac.workspace = true
pbkdf2.workspace = true

View File

@@ -0,0 +1,170 @@
//! SMTP client configuration types.
use serde::Deserialize;
/// Configuration for connecting to an SMTP server.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmtpClientConfig {
/// Target SMTP server hostname.
pub host: String,
/// Target port (25 = SMTP, 465 = implicit TLS, 587 = submission).
pub port: u16,
/// Use implicit TLS (port 465). If false, STARTTLS is attempted.
#[serde(default)]
pub secure: bool,
/// Domain to use in EHLO command. Defaults to "localhost".
#[serde(default = "default_domain")]
pub domain: String,
/// Authentication credentials (optional).
pub auth: Option<SmtpAuthConfig>,
/// Connection timeout in seconds. Default: 30.
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u64,
/// Socket read/write timeout in seconds. Default: 120.
#[serde(default = "default_socket_timeout")]
pub socket_timeout_secs: u64,
/// Pool key override. Defaults to "host:port".
pub pool_key: Option<String>,
/// Maximum connections per pool. Default: 10.
#[serde(default = "default_max_pool_connections")]
pub max_pool_connections: usize,
/// Accept invalid TLS certificates (expired, self-signed, wrong hostname).
/// Standard for MTA-to-MTA opportunistic TLS per RFC 7435.
/// Default: false.
#[serde(default)]
pub tls_opportunistic: bool,
}
/// Authentication configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmtpAuthConfig {
/// Username.
pub user: String,
/// Password.
pub pass: String,
/// Method: "PLAIN" or "LOGIN". Default: "PLAIN".
#[serde(default = "default_auth_method")]
pub method: String,
}
/// DKIM signing configuration (applied before sending).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DkimSignConfig {
/// Signing domain (e.g. "example.com").
pub domain: String,
/// DKIM selector (e.g. "default" or "mta").
pub selector: String,
/// PEM-encoded private key (RSA or Ed25519 PKCS#8).
pub private_key: String,
/// Key type: "rsa" (default) or "ed25519".
#[serde(default = "default_key_type")]
pub key_type: String,
}
fn default_key_type() -> String {
"rsa".to_string()
}
impl SmtpClientConfig {
/// Get the effective pool key for this config.
pub fn effective_pool_key(&self) -> String {
self.pool_key
.clone()
.unwrap_or_else(|| format!("{}:{}", self.host, self.port))
}
}
fn default_domain() -> String {
"localhost".to_string()
}
fn default_connection_timeout() -> u64 {
30
}
fn default_socket_timeout() -> u64 {
120
}
fn default_max_pool_connections() -> usize {
10
}
fn default_auth_method() -> String {
"PLAIN".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_minimal_config() {
let json = r#"{"host":"mail.example.com","port":25}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.host, "mail.example.com");
assert_eq!(config.port, 25);
assert!(!config.secure);
assert_eq!(config.domain, "localhost");
assert!(config.auth.is_none());
assert_eq!(config.connection_timeout_secs, 30);
assert_eq!(config.socket_timeout_secs, 120);
assert_eq!(config.max_pool_connections, 10);
}
#[test]
fn test_deserialize_full_config() {
let json = r#"{
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"domain": "myserver.com",
"auth": { "user": "u", "pass": "p", "method": "LOGIN" },
"connectionTimeoutSecs": 60,
"socketTimeoutSecs": 300,
"poolKey": "gmail",
"maxPoolConnections": 5
}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.host, "smtp.gmail.com");
assert_eq!(config.port, 465);
assert!(config.secure);
assert_eq!(config.domain, "myserver.com");
assert_eq!(config.connection_timeout_secs, 60);
assert_eq!(config.socket_timeout_secs, 300);
assert_eq!(config.effective_pool_key(), "gmail");
assert_eq!(config.max_pool_connections, 5);
let auth = config.auth.unwrap();
assert_eq!(auth.user, "u");
assert_eq!(auth.pass, "p");
assert_eq!(auth.method, "LOGIN");
}
#[test]
fn test_effective_pool_key_default() {
let json = r#"{"host":"mx.example.com","port":587}"#;
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.effective_pool_key(), "mx.example.com:587");
}
#[test]
fn test_dkim_config_deserialize() {
let json = r#"{"domain":"example.com","selector":"mta","privateKey":"-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"}"#;
let dkim: DkimSignConfig = serde_json::from_str(json).unwrap();
assert_eq!(dkim.domain, "example.com");
assert_eq!(dkim.selector, "mta");
assert!(dkim.private_key.contains("RSA PRIVATE KEY"));
}
}

View File

@@ -0,0 +1,260 @@
//! TCP/TLS connection management for the SMTP client.
use super::error::SmtpClientError;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_rustls::client::TlsStream;
use tracing::debug;
/// A client-side SMTP stream that may be plain or TLS.
pub enum ClientSmtpStream {
Plain(BufReader<TcpStream>),
Tls(BufReader<TlsStream<TcpStream>>),
}
impl std::fmt::Debug for ClientSmtpStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientSmtpStream::Plain(_) => write!(f, "ClientSmtpStream::Plain"),
ClientSmtpStream::Tls(_) => write!(f, "ClientSmtpStream::Tls"),
}
}
}
impl ClientSmtpStream {
/// Read a line from the stream (CRLF-terminated).
pub async fn read_line(&mut self, buf: &mut String) -> Result<usize, SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => reader.read_line(buf).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Read error: {e}"),
}
}),
ClientSmtpStream::Tls(reader) => reader.read_line(buf).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS read error: {e}"),
}
}),
}
}
/// Write bytes to the stream.
pub async fn write_all(&mut self, data: &[u8]) -> Result<(), SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => {
reader.get_mut().write_all(data).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Write error: {e}"),
}
})
}
ClientSmtpStream::Tls(reader) => {
reader.get_mut().write_all(data).await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS write error: {e}"),
}
})
}
}
}
/// Flush the stream.
pub async fn flush(&mut self) -> Result<(), SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => {
reader.get_mut().flush().await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("Flush error: {e}"),
}
})
}
ClientSmtpStream::Tls(reader) => {
reader.get_mut().flush().await.map_err(|e| {
SmtpClientError::ConnectionError {
message: format!("TLS flush error: {e}"),
}
})
}
}
}
/// Consume this stream and return the inner TcpStream (for STARTTLS upgrade).
/// Only works on Plain streams; returns an error on TLS streams.
pub fn into_tcp_stream(self) -> Result<TcpStream, SmtpClientError> {
match self {
ClientSmtpStream::Plain(reader) => Ok(reader.into_inner()),
ClientSmtpStream::Tls(_) => Err(SmtpClientError::TlsError {
message: "Cannot extract TcpStream from an already-TLS stream".into(),
}),
}
}
}
/// Connect to an SMTP server via plain TCP.
pub async fn connect_plain(
host: &str,
port: u16,
timeout_secs: u64,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Connecting to {}:{} (plain)", host, port);
let addr = format!("{host}:{port}");
let stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
.await
.map_err(|_| SmtpClientError::TimeoutError {
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
})?
.map_err(|e| SmtpClientError::ConnectionError {
message: format!("Failed to connect to {addr}: {e}"),
})?;
Ok(ClientSmtpStream::Plain(BufReader::new(stream)))
}
/// Connect to an SMTP server via implicit TLS (port 465).
pub async fn connect_tls(
host: &str,
port: u16,
timeout_secs: u64,
tls_opportunistic: bool,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Connecting to {}:{} (implicit TLS)", host, port);
let addr = format!("{host}:{port}");
let tcp_stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
.await
.map_err(|_| SmtpClientError::TimeoutError {
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
})?
.map_err(|e| SmtpClientError::ConnectionError {
message: format!("Failed to connect to {addr}: {e}"),
})?;
let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
}
/// Upgrade a plain TCP connection to TLS (STARTTLS).
pub async fn upgrade_to_tls(
stream: ClientSmtpStream,
hostname: &str,
tls_opportunistic: bool,
) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
let tcp_stream = stream.into_tcp_stream()?;
let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
}
/// A TLS certificate verifier that accepts any certificate.
/// Used for MTA-to-MTA opportunistic TLS per RFC 7435.
#[derive(Debug)]
struct OpportunisticVerifier;
impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Perform the TLS handshake on a TCP stream using webpki-roots.
/// When `tls_opportunistic` is true, certificate verification is skipped
/// (standard for MTA-to-MTA delivery per RFC 7435).
async fn perform_tls_handshake(
tcp_stream: TcpStream,
hostname: &str,
tls_opportunistic: bool,
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
let tls_config = if tls_opportunistic {
debug!("Using opportunistic TLS (no cert verification) for {}", hostname);
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(OpportunisticVerifier))
.with_no_client_auth()
} else {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth()
};
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
SmtpClientError::TlsError {
message: format!("Invalid server name '{hostname}': {e}"),
}
})?;
let tls_stream = connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| SmtpClientError::TlsError {
message: format!("TLS handshake with {hostname} failed: {e}"),
})?;
Ok(tls_stream)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connect_plain_refused() {
// Connecting to a port that's not listening should fail
let result = connect_plain("127.0.0.1", 19999, 2).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, SmtpClientError::ConnectionError { .. }));
assert!(err.is_retryable());
}
#[tokio::test]
async fn test_connect_tls_refused() {
let result = connect_tls("127.0.0.1", 19998, 2, false).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_connect_timeout() {
// 192.0.2.1 is TEST-NET, should time out
let result = connect_plain("192.0.2.1", 25, 1).await;
assert!(result.is_err());
let err = result.unwrap_err();
// May be timeout or connection error depending on network
assert!(err.is_retryable());
}
}

View File

@@ -0,0 +1,160 @@
//! SMTP client error types.
use serde::Serialize;
/// Errors that can occur during SMTP client operations.
#[derive(Debug, thiserror::Error, Serialize)]
pub enum SmtpClientError {
#[error("Connection error: {message}")]
ConnectionError { message: String },
#[error("Timeout: {message}")]
TimeoutError { message: String },
#[error("TLS error: {message}")]
TlsError { message: String },
#[error("Authentication failed: {message}")]
AuthenticationError { message: String },
#[error("Protocol error ({code}): {message}")]
ProtocolError { code: u16, message: String },
#[error("Pool exhausted: {message}")]
PoolExhausted { message: String },
#[error("Invalid configuration: {message}")]
ConfigError { message: String },
}
impl SmtpClientError {
/// Whether this error is retryable (temporary failure).
/// Permanent failures (5xx, auth failures) are not retryable.
pub fn is_retryable(&self) -> bool {
match self {
SmtpClientError::ConnectionError { .. } => true,
SmtpClientError::TimeoutError { .. } => true,
SmtpClientError::TlsError { .. } => false,
SmtpClientError::AuthenticationError { .. } => false,
SmtpClientError::ProtocolError { code, .. } => *code >= 400 && *code < 500,
SmtpClientError::PoolExhausted { .. } => true,
SmtpClientError::ConfigError { .. } => false,
}
}
/// The error type as a string for IPC serialization.
pub fn error_type(&self) -> &'static str {
match self {
SmtpClientError::ConnectionError { .. } => "connection",
SmtpClientError::TimeoutError { .. } => "timeout",
SmtpClientError::TlsError { .. } => "tls",
SmtpClientError::AuthenticationError { .. } => "authentication",
SmtpClientError::ProtocolError { .. } => "protocol",
SmtpClientError::PoolExhausted { .. } => "pool_exhausted",
SmtpClientError::ConfigError { .. } => "config",
}
}
/// The SMTP code if this is a protocol error.
pub fn smtp_code(&self) -> Option<u16> {
match self {
SmtpClientError::ProtocolError { code, .. } => Some(*code),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_retryable_errors() {
assert!(SmtpClientError::ConnectionError {
message: "refused".into()
}
.is_retryable());
assert!(SmtpClientError::TimeoutError {
message: "timed out".into()
}
.is_retryable());
assert!(SmtpClientError::PoolExhausted {
message: "full".into()
}
.is_retryable());
assert!(SmtpClientError::ProtocolError {
code: 421,
message: "try later".into()
}
.is_retryable());
assert!(SmtpClientError::ProtocolError {
code: 450,
message: "mailbox busy".into()
}
.is_retryable());
}
#[test]
fn test_non_retryable_errors() {
assert!(!SmtpClientError::AuthenticationError {
message: "bad creds".into()
}
.is_retryable());
assert!(!SmtpClientError::TlsError {
message: "cert invalid".into()
}
.is_retryable());
assert!(!SmtpClientError::ProtocolError {
code: 550,
message: "no such user".into()
}
.is_retryable());
assert!(!SmtpClientError::ProtocolError {
code: 554,
message: "rejected".into()
}
.is_retryable());
assert!(!SmtpClientError::ConfigError {
message: "bad config".into()
}
.is_retryable());
}
#[test]
fn test_error_type_strings() {
assert_eq!(
SmtpClientError::ConnectionError {
message: "x".into()
}
.error_type(),
"connection"
);
assert_eq!(
SmtpClientError::ProtocolError {
code: 550,
message: "x".into()
}
.error_type(),
"protocol"
);
}
#[test]
fn test_smtp_code() {
assert_eq!(
SmtpClientError::ProtocolError {
code: 550,
message: "x".into()
}
.smtp_code(),
Some(550)
);
assert_eq!(
SmtpClientError::ConnectionError {
message: "x".into()
}
.smtp_code(),
None
);
}
}

View File

@@ -0,0 +1,16 @@
//! SMTP client module for outbound email delivery.
//!
//! Provides connection pooling, SMTP protocol, TLS, and authentication
//! for sending outbound emails through remote SMTP servers.
pub mod config;
pub mod connection;
pub mod error;
pub mod pool;
pub mod protocol;
// Re-export key types for convenience.
pub use config::{DkimSignConfig, SmtpAuthConfig, SmtpClientConfig};
pub use error::SmtpClientError;
pub use pool::{SmtpClientManager, SmtpSendResult, SmtpVerifyResult};
pub use protocol::{dot_stuff, EhloCapabilities, SmtpClientResponse};

View File

@@ -0,0 +1,515 @@
//! Connection pooling for the SMTP client.
//!
//! Manages reusable connections per destination `host:port`.
use super::config::SmtpClientConfig;
use super::connection::{connect_plain, connect_tls, ClientSmtpStream};
use super::error::SmtpClientError;
use super::protocol::{self, EhloCapabilities};
use dashmap::DashMap;
use serde::Serialize;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use tracing::{debug, info};
/// Maximum age of a pooled connection (5 minutes).
const MAX_CONNECTION_AGE_SECS: u64 = 300;
/// Maximum idle time before a connection is reaped (30 seconds).
const MAX_IDLE_SECS: u64 = 30;
/// Maximum messages per pooled connection before it's recycled.
const MAX_MESSAGES_PER_CONNECTION: u32 = 100;
/// A pooled SMTP connection.
pub struct PooledConnection {
pub stream: ClientSmtpStream,
pub capabilities: EhloCapabilities,
pub created_at: Instant,
pub last_used: Instant,
pub message_count: u32,
pub idle: bool,
}
/// Check if a pooled connection is stale (too old, too many messages, or idle too long).
fn is_connection_stale(conn: &PooledConnection) -> bool {
conn.created_at.elapsed().as_secs() > MAX_CONNECTION_AGE_SECS
|| conn.message_count >= MAX_MESSAGES_PER_CONNECTION
|| (conn.idle && conn.last_used.elapsed().as_secs() > MAX_IDLE_SECS)
}
/// Per-destination connection pool.
pub struct ConnectionPool {
connections: Vec<PooledConnection>,
max_connections: usize,
config: SmtpClientConfig,
}
impl ConnectionPool {
fn new(config: SmtpClientConfig) -> Self {
let max_connections = config.max_pool_connections;
Self {
connections: Vec::new(),
max_connections,
config,
}
}
/// Get an idle connection or create a new one.
async fn acquire(&mut self) -> Result<PooledConnection, SmtpClientError> {
// Remove stale connections first
self.cleanup_stale();
// Find an idle connection
if let Some(idx) = self
.connections
.iter()
.position(|c| c.idle && !is_connection_stale(c))
{
let mut conn = self.connections.remove(idx);
conn.idle = false;
conn.last_used = Instant::now();
debug!(
"Reusing pooled connection (age={}s, msgs={})",
conn.created_at.elapsed().as_secs(),
conn.message_count
);
return Ok(conn);
}
// Check if we can create a new connection
if self.connections.len() >= self.max_connections {
return Err(SmtpClientError::PoolExhausted {
message: format!(
"Pool for {} is at max capacity ({})",
self.config.effective_pool_key(),
self.max_connections
),
});
}
// Create a new connection
self.create_connection().await
}
/// Return a connection to the pool (or close it if it's expired).
fn release(&mut self, mut conn: PooledConnection) {
conn.message_count += 1;
conn.last_used = Instant::now();
conn.idle = true;
// Don't return if it's stale
if is_connection_stale(&conn) || self.connections.len() >= self.max_connections {
debug!("Discarding stale/excess pooled connection");
// Drop the connection (stream will be closed)
return;
}
self.connections.push(conn);
}
/// Create a fresh SMTP connection and complete the handshake.
async fn create_connection(&self) -> Result<PooledConnection, SmtpClientError> {
let mut stream = if self.config.secure {
connect_tls(
&self.config.host,
self.config.port,
self.config.connection_timeout_secs,
self.config.tls_opportunistic,
)
.await?
} else {
connect_plain(
&self.config.host,
self.config.port,
self.config.connection_timeout_secs,
)
.await?
};
// Read greeting
protocol::read_greeting(&mut stream, self.config.socket_timeout_secs).await?;
// Send EHLO
let mut capabilities =
protocol::send_ehlo(&mut stream, &self.config.domain, self.config.socket_timeout_secs)
.await?;
// STARTTLS if available and not already secure
if !self.config.secure && capabilities.starttls {
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
stream =
super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
// Re-EHLO after STARTTLS — use updated capabilities for auth
capabilities = protocol::send_ehlo(
&mut stream,
&self.config.domain,
self.config.socket_timeout_secs,
)
.await?;
}
// Authenticate if credentials provided
if let Some(auth) = &self.config.auth {
protocol::authenticate(
&mut stream,
auth,
&capabilities,
self.config.socket_timeout_secs,
)
.await?;
}
info!(
"New SMTP connection to {} established",
self.config.effective_pool_key()
);
Ok(PooledConnection {
stream,
capabilities,
created_at: Instant::now(),
last_used: Instant::now(),
message_count: 0,
idle: false,
})
}
fn cleanup_stale(&mut self) {
self.connections.retain(|c| !is_connection_stale(c));
}
/// Number of connections in the pool.
fn total(&self) -> usize {
self.connections.len()
}
/// Number of idle connections.
fn idle_count(&self) -> usize {
self.connections.iter().filter(|c| c.idle).count()
}
/// Close all connections.
fn close_all(&mut self) {
self.connections.clear();
}
}
/// Status report for a single pool.
#[derive(Debug, Clone, Serialize)]
pub struct PoolStatus {
pub total: usize,
pub active: usize,
pub idle: usize,
}
/// Manages connection pools for multiple SMTP destinations.
pub struct SmtpClientManager {
pools: DashMap<String, Arc<Mutex<ConnectionPool>>>,
}
impl SmtpClientManager {
pub fn new() -> Self {
Self {
pools: DashMap::new(),
}
}
/// Get or create a pool for the given config.
fn get_pool(&self, config: &SmtpClientConfig) -> Arc<Mutex<ConnectionPool>> {
let key = config.effective_pool_key();
self.pools
.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(ConnectionPool::new(config.clone()))))
.clone()
}
/// Acquire a connection from the pool, send a message, and release it.
pub async fn send_message(
&self,
config: &SmtpClientConfig,
sender: &str,
recipients: &[String],
message: &[u8],
) -> Result<SmtpSendResult, SmtpClientError> {
let pool_arc = self.get_pool(config);
let mut pool = pool_arc.lock().await;
let mut conn = pool.acquire().await?;
drop(pool); // Release the pool lock while we do network I/O
// Reset server state if reusing a connection that has already sent messages
if conn.message_count > 0 {
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
}
// Perform the SMTP transaction (use pipelining if server supports it)
let pipelining = conn.capabilities.pipelining;
let result =
Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
// Re-acquire the pool lock and release the connection
let mut pool = pool_arc.lock().await;
match &result {
Ok(_) => pool.release(conn),
Err(_) => {
// Don't return failed connections to the pool
debug!("Discarding connection after send failure");
}
}
result
}
/// Perform the SMTP send transaction on a connected stream.
async fn perform_send(
stream: &mut ClientSmtpStream,
sender: &str,
recipients: &[String],
message: &[u8],
config: &SmtpClientConfig,
pipelining: bool,
) -> Result<SmtpSendResult, SmtpClientError> {
let timeout_secs = config.socket_timeout_secs;
let (accepted, rejected) = if pipelining {
// Use pipelined envelope: MAIL FROM + all RCPT TO in one batch
let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope(
stream, sender, recipients, timeout_secs,
).await?;
(acc, rej)
} else {
// Sequential: MAIL FROM, then each RCPT TO
protocol::send_mail_from(stream, sender, timeout_secs).await?;
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for rcpt in recipients {
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
Ok(resp) => {
if resp.is_success() {
accepted.push(rcpt.clone());
} else {
rejected.push(rcpt.clone());
}
}
Err(_) => {
rejected.push(rcpt.clone());
}
}
}
(accepted, rejected)
};
// If no recipients were accepted, fail
if accepted.is_empty() {
return Err(SmtpClientError::ProtocolError {
code: 550,
message: "All recipients were rejected".into(),
});
}
// DATA
let data_resp = protocol::send_data(stream, message, timeout_secs).await?;
// Extract message ID from the response if present
let message_id = data_resp
.lines
.iter()
.find_map(|line| {
// Look for a pattern like "queued as XXXX" or message-id
if line.contains("queued") || line.contains("id=") {
Some(line.clone())
} else {
None
}
});
Ok(SmtpSendResult {
accepted,
rejected,
message_id,
response: data_resp.full_message(),
envelope: SmtpEnvelope {
from: sender.to_string(),
to: recipients.to_vec(),
},
})
}
/// Verify connectivity to an SMTP server (connect, EHLO, QUIT).
pub async fn verify_connection(
&self,
config: &SmtpClientConfig,
) -> Result<SmtpVerifyResult, SmtpClientError> {
let mut stream = if config.secure {
connect_tls(
&config.host,
config.port,
config.connection_timeout_secs,
config.tls_opportunistic,
)
.await?
} else {
connect_plain(
&config.host,
config.port,
config.connection_timeout_secs,
)
.await?
};
let greeting = protocol::read_greeting(&mut stream, config.socket_timeout_secs).await?;
let caps =
protocol::send_ehlo(&mut stream, &config.domain, config.socket_timeout_secs).await?;
let _ = protocol::send_quit(&mut stream, config.socket_timeout_secs).await;
Ok(SmtpVerifyResult {
reachable: true,
greeting: Some(greeting.full_message()),
capabilities: Some(caps.extensions),
})
}
/// Get status of all pools.
pub fn pool_status(&self) -> std::collections::HashMap<String, PoolStatus> {
let mut result = std::collections::HashMap::new();
for entry in self.pools.iter() {
let key = entry.key().clone();
// Try to get the lock without blocking — if locked, report as active
match entry.value().try_lock() {
Ok(pool) => {
let total = pool.total();
let idle = pool.idle_count();
result.insert(
key,
PoolStatus {
total,
active: total - idle,
idle,
},
);
}
Err(_) => {
// Pool is in use; report as busy
result.insert(
key,
PoolStatus {
total: 0,
active: 1,
idle: 0,
},
);
}
}
}
result
}
/// Close a specific pool.
pub async fn close_pool(&self, key: &str) {
if let Some(pool_ref) = self.pools.get(key) {
let mut pool = pool_ref.lock().await;
pool.close_all();
}
self.pools.remove(key);
}
/// Close all pools.
pub async fn close_all_pools(&self) {
let keys: Vec<String> = self.pools.iter().map(|e| e.key().clone()).collect();
for key in keys {
self.close_pool(&key).await;
}
}
}
/// Result of sending an email via SMTP.
#[derive(Debug, Clone, Serialize)]
pub struct SmtpSendResult {
pub accepted: Vec<String>,
pub rejected: Vec<String>,
#[serde(rename = "messageId")]
pub message_id: Option<String>,
pub response: String,
pub envelope: SmtpEnvelope,
}
/// SMTP envelope (sender + recipients).
#[derive(Debug, Clone, Serialize)]
pub struct SmtpEnvelope {
pub from: String,
pub to: Vec<String>,
}
/// Result of verifying an SMTP connection.
#[derive(Debug, Clone, Serialize)]
pub struct SmtpVerifyResult {
pub reachable: bool,
pub greeting: Option<String>,
pub capabilities: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pool_status_serialization() {
let status = PoolStatus {
total: 5,
active: 2,
idle: 3,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"total\":5"));
assert!(json.contains("\"active\":2"));
assert!(json.contains("\"idle\":3"));
}
#[test]
fn test_send_result_serialization() {
let result = SmtpSendResult {
accepted: vec!["a@b.com".into()],
rejected: vec![],
message_id: Some("abc123".into()),
response: "250 OK".into(),
envelope: SmtpEnvelope {
from: "from@test.com".into(),
to: vec!["a@b.com".into()],
},
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"messageId\":\"abc123\""));
assert!(json.contains("\"accepted\":[\"a@b.com\"]"));
}
#[test]
fn test_verify_result_serialization() {
let result = SmtpVerifyResult {
reachable: true,
greeting: Some("220 mail.example.com".into()),
capabilities: Some(vec!["SIZE 10485760".into(), "STARTTLS".into()]),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"reachable\":true"));
}
#[test]
fn test_smtp_client_manager_new() {
let mgr = SmtpClientManager::new();
assert!(mgr.pool_status().is_empty());
}
#[tokio::test]
async fn test_close_all_empty() {
let mgr = SmtpClientManager::new();
mgr.close_all_pools().await;
assert!(mgr.pool_status().is_empty());
}
}

View File

@@ -0,0 +1,568 @@
//! SMTP client protocol engine.
//!
//! Implements the SMTP command/response flow for sending outbound email.
use super::config::SmtpAuthConfig;
use super::connection::ClientSmtpStream;
use super::error::SmtpClientError;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tokio::time::{timeout, Duration};
use tracing::debug;
/// Parsed SMTP response (from the remote server).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpClientResponse {
pub code: u16,
pub lines: Vec<String>,
}
impl SmtpClientResponse {
pub fn is_success(&self) -> bool {
self.code >= 200 && self.code < 300
}
pub fn is_positive_intermediate(&self) -> bool {
self.code >= 300 && self.code < 400
}
pub fn is_temp_error(&self) -> bool {
self.code >= 400 && self.code < 500
}
pub fn is_perm_error(&self) -> bool {
self.code >= 500
}
/// Full response text (all lines joined).
pub fn full_message(&self) -> String {
self.lines.join(" ")
}
/// Convert to a protocol error if this is an error response.
pub fn to_error(&self) -> SmtpClientError {
SmtpClientError::ProtocolError {
code: self.code,
message: self.full_message(),
}
}
}
/// Server capabilities parsed from EHLO response.
#[derive(Debug, Clone, Default)]
pub struct EhloCapabilities {
pub extensions: Vec<String>,
pub max_size: Option<u64>,
pub starttls: bool,
pub auth_methods: Vec<String>,
pub pipelining: bool,
pub eight_bit_mime: bool,
}
/// Read a multi-line SMTP response from the server.
pub async fn read_response(
stream: &mut ClientSmtpStream,
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
let mut lines = Vec::new();
let mut code: u16;
loop {
let mut line = String::new();
let n = timeout(
Duration::from_secs(timeout_secs),
stream.read_line(&mut line),
)
.await
.map_err(|_| SmtpClientError::TimeoutError {
message: format!("Timeout reading SMTP response after {timeout_secs}s"),
})??;
if n == 0 {
return Err(SmtpClientError::ConnectionError {
message: "Connection closed while reading response".into(),
});
}
// Guard against unbounded lines from malicious servers (RFC 5321 §4.5.3.1.4 says 512 max)
if line.len() > 4096 {
return Err(SmtpClientError::ProtocolError {
code: 0,
message: format!("Response line too long ({} bytes, max 4096)", line.len()),
});
}
let line = line.trim_end_matches('\n').trim_end_matches('\r');
if line.len() < 3 {
return Err(SmtpClientError::ProtocolError {
code: 0,
message: format!("Invalid response line: {line}"),
});
}
// Parse the 3-digit code
let parsed_code: u16 = line[..3].parse().map_err(|_| SmtpClientError::ProtocolError {
code: 0,
message: format!("Invalid response code in: {line}"),
})?;
code = parsed_code;
// Text after the code (skip the separator character)
let text = if line.len() > 4 { &line[4..] } else { "" };
lines.push(text.to_string());
// Check for continuation: "250-" means more lines, "250 " means last line
if line.len() >= 4 && line.as_bytes()[3] == b'-' {
continue;
} else {
break;
}
}
debug!("SMTP response: {} {}", code, lines.join(" | "));
Ok(SmtpClientResponse { code, lines })
}
/// Read the server greeting (first response after connect).
pub async fn read_greeting(
stream: &mut ClientSmtpStream,
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
let resp = read_response(stream, timeout_secs).await?;
if resp.code == 220 {
Ok(resp)
} else {
Err(SmtpClientError::ProtocolError {
code: resp.code,
message: format!("Unexpected greeting: {}", resp.full_message()),
})
}
}
/// Send a raw command and read the response.
async fn send_command(
stream: &mut ClientSmtpStream,
command: &str,
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
debug!("SMTP C: {}", command);
stream
.write_all(format!("{command}\r\n").as_bytes())
.await?;
stream.flush().await?;
read_response(stream, timeout_secs).await
}
/// Send EHLO and parse capabilities.
pub async fn send_ehlo(
stream: &mut ClientSmtpStream,
domain: &str,
timeout_secs: u64,
) -> Result<EhloCapabilities, SmtpClientError> {
let resp = send_command(stream, &format!("EHLO {domain}"), timeout_secs).await?;
if !resp.is_success() {
// Fall back to HELO
let helo_resp = send_command(stream, &format!("HELO {domain}"), timeout_secs).await?;
if !helo_resp.is_success() {
return Err(helo_resp.to_error());
}
return Ok(EhloCapabilities::default());
}
let mut caps = EhloCapabilities::default();
// First line is the greeting, remaining lines are capabilities
for line in resp.lines.iter().skip(1) {
let upper = line.to_uppercase();
if upper.starts_with("SIZE ") {
caps.max_size = upper[5..].trim().parse().ok();
} else if upper == "STARTTLS" {
caps.starttls = true;
} else if upper.starts_with("AUTH ") {
caps.auth_methods = upper[5..]
.split_whitespace()
.map(|s| s.to_string())
.collect();
} else if upper == "PIPELINING" {
caps.pipelining = true;
} else if upper == "8BITMIME" {
caps.eight_bit_mime = true;
}
caps.extensions.push(line.clone());
}
Ok(caps)
}
/// Send STARTTLS command (does not perform the TLS handshake itself).
pub async fn send_starttls(
stream: &mut ClientSmtpStream,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
let resp = send_command(stream, "STARTTLS", timeout_secs).await?;
if resp.code != 220 {
return Err(SmtpClientError::ProtocolError {
code: resp.code,
message: format!("STARTTLS rejected: {}", resp.full_message()),
});
}
Ok(())
}
/// Authenticate using AUTH PLAIN.
pub async fn send_auth_plain(
stream: &mut ClientSmtpStream,
user: &str,
pass: &str,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
// AUTH PLAIN sends \0user\0pass in base64
let credentials = format!("\x00{user}\x00{pass}");
let encoded = BASE64.encode(credentials.as_bytes());
let resp = send_command(stream, &format!("AUTH PLAIN {encoded}"), timeout_secs).await?;
if resp.code != 235 {
return Err(SmtpClientError::AuthenticationError {
message: format!("AUTH PLAIN failed ({}): {}", resp.code, resp.full_message()),
});
}
Ok(())
}
/// Authenticate using AUTH LOGIN.
pub async fn send_auth_login(
stream: &mut ClientSmtpStream,
user: &str,
pass: &str,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
// Step 1: Send AUTH LOGIN
let resp = send_command(stream, "AUTH LOGIN", timeout_secs).await?;
if resp.code != 334 {
return Err(SmtpClientError::AuthenticationError {
message: format!(
"AUTH LOGIN challenge failed ({}): {}",
resp.code,
resp.full_message()
),
});
}
// Step 2: Send base64 username
let user_b64 = BASE64.encode(user.as_bytes());
let resp = send_command(stream, &user_b64, timeout_secs).await?;
if resp.code != 334 {
return Err(SmtpClientError::AuthenticationError {
message: format!(
"AUTH LOGIN username rejected ({}): {}",
resp.code,
resp.full_message()
),
});
}
// Step 3: Send base64 password
let pass_b64 = BASE64.encode(pass.as_bytes());
let resp = send_command(stream, &pass_b64, timeout_secs).await?;
if resp.code != 235 {
return Err(SmtpClientError::AuthenticationError {
message: format!(
"AUTH LOGIN password rejected ({}): {}",
resp.code,
resp.full_message()
),
});
}
Ok(())
}
/// Authenticate using the configured method.
pub async fn authenticate(
stream: &mut ClientSmtpStream,
auth: &SmtpAuthConfig,
_caps: &EhloCapabilities,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
match auth.method.to_uppercase().as_str() {
"LOGIN" => send_auth_login(stream, &auth.user, &auth.pass, timeout_secs).await,
_ => send_auth_plain(stream, &auth.user, &auth.pass, timeout_secs).await,
}
}
/// Send MAIL FROM.
pub async fn send_mail_from(
stream: &mut ClientSmtpStream,
sender: &str,
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
let resp = send_command(stream, &format!("MAIL FROM:<{sender}>"), timeout_secs).await?;
if !resp.is_success() {
return Err(resp.to_error());
}
Ok(resp)
}
/// Send RCPT TO. Returns per-recipient success/failure.
pub async fn send_rcpt_to(
stream: &mut ClientSmtpStream,
recipient: &str,
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
let resp = send_command(stream, &format!("RCPT TO:<{recipient}>"), timeout_secs).await?;
// We don't fail the entire send on per-recipient errors;
// the caller decides based on the response code.
Ok(resp)
}
/// Send MAIL FROM + RCPT TO commands in a single pipelined batch.
///
/// Writes all envelope commands at once, then reads responses in order.
/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`.
pub async fn send_pipelined_envelope(
stream: &mut ClientSmtpStream,
sender: &str,
recipients: &[String],
timeout_secs: u64,
) -> Result<(bool, Vec<String>, Vec<String>), SmtpClientError> {
// Build the full pipelined command batch
let mut batch = format!("MAIL FROM:<{sender}>\r\n");
for rcpt in recipients {
batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n"));
}
// Send all commands at once
debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len());
stream.write_all(batch.as_bytes()).await?;
stream.flush().await?;
// Read MAIL FROM response
let mail_resp = read_response(stream, timeout_secs).await?;
if !mail_resp.is_success() {
return Err(mail_resp.to_error());
}
// Read RCPT TO responses
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for rcpt in recipients {
match read_response(stream, timeout_secs).await {
Ok(resp) => {
if resp.is_success() {
accepted.push(rcpt.clone());
} else {
rejected.push(rcpt.clone());
}
}
Err(_) => {
rejected.push(rcpt.clone());
}
}
}
Ok((true, accepted, rejected))
}
/// Send DATA command, followed by the message body with dot-stuffing.
pub async fn send_data(
stream: &mut ClientSmtpStream,
message: &[u8],
timeout_secs: u64,
) -> Result<SmtpClientResponse, SmtpClientError> {
// Send DATA command
let resp = send_command(stream, "DATA", timeout_secs).await?;
if !resp.is_positive_intermediate() {
return Err(resp.to_error());
}
// Send the message body with dot-stuffing
let stuffed = dot_stuff(message);
stream.write_all(&stuffed).await?;
// Send terminator: CRLF.CRLF
// If the message doesn't end with CRLF, add one
if !stuffed.ends_with(b"\r\n") {
stream.write_all(b"\r\n").await?;
}
stream.write_all(b".\r\n").await?;
stream.flush().await?;
// Read final response
let final_resp = read_response(stream, timeout_secs).await?;
if !final_resp.is_success() {
return Err(final_resp.to_error());
}
Ok(final_resp)
}
/// Send RSET command to reset the server state between messages on a reused connection.
pub async fn send_rset(
stream: &mut ClientSmtpStream,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
let resp = send_command(stream, "RSET", timeout_secs).await?;
if !resp.is_success() {
return Err(resp.to_error());
}
Ok(())
}
/// Send QUIT command.
pub async fn send_quit(
stream: &mut ClientSmtpStream,
timeout_secs: u64,
) -> Result<(), SmtpClientError> {
// Best-effort QUIT — ignore errors since we're closing anyway
let _ = send_command(stream, "QUIT", timeout_secs).await;
Ok(())
}
/// Apply SMTP dot-stuffing to a message body.
///
/// Any line starting with a period gets an extra period prepended.
/// Also normalizes bare LF to CRLF.
pub fn dot_stuff(data: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(data.len() + data.len() / 40);
let mut at_line_start = true;
for i in 0..data.len() {
let byte = data[i];
// Normalize bare LF to CRLF
if byte == b'\n' && (i == 0 || data[i - 1] != b'\r') {
result.push(b'\r');
result.push(b'\n');
at_line_start = true;
continue;
}
// Dot-stuff: add extra dot at start of line
if at_line_start && byte == b'.' {
result.push(b'.');
}
result.push(byte);
at_line_start = byte == b'\n';
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dot_stuffing_basic() {
assert_eq!(
dot_stuff(b"Hello\r\n.World\r\n"),
b"Hello\r\n..World\r\n"
);
}
#[test]
fn test_dot_stuffing_leading_dot() {
assert_eq!(dot_stuff(b".starts with dot\r\n"), b"..starts with dot\r\n");
}
#[test]
fn test_dot_stuffing_multiple_dots() {
assert_eq!(
dot_stuff(b"ok\r\n.line1\r\n..line2\r\n"),
b"ok\r\n..line1\r\n...line2\r\n"
);
}
#[test]
fn test_dot_stuffing_bare_lf() {
assert_eq!(
dot_stuff(b"line1\nline2\n"),
b"line1\r\nline2\r\n"
);
}
#[test]
fn test_dot_stuffing_bare_lf_with_dot() {
assert_eq!(
dot_stuff(b"ok\n.dotline\n"),
b"ok\r\n..dotline\r\n"
);
}
#[test]
fn test_dot_stuffing_no_change() {
assert_eq!(
dot_stuff(b"Hello World\r\nNo dots here\r\n"),
b"Hello World\r\nNo dots here\r\n"
);
}
#[test]
fn test_dot_stuffing_empty() {
assert_eq!(dot_stuff(b""), b"");
}
#[test]
fn test_response_is_success() {
let resp = SmtpClientResponse {
code: 250,
lines: vec!["OK".into()],
};
assert!(resp.is_success());
assert!(!resp.is_temp_error());
assert!(!resp.is_perm_error());
}
#[test]
fn test_response_temp_error() {
let resp = SmtpClientResponse {
code: 450,
lines: vec!["Mailbox busy".into()],
};
assert!(!resp.is_success());
assert!(resp.is_temp_error());
}
#[test]
fn test_response_perm_error() {
let resp = SmtpClientResponse {
code: 550,
lines: vec!["No such user".into()],
};
assert!(!resp.is_success());
assert!(resp.is_perm_error());
}
#[test]
fn test_response_positive_intermediate() {
let resp = SmtpClientResponse {
code: 354,
lines: vec!["Start mail input".into()],
};
assert!(resp.is_positive_intermediate());
assert!(!resp.is_success());
}
#[test]
fn test_response_full_message() {
let resp = SmtpClientResponse {
code: 250,
lines: vec!["OK".into(), "SIZE 10485760".into()],
};
assert_eq!(resp.full_message(), "OK SIZE 10485760");
}
#[test]
fn test_ehlo_capabilities_default() {
let caps = EhloCapabilities::default();
assert!(!caps.starttls);
assert!(!caps.pipelining);
assert!(!caps.eight_bit_mime);
assert!(caps.auth_methods.is_empty());
assert!(caps.max_size.is_none());
}
}

View File

@@ -0,0 +1,423 @@
//! 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,
ScramSha256,
}
/// 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,
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
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,101 @@
//! SMTP server configuration.
use serde::{Deserialize, Serialize};
/// Per-domain TLS certificate for SNI-based cert selection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsDomainCert {
/// Domain names this certificate covers (matched against SNI hostname).
pub domains: Vec<String>,
/// Certificate chain in PEM format.
pub cert_pem: String,
/// Private key in PEM format.
pub key_pem: String,
}
/// Configuration for an SMTP server instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpServerConfig {
/// 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 (default cert).
pub tls_cert_pem: Option<String>,
/// TLS private key in PEM format (default key).
pub tls_key_pem: Option<String>,
/// Additional per-domain TLS certificates for SNI-based selection.
#[serde(default)]
pub additional_tls_certs: Vec<TlsDomainCert>,
/// Maximum message size in bytes.
pub max_message_size: u64,
/// Maximum number of concurrent connections.
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,
additional_tls_certs: Vec::new(),
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,41 @@
//! 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 client;
pub mod command;
pub mod config;
pub mod connection;
pub mod data;
pub mod rate_limiter;
pub mod response;
pub mod scram;
pub mod server;
pub mod session;
pub mod state;
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 SCRAM-SHA-256".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 SCRAM-SHA-256".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 SCRAM-SHA-256".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,342 @@
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
//!
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
//! a challenge-response protocol that avoids transmitting cleartext passwords.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
/// Pre-computed SCRAM credentials for a user (derived from password).
#[derive(Debug, Clone)]
pub struct ScramCredentials {
pub salt: Vec<u8>,
pub iterations: u32,
pub stored_key: Vec<u8>,
pub server_key: Vec<u8>,
}
/// Server-side SCRAM state machine.
pub struct ScramServer {
/// Username extracted from client-first-message.
pub username: String,
/// Full combined nonce (client + server).
combined_nonce: String,
/// Server nonce portion (used in tests for verification).
#[allow(dead_code)]
server_nonce: String,
/// Stored credentials (set after TS responds).
credentials: Option<ScramCredentials>,
/// The server-first-message (for auth message construction).
server_first: String,
/// The client-first-message-bare (for auth message construction).
client_first_bare: String,
}
impl ScramServer {
/// Process the client-first-message.
///
/// Parses the client nonce and username, generates a server nonce,
/// and returns a partial state that needs credentials to produce the
/// server-first-message.
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
// client-first-message = gs2-header client-first-message-bare
// gs2-header = "n,," (no channel binding)
// client-first-message-bare = "n=username,r=nonce"
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
rest
} else if let Some(rest) = client_first.strip_prefix("y,,") {
rest
} else {
return Err("Invalid SCRAM gs2-header".into());
};
let mut username = String::new();
let mut client_nonce = String::new();
for part in bare.split(',') {
if let Some(val) = part.strip_prefix("n=") {
username = val.to_string();
} else if let Some(val) = part.strip_prefix("r=") {
client_nonce = val.to_string();
}
}
if username.is_empty() || client_nonce.is_empty() {
return Err("Missing username or nonce in client-first-message".into());
}
// Generate server nonce
let server_nonce: String = (0..24)
.map(|_| {
let idx = (rand_byte() as usize) % 62;
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
})
.collect();
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
Ok(ScramServer {
username,
combined_nonce,
server_nonce,
credentials: None,
server_first: String::new(),
client_first_bare: bare.to_string(),
})
}
/// Set the credentials and produce the server-first-message.
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
let salt_b64 = BASE64.encode(&creds.salt);
let server_first = format!(
"r={},s={},i={}",
self.combined_nonce, salt_b64, creds.iterations
);
self.server_first = server_first.clone();
self.credentials = Some(creds);
server_first
}
/// Process the client-final-message and verify the proof.
///
/// Returns the server-final-message (containing ServerSignature) on success,
/// or an error string on failure.
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
// Parse client-final-message
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
let mut channel_binding = String::new();
let mut nonce = String::new();
let mut proof_b64 = String::new();
for part in client_final.split(',') {
if let Some(val) = part.strip_prefix("c=") {
channel_binding = val.to_string();
} else if let Some(val) = part.strip_prefix("r=") {
nonce = val.to_string();
} else if let Some(val) = part.strip_prefix("p=") {
proof_b64 = val.to_string();
}
}
// Verify nonce matches
if nonce != self.combined_nonce {
return Err("Nonce mismatch".into());
}
// Build the client-final-message-without-proof
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
// Complete the auth message
let auth_message = format!(
"{},{},{}",
self.client_first_bare, self.server_first, client_final_without_proof
);
// Verify client proof
let client_proof = BASE64.decode(proof_b64.as_bytes())
.map_err(|_| "Invalid base64 in client proof")?;
// ClientSignature = HMAC(StoredKey, AuthMessage)
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
// ClientKey = ClientProof XOR ClientSignature
if client_proof.len() != client_signature.len() {
return Err("Client proof length mismatch".into());
}
let client_key: Vec<u8> = client_proof
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
// StoredKey = H(ClientKey)
let computed_stored_key = sha256(&client_key);
// Verify: computed StoredKey must match the stored StoredKey
if computed_stored_key != creds.stored_key {
return Err("Authentication failed".into());
}
// Generate ServerSignature for mutual authentication
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
let server_sig_b64 = BASE64.encode(&server_signature);
Ok(format!("v={}", server_sig_b64))
}
}
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
// ClientKey = HMAC(SaltedPassword, "Client Key")
let client_key = hmac_sha256(&salted_password, b"Client Key");
// StoredKey = H(ClientKey)
let stored_key = sha256(&client_key);
// ServerKey = HMAC(SaltedPassword, "Server Key")
let server_key = hmac_sha256(&salted_password, b"Server Key");
ScramCredentials {
salt: salt.to_vec(),
iterations,
stored_key,
server_key,
}
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Simple random byte using system randomness.
fn rand_byte() -> u8 {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let state = RandomState::new();
let mut hasher = state.build_hasher();
hasher.write_u64(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64);
hasher.finish() as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scram_full_exchange() {
let password = "pencil";
let salt = b"test-salt-1234";
let iterations = 4096;
// Pre-compute server-side credentials from password
let creds = compute_scram_credentials(password, salt, iterations);
// 1. Client sends client-first-message
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
let mut server = ScramServer::from_client_first(client_first).unwrap();
assert_eq!(server.username, "user");
// 2. Server responds with server-first-message
let server_first = server.server_first_message(creds.clone());
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
assert!(server_first.contains("s="));
assert!(server_first.contains("i=4096"));
// 3. Client computes proof
// SaltedPassword
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
let client_key = hmac_sha256(&salted_password, b"Client Key");
let stored_key = sha256(&client_key);
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
let client_proof: Vec<u8> = client_key
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
let proof_b64 = BASE64.encode(&client_proof);
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
// 4. Server verifies proof
let result = server.process_client_final(&client_final);
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
let server_final = result.unwrap();
assert!(server_final.starts_with("v="));
}
#[test]
fn test_scram_wrong_password() {
let password = "pencil";
let wrong_password = "wrong";
let salt = b"test-salt";
let iterations = 4096;
let creds = compute_scram_credentials(password, salt, iterations);
let client_first = "n,,n=user,r=clientnonce123";
let mut server = ScramServer::from_client_first(client_first).unwrap();
let server_first = server.server_first_message(creds);
// Client computes proof with wrong password
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
wrong_password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
let client_key = hmac_sha256(&salted_password, b"Client Key");
let stored_key = sha256(&client_key);
let client_first_bare = "n=user,r=clientnonce123";
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
let client_proof: Vec<u8> = client_key
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
let proof_b64 = BASE64.encode(&client_proof);
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
let result = server.process_client_final(&client_final);
assert!(result.is_err());
}
#[test]
fn test_compute_scram_credentials() {
let creds = compute_scram_credentials("password", b"salt", 4096);
assert_eq!(creds.salt, b"salt");
assert_eq!(creds.iterations, 4096);
assert_eq!(creds.stored_key.len(), 32);
assert_eq!(creds.server_key.len(), 32);
}
#[test]
fn test_invalid_client_first() {
assert!(ScramServer::from_client_first("invalid").is_err());
assert!(ScramServer::from_client_first("n,,").is_err());
}
}

View File

@@ -0,0 +1,428 @@
//! 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::collections::HashMap;
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);
});
}
}
/// SNI-based certificate resolver that selects the appropriate TLS certificate
/// based on the client's requested hostname.
struct SniCertResolver {
/// Domain -> certified key mapping.
certs: HashMap<String, Arc<rustls::sign::CertifiedKey>>,
/// Default certificate for non-matching SNI or missing SNI.
default: Arc<rustls::sign::CertifiedKey>,
}
impl std::fmt::Debug for SniCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SniCertResolver")
.field("domains", &self.certs.keys().collect::<Vec<_>>())
.finish()
}
}
impl rustls::server::ResolvesServerCert for SniCertResolver {
fn resolve(
&self,
client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
if let Some(sni) = client_hello.server_name() {
let sni_lower = sni.to_lowercase();
if let Some(key) = self.certs.get(&sni_lower) {
return Some(key.clone());
}
}
Some(self.default.clone())
}
}
/// Parse a PEM cert+key pair into a `CertifiedKey`.
fn parse_certified_key(
cert_pem: &str,
key_pem: &str,
) -> Result<rustls::sign::CertifiedKey, Box<dyn std::error::Error + Send + Sync>> {
let certs: Vec<CertificateDer<'static>> = {
let mut reader = BufReader::new(cert_pem.as_bytes());
rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?
};
if certs.is_empty() {
return Err("No certificates found in PEM".into());
}
let key: PrivateKeyDer<'static> = {
let mut reader = BufReader::new(key_pem.as_bytes());
let mut keys = Vec::new();
for item in rustls_pemfile::read_all(&mut reader) {
match item? {
rustls_pemfile::Item::Pkcs8Key(key) => keys.push(PrivateKeyDer::Pkcs8(key)),
rustls_pemfile::Item::Pkcs1Key(key) => keys.push(PrivateKeyDer::Pkcs1(key)),
rustls_pemfile::Item::Sec1Key(key) => keys.push(PrivateKeyDer::Sec1(key)),
_ => {}
}
}
keys.into_iter().next().ok_or("No private key found in PEM")?
};
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
}
/// Build a TLS acceptor from PEM cert/key strings.
fn build_tls_acceptor(
config: &SmtpServerConfig,
) -> 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")?
};
// If additional TLS certs are configured, use SNI-based resolution
let tls_config = if config.additional_tls_certs.is_empty() {
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?
} else {
// Build default certified key
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key));
// Build per-domain certs
let mut domain_certs = HashMap::new();
for domain_cert in &config.additional_tls_certs {
match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) {
Ok(ck) => {
let ck = Arc::new(ck);
for domain in &domain_cert.domains {
domain_certs.insert(domain.to_lowercase(), ck.clone());
}
info!("SNI cert loaded for domains: {:?}", domain_cert.domains);
}
Err(e) => {
warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e);
}
}
}
let resolver = SniCertResolver {
certs: domain_certs,
default: default_ck,
};
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver))
};
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
}
#[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

@@ -1,66 +0,0 @@
#!/bin/bash
set -e
# Get version from deno.json
VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
BINARY_DIR="dist/binaries"
echo "================================================"
echo " MAILER Compilation Script"
echo " Version: ${VERSION}"
echo "================================================"
echo ""
echo "Compiling for all supported platforms..."
echo ""
# Clean up old binaries and create fresh directory
rm -rf "$BINARY_DIR"
mkdir -p "$BINARY_DIR"
echo "→ Cleaned old binaries from $BINARY_DIR"
echo ""
# Linux x86_64
echo "→ Compiling for Linux x86_64..."
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-x64" \
--target x86_64-unknown-linux-gnu mod.ts
echo " ✓ Linux x86_64 complete"
echo ""
# Linux ARM64
echo "→ Compiling for Linux ARM64..."
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-arm64" \
--target aarch64-unknown-linux-gnu mod.ts
echo " ✓ Linux ARM64 complete"
echo ""
# macOS x86_64
echo "→ Compiling for macOS x86_64..."
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-x64" \
--target x86_64-apple-darwin mod.ts
echo " ✓ macOS x86_64 complete"
echo ""
# macOS ARM64
echo "→ Compiling for macOS ARM64..."
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-arm64" \
--target aarch64-apple-darwin mod.ts
echo " ✓ macOS ARM64 complete"
echo ""
# Windows x86_64
echo "→ Compiling for Windows x86_64..."
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-windows-x64.exe" \
--target x86_64-pc-windows-msvc mod.ts
echo " ✓ Windows x86_64 complete"
echo ""
echo "================================================"
echo " Compilation Summary"
echo "================================================"
echo ""
ls -lh "$BINARY_DIR/" | tail -n +2
echo ""
echo "✓ All binaries compiled successfully!"
echo ""
echo "Binary location: $BINARY_DIR/"
echo ""

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,239 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
import {
connectToSmtp,
waitForGreeting,
sendSmtpCommand,
performSmtpHandshake,
createConcurrentConnections,
createMimeMessage,
} from './helpers/utils.js';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
tap.test('setup - start server on port 10125', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10125],
hostname: 'test.inbound.local',
domains: [
{
domain: 'testdomain.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*@testdomain.com',
},
action: {
type: 'process',
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
}
});
tap.test('EHLO and capability discovery', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
// Verify we received capabilities from the EHLO response
expect(capabilities.length).toBeGreaterThan(0);
// The server hostname should be in the first capability line
const firstLine = capabilities[0];
expect(firstLine).toBeTruthy();
socket.destroy();
});
tap.test('send valid email - full SMTP transaction', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
// EHLO
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
// MAIL FROM
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
// RCPT TO
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
// DATA
await sendSmtpCommand(socket, 'DATA', '354', 10000);
// Send MIME message
const mimeMessage = createMimeMessage({
from: 'sender@example.com',
to: 'user@testdomain.com',
subject: 'E2E Test Email',
text: 'This is an end-to-end test email.',
});
// Send the message data followed by the terminator
await new Promise<void>((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
socket.removeAllListeners('data');
reject(new Error('DATA response timeout'));
}, 10000);
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('250')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve();
}
};
socket.on('data', onData);
socket.write(mimeMessage + '\r\n.\r\n');
});
// QUIT
try {
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
} catch {
// Ignore QUIT errors
}
socket.destroy();
// Verify the email was queued for processing
const stats = server.deliveryQueue.getStats();
expect(stats.queueSize).toBeGreaterThan(0);
});
tap.test('multiple recipients', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
await sendSmtpCommand(socket, 'DATA', '354', 10000);
const mimeMessage = createMimeMessage({
from: 'sender@example.com',
to: 'user1@testdomain.com',
subject: 'Multi-recipient Test',
text: 'Testing multiple recipients.',
});
await new Promise<void>((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
socket.removeAllListeners('data');
reject(new Error('DATA response timeout'));
}, 10000);
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('250')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve();
}
};
socket.on('data', onData);
socket.write(mimeMessage + '\r\n.\r\n');
});
socket.destroy();
});
tap.test('concurrent connections', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
expect(sockets.length).toEqual(3);
// Perform EHLO on each connection
for (const socket of sockets) {
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
}
// Close all connections
for (const socket of sockets) {
socket.destroy();
}
});
tap.test('RSET mid-session', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
// Start a transaction
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
// Reset the transaction
await sendSmtpCommand(socket, 'RSET', '250', 10000);
// Start a new transaction after RSET
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
socket.destroy();
});
tap.test('cleanup - stop server', async () => {
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,196 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
import { Email } from '../ts/mail/core/classes.email.js';
import * as net from 'net';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
let mockSmtpServer: net.Server;
/**
* Create a minimal mock SMTP server that accepts any email.
* This avoids the IPC deadlock that occurs when the Rust SMTP client
* sends to the same Rust process's SMTP server (the IPC stdin reader
* blocks on the sendEmail command and can't process emailProcessingResult).
*/
function createMockSmtpServer(port: number): Promise<net.Server> {
return new Promise((resolve, reject) => {
const srv = net.createServer((socket) => {
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
let inData = false;
let dataBuffer = '';
socket.on('data', (chunk) => {
const input = chunk.toString();
if (inData) {
dataBuffer += input;
if (dataBuffer.includes('\r\n.\r\n')) {
inData = false;
dataBuffer = '';
socket.write('250 2.0.0 Ok: queued\r\n');
}
return;
}
// Process SMTP commands line by line
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
for (const line of lines) {
const cmd = line.toUpperCase();
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
} else if (cmd.startsWith('MAIL FROM')) {
socket.write('250 2.1.0 Ok\r\n');
} else if (cmd.startsWith('RCPT TO')) {
socket.write('250 2.1.5 Ok\r\n');
} else if (cmd === 'DATA') {
inData = true;
dataBuffer = '';
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (cmd === 'QUIT') {
socket.write('221 2.0.0 Bye\r\n');
socket.end();
} else if (cmd === 'RSET') {
socket.write('250 2.0.0 Ok\r\n');
}
}
});
});
srv.listen(port, '127.0.0.1', () => {
resolve(srv);
});
srv.on('error', reject);
});
}
tap.test('setup - start bridge and mock SMTP server', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10325],
hostname: 'test.outbound.local',
domains: [
{
domain: 'outbound-test.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*',
},
action: {
type: 'process',
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
return;
}
// Start a mock SMTP server on a separate port for outbound delivery tests
mockSmtpServer = await createMockSmtpServer(10326);
});
tap.test('send email to mock SMTP receiver', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@outbound-test.com',
to: 'recipient@outbound-test.com',
subject: 'Outbound E2E Test',
text: 'Testing outbound delivery to the mock SMTP server.',
});
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
expect(result).toBeTruthy();
expect(result.accepted).toBeTruthy();
expect(result.accepted.length).toBeGreaterThan(0);
expect(result.response).toBeTruthy();
// Rust SMTP client returns enhanced status code without the 250 prefix
expect(result.response).toInclude('2.0.0');
});
tap.test('send email - connection refused', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@outbound-test.com',
to: 'recipient@outbound-test.com',
subject: 'Connection Refused Test',
text: 'This should fail — no server at port 59888.',
});
try {
await server.sendOutboundEmail('127.0.0.1', 59888, email);
throw new Error('Expected sendOutboundEmail to fail on connection refused');
} catch (err: any) {
expect(err).toBeTruthy();
expect(err.message.length).toBeGreaterThan(0);
}
});
tap.test('SMTP pool status and cleanup', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
expect(status).toBeTruthy();
expect(status.pools).toBeTruthy();
expect(typeof status.pools).toEqual('object');
// Close all pools
await bridge.closeSmtpPool();
// Verify pools are empty
const statusAfter = await bridge.getSmtpPoolStatus();
const poolKeys = Object.keys(statusAfter.pools);
expect(poolKeys.length).toEqual(0);
});
tap.test('cleanup - stop server and mock', async () => {
if (mockSmtpServer) {
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
}
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,239 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
import { Email } from '../ts/mail/core/classes.email.js';
import { SmtpState } from '../ts/mail/delivery/interfaces.js';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
/**
* Build a minimal SMTP session object for processEmailByMode().
*/
function buildSession(email: Email): any {
return {
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
state: SmtpState.FINISHED,
mailFrom: email.from,
rcptTo: email.to,
emailData: '',
useTLS: false,
connectionEnded: false,
remoteAddress: '127.0.0.1',
clientHostname: 'test-client.local',
secure: false,
authenticated: false,
envelope: {
mailFrom: { address: email.from, args: {} },
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
},
};
}
tap.test('setup - start server with routing rules on port 10225', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10225],
hostname: 'test.routing.local',
domains: [
{ domain: 'process.com', dnsMode: 'forward' },
{ domain: 'local.com', dnsMode: 'forward' },
{ domain: 'external.com', dnsMode: 'forward' },
],
routes: [
{
name: 'reject-route',
priority: 40,
match: {
senders: '*@spammer.com',
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Spam rejected',
},
},
},
{
name: 'process-route',
priority: 30,
match: {
recipients: '*@process.com',
},
action: {
type: 'process',
process: {
scan: true,
},
},
},
{
name: 'deliver-route',
priority: 20,
match: {
recipients: '*@local.com',
},
action: {
type: 'deliver',
},
},
{
name: 'forward-route',
priority: 10,
match: {
recipients: '*@external.com',
},
action: {
type: 'forward',
forward: {
host: '127.0.0.1',
port: 59999, // No server listening — expected failure
},
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
}
});
tap.test('process action - queues email for processing', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@process.com',
subject: 'Process test',
text: 'This email should be queued for processing.',
});
const session = buildSession(email);
const result = await server.processEmailByMode(email, session);
expect(result).toBeTruthy();
const stats = server.deliveryQueue.getStats();
expect(stats.modes.process).toBeGreaterThan(0);
});
tap.test('deliver action - queues email for MTA delivery', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@local.com',
subject: 'Deliver test',
text: 'This email should be queued for local delivery.',
});
const session = buildSession(email);
const result = await server.processEmailByMode(email, session);
expect(result).toBeTruthy();
const stats = server.deliveryQueue.getStats();
expect(stats.modes.mta).toBeGreaterThan(0);
});
tap.test('reject action - throws with correct code', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'bad@spammer.com',
to: 'user@process.com',
subject: 'Spam attempt',
text: 'This should be rejected.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for rejected email');
} catch (err: any) {
expect(err.responseCode).toEqual(550);
expect(err.message).toInclude('Spam rejected');
}
});
tap.test('forward action - fails to unreachable host', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@external.com',
subject: 'Forward test',
text: 'This forward should fail — no server at port 59999.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
} catch (err: any) {
// We expect an error from the failed SMTP connection
expect(err).toBeTruthy();
expect(err.message).toBeTruthy();
}
});
tap.test('no matching route - throws error', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'nobody@unmatched.com',
subject: 'Unmatched route test',
text: 'No route matches this recipient.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for no matching route');
} catch (err: any) {
expect(err.message).toInclude('No matching route');
}
});
tap.test('cleanup - stop server', async () => {
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,118 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import { connectToSmtp, waitForGreeting } from './helpers/utils.js';
import * as net from 'net';
// Common mock pattern for dcRouter dependency
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
tap.test('setup - reset bridge singleton', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
});
tap.test('construct server - should create UnifiedEmailServer', async () => {
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10025, 10587],
hostname: 'test.e2e.local',
domains: [
{
domain: 'e2e-test.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*@e2e-test.com',
},
action: {
type: 'process',
},
},
],
});
expect(server).toBeTruthy();
expect(server).toBeInstanceOf(UnifiedEmailServer);
});
tap.test('start server - should start and accept SMTP connections', async () => {
try {
await server.start();
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
return;
}
expect(bridge.running).toBeTrue();
// Connect to port 10025 and verify we get a 220 greeting
const socket = await connectToSmtp('127.0.0.1', 10025, 10000);
const greeting = await waitForGreeting(socket, 10000);
expect(greeting).toInclude('220');
socket.destroy();
});
tap.test('get stats - should return server statistics', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const stats = server.getStats();
expect(stats).toBeTruthy();
expect(stats.startTime).toBeInstanceOf(Date);
expect(stats.connections).toBeTruthy();
expect(typeof stats.connections.current).toEqual('number');
expect(typeof stats.connections.total).toEqual('number');
expect(stats.messages).toBeTruthy();
expect(typeof stats.messages.processed).toEqual('number');
});
tap.test('stop server - should stop and refuse connections', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
await server.stop();
// Verify connection is refused after stop
try {
const socket = await connectToSmtp('127.0.0.1', 10025, 3000);
socket.destroy();
// If we get here, the connection was accepted — that's unexpected
throw new Error('Expected connection to be refused after server stop');
} catch (err) {
// Connection refused or timeout is expected
const msg = (err as Error).message;
expect(
msg.includes('ECONNREFUSED') || msg.includes('timeout') || msg.includes('refused')
).toBeTrue();
}
expect(bridge.state).toEqual(BridgeState.Stopped);
});
tap.test('stop', async () => {
// Clean up if not already stopped
if (bridge.running) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

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

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

@@ -0,0 +1,46 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
/**
* Test email authentication systems: SPF parsing
*/
// SPF Verifier Tests
tap.test('SPF Verifier - should parse SPF record', async () => {
const spfVerifier = new SpfVerifier();
// Test valid SPF record parsing
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
const parsedRecord = spfVerifier.parseSpfRecord(record);
expect(parsedRecord).toBeTruthy();
expect(parsedRecord.version).toEqual('spf1');
expect(parsedRecord.mechanisms.length).toEqual(5);
// Check specific mechanisms
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
// Test invalid record
const invalidRecord = 'not-a-spf-record';
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
expect(invalidParsed).toBeNull();
});
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();

View File

@@ -0,0 +1,295 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
import { Email } from '../ts/mail/core/classes.email.js';
import * as net from 'net';
import * as dns from 'dns';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridgeAvailable = false;
let mockSmtpServer: net.Server;
/**
* Create a minimal mock SMTP server that accepts any email.
*/
function createMockSmtpServer(port: number): Promise<net.Server> {
return new Promise((resolve, reject) => {
const srv = net.createServer((socket) => {
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
let inData = false;
let dataBuffer = '';
socket.on('data', (chunk) => {
const input = chunk.toString();
if (inData) {
dataBuffer += input;
if (dataBuffer.includes('\r\n.\r\n')) {
inData = false;
dataBuffer = '';
socket.write('250 2.0.0 Ok: queued\r\n');
}
return;
}
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
for (const line of lines) {
const cmd = line.toUpperCase();
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
} else if (cmd.startsWith('MAIL FROM')) {
socket.write('250 2.1.0 Ok\r\n');
} else if (cmd.startsWith('RCPT TO')) {
socket.write('250 2.1.5 Ok\r\n');
} else if (cmd === 'DATA') {
inData = true;
dataBuffer = '';
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (cmd === 'QUIT') {
socket.write('221 2.0.0 Bye\r\n');
socket.end();
} else if (cmd === 'RSET') {
socket.write('250 2.0.0 Ok\r\n');
}
}
});
});
srv.listen(port, '127.0.0.1', () => {
resolve(srv);
});
srv.on('error', reject);
});
}
// Store original resolveMx so we can restore it
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
tap.test('setup - start server and mock SMTP', async () => {
RustSecurityBridge.resetInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10425],
hostname: 'test.mta.local',
domains: [
{ domain: 'mta-test.com', dnsMode: 'forward' },
],
routes: [
{
name: 'mta-route',
priority: 10,
match: { recipients: '*@mta-test.com' },
action: { type: 'deliver' },
},
{
name: 'process-route',
priority: 20,
match: { recipients: '*@process-test.com' },
action: {
type: 'process',
options: {
contentScanning: true,
scanners: [{ type: 'spam' }],
},
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
return;
}
mockSmtpServer = await createMockSmtpServer(10426);
});
tap.test('MX resolution for a public domain', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
const resolver = new dns.promises.Resolver();
try {
const records = await resolver.resolveMx('gmail.com');
expect(records).toBeTruthy();
expect(records.length).toBeGreaterThan(0);
// Each record should have exchange and priority
for (const rec of records) {
expect(typeof rec.exchange).toEqual('string');
expect(typeof rec.priority).toEqual('number');
}
console.log(`Resolved ${records.length} MX records for gmail.com`);
} catch (err) {
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
}
});
tap.test('group recipients by domain', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Test the grouping logic directly
const recipients = [
'alice@example.com',
'bob@example.com',
'carol@other.org',
'dave@example.com',
'eve@other.org',
];
const groups = new Map<string, string[]>();
for (const rcpt of recipients) {
const domain = rcpt.split('@')[1]?.toLowerCase();
if (!domain) continue;
const list = groups.get(domain) || [];
list.push(rcpt);
groups.set(domain, list);
}
expect(groups.size).toEqual(2);
expect(groups.get('example.com')!.length).toEqual(3);
expect(groups.get('other.org')!.length).toEqual(2);
});
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
return [{ exchange: '127.0.0.1', priority: 10 }];
};
const email = new Email({
from: 'sender@mta-test.com',
to: 'recipient@target-domain.com',
subject: 'MTA Delivery Test',
text: 'Testing MTA delivery with MX resolution.',
});
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
// But the real test is the delivery system's handleMtaDelivery, which we test
// by sending through the server's outbound path with the mock MX.
// Direct test: resolve MX then send
const resolver = new dns.promises.Resolver();
const mxRecords = await resolver.resolveMx('target-domain.com');
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
// Send via the resolved MX host to the mock SMTP server on port 10425
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
// We test the sendOutboundEmail path directly with the mock MX host.
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
expect(result).toBeTruthy();
expect(result.accepted.length).toBeGreaterThan(0);
expect(result.response).toInclude('2.0.0');
// Restore original resolveMx
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
});
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
const email = new Email({
from: 'sender@mta-test.com',
to: 'recipient@unreachable-domain.com',
subject: 'Connection Refused MX Test',
text: 'This should fail — no server at the target.',
});
// Send to a port that nothing is listening on
try {
await server.sendOutboundEmail('127.0.0.1', 59789, email);
throw new Error('Expected sendOutboundEmail to fail');
} catch (err: any) {
expect(err).toBeTruthy();
expect(err.message.length).toBeGreaterThan(0);
console.log(`Got expected error: ${err.message}`);
}
});
tap.test('MTA delivery with multiple recipients across domains', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Mock MX to return 127.0.0.1 for all domains
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
return [{ exchange: '127.0.0.1', priority: 10 }];
};
const email = new Email({
from: 'sender@mta-test.com',
to: ['alice@domain-a.com', 'bob@domain-b.com'],
subject: 'Multi-Domain MTA Test',
text: 'Testing delivery to multiple domains.',
});
// Send to each recipient's domain individually (simulating MTA behavior)
for (const recipient of email.to) {
const singleEmail = new Email({
from: email.from,
to: recipient,
subject: email.subject,
text: email.text,
});
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
expect(result.accepted.length).toEqual(1);
}
// Restore original resolveMx
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
});
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Resolve real MX records for task.vc
const resolver = new dns.promises.Resolver();
const mxRecords = await resolver.resolveMx('task.vc');
expect(mxRecords.length).toBeGreaterThan(0);
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
const email = new Email({
from: 'test@mta-test.com',
to: 'hello@task.vc',
subject: `MTA E2E Test — ${new Date().toISOString()}`,
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
});
const result = await server.sendOutboundEmail(mxHost, 25, email);
expect(result).toBeTruthy();
expect(result.accepted).toBeTruthy();
expect(result.accepted.length).toEqual(1);
expect(result.accepted[0]).toEqual('hello@task.vc');
expect(result.response).toInclude('2.0.0');
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
});
tap.test('cleanup - stop server and mock SMTP', async () => {
// Restore MX resolver in case it wasn't restored
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
// Force-close mock server (destroy all open sockets)
if (mockSmtpServer) {
mockSmtpServer.close();
}
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

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

View File

@@ -0,0 +1,177 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import type { IBridgeResilienceConfig } from '../ts/security/classes.rustsecuritybridge.js';
// Use fast backoff settings for testing
const TEST_CONFIG: Partial<IBridgeResilienceConfig> = {
maxRestartAttempts: 3,
healthCheckIntervalMs: 60_000, // long interval so health checks don't interfere
restartBackoffBaseMs: 100,
restartBackoffMaxMs: 500,
healthCheckTimeoutMs: 2_000,
};
tap.test('Resilience - should start in Idle state', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
const bridge = RustSecurityBridge.getInstance();
expect(bridge.state).toEqual(BridgeState.Idle);
});
tap.test('Resilience - state transitions: Idle -> Starting -> Running', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
const bridge = RustSecurityBridge.getInstance();
const transitions: Array<{ oldState: string; newState: string }> = [];
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
transitions.push(evt);
});
const ok = await bridge.start();
if (!ok) {
console.log('WARNING: Rust binary not available — skipping resilience start tests');
return;
}
// We should have seen Idle -> Starting -> Running
expect(transitions.length).toBeGreaterThanOrEqual(2);
expect(transitions[0].oldState).toEqual(BridgeState.Idle);
expect(transitions[0].newState).toEqual(BridgeState.Starting);
expect(transitions[1].oldState).toEqual(BridgeState.Starting);
expect(transitions[1].newState).toEqual(BridgeState.Running);
expect(bridge.state).toEqual(BridgeState.Running);
});
tap.test('Resilience - deliberate stop transitions to Stopped', async () => {
const bridge = RustSecurityBridge.getInstance();
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const transitions: Array<{ oldState: string; newState: string }> = [];
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
transitions.push(evt);
});
await bridge.stop();
expect(bridge.state).toEqual(BridgeState.Stopped);
// Deliberate stop should NOT trigger restart
// Wait a bit to ensure no restart happens
await new Promise(resolve => setTimeout(resolve, 300));
expect(bridge.state).toEqual(BridgeState.Stopped);
bridge.removeAllListeners('stateChange');
});
tap.test('Resilience - commands throw descriptive errors when not running', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
const bridge = RustSecurityBridge.getInstance();
// Idle state
try {
await bridge.ping();
expect(true).toBeFalse(); // Should not reach
} catch (err) {
expect((err as Error).message).toInclude('not been started');
}
// Stopped state
const ok = await bridge.start();
if (ok) {
await bridge.stop();
try {
await bridge.ping();
expect(true).toBeFalse();
} catch (err) {
expect((err as Error).message).toInclude('stopped');
}
}
});
tap.test('Resilience - restart after stop and fresh start', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
const bridge = RustSecurityBridge.getInstance();
const ok = await bridge.start();
if (!ok) {
console.log('SKIP: Rust binary not available');
return;
}
expect(bridge.state).toEqual(BridgeState.Running);
// Stop
await bridge.stop();
expect(bridge.state).toEqual(BridgeState.Stopped);
// Start again
const ok2 = await bridge.start();
expect(ok2).toBeTrue();
expect(bridge.state).toEqual(BridgeState.Running);
// Commands should work
const pong = await bridge.ping();
expect(pong).toBeTrue();
await bridge.stop();
});
tap.test('Resilience - stateChange events emitted correctly', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
const bridge = RustSecurityBridge.getInstance();
const events: Array<{ oldState: string; newState: string }> = [];
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
events.push(evt);
});
const ok = await bridge.start();
if (!ok) {
console.log('SKIP: Rust binary not available');
return;
}
await bridge.stop();
// Verify the full lifecycle: Idle->Starting->Running->Stopped
const stateSequence = events.map(e => e.newState);
expect(stateSequence).toContain(BridgeState.Starting);
expect(stateSequence).toContain(BridgeState.Running);
expect(stateSequence).toContain(BridgeState.Stopped);
bridge.removeAllListeners('stateChange');
});
tap.test('Resilience - configure sets resilience parameters', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure({
maxRestartAttempts: 10,
healthCheckIntervalMs: 60_000,
});
// Just verify no errors — config is private, but we can verify
// by the behavior in other tests
const bridge = RustSecurityBridge.getInstance();
expect(bridge).toBeTruthy();
});
tap.test('Resilience - resetInstance creates fresh singleton', async () => {
RustSecurityBridge.resetInstance();
const bridge1 = RustSecurityBridge.getInstance();
RustSecurityBridge.resetInstance();
const bridge2 = RustSecurityBridge.getInstance();
// They should be different instances (we can't compare directly since
// resetInstance nulls the static, and getInstance creates new)
expect(bridge2.state).toEqual(BridgeState.Idle);
});
tap.test('Resilience - cleanup', async () => {
RustSecurityBridge.resetInstance();
RustSecurityBridge.configure(TEST_CONFIG);
});
export default tap.start();

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,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import type { ISmtpSendOptions, ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
let bridge: RustSecurityBridge;
tap.test('Rust SMTP Client - should start bridge', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
const ok = await bridge.start();
if (!ok) {
console.log('WARNING: Rust binary not available — skipping Rust SMTP client tests');
console.log('Build it with: cd rust && cargo build --release');
}
expect(typeof ok).toEqual('boolean');
});
tap.test('Rust SMTP Client - getSmtpPoolStatus returns valid structure', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
expect(status).toBeTruthy();
expect(status.pools).toBeTruthy();
expect(typeof status.pools).toEqual('object');
});
tap.test('Rust SMTP Client - verifySmtpConnection with unreachable host', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
try {
// Use a non-routable IP to test connection failure handling
const result = await bridge.verifySmtpConnection({
host: '192.0.2.1', // TEST-NET-1 (RFC 5737) - guaranteed unreachable
port: 25,
secure: false,
domain: 'test.example.com',
});
// If it returns rather than throwing, reachable should be false
expect(result.reachable).toBeFalse();
} catch (err) {
// Connection errors are expected for unreachable hosts
expect(err).toBeTruthy();
expect(err.message || String(err)).toBeTruthy();
}
});
tap.test('Rust SMTP Client - sendEmail with connection refused error', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const opts: ISmtpSendOptions = {
host: '127.0.0.1',
port: 52599, // random high port - should be refused
secure: false,
domain: 'test.example.com',
email: {
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test email',
text: 'This is a test.',
},
connectionTimeoutSecs: 5,
socketTimeoutSecs: 5,
};
try {
await bridge.sendOutboundEmail(opts);
// Should not succeed — no server is listening
throw new Error('Expected sendEmail to fail on connection refused');
} catch (err) {
// We expect a connection error
expect(err).toBeTruthy();
const msg = err.message || String(err);
expect(msg.length).toBeGreaterThan(0);
}
});
tap.test('Rust SMTP Client - closeSmtpPool cleans up', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
// Should not throw, even when no pools exist
await bridge.closeSmtpPool();
// Verify pool status is empty after close
const status = await bridge.getSmtpPoolStatus();
expect(status.pools).toBeTruthy();
const poolKeys = Object.keys(status.pools);
expect(poolKeys.length).toEqual(0);
});
tap.test('Rust SMTP Client - stop bridge', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
await bridge.stop();
expect(bridge.state).toEqual(BridgeState.Stopped);
});
export default tap.start();

View File

@@ -2,7 +2,7 @@
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/mailer',
version: '1.0.1',
description: 'Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure'
name: '@push.rocks/smartmta',
version: '5.1.1',
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 +0,0 @@
/**
* Error types module stub
*/
export class SmtpError extends Error {
constructor(message: string, public code?: number) {
super(message);
this.name = 'SmtpError';
}
}
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
export class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = 'RateLimitError';
}
}

View File

@@ -1,12 +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 './dns/index.ts';
export * from './config/index.ts';

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);
plugins.smartfile.memory.toFsSync(bounceData, bouncePath);
await plugins.smartfs.directory(bounceDir).recursive().create();
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';

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