Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f601859f8b | |||
| eb2643de93 | |||
| 595634fb0f | |||
| cee8a51081 | |||
| f1c5546186 | |||
| 5220ee0857 | |||
| fc2e6d44f4 | |||
| 15a45089aa | |||
| b82468ab1e | |||
| ffe294643c | |||
| f1071faf3d | |||
| 6b082cee8f | |||
| 9185242530 | |||
| 8293663619 | |||
| 199b9b79d2 |
@@ -6,7 +6,7 @@ Pre-compiled binaries for multiple platforms.
|
||||
|
||||
#### Option 1: Via npm (recommended)
|
||||
```bash
|
||||
npm install -g @serve.zone/mailer
|
||||
npm install -g @push.rocks/smartmta
|
||||
```
|
||||
|
||||
#### Option 2: Direct binary download
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
||||
echo ""
|
||||
echo "Checking installed files:"
|
||||
npm ls -g @serve.zone/mailer || true
|
||||
npm ls -g @push.rocks/smartmta || true
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
@@ -93,10 +93,10 @@ jobs:
|
||||
echo "Publishing to npm registry..."
|
||||
npm publish --access public
|
||||
echo ""
|
||||
echo "✅ Successfully published @serve.zone/mailer to npm!"
|
||||
echo "✅ Successfully published @push.rocks/smartmta to npm!"
|
||||
echo ""
|
||||
echo "Package info:"
|
||||
npm view @serve.zone/mailer
|
||||
npm view @push.rocks/smartmta
|
||||
|
||||
- name: Verify npm package
|
||||
run: |
|
||||
@@ -104,10 +104,10 @@ jobs:
|
||||
sleep 30
|
||||
echo ""
|
||||
echo "Verifying published package..."
|
||||
npm view @serve.zone/mailer
|
||||
npm view @push.rocks/smartmta
|
||||
echo ""
|
||||
echo "Testing installation from npm:"
|
||||
npm install -g @serve.zone/mailer
|
||||
npm install -g @push.rocks/smartmta
|
||||
echo ""
|
||||
echo "Package installed successfully!"
|
||||
which mailer || echo "Binary location check skipped"
|
||||
@@ -118,12 +118,12 @@ jobs:
|
||||
echo " npm Publish Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "✅ Package: @serve.zone/mailer"
|
||||
echo "✅ Package: @push.rocks/smartmta"
|
||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation:"
|
||||
echo " npm install -g @serve.zone/mailer"
|
||||
echo " npm install -g @push.rocks/smartmta"
|
||||
echo ""
|
||||
echo "Registry:"
|
||||
echo " https://www.npmjs.com/package/@serve.zone/mailer"
|
||||
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
|
||||
echo ""
|
||||
|
||||
62
changelog.md
62
changelog.md
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/mailer',
|
||||
version: '1.3.0',
|
||||
description: 'Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure'
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '2.2.1',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxvQkFBb0I7SUFDMUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHNHQUFzRztDQUNwSCxDQUFBIn0=
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
||||
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
@@ -165,21 +165,6 @@ export declare class BounceManager {
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} | 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Get all known hard bounced addresses
|
||||
* @returns Array of hard bounced email addresses
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
export class EmailSignJob {
|
||||
emailServerRef;
|
||||
jobOptions;
|
||||
@@ -12,25 +13,14 @@ export class EmailSignJob {
|
||||
}
|
||||
async getSignatureHeader(emailMessage) {
|
||||
const privateKey = await this.loadPrivateKey();
|
||||
const signResult = await plugins.dkimSign(emailMessage, {
|
||||
signingDomain: this.jobOptions.domain,
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const signResult = await bridge.signDkim({
|
||||
rawMessage: emailMessage,
|
||||
domain: this.jobOptions.domain,
|
||||
selector: this.jobOptions.selector,
|
||||
privateKey,
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
algorithm: 'rsa-sha256',
|
||||
signTime: new Date(),
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: this.jobOptions.domain,
|
||||
selector: this.jobOptions.selector,
|
||||
privateKey,
|
||||
algorithm: 'rsa-sha256',
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
},
|
||||
],
|
||||
});
|
||||
const signature = signResult.signatures;
|
||||
return signature;
|
||||
return signResult.header;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFjNUMsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLFVBQVUsR0FBRyxNQUFNLE9BQU8sQ0FBQyxRQUFRLENBQUMsWUFBWSxFQUFFO1lBQ3RELGFBQWEsRUFBRSxJQUFJLENBQUMsVUFBVSxDQUFDLE1BQU07WUFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtZQUNsQyxVQUFVO1lBQ1YsZ0JBQWdCLEVBQUUsaUJBQWlCO1lBQ25DLFNBQVMsRUFBRSxZQUFZO1lBQ3ZCLFFBQVEsRUFBRSxJQUFJLElBQUksRUFBRTtZQUNwQixhQUFhLEVBQUU7Z0JBQ2I7b0JBQ0UsYUFBYSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtvQkFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtvQkFDbEMsVUFBVTtvQkFDVixTQUFTLEVBQUUsWUFBWTtvQkFDdkIsZ0JBQWdCLEVBQUUsaUJBQWlCO2lCQUNwQzthQUNGO1NBQ0YsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxTQUFTLEdBQUcsVUFBVSxDQUFDLFVBQVUsQ0FBQztRQUN4QyxPQUFPLFNBQVMsQ0FBQztJQUNuQixDQUFDO0NBQ0YifQ==
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sOENBQThDLENBQUM7QUFhbEYsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLE1BQU0sR0FBRyxrQkFBa0IsQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNoRCxNQUFNLFVBQVUsR0FBRyxNQUFNLE1BQU0sQ0FBQyxRQUFRLENBQUM7WUFDdkMsVUFBVSxFQUFFLFlBQVk7WUFDeEIsTUFBTSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtZQUM5QixRQUFRLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxRQUFRO1lBQ2xDLFVBQVU7U0FDWCxDQUFDLENBQUM7UUFDSCxPQUFPLFVBQVUsQ0FBQyxNQUFNLENBQUM7SUFDM0IsQ0FBQztDQUNGIn0=
|
||||
File diff suppressed because one or more lines are too long
3
dist_ts/mail/delivery/index.d.ts
vendored
3
dist_ts/mail/delivery/index.d.ts
vendored
@@ -8,5 +8,4 @@ export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
||||
export * from './classes.unified.rate.limiter.js';
|
||||
export * from './classes.mta.config.js';
|
||||
import * as smtpClientMod from './smtpclient/index.js';
|
||||
import * as smtpServerMod from './smtpserver/index.js';
|
||||
export { smtpClientMod, smtpServerMod };
|
||||
export { smtpClientMod };
|
||||
|
||||
@@ -13,6 +13,5 @@ export * from './classes.unified.rate.limiter.js';
|
||||
export * from './classes.mta.config.js';
|
||||
// Import and export SMTP modules as namespaces to avoid conflicts
|
||||
import * as smtpClientMod from './smtpclient/index.js';
|
||||
import * as smtpServerMod from './smtpserver/index.js';
|
||||
export { smtpClientMod, smtpServerMod };
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxhQUFhLE1BQU0sdUJBQXVCLENBQUM7QUFFdkQsT0FBTyxFQUFFLGFBQWEsRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
||||
export { smtpClientMod };
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBRXZELE9BQU8sRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Certificate Utilities for SMTP Server
|
||||
* Provides utilities for managing TLS certificates
|
||||
*/
|
||||
import * as tls from 'tls';
|
||||
/**
|
||||
* Certificate data
|
||||
*/
|
||||
export interface ICertificateData {
|
||||
key: Buffer;
|
||||
cert: Buffer;
|
||||
ca?: Buffer;
|
||||
}
|
||||
/**
|
||||
* Load certificates from PEM format strings
|
||||
* @param options - Certificate options
|
||||
* @returns Certificate data with Buffer format
|
||||
*/
|
||||
export declare function loadCertificatesFromString(options: {
|
||||
key: string | Buffer;
|
||||
cert: string | Buffer;
|
||||
ca?: string | Buffer;
|
||||
}): ICertificateData;
|
||||
/**
|
||||
* Load certificates from files
|
||||
* @param options - Certificate file paths
|
||||
* @returns Certificate data with Buffer format
|
||||
*/
|
||||
export declare function loadCertificatesFromFiles(options: {
|
||||
keyPath: string;
|
||||
certPath: string;
|
||||
caPath?: string;
|
||||
}): ICertificateData;
|
||||
/**
|
||||
* Generate self-signed certificates for testing
|
||||
* @returns Certificate data with Buffer format
|
||||
*/
|
||||
export declare function generateSelfSignedCertificates(): ICertificateData;
|
||||
/**
|
||||
* Create TLS options for secure server or STARTTLS
|
||||
* @param certificates - Certificate data
|
||||
* @param isServer - Whether this is for server (true) or client (false)
|
||||
* @returns TLS options
|
||||
*/
|
||||
export declare function createTlsOptions(certificates: ICertificateData, isServer?: boolean): tls.TlsOptions;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* SMTP Command Handler
|
||||
* Responsible for parsing and handling SMTP commands
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { ISmtpSession } from './interfaces.js';
|
||||
import type { ICommandHandler, ISmtpServer } from './interfaces.js';
|
||||
import { SmtpCommand } from './constants.js';
|
||||
/**
|
||||
* Handles SMTP commands and responses
|
||||
*/
|
||||
export declare class CommandHandler implements ICommandHandler {
|
||||
/**
|
||||
* Reference to the SMTP server instance
|
||||
*/
|
||||
private smtpServer;
|
||||
/**
|
||||
* Creates a new command handler
|
||||
* @param smtpServer - SMTP server instance
|
||||
*/
|
||||
constructor(smtpServer: ISmtpServer);
|
||||
/**
|
||||
* Process a command from the client
|
||||
* @param socket - Client socket
|
||||
* @param commandLine - Command line from client
|
||||
*/
|
||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): Promise<void>;
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response to send
|
||||
*/
|
||||
sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void;
|
||||
/**
|
||||
* Check if a socket error is potentially recoverable
|
||||
* @param error - The error that occurred
|
||||
* @returns Whether the error is potentially recoverable
|
||||
*/
|
||||
private isRecoverableSocketError;
|
||||
/**
|
||||
* Handle recoverable socket errors with retry logic
|
||||
* @param socket - Client socket
|
||||
* @param error - The error that occurred
|
||||
* @param response - The response that failed to send
|
||||
*/
|
||||
private handleSocketError;
|
||||
/**
|
||||
* Handle EHLO command
|
||||
* @param socket - Client socket
|
||||
* @param clientHostname - Client hostname from EHLO command
|
||||
*/
|
||||
handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void;
|
||||
/**
|
||||
* Handle MAIL FROM command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
||||
/**
|
||||
* Handle RCPT TO command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
||||
/**
|
||||
* Handle DATA command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Handle RSET command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Handle NOOP command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Handle QUIT command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket, args?: string): void;
|
||||
/**
|
||||
* Handle AUTH command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
private handleAuth;
|
||||
/**
|
||||
* Handle AUTH PLAIN authentication
|
||||
* @param socket - Client socket
|
||||
* @param session - Session
|
||||
* @param initialResponse - Optional initial response
|
||||
*/
|
||||
private handleAuthPlain;
|
||||
/**
|
||||
* Handle AUTH LOGIN authentication
|
||||
* @param socket - Client socket
|
||||
* @param session - Session
|
||||
* @param initialResponse - Optional initial response
|
||||
*/
|
||||
private handleAuthLogin;
|
||||
/**
|
||||
* Handle AUTH LOGIN response
|
||||
* @param socket - Client socket
|
||||
* @param session - Session
|
||||
* @param response - Response from client
|
||||
*/
|
||||
private handleAuthLoginResponse;
|
||||
/**
|
||||
* Handle HELP command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
private handleHelp;
|
||||
/**
|
||||
* Handle VRFY command (Verify user/mailbox)
|
||||
* RFC 5321 Section 3.5.1: Server MAY respond with 252 to avoid disclosing sensitive information
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments (username to verify)
|
||||
*/
|
||||
private handleVrfy;
|
||||
/**
|
||||
* Handle EXPN command (Expand mailing list)
|
||||
* RFC 5321 Section 3.5.2: Server MAY disable this for security
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments (mailing list to expand)
|
||||
*/
|
||||
private handleExpn;
|
||||
/**
|
||||
* Reset session to after-EHLO state
|
||||
* @param session - SMTP session to reset
|
||||
*/
|
||||
private resetSession;
|
||||
/**
|
||||
* Validate command sequence based on current state
|
||||
* @param command - Command to validate
|
||||
* @param session - Current session
|
||||
* @returns Whether the command is valid in the current state
|
||||
*/
|
||||
private validateCommandSequence;
|
||||
/**
|
||||
* Handle an SMTP command (interface requirement)
|
||||
*/
|
||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
||||
/**
|
||||
* Get supported commands for current session state (interface requirement)
|
||||
*/
|
||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* SMTP Connection Manager
|
||||
* Responsible for managing socket connections to the SMTP server
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IConnectionManager, ISmtpServer } from './interfaces.js';
|
||||
/**
|
||||
* Manager for SMTP connections
|
||||
* Handles connection setup, event listeners, and lifecycle management
|
||||
* Provides resource management, connection tracking, and monitoring
|
||||
*/
|
||||
export declare class ConnectionManager implements IConnectionManager {
|
||||
/**
|
||||
* Reference to the SMTP server instance
|
||||
*/
|
||||
private smtpServer;
|
||||
/**
|
||||
* Set of active socket connections
|
||||
*/
|
||||
private activeConnections;
|
||||
/**
|
||||
* Connection tracking for resource management
|
||||
*/
|
||||
private connectionStats;
|
||||
/**
|
||||
* Per-IP connection tracking for rate limiting
|
||||
*/
|
||||
private ipConnections;
|
||||
/**
|
||||
* Resource monitoring interval
|
||||
*/
|
||||
private resourceCheckInterval;
|
||||
/**
|
||||
* Track cleanup timers so we can clear them
|
||||
*/
|
||||
private cleanupTimers;
|
||||
/**
|
||||
* SMTP server options with enhanced resource controls
|
||||
*/
|
||||
private options;
|
||||
/**
|
||||
* Creates a new connection manager with enhanced resource management
|
||||
* @param smtpServer - SMTP server instance
|
||||
*/
|
||||
constructor(smtpServer: ISmtpServer);
|
||||
/**
|
||||
* Start resource monitoring interval to check resource usage
|
||||
*/
|
||||
private startResourceMonitoring;
|
||||
/**
|
||||
* Monitor resource usage and log statistics
|
||||
*/
|
||||
private monitorResourceUsage;
|
||||
/**
|
||||
* Clean up expired IP rate limits and perform additional resource monitoring
|
||||
*/
|
||||
private cleanupIpRateLimits;
|
||||
/**
|
||||
* Validate and repair resource tracking to prevent leaks
|
||||
*/
|
||||
private validateResourceTracking;
|
||||
/**
|
||||
* Handle a new connection with resource management
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
||||
/**
|
||||
* Check if an IP has exceeded the rate limit
|
||||
* @param ip - Client IP address
|
||||
* @returns True if rate limited
|
||||
*/
|
||||
private isIPRateLimited;
|
||||
/**
|
||||
* Track a new connection from an IP
|
||||
* @param ip - Client IP address
|
||||
*/
|
||||
private trackIPConnection;
|
||||
/**
|
||||
* Check if an IP has reached its connection limit
|
||||
* @param ip - Client IP address
|
||||
* @returns True if limit reached
|
||||
*/
|
||||
private hasReachedIPConnectionLimit;
|
||||
/**
|
||||
* Handle a new secure TLS connection with resource management
|
||||
* @param socket - Client TLS socket
|
||||
*/
|
||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
||||
/**
|
||||
* Set up event handlers for a socket with enhanced resource management
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Get the current connection count
|
||||
* @returns Number of active connections
|
||||
*/
|
||||
getConnectionCount(): number;
|
||||
/**
|
||||
* Check if the server has reached the maximum number of connections
|
||||
* @returns True if max connections reached
|
||||
*/
|
||||
hasReachedMaxConnections(): boolean;
|
||||
/**
|
||||
* Close all active connections
|
||||
*/
|
||||
closeAllConnections(): void;
|
||||
/**
|
||||
* Handle socket close event
|
||||
* @param socket - Client socket
|
||||
* @param hadError - Whether the socket was closed due to error
|
||||
*/
|
||||
private handleSocketClose;
|
||||
/**
|
||||
* Handle socket error event
|
||||
* @param socket - Client socket
|
||||
* @param error - Error object
|
||||
*/
|
||||
private handleSocketError;
|
||||
/**
|
||||
* Handle socket timeout event
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private handleSocketTimeout;
|
||||
/**
|
||||
* Reject a connection
|
||||
* @param socket - Client socket
|
||||
* @param reason - Reason for rejection
|
||||
*/
|
||||
private rejectConnection;
|
||||
/**
|
||||
* Send greeting message
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private sendGreeting;
|
||||
/**
|
||||
* Send service closing notification
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private sendServiceClosing;
|
||||
/**
|
||||
* Send response to client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response to send
|
||||
*/
|
||||
private sendResponse;
|
||||
/**
|
||||
* Handle a new connection (interface requirement)
|
||||
*/
|
||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
||||
/**
|
||||
* Check if accepting new connections (interface requirement)
|
||||
*/
|
||||
canAcceptConnection(): boolean;
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Constants
|
||||
* This file contains all constants and enums used by the SMTP server
|
||||
*/
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
export { SmtpState };
|
||||
/**
|
||||
* SMTP Response Codes
|
||||
* Based on RFC 5321 and common SMTP practice
|
||||
*/
|
||||
export declare enum SmtpResponseCode {
|
||||
SUCCESS = 250,// Requested mail action okay, completed
|
||||
SYSTEM_STATUS = 211,// System status, or system help reply
|
||||
HELP_MESSAGE = 214,// Help message
|
||||
SERVICE_READY = 220,// <domain> Service ready
|
||||
SERVICE_CLOSING = 221,// <domain> Service closing transmission channel
|
||||
AUTHENTICATION_SUCCESSFUL = 235,// Authentication successful
|
||||
OK = 250,// Requested mail action okay, completed
|
||||
FORWARD = 251,// User not local; will forward to <forward-path>
|
||||
CANNOT_VRFY = 252,// Cannot VRFY user, but will accept message and attempt delivery
|
||||
MORE_INFO_NEEDED = 334,// Server challenge for authentication
|
||||
START_MAIL_INPUT = 354,// Start mail input; end with <CRLF>.<CRLF>
|
||||
SERVICE_NOT_AVAILABLE = 421,// <domain> Service not available, closing transmission channel
|
||||
MAILBOX_TEMPORARILY_UNAVAILABLE = 450,// Requested mail action not taken: mailbox unavailable
|
||||
LOCAL_ERROR = 451,// Requested action aborted: local error in processing
|
||||
INSUFFICIENT_STORAGE = 452,// Requested action not taken: insufficient system storage
|
||||
TLS_UNAVAILABLE_TEMP = 454,// TLS not available due to temporary reason
|
||||
SYNTAX_ERROR = 500,// Syntax error, command unrecognized
|
||||
SYNTAX_ERROR_PARAMETERS = 501,// Syntax error in parameters or arguments
|
||||
COMMAND_NOT_IMPLEMENTED = 502,// Command not implemented
|
||||
BAD_SEQUENCE = 503,// Bad sequence of commands
|
||||
COMMAND_PARAMETER_NOT_IMPLEMENTED = 504,// Command parameter not implemented
|
||||
AUTH_REQUIRED = 530,// Authentication required
|
||||
AUTH_FAILED = 535,// Authentication credentials invalid
|
||||
MAILBOX_UNAVAILABLE = 550,// Requested action not taken: mailbox unavailable
|
||||
USER_NOT_LOCAL = 551,// User not local; please try <forward-path>
|
||||
EXCEEDED_STORAGE = 552,// Requested mail action aborted: exceeded storage allocation
|
||||
MAILBOX_NAME_INVALID = 553,// Requested action not taken: mailbox name not allowed
|
||||
TRANSACTION_FAILED = 554,// Transaction failed
|
||||
MAIL_RCPT_PARAMETERS_INVALID = 555
|
||||
}
|
||||
/**
|
||||
* SMTP Command Types
|
||||
*/
|
||||
export declare enum SmtpCommand {
|
||||
HELO = "HELO",
|
||||
EHLO = "EHLO",
|
||||
MAIL_FROM = "MAIL",
|
||||
RCPT_TO = "RCPT",
|
||||
DATA = "DATA",
|
||||
RSET = "RSET",
|
||||
NOOP = "NOOP",
|
||||
QUIT = "QUIT",
|
||||
STARTTLS = "STARTTLS",
|
||||
AUTH = "AUTH",
|
||||
HELP = "HELP",
|
||||
VRFY = "VRFY",
|
||||
EXPN = "EXPN"
|
||||
}
|
||||
/**
|
||||
* Security log event types
|
||||
*/
|
||||
export declare enum SecurityEventType {
|
||||
CONNECTION = "connection",
|
||||
AUTHENTICATION = "authentication",
|
||||
COMMAND = "command",
|
||||
DATA = "data",
|
||||
IP_REPUTATION = "ip_reputation",
|
||||
TLS_NEGOTIATION = "tls_negotiation",
|
||||
DKIM = "dkim",
|
||||
SPF = "spf",
|
||||
DMARC = "dmarc",
|
||||
EMAIL_VALIDATION = "email_validation",
|
||||
SPAM = "spam",
|
||||
ACCESS_CONTROL = "access_control"
|
||||
}
|
||||
/**
|
||||
* Security log levels
|
||||
*/
|
||||
export declare enum SecurityLogLevel {
|
||||
DEBUG = "debug",
|
||||
INFO = "info",
|
||||
WARN = "warn",
|
||||
ERROR = "error"
|
||||
}
|
||||
/**
|
||||
* SMTP Server Defaults
|
||||
*/
|
||||
export declare const SMTP_DEFAULTS: {
|
||||
CONNECTION_TIMEOUT: number;
|
||||
SOCKET_TIMEOUT: number;
|
||||
DATA_TIMEOUT: number;
|
||||
CLEANUP_INTERVAL: number;
|
||||
MAX_CONNECTIONS: number;
|
||||
MAX_RECIPIENTS: number;
|
||||
MAX_MESSAGE_SIZE: number;
|
||||
SMTP_PORT: number;
|
||||
SUBMISSION_PORT: number;
|
||||
SECURE_PORT: number;
|
||||
HOSTNAME: string;
|
||||
CRLF: string;
|
||||
};
|
||||
/**
|
||||
* SMTP Command Patterns
|
||||
* Regular expressions for parsing SMTP commands
|
||||
*/
|
||||
export declare const SMTP_PATTERNS: {
|
||||
EHLO: RegExp;
|
||||
MAIL_FROM: RegExp;
|
||||
RCPT_TO: RegExp;
|
||||
PARAM: RegExp;
|
||||
EMAIL: RegExp;
|
||||
END_DATA: RegExp;
|
||||
};
|
||||
/**
|
||||
* SMTP Extension List
|
||||
* These extensions are advertised in the EHLO response
|
||||
*/
|
||||
export declare const SMTP_EXTENSIONS: {
|
||||
PIPELINING: string;
|
||||
SIZE: string;
|
||||
EIGHTBITMIME: string;
|
||||
STARTTLS: string;
|
||||
AUTH: string;
|
||||
ENHANCEDSTATUSCODES: string;
|
||||
HELP: string;
|
||||
CHUNKING: string;
|
||||
DSN: string;
|
||||
formatExtension(name: string, parameter?: string | number): string;
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Constants
|
||||
* This file contains all constants and enums used by the SMTP server
|
||||
*/
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
// Re-export SmtpState enum from the main interfaces file
|
||||
export { SmtpState };
|
||||
/**
|
||||
* SMTP Response Codes
|
||||
* Based on RFC 5321 and common SMTP practice
|
||||
*/
|
||||
export var SmtpResponseCode;
|
||||
(function (SmtpResponseCode) {
|
||||
// Success codes (2xx)
|
||||
SmtpResponseCode[SmtpResponseCode["SUCCESS"] = 250] = "SUCCESS";
|
||||
SmtpResponseCode[SmtpResponseCode["SYSTEM_STATUS"] = 211] = "SYSTEM_STATUS";
|
||||
SmtpResponseCode[SmtpResponseCode["HELP_MESSAGE"] = 214] = "HELP_MESSAGE";
|
||||
SmtpResponseCode[SmtpResponseCode["SERVICE_READY"] = 220] = "SERVICE_READY";
|
||||
SmtpResponseCode[SmtpResponseCode["SERVICE_CLOSING"] = 221] = "SERVICE_CLOSING";
|
||||
SmtpResponseCode[SmtpResponseCode["AUTHENTICATION_SUCCESSFUL"] = 235] = "AUTHENTICATION_SUCCESSFUL";
|
||||
SmtpResponseCode[SmtpResponseCode["OK"] = 250] = "OK";
|
||||
SmtpResponseCode[SmtpResponseCode["FORWARD"] = 251] = "FORWARD";
|
||||
SmtpResponseCode[SmtpResponseCode["CANNOT_VRFY"] = 252] = "CANNOT_VRFY";
|
||||
// Intermediate codes (3xx)
|
||||
SmtpResponseCode[SmtpResponseCode["MORE_INFO_NEEDED"] = 334] = "MORE_INFO_NEEDED";
|
||||
SmtpResponseCode[SmtpResponseCode["START_MAIL_INPUT"] = 354] = "START_MAIL_INPUT";
|
||||
// Temporary error codes (4xx)
|
||||
SmtpResponseCode[SmtpResponseCode["SERVICE_NOT_AVAILABLE"] = 421] = "SERVICE_NOT_AVAILABLE";
|
||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_TEMPORARILY_UNAVAILABLE"] = 450] = "MAILBOX_TEMPORARILY_UNAVAILABLE";
|
||||
SmtpResponseCode[SmtpResponseCode["LOCAL_ERROR"] = 451] = "LOCAL_ERROR";
|
||||
SmtpResponseCode[SmtpResponseCode["INSUFFICIENT_STORAGE"] = 452] = "INSUFFICIENT_STORAGE";
|
||||
SmtpResponseCode[SmtpResponseCode["TLS_UNAVAILABLE_TEMP"] = 454] = "TLS_UNAVAILABLE_TEMP";
|
||||
// Permanent error codes (5xx)
|
||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR"] = 500] = "SYNTAX_ERROR";
|
||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR_PARAMETERS"] = 501] = "SYNTAX_ERROR_PARAMETERS";
|
||||
SmtpResponseCode[SmtpResponseCode["COMMAND_NOT_IMPLEMENTED"] = 502] = "COMMAND_NOT_IMPLEMENTED";
|
||||
SmtpResponseCode[SmtpResponseCode["BAD_SEQUENCE"] = 503] = "BAD_SEQUENCE";
|
||||
SmtpResponseCode[SmtpResponseCode["COMMAND_PARAMETER_NOT_IMPLEMENTED"] = 504] = "COMMAND_PARAMETER_NOT_IMPLEMENTED";
|
||||
SmtpResponseCode[SmtpResponseCode["AUTH_REQUIRED"] = 530] = "AUTH_REQUIRED";
|
||||
SmtpResponseCode[SmtpResponseCode["AUTH_FAILED"] = 535] = "AUTH_FAILED";
|
||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_UNAVAILABLE"] = 550] = "MAILBOX_UNAVAILABLE";
|
||||
SmtpResponseCode[SmtpResponseCode["USER_NOT_LOCAL"] = 551] = "USER_NOT_LOCAL";
|
||||
SmtpResponseCode[SmtpResponseCode["EXCEEDED_STORAGE"] = 552] = "EXCEEDED_STORAGE";
|
||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_NAME_INVALID"] = 553] = "MAILBOX_NAME_INVALID";
|
||||
SmtpResponseCode[SmtpResponseCode["TRANSACTION_FAILED"] = 554] = "TRANSACTION_FAILED";
|
||||
SmtpResponseCode[SmtpResponseCode["MAIL_RCPT_PARAMETERS_INVALID"] = 555] = "MAIL_RCPT_PARAMETERS_INVALID";
|
||||
})(SmtpResponseCode || (SmtpResponseCode = {}));
|
||||
/**
|
||||
* SMTP Command Types
|
||||
*/
|
||||
export var SmtpCommand;
|
||||
(function (SmtpCommand) {
|
||||
SmtpCommand["HELO"] = "HELO";
|
||||
SmtpCommand["EHLO"] = "EHLO";
|
||||
SmtpCommand["MAIL_FROM"] = "MAIL";
|
||||
SmtpCommand["RCPT_TO"] = "RCPT";
|
||||
SmtpCommand["DATA"] = "DATA";
|
||||
SmtpCommand["RSET"] = "RSET";
|
||||
SmtpCommand["NOOP"] = "NOOP";
|
||||
SmtpCommand["QUIT"] = "QUIT";
|
||||
SmtpCommand["STARTTLS"] = "STARTTLS";
|
||||
SmtpCommand["AUTH"] = "AUTH";
|
||||
SmtpCommand["HELP"] = "HELP";
|
||||
SmtpCommand["VRFY"] = "VRFY";
|
||||
SmtpCommand["EXPN"] = "EXPN";
|
||||
})(SmtpCommand || (SmtpCommand = {}));
|
||||
/**
|
||||
* Security log event types
|
||||
*/
|
||||
export var SecurityEventType;
|
||||
(function (SecurityEventType) {
|
||||
SecurityEventType["CONNECTION"] = "connection";
|
||||
SecurityEventType["AUTHENTICATION"] = "authentication";
|
||||
SecurityEventType["COMMAND"] = "command";
|
||||
SecurityEventType["DATA"] = "data";
|
||||
SecurityEventType["IP_REPUTATION"] = "ip_reputation";
|
||||
SecurityEventType["TLS_NEGOTIATION"] = "tls_negotiation";
|
||||
SecurityEventType["DKIM"] = "dkim";
|
||||
SecurityEventType["SPF"] = "spf";
|
||||
SecurityEventType["DMARC"] = "dmarc";
|
||||
SecurityEventType["EMAIL_VALIDATION"] = "email_validation";
|
||||
SecurityEventType["SPAM"] = "spam";
|
||||
SecurityEventType["ACCESS_CONTROL"] = "access_control";
|
||||
})(SecurityEventType || (SecurityEventType = {}));
|
||||
/**
|
||||
* Security log levels
|
||||
*/
|
||||
export var SecurityLogLevel;
|
||||
(function (SecurityLogLevel) {
|
||||
SecurityLogLevel["DEBUG"] = "debug";
|
||||
SecurityLogLevel["INFO"] = "info";
|
||||
SecurityLogLevel["WARN"] = "warn";
|
||||
SecurityLogLevel["ERROR"] = "error";
|
||||
})(SecurityLogLevel || (SecurityLogLevel = {}));
|
||||
/**
|
||||
* SMTP Server Defaults
|
||||
*/
|
||||
export const SMTP_DEFAULTS = {
|
||||
// Default timeouts in milliseconds
|
||||
CONNECTION_TIMEOUT: 30000, // 30 seconds
|
||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
||||
DATA_TIMEOUT: 60000, // 1 minute
|
||||
CLEANUP_INTERVAL: 5000, // 5 seconds
|
||||
// Default limits
|
||||
MAX_CONNECTIONS: 100,
|
||||
MAX_RECIPIENTS: 100,
|
||||
MAX_MESSAGE_SIZE: 10485760, // 10MB
|
||||
// Default ports
|
||||
SMTP_PORT: 25,
|
||||
SUBMISSION_PORT: 587,
|
||||
SECURE_PORT: 465,
|
||||
// Default hostname
|
||||
HOSTNAME: 'mail.lossless.one',
|
||||
// CRLF line ending required by SMTP protocol
|
||||
CRLF: '\r\n',
|
||||
};
|
||||
/**
|
||||
* SMTP Command Patterns
|
||||
* Regular expressions for parsing SMTP commands
|
||||
*/
|
||||
export const SMTP_PATTERNS = {
|
||||
// Match EHLO/HELO command: "EHLO example.com"
|
||||
// Made very permissive to handle various client implementations
|
||||
EHLO: /^(?:EHLO|HELO)\s+(.+)$/i,
|
||||
// Match MAIL FROM command: "MAIL FROM:<user@example.com> [PARAM=VALUE]"
|
||||
// Made more permissive with whitespace and parameter formats
|
||||
MAIL_FROM: /^MAIL\s+FROM\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
||||
// Match RCPT TO command: "RCPT TO:<user@example.com> [PARAM=VALUE]"
|
||||
// Made more permissive with whitespace and parameter formats
|
||||
RCPT_TO: /^RCPT\s+TO\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
||||
// Match parameter format: "PARAM=VALUE"
|
||||
PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g,
|
||||
// Match email address format - basic validation
|
||||
// This pattern rejects common invalid formats while being permissive for edge cases
|
||||
// Checks: no spaces, has @, has domain with dot, no double dots, proper domain format
|
||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
// Match end of DATA marker: \r\n.\r\n or just .\r\n at the start of a line (to handle various client implementations)
|
||||
END_DATA: /(\r\n\.\r\n$)|(\n\.\r\n$)|(\r\n\.\n$)|(\n\.\n$)|^\.(\r\n|\n)$/,
|
||||
};
|
||||
/**
|
||||
* SMTP Extension List
|
||||
* These extensions are advertised in the EHLO response
|
||||
*/
|
||||
export const SMTP_EXTENSIONS = {
|
||||
// Basic extensions (RFC 1869)
|
||||
PIPELINING: 'PIPELINING',
|
||||
SIZE: 'SIZE',
|
||||
EIGHTBITMIME: '8BITMIME',
|
||||
// Security extensions
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH',
|
||||
// Additional extensions
|
||||
ENHANCEDSTATUSCODES: 'ENHANCEDSTATUSCODES',
|
||||
HELP: 'HELP',
|
||||
CHUNKING: 'CHUNKING',
|
||||
DSN: 'DSN',
|
||||
// Format an extension with a parameter
|
||||
formatExtension(name, parameter) {
|
||||
return parameter !== undefined ? `${name} ${parameter}` : name;
|
||||
}
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vdHMvbWFpbC9kZWxpdmVyeS9zbXRwc2VydmVyL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O0dBR0c7QUFFSCxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFN0MseURBQXlEO0FBQ3pELE9BQU8sRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUVyQjs7O0dBR0c7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFxQ1g7QUFyQ0QsV0FBWSxnQkFBZ0I7SUFDMUIsc0JBQXNCO0lBQ3RCLCtEQUFhLENBQUE7SUFDYiwyRUFBbUIsQ0FBQTtJQUNuQix5RUFBa0IsQ0FBQTtJQUNsQiwyRUFBbUIsQ0FBQTtJQUNuQiwrRUFBcUIsQ0FBQTtJQUNyQixtR0FBK0IsQ0FBQTtJQUMvQixxREFBUSxDQUFBO0lBQ1IsK0RBQWEsQ0FBQTtJQUNiLHVFQUFpQixDQUFBO0lBRWpCLDJCQUEyQjtJQUMzQixpRkFBc0IsQ0FBQTtJQUN0QixpRkFBc0IsQ0FBQTtJQUV0Qiw4QkFBOEI7SUFDOUIsMkZBQTJCLENBQUE7SUFDM0IsK0dBQXFDLENBQUE7SUFDckMsdUVBQWlCLENBQUE7SUFDakIseUZBQTBCLENBQUE7SUFDMUIseUZBQTBCLENBQUE7SUFFMUIsOEJBQThCO0lBQzlCLHlFQUFrQixDQUFBO0lBQ2xCLCtGQUE2QixDQUFBO0lBQzdCLCtGQUE2QixDQUFBO0lBQzdCLHlFQUFrQixDQUFBO0lBQ2xCLG1IQUF1QyxDQUFBO0lBQ3ZDLDJFQUFtQixDQUFBO0lBQ25CLHVFQUFpQixDQUFBO0lBQ2pCLHVGQUF5QixDQUFBO0lBQ3pCLDZFQUFvQixDQUFBO0lBQ3BCLGlGQUFzQixDQUFBO0lBQ3RCLHlGQUEwQixDQUFBO0lBQzFCLHFGQUF3QixDQUFBO0lBQ3hCLHlHQUFrQyxDQUFBO0FBQ3BDLENBQUMsRUFyQ1csZ0JBQWdCLEtBQWhCLGdCQUFnQixRQXFDM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBTixJQUFZLFdBY1g7QUFkRCxXQUFZLFdBQVc7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYixpQ0FBa0IsQ0FBQTtJQUNsQiwrQkFBZ0IsQ0FBQTtJQUNoQiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2Isb0NBQXFCLENBQUE7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtBQUNmLENBQUMsRUFkVyxXQUFXLEtBQVgsV0FBVyxRQWN0QjtBQUVEOztHQUVHO0FBQ0gsTUFBTSxDQUFOLElBQVksaUJBYVg7QUFiRCxXQUFZLGlCQUFpQjtJQUMzQiw4Q0FBeUIsQ0FBQTtJQUN6QixzREFBaUMsQ0FBQTtJQUNqQyx3Q0FBbUIsQ0FBQTtJQUNuQixrQ0FBYSxDQUFBO0lBQ2Isb0RBQStCLENBQUE7SUFDL0Isd0RBQW1DLENBQUE7SUFDbkMsa0NBQWEsQ0FBQTtJQUNiLGdDQUFXLENBQUE7SUFDWCxvQ0FBZSxDQUFBO0lBQ2YsMERBQXFDLENBQUE7SUFDckMsa0NBQWEsQ0FBQTtJQUNiLHNEQUFpQyxDQUFBO0FBQ25DLENBQUMsRUFiVyxpQkFBaUIsS0FBakIsaUJBQWlCLFFBYTVCO0FBRUQ7O0dBRUc7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFLWDtBQUxELFdBQVksZ0JBQWdCO0lBQzFCLG1DQUFlLENBQUE7SUFDZixpQ0FBYSxDQUFBO0lBQ2IsaUNBQWEsQ0FBQTtJQUNiLG1DQUFlLENBQUE7QUFDakIsQ0FBQyxFQUxXLGdCQUFnQixLQUFoQixnQkFBZ0IsUUFLM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQixtQ0FBbUM7SUFDbkMsa0JBQWtCLEVBQUUsS0FBSyxFQUFRLGFBQWE7SUFDOUMsY0FBYyxFQUFFLE1BQU0sRUFBVyxZQUFZO0lBQzdDLFlBQVksRUFBRSxLQUFLLEVBQWMsV0FBVztJQUM1QyxnQkFBZ0IsRUFBRSxJQUFJLEVBQVcsWUFBWTtJQUU3QyxpQkFBaUI7SUFDakIsZUFBZSxFQUFFLEdBQUc7SUFDcEIsY0FBYyxFQUFFLEdBQUc7SUFDbkIsZ0JBQWdCLEVBQUUsUUFBUSxFQUFPLE9BQU87SUFFeEMsZ0JBQWdCO0lBQ2hCLFNBQVMsRUFBRSxFQUFFO0lBQ2IsZUFBZSxFQUFFLEdBQUc7SUFDcEIsV0FBVyxFQUFFLEdBQUc7SUFFaEIsbUJBQW1CO0lBQ25CLFFBQVEsRUFBRSxtQkFBbUI7SUFFN0IsNkNBQTZDO0lBQzdDLElBQUksRUFBRSxNQUFNO0NBQ2IsQ0FBQztBQUVGOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQiw4Q0FBOEM7SUFDOUMsZ0VBQWdFO0lBQ2hFLElBQUksRUFBRSx5QkFBeUI7SUFFL0Isd0VBQXdFO0lBQ3hFLDZEQUE2RDtJQUM3RCxTQUFTLEVBQUUsK0VBQStFO0lBRTFGLG9FQUFvRTtJQUNwRSw2REFBNkQ7SUFDN0QsT0FBTyxFQUFFLDZFQUE2RTtJQUV0Rix3Q0FBd0M7SUFDeEMsS0FBSyxFQUFFLCtDQUErQztJQUV0RCxnREFBZ0Q7SUFDaEQsb0ZBQW9GO0lBQ3BGLHNGQUFzRjtJQUN0RixLQUFLLEVBQUUsNEJBQTRCO0lBRW5DLHNIQUFzSDtJQUN0SCxRQUFRLEVBQUUsK0RBQStEO0NBQzFFLENBQUM7QUFFRjs7O0dBR0c7QUFDSCxNQUFNLENBQUMsTUFBTSxlQUFlLEdBQUc7SUFDN0IsOEJBQThCO0lBQzlCLFVBQVUsRUFBRSxZQUFZO0lBQ3hCLElBQUksRUFBRSxNQUFNO0lBQ1osWUFBWSxFQUFFLFVBQVU7SUFFeEIsc0JBQXNCO0lBQ3RCLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLElBQUksRUFBRSxNQUFNO0lBRVosd0JBQXdCO0lBQ3hCLG1CQUFtQixFQUFFLHFCQUFxQjtJQUMxQyxJQUFJLEVBQUUsTUFBTTtJQUNaLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLEdBQUcsRUFBRSxLQUFLO0lBRVYsdUNBQXVDO0lBQ3ZDLGVBQWUsQ0FBQyxJQUFZLEVBQUUsU0FBMkI7UUFDdkQsT0FBTyxTQUFTLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxHQUFHLElBQUksSUFBSSxTQUFTLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQ2pFLENBQUM7Q0FDRixDQUFDIn0=
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Creation Factory
|
||||
* Provides a simple way to create a complete SMTP server
|
||||
*/
|
||||
import { SmtpServer } from './smtp-server.js';
|
||||
import type { ISmtpServerOptions } from './interfaces.js';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
/**
|
||||
* Create a complete SMTP server with all components
|
||||
* @param emailServer - Email server reference
|
||||
* @param options - SMTP server options
|
||||
* @returns Configured SMTP server instance
|
||||
*/
|
||||
export declare function createSmtpServer(emailServer: UnifiedEmailServer, options: ISmtpServerOptions): SmtpServer;
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Creation Factory
|
||||
* Provides a simple way to create a complete SMTP server
|
||||
*/
|
||||
import { SmtpServer } from './smtp-server.js';
|
||||
import { SessionManager } from './session-manager.js';
|
||||
import { ConnectionManager } from './connection-manager.js';
|
||||
import { CommandHandler } from './command-handler.js';
|
||||
import { DataHandler } from './data-handler.js';
|
||||
import { TlsHandler } from './tls-handler.js';
|
||||
import { SecurityHandler } from './security-handler.js';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
/**
|
||||
* Create a complete SMTP server with all components
|
||||
* @param emailServer - Email server reference
|
||||
* @param options - SMTP server options
|
||||
* @returns Configured SMTP server instance
|
||||
*/
|
||||
export function createSmtpServer(emailServer, options) {
|
||||
// First create the SMTP server instance
|
||||
const smtpServer = new SmtpServer({
|
||||
emailServer,
|
||||
options
|
||||
});
|
||||
// Return the configured server
|
||||
return smtpServer;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9jcmVhdGUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLCtDQUErQyxDQUFDO0FBRW5GOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLGdCQUFnQixDQUFDLFdBQStCLEVBQUUsT0FBMkI7SUFDM0Ysd0NBQXdDO0lBQ3hDLE1BQU0sVUFBVSxHQUFHLElBQUksVUFBVSxDQUFDO1FBQ2hDLFdBQVc7UUFDWCxPQUFPO0tBQ1IsQ0FBQyxDQUFDO0lBRUgsK0JBQStCO0lBQy9CLE9BQU8sVUFBVSxDQUFDO0FBQ3BCLENBQUMifQ==
|
||||
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* SMTP Data Handler
|
||||
* Responsible for processing email data during and after DATA command
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { ISmtpSession, ISmtpTransactionResult } from './interfaces.js';
|
||||
import type { IDataHandler, ISmtpServer } from './interfaces.js';
|
||||
import { Email } from '../../core/classes.email.js';
|
||||
/**
|
||||
* Handles SMTP DATA command and email data processing
|
||||
*/
|
||||
export declare class DataHandler implements IDataHandler {
|
||||
/**
|
||||
* Reference to the SMTP server instance
|
||||
*/
|
||||
private smtpServer;
|
||||
/**
|
||||
* Creates a new data handler
|
||||
* @param smtpServer - SMTP server instance
|
||||
*/
|
||||
constructor(smtpServer: ISmtpServer);
|
||||
/**
|
||||
* Process incoming email data
|
||||
* @param socket - Client socket
|
||||
* @param data - Data chunk
|
||||
* @returns Promise that resolves when the data is processed
|
||||
*/
|
||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
||||
/**
|
||||
* Handle raw data chunks during DATA mode (optimized for large messages)
|
||||
* @param socket - Client socket
|
||||
* @param data - Raw data chunk
|
||||
*/
|
||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
||||
/**
|
||||
* Process email data chunks efficiently for large messages
|
||||
* @param chunks - Array of email data chunks
|
||||
* @returns Processed email data string
|
||||
*/
|
||||
private processEmailDataStreaming;
|
||||
/**
|
||||
* Process a complete email
|
||||
* @param rawData - Raw email data
|
||||
* @param session - SMTP session
|
||||
* @returns Promise that resolves with the Email object
|
||||
*/
|
||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
||||
/**
|
||||
* Parse email from raw data
|
||||
* @param rawData - Raw email data
|
||||
* @param session - SMTP session
|
||||
* @returns Email object
|
||||
*/
|
||||
private parseEmailFromData;
|
||||
/**
|
||||
* Process a complete email (legacy method)
|
||||
* @param session - SMTP session
|
||||
* @returns Promise that resolves with the result of the transaction
|
||||
*/
|
||||
processEmailLegacy(session: ISmtpSession): Promise<ISmtpTransactionResult>;
|
||||
/**
|
||||
* Save an email to disk
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
saveEmail(session: ISmtpSession): void;
|
||||
/**
|
||||
* Parse an email into an Email object
|
||||
* @param session - SMTP session
|
||||
* @returns Promise that resolves with the parsed Email object
|
||||
*/
|
||||
parseEmail(session: ISmtpSession): Promise<Email>;
|
||||
/**
|
||||
* Basic fallback method for parsing emails
|
||||
* @param session - SMTP session
|
||||
* @returns The parsed Email object
|
||||
*/
|
||||
private parseEmailBasic;
|
||||
/**
|
||||
* Handle multipart content parsing
|
||||
* @param email - Email object to update
|
||||
* @param bodyText - Body text to parse
|
||||
* @param boundary - MIME boundary
|
||||
*/
|
||||
private handleMultipartContent;
|
||||
/**
|
||||
* Handle end of data marker received
|
||||
* @param socket - Client socket
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
private handleEndOfData;
|
||||
/**
|
||||
* Reset session after email processing
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
private resetSession;
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response message
|
||||
*/
|
||||
private sendResponse;
|
||||
/**
|
||||
* Check if a socket error is potentially recoverable
|
||||
* @param error - The error that occurred
|
||||
* @returns Whether the error is potentially recoverable
|
||||
*/
|
||||
private isRecoverableSocketError;
|
||||
/**
|
||||
* Handle recoverable socket errors with retry logic
|
||||
* @param socket - Client socket
|
||||
* @param error - The error that occurred
|
||||
* @param response - The response that failed to send
|
||||
*/
|
||||
private handleSocketError;
|
||||
/**
|
||||
* Handle email data (interface requirement)
|
||||
*/
|
||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Module Exports
|
||||
* This file exports all components of the refactored SMTP server
|
||||
*/
|
||||
export * from './interfaces.js';
|
||||
export { SmtpServer } from './smtp-server.js';
|
||||
export { SessionManager } from './session-manager.js';
|
||||
export { ConnectionManager } from './connection-manager.js';
|
||||
export { CommandHandler } from './command-handler.js';
|
||||
export { DataHandler } from './data-handler.js';
|
||||
export { TlsHandler } from './tls-handler.js';
|
||||
export { SecurityHandler } from './security-handler.js';
|
||||
export * from './constants.js';
|
||||
export { SmtpLogger } from './utils/logging.js';
|
||||
export * from './utils/validation.js';
|
||||
export * from './utils/helpers.js';
|
||||
export * from './certificate-utils.js';
|
||||
export * from './secure-server.js';
|
||||
export * from './starttls-handler.js';
|
||||
export { createSmtpServer } from './create-server.js';
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Module Exports
|
||||
* This file exports all components of the refactored SMTP server
|
||||
*/
|
||||
// Export interfaces
|
||||
export * from './interfaces.js';
|
||||
// Export server classes
|
||||
export { SmtpServer } from './smtp-server.js';
|
||||
export { SessionManager } from './session-manager.js';
|
||||
export { ConnectionManager } from './connection-manager.js';
|
||||
export { CommandHandler } from './command-handler.js';
|
||||
export { DataHandler } from './data-handler.js';
|
||||
export { TlsHandler } from './tls-handler.js';
|
||||
export { SecurityHandler } from './security-handler.js';
|
||||
// Export constants
|
||||
export * from './constants.js';
|
||||
// Export utilities
|
||||
export { SmtpLogger } from './utils/logging.js';
|
||||
export * from './utils/validation.js';
|
||||
export * from './utils/helpers.js';
|
||||
// Export TLS and certificate utilities
|
||||
export * from './certificate-utils.js';
|
||||
export * from './secure-server.js';
|
||||
export * from './starttls-handler.js';
|
||||
// Factory function to create a complete SMTP server with default components
|
||||
export { createSmtpServer } from './create-server.js';
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L3NtdHBzZXJ2ZXIvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztHQUdHO0FBRUgsb0JBQW9CO0FBQ3BCLGNBQWMsaUJBQWlCLENBQUM7QUFFaEMsd0JBQXdCO0FBQ3hCLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELG1CQUFtQjtBQUNuQixjQUFjLGdCQUFnQixDQUFDO0FBRS9CLG1CQUFtQjtBQUNuQixPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sb0JBQW9CLENBQUM7QUFDaEQsY0FBYyx1QkFBdUIsQ0FBQztBQUN0QyxjQUFjLG9CQUFvQixDQUFDO0FBRW5DLHVDQUF1QztBQUN2QyxjQUFjLHdCQUF3QixDQUFDO0FBQ3ZDLGNBQWMsb0JBQW9CLENBQUM7QUFDbkMsY0FBYyx1QkFBdUIsQ0FBQztBQUV0Qyw0RUFBNEU7QUFDNUUsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sb0JBQW9CLENBQUMifQ==
|
||||
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
@@ -1,530 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Interfaces
|
||||
* Defines all the interfaces used by the SMTP server implementation
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
import { SmtpCommand } from './constants.js';
|
||||
export { SmtpState, SmtpCommand };
|
||||
export type { IEnvelopeRecipient } from '../interfaces.js';
|
||||
/**
|
||||
* Interface for components that need cleanup
|
||||
*/
|
||||
export interface IDestroyable {
|
||||
/**
|
||||
* Clean up all resources (timers, listeners, etc)
|
||||
*/
|
||||
destroy(): void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* SMTP authentication credentials
|
||||
*/
|
||||
export interface ISmtpAuth {
|
||||
/**
|
||||
* Username for authentication
|
||||
*/
|
||||
username: string;
|
||||
/**
|
||||
* Password for authentication
|
||||
*/
|
||||
password: string;
|
||||
}
|
||||
/**
|
||||
* SMTP envelope (sender and recipients)
|
||||
*/
|
||||
export interface ISmtpEnvelope {
|
||||
/**
|
||||
* Mail from address
|
||||
*/
|
||||
mailFrom: {
|
||||
address: string;
|
||||
args?: Record<string, string>;
|
||||
};
|
||||
/**
|
||||
* Recipients list
|
||||
*/
|
||||
rcptTo: Array<{
|
||||
address: string;
|
||||
args?: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* SMTP session representing a client connection
|
||||
*/
|
||||
export interface ISmtpSession {
|
||||
/**
|
||||
* Unique session identifier
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Current state of the SMTP session
|
||||
*/
|
||||
state: SmtpState;
|
||||
/**
|
||||
* Client's hostname from EHLO/HELO
|
||||
*/
|
||||
clientHostname: string | null;
|
||||
/**
|
||||
* Whether TLS is active for this session
|
||||
*/
|
||||
secure: boolean;
|
||||
/**
|
||||
* Authentication status
|
||||
*/
|
||||
authenticated: boolean;
|
||||
/**
|
||||
* Authentication username if authenticated
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* Transaction envelope
|
||||
*/
|
||||
envelope: ISmtpEnvelope;
|
||||
/**
|
||||
* When the session was created
|
||||
*/
|
||||
createdAt: Date;
|
||||
/**
|
||||
* Last activity timestamp
|
||||
*/
|
||||
lastActivity: number;
|
||||
/**
|
||||
* Client's IP address
|
||||
*/
|
||||
remoteAddress: string;
|
||||
/**
|
||||
* Client's port
|
||||
*/
|
||||
remotePort: number;
|
||||
/**
|
||||
* Additional session data
|
||||
*/
|
||||
data?: Record<string, any>;
|
||||
/**
|
||||
* Message size if SIZE extension is used
|
||||
*/
|
||||
messageSize?: number;
|
||||
/**
|
||||
* Server capabilities advertised to client
|
||||
*/
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Buffer for incomplete data
|
||||
*/
|
||||
dataBuffer?: string;
|
||||
/**
|
||||
* Flag to track if we're currently receiving DATA
|
||||
*/
|
||||
receivingData?: boolean;
|
||||
/**
|
||||
* The raw email data being received
|
||||
*/
|
||||
rawData?: string;
|
||||
/**
|
||||
* Greeting sent to client
|
||||
*/
|
||||
greeting?: string;
|
||||
/**
|
||||
* Whether EHLO has been sent
|
||||
*/
|
||||
ehloSent?: boolean;
|
||||
/**
|
||||
* Whether HELO has been sent
|
||||
*/
|
||||
heloSent?: boolean;
|
||||
/**
|
||||
* TLS options for this session
|
||||
*/
|
||||
tlsOptions?: any;
|
||||
/**
|
||||
* Whether TLS is being used
|
||||
*/
|
||||
useTLS?: boolean;
|
||||
/**
|
||||
* Mail from address for this transaction
|
||||
*/
|
||||
mailFrom?: string;
|
||||
/**
|
||||
* Recipients for this transaction
|
||||
*/
|
||||
rcptTo?: string[];
|
||||
/**
|
||||
* Email data being received
|
||||
*/
|
||||
emailData?: string;
|
||||
/**
|
||||
* Chunks of email data
|
||||
*/
|
||||
emailDataChunks?: string[];
|
||||
/**
|
||||
* Timeout ID for data reception
|
||||
*/
|
||||
dataTimeoutId?: NodeJS.Timeout;
|
||||
/**
|
||||
* Whether connection has ended
|
||||
*/
|
||||
connectionEnded?: boolean;
|
||||
/**
|
||||
* Size of email data being received
|
||||
*/
|
||||
emailDataSize?: number;
|
||||
/**
|
||||
* Processing mode for this session
|
||||
*/
|
||||
processingMode?: string;
|
||||
}
|
||||
/**
|
||||
* Session manager interface
|
||||
*/
|
||||
export interface ISessionManager extends IDestroyable {
|
||||
/**
|
||||
* Create a new session for a socket
|
||||
*/
|
||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure?: boolean): ISmtpSession;
|
||||
/**
|
||||
* Get session by socket
|
||||
*/
|
||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
||||
/**
|
||||
* Update session state
|
||||
*/
|
||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
||||
/**
|
||||
* Remove a session
|
||||
*/
|
||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Clear all sessions
|
||||
*/
|
||||
clearAllSessions(): void;
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getAllSessions(): ISmtpSession[];
|
||||
/**
|
||||
* Get session count
|
||||
*/
|
||||
getSessionCount(): number;
|
||||
/**
|
||||
* Update last activity for a session
|
||||
*/
|
||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Check for timed out sessions
|
||||
*/
|
||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
||||
/**
|
||||
* Update session activity timestamp
|
||||
*/
|
||||
updateSessionActivity(session: ISmtpSession): void;
|
||||
/**
|
||||
* Replace socket in session (for TLS upgrade)
|
||||
*/
|
||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
||||
}
|
||||
/**
|
||||
* Connection manager interface
|
||||
*/
|
||||
export interface IConnectionManager extends IDestroyable {
|
||||
/**
|
||||
* Handle a new connection
|
||||
*/
|
||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
||||
/**
|
||||
* Close all active connections
|
||||
*/
|
||||
closeAllConnections(): void;
|
||||
/**
|
||||
* Get active connection count
|
||||
*/
|
||||
getConnectionCount(): number;
|
||||
/**
|
||||
* Check if accepting new connections
|
||||
*/
|
||||
canAcceptConnection(): boolean;
|
||||
/**
|
||||
* Handle new connection (legacy method name)
|
||||
*/
|
||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
||||
/**
|
||||
* Handle new secure connection (legacy method name)
|
||||
*/
|
||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
||||
/**
|
||||
* Setup socket event handlers
|
||||
*/
|
||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
}
|
||||
/**
|
||||
* Command handler interface
|
||||
*/
|
||||
export interface ICommandHandler extends IDestroyable {
|
||||
/**
|
||||
* Handle an SMTP command
|
||||
*/
|
||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
||||
/**
|
||||
* Get supported commands for current session state
|
||||
*/
|
||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
||||
/**
|
||||
* Process command (legacy method name)
|
||||
*/
|
||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: string): Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Data handler interface
|
||||
*/
|
||||
export interface IDataHandler extends IDestroyable {
|
||||
/**
|
||||
* Handle email data
|
||||
*/
|
||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
||||
/**
|
||||
* Process a complete email
|
||||
*/
|
||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
||||
/**
|
||||
* Handle data received (legacy method name)
|
||||
*/
|
||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
||||
/**
|
||||
* Process email data (legacy method name)
|
||||
*/
|
||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
||||
}
|
||||
/**
|
||||
* TLS handler interface
|
||||
*/
|
||||
export interface ITlsHandler extends IDestroyable {
|
||||
/**
|
||||
* Handle STARTTLS command
|
||||
*/
|
||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
||||
/**
|
||||
* Check if TLS is available
|
||||
*/
|
||||
isTlsAvailable(): boolean;
|
||||
/**
|
||||
* Get TLS options
|
||||
*/
|
||||
getTlsOptions(): plugins.tls.TlsOptions;
|
||||
/**
|
||||
* Check if TLS is enabled
|
||||
*/
|
||||
isTlsEnabled(): boolean;
|
||||
}
|
||||
/**
|
||||
* Security handler interface
|
||||
*/
|
||||
export interface ISecurityHandler extends IDestroyable {
|
||||
/**
|
||||
* Check IP reputation
|
||||
*/
|
||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
||||
/**
|
||||
* Validate email address
|
||||
*/
|
||||
isValidEmail(email: string): boolean;
|
||||
/**
|
||||
* Authenticate user
|
||||
*/
|
||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
||||
}
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
export interface ISmtpServerOptions {
|
||||
/**
|
||||
* Port to listen on
|
||||
*/
|
||||
port: number;
|
||||
/**
|
||||
* Hostname of the server
|
||||
*/
|
||||
hostname: string;
|
||||
/**
|
||||
* Host to bind to (optional, defaults to 0.0.0.0)
|
||||
*/
|
||||
host?: string;
|
||||
/**
|
||||
* Secure port for TLS connections
|
||||
*/
|
||||
securePort?: number;
|
||||
/**
|
||||
* TLS/SSL private key (PEM format)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* TLS/SSL certificate (PEM format)
|
||||
*/
|
||||
cert?: string;
|
||||
/**
|
||||
* CA certificates for TLS (PEM format)
|
||||
*/
|
||||
ca?: string;
|
||||
/**
|
||||
* Maximum size of messages in bytes
|
||||
*/
|
||||
maxSize?: number;
|
||||
/**
|
||||
* Maximum number of concurrent connections
|
||||
*/
|
||||
maxConnections?: number;
|
||||
/**
|
||||
* Authentication options
|
||||
*/
|
||||
auth?: {
|
||||
/**
|
||||
* Whether authentication is required
|
||||
*/
|
||||
required: boolean;
|
||||
/**
|
||||
* Allowed authentication methods
|
||||
*/
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
};
|
||||
/**
|
||||
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
|
||||
*/
|
||||
socketTimeout?: number;
|
||||
/**
|
||||
* Initial connection timeout in milliseconds (default: 30 seconds / 30000ms)
|
||||
*/
|
||||
connectionTimeout?: number;
|
||||
/**
|
||||
* Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms)
|
||||
* For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly
|
||||
*/
|
||||
cleanupInterval?: number;
|
||||
/**
|
||||
* Maximum number of recipients allowed per message (default: 100)
|
||||
*/
|
||||
maxRecipients?: number;
|
||||
/**
|
||||
* Maximum message size in bytes (default: 10MB / 10485760 bytes)
|
||||
* This is advertised in the EHLO SIZE extension
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute)
|
||||
* This controls how long to wait for the complete email data
|
||||
*/
|
||||
dataTimeout?: number;
|
||||
}
|
||||
/**
|
||||
* Result of SMTP transaction
|
||||
*/
|
||||
export interface ISmtpTransactionResult {
|
||||
/**
|
||||
* Whether the transaction was successful
|
||||
*/
|
||||
success: boolean;
|
||||
/**
|
||||
* Error message if failed
|
||||
*/
|
||||
error?: string;
|
||||
/**
|
||||
* Message ID if successful
|
||||
*/
|
||||
messageId?: string;
|
||||
/**
|
||||
* Resulting email if successful
|
||||
*/
|
||||
email?: Email;
|
||||
}
|
||||
/**
|
||||
* Interface for SMTP session events
|
||||
* These events are emitted by the session manager
|
||||
*/
|
||||
export interface ISessionEvents {
|
||||
created: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
stateChanged: (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void;
|
||||
timeout: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
completed: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
error: (session: ISmtpSession, error: Error) => void;
|
||||
}
|
||||
/**
|
||||
* SMTP Server interface
|
||||
*/
|
||||
export interface ISmtpServer extends IDestroyable {
|
||||
/**
|
||||
* Start the SMTP server
|
||||
*/
|
||||
listen(): Promise<void>;
|
||||
/**
|
||||
* Stop the SMTP server
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
/**
|
||||
* Get the session manager
|
||||
*/
|
||||
getSessionManager(): ISessionManager;
|
||||
/**
|
||||
* Get the connection manager
|
||||
*/
|
||||
getConnectionManager(): IConnectionManager;
|
||||
/**
|
||||
* Get the command handler
|
||||
*/
|
||||
getCommandHandler(): ICommandHandler;
|
||||
/**
|
||||
* Get the data handler
|
||||
*/
|
||||
getDataHandler(): IDataHandler;
|
||||
/**
|
||||
* Get the TLS handler
|
||||
*/
|
||||
getTlsHandler(): ITlsHandler;
|
||||
/**
|
||||
* Get the security handler
|
||||
*/
|
||||
getSecurityHandler(): ISecurityHandler;
|
||||
/**
|
||||
* Get the server options
|
||||
*/
|
||||
getOptions(): ISmtpServerOptions;
|
||||
/**
|
||||
* Get the email server reference
|
||||
*/
|
||||
getEmailServer(): UnifiedEmailServer;
|
||||
}
|
||||
/**
|
||||
* Configuration for creating SMTP server
|
||||
*/
|
||||
export interface ISmtpServerConfig {
|
||||
/**
|
||||
* Email server instance
|
||||
*/
|
||||
emailServer: UnifiedEmailServer;
|
||||
/**
|
||||
* Server options
|
||||
*/
|
||||
options: ISmtpServerOptions;
|
||||
/**
|
||||
* Optional custom session manager
|
||||
*/
|
||||
sessionManager?: ISessionManager;
|
||||
/**
|
||||
* Optional custom connection manager
|
||||
*/
|
||||
connectionManager?: IConnectionManager;
|
||||
/**
|
||||
* Optional custom command handler
|
||||
*/
|
||||
commandHandler?: ICommandHandler;
|
||||
/**
|
||||
* Optional custom data handler
|
||||
*/
|
||||
dataHandler?: IDataHandler;
|
||||
/**
|
||||
* Optional custom TLS handler
|
||||
*/
|
||||
tlsHandler?: ITlsHandler;
|
||||
/**
|
||||
* Optional custom security handler
|
||||
*/
|
||||
securityHandler?: ISecurityHandler;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* SMTP Server Interfaces
|
||||
* Defines all the interfaces used by the SMTP server implementation
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
// Re-export types from other modules
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
import { SmtpCommand } from './constants.js';
|
||||
export { SmtpState, SmtpCommand };
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9pbnRlcmZhY2VzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFJL0MsdUNBQXVDO0FBQ3ZDLE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM3QyxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFDN0MsT0FBTyxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsQ0FBQyJ9
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Secure SMTP Server Utility Functions
|
||||
* Provides helper functions for creating and managing secure TLS server
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
/**
|
||||
* Create a secure TLS server for direct TLS connections
|
||||
* @param options - TLS certificate options
|
||||
* @returns A configured TLS server or undefined if TLS is not available
|
||||
*/
|
||||
export declare function createSecureTlsServer(options: {
|
||||
key: string;
|
||||
cert: string;
|
||||
ca?: string;
|
||||
}): plugins.tls.Server | undefined;
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Secure SMTP Server Utility Functions
|
||||
* Provides helper functions for creating and managing secure TLS server
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { loadCertificatesFromString, generateSelfSignedCertificates, createTlsOptions } from './certificate-utils.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
/**
|
||||
* Create a secure TLS server for direct TLS connections
|
||||
* @param options - TLS certificate options
|
||||
* @returns A configured TLS server or undefined if TLS is not available
|
||||
*/
|
||||
export function createSecureTlsServer(options) {
|
||||
try {
|
||||
// Log the creation attempt
|
||||
SmtpLogger.info('Creating secure TLS server for direct connections');
|
||||
// Load certificates from strings
|
||||
let certificates;
|
||||
try {
|
||||
certificates = loadCertificatesFromString({
|
||||
key: options.key,
|
||||
cert: options.cert,
|
||||
ca: options.ca
|
||||
});
|
||||
SmtpLogger.info('Successfully loaded TLS certificates for secure server');
|
||||
}
|
||||
catch (certificateError) {
|
||||
SmtpLogger.warn(`Failed to load certificates, using self-signed: ${certificateError instanceof Error ? certificateError.message : String(certificateError)}`);
|
||||
certificates = generateSelfSignedCertificates();
|
||||
}
|
||||
// Create server-side TLS options
|
||||
const tlsOptions = createTlsOptions(certificates, true);
|
||||
// Log details for debugging
|
||||
SmtpLogger.debug('Creating secure server with options', {
|
||||
certificates: {
|
||||
keyLength: certificates.key.length,
|
||||
certLength: certificates.cert.length,
|
||||
caLength: certificates.ca ? certificates.ca.length : 0
|
||||
},
|
||||
tlsOptions: {
|
||||
minVersion: tlsOptions.minVersion,
|
||||
maxVersion: tlsOptions.maxVersion,
|
||||
ciphers: tlsOptions.ciphers?.substring(0, 50) + '...' // Truncate long cipher list
|
||||
}
|
||||
});
|
||||
// Create the TLS server
|
||||
const server = new plugins.tls.Server(tlsOptions);
|
||||
// Set up error handlers
|
||||
server.on('error', (err) => {
|
||||
SmtpLogger.error(`Secure server error: ${err.message}`, {
|
||||
component: 'secure-server',
|
||||
error: err,
|
||||
stack: err.stack
|
||||
});
|
||||
});
|
||||
// Log secure connections
|
||||
server.on('secureConnection', (socket) => {
|
||||
const protocol = socket.getProtocol();
|
||||
const cipher = socket.getCipher();
|
||||
SmtpLogger.info('New direct TLS connection established', {
|
||||
component: 'secure-server',
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
protocol: protocol || 'unknown',
|
||||
cipher: cipher?.name || 'unknown'
|
||||
});
|
||||
});
|
||||
return server;
|
||||
}
|
||||
catch (error) {
|
||||
SmtpLogger.error(`Failed to create secure TLS server: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
component: 'secure-server',
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
stack: error instanceof Error ? error.stack : 'No stack trace available'
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VjdXJlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9zZWN1cmUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxFQUNMLDBCQUEwQixFQUMxQiw4QkFBOEIsRUFDOUIsZ0JBQWdCLEVBRWpCLE1BQU0sd0JBQXdCLENBQUM7QUFDaEMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRWhEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUscUJBQXFCLENBQUMsT0FJckM7SUFDQyxJQUFJLENBQUM7UUFDSCwyQkFBMkI7UUFDM0IsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsQ0FBQyxDQUFDO1FBRXJFLGlDQUFpQztRQUNqQyxJQUFJLFlBQThCLENBQUM7UUFDbkMsSUFBSSxDQUFDO1lBQ0gsWUFBWSxHQUFHLDBCQUEwQixDQUFDO2dCQUN4QyxHQUFHLEVBQUUsT0FBTyxDQUFDLEdBQUc7Z0JBQ2hCLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSTtnQkFDbEIsRUFBRSxFQUFFLE9BQU8sQ0FBQyxFQUFFO2FBQ2YsQ0FBQyxDQUFDO1lBRUgsVUFBVSxDQUFDLElBQUksQ0FBQyx3REFBd0QsQ0FBQyxDQUFDO1FBQzVFLENBQUM7UUFBQyxPQUFPLGdCQUFnQixFQUFFLENBQUM7WUFDMUIsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsZ0JBQWdCLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxnQkFBZ0IsQ0FBQyxFQUFFLENBQUMsQ0FBQztZQUM5SixZQUFZLEdBQUcsOEJBQThCLEVBQUUsQ0FBQztRQUNsRCxDQUFDO1FBRUQsaUNBQWlDO1FBQ2pDLE1BQU0sVUFBVSxHQUFHLGdCQUFnQixDQUFDLFlBQVksRUFBRSxJQUFJLENBQUMsQ0FBQztRQUV4RCw0QkFBNEI7UUFDNUIsVUFBVSxDQUFDLEtBQUssQ0FBQyxxQ0FBcUMsRUFBRTtZQUN0RCxZQUFZLEVBQUU7Z0JBQ1osU0FBUyxFQUFFLFlBQVksQ0FBQyxHQUFHLENBQUMsTUFBTTtnQkFDbEMsVUFBVSxFQUFFLFlBQVksQ0FBQyxJQUFJLENBQUMsTUFBTTtnQkFDcEMsUUFBUSxFQUFFLFlBQVksQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLFlBQVksQ0FBQyxFQUFFLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDO2FBQ3ZEO1lBQ0QsVUFBVSxFQUFFO2dCQUNWLFVBQVUsRUFBRSxVQUFVLENBQUMsVUFBVTtnQkFDakMsVUFBVSxFQUFFLFVBQVUsQ0FBQyxVQUFVO2dCQUNqQyxPQUFPLEVBQUUsVUFBVSxDQUFDLE9BQU8sRUFBRSxTQUFTLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxHQUFHLEtBQUssQ0FBQyw0QkFBNEI7YUFDbkY7U0FDRixDQUFDLENBQUM7UUFFSCx3QkFBd0I7UUFDeEIsTUFBTSxNQUFNLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUVsRCx3QkFBd0I7UUFDeEIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxHQUFHLEVBQUUsRUFBRTtZQUN6QixVQUFVLENBQUMsS0FBSyxDQUFDLHdCQUF3QixHQUFHLENBQUMsT0FBTyxFQUFFLEVBQUU7Z0JBQ3RELFNBQVMsRUFBRSxlQUFlO2dCQUMxQixLQUFLLEVBQUUsR0FBRztnQkFDVixLQUFLLEVBQUUsR0FBRyxDQUFDLEtBQUs7YUFDakIsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCx5QkFBeUI7UUFDekIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxrQkFBa0IsRUFBRSxDQUFDLE1BQU0sRUFBRSxFQUFFO1lBQ3ZDLE1BQU0sUUFBUSxHQUFHLE1BQU0sQ0FBQyxXQUFXLEVBQUUsQ0FBQztZQUN0QyxNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsU0FBUyxFQUFFLENBQUM7WUFFbEMsVUFBVSxDQUFDLElBQUksQ0FBQyx1Q0FBdUMsRUFBRTtnQkFDdkQsU0FBUyxFQUFFLGVBQWU7Z0JBQzFCLGFBQWEsRUFBRSxNQUFNLENBQUMsYUFBYTtnQkFDbkMsVUFBVSxFQUFFLE1BQU0sQ0FBQyxVQUFVO2dCQUM3QixRQUFRLEVBQUUsUUFBUSxJQUFJLFNBQVM7Z0JBQy9CLE1BQU0sRUFBRSxNQUFNLEVBQUUsSUFBSSxJQUFJLFNBQVM7YUFDbEMsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCxPQUFPLE1BQU0sQ0FBQztJQUNoQixDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLFVBQVUsQ0FBQyxLQUFLLENBQUMsdUNBQXVDLEtBQUssWUFBWSxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsRUFBRSxFQUFFO1lBQ2hILFNBQVMsRUFBRSxlQUFlO1lBQzFCLEtBQUssRUFBRSxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLElBQUksS0FBSyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNoRSxLQUFLLEVBQUUsS0FBSyxZQUFZLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsMEJBQTBCO1NBQ3pFLENBQUMsQ0FBQztRQUVILE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIn0=
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* SMTP Security Handler
|
||||
* Responsible for security aspects including IP reputation checking,
|
||||
* email validation, and authentication
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { ISmtpAuth } from './interfaces.js';
|
||||
import type { ISecurityHandler, ISmtpServer } from './interfaces.js';
|
||||
/**
|
||||
* Handles security aspects for SMTP server
|
||||
*/
|
||||
export declare class SecurityHandler implements ISecurityHandler {
|
||||
/**
|
||||
* Reference to the SMTP server instance
|
||||
*/
|
||||
private smtpServer;
|
||||
/**
|
||||
* IP reputation checker service
|
||||
*/
|
||||
private ipReputationService;
|
||||
/**
|
||||
* Simple in-memory IP denylist
|
||||
*/
|
||||
private ipDenylist;
|
||||
/**
|
||||
* Cleanup interval timer
|
||||
*/
|
||||
private cleanupInterval;
|
||||
/**
|
||||
* Creates a new security handler
|
||||
* @param smtpServer - SMTP server instance
|
||||
*/
|
||||
constructor(smtpServer: ISmtpServer);
|
||||
/**
|
||||
* Check IP reputation for a connection
|
||||
* @param socket - Client socket
|
||||
* @returns Promise that resolves to true if IP is allowed, false if blocked
|
||||
*/
|
||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
||||
/**
|
||||
* Validate an email address
|
||||
* @param email - Email address to validate
|
||||
* @returns Whether the email address is valid
|
||||
*/
|
||||
isValidEmail(email: string): boolean;
|
||||
/**
|
||||
* Validate authentication credentials
|
||||
* @param auth - Authentication credentials
|
||||
* @returns Promise that resolves to true if authenticated
|
||||
*/
|
||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event - Event type
|
||||
* @param level - Log level
|
||||
* @param details - Event details
|
||||
*/
|
||||
logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void;
|
||||
/**
|
||||
* Add an IP to the denylist
|
||||
* @param ip - IP address
|
||||
* @param reason - Reason for denylisting
|
||||
* @param duration - Duration in milliseconds (optional, indefinite if not specified)
|
||||
*/
|
||||
private addToDenylist;
|
||||
/**
|
||||
* Check if an IP is denylisted
|
||||
* @param ip - IP address
|
||||
* @returns Whether the IP is denylisted
|
||||
*/
|
||||
private isIpDenylisted;
|
||||
/**
|
||||
* Get the reason an IP was denylisted
|
||||
* @param ip - IP address
|
||||
* @returns Reason for denylisting or undefined if not denylisted
|
||||
*/
|
||||
private getDenylistReason;
|
||||
/**
|
||||
* Clean expired denylist entries
|
||||
*/
|
||||
private cleanExpiredDenylistEntries;
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* SMTP Session Manager
|
||||
* Responsible for creating, managing, and cleaning up SMTP sessions
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { SmtpState } from './interfaces.js';
|
||||
import type { ISmtpSession } from './interfaces.js';
|
||||
import type { ISessionManager, ISessionEvents } from './interfaces.js';
|
||||
/**
|
||||
* Manager for SMTP sessions
|
||||
* Handles session creation, tracking, timeout management, and cleanup
|
||||
*/
|
||||
export declare class SessionManager implements ISessionManager {
|
||||
/**
|
||||
* Map of socket ID to session
|
||||
*/
|
||||
private sessions;
|
||||
/**
|
||||
* Map of socket to socket ID
|
||||
*/
|
||||
private socketIds;
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options;
|
||||
/**
|
||||
* Event listeners
|
||||
*/
|
||||
private eventListeners;
|
||||
/**
|
||||
* Timer for cleanup interval
|
||||
*/
|
||||
private cleanupTimer;
|
||||
/**
|
||||
* Creates a new session manager
|
||||
* @param options - Session manager options
|
||||
*/
|
||||
constructor(options?: {
|
||||
socketTimeout?: number;
|
||||
connectionTimeout?: number;
|
||||
cleanupInterval?: number;
|
||||
});
|
||||
/**
|
||||
* Creates a new session for a socket connection
|
||||
* @param socket - Client socket
|
||||
* @param secure - Whether the connection is secure (TLS)
|
||||
* @returns New SMTP session
|
||||
*/
|
||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession;
|
||||
/**
|
||||
* Updates the session state
|
||||
* @param session - SMTP session
|
||||
* @param newState - New state
|
||||
*/
|
||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
||||
/**
|
||||
* Updates the session's last activity timestamp
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
updateSessionActivity(session: ISmtpSession): void;
|
||||
/**
|
||||
* Removes a session
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Gets a session for a socket
|
||||
* @param socket - Client socket
|
||||
* @returns SMTP session or undefined if not found
|
||||
*/
|
||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
||||
/**
|
||||
* Cleans up idle sessions
|
||||
*/
|
||||
cleanupIdleSessions(): void;
|
||||
/**
|
||||
* Gets the current number of active sessions
|
||||
* @returns Number of active sessions
|
||||
*/
|
||||
getSessionCount(): number;
|
||||
/**
|
||||
* Clears all sessions (used when shutting down)
|
||||
*/
|
||||
clearAllSessions(): void;
|
||||
/**
|
||||
* Register an event listener
|
||||
* @param event - Event name
|
||||
* @param listener - Event listener function
|
||||
*/
|
||||
on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
||||
/**
|
||||
* Remove an event listener
|
||||
* @param event - Event name
|
||||
* @param listener - Event listener function
|
||||
*/
|
||||
off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
||||
/**
|
||||
* Emit an event to registered listeners
|
||||
* @param event - Event name
|
||||
* @param args - Event arguments
|
||||
*/
|
||||
private emitEvent;
|
||||
/**
|
||||
* Start the cleanup timer
|
||||
*/
|
||||
private startCleanupTimer;
|
||||
/**
|
||||
* Stop the cleanup timer
|
||||
*/
|
||||
private stopCleanupTimer;
|
||||
/**
|
||||
* Replace socket mapping for STARTTLS upgrades
|
||||
* @param oldSocket - Original plain socket
|
||||
* @param newSocket - New TLS socket
|
||||
* @returns Whether the replacement was successful
|
||||
*/
|
||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
||||
/**
|
||||
* Gets a unique key for a socket
|
||||
* @param socket - Client socket
|
||||
* @returns Socket key
|
||||
*/
|
||||
private getSocketKey;
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getAllSessions(): ISmtpSession[];
|
||||
/**
|
||||
* Update last activity for a session by socket
|
||||
*/
|
||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Check for timed out sessions
|
||||
*/
|
||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* SMTP Server
|
||||
* Core implementation for the refactored SMTP server
|
||||
*/
|
||||
import type { ISmtpServerOptions } from './interfaces.js';
|
||||
import type { ISmtpServer, ISmtpServerConfig, ISessionManager, IConnectionManager, ICommandHandler, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
/**
|
||||
* SMTP Server implementation
|
||||
* The main server class that coordinates all components
|
||||
*/
|
||||
export declare class SmtpServer implements ISmtpServer {
|
||||
/**
|
||||
* Email server reference
|
||||
*/
|
||||
private emailServer;
|
||||
/**
|
||||
* Session manager
|
||||
*/
|
||||
private sessionManager;
|
||||
/**
|
||||
* Connection manager
|
||||
*/
|
||||
private connectionManager;
|
||||
/**
|
||||
* Command handler
|
||||
*/
|
||||
private commandHandler;
|
||||
/**
|
||||
* Data handler
|
||||
*/
|
||||
private dataHandler;
|
||||
/**
|
||||
* TLS handler
|
||||
*/
|
||||
private tlsHandler;
|
||||
/**
|
||||
* Security handler
|
||||
*/
|
||||
private securityHandler;
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options;
|
||||
/**
|
||||
* Net server instance
|
||||
*/
|
||||
private server;
|
||||
/**
|
||||
* Secure server instance
|
||||
*/
|
||||
private secureServer;
|
||||
/**
|
||||
* Whether the server is running
|
||||
*/
|
||||
private running;
|
||||
/**
|
||||
* Server recovery state
|
||||
*/
|
||||
private recoveryState;
|
||||
/**
|
||||
* Creates a new SMTP server
|
||||
* @param config - Server configuration
|
||||
*/
|
||||
constructor(config: ISmtpServerConfig);
|
||||
/**
|
||||
* Start the SMTP server
|
||||
* @returns Promise that resolves when server is started
|
||||
*/
|
||||
listen(): Promise<void>;
|
||||
/**
|
||||
* Stop the SMTP server
|
||||
* @returns Promise that resolves when server is stopped
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
/**
|
||||
* Get the session manager
|
||||
* @returns Session manager instance
|
||||
*/
|
||||
getSessionManager(): ISessionManager;
|
||||
/**
|
||||
* Get the connection manager
|
||||
* @returns Connection manager instance
|
||||
*/
|
||||
getConnectionManager(): IConnectionManager;
|
||||
/**
|
||||
* Get the command handler
|
||||
* @returns Command handler instance
|
||||
*/
|
||||
getCommandHandler(): ICommandHandler;
|
||||
/**
|
||||
* Get the data handler
|
||||
* @returns Data handler instance
|
||||
*/
|
||||
getDataHandler(): IDataHandler;
|
||||
/**
|
||||
* Get the TLS handler
|
||||
* @returns TLS handler instance
|
||||
*/
|
||||
getTlsHandler(): ITlsHandler;
|
||||
/**
|
||||
* Get the security handler
|
||||
* @returns Security handler instance
|
||||
*/
|
||||
getSecurityHandler(): ISecurityHandler;
|
||||
/**
|
||||
* Get the server options
|
||||
* @returns SMTP server options
|
||||
*/
|
||||
getOptions(): ISmtpServerOptions;
|
||||
/**
|
||||
* Get the email server reference
|
||||
* @returns Email server instance
|
||||
*/
|
||||
getEmailServer(): UnifiedEmailServer;
|
||||
/**
|
||||
* Check if the server is running
|
||||
* @returns Whether the server is running
|
||||
*/
|
||||
isRunning(): boolean;
|
||||
/**
|
||||
* Check if we should attempt to recover from an error
|
||||
* @param error - The error that occurred
|
||||
* @returns Whether recovery should be attempted
|
||||
*/
|
||||
private shouldAttemptRecovery;
|
||||
/**
|
||||
* Attempt to recover the server after a critical error
|
||||
* @param serverType - The type of server to recover ('standard' or 'secure')
|
||||
* @param error - The error that triggered recovery
|
||||
*/
|
||||
private attemptServerRecovery;
|
||||
/**
|
||||
* Clean up all component resources
|
||||
*/
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* STARTTLS Implementation
|
||||
* Provides an improved implementation for STARTTLS upgrades
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { ISmtpSession, ISessionManager, IConnectionManager } from './interfaces.js';
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
/**
|
||||
* Enhanced STARTTLS handler for more reliable TLS upgrades
|
||||
*/
|
||||
export declare function performStartTLS(socket: plugins.net.Socket, options: {
|
||||
key: string;
|
||||
cert: string;
|
||||
ca?: string;
|
||||
session?: ISmtpSession;
|
||||
sessionManager?: ISessionManager;
|
||||
connectionManager?: IConnectionManager;
|
||||
onSuccess?: (tlsSocket: plugins.tls.TLSSocket) => void;
|
||||
onFailure?: (error: Error) => void;
|
||||
updateSessionState?: (session: ISmtpSession, state: SmtpState) => void;
|
||||
}): Promise<plugins.tls.TLSSocket | undefined>;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* SMTP TLS Handler
|
||||
* Responsible for handling TLS-related SMTP functionality
|
||||
*/
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { ITlsHandler, ISmtpServer, ISmtpSession } from './interfaces.js';
|
||||
/**
|
||||
* Handles TLS functionality for SMTP server
|
||||
*/
|
||||
export declare class TlsHandler implements ITlsHandler {
|
||||
/**
|
||||
* Reference to the SMTP server instance
|
||||
*/
|
||||
private smtpServer;
|
||||
/**
|
||||
* Certificate data
|
||||
*/
|
||||
private certificates;
|
||||
/**
|
||||
* TLS options
|
||||
*/
|
||||
private options;
|
||||
/**
|
||||
* Creates a new TLS handler
|
||||
* @param smtpServer - SMTP server instance
|
||||
*/
|
||||
constructor(smtpServer: ISmtpServer);
|
||||
/**
|
||||
* Handle STARTTLS command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
||||
/**
|
||||
* Upgrade a connection to TLS
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
startTLS(socket: plugins.net.Socket): Promise<plugins.tls.TLSSocket>;
|
||||
/**
|
||||
* Create a secure server
|
||||
* @returns TLS server instance or undefined if TLS is not enabled
|
||||
*/
|
||||
createSecureServer(): plugins.tls.Server | undefined;
|
||||
/**
|
||||
* Check if TLS is enabled
|
||||
* @returns Whether TLS is enabled
|
||||
*/
|
||||
isTlsEnabled(): boolean;
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response message
|
||||
*/
|
||||
private sendResponse;
|
||||
/**
|
||||
* Check if TLS is available (interface requirement)
|
||||
*/
|
||||
isTlsAvailable(): boolean;
|
||||
/**
|
||||
* Get TLS options (interface requirement)
|
||||
*/
|
||||
getTlsOptions(): plugins.tls.TlsOptions;
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Adaptive SMTP Logging System
|
||||
* Automatically switches between logging modes based on server load (active connections)
|
||||
* to maintain performance during high-concurrency scenarios
|
||||
*/
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
||||
import type { ISmtpSession } from '../interfaces.js';
|
||||
import type { LogLevel, ISmtpLogOptions } from './logging.js';
|
||||
/**
|
||||
* Log modes based on server load
|
||||
*/
|
||||
export declare enum LogMode {
|
||||
VERBOSE = "VERBOSE",// < 20 connections: Full detailed logging
|
||||
REDUCED = "REDUCED",// 20-40 connections: Limited command/response logging, full error logging
|
||||
MINIMAL = "MINIMAL"
|
||||
}
|
||||
/**
|
||||
* Configuration for adaptive logging thresholds
|
||||
*/
|
||||
export interface IAdaptiveLogConfig {
|
||||
verboseThreshold: number;
|
||||
reducedThreshold: number;
|
||||
aggregationInterval: number;
|
||||
maxAggregatedEntries: number;
|
||||
}
|
||||
/**
|
||||
* Connection metadata for aggregation tracking
|
||||
*/
|
||||
interface IConnectionTracker {
|
||||
activeConnections: number;
|
||||
peakConnections: number;
|
||||
totalConnections: number;
|
||||
connectionsPerSecond: number;
|
||||
lastConnectionTime: number;
|
||||
}
|
||||
/**
|
||||
* Adaptive SMTP Logger that scales logging based on server load
|
||||
*/
|
||||
export declare class AdaptiveSmtpLogger {
|
||||
private static instance;
|
||||
private currentMode;
|
||||
private config;
|
||||
private aggregatedEntries;
|
||||
private aggregationTimer;
|
||||
private connectionTracker;
|
||||
private constructor();
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger;
|
||||
/**
|
||||
* Update active connection count and adjust log mode if needed
|
||||
*/
|
||||
updateConnectionCount(activeConnections: number): void;
|
||||
/**
|
||||
* Track new connection for rate calculation
|
||||
*/
|
||||
trackConnection(): void;
|
||||
/**
|
||||
* Get current logging mode
|
||||
*/
|
||||
getCurrentMode(): LogMode;
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getConnectionStats(): IConnectionTracker;
|
||||
/**
|
||||
* Log a message with adaptive behavior
|
||||
*/
|
||||
log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log command with adaptive behavior
|
||||
*/
|
||||
logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
||||
/**
|
||||
* Log response with adaptive behavior
|
||||
*/
|
||||
logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Log connection event with adaptive behavior
|
||||
*/
|
||||
logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
||||
/**
|
||||
* Log security event (always logged regardless of mode)
|
||||
*/
|
||||
logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
||||
/**
|
||||
* Determine appropriate log mode based on connection count
|
||||
*/
|
||||
private determineLogMode;
|
||||
/**
|
||||
* Switch to a new log mode
|
||||
*/
|
||||
private switchLogMode;
|
||||
/**
|
||||
* Add entry to aggregation buffer
|
||||
*/
|
||||
private aggregateEntry;
|
||||
/**
|
||||
* Start the aggregation timer
|
||||
*/
|
||||
private startAggregationTimer;
|
||||
/**
|
||||
* Flush aggregated entries to logs
|
||||
*/
|
||||
private flushAggregatedEntries;
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
/**
|
||||
* Default instance for easy access
|
||||
*/
|
||||
export declare const adaptiveLogger: AdaptiveSmtpLogger;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* SMTP Helper Functions
|
||||
* Provides utility functions for SMTP server implementation
|
||||
*/
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import type { ISmtpServerOptions } from '../interfaces.js';
|
||||
/**
|
||||
* Formats a multi-line SMTP response according to RFC 5321
|
||||
* @param code - Response code
|
||||
* @param lines - Response lines
|
||||
* @returns Formatted SMTP response
|
||||
*/
|
||||
export declare function formatMultilineResponse(code: number, lines: string[]): string;
|
||||
/**
|
||||
* Generates a unique session ID
|
||||
* @returns Unique session ID
|
||||
*/
|
||||
export declare function generateSessionId(): string;
|
||||
/**
|
||||
* Safely parses an integer from string with a default value
|
||||
* @param value - String value to parse
|
||||
* @param defaultValue - Default value if parsing fails
|
||||
* @returns Parsed integer or default value
|
||||
*/
|
||||
export declare function safeParseInt(value: string | undefined, defaultValue: number): number;
|
||||
/**
|
||||
* Safely gets the socket details
|
||||
* @param socket - Socket to get details from
|
||||
* @returns Socket details object
|
||||
*/
|
||||
export declare function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
||||
remoteAddress: string;
|
||||
remotePort: number;
|
||||
remoteFamily: string;
|
||||
localAddress: string;
|
||||
localPort: number;
|
||||
encrypted: boolean;
|
||||
};
|
||||
/**
|
||||
* Gets TLS details if socket is TLS
|
||||
* @param socket - Socket to get TLS details from
|
||||
* @returns TLS details or undefined if not TLS
|
||||
*/
|
||||
export declare function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
||||
protocol?: string;
|
||||
cipher?: string;
|
||||
authorized?: boolean;
|
||||
} | undefined;
|
||||
/**
|
||||
* Merges default options with provided options
|
||||
* @param options - User provided options
|
||||
* @returns Merged options with defaults
|
||||
*/
|
||||
export declare function mergeWithDefaults(options: Partial<ISmtpServerOptions>): ISmtpServerOptions;
|
||||
/**
|
||||
* Creates a text response formatter for the SMTP server
|
||||
* @param socket - Socket to send responses to
|
||||
* @returns Function to send formatted response
|
||||
*/
|
||||
export declare function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void;
|
||||
/**
|
||||
* Extracts SMTP command name from a command line
|
||||
* @param commandLine - Full command line
|
||||
* @returns Command name in uppercase
|
||||
*/
|
||||
export declare function extractCommandName(commandLine: string): string;
|
||||
/**
|
||||
* Extracts SMTP command arguments from a command line
|
||||
* @param commandLine - Full command line
|
||||
* @returns Arguments string
|
||||
*/
|
||||
export declare function extractCommandArgs(commandLine: string): string;
|
||||
/**
|
||||
* Sanitizes data for logging (hides sensitive info)
|
||||
* @param data - Data to sanitize
|
||||
* @returns Sanitized data
|
||||
*/
|
||||
export declare function sanitizeForLogging(data: any): any;
|
||||
File diff suppressed because one or more lines are too long
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* SMTP Logging Utilities
|
||||
* Provides structured logging for SMTP server components
|
||||
*/
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
||||
import type { ISmtpSession } from '../interfaces.js';
|
||||
/**
|
||||
* SMTP connection metadata to include in logs
|
||||
*/
|
||||
export interface IConnectionMetadata {
|
||||
remoteAddress?: string;
|
||||
remotePort?: number;
|
||||
socketId?: string;
|
||||
secure?: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
/**
|
||||
* Log levels for SMTP server
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
/**
|
||||
* Options for SMTP log
|
||||
*/
|
||||
export interface ISmtpLogOptions {
|
||||
level?: LogLevel;
|
||||
sessionId?: string;
|
||||
sessionState?: string;
|
||||
remoteAddress?: string;
|
||||
remotePort?: number;
|
||||
command?: string;
|
||||
error?: Error;
|
||||
[key: string]: any;
|
||||
}
|
||||
/**
|
||||
* SMTP logger - provides structured logging for SMTP server
|
||||
*/
|
||||
export declare class SmtpLogger {
|
||||
/**
|
||||
* Log a message with context
|
||||
* @param level - Log level
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
static log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log debug level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
static debug(message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log info level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
static info(message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log warning level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
static warn(message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log error level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
static error(message: string, options?: ISmtpLogOptions): void;
|
||||
/**
|
||||
* Log command received from client
|
||||
* @param command - The command string
|
||||
* @param socket - The client socket
|
||||
* @param session - The SMTP session
|
||||
*/
|
||||
static logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
||||
/**
|
||||
* Log response sent to client
|
||||
* @param response - The response string
|
||||
* @param socket - The client socket
|
||||
*/
|
||||
static logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
/**
|
||||
* Log client connection event
|
||||
* @param socket - The client socket
|
||||
* @param eventType - Type of connection event (connect, close, error)
|
||||
* @param session - The SMTP session
|
||||
* @param error - Optional error object for error events
|
||||
*/
|
||||
static logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
||||
/**
|
||||
* Log security event
|
||||
* @param level - Security log level
|
||||
* @param type - Security event type
|
||||
* @param message - Log message
|
||||
* @param details - Event details
|
||||
* @param ipAddress - Client IP address
|
||||
* @param domain - Optional domain involved
|
||||
* @param success - Whether the security check was successful
|
||||
*/
|
||||
static logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
||||
}
|
||||
/**
|
||||
* Default instance for backward compatibility
|
||||
*/
|
||||
export declare const smtpLogger: typeof SmtpLogger;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* SMTP Validation Utilities
|
||||
* Provides validation functions for SMTP server
|
||||
*/
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
/**
|
||||
* Detects header injection attempts in input strings
|
||||
* @param input - The input string to check
|
||||
* @param context - The context where this input is being used ('smtp-command' or 'email-header')
|
||||
* @returns true if header injection is detected, false otherwise
|
||||
*/
|
||||
export declare function detectHeaderInjection(input: string, context?: 'smtp-command' | 'email-header'): boolean;
|
||||
/**
|
||||
* Sanitizes input by removing or escaping potentially dangerous characters
|
||||
* @param input - The input string to sanitize
|
||||
* @returns Sanitized string
|
||||
*/
|
||||
export declare function sanitizeInput(input: string): string;
|
||||
/**
|
||||
* Validates an email address
|
||||
* @param email - Email address to validate
|
||||
* @returns Whether the email address is valid
|
||||
*/
|
||||
export declare function isValidEmail(email: string): boolean;
|
||||
/**
|
||||
* Validates the MAIL FROM command syntax
|
||||
* @param args - Arguments string from the MAIL FROM command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export declare function validateMailFrom(args: string): {
|
||||
isValid: boolean;
|
||||
address?: string;
|
||||
params?: Record<string, string>;
|
||||
errorMessage?: string;
|
||||
};
|
||||
/**
|
||||
* Validates the RCPT TO command syntax
|
||||
* @param args - Arguments string from the RCPT TO command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export declare function validateRcptTo(args: string): {
|
||||
isValid: boolean;
|
||||
address?: string;
|
||||
params?: Record<string, string>;
|
||||
errorMessage?: string;
|
||||
};
|
||||
/**
|
||||
* Validates the EHLO command syntax
|
||||
* @param args - Arguments string from the EHLO command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export declare function validateEhlo(args: string): {
|
||||
isValid: boolean;
|
||||
hostname?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
/**
|
||||
* Validates command in the current SMTP state
|
||||
* @param command - SMTP command
|
||||
* @param currentState - Current SMTP state
|
||||
* @returns Whether the command is valid in the current state
|
||||
*/
|
||||
export declare function isValidCommandSequence(command: string, currentState: SmtpState): boolean;
|
||||
/**
|
||||
* Validates if a hostname is valid according to RFC 5321
|
||||
* @param hostname - Hostname to validate
|
||||
* @returns Whether the hostname is valid
|
||||
*/
|
||||
export declare function isValidHostname(hostname: string): boolean;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
interface IIPWarmupConfig {
|
||||
@@ -134,6 +133,7 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
||||
private servers;
|
||||
private stats;
|
||||
dkimCreator: DKIMCreator;
|
||||
private rustBridge;
|
||||
private ipReputationChecker;
|
||||
private bounceManager;
|
||||
private ipWarmupManager;
|
||||
@@ -153,16 +153,26 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
||||
* Start the unified email server
|
||||
*/
|
||||
start(): Promise<void>;
|
||||
/**
|
||||
* Handle a socket from smartproxy in socket-handler mode
|
||||
* @param socket The socket to handle
|
||||
* @param port The port this connection is for (25, 587, 465)
|
||||
*/
|
||||
handleSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket, port: number): Promise<void>;
|
||||
/**
|
||||
* Stop the unified email server
|
||||
*/
|
||||
stop(): Promise<void>;
|
||||
/**
|
||||
* Handle an emailReceived event from the Rust SMTP server.
|
||||
* Decodes the email data, processes it through the routing system,
|
||||
* and sends back the result via the correlation-ID callback.
|
||||
*/
|
||||
private handleRustEmailReceived;
|
||||
/**
|
||||
* Handle an authRequest event from the Rust SMTP server.
|
||||
* Validates credentials and sends back the result.
|
||||
*/
|
||||
private handleRustAuthRequest;
|
||||
/**
|
||||
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
|
||||
* or falling back to IPC call if no pre-computed results are available.
|
||||
*/
|
||||
private verifyInboundSecurity;
|
||||
/**
|
||||
* Process email based on routing rules
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
@@ -11,36 +11,19 @@ export interface IDkimVerificationResult {
|
||||
signatureFields?: Record<string, string>;
|
||||
}
|
||||
/**
|
||||
* Enhanced DKIM verifier using smartmail capabilities
|
||||
* DKIM verifier — delegates to the Rust security bridge.
|
||||
*/
|
||||
export declare class DKIMVerifier {
|
||||
private verificationCache;
|
||||
private cacheTtl;
|
||||
constructor();
|
||||
/**
|
||||
* Verify DKIM signature for an email
|
||||
* @param emailData The raw email data
|
||||
* @param options Verification options
|
||||
* @returns Verification result
|
||||
* Verify DKIM signature for an email via Rust bridge
|
||||
*/
|
||||
verify(emailData: string, options?: {
|
||||
useCache?: boolean;
|
||||
returnDetails?: boolean;
|
||||
}): Promise<IDkimVerificationResult>;
|
||||
/**
|
||||
* Fetch DKIM public key from DNS
|
||||
* @param domain The domain
|
||||
* @param selector The DKIM selector
|
||||
* @returns The DKIM public key or null if not found
|
||||
*/
|
||||
private fetchDkimKey;
|
||||
/**
|
||||
* Clear the verification cache
|
||||
*/
|
||||
/** No-op — Rust bridge handles its own caching */
|
||||
clearCache(): void;
|
||||
/**
|
||||
* Get the size of the verification cache
|
||||
* @returns Number of cached items
|
||||
*/
|
||||
/** Always 0 — cache is managed by the Rust side */
|
||||
getCacheSize(): number;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
@@ -50,54 +50,22 @@ export interface SpfResult {
|
||||
error?: string;
|
||||
}
|
||||
/**
|
||||
* Class for verifying SPF records
|
||||
* Class for verifying SPF records.
|
||||
* Delegates actual SPF evaluation to the Rust security bridge.
|
||||
* Retains parseSpfRecord() for lightweight local parsing.
|
||||
*/
|
||||
export declare class SpfVerifier {
|
||||
private dnsManager?;
|
||||
private lookupCount;
|
||||
constructor(dnsManager?: any);
|
||||
constructor(_dnsManager?: any);
|
||||
/**
|
||||
* Parse SPF record from TXT record
|
||||
* @param record SPF TXT record
|
||||
* @returns Parsed SPF record or null if invalid
|
||||
* Parse SPF record from TXT record (pure string parsing, no DNS)
|
||||
*/
|
||||
parseSpfRecord(record: string): SpfRecord | null;
|
||||
/**
|
||||
* Check if IP is in CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range
|
||||
* @returns Whether the IP is in the CIDR range
|
||||
*/
|
||||
private isIpInCidr;
|
||||
/**
|
||||
* Check if a domain has the specified IP in its A or AAAA records
|
||||
* @param domain Domain to check
|
||||
* @param ip IP address to check
|
||||
* @returns Whether the domain resolves to the IP
|
||||
*/
|
||||
private isDomainResolvingToIp;
|
||||
/**
|
||||
* Verify SPF for a given email with IP and helo domain
|
||||
* @param email Email to verify
|
||||
* @param ip Sender IP address
|
||||
* @param heloDomain HELO/EHLO domain used by sender
|
||||
* @returns SPF verification result
|
||||
* Verify SPF for a given email — delegates to Rust bridge
|
||||
*/
|
||||
verify(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
|
||||
/**
|
||||
* Check SPF record against IP address
|
||||
* @param spfRecord Parsed SPF record
|
||||
* @param domain Domain being checked
|
||||
* @param ip IP address to check
|
||||
* @returns SPF result
|
||||
*/
|
||||
private checkSpfRecord;
|
||||
/**
|
||||
* Check if email passes SPF verification
|
||||
* @param email Email to verify
|
||||
* @param ip Sender IP address
|
||||
* @param heloDomain HELO/EHLO domain used by sender
|
||||
* @returns Whether email passes SPF
|
||||
* Check if email passes SPF verification and apply headers
|
||||
*/
|
||||
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
8
dist_ts/plugins.d.ts
vendored
8
dist_ts/plugins.d.ts
vendored
@@ -32,18 +32,16 @@ import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrule from '@push.rocks/smartrule';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
export declare const smartfs: SmartFs;
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
export { cloudflare, };
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
export { tsclass, };
|
||||
import * as mailauth from 'mailauth';
|
||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
||||
import mailparser from 'mailparser';
|
||||
import * as uuid from 'uuid';
|
||||
import * as ip from 'ip';
|
||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
||||
export { mailparser, uuid, };
|
||||
|
||||
@@ -36,10 +36,11 @@ import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrule from '@push.rocks/smartrule';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
||||
// apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
export { cloudflare, };
|
||||
@@ -47,10 +48,7 @@ export { cloudflare, };
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
export { tsclass, };
|
||||
// third party
|
||||
import * as mailauth from 'mailauth';
|
||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
||||
import mailparser from 'mailparser';
|
||||
import * as uuid from 'uuid';
|
||||
import * as ip from 'ip';
|
||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssT0FBTyxNQUFNLHFCQUFxQixDQUFDO0FBQy9DLE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsTUFBTSxDQUFDLE1BQU0sT0FBTyxHQUFHLElBQUksT0FBTyxDQUFDLElBQUksbUJBQW1CLEVBQUUsQ0FBQyxDQUFDO0FBRTlELE9BQU8sRUFBRSxXQUFXLEVBQUUsSUFBSSxFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsUUFBUSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsWUFBWSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLNU8sc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sS0FBSyxRQUFRLE1BQU0sVUFBVSxDQUFDO0FBQ3JDLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSwyQkFBMkIsQ0FBQztBQUNyRCxPQUFPLFVBQVUsTUFBTSxZQUFZLENBQUM7QUFDcEMsT0FBTyxLQUFLLElBQUksTUFBTSxNQUFNLENBQUM7QUFDN0IsT0FBTyxLQUFLLEVBQUUsTUFBTSxJQUFJLENBQUM7QUFFekIsT0FBTyxFQUNMLFFBQVEsRUFDUixRQUFRLEVBQ1IsVUFBVSxFQUNWLElBQUksRUFDSixFQUFFLEdBQ0gsQ0FBQSJ9
|
||||
export { mailparser, uuid, };
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sVUFBVSxNQUFNLFlBQVksQ0FBQztBQUNwQyxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUU3QixPQUFPLEVBQ0wsVUFBVSxFQUNWLElBQUksR0FDTCxDQUFBIn0=
|
||||
52
dist_ts/security/classes.contentscanner.d.ts
vendored
52
dist_ts/security/classes.contentscanner.d.ts
vendored
@@ -54,9 +54,6 @@ export declare class ContentScanner {
|
||||
private static instance;
|
||||
private scanCache;
|
||||
private options;
|
||||
private static readonly MALICIOUS_PATTERNS;
|
||||
private static readonly EXECUTABLE_EXTENSIONS;
|
||||
private static readonly MACRO_DOCUMENT_EXTENSIONS;
|
||||
/**
|
||||
* Default options for the content scanner
|
||||
*/
|
||||
@@ -73,7 +70,9 @@ export declare class ContentScanner {
|
||||
*/
|
||||
static getInstance(options?: IContentScannerOptions): ContentScanner;
|
||||
/**
|
||||
* Scan an email for malicious content
|
||||
* Scan an email for malicious content.
|
||||
* Delegates text/subject/html/filename pattern scanning to Rust.
|
||||
* Binary attachment scanning (PE headers, VBA macros) stays in TS.
|
||||
* @param email The email to scan
|
||||
* @returns Scan result
|
||||
*/
|
||||
@@ -85,41 +84,19 @@ export declare class ContentScanner {
|
||||
*/
|
||||
private generateCacheKey;
|
||||
/**
|
||||
* Scan email subject for threats
|
||||
* @param subject The subject to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private scanSubject;
|
||||
/**
|
||||
* Scan plain text content for threats
|
||||
* @param text The text content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private scanTextContent;
|
||||
/**
|
||||
* Scan HTML content for threats
|
||||
* @param html The HTML content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private scanHtmlContent;
|
||||
/**
|
||||
* Scan an attachment for threats
|
||||
* Scan attachment binary content for PE headers and VBA macros.
|
||||
* This stays in TS because it accesses raw Buffer data (too large for IPC).
|
||||
* @param attachment The attachment to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private scanAttachment;
|
||||
private scanAttachmentBinary;
|
||||
/**
|
||||
* Extract links from HTML content
|
||||
* @param html HTML content
|
||||
* @returns Array of extracted links
|
||||
* Apply custom rules (runtime-configured patterns) to the email.
|
||||
* These stay in TS because they are configured at runtime.
|
||||
* @param email The email to check
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private extractLinksFromHtml;
|
||||
/**
|
||||
* Extract plain text from HTML
|
||||
* @param html HTML content
|
||||
* @returns Extracted text
|
||||
*/
|
||||
private extractTextFromHtml;
|
||||
private applyCustomRules;
|
||||
/**
|
||||
* Extract text from a binary buffer for scanning
|
||||
* @param buffer Binary content
|
||||
@@ -128,17 +105,10 @@ export declare class ContentScanner {
|
||||
private extractTextFromBuffer;
|
||||
/**
|
||||
* Check if an Office document likely contains macros
|
||||
* This is a simplified check - real implementation would use specialized libraries
|
||||
* @param attachment The attachment to check
|
||||
* @returns Whether the file likely contains macros
|
||||
*/
|
||||
private likelyContainsMacros;
|
||||
/**
|
||||
* Map a pattern category to a threat type
|
||||
* @param category The pattern category
|
||||
* @returns The corresponding threat type
|
||||
*/
|
||||
private mapCategoryToThreatType;
|
||||
/**
|
||||
* Log a high threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -48,103 +48,26 @@ export interface IIPReputationOptions {
|
||||
enableIPInfo?: boolean;
|
||||
}
|
||||
/**
|
||||
* Class for checking IP reputation of inbound email senders
|
||||
* IP reputation checker — delegates DNSBL lookups to the Rust security bridge.
|
||||
* Retains LRU caching and disk persistence in TypeScript.
|
||||
*/
|
||||
export declare class IPReputationChecker {
|
||||
private static instance;
|
||||
private reputationCache;
|
||||
private options;
|
||||
private storageManager?;
|
||||
private static readonly DEFAULT_DNSBL_SERVERS;
|
||||
private static readonly DEFAULT_OPTIONS;
|
||||
/**
|
||||
* Constructor for IPReputationChecker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
*/
|
||||
constructor(options?: IIPReputationOptions, storageManager?: any);
|
||||
/**
|
||||
* Get the singleton instance of the checker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
* @returns Singleton instance
|
||||
*/
|
||||
static getInstance(options?: IIPReputationOptions, storageManager?: any): IPReputationChecker;
|
||||
/**
|
||||
* Check an IP address's reputation
|
||||
* @param ip IP address to check
|
||||
* @returns Reputation check result
|
||||
* Check an IP address's reputation via the Rust bridge
|
||||
*/
|
||||
checkReputation(ip: string): Promise<IReputationResult>;
|
||||
/**
|
||||
* Check an IP against DNS blacklists
|
||||
* @param ip IP address to check
|
||||
* @returns DNSBL check results
|
||||
*/
|
||||
private checkDNSBL;
|
||||
/**
|
||||
* Get information about an IP address
|
||||
* @param ip IP address to check
|
||||
* @returns IP information
|
||||
*/
|
||||
private getIPInfo;
|
||||
/**
|
||||
* Simplified method to determine country from IP
|
||||
* In a real implementation, this would use a geolocation database or service
|
||||
* @param ip IP address
|
||||
* @returns Country code
|
||||
*/
|
||||
private determineCountry;
|
||||
/**
|
||||
* Simplified method to determine organization from IP
|
||||
* In a real implementation, this would use an IP-to-org database or service
|
||||
* @param ip IP address
|
||||
* @returns Organization name
|
||||
*/
|
||||
private determineOrg;
|
||||
/**
|
||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||||
* @param ip IP address to reverse
|
||||
* @returns Reversed IP for DNSBL queries
|
||||
*/
|
||||
private reverseIP;
|
||||
/**
|
||||
* Create an error result for when reputation check fails
|
||||
* @param ip IP address
|
||||
* @param errorMessage Error message
|
||||
* @returns Error result
|
||||
*/
|
||||
private createErrorResult;
|
||||
/**
|
||||
* Validate IP address format
|
||||
* @param ip IP address to validate
|
||||
* @returns Whether the IP is valid
|
||||
*/
|
||||
private isValidIPAddress;
|
||||
/**
|
||||
* Log reputation check to security logger
|
||||
* @param ip IP address
|
||||
* @param result Reputation result
|
||||
*/
|
||||
private logReputationCheck;
|
||||
/**
|
||||
* Save cache to disk or storage manager
|
||||
*/
|
||||
private saveCache;
|
||||
/**
|
||||
* Load cache from disk or storage manager
|
||||
*/
|
||||
private loadCache;
|
||||
/**
|
||||
* Get the risk level for a reputation score
|
||||
* @param score Reputation score (0-100)
|
||||
* @returns Risk level description
|
||||
*/
|
||||
static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted';
|
||||
/**
|
||||
* Update the storage manager after instantiation
|
||||
* This is useful when the storage manager is not available at construction time
|
||||
* @param storageManager The StorageManager instance to use
|
||||
*/
|
||||
updateStorageManager(storageManager: any): void;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
229
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
Normal file
229
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
interface IDkimVerificationResult {
|
||||
is_valid: boolean;
|
||||
domain: string | null;
|
||||
selector: string | null;
|
||||
status: string;
|
||||
details: string | null;
|
||||
}
|
||||
interface ISpfResult {
|
||||
result: string;
|
||||
domain: string;
|
||||
ip: string;
|
||||
explanation: string | null;
|
||||
}
|
||||
interface IDmarcResult {
|
||||
passed: boolean;
|
||||
policy: string;
|
||||
domain: string;
|
||||
dkim_result: string;
|
||||
spf_result: string;
|
||||
action: string;
|
||||
details: string | null;
|
||||
}
|
||||
interface IEmailSecurityResult {
|
||||
dkim: IDkimVerificationResult[];
|
||||
spf: ISpfResult | null;
|
||||
dmarc: IDmarcResult | null;
|
||||
}
|
||||
interface IValidationResult {
|
||||
valid: boolean;
|
||||
formatValid: boolean;
|
||||
score: number;
|
||||
error: string | null;
|
||||
}
|
||||
interface IBounceDetection {
|
||||
bounce_type: string;
|
||||
category: string;
|
||||
}
|
||||
interface IReputationResult {
|
||||
ip: string;
|
||||
score: number;
|
||||
risk_level: string;
|
||||
ip_type: string;
|
||||
dnsbl_results: Array<{
|
||||
server: string;
|
||||
listed: boolean;
|
||||
response: string | null;
|
||||
}>;
|
||||
listed_count: number;
|
||||
total_checked: number;
|
||||
}
|
||||
interface IContentScanResult {
|
||||
threatScore: number;
|
||||
threatType: string | null;
|
||||
threatDetails: string | null;
|
||||
scannedElements: string[];
|
||||
}
|
||||
interface IVersionInfo {
|
||||
bin: string;
|
||||
core: string;
|
||||
security: string;
|
||||
smtp: string;
|
||||
}
|
||||
interface ISmtpServerConfig {
|
||||
hostname: string;
|
||||
ports: number[];
|
||||
securePort?: number;
|
||||
tlsCertPem?: string;
|
||||
tlsKeyPem?: string;
|
||||
maxMessageSize?: number;
|
||||
maxConnections?: number;
|
||||
maxRecipients?: number;
|
||||
connectionTimeoutSecs?: number;
|
||||
dataTimeoutSecs?: number;
|
||||
authEnabled?: boolean;
|
||||
maxAuthFailures?: number;
|
||||
socketTimeoutSecs?: number;
|
||||
processingTimeoutSecs?: number;
|
||||
rateLimits?: IRateLimitConfig;
|
||||
}
|
||||
interface IRateLimitConfig {
|
||||
maxConnectionsPerIp?: number;
|
||||
maxMessagesPerSender?: number;
|
||||
maxAuthFailuresPerIp?: number;
|
||||
windowSecs?: number;
|
||||
}
|
||||
interface IEmailData {
|
||||
type: 'inline' | 'file';
|
||||
base64?: string;
|
||||
path?: string;
|
||||
}
|
||||
interface IEmailReceivedEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
mailFrom: string;
|
||||
rcptTo: string[];
|
||||
data: IEmailData;
|
||||
remoteAddr: string;
|
||||
clientHostname: string | null;
|
||||
secure: boolean;
|
||||
authenticatedUser: string | null;
|
||||
securityResults: any | null;
|
||||
}
|
||||
interface IAuthRequestEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
remoteAddr: string;
|
||||
}
|
||||
/**
|
||||
* Bridge between TypeScript and the Rust `mailer-bin` binary.
|
||||
*
|
||||
* Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC.
|
||||
* Singleton — access via `RustSecurityBridge.getInstance()`.
|
||||
*/
|
||||
export declare class RustSecurityBridge {
|
||||
private static instance;
|
||||
private bridge;
|
||||
private _running;
|
||||
private constructor();
|
||||
/** Get or create the singleton instance. */
|
||||
static getInstance(): RustSecurityBridge;
|
||||
/** Whether the Rust process is currently running and accepting commands. */
|
||||
get running(): boolean;
|
||||
/**
|
||||
* Spawn the Rust binary and wait for the ready signal.
|
||||
* @returns `true` if the binary started successfully, `false` otherwise.
|
||||
*/
|
||||
start(): Promise<boolean>;
|
||||
/** Kill the Rust process. */
|
||||
stop(): Promise<void>;
|
||||
/** Ping the Rust process. */
|
||||
ping(): Promise<boolean>;
|
||||
/** Get version information for all Rust crates. */
|
||||
getVersion(): Promise<IVersionInfo>;
|
||||
/** Validate an email address. */
|
||||
validateEmail(email: string): Promise<IValidationResult>;
|
||||
/** Detect bounce type from SMTP response / diagnostic code. */
|
||||
detectBounce(opts: {
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
}): Promise<IBounceDetection>;
|
||||
/** Scan email content for threats (phishing, spam, malware, etc.). */
|
||||
scanContent(opts: {
|
||||
subject?: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
attachmentNames?: string[];
|
||||
}): Promise<IContentScanResult>;
|
||||
/** Check IP reputation via DNSBL. */
|
||||
checkIpReputation(ip: string): Promise<IReputationResult>;
|
||||
/** Verify DKIM signatures on a raw email message. */
|
||||
verifyDkim(rawMessage: string): Promise<IDkimVerificationResult[]>;
|
||||
/** Sign an email with DKIM. */
|
||||
signDkim(opts: {
|
||||
rawMessage: string;
|
||||
domain: string;
|
||||
selector?: string;
|
||||
privateKey: string;
|
||||
}): Promise<{
|
||||
header: string;
|
||||
signedMessage: string;
|
||||
}>;
|
||||
/** Check SPF for a sender. */
|
||||
checkSpf(opts: {
|
||||
ip: string;
|
||||
heloDomain: string;
|
||||
hostname?: string;
|
||||
mailFrom: string;
|
||||
}): Promise<ISpfResult>;
|
||||
/**
|
||||
* Compound email security verification: DKIM + SPF + DMARC in one IPC call.
|
||||
*
|
||||
* This is the preferred method for inbound email verification — it avoids
|
||||
* 3 sequential round-trips and correctly passes raw mail-auth types internally.
|
||||
*/
|
||||
verifyEmail(opts: {
|
||||
rawMessage: string;
|
||||
ip: string;
|
||||
heloDomain: string;
|
||||
hostname?: string;
|
||||
mailFrom: string;
|
||||
}): Promise<IEmailSecurityResult>;
|
||||
/**
|
||||
* Start the Rust SMTP server.
|
||||
* The server will listen on the configured ports and emit events for
|
||||
* emailReceived and authRequest that must be handled by the caller.
|
||||
*/
|
||||
startSmtpServer(config: ISmtpServerConfig): Promise<boolean>;
|
||||
/** Stop the Rust SMTP server. */
|
||||
stopSmtpServer(): Promise<void>;
|
||||
/**
|
||||
* Send the result of email processing back to the Rust SMTP server.
|
||||
* This resolves a pending correlation-ID callback, allowing the Rust
|
||||
* server to send the SMTP response to the client.
|
||||
*/
|
||||
sendEmailProcessingResult(opts: {
|
||||
correlationId: string;
|
||||
accepted: boolean;
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Send the result of authentication validation back to the Rust SMTP server.
|
||||
*/
|
||||
sendAuthResult(opts: {
|
||||
correlationId: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}): Promise<void>;
|
||||
/** Update rate limit configuration at runtime. */
|
||||
configureRateLimits(config: IRateLimitConfig): Promise<void>;
|
||||
/**
|
||||
* Register a handler for emailReceived events from the Rust SMTP server.
|
||||
* These events fire when a complete email has been received and needs processing.
|
||||
*/
|
||||
onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
|
||||
/**
|
||||
* Register a handler for authRequest events from the Rust SMTP server.
|
||||
* The handler must call sendAuthResult() with the correlationId.
|
||||
*/
|
||||
onAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
|
||||
/** Remove an emailReceived event handler. */
|
||||
offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
|
||||
/** Remove an authRequest event handler. */
|
||||
offAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
|
||||
}
|
||||
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, ISmtpServerConfig, IRateLimitConfig, IEmailData, IEmailReceivedEvent, IAuthRequestEvent, };
|
||||
204
dist_ts/security/classes.rustsecuritybridge.js
Normal file
204
dist_ts/security/classes.rustsecuritybridge.js
Normal file
File diff suppressed because one or more lines are too long
1
dist_ts/security/index.d.ts
vendored
1
dist_ts/security/index.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
export { SecurityLogger, SecurityLogLevel, SecurityEventType, type ISecurityEvent } from './classes.securitylogger.js';
|
||||
export { IPReputationChecker, ReputationThreshold, IPType, type IReputationResult, type IIPReputationOptions } from './classes.ipreputationchecker.js';
|
||||
export { ContentScanner, ThreatCategory, type IScanResult, type IContentScannerOptions } from './classes.contentscanner.js';
|
||||
export { RustSecurityBridge, type IDkimVerificationResult, type ISpfResult, type IDmarcResult, type IEmailSecurityResult, type IValidationResult, type IBounceDetection, type IRustReputationResult, type IVersionInfo, } from './classes.rustsecuritybridge.js';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
export { IPReputationChecker, ReputationThreshold, IPType } from './classes.ipreputationchecker.js';
|
||||
export { ContentScanner, ThreatCategory } from './classes.contentscanner.js';
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9zZWN1cml0eS9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsY0FBYyxFQUNkLGdCQUFnQixFQUNoQixpQkFBaUIsRUFFbEIsTUFBTSw2QkFBNkIsQ0FBQztBQUVyQyxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLG1CQUFtQixFQUNuQixNQUFNLEVBR1AsTUFBTSxrQ0FBa0MsQ0FBQztBQUUxQyxPQUFPLEVBQ0wsY0FBYyxFQUNkLGNBQWMsRUFHZixNQUFNLDZCQUE2QixDQUFDIn0=
|
||||
export { RustSecurityBridge, } from './classes.rustsecuritybridge.js';
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9zZWN1cml0eS9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsY0FBYyxFQUNkLGdCQUFnQixFQUNoQixpQkFBaUIsRUFFbEIsTUFBTSw2QkFBNkIsQ0FBQztBQUVyQyxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLG1CQUFtQixFQUNuQixNQUFNLEVBR1AsTUFBTSxrQ0FBa0MsQ0FBQztBQUUxQyxPQUFPLEVBQ0wsY0FBYyxFQUNkLGNBQWMsRUFHZixNQUFNLDZCQUE2QixDQUFDO0FBRXJDLE9BQU8sRUFDTCxrQkFBa0IsR0FTbkIsTUFBTSxpQ0FBaUMsQ0FBQyJ9
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"@git.zone/tsrust": {
|
||||
"targets": ["linux_amd64", "linux_arm64"]
|
||||
"targets": [
|
||||
"linux_amd64",
|
||||
"linux_arm64"
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
package.json
25
package.json
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"name": "@serve.zone/mailer",
|
||||
"version": "1.3.1",
|
||||
"description": "Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure",
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "2.3.0",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mailer",
|
||||
"mta",
|
||||
"smtp",
|
||||
"email",
|
||||
"mail server",
|
||||
"mailgun",
|
||||
"mail transfer agent",
|
||||
"dkim",
|
||||
"spf",
|
||||
"dmarc",
|
||||
"dns",
|
||||
"cloudflare",
|
||||
"daemon service",
|
||||
"api",
|
||||
"serve.zone"
|
||||
"typescript",
|
||||
"rust"
|
||||
],
|
||||
"homepage": "https://code.foss.global/serve.zone/mailer",
|
||||
"homepage": "https://code.foss.global/push.rocks/smartmta",
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/serve.zone/mailer/issues"
|
||||
"url": "https://code.foss.global/push.rocks/smartmta/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://code.foss.global/serve.zone/mailer.git"
|
||||
"url": "git+https://code.foss.global/push.rocks/smartmta.git"
|
||||
},
|
||||
"author": "Serve Zone",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -66,6 +66,7 @@
|
||||
"@push.rocks/smartproxy": "^23.1.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@push.rocks/smartrust": "^1.1.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.0.4",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -74,6 +74,9 @@ importers:
|
||||
'@push.rocks/smartrule':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@push.rocks/smartrust':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@push.rocks/smartrx':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
|
||||
860
readme.md
860
readme.md
@@ -1,361 +1,637 @@
|
||||
# @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)
|
||||
[](package.json)
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @push.rocks/smartmta
|
||||
# or
|
||||
npm install @push.rocks/smartmta
|
||||
```
|
||||
|
||||
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required** — `smartmta` will not start without it.
|
||||
|
||||
## Overview
|
||||
|
||||
`@serve.zone/mailer` is a comprehensive mail server solution built with Deno, featuring:
|
||||
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
|
||||
|
||||
- **SMTP Server & Client** - Full-featured SMTP implementation for sending and receiving emails
|
||||
- **HTTP REST API** - Mailgun-compatible API for programmatic email management
|
||||
- **DNS Management** - Automatic DNS setup via Cloudflare API
|
||||
- **DKIM/SPF/DMARC** - Complete email authentication and security
|
||||
- **Daemon Service** - Systemd integration for production deployments
|
||||
- **CLI Interface** - Command-line management of all features
|
||||
### ⚡ What's Inside
|
||||
|
||||
## Architecture
|
||||
| Module | What It Does |
|
||||
|---|---|
|
||||
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
||||
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
|
||||
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||
| **SPF** | Full SPF record validation via Rust |
|
||||
| **DMARC** | Policy enforcement and verification |
|
||||
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
|
||||
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
|
||||
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
|
||||
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
|
||||
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
|
||||
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
||||
| **Template Engine** | Email templates with variable substitution |
|
||||
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
||||
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
|
||||
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Runtime**: Deno (compiles to standalone binaries)
|
||||
- **Language**: TypeScript
|
||||
- **Distribution**: npm (via binary wrappers)
|
||||
- **Service**: systemd daemon
|
||||
- **DNS**: Cloudflare API integration
|
||||
|
||||
### Project Structure
|
||||
### 🏗️ Architecture
|
||||
|
||||
```
|
||||
mailer/
|
||||
├── bin/ # npm binary wrappers
|
||||
├── scripts/ # Build scripts
|
||||
├── ts/ # TypeScript source
|
||||
│ ├── mail/ # Email implementation (ported from dcrouter)
|
||||
│ │ ├── core/ # Email classes, validation, templates
|
||||
│ │ ├── delivery/ # SMTP client/server, queues
|
||||
│ │ ├── routing/ # Email routing, domain config
|
||||
│ │ └── security/ # DKIM, SPF, DMARC
|
||||
│ ├── api/ # HTTP REST API (Mailgun-compatible)
|
||||
│ ├── dns/ # DNS management + Cloudflare
|
||||
│ ├── daemon/ # Systemd service management
|
||||
│ ├── config/ # Configuration system
|
||||
│ └── cli/ # Command-line interface
|
||||
├── test/ # Test suite
|
||||
├── deno.json # Deno configuration
|
||||
├── package.json # npm metadata
|
||||
└── mod.ts # Main entry point
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
├───────────┬───────────┬──────────────┬───────────────────────┤
|
||||
│ Email │ Security │ Delivery │ Configuration │
|
||||
│ Router │ Stack │ System │ │
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
||||
│ │ └───────┘ │ │ │
|
||||
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Rust Acceleration Layer │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Installation
|
||||
**Data flow for inbound mail:**
|
||||
|
||||
### Via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/mailer
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://code.foss.global/serve.zone/mailer
|
||||
cd mailer
|
||||
deno task compile
|
||||
```
|
||||
1. Rust SMTP server accepts the connection and handles the SMTP protocol
|
||||
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
|
||||
3. TypeScript processes the email (routing, scanning, delivery decisions)
|
||||
4. TypeScript sends the processing result back to Rust via IPC
|
||||
5. Rust sends the final SMTP response to the client
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Commands
|
||||
### 🚀 Setting Up the Email Server
|
||||
|
||||
#### Service Management
|
||||
|
||||
```bash
|
||||
# Start the mailer daemon
|
||||
sudo mailer service start
|
||||
|
||||
# Stop the daemon
|
||||
sudo mailer service stop
|
||||
|
||||
# Restart the daemon
|
||||
sudo mailer service restart
|
||||
|
||||
# Check status
|
||||
mailer service status
|
||||
|
||||
# Enable systemd service
|
||||
sudo mailer service enable
|
||||
|
||||
# Disable systemd service
|
||||
sudo mailer service disable
|
||||
```
|
||||
|
||||
#### Domain Management
|
||||
|
||||
```bash
|
||||
# Add a domain
|
||||
mailer domain add example.com
|
||||
|
||||
# Remove a domain
|
||||
mailer domain remove example.com
|
||||
|
||||
# List all domains
|
||||
mailer domain list
|
||||
```
|
||||
|
||||
#### DNS Management
|
||||
|
||||
```bash
|
||||
# Auto-configure DNS via Cloudflare
|
||||
mailer dns setup example.com
|
||||
|
||||
# Validate DNS configuration
|
||||
mailer dns validate example.com
|
||||
|
||||
# Show required DNS records
|
||||
mailer dns show example.com
|
||||
```
|
||||
|
||||
#### Sending Email
|
||||
|
||||
```bash
|
||||
# Send email via CLI
|
||||
mailer send \\
|
||||
--from sender@example.com \\
|
||||
--to recipient@example.com \\
|
||||
--subject "Hello" \\
|
||||
--text "World"
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
```bash
|
||||
# Show current configuration
|
||||
mailer config show
|
||||
|
||||
# Set configuration value
|
||||
mailer config set smtpPort 25
|
||||
mailer config set apiPort 8080
|
||||
mailer config set hostname mail.example.com
|
||||
```
|
||||
|
||||
### HTTP API
|
||||
|
||||
The mailer provides a Mailgun-compatible REST API:
|
||||
|
||||
#### Send Email
|
||||
|
||||
```bash
|
||||
POST /v1/messages
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"from": "sender@example.com",
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Hello",
|
||||
"text": "World",
|
||||
"html": "<p>World</p>"
|
||||
}
|
||||
```
|
||||
|
||||
#### List Domains
|
||||
|
||||
```bash
|
||||
GET /v1/domains
|
||||
```
|
||||
|
||||
#### Manage SMTP Credentials
|
||||
|
||||
```bash
|
||||
GET /v1/domains/:domain/credentials
|
||||
POST /v1/domains/:domain/credentials
|
||||
DELETE /v1/domains/:domain/credentials/:id
|
||||
```
|
||||
|
||||
#### Email Events
|
||||
|
||||
```bash
|
||||
GET /v1/events
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
|
||||
|
||||
```typescript
|
||||
import { Email, SmtpClient } from '@serve.zone/mailer';
|
||||
import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
|
||||
// Create an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Hello from Mailer',
|
||||
text: 'This is a test email',
|
||||
html: '<p>This is a test email</p>',
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
|
||||
// Multi-domain configuration
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
dkim: {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: true,
|
||||
rotationInterval: 90,
|
||||
},
|
||||
rateLimits: {
|
||||
outbound: { messagesPerMinute: 100 },
|
||||
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Routing rules (evaluated by priority, highest first)
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all-forward',
|
||||
priority: 10,
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'internal-mail.example.com',
|
||||
port: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reject-spam-senders',
|
||||
priority: 100,
|
||||
match: {
|
||||
senders: '*@spamdomain.com',
|
||||
},
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: {
|
||||
code: 550,
|
||||
message: 'Sender rejected by policy',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Authentication settings for the SMTP server
|
||||
auth: {
|
||||
required: false,
|
||||
methods: ['PLAIN', 'LOGIN'],
|
||||
users: [{ username: 'outbound', password: 'secret' }],
|
||||
},
|
||||
|
||||
// TLS certificates
|
||||
tls: {
|
||||
certPath: '/etc/ssl/mail.crt',
|
||||
keyPath: '/etc/ssl/mail.key',
|
||||
},
|
||||
|
||||
maxMessageSize: 25 * 1024 * 1024, // 25 MB
|
||||
maxClients: 500,
|
||||
});
|
||||
|
||||
// Send via SMTP
|
||||
const client = new SmtpClient({
|
||||
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
|
||||
await emailServer.start();
|
||||
```
|
||||
|
||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||
|
||||
### 📧 Sending Emails with the SMTP Client
|
||||
|
||||
Create and send emails using the built-in SMTP client with connection pooling:
|
||||
|
||||
```typescript
|
||||
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||
|
||||
// Create a client with connection pooling
|
||||
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: true,
|
||||
secure: false, // will upgrade via STARTTLS
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
auth: {
|
||||
user: 'username',
|
||||
pass: 'password',
|
||||
user: 'sender@example.com',
|
||||
pass: 'your-password',
|
||||
},
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
// Build an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
cc: ['cc@example.com'],
|
||||
subject: 'Hello from smartmta!',
|
||||
text: 'Plain text body',
|
||||
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||
priority: 'high',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.pdf',
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Send it
|
||||
const result = await client.sendMail(email);
|
||||
console.log(`Message sent: ${result.messageId}`);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Additional client factories are available:
|
||||
|
||||
Configuration is stored in `~/.mailer/config.json`:
|
||||
```typescript
|
||||
// Pooled client for high-throughput scenarios
|
||||
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||
|
||||
```json
|
||||
{
|
||||
"domains": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"dnsMode": "external-dns",
|
||||
"cloudflare": {
|
||||
"apiToken": "your-cloudflare-token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"apiKeys": ["api-key-1", "api-key-2"],
|
||||
"smtpPort": 25,
|
||||
"apiPort": 8080,
|
||||
"hostname": "mail.example.com"
|
||||
// Optimized for bulk sending
|
||||
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
|
||||
|
||||
// Optimized for transactional emails
|
||||
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
|
||||
```
|
||||
|
||||
### 🔑 DKIM Signing
|
||||
|
||||
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
|
||||
|
||||
```typescript
|
||||
import { DKIMCreator } from '@push.rocks/smartmta';
|
||||
|
||||
const dkimCreator = new DKIMCreator('/path/to/keys');
|
||||
|
||||
// Auto-generate keys if they don't exist
|
||||
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
||||
|
||||
// Get the DNS record you need to publish
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
|
||||
console.log(dnsRecord);
|
||||
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
|
||||
|
||||
// Check if keys need rotation
|
||||
const needsRotation = await dkimCreator.needsRotation('example.com', 'default', 90);
|
||||
if (needsRotation) {
|
||||
const newSelector = await dkimCreator.rotateDkimKeys('example.com', 'default', 2048);
|
||||
console.log(`Rotated to selector: ${newSelector}`);
|
||||
}
|
||||
```
|
||||
|
||||
## DNS Setup
|
||||
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the Rust security bridge's `signDkim()` method for maximum performance.
|
||||
|
||||
The mailer requires the following DNS records for each domain:
|
||||
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
||||
|
||||
### MX Record
|
||||
```
|
||||
Type: MX
|
||||
Name: @
|
||||
Value: mail.example.com
|
||||
Priority: 10
|
||||
TTL: 3600
|
||||
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
|
||||
|
||||
```typescript
|
||||
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||
|
||||
// SPF verification — first arg is an Email object
|
||||
const spfVerifier = new SpfVerifier();
|
||||
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||
// domain: string, ip: string }
|
||||
|
||||
// DKIM verification — takes raw email content
|
||||
const dkimVerifier = new DKIMVerifier();
|
||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||
|
||||
// DMARC verification — first arg is an Email object
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||
```
|
||||
|
||||
### A Record
|
||||
```
|
||||
Type: A
|
||||
Name: mail
|
||||
Value: <your-server-ip>
|
||||
TTL: 3600
|
||||
### 🔀 Email Routing
|
||||
|
||||
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
|
||||
|
||||
```typescript
|
||||
import { EmailRouter } from '@push.rocks/smartmta';
|
||||
|
||||
const router = new EmailRouter([
|
||||
{
|
||||
name: 'admin-mail',
|
||||
priority: 100,
|
||||
match: {
|
||||
recipients: 'admin@example.com',
|
||||
authenticated: true,
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'external-forward',
|
||||
priority: 50,
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'backend-mail.internal',
|
||||
port: 25,
|
||||
preserveHeaders: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'process-with-scanning',
|
||||
priority: 10,
|
||||
match: {
|
||||
recipients: '*@*',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
process: {
|
||||
scan: true,
|
||||
dkim: true,
|
||||
queue: 'normal',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Evaluate routes against an email context
|
||||
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
```
|
||||
|
||||
### SPF Record
|
||||
```
|
||||
Type: TXT
|
||||
Name: @
|
||||
Value: v=spf1 mx ip4:<your-server-ip> ~all
|
||||
TTL: 3600
|
||||
**Match criteria available:**
|
||||
|
||||
| Criterion | Description |
|
||||
|---|---|
|
||||
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
|
||||
| `senders` | Glob patterns for sender addresses |
|
||||
| `clientIp` | IP addresses or CIDR ranges |
|
||||
| `authenticated` | Require authentication status |
|
||||
| `headers` | Match specific headers (string or RegExp) |
|
||||
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
|
||||
| `subject` | Subject line pattern (string or RegExp) |
|
||||
| `hasAttachments` | Filter by attachment presence |
|
||||
|
||||
### 🔍 Content Scanning
|
||||
|
||||
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
|
||||
|
||||
```typescript
|
||||
import { ContentScanner } from '@push.rocks/smartmta';
|
||||
|
||||
const scanner = new ContentScanner({
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: true,
|
||||
blockExecutables: true,
|
||||
blockMacros: true,
|
||||
minThreatScore: 30,
|
||||
highThreatScore: 70,
|
||||
customRules: [
|
||||
{
|
||||
pattern: /bitcoin.*wallet/i,
|
||||
type: 'scam',
|
||||
score: 80,
|
||||
description: 'Cryptocurrency scam pattern',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||
```
|
||||
|
||||
### DKIM Record
|
||||
```
|
||||
Type: TXT
|
||||
Name: default._domainkey
|
||||
Value: <dkim-public-key>
|
||||
TTL: 3600
|
||||
### 🌐 IP Reputation Checking
|
||||
|
||||
Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
|
||||
|
||||
```typescript
|
||||
import { IPReputationChecker } from '@push.rocks/smartmta';
|
||||
|
||||
const ipChecker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: true,
|
||||
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
|
||||
const reputation = await ipChecker.checkReputation('192.168.1.1');
|
||||
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
||||
```
|
||||
|
||||
### DMARC Record
|
||||
```
|
||||
Type: TXT
|
||||
Name: _dmarc
|
||||
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com
|
||||
TTL: 3600
|
||||
### ⏱️ Rate Limiting
|
||||
|
||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||
|
||||
```typescript
|
||||
import { Delivery } from '@push.rocks/smartmta';
|
||||
const { UnifiedRateLimiter } = Delivery;
|
||||
|
||||
const rateLimiter = new UnifiedRateLimiter({
|
||||
global: {
|
||||
maxMessagesPerMinute: 1000,
|
||||
maxRecipientsPerMessage: 500,
|
||||
maxConnectionsPerIP: 20,
|
||||
maxErrorsPerIP: 10,
|
||||
maxAuthFailuresPerIP: 5,
|
||||
blockDuration: 600000, // 10 minutes
|
||||
},
|
||||
domains: {
|
||||
'example.com': {
|
||||
maxMessagesPerMinute: 100,
|
||||
maxRecipientsPerMessage: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check before sending
|
||||
const allowed = rateLimiter.checkMessageLimit(
|
||||
'sender@example.com',
|
||||
'192.168.1.1',
|
||||
recipientCount,
|
||||
undefined,
|
||||
'example.com'
|
||||
);
|
||||
|
||||
if (!allowed.allowed) {
|
||||
console.log(`Rate limited: ${allowed.reason}`);
|
||||
}
|
||||
```
|
||||
|
||||
Use `mailer dns setup <domain>` to automatically configure these via Cloudflare.
|
||||
### 📬 Bounce Management
|
||||
|
||||
## Development
|
||||
Automatic bounce detection (via Rust), classification, and suppression tracking:
|
||||
|
||||
### Prerequisites
|
||||
```typescript
|
||||
import { Core } from '@push.rocks/smartmta';
|
||||
const { BounceManager } = Core;
|
||||
|
||||
- Deno 1.40+
|
||||
- Node.js 14+ (for npm distribution)
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
### Build
|
||||
// Process an SMTP failure
|
||||
const bounce = await bounceManager.processSmtpFailure(
|
||||
'recipient@example.com',
|
||||
'550 5.1.1 User unknown',
|
||||
{ originalEmailId: 'msg-123' }
|
||||
);
|
||||
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
||||
|
||||
```bash
|
||||
# Compile for all platforms
|
||||
deno task compile
|
||||
// Check if an address is suppressed due to bounces
|
||||
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||
|
||||
# Run in development mode
|
||||
deno task dev
|
||||
|
||||
# Run tests
|
||||
deno task test
|
||||
|
||||
# Format code
|
||||
deno task fmt
|
||||
|
||||
# Lint code
|
||||
deno task lint
|
||||
// Manually manage the suppression list
|
||||
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||
```
|
||||
|
||||
### Ported Components
|
||||
### 📝 Email Templates
|
||||
|
||||
The mail implementation is ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) and adapted for Deno:
|
||||
Template engine with variable substitution for transactional and notification emails:
|
||||
|
||||
- ✅ Email core (Email, EmailValidator, BounceManager, TemplateManager)
|
||||
- ✅ SMTP Server (with TLS support)
|
||||
- ✅ SMTP Client (with connection pooling)
|
||||
- ✅ Email routing and domain management
|
||||
- ✅ DKIM signing and verification
|
||||
- ✅ SPF and DMARC validation
|
||||
- ✅ Delivery queues and rate limiting
|
||||
```typescript
|
||||
import { Core } from '@push.rocks/smartmta';
|
||||
const { TemplateManager } = Core;
|
||||
|
||||
## Roadmap
|
||||
const templates = new TemplateManager({
|
||||
from: 'noreply@example.com',
|
||||
footerHtml: '<p>© 2026 Example Corp</p>',
|
||||
});
|
||||
|
||||
### Phase 1 - Core Functionality (Current)
|
||||
- [x] Project structure and build system
|
||||
- [x] Port mail implementation from dcrouter
|
||||
- [x] CLI interface
|
||||
- [x] Configuration management
|
||||
- [x] DNS management basics
|
||||
- [ ] Cloudflare DNS integration
|
||||
- [ ] HTTP REST API implementation
|
||||
- [ ] Systemd service integration
|
||||
// Register a template
|
||||
templates.registerTemplate({
|
||||
id: 'welcome',
|
||||
name: 'Welcome Email',
|
||||
description: 'Sent to new users',
|
||||
from: 'welcome@example.com',
|
||||
subject: 'Welcome, {{name}}!',
|
||||
bodyHtml: '<h1>Welcome, {{name}}!</h1><p>Your account is ready.</p>',
|
||||
bodyText: 'Welcome, {{name}}! Your account is ready.',
|
||||
category: 'transactional',
|
||||
});
|
||||
|
||||
### Phase 2 - Production Ready
|
||||
- [ ] Comprehensive testing
|
||||
- [ ] Documentation
|
||||
- [ ] Performance optimization
|
||||
- [ ] Security hardening
|
||||
- [ ] Monitoring and logging
|
||||
// Create an Email object from the template
|
||||
const email = await templates.createEmail('welcome', {
|
||||
to: 'newuser@example.com',
|
||||
variables: { name: 'Alice' },
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 3 - Advanced Features
|
||||
- [ ] Webhook support
|
||||
- [ ] Email templates
|
||||
- [ ] Analytics and reporting
|
||||
- [ ] Multi-tenancy
|
||||
- [ ] Load balancing
|
||||
### 🌍 DNS Management
|
||||
|
||||
## License
|
||||
DNS record management for email authentication is handled automatically by `UnifiedEmailServer`. When the server starts, it ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains via the Cloudflare API:
|
||||
|
||||
MIT © Serve Zone
|
||||
```typescript
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
hostname: 'mail.example.com',
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
},
|
||||
],
|
||||
// ... other config
|
||||
});
|
||||
|
||||
## Contributing
|
||||
// DNS records are set up automatically on start:
|
||||
// - MX records pointing to your mail server
|
||||
// - SPF TXT records authorizing your server IP
|
||||
// - DKIM TXT records with public keys from DKIMCreator
|
||||
// - DMARC TXT records with your policy
|
||||
await emailServer.start();
|
||||
```
|
||||
|
||||
Contributions are welcome! Please see our [contributing guidelines](https://code.foss.global/serve.zone/mailer/contributing).
|
||||
### 🦀 RustSecurityBridge
|
||||
|
||||
## Support
|
||||
The `RustSecurityBridge` is the singleton that manages the Rust binary process. It handles security verification, content scanning, bounce detection, and the SMTP server lifecycle — all via `@push.rocks/smartrust` IPC:
|
||||
|
||||
- Documentation: https://code.foss.global/serve.zone/mailer
|
||||
- Issues: https://code.foss.global/serve.zone/mailer/issues
|
||||
- Email: support@serve.zone
|
||||
```typescript
|
||||
import { RustSecurityBridge } from '@push.rocks/smartmta';
|
||||
|
||||
## Acknowledgments
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
await bridge.start();
|
||||
|
||||
- Mail implementation ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter)
|
||||
- Inspired by [Mailgun](https://www.mailgun.com/) API design
|
||||
- Built with [Deno](https://deno.land/)
|
||||
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||
const securityResult = await bridge.verifyEmail({
|
||||
rawMessage: rawEmailString,
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||
|
||||
// Individual security operations
|
||||
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||
const spfResult = await bridge.checkSpf({
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
|
||||
|
||||
// DKIM signing
|
||||
const signed = await bridge.signDkim({
|
||||
email: rawEmailString,
|
||||
domain: 'example.com',
|
||||
selector: 'default',
|
||||
privateKeyPem: privateKey,
|
||||
});
|
||||
|
||||
// Content scanning
|
||||
const scanResult = await bridge.scanContent({
|
||||
subject: 'Win a free iPhone!!!',
|
||||
body: '<a href="http://phishing.example.com">Click here</a>',
|
||||
from: 'scammer@evil.com',
|
||||
});
|
||||
|
||||
// Bounce detection
|
||||
const bounceResult = await bridge.detectBounce({
|
||||
subject: 'Delivery Status Notification (Failure)',
|
||||
body: '550 5.1.1 User unknown',
|
||||
from: 'mailer-daemon@example.com',
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
```
|
||||
|
||||
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
|
||||
|
||||
## 🦀 Rust Acceleration Layer
|
||||
|
||||
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
|
||||
|
||||
| Crate | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
|
||||
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
|
||||
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting |
|
||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
|
||||
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
|
||||
|
||||
### What Runs in Rust
|
||||
|
||||
| Operation | Runs In | Why |
|
||||
|---|---|---|
|
||||
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
|
||||
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
|
||||
| SPF validation | Rust | DNS lookups with async resolver |
|
||||
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
|
||||
| IP reputation / DNSBL | Rust | Parallel DNS queries |
|
||||
| Content scanning (text patterns) | Rust | Regex engine performance |
|
||||
| Bounce detection (pattern matching) | Rust | Regex engine performance |
|
||||
| Email validation & MIME building | Rust | Parsing performance |
|
||||
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
|
||||
| Email routing & orchestration | TypeScript | Business logic, flexibility |
|
||||
| Delivery queue & retry | TypeScript | State management, persistence |
|
||||
| Template rendering | TypeScript | String interpolation |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
smartmta/
|
||||
├── ts/ # TypeScript source
|
||||
│ ├── mail/
|
||||
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
||||
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
|
||||
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
|
||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||
├── rust/ # Rust workspace
|
||||
│ └── crates/
|
||||
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
|
||||
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
|
||||
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
|
||||
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
|
||||
│ └── mailer-napi/ # N-API addon (planned)
|
||||
├── test/ # Test suite
|
||||
├── dist_ts/ # Compiled TypeScript output
|
||||
└── dist_rust/ # Compiled Rust binaries
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
212
readme.plan.md
212
readme.plan.md
@@ -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 |
|
||||
|
||||
553
rust/Cargo.lock
generated
553
rust/Cargo.lock
generated
@@ -41,6 +41,56 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -83,12 +133,6 @@ dependencies = [
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -116,12 +160,6 @@ version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -130,21 +168,11 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -171,7 +199,7 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
@@ -185,6 +213,46 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
@@ -194,6 +262,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
@@ -417,6 +491,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -486,12 +561,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "0.4.3"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-targets 0.48.5",
|
||||
"rustix",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -512,11 +587,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -531,40 +604,23 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.24.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"enum-as-inner",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"thiserror 1.0.69",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.25.2"
|
||||
@@ -581,9 +637,9 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.2",
|
||||
"rand",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -591,26 +647,34 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.24.4"
|
||||
name = "hickory-proto"
|
||||
version = "0.26.0-alpha.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"
|
||||
checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"enum-as-inner",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hickory-proto 0.24.4",
|
||||
"ipconfig",
|
||||
"lru-cache",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"resolv-conf",
|
||||
"rustls 0.21.12",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -626,14 +690,37 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"rand",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.26.0-alpha.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbab5e26a7f82341145ba1fbd1f1858d0490624fcc46270db2d3c4a101f763f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"hickory-proto 0.26.0-alpha.1",
|
||||
"ipconfig",
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"resolv-conf",
|
||||
"rustls",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -782,6 +869,12 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -808,6 +901,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
@@ -825,10 +924,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -852,51 +951,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-cache"
|
||||
version = "0.1.2"
|
||||
name = "lzma-rust2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
|
||||
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-sys"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-auth"
|
||||
version = "0.4.3"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bd9d657de66a3d5ac360c3eab8c9f5cac2565f2b97cc032d5de4c900ef470de"
|
||||
checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"flate2",
|
||||
"hickory-resolver 0.24.4",
|
||||
"lru-cache",
|
||||
"hashify",
|
||||
"hickory-resolver 0.26.0-alpha.1",
|
||||
"mail-builder",
|
||||
"mail-parser",
|
||||
"parking_lot",
|
||||
"quick-xml",
|
||||
"quick_cache",
|
||||
"ring",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"zip",
|
||||
@@ -904,26 +983,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mail-builder"
|
||||
version = "0.3.2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f5871d5270ed80f2ee750b95600c8d69b05f8653ad3be913b2ad2e924fefcb"
|
||||
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-parser"
|
||||
version = "0.9.4"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93c3b9e5d8b17faf573330bbc43b37d6e918c0a3bf8a88e7d0a220ebc84af9fc"
|
||||
checksum = "dcf4390741c4e6fa330bdeccdfb580815dbb462952de91838b723357985119a3"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hashify",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailer-bin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
@@ -937,12 +1020,15 @@ dependencies = [
|
||||
name = "mailer-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"mailparse",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -964,11 +1050,18 @@ dependencies = [
|
||||
name = "mailer-security"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hickory-resolver 0.25.2",
|
||||
"ipnet",
|
||||
"mail-auth",
|
||||
"mailer-core",
|
||||
"psl",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -976,22 +1069,31 @@ dependencies = [
|
||||
name = "mailer-smtp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailparse",
|
||||
"regex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailparse"
|
||||
version = "0.15.0"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
|
||||
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
|
||||
dependencies = [
|
||||
"charset",
|
||||
"data-encoding",
|
||||
@@ -1118,6 +1220,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1196,6 +1304,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1215,14 +1329,41 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.32.0"
|
||||
name = "psl"
|
||||
version = "2.1.188"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
|
||||
checksum = "b033d75bca9da25cfdcd9528de22ed7870d1695b9e1c3ce55b7127a4a2b16fac"
|
||||
dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick_cache"
|
||||
version = "0.6.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -1244,35 +1385,14 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1282,16 +1402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1362,15 +1473,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1382,21 +1494,13 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
@@ -1415,16 +1519,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -1449,16 +1543,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -1519,6 +1603,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -1579,6 +1674,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1613,33 +1714,13 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1725,23 +1806,13 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.36",
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -1818,6 +1889,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.20.0"
|
||||
@@ -2151,15 +2228,6 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||
dependencies = [
|
||||
"lzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -2279,34 +2347,37 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"deflate64",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"lzma-rs",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"xz2",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.20"
|
||||
|
||||
@@ -17,3 +17,5 @@ tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
485
rust/crates/mailer-core/src/bounce.rs
Normal file
485
rust/crates/mailer-core/src/bounce.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
411
rust/crates/mailer-core/src/email.rs
Normal file
411
rust/crates/mailer-core/src/email.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
31
rust/crates/mailer-core/src/error.rs
Normal file
31
rust/crates/mailer-core/src/error.rs
Normal 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>;
|
||||
@@ -1,9 +1,25 @@
|
||||
//! 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;
|
||||
|
||||
/// Placeholder for email address validation and data types.
|
||||
/// Crate version.
|
||||
pub fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
377
rust/crates/mailer-core/src/mime.rs
Normal file
377
rust/crates/mailer-core/src/mime.rs
Normal 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("ed_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("ed_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("ed_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("ed_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("ed_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("ed_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);
|
||||
}
|
||||
}
|
||||
178
rust/crates/mailer-core/src/validation.rs
Normal file
178
rust/crates/mailer-core/src/validation.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ hickory-resolver.workspace = true
|
||||
ipnet.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
psl.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
//! Content scanning for email threat detection.
|
||||
//!
|
||||
//! Provides pattern-based scanning of email subjects, text bodies, HTML bodies,
|
||||
//! and attachment filenames for phishing, spam, malware, suspicious links,
|
||||
//! script injection, and sensitive data patterns.
|
||||
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentScanResult {
|
||||
pub threat_score: u32,
|
||||
pub threat_type: Option<String>,
|
||||
pub threat_details: Option<String>,
|
||||
pub scanned_elements: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern definitions (compiled once via LazyLock)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static PHISHING_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?i)(?:verify|confirm|update|login).*(?:account|password|details)").unwrap(),
|
||||
Regex::new(r"(?i)urgent.*(?:action|attention|required)").unwrap(),
|
||||
Regex::new(r"(?i)(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)").unwrap(),
|
||||
Regex::new(r"(?i)your.*(?:account).*(?:suspended|compromised|locked)").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:password reset|security alert|security notice)\b").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static SPAM_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?i)\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:earn from home|make money fast|earn \$\d{3,}/day)\b").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static MALWARE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?i)(?:attached file|see attachment).*(?:invoice|receipt|statement|document)").unwrap(),
|
||||
Regex::new(r"(?i)open.*(?:the attached|this attachment)").unwrap(),
|
||||
Regex::new(r"(?i)(?:enable|allow).*(?:macros|content|editing)").unwrap(),
|
||||
Regex::new(r"(?i)download.*(?:attachment|file|document)").unwrap(),
|
||||
Regex::new(r"(?i)\b(?:ransomware protection|virus alert|malware detected)\b").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static SUSPICIOUS_LINK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?i)https?://bit\.ly/").unwrap(),
|
||||
Regex::new(r"(?i)https?://goo\.gl/").unwrap(),
|
||||
Regex::new(r"(?i)https?://t\.co/").unwrap(),
|
||||
Regex::new(r"(?i)https?://tinyurl\.com/").unwrap(),
|
||||
Regex::new(r"(?i)https?://(?:\d{1,3}\.){3}\d{1,3}").unwrap(),
|
||||
Regex::new(r"(?i)https?://.*\.(?:xyz|top|club|gq|cf)/").unwrap(),
|
||||
Regex::new(r"(?i)(?:login|account|signin|auth).*\.(?:xyz|top|club|gq|cf|tk|ml|ga|pw|ws|buzz)\b").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static SCRIPT_INJECTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?is)<script.*>.*</script>").unwrap(),
|
||||
Regex::new(r"(?i)javascript:").unwrap(),
|
||||
Regex::new(r#"(?i)on(?:click|load|mouse|error|focus|blur)=".*""#).unwrap(),
|
||||
Regex::new(r"(?i)document\.(?:cookie|write|location)").unwrap(),
|
||||
Regex::new(r"(?i)eval\s*\(").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static SENSITIVE_DATA_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Regex::new(r"\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b").unwrap(),
|
||||
Regex::new(r"\b\d{13,16}\b").unwrap(),
|
||||
Regex::new(r"\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
/// Link extraction from HTML href attributes.
|
||||
static HREF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)href=["'](https?://[^"']+)["']"#).unwrap()
|
||||
});
|
||||
|
||||
/// Executable file extensions that are considered dangerous.
|
||||
static EXECUTABLE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
vec![
|
||||
".exe", ".dll", ".bat", ".cmd", ".msi", ".vbs", ".ps1",
|
||||
".sh", ".jar", ".py", ".com", ".scr", ".pif", ".hta", ".cpl",
|
||||
".reg", ".vba", ".lnk", ".wsf", ".msp", ".mst",
|
||||
]
|
||||
});
|
||||
|
||||
/// Document extensions that may contain macros.
|
||||
static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
vec![
|
||||
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm",
|
||||
".dotm", ".xlsb", ".ppam", ".potm",
|
||||
]
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Strip HTML tags and decode common entities to produce plain text.
|
||||
fn extract_text_from_html(html: &str) -> String {
|
||||
// Remove style and script blocks first
|
||||
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
||||
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
|
||||
let no_tags = Regex::new(r"<[^>]+>").unwrap();
|
||||
|
||||
let text = no_style.replace_all(html, " ");
|
||||
let text = no_script.replace_all(&text, " ");
|
||||
let text = no_tags.replace_all(&text, " ");
|
||||
|
||||
text.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
152
rust/crates/mailer-security/src/dkim.rs
Normal file
152
rust/crates/mailer-security/src/dkim.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use mail_auth::common::crypto::{RsaKey, Sha256};
|
||||
use mail_auth::common::headers::HeaderWriter;
|
||||
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Result, SecurityError};
|
||||
|
||||
/// Result of DKIM verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DkimVerificationResult {
|
||||
/// Whether the DKIM signature is valid.
|
||||
pub is_valid: bool,
|
||||
/// The signing domain (d= tag).
|
||||
pub domain: Option<String>,
|
||||
/// The selector (s= tag).
|
||||
pub selector: Option<String>,
|
||||
/// Result status: "pass", "fail", "permerror", "temperror", "none".
|
||||
pub status: String,
|
||||
/// Human-readable details.
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
/// Convert raw `mail-auth` DKIM outputs to our serializable results.
|
||||
///
|
||||
/// This is used internally by `verify_dkim` and by the compound `verify_email_security`.
|
||||
pub fn dkim_outputs_to_results(dkim_outputs: &[DkimOutput<'_>]) -> Vec<DkimVerificationResult> {
|
||||
if dkim_outputs.is_empty() {
|
||||
return vec![DkimVerificationResult {
|
||||
is_valid: false,
|
||||
domain: None,
|
||||
selector: None,
|
||||
status: "none".to_string(),
|
||||
details: Some("No DKIM signatures found".to_string()),
|
||||
}];
|
||||
}
|
||||
|
||||
dkim_outputs
|
||||
.iter()
|
||||
.map(|output| {
|
||||
let (is_valid, status, details) = match output.result() {
|
||||
DkimResult::Pass => (true, "pass", None),
|
||||
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
|
||||
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
|
||||
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
|
||||
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
|
||||
DkimResult::None => (false, "none", None),
|
||||
};
|
||||
|
||||
let (domain, selector) = output
|
||||
.signature()
|
||||
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
DkimVerificationResult {
|
||||
is_valid,
|
||||
domain,
|
||||
selector,
|
||||
status: status.to_string(),
|
||||
details,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verify DKIM signatures on a raw email message.
|
||||
///
|
||||
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
|
||||
/// including DNS lookups for the public key.
|
||||
pub async fn verify_dkim(
|
||||
raw_message: &[u8],
|
||||
authenticator: &MessageAuthenticator,
|
||||
) -> Result<Vec<DkimVerificationResult>> {
|
||||
let message = AuthenticatedMessage::parse(raw_message)
|
||||
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?;
|
||||
|
||||
let dkim_outputs = authenticator.verify_dkim(&message).await;
|
||||
Ok(dkim_outputs_to_results(&dkim_outputs))
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM (RSA-SHA256).
|
||||
///
|
||||
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||
/// * `domain` - The signing domain (d= tag)
|
||||
/// * `selector` - The DKIM selector (s= tag)
|
||||
/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8)
|
||||
///
|
||||
/// Returns the DKIM-Signature header string to prepend to the message.
|
||||
pub fn sign_dkim(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pem: &str,
|
||||
) -> Result<String> {
|
||||
// Try PKCS#1 PEM first, then PKCS#8
|
||||
let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.map(PrivateKeyDer::Pkcs1)
|
||||
.or_else(|_| {
|
||||
// Try PKCS#8
|
||||
rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.map(PrivateKeyDer::Pkcs8)
|
||||
})
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?;
|
||||
|
||||
let rsa_key = RsaKey::<Sha256>::from_key_der(key_der)
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?;
|
||||
|
||||
let signature = DkimSigner::from_key(rsa_key)
|
||||
.domain(domain)
|
||||
.selector(selector)
|
||||
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
|
||||
.header_canonicalization(Canonicalization::Relaxed)
|
||||
.body_canonicalization(Canonicalization::Relaxed)
|
||||
.sign(raw_message)
|
||||
.map_err(|e| SecurityError::Dkim(format!("DKIM signing failed: {}", e)))?;
|
||||
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||
///
|
||||
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
||||
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
// Extract the base64 content from PEM
|
||||
let key_b64: String = public_key_pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dkim_dns_record_value() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||
let record = dkim_dns_record_value(pem);
|
||||
assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p="));
|
||||
assert!(record.contains("MIIBIjANBg=="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_invalid_key() {
|
||||
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
127
rust/crates/mailer-security/src/dmarc.rs
Normal file
127
rust/crates/mailer-security/src/dmarc.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
rust/crates/mailer-security/src/error.rs
Normal file
31
rust/crates/mailer-security/src/error.rs
Normal 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>;
|
||||
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use hickory_resolver::TokioResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
|
||||
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
|
||||
"zen.spamhaus.org",
|
||||
"bl.spamcop.net",
|
||||
"b.barracudacentral.org",
|
||||
"spam.dnsbl.sorbs.net",
|
||||
"dnsbl.sorbs.net",
|
||||
"cbl.abuseat.org",
|
||||
"xbl.spamhaus.org",
|
||||
"pbl.spamhaus.org",
|
||||
"dnsbl-1.uceprotect.net",
|
||||
"psbl.surriel.com",
|
||||
];
|
||||
|
||||
/// Result of a DNSBL check.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DnsblResult {
|
||||
/// IP address that was checked.
|
||||
pub ip: String,
|
||||
/// Number of DNSBL servers that list this IP.
|
||||
pub listed_count: usize,
|
||||
/// Names of DNSBL servers that list this IP.
|
||||
pub listed_on: Vec<String>,
|
||||
/// Total number of DNSBL servers checked.
|
||||
pub total_checked: usize,
|
||||
}
|
||||
|
||||
/// Result of a full IP reputation check.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReputationResult {
|
||||
/// Reputation score: 0 (worst) to 100 (best).
|
||||
pub score: u8,
|
||||
/// Whether the IP is considered spam source.
|
||||
pub is_spam: bool,
|
||||
/// IP address that was checked.
|
||||
pub ip: String,
|
||||
/// DNSBL results.
|
||||
pub dnsbl: DnsblResult,
|
||||
/// Heuristic IP type classification.
|
||||
pub ip_type: IpType,
|
||||
}
|
||||
|
||||
/// Heuristic IP type classification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IpType {
|
||||
Residential,
|
||||
Datacenter,
|
||||
Proxy,
|
||||
Tor,
|
||||
Vpn,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Risk level based on reputation score.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskLevel {
|
||||
/// Score < 20
|
||||
High,
|
||||
/// Score 20-49
|
||||
Medium,
|
||||
/// Score 50-79
|
||||
Low,
|
||||
/// Score >= 80
|
||||
Trusted,
|
||||
}
|
||||
|
||||
/// Get the risk level for a reputation score.
|
||||
pub fn risk_level(score: u8) -> RiskLevel {
|
||||
match score {
|
||||
0..=19 => RiskLevel::High,
|
||||
20..=49 => RiskLevel::Medium,
|
||||
50..=79 => RiskLevel::Low,
|
||||
_ => RiskLevel::Trusted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check an IP against DNSBL servers.
|
||||
///
|
||||
/// * `ip` - The IP address to check (must be IPv4)
|
||||
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||
/// * `resolver` - DNS resolver to use
|
||||
pub async fn check_dnsbl(
|
||||
ip: IpAddr,
|
||||
dnsbl_servers: &[&str],
|
||||
resolver: &TokioResolver,
|
||||
) -> Result<DnsblResult> {
|
||||
let ipv4 = match ip {
|
||||
IpAddr::V4(v4) => v4,
|
||||
IpAddr::V6(_) => {
|
||||
// IPv6 DNSBL is less common; return clean result
|
||||
return Ok(DnsblResult {
|
||||
ip: ip.to_string(),
|
||||
listed_count: 0,
|
||||
listed_on: Vec::new(),
|
||||
total_checked: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let reversed = reverse_ipv4(ipv4);
|
||||
let total = dnsbl_servers.len();
|
||||
|
||||
// Query all DNSBL servers in parallel
|
||||
let mut handles = Vec::with_capacity(total);
|
||||
for &server in dnsbl_servers {
|
||||
let query = format!("{}.{}", reversed, server);
|
||||
let resolver = resolver.clone();
|
||||
let server_name = server.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
match resolver.lookup_ip(&query).await {
|
||||
Ok(_) => Some(server_name), // IP is listed
|
||||
Err(_) => None, // IP is not listed (NXDOMAIN)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut listed_on = Vec::new();
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Some(server)) => listed_on.push(server),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DnsblResult {
|
||||
ip: ip.to_string(),
|
||||
listed_count: listed_on.len(),
|
||||
listed_on,
|
||||
total_checked: total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
|
||||
pub async fn check_reputation(
|
||||
ip: IpAddr,
|
||||
dnsbl_servers: &[&str],
|
||||
resolver: &TokioResolver,
|
||||
) -> Result<ReputationResult> {
|
||||
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
|
||||
let ip_type = classify_ip(ip);
|
||||
|
||||
// Scoring: start at 100
|
||||
let mut score: i16 = 100;
|
||||
|
||||
// Subtract 10 per DNSBL listing
|
||||
score -= (dnsbl.listed_count as i16) * 10;
|
||||
|
||||
// Subtract 30 for suspicious IP types
|
||||
match ip_type {
|
||||
IpType::Proxy | IpType::Tor | IpType::Vpn => {
|
||||
score -= 30;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let score = score.clamp(0, 100) as u8;
|
||||
let is_spam = score < 50;
|
||||
|
||||
Ok(ReputationResult {
|
||||
score,
|
||||
is_spam,
|
||||
ip: ip.to_string(),
|
||||
dnsbl,
|
||||
ip_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
|
||||
fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
||||
let octets = ip.octets();
|
||||
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
||||
}
|
||||
|
||||
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||
fn classify_ip(ip: IpAddr) -> IpType {
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
// Known Tor exit node prefixes
|
||||
if ip_str.starts_with("171.25.")
|
||||
|| ip_str.starts_with("185.220.")
|
||||
|| ip_str.starts_with("95.216.")
|
||||
{
|
||||
return IpType::Tor;
|
||||
}
|
||||
|
||||
// Known VPN provider prefixes
|
||||
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
|
||||
return IpType::Vpn;
|
||||
}
|
||||
|
||||
// Known proxy prefixes
|
||||
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
|
||||
return IpType::Proxy;
|
||||
}
|
||||
|
||||
// Major cloud provider prefixes (datacenter)
|
||||
if ip_str.starts_with("13.")
|
||||
|| ip_str.starts_with("35.")
|
||||
|| ip_str.starts_with("52.")
|
||||
|| ip_str.starts_with("34.")
|
||||
|| ip_str.starts_with("104.")
|
||||
{
|
||||
return IpType::Datacenter;
|
||||
}
|
||||
|
||||
IpType::Residential
|
||||
}
|
||||
|
||||
/// Validate an IPv4 address string.
|
||||
pub fn is_valid_ipv4(ip: &str) -> bool {
|
||||
ip.parse::<Ipv4Addr>().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv4() {
|
||||
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
|
||||
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
|
||||
|
||||
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
|
||||
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_ip() {
|
||||
assert_eq!(
|
||||
classify_ip("171.25.193.20".parse().unwrap()),
|
||||
IpType::Tor
|
||||
);
|
||||
assert_eq!(
|
||||
classify_ip("185.156.73.1".parse().unwrap()),
|
||||
IpType::Vpn
|
||||
);
|
||||
assert_eq!(
|
||||
classify_ip("34.92.1.1".parse().unwrap()),
|
||||
IpType::Proxy
|
||||
);
|
||||
assert_eq!(
|
||||
classify_ip("52.0.0.1".parse().unwrap()),
|
||||
IpType::Datacenter
|
||||
);
|
||||
assert_eq!(
|
||||
classify_ip("203.0.113.1".parse().unwrap()),
|
||||
IpType::Residential
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_risk_level() {
|
||||
assert_eq!(risk_level(10), RiskLevel::High);
|
||||
assert_eq!(risk_level(30), RiskLevel::Medium);
|
||||
assert_eq!(risk_level(60), RiskLevel::Low);
|
||||
assert_eq!(risk_level(90), RiskLevel::Trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_ipv4() {
|
||||
assert!(is_valid_ipv4("1.2.3.4"));
|
||||
assert!(is_valid_ipv4("255.255.255.255"));
|
||||
assert!(!is_valid_ipv4("999.999.999.999"));
|
||||
assert!(!is_valid_ipv4("not-an-ip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_dnsbl_servers() {
|
||||
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,39 @@
|
||||
//! mailer-security: DKIM, SPF, and DMARC verification.
|
||||
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
||||
|
||||
pub mod content_scanner;
|
||||
pub mod dkim;
|
||||
pub mod dmarc;
|
||||
pub mod error;
|
||||
pub mod ip_reputation;
|
||||
pub mod spf;
|
||||
pub mod verify;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
|
||||
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||
pub use error::{Result, SecurityError};
|
||||
pub use ip_reputation::{
|
||||
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,
|
||||
DEFAULT_DNSBL_SERVERS,
|
||||
};
|
||||
pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult};
|
||||
|
||||
// Re-export mail-auth's MessageAuthenticator for callers to construct
|
||||
pub use mail_auth::MessageAuthenticator;
|
||||
|
||||
pub use mailer_core;
|
||||
|
||||
/// Placeholder for DKIM/SPF/DMARC implementation.
|
||||
/// 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::*;
|
||||
|
||||
167
rust/crates/mailer-security/src/spf.rs
Normal file
167
rust/crates/mailer-security/src/spf.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
115
rust/crates/mailer-security/src/verify.rs
Normal file
115
rust/crates/mailer-security/src/verify.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -14,3 +15,11 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
bytes.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
rustls-pemfile = "2"
|
||||
mailparse.workspace = true
|
||||
|
||||
421
rust/crates/mailer-smtp/src/command.rs
Normal file
421
rust/crates/mailer-smtp/src/command.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
//! SMTP command parser.
|
||||
//!
|
||||
//! Parses raw SMTP command lines into structured `SmtpCommand` variants.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A parsed SMTP command.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SmtpCommand {
|
||||
/// EHLO with client hostname/IP
|
||||
Ehlo(String),
|
||||
/// HELO with client hostname/IP
|
||||
Helo(String),
|
||||
/// MAIL FROM with sender address and optional parameters (e.g. SIZE=12345)
|
||||
MailFrom {
|
||||
address: String,
|
||||
params: HashMap<String, Option<String>>,
|
||||
},
|
||||
/// RCPT TO with recipient address and optional parameters
|
||||
RcptTo {
|
||||
address: String,
|
||||
params: HashMap<String, Option<String>>,
|
||||
},
|
||||
/// DATA command — begin message body
|
||||
Data,
|
||||
/// RSET — reset current transaction
|
||||
Rset,
|
||||
/// NOOP — no operation
|
||||
Noop,
|
||||
/// QUIT — close connection
|
||||
Quit,
|
||||
/// STARTTLS — upgrade to TLS
|
||||
StartTls,
|
||||
/// AUTH with mechanism and optional initial response
|
||||
Auth {
|
||||
mechanism: AuthMechanism,
|
||||
initial_response: Option<String>,
|
||||
},
|
||||
/// HELP with optional topic
|
||||
Help(Option<String>),
|
||||
/// VRFY with address or username
|
||||
Vrfy(String),
|
||||
/// EXPN with mailing list name
|
||||
Expn(String),
|
||||
}
|
||||
|
||||
/// Supported AUTH mechanisms.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AuthMechanism {
|
||||
Plain,
|
||||
Login,
|
||||
}
|
||||
|
||||
/// Errors that can occur during command parsing.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("empty command line")]
|
||||
Empty,
|
||||
#[error("unrecognized command: {0}")]
|
||||
UnrecognizedCommand(String),
|
||||
#[error("syntax error in parameters: {0}")]
|
||||
SyntaxError(String),
|
||||
#[error("missing required argument for {0}")]
|
||||
MissingArgument(String),
|
||||
}
|
||||
|
||||
/// Parse a raw SMTP command line (without trailing CRLF) into a `SmtpCommand`.
|
||||
pub fn parse_command(line: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let line = line.trim_end_matches('\r').trim_end_matches('\n');
|
||||
if line.is_empty() {
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
|
||||
// Split into verb and the rest
|
||||
let (verb, rest) = split_first_word(line);
|
||||
let verb_upper = verb.to_ascii_uppercase();
|
||||
|
||||
match verb_upper.as_str() {
|
||||
"EHLO" => {
|
||||
let hostname = rest.trim();
|
||||
if hostname.is_empty() {
|
||||
return Err(ParseError::MissingArgument("EHLO".into()));
|
||||
}
|
||||
Ok(SmtpCommand::Ehlo(hostname.to_string()))
|
||||
}
|
||||
"HELO" => {
|
||||
let hostname = rest.trim();
|
||||
if hostname.is_empty() {
|
||||
return Err(ParseError::MissingArgument("HELO".into()));
|
||||
}
|
||||
Ok(SmtpCommand::Helo(hostname.to_string()))
|
||||
}
|
||||
"MAIL" => parse_mail_from(rest),
|
||||
"RCPT" => parse_rcpt_to(rest),
|
||||
"DATA" => Ok(SmtpCommand::Data),
|
||||
"RSET" => Ok(SmtpCommand::Rset),
|
||||
"NOOP" => Ok(SmtpCommand::Noop),
|
||||
"QUIT" => Ok(SmtpCommand::Quit),
|
||||
"STARTTLS" => Ok(SmtpCommand::StartTls),
|
||||
"AUTH" => parse_auth(rest),
|
||||
"HELP" => {
|
||||
let topic = rest.trim();
|
||||
if topic.is_empty() {
|
||||
Ok(SmtpCommand::Help(None))
|
||||
} else {
|
||||
Ok(SmtpCommand::Help(Some(topic.to_string())))
|
||||
}
|
||||
}
|
||||
"VRFY" => {
|
||||
let arg = rest.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(ParseError::MissingArgument("VRFY".into()));
|
||||
}
|
||||
Ok(SmtpCommand::Vrfy(arg.to_string()))
|
||||
}
|
||||
"EXPN" => {
|
||||
let arg = rest.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(ParseError::MissingArgument("EXPN".into()));
|
||||
}
|
||||
Ok(SmtpCommand::Expn(arg.to_string()))
|
||||
}
|
||||
_ => Err(ParseError::UnrecognizedCommand(verb_upper)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `FROM:<addr> [PARAM=VALUE ...]` after "MAIL".
|
||||
fn parse_mail_from(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
// Expect "FROM:" prefix (case-insensitive, whitespace-flexible)
|
||||
let rest = rest.trim_start();
|
||||
let rest_upper = rest.to_ascii_uppercase();
|
||||
if !rest_upper.starts_with("FROM") {
|
||||
return Err(ParseError::SyntaxError(
|
||||
"expected FROM after MAIL".into(),
|
||||
));
|
||||
}
|
||||
let rest = &rest[4..]; // skip "FROM"
|
||||
let rest = rest.trim_start();
|
||||
if !rest.starts_with(':') {
|
||||
return Err(ParseError::SyntaxError(
|
||||
"expected colon after MAIL FROM".into(),
|
||||
));
|
||||
}
|
||||
let rest = &rest[1..]; // skip ':'
|
||||
let rest = rest.trim_start();
|
||||
|
||||
parse_address_and_params(rest, "MAIL FROM").map(|(address, params)| SmtpCommand::MailFrom {
|
||||
address,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `TO:<addr> [PARAM=VALUE ...]` after "RCPT".
|
||||
fn parse_rcpt_to(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let rest = rest.trim_start();
|
||||
let rest_upper = rest.to_ascii_uppercase();
|
||||
if !rest_upper.starts_with("TO") {
|
||||
return Err(ParseError::SyntaxError("expected TO after RCPT".into()));
|
||||
}
|
||||
let rest = &rest[2..]; // skip "TO"
|
||||
let rest = rest.trim_start();
|
||||
if !rest.starts_with(':') {
|
||||
return Err(ParseError::SyntaxError(
|
||||
"expected colon after RCPT TO".into(),
|
||||
));
|
||||
}
|
||||
let rest = &rest[1..]; // skip ':'
|
||||
let rest = rest.trim_start();
|
||||
|
||||
parse_address_and_params(rest, "RCPT TO").map(|(address, params)| SmtpCommand::RcptTo {
|
||||
address,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `<address> [PARAM=VALUE ...]` from the rest of a MAIL FROM or RCPT TO line.
|
||||
fn parse_address_and_params(
|
||||
input: &str,
|
||||
context: &str,
|
||||
) -> Result<(String, HashMap<String, Option<String>>), ParseError> {
|
||||
if !input.starts_with('<') {
|
||||
return Err(ParseError::SyntaxError(format!(
|
||||
"expected '<' in {context}"
|
||||
)));
|
||||
}
|
||||
let close_bracket = input.find('>').ok_or_else(|| {
|
||||
ParseError::SyntaxError(format!("missing '>' in {context}"))
|
||||
})?;
|
||||
let address = input[1..close_bracket].to_string();
|
||||
let remainder = &input[close_bracket + 1..];
|
||||
let params = parse_params(remainder)?;
|
||||
Ok((address, params))
|
||||
}
|
||||
|
||||
/// Parse SMTP extension parameters like `SIZE=12345 BODY=8BITMIME`.
|
||||
fn parse_params(input: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
|
||||
let mut params = HashMap::new();
|
||||
for token in input.split_whitespace() {
|
||||
if let Some(eq_pos) = token.find('=') {
|
||||
let key = token[..eq_pos].to_ascii_uppercase();
|
||||
let value = token[eq_pos + 1..].to_string();
|
||||
params.insert(key, Some(value));
|
||||
} else {
|
||||
params.insert(token.to_ascii_uppercase(), None);
|
||||
}
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Parse AUTH command: `AUTH <mechanism> [initial-response]`.
|
||||
fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let rest = rest.trim();
|
||||
if rest.is_empty() {
|
||||
return Err(ParseError::MissingArgument("AUTH".into()));
|
||||
}
|
||||
let (mech_str, initial) = split_first_word(rest);
|
||||
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||
"PLAIN" => AuthMechanism::Plain,
|
||||
"LOGIN" => AuthMechanism::Login,
|
||||
other => {
|
||||
return Err(ParseError::SyntaxError(format!(
|
||||
"unsupported AUTH mechanism: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let initial_response = {
|
||||
let s = initial.trim();
|
||||
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||
};
|
||||
Ok(SmtpCommand::Auth {
|
||||
mechanism,
|
||||
initial_response,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a string into the first whitespace-delimited word and the remainder.
|
||||
fn split_first_word(s: &str) -> (&str, &str) {
|
||||
match s.find(char::is_whitespace) {
|
||||
Some(pos) => (&s[..pos], &s[pos + 1..]),
|
||||
None => (s, ""),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ehlo() {
|
||||
let cmd = parse_command("EHLO mail.example.com").unwrap();
|
||||
assert_eq!(cmd, SmtpCommand::Ehlo("mail.example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_case_insensitive() {
|
||||
let cmd = parse_command("ehlo MAIL.EXAMPLE.COM").unwrap();
|
||||
assert_eq!(cmd, SmtpCommand::Ehlo("MAIL.EXAMPLE.COM".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helo() {
|
||||
let cmd = parse_command("HELO example.com").unwrap();
|
||||
assert_eq!(cmd, SmtpCommand::Helo("example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_missing_arg() {
|
||||
let err = parse_command("EHLO").unwrap_err();
|
||||
assert!(matches!(err, ParseError::MissingArgument(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_from() {
|
||||
let cmd = parse_command("MAIL FROM:<sender@example.com>").unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
SmtpCommand::MailFrom {
|
||||
address: "sender@example.com".into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_from_with_params() {
|
||||
let cmd = parse_command("MAIL FROM:<sender@example.com> SIZE=12345 BODY=8BITMIME").unwrap();
|
||||
if let SmtpCommand::MailFrom { address, params } = cmd {
|
||||
assert_eq!(address, "sender@example.com");
|
||||
assert_eq!(params.get("SIZE"), Some(&Some("12345".into())));
|
||||
assert_eq!(params.get("BODY"), Some(&Some("8BITMIME".into())));
|
||||
} else {
|
||||
panic!("expected MailFrom");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_from_empty_address() {
|
||||
let cmd = parse_command("MAIL FROM:<>").unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
SmtpCommand::MailFrom {
|
||||
address: "".into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_from_flexible_spacing() {
|
||||
let cmd = parse_command("MAIL FROM: <user@example.com>").unwrap();
|
||||
if let SmtpCommand::MailFrom { address, .. } = cmd {
|
||||
assert_eq!(address, "user@example.com");
|
||||
} else {
|
||||
panic!("expected MailFrom");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rcpt_to() {
|
||||
let cmd = parse_command("RCPT TO:<recipient@example.com>").unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
SmtpCommand::RcptTo {
|
||||
address: "recipient@example.com".into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data() {
|
||||
assert_eq!(parse_command("DATA").unwrap(), SmtpCommand::Data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rset() {
|
||||
assert_eq!(parse_command("RSET").unwrap(), SmtpCommand::Rset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_noop() {
|
||||
assert_eq!(parse_command("NOOP").unwrap(), SmtpCommand::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quit() {
|
||||
assert_eq!(parse_command("QUIT").unwrap(), SmtpCommand::Quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starttls() {
|
||||
assert_eq!(parse_command("STARTTLS").unwrap(), SmtpCommand::StartTls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_plain() {
|
||||
let cmd = parse_command("AUTH PLAIN dGVzdAB0ZXN0AHBhc3N3b3Jk").unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
SmtpCommand::Auth {
|
||||
mechanism: AuthMechanism::Plain,
|
||||
initial_response: Some("dGVzdAB0ZXN0AHBhc3N3b3Jk".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_login_no_initial() {
|
||||
let cmd = parse_command("AUTH LOGIN").unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
SmtpCommand::Auth {
|
||||
mechanism: AuthMechanism::Login,
|
||||
initial_response: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help() {
|
||||
assert_eq!(parse_command("HELP").unwrap(), SmtpCommand::Help(None));
|
||||
assert_eq!(
|
||||
parse_command("HELP MAIL").unwrap(),
|
||||
SmtpCommand::Help(Some("MAIL".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vrfy() {
|
||||
assert_eq!(
|
||||
parse_command("VRFY user@example.com").unwrap(),
|
||||
SmtpCommand::Vrfy("user@example.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expn() {
|
||||
assert_eq!(
|
||||
parse_command("EXPN staff").unwrap(),
|
||||
SmtpCommand::Expn("staff".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert!(matches!(parse_command(""), Err(ParseError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unrecognized() {
|
||||
let err = parse_command("FOOBAR test").unwrap_err();
|
||||
assert!(matches!(err, ParseError::UnrecognizedCommand(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crlf_stripped() {
|
||||
let cmd = parse_command("QUIT\r\n").unwrap();
|
||||
assert_eq!(cmd, SmtpCommand::Quit);
|
||||
}
|
||||
}
|
||||
86
rust/crates/mailer-smtp/src/config.rs
Normal file
86
rust/crates/mailer-smtp/src/config.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! SMTP server configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for an SMTP server instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpServerConfig {
|
||||
/// Server hostname for greeting and EHLO responses.
|
||||
pub hostname: String,
|
||||
/// Ports to listen on (e.g. [25, 587]).
|
||||
pub ports: Vec<u16>,
|
||||
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
||||
pub secure_port: Option<u16>,
|
||||
/// TLS certificate chain in PEM format.
|
||||
pub tls_cert_pem: Option<String>,
|
||||
/// TLS private key in PEM format.
|
||||
pub tls_key_pem: Option<String>,
|
||||
/// Maximum message size in bytes.
|
||||
pub max_message_size: u64,
|
||||
/// Maximum number of concurrent connections.
|
||||
pub max_connections: u32,
|
||||
/// Maximum recipients per message.
|
||||
pub max_recipients: u32,
|
||||
/// Connection timeout in seconds.
|
||||
pub connection_timeout_secs: u64,
|
||||
/// Data phase timeout in seconds.
|
||||
pub data_timeout_secs: u64,
|
||||
/// Whether authentication is available.
|
||||
pub auth_enabled: bool,
|
||||
/// Maximum authentication failures before disconnect.
|
||||
pub max_auth_failures: u32,
|
||||
/// Socket timeout in seconds (idle timeout for the entire connection).
|
||||
pub socket_timeout_secs: u64,
|
||||
/// Timeout in seconds waiting for TS to respond to email processing.
|
||||
pub processing_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for SmtpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hostname: "mail.example.com".to_string(),
|
||||
ports: vec![25],
|
||||
secure_port: None,
|
||||
tls_cert_pem: None,
|
||||
tls_key_pem: None,
|
||||
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||
max_connections: 100,
|
||||
max_recipients: 100,
|
||||
connection_timeout_secs: 30,
|
||||
data_timeout_secs: 60,
|
||||
auth_enabled: false,
|
||||
max_auth_failures: 3,
|
||||
socket_timeout_secs: 300,
|
||||
processing_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpServerConfig {
|
||||
/// Check if TLS is configured.
|
||||
pub fn has_tls(&self) -> bool {
|
||||
self.tls_cert_pem.is_some() && self.tls_key_pem.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_defaults() {
|
||||
let cfg = SmtpServerConfig::default();
|
||||
assert_eq!(cfg.max_message_size, 10 * 1024 * 1024);
|
||||
assert_eq!(cfg.max_connections, 100);
|
||||
assert!(!cfg.has_tls());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_tls() {
|
||||
let mut cfg = SmtpServerConfig::default();
|
||||
cfg.tls_cert_pem = Some("cert".into());
|
||||
assert!(!cfg.has_tls()); // need both
|
||||
cfg.tls_key_pem = Some("key".into());
|
||||
assert!(cfg.has_tls());
|
||||
}
|
||||
}
|
||||
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
File diff suppressed because it is too large
Load Diff
289
rust/crates/mailer-smtp/src/data.rs
Normal file
289
rust/crates/mailer-smtp/src/data.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,39 @@
|
||||
//! mailer-smtp: SMTP protocol engine (server + client).
|
||||
//!
|
||||
//! This crate provides the SMTP protocol implementation including:
|
||||
//! - Command parsing (`command`)
|
||||
//! - State machine (`state`)
|
||||
//! - Response building (`response`)
|
||||
//! - Email data accumulation (`data`)
|
||||
//! - Per-connection session state (`session`)
|
||||
//! - Address/input validation (`validation`)
|
||||
//! - Server configuration (`config`)
|
||||
//! - Rate limiting (`rate_limiter`)
|
||||
//! - TCP/TLS server (`server`)
|
||||
//! - Connection handling (`connection`)
|
||||
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod data;
|
||||
pub mod rate_limiter;
|
||||
pub mod response;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
pub mod validation;
|
||||
|
||||
pub use mailer_core;
|
||||
|
||||
/// Placeholder for the SMTP server and client implementation.
|
||||
// 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")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert!(!version().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
284
rust/crates/mailer-smtp/src/response.rs
Normal file
284
rust/crates/mailer-smtp/src/response.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! SMTP response builder.
|
||||
//!
|
||||
//! Constructs properly formatted SMTP response lines with status codes,
|
||||
//! multiline support, and EHLO capability advertisement.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An SMTP response to send to the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SmtpResponse {
|
||||
/// 3-digit SMTP status code.
|
||||
pub code: u16,
|
||||
/// Response lines (without the status code prefix).
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SmtpResponse {
|
||||
/// Create a single-line response.
|
||||
pub fn new(code: u16, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
lines: vec![message.into()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a multiline response.
|
||||
pub fn multiline(code: u16, lines: Vec<String>) -> Self {
|
||||
Self { code, lines }
|
||||
}
|
||||
|
||||
/// Format the response as bytes ready to write to the socket.
|
||||
///
|
||||
/// Multiline responses use `code-text` for intermediate lines
|
||||
/// and `code text` for the final line (RFC 5321 §4.2).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
if self.lines.is_empty() {
|
||||
buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes());
|
||||
} else if self.lines.len() == 1 {
|
||||
buf.extend_from_slice(
|
||||
format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(),
|
||||
);
|
||||
} else {
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
if i < self.lines.len() - 1 {
|
||||
buf.extend_from_slice(
|
||||
format!("{}-{}\r\n", self.code, line).as_bytes(),
|
||||
);
|
||||
} else {
|
||||
buf.extend_from_slice(
|
||||
format!("{} {}\r\n", self.code, line).as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
// --- Common response constructors ---
|
||||
|
||||
/// 220 Service ready greeting.
|
||||
pub fn greeting(hostname: &str) -> Self {
|
||||
Self::new(220, format!("{hostname} ESMTP Service Ready"))
|
||||
}
|
||||
|
||||
/// 221 Service closing.
|
||||
pub fn closing(hostname: &str) -> Self {
|
||||
Self::new(221, format!("{hostname} Service closing transmission channel"))
|
||||
}
|
||||
|
||||
/// 250 OK.
|
||||
pub fn ok(message: impl Into<String>) -> Self {
|
||||
Self::new(250, message)
|
||||
}
|
||||
|
||||
/// EHLO response with capabilities.
|
||||
pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self {
|
||||
let mut lines = Vec::with_capacity(capabilities.len() + 1);
|
||||
lines.push(format!("{hostname} greets you"));
|
||||
for cap in capabilities {
|
||||
lines.push(cap.clone());
|
||||
}
|
||||
Self::multiline(250, lines)
|
||||
}
|
||||
|
||||
/// 235 Authentication successful.
|
||||
pub fn auth_success() -> Self {
|
||||
Self::new(235, "2.7.0 Authentication successful")
|
||||
}
|
||||
|
||||
/// 334 Auth challenge (base64-encoded prompt).
|
||||
pub fn auth_challenge(prompt: &str) -> Self {
|
||||
Self::new(334, prompt)
|
||||
}
|
||||
|
||||
/// 354 Start mail input.
|
||||
pub fn start_data() -> Self {
|
||||
Self::new(354, "Start mail input; end with <CRLF>.<CRLF>")
|
||||
}
|
||||
|
||||
/// 421 Service not available.
|
||||
pub fn service_unavailable(hostname: &str, reason: &str) -> Self {
|
||||
Self::new(421, format!("{hostname} {reason}"))
|
||||
}
|
||||
|
||||
/// 450 Temporary failure.
|
||||
pub fn temp_failure(message: impl Into<String>) -> Self {
|
||||
Self::new(450, message)
|
||||
}
|
||||
|
||||
/// 451 Local error.
|
||||
pub fn local_error(message: impl Into<String>) -> Self {
|
||||
Self::new(451, message)
|
||||
}
|
||||
|
||||
/// 500 Syntax error.
|
||||
pub fn syntax_error() -> Self {
|
||||
Self::new(500, "Syntax error, command unrecognized")
|
||||
}
|
||||
|
||||
/// 501 Syntax error in parameters.
|
||||
pub fn param_error(message: impl Into<String>) -> Self {
|
||||
Self::new(501, message)
|
||||
}
|
||||
|
||||
/// 502 Command not implemented.
|
||||
pub fn not_implemented() -> Self {
|
||||
Self::new(502, "Command not implemented")
|
||||
}
|
||||
|
||||
/// 503 Bad sequence.
|
||||
pub fn bad_sequence(message: impl Into<String>) -> Self {
|
||||
Self::new(503, message)
|
||||
}
|
||||
|
||||
/// 530 Authentication required.
|
||||
pub fn auth_required() -> Self {
|
||||
Self::new(530, "5.7.0 Authentication required")
|
||||
}
|
||||
|
||||
/// 535 Authentication failed.
|
||||
pub fn auth_failed() -> Self {
|
||||
Self::new(535, "5.7.8 Authentication credentials invalid")
|
||||
}
|
||||
|
||||
/// 550 Mailbox unavailable.
|
||||
pub fn mailbox_unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(550, message)
|
||||
}
|
||||
|
||||
/// 552 Message size exceeded.
|
||||
pub fn size_exceeded(max_size: u64) -> Self {
|
||||
Self::new(
|
||||
552,
|
||||
format!("5.3.4 Message size exceeds maximum of {max_size} bytes"),
|
||||
)
|
||||
}
|
||||
|
||||
/// 554 Transaction failed.
|
||||
pub fn transaction_failed(message: impl Into<String>) -> Self {
|
||||
Self::new(554, message)
|
||||
}
|
||||
|
||||
/// Check if this is a success response (2xx).
|
||||
pub fn is_success(&self) -> bool {
|
||||
self.code >= 200 && self.code < 300
|
||||
}
|
||||
|
||||
/// Check if this is a temporary error (4xx).
|
||||
pub fn is_temp_error(&self) -> bool {
|
||||
self.code >= 400 && self.code < 500
|
||||
}
|
||||
|
||||
/// Check if this is a permanent error (5xx).
|
||||
pub fn is_perm_error(&self) -> bool {
|
||||
self.code >= 500 && self.code < 600
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the list of EHLO capabilities for the server.
|
||||
pub fn build_capabilities(
|
||||
max_size: u64,
|
||||
tls_available: bool,
|
||||
already_secure: bool,
|
||||
auth_available: bool,
|
||||
) -> Vec<String> {
|
||||
let mut caps = vec![
|
||||
format!("SIZE {max_size}"),
|
||||
"8BITMIME".to_string(),
|
||||
"PIPELINING".to_string(),
|
||||
"ENHANCEDSTATUSCODES".to_string(),
|
||||
"HELP".to_string(),
|
||||
];
|
||||
// Only advertise STARTTLS if TLS is available and not already using TLS
|
||||
if tls_available && !already_secure {
|
||||
caps.push("STARTTLS".to_string());
|
||||
}
|
||||
if auth_available {
|
||||
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||
}
|
||||
caps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_single_line() {
|
||||
let resp = SmtpResponse::new(250, "OK");
|
||||
assert_eq!(resp.to_bytes(), b"250 OK\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiline() {
|
||||
let resp = SmtpResponse::multiline(
|
||||
250,
|
||||
vec![
|
||||
"mail.example.com greets you".into(),
|
||||
"SIZE 10485760".into(),
|
||||
"STARTTLS".into(),
|
||||
],
|
||||
);
|
||||
let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n";
|
||||
assert_eq!(resp.to_bytes(), expected.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_greeting() {
|
||||
let resp = SmtpResponse::greeting("mail.example.com");
|
||||
assert_eq!(resp.code, 220);
|
||||
assert!(resp.lines[0].contains("mail.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_response() {
|
||||
let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()];
|
||||
let resp = SmtpResponse::ehlo_response("mail.example.com", &caps);
|
||||
assert_eq!(resp.code, 250);
|
||||
assert_eq!(resp.lines.len(), 3); // hostname + 2 caps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_checks() {
|
||||
assert!(SmtpResponse::new(250, "OK").is_success());
|
||||
assert!(SmtpResponse::new(450, "Try later").is_temp_error());
|
||||
assert!(SmtpResponse::new(550, "No such user").is_perm_error());
|
||||
assert!(!SmtpResponse::new(250, "OK").is_temp_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_capabilities() {
|
||||
let caps = build_capabilities(10485760, true, false, true);
|
||||
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
||||
assert!(caps.contains(&"STARTTLS".to_string()));
|
||||
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_capabilities_secure() {
|
||||
// When already secure, STARTTLS should NOT be advertised
|
||||
let caps = build_capabilities(10485760, true, true, false);
|
||||
assert!(!caps.contains(&"STARTTLS".to_string()));
|
||||
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_response() {
|
||||
let resp = SmtpResponse::multiline(250, vec![]);
|
||||
assert_eq!(resp.to_bytes(), b"250 \r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_responses() {
|
||||
assert_eq!(SmtpResponse::start_data().code, 354);
|
||||
assert_eq!(SmtpResponse::syntax_error().code, 500);
|
||||
assert_eq!(SmtpResponse::not_implemented().code, 502);
|
||||
assert_eq!(SmtpResponse::bad_sequence("test").code, 503);
|
||||
assert_eq!(SmtpResponse::auth_required().code, 530);
|
||||
assert_eq!(SmtpResponse::auth_failed().code, 535);
|
||||
assert_eq!(SmtpResponse::auth_success().code, 235);
|
||||
}
|
||||
}
|
||||
331
rust/crates/mailer-smtp/src/server.rs
Normal file
331
rust/crates/mailer-smtp/src/server.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! SMTP TCP/TLS server.
|
||||
//!
|
||||
//! Listens on configured ports, accepts connections, and dispatches
|
||||
//! them to per-connection handlers.
|
||||
|
||||
use crate::config::SmtpServerConfig;
|
||||
use crate::connection::{
|
||||
self, CallbackRegistry, ConnectionEvent, SmtpStream,
|
||||
};
|
||||
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||
|
||||
use hickory_resolver::TokioResolver;
|
||||
use mailer_security::MessageAuthenticator;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use std::io::BufReader;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::BufReader as TokioBufReader;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Handle for a running SMTP server.
|
||||
pub struct SmtpServerHandle {
|
||||
/// Shutdown signal.
|
||||
shutdown: Arc<AtomicBool>,
|
||||
/// Join handles for the listener tasks.
|
||||
handles: Vec<tokio::task::JoinHandle<()>>,
|
||||
/// Active connection count.
|
||||
pub active_connections: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl SmtpServerHandle {
|
||||
/// Signal shutdown and wait for all listeners to stop.
|
||||
pub async fn shutdown(self) {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
for handle in self.handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
info!("SMTP server shut down");
|
||||
}
|
||||
|
||||
/// Check if the server is running.
|
||||
pub fn is_running(&self) -> bool {
|
||||
!self.shutdown.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the SMTP server with the given configuration.
|
||||
///
|
||||
/// Returns a handle that can be used to shut down the server,
|
||||
/// and an event receiver for connection events (emailReceived, authRequest).
|
||||
pub async fn start_server(
|
||||
config: SmtpServerConfig,
|
||||
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||
rate_limit_config: Option<RateLimitConfig>,
|
||||
) -> Result<(SmtpServerHandle, mpsc::Receiver<ConnectionEvent>), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let config = Arc::new(config);
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let active_connections = Arc::new(AtomicU32::new(0));
|
||||
let rate_limiter = Arc::new(RateLimiter::new(
|
||||
rate_limit_config.unwrap_or_default(),
|
||||
));
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
|
||||
|
||||
// Create shared security resources for in-process email verification
|
||||
let authenticator: Arc<MessageAuthenticator> = Arc::new(
|
||||
mailer_security::default_authenticator()
|
||||
.map_err(|e| format!("Failed to create MessageAuthenticator: {e}"))?
|
||||
);
|
||||
let resolver: Arc<TokioResolver> = Arc::new(
|
||||
TokioResolver::builder_tokio()
|
||||
.map(|b| b.build())
|
||||
.map_err(|e| format!("Failed to create TokioResolver: {e}"))?
|
||||
);
|
||||
|
||||
// Build TLS acceptor if configured
|
||||
let tls_acceptor = if config.has_tls() {
|
||||
Some(Arc::new(build_tls_acceptor(&config)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Start listeners on each port
|
||||
for &port in &config.ports {
|
||||
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||
info!(port = port, "SMTP server listening (STARTTLS)");
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
config.clone(),
|
||||
shutdown.clone(),
|
||||
active_connections.clone(),
|
||||
rate_limiter.clone(),
|
||||
event_tx.clone(),
|
||||
callback_registry.clone(),
|
||||
tls_acceptor.clone(),
|
||||
false, // not implicit TLS
|
||||
authenticator.clone(),
|
||||
resolver.clone(),
|
||||
));
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Start implicit TLS listener if configured
|
||||
if let Some(secure_port) = config.secure_port {
|
||||
if tls_acceptor.is_some() {
|
||||
let listener =
|
||||
TcpListener::bind(format!("0.0.0.0:{secure_port}")).await?;
|
||||
info!(port = secure_port, "SMTP server listening (implicit TLS)");
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
config.clone(),
|
||||
shutdown.clone(),
|
||||
active_connections.clone(),
|
||||
rate_limiter.clone(),
|
||||
event_tx.clone(),
|
||||
callback_registry.clone(),
|
||||
tls_acceptor.clone(),
|
||||
true, // implicit TLS
|
||||
authenticator.clone(),
|
||||
resolver.clone(),
|
||||
));
|
||||
handles.push(handle);
|
||||
} else {
|
||||
warn!("Secure port configured but TLS certificates not provided");
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn periodic rate limiter cleanup
|
||||
{
|
||||
let rate_limiter = rate_limiter.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(tokio::time::Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if shutdown.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
rate_limiter.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok((
|
||||
SmtpServerHandle {
|
||||
shutdown,
|
||||
handles,
|
||||
active_connections,
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Accept loop for a single listener.
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
config: Arc<SmtpServerConfig>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
active_connections: Arc<AtomicU32>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
event_tx: mpsc::Sender<ConnectionEvent>,
|
||||
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||
implicit_tls: bool,
|
||||
authenticator: Arc<MessageAuthenticator>,
|
||||
resolver: Arc<TokioResolver>,
|
||||
) {
|
||||
loop {
|
||||
if shutdown.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Use a short timeout to check shutdown periodically
|
||||
let accept_result = tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(1),
|
||||
listener.accept(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (tcp_stream, peer_addr) = match accept_result {
|
||||
Ok(Ok((stream, addr))) => (stream, addr),
|
||||
Ok(Err(e)) => {
|
||||
error!(error = %e, "Accept error");
|
||||
continue;
|
||||
}
|
||||
Err(_) => continue, // timeout, check shutdown
|
||||
};
|
||||
|
||||
// Check max connections
|
||||
let current = active_connections.load(Ordering::SeqCst);
|
||||
if current >= config.max_connections {
|
||||
warn!(
|
||||
current = current,
|
||||
max = config.max_connections,
|
||||
"Max connections reached, rejecting"
|
||||
);
|
||||
drop(tcp_stream);
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote_addr = peer_addr.ip().to_string();
|
||||
let config = config.clone();
|
||||
let rate_limiter = rate_limiter.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
let callback_registry = callback_registry.clone();
|
||||
let tls_acceptor = tls_acceptor.clone();
|
||||
let active_connections = active_connections.clone();
|
||||
let authenticator = authenticator.clone();
|
||||
let resolver = resolver.clone();
|
||||
|
||||
active_connections.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stream = if implicit_tls {
|
||||
// Implicit TLS: wrap immediately
|
||||
if let Some(acceptor) = &tls_acceptor {
|
||||
match acceptor.accept(tcp_stream).await {
|
||||
Ok(tls_stream) => {
|
||||
SmtpStream::Tls(TokioBufReader::new(tls_stream))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
remote_addr = %remote_addr,
|
||||
error = %e,
|
||||
"Implicit TLS handshake failed"
|
||||
);
|
||||
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
SmtpStream::Plain(TokioBufReader::new(tcp_stream))
|
||||
};
|
||||
|
||||
connection::handle_connection(
|
||||
stream,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
tls_acceptor,
|
||||
remote_addr,
|
||||
implicit_tls,
|
||||
authenticator,
|
||||
resolver,
|
||||
)
|
||||
.await;
|
||||
|
||||
active_connections.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a TLS acceptor from PEM cert/key strings.
|
||||
fn build_tls_acceptor(
|
||||
config: &SmtpServerConfig,
|
||||
) -> Result<tokio_rustls::TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cert_pem = config
|
||||
.tls_cert_pem
|
||||
.as_ref()
|
||||
.ok_or("TLS cert not configured")?;
|
||||
let key_pem = config
|
||||
.tls_key_pem
|
||||
.as_ref()
|
||||
.ok_or("TLS key not configured")?;
|
||||
|
||||
// Parse certificates
|
||||
let certs: Vec<CertificateDer<'static>> = {
|
||||
let mut reader = BufReader::new(cert_pem.as_bytes());
|
||||
rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
if certs.is_empty() {
|
||||
return Err("No certificates found in PEM".into());
|
||||
}
|
||||
|
||||
// Parse private key
|
||||
let key: PrivateKeyDer<'static> = {
|
||||
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||
// Try PKCS8 first, then RSA, then EC
|
||||
let mut keys = Vec::new();
|
||||
for item in rustls_pemfile::read_all(&mut reader) {
|
||||
match item? {
|
||||
rustls_pemfile::Item::Pkcs8Key(key) => {
|
||||
keys.push(PrivateKeyDer::Pkcs8(key));
|
||||
}
|
||||
rustls_pemfile::Item::Pkcs1Key(key) => {
|
||||
keys.push(PrivateKeyDer::Pkcs1(key));
|
||||
}
|
||||
rustls_pemfile::Item::Sec1Key(key) => {
|
||||
keys.push(PrivateKeyDer::Sec1(key));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys.into_iter()
|
||||
.next()
|
||||
.ok_or("No private key found in PEM")?
|
||||
};
|
||||
|
||||
let tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
|
||||
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_server_config_defaults() {
|
||||
let config = SmtpServerConfig::default();
|
||||
assert!(!config.has_tls());
|
||||
assert_eq!(config.ports, vec![25]);
|
||||
}
|
||||
}
|
||||
206
rust/crates/mailer-smtp/src/session.rs
Normal file
206
rust/crates/mailer-smtp/src/session.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
219
rust/crates/mailer-smtp/src/state.rs
Normal file
219
rust/crates/mailer-smtp/src/state.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
169
rust/crates/mailer-smtp/src/validation.rs
Normal 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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.js';
|
||||
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.js';
|
||||
import type { net } from '../../ts/plugins.js';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
@@ -27,165 +23,18 @@ export interface ITestServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a test SMTP server with the given configuration
|
||||
* Starts a test SMTP server with the given configuration.
|
||||
*
|
||||
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||
* which is not installed (pre-existing issue).
|
||||
*/
|
||||
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
||||
// Find a free port if one wasn't specified
|
||||
// Using smartnetwork to find an available port in the range 10000-60000
|
||||
let port = config.port;
|
||||
if (port === undefined || port === 0) {
|
||||
const network = new plugins.smartnetwork.Network();
|
||||
port = await network.findFreePort(10000, 60000, { randomize: true });
|
||||
if (!port) {
|
||||
throw new Error('No free ports available in range 10000-60000');
|
||||
}
|
||||
}
|
||||
|
||||
const serverConfig = {
|
||||
port: port, // Use the found free port
|
||||
hostname: config.hostname || 'localhost',
|
||||
tlsEnabled: config.tlsEnabled || false,
|
||||
authRequired: config.authRequired || false,
|
||||
timeout: config.timeout || 30000,
|
||||
maxConnections: config.maxConnections || 100,
|
||||
size: config.size || 10 * 1024 * 1024, // 10MB default
|
||||
maxRecipients: config.maxRecipients || 100
|
||||
};
|
||||
|
||||
// Create a mock email server for testing
|
||||
const mockEmailServer = {
|
||||
processEmailByMode: async (emailData: any) => {
|
||||
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
|
||||
return emailData;
|
||||
},
|
||||
getRateLimiter: () => {
|
||||
// Return a mock rate limiter for testing
|
||||
return {
|
||||
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||
checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }),
|
||||
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
||||
recordAuthenticationFailure: async (_ip: string) => {},
|
||||
recordSyntaxError: async (_ip: string) => {},
|
||||
recordCommandError: async (_ip: string) => {},
|
||||
recordError: (_key: string) => false, // Return false to not block during tests
|
||||
isBlocked: async (_ip: string) => false,
|
||||
cleanup: async () => {}
|
||||
};
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Load test certificates
|
||||
let key: string;
|
||||
let cert: string;
|
||||
|
||||
if (serverConfig.tlsEnabled) {
|
||||
try {
|
||||
const certPath = config.testCertPath || './test/fixtures/test-cert.pem';
|
||||
const keyPath = config.testKeyPath || './test/fixtures/test-key.pem';
|
||||
|
||||
cert = await plugins.fs.promises.readFile(certPath, 'utf8');
|
||||
key = await plugins.fs.promises.readFile(keyPath, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed');
|
||||
// Generate self-signed certificate for testing
|
||||
const forge = await import('node-forge');
|
||||
const pki = forge.default.pki;
|
||||
|
||||
// Generate key pair
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create certificate
|
||||
const certificate = pki.createCertificate();
|
||||
certificate.publicKey = keys.publicKey;
|
||||
certificate.serialNumber = '01';
|
||||
certificate.validity.notBefore = new Date();
|
||||
certificate.validity.notAfter = new Date();
|
||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{
|
||||
name: 'commonName',
|
||||
value: serverConfig.hostname
|
||||
}];
|
||||
certificate.setSubject(attrs);
|
||||
certificate.setIssuer(attrs);
|
||||
certificate.sign(keys.privateKey);
|
||||
|
||||
// Convert to PEM
|
||||
cert = pki.certificateToPem(certificate);
|
||||
key = pki.privateKeyToPem(keys.privateKey);
|
||||
}
|
||||
} else {
|
||||
// Always provide a self-signed certificate for non-TLS servers
|
||||
// This is required by the interface
|
||||
const forge = await import('node-forge');
|
||||
const pki = forge.default.pki;
|
||||
|
||||
// Generate key pair
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create certificate
|
||||
const certificate = pki.createCertificate();
|
||||
certificate.publicKey = keys.publicKey;
|
||||
certificate.serialNumber = '01';
|
||||
certificate.validity.notBefore = new Date();
|
||||
certificate.validity.notAfter = new Date();
|
||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{
|
||||
name: 'commonName',
|
||||
value: serverConfig.hostname
|
||||
}];
|
||||
certificate.setSubject(attrs);
|
||||
certificate.setIssuer(attrs);
|
||||
certificate.sign(keys.privateKey);
|
||||
|
||||
// Convert to PEM
|
||||
cert = pki.certificateToPem(certificate);
|
||||
key = pki.privateKeyToPem(keys.privateKey);
|
||||
}
|
||||
|
||||
// SMTP server options
|
||||
const smtpOptions: ISmtpServerOptions = {
|
||||
port: serverConfig.port,
|
||||
hostname: serverConfig.hostname,
|
||||
key: key,
|
||||
cert: cert,
|
||||
maxConnections: serverConfig.maxConnections,
|
||||
size: serverConfig.size,
|
||||
maxRecipients: serverConfig.maxRecipients,
|
||||
socketTimeout: serverConfig.timeout,
|
||||
connectionTimeout: serverConfig.timeout * 2,
|
||||
cleanupInterval: 300000,
|
||||
auth: serverConfig.authRequired ? ({
|
||||
required: true,
|
||||
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
||||
validateUser: async (username: string, password: string) => {
|
||||
// Test server accepts these credentials
|
||||
return username === 'testuser' && password === 'testpass';
|
||||
}
|
||||
} as any) : undefined
|
||||
};
|
||||
|
||||
// Create SMTP server
|
||||
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
|
||||
|
||||
// Start the server
|
||||
await smtpServer.listen();
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServerReady(serverConfig.hostname, serverConfig.port);
|
||||
|
||||
console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`);
|
||||
|
||||
return {
|
||||
server: mockEmailServer,
|
||||
smtpServer: smtpServer,
|
||||
port: serverConfig.port, // Return the port we already know
|
||||
hostname: serverConfig.hostname,
|
||||
config: serverConfig,
|
||||
startTime: Date.now()
|
||||
};
|
||||
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||
throw new Error(
|
||||
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,94 +42,19 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
||||
*/
|
||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
if (!testServer || !testServer.smtpServer) {
|
||||
console.warn('⚠️ No test server to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
|
||||
|
||||
// Stop the SMTP server
|
||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||
await testServer.smtpServer.close();
|
||||
}
|
||||
|
||||
// Wait for port to be free
|
||||
await waitForPortFree(testServer.port);
|
||||
|
||||
console.log(`✅ Test SMTP server stopped`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping test server:', error);
|
||||
console.error('Error stopping test server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be ready to accept connections
|
||||
*/
|
||||
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = plugins.net.createConnection({ port, host: hostname });
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', reject);
|
||||
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return; // Server is ready
|
||||
} catch {
|
||||
// Server not ready yet, wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Server did not become ready within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for port to be free
|
||||
*/
|
||||
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const isFree = await isPortFree(port);
|
||||
if (isFree) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an available port for testing
|
||||
*/
|
||||
@@ -293,6 +67,21 @@ export async function getAvailablePort(startPort: number = 25000): Promise<numbe
|
||||
throw new Error(`No available ports found starting from ${startPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
@@ -332,7 +121,7 @@ export async function createTestServer(options: {
|
||||
}): Promise<ISimpleTestServer> {
|
||||
const hostname = options.hostname || 'localhost';
|
||||
const port = options.port || await getAvailablePort();
|
||||
|
||||
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
if (options.onConnection) {
|
||||
const result = options.onConnection(socket);
|
||||
@@ -344,7 +133,7 @@ export async function createTestServer(options: {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
@@ -353,7 +142,7 @@ export async function createTestServer(options: {
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user