Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 595634fb0f | |||
| cee8a51081 | |||
| f1c5546186 | |||
| 5220ee0857 | |||
| fc2e6d44f4 | |||
| 15a45089aa | |||
| b82468ab1e | |||
| ffe294643c | |||
| f1071faf3d | |||
| 6b082cee8f |
@@ -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 ""
|
||||
|
||||
42
changelog.md
42
changelog.md
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '1.3.1',
|
||||
version: '2.1.0',
|
||||
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
@@ -134,6 +134,7 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
||||
private servers;
|
||||
private stats;
|
||||
dkimCreator: DKIMCreator;
|
||||
private rustBridge;
|
||||
private ipReputationChecker;
|
||||
private bounceManager;
|
||||
private ipWarmupManager;
|
||||
@@ -163,6 +164,22 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
||||
* 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 the Rust bridge.
|
||||
* Falls back gracefully if the bridge is not running.
|
||||
*/
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "2.0.0",
|
||||
"version": "2.2.1",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
|
||||
372
readme.md
372
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartmta
|
||||
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration — no nodemailer, no shortcuts. 🚀
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -14,64 +14,83 @@ pnpm install @push.rocks/smartmta
|
||||
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
|
||||
|
||||
`@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. No wrappers around nodemailer. No half-measures.
|
||||
`@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.
|
||||
|
||||
### ✨ What's Inside
|
||||
### ⚡ What's Inside
|
||||
|
||||
| Module | What It Does |
|
||||
|---|---|
|
||||
| **SMTP Server** | RFC 5321-compliant server with TLS/STARTTLS, authentication, pipelining |
|
||||
| **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 |
|
||||
| **SPF** | Full SPF record validation |
|
||||
| **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, classification (hard/soft), and tracking |
|
||||
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection |
|
||||
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring |
|
||||
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-sender) |
|
||||
| **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 Accelerator** | Performance-critical operations (DKIM, MIME, validation) in Rust via IPC |
|
||||
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
||||
|
||||
### 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
├──────────┬──────────┬───────────┬───────────────────┤
|
||||
│ SMTP │ Email │ Security │ Delivery │
|
||||
│ Server │ Router │ Stack │ System │
|
||||
│ ┌─────┐ │ ┌─────┐ │ ┌──────┐ │ ┌─────────────┐ │
|
||||
│ │ TLS │ │ │Match│ │ │ DKIM │ │ │ Queue │ │
|
||||
│ │ Auth│ │ │Route│ │ │ SPF │ │ │ Rate Limit │ │
|
||||
│ │ Cmd │ │ │ Act │ │ │DMARC │ │ │ SMTP Client │ │
|
||||
│ │ Data│ │ │ │ │ │IPRep │ │ │ Retry Logic │ │
|
||||
│ └─────┘ │ └─────┘ │ │Scan │ │ └─────────────┘ │
|
||||
│ │ │ └──────┘ │ │
|
||||
├──────────┴──────────┴───────────┴───────────────────┤
|
||||
│ Rust Acceleration Layer │
|
||||
│ (mailer-core, mailer-security via smartrust IPC) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 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 │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data flow for inbound mail:**
|
||||
|
||||
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
|
||||
|
||||
### 🔧 Setting Up the Email Server
|
||||
### 🚀 Setting Up the Email Server
|
||||
|
||||
The central entry point is `UnifiedEmailServer`, which orchestrates SMTP, routing, security, and delivery:
|
||||
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
|
||||
|
||||
```typescript
|
||||
import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
|
||||
// Multi-domain configuration
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
@@ -83,11 +102,13 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
rotationInterval: 90,
|
||||
},
|
||||
rateLimits: {
|
||||
maxMessagesPerMinute: 100,
|
||||
maxRecipientsPerMessage: 50,
|
||||
outbound: { messagesPerMinute: 100 },
|
||||
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Routing rules (evaluated by priority, highest first)
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all-forward',
|
||||
@@ -118,31 +139,39 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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, createSmtpClient } from '@push.rocks/smartmta';
|
||||
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||
|
||||
// Create a client with connection pooling
|
||||
const client = createSmtpClient({
|
||||
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false, // will upgrade via STARTTLS
|
||||
@@ -177,9 +206,22 @@ const result = await client.sendMail(email);
|
||||
console.log(`Message sent: ${result.messageId}`);
|
||||
```
|
||||
|
||||
### 🔐 DKIM Signing
|
||||
Additional client factories are available:
|
||||
|
||||
Automatic DKIM key generation, storage, and signing per domain:
|
||||
```typescript
|
||||
// Pooled client for high-throughput scenarios
|
||||
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||
|
||||
// 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';
|
||||
@@ -192,36 +234,45 @@ 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=...' }
|
||||
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
|
||||
|
||||
// Sign an email
|
||||
const signedEmail = await dkimCreator.signEmail(email);
|
||||
// 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}`);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
||||
|
||||
Verify incoming emails against all three authentication standards:
|
||||
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
|
||||
// SPF verification — first arg is an Email object
|
||||
const spfVerifier = new SpfVerifier();
|
||||
const spfResult = await spfVerifier.verify(senderIP, senderDomain, ehloHostname);
|
||||
// → { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror' }
|
||||
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||
// domain: string, ip: string }
|
||||
|
||||
// DKIM verification
|
||||
// DKIM verification — takes raw email content
|
||||
const dkimVerifier = new DKIMVerifier();
|
||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||
|
||||
// DMARC verification
|
||||
// DMARC verification — first arg is an Email object
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
const dmarcResult = await dmarcVerifier.verify(fromDomain, spfResult, dkimResult);
|
||||
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||
```
|
||||
|
||||
### 🔀 Email Routing
|
||||
|
||||
Pattern-based routing engine with priority ordering and flexible match criteria:
|
||||
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';
|
||||
@@ -271,13 +322,26 @@ const router = new EmailRouter([
|
||||
},
|
||||
]);
|
||||
|
||||
// Routes are evaluated by priority (highest first)
|
||||
const matchedRoute = router.route(email, context);
|
||||
// Evaluate routes against an email context
|
||||
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
```
|
||||
|
||||
### 🕵️ Content Scanning
|
||||
**Match criteria available:**
|
||||
|
||||
Built-in content scanner for detecting spam, phishing, malware, and other threats:
|
||||
| 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';
|
||||
@@ -300,25 +364,25 @@ const scanner = new ContentScanner({
|
||||
],
|
||||
});
|
||||
|
||||
const result = await scanner.scan(email);
|
||||
// → { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||
const result = await scanner.scanEmail(email);
|
||||
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||
```
|
||||
|
||||
### 🌐 IP Reputation Checking
|
||||
|
||||
Check sender IP addresses against DNSBL blacklists and classify IP types:
|
||||
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 = new IPReputationChecker({
|
||||
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: [] }
|
||||
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
||||
```
|
||||
|
||||
### ⏱️ Rate Limiting
|
||||
@@ -326,32 +390,47 @@ const reputation = await ipChecker.checkReputation('192.168.1.1');
|
||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||
|
||||
```typescript
|
||||
import { UnifiedRateLimiter } from '@push.rocks/smartmta';
|
||||
import { Delivery } from '@push.rocks/smartmta';
|
||||
const { UnifiedRateLimiter } = Delivery;
|
||||
|
||||
const rateLimiter = new UnifiedRateLimiter({
|
||||
global: {
|
||||
maxPerMinute: 1000,
|
||||
maxPerHour: 10000,
|
||||
maxMessagesPerMinute: 1000,
|
||||
maxRecipientsPerMessage: 500,
|
||||
maxConnectionsPerIP: 20,
|
||||
maxErrorsPerIP: 10,
|
||||
maxAuthFailuresPerIP: 5,
|
||||
blockDuration: 600000, // 10 minutes
|
||||
},
|
||||
perDomain: {
|
||||
domains: {
|
||||
'example.com': {
|
||||
maxPerMinute: 100,
|
||||
maxPerHour: 1000,
|
||||
maxMessagesPerMinute: 100,
|
||||
maxRecipientsPerMessage: 50,
|
||||
},
|
||||
},
|
||||
perSender: {
|
||||
maxPerMinute: 20,
|
||||
maxPerHour: 200,
|
||||
},
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 📬 Bounce Management
|
||||
|
||||
Automatic bounce detection, classification, and tracking:
|
||||
Automatic bounce detection (via Rust), classification, and suppression tracking:
|
||||
|
||||
```typescript
|
||||
import { BounceManager } from '@push.rocks/smartmta';
|
||||
import { Core } from '@push.rocks/smartmta';
|
||||
const { BounceManager } = Core;
|
||||
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
@@ -361,10 +440,14 @@ const bounce = await bounceManager.processSmtpFailure(
|
||||
'550 5.1.1 User unknown',
|
||||
{ originalEmailId: 'msg-123' }
|
||||
);
|
||||
// → { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
||||
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
||||
|
||||
// Check if an address is known to bounce
|
||||
const shouldSuppress = bounceManager.shouldSuppressDelivery('recipient@example.com');
|
||||
// Check if an address is suppressed due to bounces
|
||||
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||
|
||||
// Manually manage the suppression list
|
||||
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||
```
|
||||
|
||||
### 📝 Email Templates
|
||||
@@ -372,11 +455,12 @@ const shouldSuppress = bounceManager.shouldSuppressDelivery('recipient@example.c
|
||||
Template engine with variable substitution for transactional and notification emails:
|
||||
|
||||
```typescript
|
||||
import { TemplateManager } from '@push.rocks/smartmta';
|
||||
import { Core } from '@push.rocks/smartmta';
|
||||
const { TemplateManager } = Core;
|
||||
|
||||
const templates = new TemplateManager({
|
||||
from: 'noreply@example.com',
|
||||
footerHtml: '<p>© 2026 Example Corp</p>',
|
||||
footerHtml: '<p>© 2026 Example Corp</p>',
|
||||
});
|
||||
|
||||
// Register a template
|
||||
@@ -391,70 +475,144 @@ templates.registerTemplate({
|
||||
category: 'transactional',
|
||||
});
|
||||
|
||||
// Render and send
|
||||
const email = templates.renderTemplate('welcome', {
|
||||
// Create an Email object from the template
|
||||
const email = await templates.createEmail('welcome', {
|
||||
to: 'newuser@example.com',
|
||||
variables: { name: 'Alice' },
|
||||
});
|
||||
```
|
||||
|
||||
### 🌍 DNS Management with Cloudflare
|
||||
### 🌍 DNS Management
|
||||
|
||||
Automatic DNS record setup for MX, SPF, DKIM, and DMARC via the Cloudflare API:
|
||||
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:
|
||||
|
||||
```typescript
|
||||
import { DnsManager } from '@push.rocks/smartmta';
|
||||
|
||||
const dnsManager = new DnsManager({
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
hostname: 'mail.example.com',
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
},
|
||||
],
|
||||
// ... other config
|
||||
});
|
||||
|
||||
// Auto-configure all required DNS records
|
||||
await dnsManager.setupDnsForDomain('example.com', {
|
||||
serverIp: '203.0.113.10',
|
||||
mxHostname: 'mail.example.com',
|
||||
});
|
||||
// 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();
|
||||
```
|
||||
|
||||
## 🦀 Rust Acceleration
|
||||
### 🦀 RustSecurityBridge
|
||||
|
||||
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC):
|
||||
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:
|
||||
|
||||
- **mailer-core**: Email type validation, MIME building, bounce detection
|
||||
- **mailer-security**: DKIM signing/verification, SPF checks, DMARC policy, IP reputation/DNSBL
|
||||
```typescript
|
||||
import { RustSecurityBridge } from '@push.rocks/smartmta';
|
||||
|
||||
The Rust workspace is at `rust/` with five crates:
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
await bridge.start();
|
||||
|
||||
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||
const securityResult = await bridge.verifyEmail({
|
||||
rawMessage: rawEmailString,
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||
|
||||
// Individual security operations
|
||||
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||
const spfResult = await bridge.checkSpf({
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
|
||||
|
||||
// DKIM signing
|
||||
const signed = await bridge.signDkim({
|
||||
email: rawEmailString,
|
||||
domain: 'example.com',
|
||||
selector: 'default',
|
||||
privateKeyPem: privateKey,
|
||||
});
|
||||
|
||||
// Content scanning
|
||||
const scanResult = await bridge.scanContent({
|
||||
subject: 'Win a free iPhone!!!',
|
||||
body: '<a href="http://phishing.example.com">Click here</a>',
|
||||
from: 'scammer@evil.com',
|
||||
});
|
||||
|
||||
// Bounce detection
|
||||
const bounceResult = await bridge.detectBounce({
|
||||
subject: 'Delivery Status Notification (Failure)',
|
||||
body: '550 5.1.1 User unknown',
|
||||
from: 'mailer-daemon@example.com',
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
```
|
||||
|
||||
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
|
||||
|
||||
## 🦀 Rust Acceleration Layer
|
||||
|
||||
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 | Email types, validation, MIME, bounce detection |
|
||||
| `mailer-security` | ✅ Complete | DKIM, SPF, DMARC, IP reputation |
|
||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge |
|
||||
| `mailer-smtp` | 🔜 Phase 2 | SMTP protocol in Rust |
|
||||
| `mailer-napi` | 🔜 Phase 2 | Native Node.js addon |
|
||||
| `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
|
||||
├── ts/ # TypeScript source
|
||||
│ ├── mail/
|
||||
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
||||
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
|
||||
│ │ │ └── smtpserver/ # SMTP server with TLS, auth, pipelining
|
||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||
│ └── security/ # ContentScanner, IPReputationChecker, SecurityLogger
|
||||
├── rust/ # Rust workspace
|
||||
│ └── crates/ # mailer-core, mailer-security, mailer-bin, mailer-smtp, mailer-napi
|
||||
├── test/ # Comprehensive test suite (RFC compliance, security, performance, edge cases)
|
||||
└── dist_ts/ # Compiled output
|
||||
│ │ ├── 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
|
||||
|
||||
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 |
|
||||
|
||||
19
rust/Cargo.lock
generated
19
rust/Cargo.lock
generated
@@ -1005,6 +1005,7 @@ name = "mailer-bin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
@@ -1054,6 +1055,7 @@ dependencies = [
|
||||
"mail-auth",
|
||||
"mailer-core",
|
||||
"psl",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
@@ -1067,15 +1069,23 @@ dependencies = [
|
||||
name = "mailer-smtp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"regex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1490,6 +1500,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
|
||||
@@ -18,3 +18,4 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! mailer-bin: CLI and IPC binary for the @serve.zone/mailer Rust crates.
|
||||
//! mailer-bin: CLI and IPC binary for the @push.rocks/smartmta Rust crates.
|
||||
//!
|
||||
//! Supports two modes:
|
||||
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
||||
@@ -6,9 +6,16 @@
|
||||
//! integration with `@push.rocks/smartrust` from TypeScript
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use mailer_smtp::connection::{
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
||||
};
|
||||
|
||||
/// mailer-bin: Rust-powered email security tools
|
||||
#[derive(Parser)]
|
||||
@@ -105,6 +112,43 @@ struct IpcEvent {
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- Pending callbacks for correlation-ID based reverse calls ---
|
||||
|
||||
/// Stores oneshot senders for pending email processing and auth callbacks.
|
||||
struct PendingCallbacks {
|
||||
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||
}
|
||||
|
||||
impl PendingCallbacks {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
email: DashMap::new(),
|
||||
auth: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CallbackRegistry for PendingCallbacks {
|
||||
fn register_email_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<EmailProcessingResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.email.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
fn register_auth_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<AuthResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.auth.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -278,7 +322,17 @@ fn main() {
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Shared state for the management mode.
|
||||
struct ManagementState {
|
||||
callbacks: Arc<PendingCallbacks>,
|
||||
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
||||
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
||||
}
|
||||
|
||||
/// Run in management/IPC mode for smartrust bridge.
|
||||
///
|
||||
/// This mode supports both request/response IPC (existing commands) and
|
||||
/// long-running SMTP server with event-based callbacks.
|
||||
fn run_management_mode() {
|
||||
// Signal readiness
|
||||
let ready_event = IpcEvent {
|
||||
@@ -294,39 +348,153 @@ fn run_management_mode() {
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
let callbacks = Arc::new(PendingCallbacks::new());
|
||||
let mut state = ManagementState {
|
||||
callbacks: callbacks.clone(),
|
||||
smtp_handle: None,
|
||||
smtp_event_rx: None,
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// We need to read stdin in a separate thread (blocking I/O)
|
||||
// and process commands + SMTP events in the tokio runtime.
|
||||
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<String>(256);
|
||||
|
||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = IpcResponse {
|
||||
id: "unknown".to_string(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid request: {}", e)),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&resp).unwrap());
|
||||
io::stdout().flush().unwrap();
|
||||
continue;
|
||||
// Spawn stdin reader thread
|
||||
std::thread::spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
match line {
|
||||
Ok(l) if !l.trim().is_empty() => {
|
||||
if cmd_tx.blocking_send(l).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
let response = rt.block_on(handle_ipc_request(&req));
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
io::stdout().flush().unwrap();
|
||||
rt.block_on(async {
|
||||
loop {
|
||||
// Select between stdin commands and SMTP server events
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(line) => {
|
||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = IpcResponse {
|
||||
id: "unknown".to_string(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid request: {}", e)),
|
||||
};
|
||||
emit_line(&serde_json::to_string(&resp).unwrap());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response = handle_ipc_request(&req, &mut state).await;
|
||||
emit_line(&serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
None => {
|
||||
// stdin closed — shut down
|
||||
if let Some(handle) = state.smtp_handle.take() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
event = async {
|
||||
if let Some(rx) = &mut state.smtp_event_rx {
|
||||
rx.recv().await
|
||||
} else {
|
||||
// No SMTP server running — wait forever (yields to other branch)
|
||||
std::future::pending::<Option<ConnectionEvent>>().await
|
||||
}
|
||||
} => {
|
||||
if let Some(event) = event {
|
||||
handle_smtp_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit a line to stdout and flush.
|
||||
fn emit_line(line: &str) {
|
||||
let stdout = io::stdout();
|
||||
let mut handle = stdout.lock();
|
||||
let _ = writeln!(handle, "{}", line);
|
||||
let _ = handle.flush();
|
||||
}
|
||||
|
||||
/// Emit an IPC event to stdout.
|
||||
fn emit_event(event_name: &str, data: serde_json::Value) {
|
||||
let event = IpcEvent {
|
||||
event: event_name.to_string(),
|
||||
data,
|
||||
};
|
||||
emit_line(&serde_json::to_string(&event).unwrap());
|
||||
}
|
||||
|
||||
/// Handle a connection event from the SMTP server.
|
||||
fn handle_smtp_event(event: ConnectionEvent) {
|
||||
match event {
|
||||
ConnectionEvent::EmailReceived {
|
||||
correlation_id,
|
||||
session_id,
|
||||
mail_from,
|
||||
rcpt_to,
|
||||
data,
|
||||
remote_addr,
|
||||
client_hostname,
|
||||
secure,
|
||||
authenticated_user,
|
||||
security_results,
|
||||
} => {
|
||||
emit_event(
|
||||
"emailReceived",
|
||||
serde_json::json!({
|
||||
"correlationId": correlation_id,
|
||||
"sessionId": session_id,
|
||||
"mailFrom": mail_from,
|
||||
"rcptTo": rcpt_to,
|
||||
"data": data,
|
||||
"remoteAddr": remote_addr,
|
||||
"clientHostname": client_hostname,
|
||||
"secure": secure,
|
||||
"authenticatedUser": authenticated_user,
|
||||
"securityResults": security_results,
|
||||
}),
|
||||
);
|
||||
}
|
||||
ConnectionEvent::AuthRequest {
|
||||
correlation_id,
|
||||
session_id,
|
||||
username,
|
||||
password,
|
||||
remote_addr,
|
||||
} => {
|
||||
emit_event(
|
||||
"authRequest",
|
||||
serde_json::json!({
|
||||
"correlationId": correlation_id,
|
||||
"sessionId": session_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"remoteAddr": remote_addr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
||||
async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||
match req.method.as_str() {
|
||||
"ping" => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
@@ -560,6 +728,25 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
||||
}
|
||||
}
|
||||
|
||||
"scanContent" => {
|
||||
let subject = req.params.get("subject").and_then(|v| v.as_str());
|
||||
let text_body = req.params.get("textBody").and_then(|v| v.as_str());
|
||||
let html_body = req.params.get("htmlBody").and_then(|v| v.as_str());
|
||||
let attachment_names: Vec<String> = req.params.get("attachmentNames")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let result = mailer_security::content_scanner::scan_content(
|
||||
subject, text_body, html_body, &attachment_names
|
||||
);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
"checkSpf" => {
|
||||
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let helo = req
|
||||
@@ -617,6 +804,35 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SMTP Server lifecycle commands ---
|
||||
|
||||
"startSmtpServer" => {
|
||||
handle_start_smtp_server(req, state).await
|
||||
}
|
||||
|
||||
"stopSmtpServer" => {
|
||||
handle_stop_smtp_server(req, state).await
|
||||
}
|
||||
|
||||
"emailProcessingResult" => {
|
||||
handle_email_processing_result(req, state)
|
||||
}
|
||||
|
||||
"authResult" => {
|
||||
handle_auth_result(req, state)
|
||||
}
|
||||
|
||||
"configureRateLimits" => {
|
||||
// Rate limit configuration is set at startSmtpServer time.
|
||||
// This command allows runtime updates, but for now we acknowledge it.
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"configured": true})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
_ => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
@@ -625,3 +841,214 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle startSmtpServer IPC command.
|
||||
async fn handle_start_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||
// Stop existing server if running
|
||||
if let Some(handle) = state.smtp_handle.take() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
// Parse config from params
|
||||
let config = match parse_smtp_config(&req.params) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Parse optional rate limit config
|
||||
let rate_config = req.params.get("rateLimits").and_then(|v| {
|
||||
serde_json::from_value::<mailer_smtp::rate_limiter::RateLimitConfig>(v.clone()).ok()
|
||||
});
|
||||
|
||||
match mailer_smtp::server::start_server(config, state.callbacks.clone(), rate_config).await {
|
||||
Ok((handle, event_rx)) => {
|
||||
state.smtp_handle = Some(handle);
|
||||
state.smtp_event_rx = Some(event_rx);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"started": true})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Failed to start SMTP server: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle stopSmtpServer IPC command.
|
||||
async fn handle_stop_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
|
||||
if let Some(handle) = state.smtp_handle.take() {
|
||||
handle.shutdown().await;
|
||||
state.smtp_event_rx = None;
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"stopped": true})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"stopped": true, "wasRunning": false})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle emailProcessingResult IPC command — resolves a pending email callback.
|
||||
fn handle_email_processing_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
let correlation_id = req
|
||||
.params
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let result = EmailProcessingResult {
|
||||
accepted: req.params.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
smtp_code: req.params.get("smtpCode").and_then(|v| v.as_u64()).map(|v| v as u16),
|
||||
smtp_message: req
|
||||
.params
|
||||
.get("smtpMessage")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
};
|
||||
|
||||
if let Some((_, tx)) = state.callbacks.email.remove(correlation_id) {
|
||||
let _ = tx.send(result);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"resolved": true})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!(
|
||||
"No pending callback for correlationId: {}",
|
||||
correlation_id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle authResult IPC command — resolves a pending auth callback.
|
||||
fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
let correlation_id = req
|
||||
.params
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let result = AuthResult {
|
||||
success: req.params.get("success").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
message: req
|
||||
.params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
};
|
||||
|
||||
if let Some((_, tx)) = state.callbacks.auth.remove(correlation_id) {
|
||||
let _ = tx.send(result);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"resolved": true})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!(
|
||||
"No pending auth callback for correlationId: {}",
|
||||
correlation_id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SmtpServerConfig from IPC params JSON.
|
||||
fn parse_smtp_config(
|
||||
params: &serde_json::Value,
|
||||
) -> Result<mailer_smtp::config::SmtpServerConfig, String> {
|
||||
let mut config = mailer_smtp::config::SmtpServerConfig::default();
|
||||
|
||||
if let Some(hostname) = params.get("hostname").and_then(|v| v.as_str()) {
|
||||
config.hostname = hostname.to_string();
|
||||
}
|
||||
|
||||
if let Some(ports) = params.get("ports").and_then(|v| v.as_array()) {
|
||||
config.ports = ports
|
||||
.iter()
|
||||
.filter_map(|v| v.as_u64().map(|p| p as u16))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(secure_port) = params.get("securePort").and_then(|v| v.as_u64()) {
|
||||
config.secure_port = Some(secure_port as u16);
|
||||
}
|
||||
|
||||
if let Some(cert) = params.get("tlsCertPem").and_then(|v| v.as_str()) {
|
||||
config.tls_cert_pem = Some(cert.to_string());
|
||||
}
|
||||
|
||||
if let Some(key) = params.get("tlsKeyPem").and_then(|v| v.as_str()) {
|
||||
config.tls_key_pem = Some(key.to_string());
|
||||
}
|
||||
|
||||
if let Some(size) = params.get("maxMessageSize").and_then(|v| v.as_u64()) {
|
||||
config.max_message_size = size;
|
||||
}
|
||||
|
||||
if let Some(conns) = params.get("maxConnections").and_then(|v| v.as_u64()) {
|
||||
config.max_connections = conns as u32;
|
||||
}
|
||||
|
||||
if let Some(rcpts) = params.get("maxRecipients").and_then(|v| v.as_u64()) {
|
||||
config.max_recipients = rcpts as u32;
|
||||
}
|
||||
|
||||
if let Some(timeout) = params.get("connectionTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||
config.connection_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
if let Some(timeout) = params.get("dataTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||
config.data_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
if let Some(auth) = params.get("authEnabled").and_then(|v| v.as_bool()) {
|
||||
config.auth_enabled = auth;
|
||||
}
|
||||
|
||||
if let Some(failures) = params.get("maxAuthFailures").and_then(|v| v.as_u64()) {
|
||||
config.max_auth_failures = failures as u32;
|
||||
}
|
||||
|
||||
if let Some(timeout) = params.get("socketTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||
config.socket_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
if let Some(timeout) = params.get("processingTimeoutSecs").and_then(|v| v.as_u64()) {
|
||||
config.processing_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -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::Serialize;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
||||
|
||||
pub mod content_scanner;
|
||||
pub mod dkim;
|
||||
pub mod dmarc;
|
||||
pub mod error;
|
||||
|
||||
@@ -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,10 @@ 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"
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
1023
rust/crates/mailer-smtp/src/connection.rs
Normal file
1023
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);
|
||||
}
|
||||
}
|
||||
308
rust/crates/mailer-smtp/src/server.rs
Normal file
308
rust/crates/mailer-smtp/src/server.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
//! 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 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);
|
||||
|
||||
// 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
|
||||
));
|
||||
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
|
||||
));
|
||||
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,
|
||||
) {
|
||||
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();
|
||||
|
||||
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,
|
||||
)
|
||||
.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,6 +1,13 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
tap.test('setup - start Rust security bridge', async () => {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const ok = await bridge.start();
|
||||
expect(ok).toEqual(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test the BounceManager class
|
||||
@@ -189,6 +196,10 @@ tap.test('BounceManager - should handle retries for soft bounces', async () => {
|
||||
expect(info.expiresAt).toBeUndefined(); // Permanent
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||
await RustSecurityBridge.getInstance().stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
tap.test('setup - start Rust security bridge', async () => {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const ok = await bridge.start();
|
||||
expect(ok).toEqual(true);
|
||||
});
|
||||
|
||||
// Test instantiation
|
||||
tap.test('ContentScanner - should be instantiable', async () => {
|
||||
@@ -258,6 +265,10 @@ tap.test('ContentScanner - should classify threat levels correctly', async () =>
|
||||
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||
await RustSecurityBridge.getInstance().stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { IPReputationChecker, ReputationThreshold } from '../ts/security/classes.ipreputationchecker.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
// Mock for dns lookup
|
||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||
let bridge: RustSecurityBridge;
|
||||
|
||||
// Setup mock DNS resolver with proper typing
|
||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||
return mockDnsResolveImpl(hostname);
|
||||
};
|
||||
// Start the Rust bridge before tests
|
||||
tap.test('setup - start Rust security bridge', async () => {
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
const ok = await bridge.start();
|
||||
expect(ok).toEqual(true);
|
||||
});
|
||||
|
||||
// Test instantiation
|
||||
tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
|
||||
expect(checker).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -26,92 +24,62 @@ tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||
tap.test('IPReputationChecker - should use singleton pattern', async () => {
|
||||
const checker1 = IPReputationChecker.getInstance();
|
||||
const checker2 = IPReputationChecker.getInstance();
|
||||
|
||||
// Both instances should be the same object
|
||||
|
||||
expect(checker1 === checker2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test IP validation
|
||||
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
// Valid IP should work
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const checker = IPReputationChecker.getInstance();
|
||||
|
||||
// Invalid IP should fail with error
|
||||
const invalidResult = await checker.checkReputation('invalid.ip');
|
||||
expect(invalidResult.error).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test DNSBL lookups
|
||||
tap.test('IPReputationChecker - should check IP against DNSBL', async () => {
|
||||
try {
|
||||
// Setup mock implementation for DNSBL
|
||||
mockDnsResolveImpl = async (hostname: string) => {
|
||||
// Listed in DNSBL if IP contains 2
|
||||
if (hostname.includes('2.1.168.192') && hostname.includes('zen.spamhaus.org')) {
|
||||
return ['127.0.0.2'];
|
||||
}
|
||||
throw { code: 'ENOTFOUND' };
|
||||
};
|
||||
// Test reputation check via Rust bridge
|
||||
tap.test('IPReputationChecker - should check IP reputation via Rust', async () => {
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 10
|
||||
});
|
||||
|
||||
// Create a new instance with specific settings for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 1 // Small cache for testing
|
||||
});
|
||||
|
||||
// Clean IP should have good score
|
||||
const cleanResult = await testInstance.checkReputation('192.168.1.1');
|
||||
expect(cleanResult.isSpam).toEqual(false);
|
||||
expect(cleanResult.score).toEqual(100);
|
||||
|
||||
// Blacklisted IP should have reduced score
|
||||
const blacklistedResult = await testInstance.checkReputation('192.168.1.2');
|
||||
expect(blacklistedResult.isSpam).toEqual(true);
|
||||
expect(blacklistedResult.score < 100).toEqual(true); // Less than 100
|
||||
expect(blacklistedResult.blacklists).toBeTruthy();
|
||||
expect((blacklistedResult.blacklists || []).length > 0).toEqual(true);
|
||||
} catch (err) {
|
||||
console.error('Test error:', err);
|
||||
throw err;
|
||||
}
|
||||
// Check a public IP (Google DNS) — should get a result with a score
|
||||
const result = await testInstance.checkReputation('8.8.8.8');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.score).toBeLessThanOrEqual(100);
|
||||
expect(typeof result.isSpam).toEqual('boolean');
|
||||
expect(typeof result.isProxy).toEqual('boolean');
|
||||
expect(typeof result.isTor).toEqual('boolean');
|
||||
expect(typeof result.isVPN).toEqual('boolean');
|
||||
expect(result.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test caching behavior
|
||||
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
||||
// Create a fresh instance for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 10 // Small cache for testing
|
||||
maxCacheSize: 10
|
||||
});
|
||||
|
||||
// Check that first look performs a lookup and second uses cache
|
||||
const ip = '192.168.1.10';
|
||||
|
||||
|
||||
const ip = '1.1.1.1';
|
||||
|
||||
// First check should add to cache
|
||||
const result1 = await testInstance.checkReputation(ip);
|
||||
expect(result1).toBeTruthy();
|
||||
|
||||
// Manually verify it's in cache - access private member for testing
|
||||
|
||||
// Verify it's in cache
|
||||
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
||||
expect(hasInCache).toEqual(true);
|
||||
|
||||
|
||||
// Call again, should use cache
|
||||
const result2 = await testInstance.checkReputation(ip);
|
||||
expect(result2).toBeTruthy();
|
||||
|
||||
// Results should be identical
|
||||
|
||||
// Results should be identical (from cache)
|
||||
expect(result1.score).toEqual(result2.score);
|
||||
expect(result1.isSpam).toEqual(result2.isSpam);
|
||||
});
|
||||
|
||||
// Test risk level classification
|
||||
@@ -122,58 +90,27 @@ tap.test('IPReputationChecker - should classify risk levels correctly', async ()
|
||||
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
||||
});
|
||||
|
||||
// Test IP type detection
|
||||
tap.test('IPReputationChecker - should detect special IP types', async () => {
|
||||
// Test error handling for error result
|
||||
tap.test('IPReputationChecker - should handle errors gracefully', async () => {
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: true,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 5 // Small cache for testing
|
||||
maxCacheSize: 5
|
||||
});
|
||||
|
||||
// Test Tor exit node detection
|
||||
const torResult = await testInstance.checkReputation('171.25.1.1');
|
||||
expect(torResult.isTor).toEqual(true);
|
||||
expect(torResult.score < 90).toEqual(true);
|
||||
|
||||
// Test VPN detection
|
||||
const vpnResult = await testInstance.checkReputation('185.156.1.1');
|
||||
expect(vpnResult.isVPN).toEqual(true);
|
||||
expect(vpnResult.score < 90).toEqual(true);
|
||||
|
||||
// Test proxy detection
|
||||
const proxyResult = await testInstance.checkReputation('34.92.1.1');
|
||||
expect(proxyResult.isProxy).toEqual(true);
|
||||
expect(proxyResult.score < 90).toEqual(true);
|
||||
});
|
||||
|
||||
// Test error handling
|
||||
tap.test('IPReputationChecker - should handle DNS lookup errors gracefully', async () => {
|
||||
// Setup mock implementation to simulate error
|
||||
mockDnsResolveImpl = async () => {
|
||||
throw new Error('DNS server error');
|
||||
};
|
||||
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 300 // Force new instance
|
||||
});
|
||||
|
||||
// Should return a result despite errors
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toEqual(100); // No blacklist hits found due to error
|
||||
// Invalid format should return error result with neutral score
|
||||
const result = await testInstance.checkReputation('not-an-ip');
|
||||
expect(result.score).toEqual(50);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.isSpam).toEqual(false);
|
||||
});
|
||||
|
||||
// Restore original implementation at the end
|
||||
tap.test('Cleanup - restore mocks', async () => {
|
||||
plugins.dns.promises.resolve = originalDnsResolve;
|
||||
// Stop bridge
|
||||
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
136
test/test.rustsecuritybridge.node.ts
Normal file
136
test/test.rustsecuritybridge.node.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
let bridge: RustSecurityBridge;
|
||||
|
||||
tap.test('RustSecurityBridge - should get singleton instance', async () => {
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
expect(bridge).toBeTruthy();
|
||||
expect(bridge).toEqual(RustSecurityBridge.getInstance()); // same instance
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - should start the Rust binary', async () => {
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('WARNING: Rust binary not available — skipping bridge tests');
|
||||
console.log('Build it with: cd rust && cargo build --release');
|
||||
}
|
||||
// We accept both true and false — the binary may not be built yet
|
||||
expect(typeof ok).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - ping should return pong', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const pong = await bridge.ping();
|
||||
expect(pong).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - version should return crate versions', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const version = await bridge.getVersion();
|
||||
expect(version.bin).toBeTruthy();
|
||||
expect(version.core).toBeTruthy();
|
||||
expect(version.security).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - validateEmail with valid address', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const result = await bridge.validateEmail('test@example.com');
|
||||
expect(result.valid).toBeTrue();
|
||||
expect(result.formatValid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - validateEmail with invalid address', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const result = await bridge.validateEmail('not-an-email');
|
||||
expect(result.formatValid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - detectBounce with known SMTP response', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const result = await bridge.detectBounce({
|
||||
smtpResponse: '550 5.1.1 User unknown',
|
||||
});
|
||||
expect(result.bounce_type).toBeTruthy();
|
||||
expect(result.category).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - checkIpReputation', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
// Use a well-known IP that should NOT be on blacklists
|
||||
const result = await bridge.checkIpReputation('8.8.8.8');
|
||||
expect(result.ip).toEqual('8.8.8.8');
|
||||
expect(typeof result.score).toEqual('number');
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - verifyDkim with unsigned message', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
|
||||
const results = await bridge.verifyDkim(rawMessage);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].status).toEqual('none'); // no DKIM signature
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - verifyEmail compound call', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
const rawMessage = 'From: test@example.com\r\nTo: receiver@example.com\r\nSubject: Test\r\n\r\nHello';
|
||||
const result = await bridge.verifyEmail({
|
||||
rawMessage,
|
||||
ip: '93.184.216.34', // example.com IP
|
||||
heloDomain: 'example.com',
|
||||
hostname: 'mail.test.local',
|
||||
mailFrom: 'test@example.com',
|
||||
});
|
||||
expect(result.dkim).toBeTruthy();
|
||||
expect(result.dkim.length).toBeGreaterThan(0);
|
||||
expect(result.spf).toBeTruthy();
|
||||
// DMARC may or may not be present depending on DNS
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - should stop gracefully', async () => {
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
await bridge.stop();
|
||||
expect(bridge.running).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('RustSecurityBridge - commands should fail when bridge is stopped', async () => {
|
||||
// Bridge should not be running now
|
||||
expect(bridge.running).toBeFalse();
|
||||
try {
|
||||
await bridge.ping();
|
||||
// If we get here, the bridge auto-restarted or something unexpected
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (err) {
|
||||
expect(err).toBeTruthy(); // Expected: bridge not running error
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '2.0.0',
|
||||
version: '2.2.1',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import type { Email } from './classes.email.js';
|
||||
|
||||
@@ -63,112 +64,6 @@ export interface BounceRecord {
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
|
||||
*/
|
||||
const BOUNCE_PATTERNS = {
|
||||
// Hard bounce patterns
|
||||
[BounceType.INVALID_RECIPIENT]: [
|
||||
/no such user/i,
|
||||
/user unknown/i,
|
||||
/does not exist/i,
|
||||
/invalid recipient/i,
|
||||
/unknown recipient/i,
|
||||
/no mailbox/i,
|
||||
/user not found/i,
|
||||
/recipient address rejected/i,
|
||||
/550 5\.1\.1/i
|
||||
],
|
||||
[BounceType.DOMAIN_NOT_FOUND]: [
|
||||
/domain not found/i,
|
||||
/unknown domain/i,
|
||||
/no such domain/i,
|
||||
/host not found/i,
|
||||
/domain invalid/i,
|
||||
/550 5\.1\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_FULL]: [
|
||||
/mailbox full/i,
|
||||
/over quota/i,
|
||||
/quota exceeded/i,
|
||||
/552 5\.2\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_INACTIVE]: [
|
||||
/mailbox disabled/i,
|
||||
/mailbox inactive/i,
|
||||
/account disabled/i,
|
||||
/mailbox not active/i,
|
||||
/account suspended/i
|
||||
],
|
||||
[BounceType.BLOCKED]: [
|
||||
/blocked/i,
|
||||
/rejected/i,
|
||||
/denied/i,
|
||||
/blacklisted/i,
|
||||
/prohibited/i,
|
||||
/refused/i,
|
||||
/550 5\.7\./i
|
||||
],
|
||||
[BounceType.SPAM_RELATED]: [
|
||||
/spam/i,
|
||||
/bulk mail/i,
|
||||
/content rejected/i,
|
||||
/message rejected/i,
|
||||
/550 5\.7\.1/i
|
||||
],
|
||||
|
||||
// Soft bounce patterns
|
||||
[BounceType.SERVER_UNAVAILABLE]: [
|
||||
/server unavailable/i,
|
||||
/service unavailable/i,
|
||||
/try again later/i,
|
||||
/try later/i,
|
||||
/451 4\.3\./i,
|
||||
/421 4\.3\./i
|
||||
],
|
||||
[BounceType.TEMPORARY_FAILURE]: [
|
||||
/temporary failure/i,
|
||||
/temporary error/i,
|
||||
/temporary problem/i,
|
||||
/try again/i,
|
||||
/451 4\./i
|
||||
],
|
||||
[BounceType.QUOTA_EXCEEDED]: [
|
||||
/quota temporarily exceeded/i,
|
||||
/mailbox temporarily full/i,
|
||||
/452 4\.2\.2/i
|
||||
],
|
||||
[BounceType.NETWORK_ERROR]: [
|
||||
/network error/i,
|
||||
/connection error/i,
|
||||
/connection timed out/i,
|
||||
/routing error/i,
|
||||
/421 4\.4\./i
|
||||
],
|
||||
[BounceType.TIMEOUT]: [
|
||||
/timed out/i,
|
||||
/timeout/i,
|
||||
/450 4\.4\.2/i
|
||||
],
|
||||
|
||||
// Auto-responses
|
||||
[BounceType.AUTO_RESPONSE]: [
|
||||
/auto[- ]reply/i,
|
||||
/auto[- ]response/i,
|
||||
/vacation/i,
|
||||
/out of office/i,
|
||||
/away from office/i,
|
||||
/on vacation/i,
|
||||
/automatic reply/i
|
||||
],
|
||||
[BounceType.CHALLENGE_RESPONSE]: [
|
||||
/challenge[- ]response/i,
|
||||
/verify your email/i,
|
||||
/confirm your email/i,
|
||||
/email verification/i
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry strategy configuration for soft bounces
|
||||
*/
|
||||
@@ -269,16 +164,16 @@ export class BounceManager {
|
||||
nextRetryTime: bounceData.nextRetryTime
|
||||
};
|
||||
|
||||
// Determine bounce type and category if not provided
|
||||
// Determine bounce type and category via Rust bridge if not provided
|
||||
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
||||
const bounceInfo = this.detectBounceType(
|
||||
bounce.smtpResponse || '',
|
||||
bounce.diagnosticCode || '',
|
||||
bounce.statusCode || ''
|
||||
);
|
||||
|
||||
bounce.bounceType = bounceInfo.type;
|
||||
bounce.bounceCategory = bounceInfo.category;
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const rustResult = await bridge.detectBounce({
|
||||
smtpResponse: bounce.smtpResponse,
|
||||
diagnosticCode: bounce.diagnosticCode,
|
||||
statusCode: bounce.statusCode,
|
||||
});
|
||||
bounce.bounceType = rustResult.bounce_type as BounceType;
|
||||
bounce.bounceCategory = rustResult.category as BounceCategory;
|
||||
}
|
||||
|
||||
// Process the bounce based on category
|
||||
@@ -791,134 +686,6 @@ export class BounceManager {
|
||||
return this.bounceCache.get(email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
||||
* @param smtpResponse SMTP response string
|
||||
* @param diagnosticCode Diagnostic code from bounce
|
||||
* @param statusCode Status code from bounce
|
||||
* @returns Detected bounce type and category
|
||||
*/
|
||||
private detectBounceType(
|
||||
smtpResponse: string,
|
||||
diagnosticCode: string,
|
||||
statusCode: string
|
||||
): {
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} {
|
||||
// Combine all text for comprehensive pattern matching
|
||||
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
|
||||
|
||||
// Check for auto-responses first
|
||||
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
|
||||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
|
||||
return {
|
||||
type: BounceType.AUTO_RESPONSE,
|
||||
category: BounceCategory.AUTO_RESPONSE
|
||||
};
|
||||
}
|
||||
|
||||
// Check for hard bounces
|
||||
for (const bounceType of [
|
||||
BounceType.INVALID_RECIPIENT,
|
||||
BounceType.DOMAIN_NOT_FOUND,
|
||||
BounceType.MAILBOX_FULL,
|
||||
BounceType.MAILBOX_INACTIVE,
|
||||
BounceType.BLOCKED,
|
||||
BounceType.SPAM_RELATED,
|
||||
BounceType.POLICY_RELATED
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.HARD
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for soft bounces
|
||||
for (const bounceType of [
|
||||
BounceType.SERVER_UNAVAILABLE,
|
||||
BounceType.TEMPORARY_FAILURE,
|
||||
BounceType.QUOTA_EXCEEDED,
|
||||
BounceType.NETWORK_ERROR,
|
||||
BounceType.TIMEOUT
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.SOFT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DSN (Delivery Status Notification) status codes
|
||||
if (statusCode) {
|
||||
// Format: class.subject.detail
|
||||
const parts = statusCode.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const statusClass = parts[0];
|
||||
const statusSubject = parts[1];
|
||||
|
||||
// 5.X.X is permanent failure (hard bounce)
|
||||
if (statusClass === '5') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '1') {
|
||||
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '2') {
|
||||
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '7') {
|
||||
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
|
||||
} else {
|
||||
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
|
||||
}
|
||||
}
|
||||
|
||||
// 4.X.X is temporary failure (soft bounce)
|
||||
if (statusClass === '4') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '2') {
|
||||
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '3') {
|
||||
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '4') {
|
||||
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
|
||||
} else {
|
||||
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown
|
||||
return {
|
||||
type: BounceType.UNKNOWN,
|
||||
category: BounceCategory.UNKNOWN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text matches any pattern for a bounce type
|
||||
* @param text Text to check against patterns
|
||||
* @param bounceType Bounce type to get patterns for
|
||||
* @returns Whether the text matches any pattern
|
||||
*/
|
||||
private matchesPattern(text: string, bounceType: BounceType): boolean {
|
||||
const patterns = BOUNCE_PATTERNS[bounceType];
|
||||
|
||||
if (!patterns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known hard bounced addresses
|
||||
* @returns Array of hard bounced email addresses
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
/**
|
||||
* Delivery status enumeration
|
||||
@@ -763,33 +764,24 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
try {
|
||||
// Ensure DKIM keys exist for the domain
|
||||
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
|
||||
|
||||
|
||||
// Get the private key
|
||||
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
|
||||
|
||||
// Convert Email to raw format for signing
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Sign the email
|
||||
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
|
||||
const signResult = await plugins.dkimSign(rawEmail, {
|
||||
signingDomain: domainName,
|
||||
|
||||
// Sign via Rust bridge
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const signResult = await bridge.signDkim({
|
||||
rawMessage: rawEmail,
|
||||
domain: domainName,
|
||||
selector: keySelector,
|
||||
privateKey: dkimPrivateKey,
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
algorithm: 'rsa-sha256',
|
||||
signTime: new Date(),
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: domainName,
|
||||
selector: keySelector,
|
||||
privateKey: dkimPrivateKey,
|
||||
algorithm: 'rsa-sha256',
|
||||
canonicalization: 'relaxed/relaxed'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Add the DKIM-Signature header to the email
|
||||
if (signResult.signatures) {
|
||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
||||
|
||||
if (signResult.header) {
|
||||
email.addHeader('DKIM-Signature', signResult.header);
|
||||
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
interface Headers {
|
||||
[key: string]: string;
|
||||
@@ -28,24 +29,13 @@ export class EmailSignJob {
|
||||
|
||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
import {
|
||||
MtaConnectionError,
|
||||
@@ -844,42 +845,22 @@ export class SmtpClient {
|
||||
|
||||
try {
|
||||
logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`);
|
||||
|
||||
// Format email for DKIM signing
|
||||
const { dkimSign } = plugins;
|
||||
|
||||
const emailContent = await this.getFormattedEmail(email);
|
||||
|
||||
// Sign email
|
||||
const signOptions = {
|
||||
signingDomain: this.options.dkim.domain,
|
||||
|
||||
// Sign via Rust bridge
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const signResult = await bridge.signDkim({
|
||||
rawMessage: emailContent,
|
||||
domain: this.options.dkim.domain,
|
||||
selector: this.options.dkim.selector,
|
||||
privateKey: this.options.dkim.privateKey,
|
||||
canonicalization: 'relaxed/relaxed' as const,
|
||||
algorithm: 'rsa-sha256' as const,
|
||||
signTime: new Date(),
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: this.options.dkim.domain,
|
||||
selector: this.options.dkim.selector,
|
||||
privateKey: this.options.dkim.privateKey,
|
||||
algorithm: 'rsa-sha256',
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const signResult = await dkimSign(emailContent, signOptions);
|
||||
|
||||
// Add DKIM-Signature header from the signing result
|
||||
if (signResult.signatures) {
|
||||
const dkimHeader = signResult.signatures.split('\r\n')
|
||||
.find(line => line.startsWith('DKIM-Signature: '));
|
||||
|
||||
if (dkimHeader) {
|
||||
email.addHeader('DKIM-Signature', dkimHeader.substring('DKIM-Signature: '.length));
|
||||
}
|
||||
if (signResult.header) {
|
||||
email.addHeader('DKIM-Signature', signResult.header);
|
||||
}
|
||||
|
||||
|
||||
logger.log('debug', 'DKIM signature applied successfully');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to apply DKIM signature: ${error.message}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||
// Deliverability types (IPWarmupManager and SenderReputationMonitor are optional external modules)
|
||||
interface IIPWarmupConfig {
|
||||
enabled?: boolean;
|
||||
@@ -366,13 +367,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
await this.deliverySystem.start();
|
||||
logger.log('info', 'Email delivery system started');
|
||||
|
||||
// Start Rust security bridge (non-blocking — server works without it)
|
||||
// Start Rust security bridge — required for all security operations
|
||||
const bridgeOk = await this.rustBridge.start();
|
||||
if (bridgeOk) {
|
||||
logger.log('info', 'Rust security bridge started — using Rust for DKIM/SPF/DMARC verification');
|
||||
} else {
|
||||
logger.log('warn', 'Rust security bridge unavailable — falling back to TypeScript security verification');
|
||||
if (!bridgeOk) {
|
||||
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
|
||||
}
|
||||
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
||||
|
||||
// Set up DKIM for all domains
|
||||
await this.setupDkimForDomains();
|
||||
@@ -397,137 +397,86 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
this.emit('started');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure we have the necessary TLS options
|
||||
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
||||
|
||||
|
||||
// Prepare the certificate and key if available
|
||||
let key: string | undefined;
|
||||
let cert: string | undefined;
|
||||
|
||||
let tlsCertPem: string | undefined;
|
||||
let tlsKeyPem: string | undefined;
|
||||
|
||||
if (hasTlsConfig) {
|
||||
try {
|
||||
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||
cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||
logger.log('info', 'TLS certificates loaded successfully');
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a SMTP server for each port
|
||||
for (const port of this.options.ports as number[]) {
|
||||
// Create a reference object to hold the MTA service during setup
|
||||
const mtaRef = {
|
||||
config: {
|
||||
smtp: {
|
||||
hostname: this.options.hostname
|
||||
},
|
||||
security: {
|
||||
checkIPReputation: false,
|
||||
verifyDkim: true,
|
||||
verifySpf: true,
|
||||
verifyDmarc: true
|
||||
}
|
||||
},
|
||||
// Security verification delegated to the Rust bridge when available
|
||||
dkimVerifier: {
|
||||
verify: async (rawMessage: string) => {
|
||||
if (this.rustBridge.running) {
|
||||
try {
|
||||
const results = await this.rustBridge.verifyDkim(rawMessage);
|
||||
const first = results[0];
|
||||
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust DKIM verification failed, accepting: ${(err as Error).message}`);
|
||||
return { isValid: true, domain: '' };
|
||||
}
|
||||
}
|
||||
return { isValid: true, domain: '' }; // No bridge — accept
|
||||
}
|
||||
},
|
||||
spfVerifier: {
|
||||
verifyAndApply: async (session: any) => {
|
||||
if (this.rustBridge.running && session?.remoteAddress && session.remoteAddress !== '127.0.0.1') {
|
||||
try {
|
||||
const result = await this.rustBridge.checkSpf({
|
||||
ip: session.remoteAddress,
|
||||
heloDomain: session.clientHostname || '',
|
||||
hostname: this.options.hostname,
|
||||
mailFrom: session.envelope?.mailFrom?.address || session.mailFrom || '',
|
||||
});
|
||||
return result.result === 'pass' || result.result === 'none' || result.result === 'neutral';
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust SPF check failed, accepting: ${(err as Error).message}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true; // No bridge or localhost — accept
|
||||
}
|
||||
},
|
||||
dmarcVerifier: {
|
||||
verify: async () => ({}),
|
||||
applyPolicy: () => true
|
||||
},
|
||||
processIncomingEmail: async (email: Email) => {
|
||||
// Process email using the new route-based system
|
||||
await this.processEmailByMode(email, {
|
||||
id: 'session-' + Math.random().toString(36).substring(2),
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: email.from,
|
||||
rcptTo: email.to,
|
||||
emailData: email.toRFC822String(), // Use the proper method to get the full email content
|
||||
useTLS: false,
|
||||
connectionEnded: true,
|
||||
remoteAddress: '127.0.0.1',
|
||||
clientHostname: '',
|
||||
secure: false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: email.from, args: {} },
|
||||
rcptTo: email.to.map(recipient => ({ address: recipient, args: {} }))
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Create server options
|
||||
const serverOptions = {
|
||||
port,
|
||||
hostname: this.options.hostname,
|
||||
key,
|
||||
cert
|
||||
};
|
||||
|
||||
// Create and start the SMTP server
|
||||
const smtpServer = createSmtpServer(mtaRef as any, serverOptions);
|
||||
this.servers.push(smtpServer);
|
||||
|
||||
// Start the server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
// Leave this empty for now, smtpServer.start() is handled by the SMTPServer class internally
|
||||
// The server is started when it's created
|
||||
logger.log('info', `UnifiedEmailServer listening on port ${port}`);
|
||||
|
||||
// Event handlers are managed internally by the SmtpServer class
|
||||
// No need to access the private server property
|
||||
|
||||
resolve();
|
||||
} catch (err) {
|
||||
if ((err as any).code === 'EADDRINUSE') {
|
||||
logger.log('error', `Port ${port} is already in use`);
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
logger.log('error', `Error starting server on port ${port}: ${err.message}`);
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Start Rust SMTP server ---
|
||||
// Register event handlers for email reception and auth
|
||||
this.rustBridge.onEmailReceived(async (data) => {
|
||||
try {
|
||||
await this.handleRustEmailReceived(data);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||
// Send rejection back to Rust
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId: data.correlationId,
|
||||
accepted: false,
|
||||
smtpCode: 451,
|
||||
smtpMessage: 'Internal processing error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.rustBridge.onAuthRequest(async (data) => {
|
||||
try {
|
||||
await this.handleRustAuthRequest(data);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`);
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId: data.correlationId,
|
||||
success: false,
|
||||
message: 'Internal auth error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Determine which ports need STARTTLS and which need implicit TLS
|
||||
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
||||
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||
|
||||
const started = await this.rustBridge.startSmtpServer({
|
||||
hostname: this.options.hostname,
|
||||
ports: smtpPorts,
|
||||
securePort: securePort,
|
||||
tlsCertPem,
|
||||
tlsKeyPem,
|
||||
maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024,
|
||||
maxConnections: this.options.maxConnections || this.options.maxClients || 100,
|
||||
maxRecipients: 100,
|
||||
connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
|
||||
dataTimeoutSecs: 60,
|
||||
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
||||
maxAuthFailures: 3,
|
||||
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
||||
processingTimeoutSecs: 30,
|
||||
rateLimits: this.options.rateLimits ? {
|
||||
maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
|
||||
maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
|
||||
maxAuthFailuresPerIp: this.options.rateLimits.global?.maxAuthFailuresPerIP || 5,
|
||||
windowSecs: 60,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
throw new Error('Failed to start Rust SMTP server');
|
||||
}
|
||||
|
||||
|
||||
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
||||
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||
this.emit('started');
|
||||
} catch (error) {
|
||||
@@ -591,6 +540,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
logger.log('info', 'Stopping UnifiedEmailServer');
|
||||
|
||||
try {
|
||||
// Stop the Rust SMTP server first
|
||||
try {
|
||||
await this.rustBridge.stopSmtpServer();
|
||||
logger.log('info', 'Rust SMTP server stopped');
|
||||
} catch (err) {
|
||||
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Clear the servers array - servers will be garbage collected
|
||||
this.servers = [];
|
||||
|
||||
@@ -627,20 +584,117 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rust SMTP server event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle an emailReceived event from the Rust SMTP server.
|
||||
* Decodes the email data, processes it through the routing system,
|
||||
* and sends back the result via the correlation-ID callback.
|
||||
*/
|
||||
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
||||
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
||||
|
||||
logger.log('info', `Rust SMTP received email from=${mailFrom} to=${rcptTo.join(',')} remote=${remoteAddr}`);
|
||||
|
||||
try {
|
||||
// Decode the email data
|
||||
let rawMessageBuffer: Buffer;
|
||||
if (data.data.type === 'inline' && data.data.base64) {
|
||||
rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
|
||||
} else if (data.data.type === 'file' && data.data.path) {
|
||||
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
||||
// Clean up temp file
|
||||
try {
|
||||
plugins.fs.unlinkSync(data.data.path);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid email data transport');
|
||||
}
|
||||
|
||||
// Build a session-like object for processEmailByMode
|
||||
const session: IExtendedSmtpSession = {
|
||||
id: data.sessionId || 'rust-' + Math.random().toString(36).substring(2),
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: mailFrom,
|
||||
rcptTo: rcptTo,
|
||||
emailData: rawMessageBuffer.toString('utf8'),
|
||||
useTLS: secure,
|
||||
connectionEnded: false,
|
||||
remoteAddress: remoteAddr,
|
||||
clientHostname: clientHostname || '',
|
||||
secure: secure,
|
||||
authenticated: !!authenticatedUser,
|
||||
envelope: {
|
||||
mailFrom: { address: mailFrom, args: {} },
|
||||
rcptTo: rcptTo.map(addr => ({ address: addr, args: {} })),
|
||||
},
|
||||
};
|
||||
|
||||
if (authenticatedUser) {
|
||||
session.user = { username: authenticatedUser };
|
||||
}
|
||||
|
||||
// Process the email through the routing system
|
||||
await this.processEmailByMode(rawMessageBuffer, session);
|
||||
|
||||
// Send acceptance back to Rust
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId,
|
||||
accepted: true,
|
||||
smtpCode: 250,
|
||||
smtpMessage: '2.0.0 Message accepted for delivery',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId,
|
||||
accepted: false,
|
||||
smtpCode: 550,
|
||||
smtpMessage: `5.0.0 Processing failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an authRequest event from the Rust SMTP server.
|
||||
* Validates credentials and sends back the result.
|
||||
*/
|
||||
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
||||
const { correlationId, username, password, remoteAddr } = data;
|
||||
|
||||
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
||||
|
||||
// Check against configured users
|
||||
const users = this.options.auth?.users || [];
|
||||
const matched = users.find(
|
||||
u => u.username === username && u.password === password
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId,
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
logger.log('warn', `Auth failed for user=${username} from=${remoteAddr}`);
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId,
|
||||
success: false,
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
||||
* Falls back gracefully if the bridge is not running.
|
||||
*/
|
||||
private async verifyInboundSecurity(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
||||
if (!this.rustBridge.running) {
|
||||
return; // Bridge not available — skip verification
|
||||
}
|
||||
|
||||
try {
|
||||
const rawMessage = session.emailData || email.toRFC822String();
|
||||
const result = await this.rustBridge.verifyEmail({
|
||||
@@ -656,12 +710,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
const dkimSummary = result.dkim
|
||||
.map(d => `${d.status}${d.domain ? ` (${d.domain})` : ''}`)
|
||||
.join(', ');
|
||||
email.setHeader('X-DKIM-Result', dkimSummary);
|
||||
email.addHeader('X-DKIM-Result', dkimSummary);
|
||||
}
|
||||
|
||||
// Apply SPF result header
|
||||
if (result.spf) {
|
||||
email.setHeader('Received-SPF', `${result.spf.result} (domain: ${result.spf.domain}, ip: ${result.spf.ip})`);
|
||||
email.addHeader('Received-SPF', `${result.spf.result} (domain: ${result.spf.domain}, ip: ${result.spf.ip})`);
|
||||
|
||||
// Mark as spam on SPF hard fail
|
||||
if (result.spf.result === 'fail') {
|
||||
@@ -672,7 +726,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
|
||||
// Apply DMARC result header and policy
|
||||
if (result.dmarc) {
|
||||
email.setHeader('X-DMARC-Result', `${result.dmarc.action} (policy=${result.dmarc.policy}, dkim=${result.dmarc.dkim_result}, spf=${result.dmarc.spf_result})`);
|
||||
email.addHeader('X-DMARC-Result', `${result.dmarc.action} (policy=${result.dmarc.policy}, dkim=${result.dmarc.dkim_result}, spf=${result.dmarc.spf_result})`);
|
||||
|
||||
if (result.dmarc.action === 'reject') {
|
||||
email.mightBeSpam = true;
|
||||
@@ -942,52 +996,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
|
||||
// Apply DKIM signing if enabled
|
||||
if (options.dkimSign && options.dkimOptions) {
|
||||
// Sign the email with DKIM
|
||||
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
|
||||
|
||||
try {
|
||||
// Ensure DKIM keys exist for the domain
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(options.dkimOptions.domainName);
|
||||
|
||||
// Convert Email to raw format for signing
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Create headers object
|
||||
const headers = {};
|
||||
for (const [key, value] of Object.entries(email.headers)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
// Sign the email
|
||||
const dkimDomain = options.dkimOptions.domainName;
|
||||
const dkimSelector = options.dkimOptions.keySelector || 'mta';
|
||||
const dkimPrivateKey = (await this.dkimCreator.readDKIMKeys(dkimDomain)).privateKey;
|
||||
const signResult = await plugins.dkimSign(rawEmail, {
|
||||
signingDomain: dkimDomain,
|
||||
selector: dkimSelector,
|
||||
privateKey: dkimPrivateKey,
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
algorithm: 'rsa-sha256',
|
||||
signTime: new Date(),
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: dkimDomain,
|
||||
selector: dkimSelector,
|
||||
privateKey: dkimPrivateKey,
|
||||
algorithm: 'rsa-sha256',
|
||||
canonicalization: 'relaxed/relaxed'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Add the DKIM-Signature header to the email
|
||||
if (signResult.signatures) {
|
||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
||||
logger.log('info', `Successfully added DKIM signature for ${options.dkimOptions.domainName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
||||
}
|
||||
const dkimDomain = options.dkimOptions.domainName;
|
||||
const dkimSelector = options.dkimOptions.keySelector || 'mta';
|
||||
logger.log('info', `Signing email with DKIM for domain ${dkimDomain}`);
|
||||
await this.handleDkimSigning(email, dkimDomain, dkimSelector);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1555,35 +1567,23 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
try {
|
||||
// Ensure we have DKIM keys for this domain
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||
|
||||
|
||||
// Get the private key
|
||||
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||
|
||||
|
||||
// Convert Email to raw format for signing
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Sign the email
|
||||
const signResult = await plugins.dkimSign(rawEmail, {
|
||||
signingDomain: domain,
|
||||
selector: selector,
|
||||
privateKey: privateKey,
|
||||
canonicalization: 'relaxed/relaxed',
|
||||
algorithm: 'rsa-sha256',
|
||||
signTime: new Date(),
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: domain,
|
||||
selector: selector,
|
||||
privateKey: privateKey,
|
||||
algorithm: 'rsa-sha256',
|
||||
canonicalization: 'relaxed/relaxed'
|
||||
}
|
||||
]
|
||||
|
||||
// Sign the email via Rust bridge
|
||||
const signResult = await this.rustBridge.signDkim({
|
||||
rawMessage: rawEmail,
|
||||
domain,
|
||||
selector,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
// Add the DKIM-Signature header to the email
|
||||
if (signResult.signatures) {
|
||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
||||
|
||||
if (signResult.header) {
|
||||
email.addHeader('DKIM-Signature', signResult.header);
|
||||
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
// MtaService reference removed
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
/**
|
||||
* Result of a DKIM verification
|
||||
@@ -17,23 +16,13 @@ export interface IDkimVerificationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced DKIM verifier using smartmail capabilities
|
||||
* DKIM verifier — delegates to the Rust security bridge.
|
||||
*/
|
||||
export class DKIMVerifier {
|
||||
// MtaRef reference removed
|
||||
|
||||
// Cache verified results to avoid repeated verification
|
||||
private verificationCache: Map<string, { result: IDkimVerificationResult, timestamp: number }> = new Map();
|
||||
private cacheTtl = 30 * 60 * 1000; // 30 minutes cache
|
||||
|
||||
constructor() {
|
||||
}
|
||||
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
|
||||
*/
|
||||
public async verify(
|
||||
emailData: string,
|
||||
@@ -43,340 +32,55 @@ export class DKIMVerifier {
|
||||
} = {}
|
||||
): Promise<IDkimVerificationResult> {
|
||||
try {
|
||||
// Generate a cache key from the first 128 bytes of the email data
|
||||
const cacheKey = emailData.slice(0, 128);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const results = await bridge.verifyDkim(emailData);
|
||||
const first = results[0];
|
||||
|
||||
// Check cache if enabled
|
||||
if (options.useCache !== false) {
|
||||
const cached = this.verificationCache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTtl) {
|
||||
logger.log('info', 'DKIM verification result from cache');
|
||||
return cached.result;
|
||||
}
|
||||
}
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: first?.is_valid ?? false,
|
||||
domain: first?.domain ?? undefined,
|
||||
selector: first?.selector ?? undefined,
|
||||
status: first?.status ?? 'none',
|
||||
details: options.returnDetails ? results : undefined,
|
||||
};
|
||||
|
||||
// Try to verify using mailauth first
|
||||
try {
|
||||
const verificationMailauth = await plugins.mailauth.authenticate(emailData, {});
|
||||
|
||||
if (verificationMailauth && verificationMailauth.dkim && verificationMailauth.dkim.results.length > 0) {
|
||||
const dkimResult = verificationMailauth.dkim.results[0];
|
||||
const isValid = dkimResult.status.result === 'pass';
|
||||
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid,
|
||||
domain: dkimResult.signingDomain,
|
||||
selector: dkimResult.selector,
|
||||
status: dkimResult.status.result,
|
||||
signatureFields: (dkimResult as any).signature,
|
||||
details: options.returnDetails ? verificationMailauth : undefined
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.signingDomain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.signingDomain}`,
|
||||
details: {
|
||||
selector: dkimResult.selector,
|
||||
signatureFields: (dkimResult as any).signature,
|
||||
result: dkimResult.status.result
|
||||
},
|
||||
domain: dkimResult.signingDomain,
|
||||
success: isValid
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (mailauthError) {
|
||||
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification with mailauth failed, trying smartmail fallback`,
|
||||
details: { error: mailauthError.message },
|
||||
success: false
|
||||
});
|
||||
}
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`,
|
||||
details: { selector: result.selector, status: result.status },
|
||||
domain: result.domain || 'unknown',
|
||||
success: result.isValid
|
||||
});
|
||||
|
||||
// Fall back to smartmail for verification
|
||||
try {
|
||||
// Parse and extract DKIM signature
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
|
||||
// Find DKIM signature header
|
||||
let dkimSignature = '';
|
||||
if (parsedEmail.headers.has('dkim-signature')) {
|
||||
dkimSignature = parsedEmail.headers.get('dkim-signature') as string;
|
||||
} else {
|
||||
// No DKIM signature found
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
errorMessage: 'No DKIM signature found'
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract domain from DKIM signature
|
||||
const domainMatch = dkimSignature.match(/d=([^;]+)/i);
|
||||
const domain = domainMatch ? domainMatch[1].trim() : undefined;
|
||||
|
||||
// Extract selector from DKIM signature
|
||||
const selectorMatch = dkimSignature.match(/s=([^;]+)/i);
|
||||
const selector = selectorMatch ? selectorMatch[1].trim() : undefined;
|
||||
|
||||
// Parse DKIM fields
|
||||
const signatureFields: Record<string, string> = {};
|
||||
const fieldMatches = dkimSignature.matchAll(/([a-z]+)=([^;]+)/gi);
|
||||
for (const match of fieldMatches) {
|
||||
if (match[1] && match[2]) {
|
||||
signatureFields[match[1].toLowerCase()] = match[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Use smartmail's verification if we have domain and selector
|
||||
if (domain && selector) {
|
||||
const dkimKey = await this.fetchDkimKey(domain, selector);
|
||||
|
||||
if (!dkimKey) {
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
domain,
|
||||
selector,
|
||||
status: 'permerror',
|
||||
errorMessage: 'DKIM public key not found',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// In a real implementation, we would validate the signature here
|
||||
// For now, if we found a key, we'll consider it valid
|
||||
// In a future update, add actual crypto verification
|
||||
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: true,
|
||||
domain,
|
||||
selector,
|
||||
status: 'pass',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification passed for domain ${domain} using fallback verification`,
|
||||
details: {
|
||||
selector,
|
||||
signatureFields
|
||||
},
|
||||
domain,
|
||||
success: true
|
||||
});
|
||||
|
||||
return result;
|
||||
} else {
|
||||
// Missing domain or selector
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
domain,
|
||||
selector,
|
||||
status: 'permerror',
|
||||
errorMessage: 'Missing domain or selector in DKIM signature',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed: Missing domain or selector in signature`,
|
||||
details: { domain, selector, signatureFields },
|
||||
domain: domain || 'unknown',
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
status: 'temperror',
|
||||
errorMessage: `Verification error: ${error.message}`
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('error', `DKIM verification error: ${error.message}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification error during processing`,
|
||||
details: { error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
logger.log(result.isValid ? 'info' : 'warn',
|
||||
`DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
|
||||
|
||||
// Enhanced security logging for unexpected errors
|
||||
logger.log('error', `DKIM verification failed: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed with unexpected error`,
|
||||
message: `DKIM verification error`,
|
||||
details: { error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'temperror',
|
||||
errorMessage: `Unexpected verification error: ${error.message}`
|
||||
errorMessage: `Verification error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async fetchDkimKey(domain: string, selector: string): Promise<string | null> {
|
||||
try {
|
||||
const dkimRecord = `${selector}._domainkey.${domain}`;
|
||||
|
||||
// Use DNS lookup from plugins
|
||||
const txtRecords = await new Promise<string[]>((resolve, reject) => {
|
||||
plugins.dns.resolveTxt(dkimRecord, (err, records) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
||||
resolve([]);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Flatten the arrays that resolveTxt returns
|
||||
resolve(records.map(record => record.join('')));
|
||||
});
|
||||
});
|
||||
|
||||
if (!txtRecords || txtRecords.length === 0) {
|
||||
logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`);
|
||||
|
||||
// Security logging for missing DKIM record
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `No DKIM TXT record found for ${dkimRecord}`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { selector }
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find record matching DKIM format
|
||||
for (const record of txtRecords) {
|
||||
if (record.includes('p=')) {
|
||||
// Extract public key
|
||||
const publicKeyMatch = record.match(/p=([^;]+)/i);
|
||||
if (publicKeyMatch && publicKeyMatch[1]) {
|
||||
return publicKeyMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
|
||||
|
||||
// Security logging for invalid DKIM key
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `No valid DKIM public key found in TXT records`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { dkimRecord, selector }
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error fetching DKIM key: ${error.message}`);
|
||||
|
||||
// Security logging for DKIM key fetch error
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `Error fetching DKIM key for domain`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` }
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the verification cache
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.verificationCache.clear();
|
||||
logger.log('info', 'DKIM verification cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the verification cache
|
||||
* @returns Number of cached items
|
||||
*/
|
||||
/** No-op — Rust bridge handles its own caching */
|
||||
public clearCache(): void {}
|
||||
|
||||
/** Always 0 — cache is managed by the Rust side */
|
||||
public getCacheSize(): number {
|
||||
return this.verificationCache.size;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
// MtaService reference removed
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js';
|
||||
|
||||
/**
|
||||
* DMARC policy types
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
// MtaService reference removed
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js';
|
||||
|
||||
/**
|
||||
* SPF result qualifiers
|
||||
@@ -61,79 +60,64 @@ export interface SpfResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum lookup limit for SPF records (prevent infinite loops)
|
||||
*/
|
||||
const MAX_SPF_LOOKUPS = 10;
|
||||
|
||||
/**
|
||||
* 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 class SpfVerifier {
|
||||
// DNS Manager reference for verifying records
|
||||
private dnsManager?: any;
|
||||
private lookupCount: number = 0;
|
||||
|
||||
constructor(dnsManager?: any) {
|
||||
this.dnsManager = dnsManager;
|
||||
constructor(_dnsManager?: any) {
|
||||
// dnsManager is no longer needed — Rust handles DNS lookups
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
public parseSpfRecord(record: string): SpfRecord | null {
|
||||
if (!record.startsWith('v=spf1')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const spfRecord: SpfRecord = {
|
||||
version: 'spf1',
|
||||
mechanisms: [],
|
||||
modifiers: {}
|
||||
};
|
||||
|
||||
// Split into terms
|
||||
|
||||
const terms = record.split(' ').filter(term => term.length > 0);
|
||||
|
||||
// Skip version term
|
||||
|
||||
for (let i = 1; i < terms.length; i++) {
|
||||
const term = terms[i];
|
||||
|
||||
// Check if it's a modifier (name=value)
|
||||
|
||||
if (term.includes('=')) {
|
||||
const [name, value] = term.split('=');
|
||||
spfRecord.modifiers[name] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse as mechanism
|
||||
let qualifier = SpfQualifier.PASS; // Default is +
|
||||
|
||||
let qualifier = SpfQualifier.PASS;
|
||||
let mechanismText = term;
|
||||
|
||||
// Check for qualifier
|
||||
if (term.startsWith('+') || term.startsWith('-') ||
|
||||
|
||||
if (term.startsWith('+') || term.startsWith('-') ||
|
||||
term.startsWith('~') || term.startsWith('?')) {
|
||||
qualifier = term[0] as SpfQualifier;
|
||||
mechanismText = term.substring(1);
|
||||
}
|
||||
|
||||
// Parse mechanism type and value
|
||||
|
||||
const colonIndex = mechanismText.indexOf(':');
|
||||
let type: SpfMechanismType;
|
||||
let value: string | undefined;
|
||||
|
||||
|
||||
if (colonIndex !== -1) {
|
||||
type = mechanismText.substring(0, colonIndex) as SpfMechanismType;
|
||||
value = mechanismText.substring(colonIndex + 1);
|
||||
} else {
|
||||
type = mechanismText as SpfMechanismType;
|
||||
}
|
||||
|
||||
|
||||
spfRecord.mechanisms.push({ qualifier, type, value });
|
||||
}
|
||||
|
||||
|
||||
return spfRecord;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error parsing SPF record: ${error.message}`, {
|
||||
@@ -143,60 +127,9 @@ export class SpfVerifier {
|
||||
return 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(ip: string, cidr: string): boolean {
|
||||
try {
|
||||
const ipAddress = plugins.ip.Address4.parse(ip);
|
||||
return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
|
||||
} catch (error) {
|
||||
// Try IPv6
|
||||
try {
|
||||
const ipAddress = plugins.ip.Address6.parse(ip);
|
||||
return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
|
||||
try {
|
||||
// First try IPv4
|
||||
const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
|
||||
if (ipv4Addresses.includes(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then try IPv6
|
||||
const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
|
||||
if (ipv6Addresses.includes(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public async verify(
|
||||
email: Email,
|
||||
@@ -204,109 +137,48 @@ export class SpfVerifier {
|
||||
heloDomain: string
|
||||
): Promise<SpfResult> {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
// Reset lookup count
|
||||
this.lookupCount = 0;
|
||||
|
||||
// Get domain from envelope from (return-path)
|
||||
const domain = email.getEnvelopeFrom().split('@')[1] || '';
|
||||
|
||||
if (!domain) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'No envelope from domain',
|
||||
domain: '',
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
const mailFrom = email.from || '';
|
||||
const domain = mailFrom.split('@')[1] || '';
|
||||
|
||||
try {
|
||||
// Look up SPF record
|
||||
const spfVerificationResult = this.dnsManager ?
|
||||
await this.dnsManager.verifySpfRecord(domain) :
|
||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
||||
|
||||
if (!spfVerificationResult.found) {
|
||||
return {
|
||||
result: 'none',
|
||||
explanation: 'No SPF record found',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
if (!spfVerificationResult.valid) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Invalid SPF record',
|
||||
domain,
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
};
|
||||
}
|
||||
|
||||
// Parse SPF record
|
||||
const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
|
||||
|
||||
if (!spfRecord) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Failed to parse SPF record',
|
||||
domain,
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
};
|
||||
}
|
||||
|
||||
// Check SPF record
|
||||
const result = await this.checkSpfRecord(spfRecord, domain, ip);
|
||||
|
||||
// Log the result
|
||||
const spfLogLevel = result.result === 'pass' ?
|
||||
SecurityLogLevel.INFO :
|
||||
(result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: spfLogLevel,
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF ${result.result} for ${domain} from IP ${ip}`,
|
||||
domain,
|
||||
details: {
|
||||
ip,
|
||||
heloDomain,
|
||||
result: result.result,
|
||||
explanation: result.explanation,
|
||||
record: spfVerificationResult.value
|
||||
},
|
||||
success: result.result === 'pass'
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
domain,
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const result = await bridge.checkSpf({
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
heloDomain,
|
||||
hostname: plugins.os.hostname(),
|
||||
mailFrom,
|
||||
});
|
||||
|
||||
const spfResult: SpfResult = {
|
||||
result: result.result as SpfResult['result'],
|
||||
domain: result.domain,
|
||||
ip: result.ip,
|
||||
explanation: result.explanation ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
// Log error
|
||||
logger.log('error', `SPF verification error: ${error.message}`, {
|
||||
domain,
|
||||
ip,
|
||||
error: error.message
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
|
||||
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
|
||||
domain: spfResult.domain,
|
||||
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
|
||||
success: spfResult.result === 'pass'
|
||||
});
|
||||
|
||||
|
||||
return spfResult;
|
||||
} catch (error) {
|
||||
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF verification error for ${domain}`,
|
||||
domain,
|
||||
details: {
|
||||
ip,
|
||||
error: error.message
|
||||
},
|
||||
details: { ip, error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
result: 'temperror',
|
||||
explanation: `Error verifying SPF: ${error.message}`,
|
||||
@@ -316,247 +188,9 @@ export class SpfVerifier {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 async checkSpfRecord(
|
||||
spfRecord: SpfRecord,
|
||||
domain: string,
|
||||
ip: string
|
||||
): Promise<SpfResult> {
|
||||
// Check for 'redirect' modifier
|
||||
if (spfRecord.modifiers.redirect) {
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Handle redirect
|
||||
const redirectDomain = spfRecord.modifiers.redirect;
|
||||
const redirectResult = this.dnsManager ?
|
||||
await this.dnsManager.verifySpfRecord(redirectDomain) :
|
||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
||||
|
||||
if (!redirectResult.found || !redirectResult.valid) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: `Invalid redirect to ${redirectDomain}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
const redirectRecord = this.parseSpfRecord(redirectResult.value);
|
||||
|
||||
if (!redirectRecord) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: `Failed to parse redirect record from ${redirectDomain}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
|
||||
}
|
||||
|
||||
// Check each mechanism in order
|
||||
for (const mechanism of spfRecord.mechanisms) {
|
||||
let matched = false;
|
||||
|
||||
switch (mechanism.type) {
|
||||
case SpfMechanismType.ALL:
|
||||
matched = true;
|
||||
break;
|
||||
|
||||
case SpfMechanismType.IP4:
|
||||
if (mechanism.value) {
|
||||
matched = this.isIpInCidr(ip, mechanism.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.IP6:
|
||||
if (mechanism.value) {
|
||||
matched = this.isIpInCidr(ip, mechanism.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.A:
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check if domain has A/AAAA record matching IP
|
||||
const checkDomain = mechanism.value || domain;
|
||||
matched = await this.isDomainResolvingToIp(checkDomain, ip);
|
||||
break;
|
||||
|
||||
case SpfMechanismType.MX:
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
const mxDomain = mechanism.value || domain;
|
||||
|
||||
try {
|
||||
const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
|
||||
|
||||
for (const mx of mxRecords) {
|
||||
// Check if this MX record's IP matches
|
||||
const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
|
||||
|
||||
if (mxMatches) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// No MX records or error
|
||||
matched = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.INCLUDE:
|
||||
if (!mechanism.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check included domain's SPF record
|
||||
const includeDomain = mechanism.value;
|
||||
const includeResult = this.dnsManager ?
|
||||
await this.dnsManager.verifySpfRecord(includeDomain) :
|
||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
||||
|
||||
if (!includeResult.found || !includeResult.valid) {
|
||||
continue; // Skip this mechanism
|
||||
}
|
||||
|
||||
const includeRecord = this.parseSpfRecord(includeResult.value);
|
||||
|
||||
if (!includeRecord) {
|
||||
continue; // Skip this mechanism
|
||||
}
|
||||
|
||||
// Recursively check the included SPF record
|
||||
const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
|
||||
|
||||
// Include mechanism matches if the result is "pass"
|
||||
matched = includeCheck.result === 'pass';
|
||||
break;
|
||||
|
||||
case SpfMechanismType.EXISTS:
|
||||
if (!mechanism.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check if domain exists (has any A record)
|
||||
try {
|
||||
await plugins.dns.promises.resolve(mechanism.value, 'A');
|
||||
matched = true;
|
||||
} catch (error) {
|
||||
matched = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// If this mechanism matched, return its result
|
||||
if (matched) {
|
||||
switch (mechanism.qualifier) {
|
||||
case SpfQualifier.PASS:
|
||||
return {
|
||||
result: 'pass',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.FAIL:
|
||||
return {
|
||||
result: 'fail',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.SOFTFAIL:
|
||||
return {
|
||||
result: 'softfail',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.NEUTRAL:
|
||||
return {
|
||||
result: 'neutral',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no mechanism matched, default to neutral
|
||||
return {
|
||||
result: 'neutral',
|
||||
explanation: 'No matching mechanism found',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public async verifyAndApply(
|
||||
email: Email,
|
||||
@@ -564,43 +198,36 @@ export class SpfVerifier {
|
||||
heloDomain: string
|
||||
): Promise<boolean> {
|
||||
const result = await this.verify(email, ip, heloDomain);
|
||||
|
||||
// Add headers
|
||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
||||
|
||||
// Apply policy based on result
|
||||
|
||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
||||
|
||||
switch (result.result) {
|
||||
case 'fail':
|
||||
// Fail - mark as spam
|
||||
email.mightBeSpam = true;
|
||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return false;
|
||||
|
||||
|
||||
case 'softfail':
|
||||
// Soft fail - accept but mark as suspicious
|
||||
email.mightBeSpam = true;
|
||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
|
||||
case 'neutral':
|
||||
case 'none':
|
||||
// Neutral or none - accept but note in headers
|
||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
|
||||
case 'pass':
|
||||
// Pass - accept
|
||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
|
||||
case 'temperror':
|
||||
case 'permerror':
|
||||
// Temporary or permanent error - log but accept
|
||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +84,10 @@ export {
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logger } from '../logger.js';
|
||||
import { Email } from '../mail/core/classes.email.js';
|
||||
import type { IAttachment } from '../mail/core/classes.email.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { RustSecurityBridge } from './classes.rustsecuritybridge.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
@@ -65,75 +66,6 @@ export class ContentScanner {
|
||||
private scanCache: LRUCache<string, IScanResult>;
|
||||
private options: Required<IContentScannerOptions>;
|
||||
|
||||
// Predefined patterns for common threats
|
||||
private static readonly MALICIOUS_PATTERNS = {
|
||||
// Phishing patterns
|
||||
phishing: [
|
||||
/(?:verify|confirm|update|login).*(?:account|password|details)/i,
|
||||
/urgent.*(?:action|attention|required)/i,
|
||||
/(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)/i,
|
||||
/your.*(?:account).*(?:suspended|compromised|locked)/i,
|
||||
/\b(?:password reset|security alert|security notice)\b/i
|
||||
],
|
||||
|
||||
// Spam indicators
|
||||
spam: [
|
||||
/\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b/i,
|
||||
/\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b/i,
|
||||
/\b(?:earn from home|make money fast|earn \$\d{3,}\/day)\b/i,
|
||||
/\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b/i,
|
||||
/\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b/i
|
||||
],
|
||||
|
||||
// Malware indicators in text
|
||||
malware: [
|
||||
/(?:attached file|see attachment).*(?:invoice|receipt|statement|document)/i,
|
||||
/open.*(?:the attached|this attachment)/i,
|
||||
/(?:enable|allow).*(?:macros|content|editing)/i,
|
||||
/download.*(?:attachment|file|document)/i,
|
||||
/\b(?:ransomware protection|virus alert|malware detected)\b/i
|
||||
],
|
||||
|
||||
// Suspicious links
|
||||
suspiciousLinks: [
|
||||
/https?:\/\/bit\.ly\//i,
|
||||
/https?:\/\/goo\.gl\//i,
|
||||
/https?:\/\/t\.co\//i,
|
||||
/https?:\/\/tinyurl\.com\//i,
|
||||
/https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/i, // IP address URLs
|
||||
/https?:\/\/.*\.(?:xyz|top|club|gq|cf)\//i, // Suspicious TLDs
|
||||
/(?:login|account|signin|auth).*\.(?!gov|edu|com|org|net)\w+\.\w+/i, // Login pages on unusual domains
|
||||
],
|
||||
|
||||
// XSS and script injection
|
||||
scriptInjection: [
|
||||
/<script.*>.*<\/script>/is,
|
||||
/javascript:/i,
|
||||
/on(?:click|load|mouse|error|focus|blur)=".*"/i,
|
||||
/document\.(?:cookie|write|location)/i,
|
||||
/eval\s*\(/i
|
||||
],
|
||||
|
||||
// Sensitive data patterns
|
||||
sensitiveData: [
|
||||
/\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b/, // SSN
|
||||
/\b\d{13,16}\b/, // Credit card numbers
|
||||
/\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b/ // Possible Base64
|
||||
]
|
||||
};
|
||||
|
||||
// Common executable extensions
|
||||
private static readonly EXECUTABLE_EXTENSIONS = [
|
||||
'.exe', '.dll', '.bat', '.cmd', '.msi', '.ts', '.vbs', '.ps1',
|
||||
'.sh', '.jar', '.py', '.com', '.scr', '.pif', '.hta', '.cpl',
|
||||
'.reg', '.vba', '.lnk', '.wsf', '.msi', '.msp', '.mst'
|
||||
];
|
||||
|
||||
// Document formats that may contain macros
|
||||
private static readonly MACRO_DOCUMENT_EXTENSIONS = [
|
||||
'.doc', '.docm', '.xls', '.xlsm', '.ppt', '.pptm', '.dotm', '.xlsb', '.ppam', '.potm'
|
||||
];
|
||||
|
||||
/**
|
||||
* Default options for the content scanner
|
||||
*/
|
||||
@@ -185,7 +117,9 @@ export class 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
|
||||
*/
|
||||
@@ -193,74 +127,67 @@ export class ContentScanner {
|
||||
try {
|
||||
// Generate a cache key from the email
|
||||
const cacheKey = this.generateCacheKey(email);
|
||||
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = this.scanCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
logger.log('info', `Using cached scan result for email ${email.getMessageId()}`);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Initialize scan result
|
||||
|
||||
// Delegate text/subject/html/filename scanning to Rust
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
const rustResult = await bridge.scanContent({
|
||||
subject: this.options.scanSubject ? email.subject : undefined,
|
||||
textBody: this.options.scanBody ? email.text : undefined,
|
||||
htmlBody: this.options.scanBody ? email.html : undefined,
|
||||
attachmentNames: this.options.scanAttachmentNames
|
||||
? email.attachments?.map(a => a.filename) ?? []
|
||||
: [],
|
||||
});
|
||||
|
||||
const result: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
threatScore: rustResult.threatScore,
|
||||
threatType: rustResult.threatType ?? undefined,
|
||||
threatDetails: rustResult.threatDetails ?? undefined,
|
||||
scannedElements: rustResult.scannedElements,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// List of scan promises
|
||||
const scanPromises: Array<Promise<void>> = [];
|
||||
|
||||
// Scan subject
|
||||
if (this.options.scanSubject && email.subject) {
|
||||
scanPromises.push(this.scanSubject(email.subject, result));
|
||||
}
|
||||
|
||||
// Scan body content
|
||||
if (this.options.scanBody) {
|
||||
if (email.text) {
|
||||
scanPromises.push(this.scanTextContent(email.text, result));
|
||||
}
|
||||
|
||||
if (email.html) {
|
||||
scanPromises.push(this.scanHtmlContent(email.html, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan attachments
|
||||
if (this.options.scanAttachments && email.attachments && email.attachments.length > 0) {
|
||||
|
||||
// Attachment binary scanning stays in TS (PE headers, macro detection)
|
||||
if (this.options.scanAttachments && email.attachments?.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
scanPromises.push(this.scanAttachment(attachment, result));
|
||||
this.scanAttachmentBinary(attachment, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all scans in parallel
|
||||
await Promise.all(scanPromises);
|
||||
|
||||
|
||||
// Apply custom rules (TS-only, runtime-configured)
|
||||
this.applyCustomRules(email, result);
|
||||
|
||||
// Determine if the email is clean based on threat score
|
||||
result.isClean = result.threatScore < this.options.minThreatScore;
|
||||
|
||||
|
||||
// Save to cache
|
||||
this.scanCache.set(cacheKey, result);
|
||||
|
||||
|
||||
// Log high threat findings
|
||||
if (result.threatScore >= this.options.highThreatScore) {
|
||||
this.logHighThreatFound(email, result);
|
||||
} else if (!result.isClean) {
|
||||
this.logThreatFound(email, result);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
||||
messageId: email.getMessageId(),
|
||||
error: error.stack
|
||||
});
|
||||
|
||||
|
||||
// Return a safe default with error indication
|
||||
return {
|
||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: ['error'],
|
||||
timestamp: Date.now(),
|
||||
@@ -269,7 +196,7 @@ export class ContentScanner {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a cache key from an email
|
||||
* @param email The email to generate a key for
|
||||
@@ -280,7 +207,7 @@ export class ContentScanner {
|
||||
if (email.getMessageId()) {
|
||||
return `email:${email.getMessageId()}`;
|
||||
}
|
||||
|
||||
|
||||
// Fallback to a hash of key content
|
||||
const contentToHash = [
|
||||
email.from,
|
||||
@@ -289,321 +216,75 @@ export class ContentScanner {
|
||||
email.html?.substring(0, 1000) || '',
|
||||
email.attachments?.length || 0
|
||||
].join(':');
|
||||
|
||||
|
||||
return `email:${plugins.crypto.createHash('sha256').update(contentToHash).digest('hex')}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scan email subject for threats
|
||||
* @param subject The subject to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanSubject(subject: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('subject');
|
||||
|
||||
// Check against phishing patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 25;
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Subject contains potential phishing indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check against spam patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 15;
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Subject contains potential spam indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += rule.score;
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan plain text content for threats
|
||||
* @param text The text content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanTextContent(text: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('text');
|
||||
|
||||
// Check suspicious links
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 20;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SUSPICIOUS_LINK ? 0 : 20)) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `Text contains suspicious links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check phishing
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.PHISHING ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Text contains potential phishing indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check spam
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 15;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SPAM ? 0 : 15)) {
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Text contains potential spam indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check malware indicators
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.malware) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 30;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.MALWARE ? 0 : 30)) {
|
||||
result.threatType = ThreatCategory.MALWARE;
|
||||
result.threatDetails = `Text contains potential malware indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check sensitive data
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.sensitiveData) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SENSITIVE_DATA ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.SENSITIVE_DATA;
|
||||
result.threatDetails = `Text contains potentially sensitive data patterns`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += rule.score;
|
||||
if (!result.threatType || result.threatScore > 20) {
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML content for threats
|
||||
* @param html The HTML content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanHtmlContent(html: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('html');
|
||||
|
||||
// Check for script injection
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.scriptInjection) {
|
||||
if (pattern.test(html)) {
|
||||
result.threatScore += 40;
|
||||
if (!result.threatType || result.threatType !== ThreatCategory.XSS) {
|
||||
result.threatType = ThreatCategory.XSS;
|
||||
result.threatDetails = `HTML contains potentially malicious script content`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text content from HTML for further scanning
|
||||
const textContent = this.extractTextFromHtml(html);
|
||||
if (textContent) {
|
||||
// We'll leverage the text scanning but not double-count threat score
|
||||
const tempResult: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await this.scanTextContent(textContent, tempResult);
|
||||
|
||||
// Only add additional threat types if they're more severe
|
||||
if (tempResult.threatType && tempResult.threatScore > 0) {
|
||||
// Add half of the text content score to avoid double counting
|
||||
result.threatScore += Math.floor(tempResult.threatScore / 2);
|
||||
|
||||
// Adopt the threat type if more severe or no existing type
|
||||
if (!result.threatType || tempResult.threatScore > result.threatScore) {
|
||||
result.threatType = tempResult.threatType;
|
||||
result.threatDetails = tempResult.threatDetails;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and check links from HTML
|
||||
const links = this.extractLinksFromHtml(html);
|
||||
if (links.length > 0) {
|
||||
// Check for suspicious links
|
||||
let suspiciousLinks = 0;
|
||||
for (const link of links) {
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(link)) {
|
||||
suspiciousLinks++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suspiciousLinks > 0) {
|
||||
// Add score based on percentage of suspicious links
|
||||
const suspiciousPercentage = (suspiciousLinks / links.length) * 100;
|
||||
const additionalScore = Math.min(40, Math.floor(suspiciousPercentage / 2.5));
|
||||
result.threatScore += additionalScore;
|
||||
|
||||
if (!result.threatType || additionalScore > 20) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `HTML contains ${suspiciousLinks} suspicious links out of ${links.length} total links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async scanAttachment(attachment: IAttachment, result: IScanResult): Promise<void> {
|
||||
const filename = attachment.filename.toLowerCase();
|
||||
result.scannedElements.push(`attachment:${filename}`);
|
||||
|
||||
// Skip large attachments if configured
|
||||
if (attachment.content && attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
||||
logger.log('info', `Skipping scan of large attachment: ${filename} (${attachment.content.length} bytes)`);
|
||||
private scanAttachmentBinary(attachment: IAttachment, result: IScanResult): void {
|
||||
if (!attachment.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check filename for executable extensions
|
||||
if (this.options.blockExecutables) {
|
||||
for (const ext of ContentScanner.EXECUTABLE_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
result.threatScore += 70; // High score for executable attachments
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment has a potentially dangerous extension: ${filename}`;
|
||||
return; // No need to scan contents if filename already flagged
|
||||
}
|
||||
}
|
||||
|
||||
// Skip large attachments
|
||||
if (attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Office documents with macros
|
||||
if (this.options.blockMacros) {
|
||||
for (const ext of ContentScanner.MACRO_DOCUMENT_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
// For Office documents, check if they contain macros
|
||||
// This is a simplified check - a real implementation would use specialized libraries
|
||||
// to detect macros in Office documents
|
||||
if (attachment.content && this.likelyContainsMacros(attachment)) {
|
||||
result.threatScore += 60;
|
||||
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
||||
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filename = attachment.filename.toLowerCase();
|
||||
|
||||
// Check for PE headers (Windows executables disguised with non-.exe extensions)
|
||||
if (attachment.content.length > 64 &&
|
||||
attachment.content[0] === 0x4D &&
|
||||
attachment.content[1] === 0x5A) { // 'MZ' header
|
||||
result.threatScore += 80;
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment contains executable code: ${filename}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform basic content analysis if we have content buffer
|
||||
if (attachment.content) {
|
||||
// Convert to string for scanning, with a limit to prevent memory issues
|
||||
const textContent = this.extractTextFromBuffer(attachment.content);
|
||||
|
||||
if (textContent) {
|
||||
// Scan for malicious patterns in attachment content
|
||||
for (const category in ContentScanner.MALICIOUS_PATTERNS) {
|
||||
const patterns = ContentScanner.MALICIOUS_PATTERNS[category];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(textContent)) {
|
||||
result.threatScore += 30;
|
||||
|
||||
if (!result.threatType) {
|
||||
result.threatType = this.mapCategoryToThreatType(category);
|
||||
result.threatDetails = `Attachment content contains suspicious patterns: ${filename}`;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for PE headers (Windows executables)
|
||||
if (attachment.content.length > 64 &&
|
||||
attachment.content[0] === 0x4D &&
|
||||
attachment.content[1] === 0x5A) { // 'MZ' header
|
||||
result.threatScore += 80;
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment contains executable code: ${filename}`;
|
||||
}
|
||||
|
||||
// Check for VBA macro indicators in Office documents
|
||||
if (this.options.blockMacros && this.likelyContainsMacros(attachment)) {
|
||||
result.threatScore += 60;
|
||||
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
||||
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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(html: string): string[] {
|
||||
const links: string[] = [];
|
||||
|
||||
// Simple regex-based extraction - a real implementation might use a proper HTML parser
|
||||
const matches = html.match(/href=["'](https?:\/\/[^"']+)["']/gi);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const linkMatch = match.match(/href=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (linkMatch && linkMatch[1]) {
|
||||
links.push(linkMatch[1]);
|
||||
private applyCustomRules(email: Email, result: IScanResult): void {
|
||||
if (!this.options.customRules.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textsToCheck: string[] = [];
|
||||
if (email.subject) textsToCheck.push(email.subject);
|
||||
if (email.text) textsToCheck.push(email.text);
|
||||
if (email.html) textsToCheck.push(email.html);
|
||||
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
for (const text of textsToCheck) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += rule.score;
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from HTML
|
||||
* @param html HTML content
|
||||
* @returns Extracted text
|
||||
*/
|
||||
private extractTextFromHtml(html: string): string {
|
||||
// Remove HTML tags and decode entities - simplified version
|
||||
return html
|
||||
.replace(/<style[^>]*>.*?<\/style>/gs, '')
|
||||
.replace(/<script[^>]*>.*?<\/script>/gs, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract text from a binary buffer for scanning
|
||||
* @param buffer Binary content
|
||||
@@ -614,7 +295,7 @@ export class ContentScanner {
|
||||
// Limit the amount we convert to avoid memory issues
|
||||
const sampleSize = Math.min(buffer.length, 100 * 1024); // 100KB max sample
|
||||
const sample = buffer.slice(0, sampleSize);
|
||||
|
||||
|
||||
// Try to convert to string, filtering out non-printable chars
|
||||
return sample.toString('utf8')
|
||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||
@@ -624,16 +305,13 @@ export class ContentScanner {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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(attachment: IAttachment): boolean {
|
||||
// Simple heuristic: look for VBA/macro related strings
|
||||
// This is a simplified approach and not comprehensive
|
||||
const content = this.extractTextFromBuffer(attachment.content);
|
||||
const macroIndicators = [
|
||||
/vbaProject\.bin/i,
|
||||
@@ -647,33 +325,16 @@ export class ContentScanner {
|
||||
/\bShell\(/i,
|
||||
/\bCreateObject\(/i
|
||||
];
|
||||
|
||||
|
||||
for (const indicator of macroIndicators) {
|
||||
if (indicator.test(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a pattern category to a threat type
|
||||
* @param category The pattern category
|
||||
* @returns The corresponding threat type
|
||||
*/
|
||||
private mapCategoryToThreatType(category: string): string {
|
||||
switch (category) {
|
||||
case 'phishing': return ThreatCategory.PHISHING;
|
||||
case 'spam': return ThreatCategory.SPAM;
|
||||
case 'malware': return ThreatCategory.MALWARE;
|
||||
case 'suspiciousLinks': return ThreatCategory.SUSPICIOUS_LINK;
|
||||
case 'scriptInjection': return ThreatCategory.XSS;
|
||||
case 'sensitiveData': return ThreatCategory.SENSITIVE_DATA;
|
||||
default: return ThreatCategory.BLACKLISTED_CONTENT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log a high threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
|
||||
@@ -59,33 +59,19 @@ export interface IIPReputationOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 class IPReputationChecker {
|
||||
private static instance: IPReputationChecker;
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
|
||||
// Default DNSBL servers
|
||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||
'zen.spamhaus.org', // Spamhaus
|
||||
'bl.spamcop.net', // SpamCop
|
||||
'b.barracudacentral.org', // Barracuda
|
||||
'spam.dnsbl.sorbs.net', // SORBS
|
||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
||||
'cbl.abuseat.org', // Composite Blocking List
|
||||
'xbl.spamhaus.org', // Spamhaus XBL
|
||||
'pbl.spamhaus.org', // Spamhaus PBL
|
||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||||
'psbl.surriel.com' // PSBL
|
||||
];
|
||||
|
||||
// Default options
|
||||
private storageManager?: any;
|
||||
|
||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
dnsblServers: IPReputationChecker.DEFAULT_DNSBL_SERVERS,
|
||||
cacheTTL: 24 * 60 * 60 * 1000,
|
||||
dnsblServers: [],
|
||||
highRiskThreshold: ReputationThreshold.HIGH_RISK,
|
||||
mediumRiskThreshold: ReputationThreshold.MEDIUM_RISK,
|
||||
lowRiskThreshold: ReputationThreshold.LOW_RISK,
|
||||
@@ -93,66 +79,39 @@ export class IPReputationChecker {
|
||||
enableDNSBL: true,
|
||||
enableIPInfo: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor for IPReputationChecker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
*/
|
||||
|
||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
||||
// Merge with default options
|
||||
this.options = {
|
||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||
...options
|
||||
};
|
||||
|
||||
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// If no storage manager provided, log warning
|
||||
if (!storageManager && this.options.enableLocalCache) {
|
||||
logger.log('warn',
|
||||
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
|
||||
' IP reputation cache will only be stored to filesystem.\n' +
|
||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize reputation cache
|
||||
|
||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||
max: this.options.maxCacheSize,
|
||||
ttl: this.options.cacheTTL, // Cache TTL
|
||||
ttl: this.options.cacheTTL,
|
||||
});
|
||||
|
||||
// Load cache from disk if enabled
|
||||
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the load operation
|
||||
this.loadCache().catch(error => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the checker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
* @returns Singleton instance
|
||||
*/
|
||||
|
||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
||||
if (!IPReputationChecker.instance) {
|
||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
||||
}
|
||||
return IPReputationChecker.instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public async checkReputation(ip: string): Promise<IReputationResult> {
|
||||
try {
|
||||
// Validate IP address format
|
||||
if (!this.isValidIPAddress(ip)) {
|
||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||
return this.createErrorResult(ip, 'Invalid IP address format');
|
||||
@@ -168,262 +127,47 @@ export class IPReputationChecker {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Try Rust bridge first (parallel DNSBL via tokio — faster than Node sequential DNS)
|
||||
// Delegate to Rust bridge
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
if (bridge.running) {
|
||||
try {
|
||||
const rustResult = await bridge.checkIpReputation(ip);
|
||||
const result: IReputationResult = {
|
||||
score: rustResult.score,
|
||||
isSpam: rustResult.listed_count > 0,
|
||||
isProxy: rustResult.ip_type === 'proxy',
|
||||
isTor: rustResult.ip_type === 'tor',
|
||||
isVPN: rustResult.ip_type === 'vpn',
|
||||
blacklists: rustResult.dnsbl_results
|
||||
.filter(d => d.listed)
|
||||
.map(d => d.server),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.reputationCache.set(ip, result);
|
||||
if (this.options.enableLocalCache) {
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
});
|
||||
}
|
||||
this.logReputationCheck(ip, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust IP reputation check failed, falling back to TS: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const rustResult = await bridge.checkIpReputation(ip);
|
||||
|
||||
// Fallback: TypeScript DNSBL implementation
|
||||
const result: IReputationResult = {
|
||||
score: 100, // Start with perfect score
|
||||
isSpam: false,
|
||||
isProxy: false,
|
||||
isTor: false,
|
||||
isVPN: false,
|
||||
timestamp: Date.now()
|
||||
score: rustResult.score,
|
||||
isSpam: rustResult.listed_count > 0,
|
||||
isProxy: rustResult.ip_type === 'proxy',
|
||||
isTor: rustResult.ip_type === 'tor',
|
||||
isVPN: rustResult.ip_type === 'vpn',
|
||||
blacklists: rustResult.dnsbl_results
|
||||
.filter(d => d.listed)
|
||||
.map(d => d.server),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Check IP against DNS blacklists if enabled
|
||||
if (this.options.enableDNSBL) {
|
||||
const dnsblResult = await this.checkDNSBL(ip);
|
||||
|
||||
// Update result with DNSBL information
|
||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||||
result.isSpam = dnsblResult.listCount > 0;
|
||||
result.blacklists = dnsblResult.lists;
|
||||
}
|
||||
|
||||
// Get additional IP information if enabled
|
||||
if (this.options.enableIPInfo) {
|
||||
const ipInfo = await this.getIPInfo(ip);
|
||||
|
||||
// Update result with IP info
|
||||
result.country = ipInfo.country;
|
||||
result.asn = ipInfo.asn;
|
||||
result.org = ipInfo.org;
|
||||
|
||||
// Adjust score based on IP type
|
||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||||
|
||||
// Set proxy flags
|
||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
||||
result.isTor = ipInfo.type === IPType.TOR;
|
||||
result.isVPN = ipInfo.type === IPType.VPN;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score is between 0 and 100
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
|
||||
// Update cache with result
|
||||
this.reputationCache.set(ip, result);
|
||||
|
||||
// Save cache if enabled
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the save operation
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Log the reputation check
|
||||
this.logReputationCheck(ip, result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||||
ip,
|
||||
stack: error.stack
|
||||
});
|
||||
const errorResult = this.createErrorResult(ip, error.message);
|
||||
// Cache error results to avoid repeated failing lookups
|
||||
this.reputationCache.set(ip, errorResult);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
return this.createErrorResult(ip, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an IP against DNS blacklists
|
||||
* @param ip IP address to check
|
||||
* @returns DNSBL check results
|
||||
*/
|
||||
private async checkDNSBL(ip: string): Promise<{
|
||||
listCount: number;
|
||||
lists: string[];
|
||||
}> {
|
||||
try {
|
||||
// Reverse the IP for DNSBL queries
|
||||
const reversedIP = this.reverseIP(ip);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
this.options.dnsblServers.map(async (server) => {
|
||||
try {
|
||||
const lookupDomain = `${reversedIP}.${server}`;
|
||||
await plugins.dns.promises.resolve(lookupDomain);
|
||||
return server; // IP is listed in this DNSBL
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return null; // IP is not listed in this DNSBL
|
||||
}
|
||||
throw error; // Other error
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Extract successful lookups (listed in DNSBL)
|
||||
const lists = results
|
||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||
result.status === 'fulfilled' && result.value !== null
|
||||
)
|
||||
.map(result => result.value);
|
||||
|
||||
return {
|
||||
listCount: lists.length,
|
||||
lists
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
||||
return {
|
||||
listCount: 0,
|
||||
lists: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about an IP address
|
||||
* @param ip IP address to check
|
||||
* @returns IP information
|
||||
*/
|
||||
private async getIPInfo(ip: string): Promise<{
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
type: IPType;
|
||||
}> {
|
||||
try {
|
||||
// In a real implementation, this would use an IP data service API
|
||||
// For this implementation, we'll use a simplified approach
|
||||
|
||||
// Check if it's a known Tor exit node (simplified)
|
||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||||
|
||||
// Check if it's a known VPN (simplified)
|
||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||||
|
||||
// Check if it's a known proxy (simplified)
|
||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||||
|
||||
// Determine IP type
|
||||
let type = IPType.UNKNOWN;
|
||||
if (isTor) {
|
||||
type = IPType.TOR;
|
||||
} else if (isVPN) {
|
||||
type = IPType.VPN;
|
||||
} else if (isProxy) {
|
||||
type = IPType.PROXY;
|
||||
} else {
|
||||
// Simple datacenters detection (major cloud providers)
|
||||
if (
|
||||
ip.startsWith('13.') || // AWS
|
||||
ip.startsWith('35.') || // Google Cloud
|
||||
ip.startsWith('52.') || // AWS
|
||||
ip.startsWith('34.') || // Google Cloud
|
||||
ip.startsWith('104.') // Various providers
|
||||
) {
|
||||
type = IPType.DATACENTER;
|
||||
} else {
|
||||
type = IPType.RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the information
|
||||
return {
|
||||
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
||||
asn: 'AS12345', // Simplified, would look up real ASN
|
||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
||||
type
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
||||
return {
|
||||
type: IPType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(ip: string): string {
|
||||
// Simplified mapping for demo purposes
|
||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'US';
|
||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'US';
|
||||
if (ip.startsWith('185.')) return 'NL';
|
||||
if (ip.startsWith('171.')) return 'DE';
|
||||
return 'XX'; // Unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(ip: string): string {
|
||||
// Simplified mapping for demo purposes
|
||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'Amazon AWS';
|
||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'Google Cloud';
|
||||
if (ip.startsWith('185.156.')) return 'NordVPN';
|
||||
if (ip.startsWith('37.120.')) return 'ExpressVPN';
|
||||
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(ip: string): string {
|
||||
return ip.split('.').reverse().join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error result for when reputation check fails
|
||||
* @param ip IP address
|
||||
* @param errorMessage Error message
|
||||
* @returns Error result
|
||||
*/
|
||||
private createErrorResult(ip: string, errorMessage: string): IReputationResult {
|
||||
return {
|
||||
score: 50, // Neutral score for errors
|
||||
score: 50,
|
||||
isSpam: false,
|
||||
isProxy: false,
|
||||
isTor: false,
|
||||
@@ -432,33 +176,18 @@ export class IPReputationChecker {
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
* @param ip IP address to validate
|
||||
* @returns Whether the IP is valid
|
||||
*/
|
||||
|
||||
private isValidIPAddress(ip: string): boolean {
|
||||
// IPv4 regex pattern
|
||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipv4Pattern.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log reputation check to security logger
|
||||
* @param ip IP address
|
||||
* @param result Reputation result
|
||||
*/
|
||||
|
||||
private logReputationCheck(ip: string, result: IReputationResult): void {
|
||||
// Determine log level based on reputation score
|
||||
let logLevel = SecurityLogLevel.INFO;
|
||||
if (result.score < this.options.highRiskThreshold) {
|
||||
logLevel = SecurityLogLevel.WARN;
|
||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
||||
logLevel = SecurityLogLevel.INFO;
|
||||
}
|
||||
|
||||
// Log the check
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: logLevel,
|
||||
type: SecurityEventType.IP_REPUTATION,
|
||||
@@ -476,71 +205,52 @@ export class IPReputationChecker {
|
||||
success: !result.isSpam
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk or storage manager
|
||||
*/
|
||||
|
||||
private async saveCache(): Promise<void> {
|
||||
try {
|
||||
// Convert cache entries to serializable array
|
||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
||||
ip,
|
||||
data
|
||||
}));
|
||||
|
||||
// Only save if we have entries
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const cacheData = JSON.stringify(entries);
|
||||
|
||||
// Save to storage manager if available
|
||||
|
||||
if (this.storageManager) {
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
||||
} else {
|
||||
// Fall back to filesystem
|
||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
||||
await plugins.smartfs.directory(cacheDir).recursive().create();
|
||||
|
||||
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
||||
await plugins.smartfs.file(cacheFile).write(cacheData);
|
||||
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from disk or storage manager
|
||||
*/
|
||||
|
||||
private async loadCache(): Promise<void> {
|
||||
try {
|
||||
let cacheData: string | null = null;
|
||||
let fromFilesystem = false;
|
||||
|
||||
// Try to load from storage manager first
|
||||
|
||||
if (this.storageManager) {
|
||||
try {
|
||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
||||
|
||||
|
||||
if (!cacheData) {
|
||||
// Check if data exists in filesystem and migrate it
|
||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||
|
||||
if (plugins.fs.existsSync(cacheFile)) {
|
||||
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||
fromFilesystem = true;
|
||||
|
||||
// Migrate to storage manager
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
||||
|
||||
// Optionally delete the old file after successful migration
|
||||
try {
|
||||
plugins.fs.unlinkSync(cacheFile);
|
||||
logger.log('info', 'Old cache file removed after migration');
|
||||
@@ -553,31 +263,25 @@ export class IPReputationChecker {
|
||||
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
// No storage manager, load from filesystem
|
||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||
|
||||
if (plugins.fs.existsSync(cacheFile)) {
|
||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||
fromFilesystem = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and restore cache if data was found
|
||||
|
||||
if (cacheData) {
|
||||
const entries = JSON.parse(cacheData);
|
||||
|
||||
// Validate and filter entries
|
||||
const now = Date.now();
|
||||
const validEntries = entries.filter(entry => {
|
||||
const age = now - entry.data.timestamp;
|
||||
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
||||
return age < this.options.cacheTTL;
|
||||
});
|
||||
|
||||
// Restore cache
|
||||
|
||||
for (const entry of validEntries) {
|
||||
this.reputationCache.set(entry.ip, entry.data);
|
||||
}
|
||||
|
||||
|
||||
const source = fromFilesystem ? 'disk' : 'StorageManager';
|
||||
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
|
||||
}
|
||||
@@ -585,12 +289,7 @@ export class IPReputationChecker {
|
||||
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the risk level for a reputation score
|
||||
* @param score Reputation score (0-100)
|
||||
* @returns Risk level description
|
||||
*/
|
||||
|
||||
public static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted' {
|
||||
if (score < ReputationThreshold.HIGH_RISK) {
|
||||
return 'high';
|
||||
@@ -602,21 +301,15 @@ export class IPReputationChecker {
|
||||
return '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
|
||||
*/
|
||||
|
||||
public updateStorageManager(storageManager: any): void {
|
||||
this.storageManager = storageManager;
|
||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
||||
|
||||
// If cache is enabled and we have entries, save them to the new storage manager
|
||||
|
||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,7 @@ interface IValidationResult {
|
||||
|
||||
interface IBounceDetection {
|
||||
bounce_type: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
should_retry: boolean;
|
||||
recommended_action: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
interface IReputationResult {
|
||||
@@ -63,6 +59,13 @@ interface IReputationResult {
|
||||
total_checked: number;
|
||||
}
|
||||
|
||||
interface IContentScanResult {
|
||||
threatScore: number;
|
||||
threatType: string | null;
|
||||
threatDetails: string | null;
|
||||
scannedElements: string[];
|
||||
}
|
||||
|
||||
interface IVersionInfo {
|
||||
bin: string;
|
||||
core: string;
|
||||
@@ -70,6 +73,60 @@ interface IVersionInfo {
|
||||
smtp: string;
|
||||
}
|
||||
|
||||
// --- SMTP Server types ---
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe command map for the mailer-bin IPC bridge.
|
||||
*/
|
||||
@@ -106,6 +163,15 @@ type TMailerCommands = {
|
||||
params: { ip: string; heloDomain: string; hostname?: string; mailFrom: string };
|
||||
result: ISpfResult;
|
||||
};
|
||||
scanContent: {
|
||||
params: {
|
||||
subject?: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
attachmentNames?: string[];
|
||||
};
|
||||
result: IContentScanResult;
|
||||
};
|
||||
verifyEmail: {
|
||||
params: {
|
||||
rawMessage: string;
|
||||
@@ -116,6 +182,35 @@ type TMailerCommands = {
|
||||
};
|
||||
result: IEmailSecurityResult;
|
||||
};
|
||||
startSmtpServer: {
|
||||
params: ISmtpServerConfig;
|
||||
result: { started: boolean };
|
||||
};
|
||||
stopSmtpServer: {
|
||||
params: Record<string, never>;
|
||||
result: { stopped: boolean; wasRunning?: boolean };
|
||||
};
|
||||
emailProcessingResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
accepted: boolean;
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
authResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
configureRateLimits: {
|
||||
params: IRateLimitConfig;
|
||||
result: { configured: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -247,6 +342,16 @@ export class RustSecurityBridge {
|
||||
return this.bridge.sendCommand('detectBounce', opts);
|
||||
}
|
||||
|
||||
/** Scan email content for threats (phishing, spam, malware, etc.). */
|
||||
public async scanContent(opts: {
|
||||
subject?: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
attachmentNames?: string[];
|
||||
}): Promise<IContentScanResult> {
|
||||
return this.bridge.sendCommand('scanContent', opts);
|
||||
}
|
||||
|
||||
/** Check IP reputation via DNSBL. */
|
||||
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
||||
return this.bridge.sendCommand('checkIpReputation', { ip });
|
||||
@@ -292,6 +397,85 @@ export class RustSecurityBridge {
|
||||
}): Promise<IEmailSecurityResult> {
|
||||
return this.bridge.sendCommand('verifyEmail', opts);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SMTP Server lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
||||
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
||||
return result?.started === true;
|
||||
}
|
||||
|
||||
/** Stop the Rust SMTP server. */
|
||||
public async stopSmtpServer(): Promise<void> {
|
||||
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public async sendEmailProcessingResult(opts: {
|
||||
correlationId: string;
|
||||
accepted: boolean;
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
}): Promise<void> {
|
||||
await this.bridge.sendCommand('emailProcessingResult', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the result of authentication validation back to the Rust SMTP server.
|
||||
*/
|
||||
public async sendAuthResult(opts: {
|
||||
correlationId: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}): Promise<void> {
|
||||
await this.bridge.sendCommand('authResult', opts);
|
||||
}
|
||||
|
||||
/** Update rate limit configuration at runtime. */
|
||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||
await this.bridge.sendCommand('configureRateLimits', config);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event registration — delegates to the underlying bridge EventEmitter
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a handler for emailReceived events from the Rust SMTP server.
|
||||
* These events fire when a complete email has been received and needs processing.
|
||||
*/
|
||||
public onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.on('management:emailReceived', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for authRequest events from the Rust SMTP server.
|
||||
* The handler must call sendAuthResult() with the correlationId.
|
||||
*/
|
||||
public onAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.on('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove an emailReceived event handler. */
|
||||
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.off('management:emailReceived', handler);
|
||||
}
|
||||
|
||||
/** Remove an authRequest event handler. */
|
||||
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.off('management:authRequest', handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export interfaces for consumers
|
||||
@@ -302,6 +486,12 @@ export type {
|
||||
IEmailSecurityResult,
|
||||
IValidationResult,
|
||||
IBounceDetection,
|
||||
IContentScanResult,
|
||||
IReputationResult as IRustReputationResult,
|
||||
IVersionInfo,
|
||||
ISmtpServerConfig,
|
||||
IRateLimitConfig,
|
||||
IEmailData,
|
||||
IEmailReceivedEvent,
|
||||
IAuthRequestEvent,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user