BREAKING CHANGE(mail): remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
This commit is contained in:
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
|
||||||
|
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
|
||||||
|
|
||||||
|
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
|
||||||
|
- Removed ts/errors/index.ts — MTA-specific error classes removed
|
||||||
|
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
|
||||||
|
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
|
||||||
|
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
|
||||||
|
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
|
||||||
|
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
|
||||||
|
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
|
||||||
|
|
||||||
## 2026-02-11 - 4.1.1 - fix(readme)
|
## 2026-02-11 - 4.1.1 - fix(readme)
|
||||||
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
|
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
|
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
|
||||||
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js';
|
|
||||||
import { Email } from '../ts/mail/core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test email authentication systems: SPF and DMARC
|
* Test email authentication systems: SPF parsing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// SPF Verifier Tests
|
// SPF Verifier Tests
|
||||||
@@ -41,153 +39,6 @@ tap.test('SPF Verifier - should parse SPF record', async () => {
|
|||||||
expect(invalidParsed).toBeNull();
|
expect(invalidParsed).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// DMARC Verifier Tests
|
|
||||||
tap.test('DMARC Verifier - should parse DMARC record', async () => {
|
|
||||||
const dmarcVerifier = new DmarcVerifier();
|
|
||||||
|
|
||||||
// Test valid DMARC record parsing
|
|
||||||
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
|
|
||||||
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
|
|
||||||
|
|
||||||
expect(parsedRecord).toBeTruthy();
|
|
||||||
expect(parsedRecord.version).toEqual('DMARC1');
|
|
||||||
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
|
|
||||||
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
|
|
||||||
expect(parsedRecord.pct).toEqual(50);
|
|
||||||
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
|
|
||||||
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
|
|
||||||
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
|
|
||||||
|
|
||||||
// Test invalid record
|
|
||||||
const invalidRecord = 'not-a-dmarc-record';
|
|
||||||
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
|
|
||||||
expect(invalidParsed).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
|
|
||||||
const dmarcVerifier = new DmarcVerifier();
|
|
||||||
|
|
||||||
// Test email domains with DMARC alignment
|
|
||||||
const email = new Email({
|
|
||||||
from: 'sender@example.com',
|
|
||||||
to: 'recipient@example.net',
|
|
||||||
subject: 'Test DMARC alignment',
|
|
||||||
text: 'This is a test email'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test when both SPF and DKIM pass with alignment
|
|
||||||
const dmarcResult = await dmarcVerifier.verify(
|
|
||||||
email,
|
|
||||||
{ domain: 'example.com', result: true }, // SPF - aligned and passed
|
|
||||||
{ domain: 'example.com', result: true } // DKIM - aligned and passed
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dmarcResult).toBeTruthy();
|
|
||||||
expect(dmarcResult.spfPassed).toEqual(true);
|
|
||||||
expect(dmarcResult.dkimPassed).toEqual(true);
|
|
||||||
expect(dmarcResult.spfDomainAligned).toEqual(true);
|
|
||||||
expect(dmarcResult.dkimDomainAligned).toEqual(true);
|
|
||||||
expect(dmarcResult.action).toEqual('pass');
|
|
||||||
|
|
||||||
// Test when neither SPF nor DKIM is aligned
|
|
||||||
const dmarcResult2 = await dmarcVerifier.verify(
|
|
||||||
email,
|
|
||||||
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
|
|
||||||
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
|
|
||||||
);
|
|
||||||
|
|
||||||
// Without a DNS manager, no DMARC record will be found
|
|
||||||
|
|
||||||
expect(dmarcResult2).toBeTruthy();
|
|
||||||
expect(dmarcResult2.spfPassed).toEqual(true);
|
|
||||||
expect(dmarcResult2.dkimPassed).toEqual(true);
|
|
||||||
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
|
||||||
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
|
||||||
|
|
||||||
// Without a DMARC record, the default action is 'pass'
|
|
||||||
expect(dmarcResult2.hasDmarc).toEqual(false);
|
|
||||||
expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE);
|
|
||||||
expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE);
|
|
||||||
expect(dmarcResult2.action).toEqual('pass');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
|
||||||
const dmarcVerifier = new DmarcVerifier();
|
|
||||||
|
|
||||||
// Create test email
|
|
||||||
const email = new Email({
|
|
||||||
from: 'sender@example.com',
|
|
||||||
to: 'recipient@example.net',
|
|
||||||
subject: 'Test DMARC policy application',
|
|
||||||
text: 'This is a test email'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test pass action
|
|
||||||
const passResult: any = {
|
|
||||||
hasDmarc: true,
|
|
||||||
spfDomainAligned: true,
|
|
||||||
dkimDomainAligned: true,
|
|
||||||
spfPassed: true,
|
|
||||||
dkimPassed: true,
|
|
||||||
policyEvaluated: DmarcPolicy.NONE,
|
|
||||||
actualPolicy: DmarcPolicy.NONE,
|
|
||||||
appliedPercentage: 100,
|
|
||||||
action: 'pass',
|
|
||||||
details: 'DMARC passed'
|
|
||||||
};
|
|
||||||
|
|
||||||
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
|
|
||||||
expect(passApplied).toEqual(true);
|
|
||||||
expect(email.mightBeSpam).toEqual(false);
|
|
||||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
|
|
||||||
|
|
||||||
// Test quarantine action
|
|
||||||
const quarantineResult: any = {
|
|
||||||
hasDmarc: true,
|
|
||||||
spfDomainAligned: false,
|
|
||||||
dkimDomainAligned: false,
|
|
||||||
spfPassed: false,
|
|
||||||
dkimPassed: false,
|
|
||||||
policyEvaluated: DmarcPolicy.QUARANTINE,
|
|
||||||
actualPolicy: DmarcPolicy.QUARANTINE,
|
|
||||||
appliedPercentage: 100,
|
|
||||||
action: 'quarantine',
|
|
||||||
details: 'DMARC failed, policy=quarantine'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset email spam flag
|
|
||||||
email.mightBeSpam = false;
|
|
||||||
email.headers = {};
|
|
||||||
|
|
||||||
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
|
|
||||||
expect(quarantineApplied).toEqual(true);
|
|
||||||
expect(email.mightBeSpam).toEqual(true);
|
|
||||||
expect(email.headers['X-Spam-Flag']).toEqual('YES');
|
|
||||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
|
|
||||||
|
|
||||||
// Test reject action
|
|
||||||
const rejectResult: any = {
|
|
||||||
hasDmarc: true,
|
|
||||||
spfDomainAligned: false,
|
|
||||||
dkimDomainAligned: false,
|
|
||||||
spfPassed: false,
|
|
||||||
dkimPassed: false,
|
|
||||||
policyEvaluated: DmarcPolicy.REJECT,
|
|
||||||
actualPolicy: DmarcPolicy.REJECT,
|
|
||||||
appliedPercentage: 100,
|
|
||||||
action: 'reject',
|
|
||||||
details: 'DMARC failed, policy=reject'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset email spam flag
|
|
||||||
email.mightBeSpam = false;
|
|
||||||
email.headers = {};
|
|
||||||
|
|
||||||
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
|
|
||||||
expect(rejectApplied).toEqual(false);
|
|
||||||
expect(email.mightBeSpam).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
tap.test('stop', async () => {
|
||||||
await tap.stopForcefully();
|
await tap.stopForcefully();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should be instantiable', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 10,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(limiter).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 5,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 5 requests
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6th request should be denied
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 3,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 3 requests for key1
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
expect(limiter.isAllowed('key1')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4th request for key1 should be denied
|
|
||||||
expect(limiter.isAllowed('key1')).toEqual(false);
|
|
||||||
|
|
||||||
// But key2 should still be allowed
|
|
||||||
expect(limiter.isAllowed('key2')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should refill tokens over time', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 2,
|
|
||||||
periodMs: 100, // Short period for testing
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use all tokens
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Wait for refill
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Should have tokens again
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should support burst allowance', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 2,
|
|
||||||
periodMs: 100,
|
|
||||||
perKey: true,
|
|
||||||
burstTokens: 2, // Allow 2 extra tokens for bursts
|
|
||||||
initialTokens: 4 // Start with max + burst tokens
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should allow 4 requests (2 regular + 2 burst)
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5th request should be denied
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Wait for refill
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
|
|
||||||
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should return correct stats', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 10,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make some requests
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
limiter.isAllowed('test');
|
|
||||||
|
|
||||||
// Get stats
|
|
||||||
const stats = limiter.getStats('test');
|
|
||||||
|
|
||||||
expect(stats.remaining).toEqual(7);
|
|
||||||
expect(stats.limit).toEqual(10);
|
|
||||||
expect(stats.allowed).toEqual(3);
|
|
||||||
expect(stats.denied).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RateLimiter - should reset limits', async () => {
|
|
||||||
const limiter = new RateLimiter({
|
|
||||||
maxPerPeriod: 3,
|
|
||||||
periodMs: 1000,
|
|
||||||
perKey: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use all tokens
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(false);
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
limiter.reset('test');
|
|
||||||
|
|
||||||
// Should have tokens again
|
|
||||||
expect(limiter.isAllowed('test')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '4.1.1',
|
version: '5.0.0',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* MTA error classes for SMTP client operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class MtaConnectionError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaConnectionError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'CONNECTION_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError {
|
|
||||||
return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT');
|
|
||||||
}
|
|
||||||
static refused(host: string, port: number): MtaConnectionError {
|
|
||||||
return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED');
|
|
||||||
}
|
|
||||||
static dnsError(host: string, err?: any): MtaConnectionError {
|
|
||||||
const errMsg = typeof err === 'string' ? err : err?.message || '';
|
|
||||||
return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaAuthenticationError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaAuthenticationError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'AUTH_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static invalidCredentials(host?: string, user?: string): MtaAuthenticationError {
|
|
||||||
const detail = host && user ? `${user}@${host}` : host || user || '';
|
|
||||||
return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaDeliveryError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public responseCode?: number;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any, responseCode?: number) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaDeliveryError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
this.responseCode = responseCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'DELIVERY_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static temporary(message: string, ...args: any[]): MtaDeliveryError {
|
|
||||||
return new MtaDeliveryError(message, 'TEMPORARY');
|
|
||||||
}
|
|
||||||
static permanent(message: string, ...args: any[]): MtaDeliveryError {
|
|
||||||
return new MtaDeliveryError(message, 'PERMANENT');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaConfigurationError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaConfigurationError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'CONFIG_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaTimeoutError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaTimeoutError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'TIMEOUT';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError {
|
|
||||||
const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs;
|
|
||||||
return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MtaProtocolError extends Error {
|
|
||||||
public code: string;
|
|
||||||
public details?: any;
|
|
||||||
constructor(message: string, detailsOrCode?: any) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MtaProtocolError';
|
|
||||||
if (typeof detailsOrCode === 'string') {
|
|
||||||
this.code = detailsOrCode;
|
|
||||||
} else {
|
|
||||||
this.code = 'PROTOCOL_ERROR';
|
|
||||||
this.details = detailsOrCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { type EmailProcessingMode } from '../routing/classes.email.config.js';
|
import { type EmailProcessingMode } from './interfaces.js';
|
||||||
import type { IEmailRoute } from '../routing/interfaces.js';
|
import type { IEmailRoute } from '../routing/interfaces.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
153
ts/mail/routing/classes.dkim.manager.ts
Normal file
153
ts/mail/routing/classes.dkim.manager.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
|
import { DomainRegistry } from './classes.domain.registry.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../core/classes.email.js';
|
||||||
|
|
||||||
|
/** External DcRouter interface shape used by DkimManager */
|
||||||
|
interface DcRouter {
|
||||||
|
storageManager: any;
|
||||||
|
dnsServer?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages DKIM key setup, rotation, and signing for all configured domains
|
||||||
|
*/
|
||||||
|
export class DkimManager {
|
||||||
|
private dkimKeys: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dkimCreator: DKIMCreator,
|
||||||
|
private domainRegistry: DomainRegistry,
|
||||||
|
private dcRouter: DcRouter,
|
||||||
|
private rustBridge: RustSecurityBridge,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setupDkimForDomains(): Promise<void> {
|
||||||
|
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||||
|
|
||||||
|
if (domainConfigs.length === 0) {
|
||||||
|
logger.log('warn', 'No domains configured for DKIM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const domainConfig of domainConfigs) {
|
||||||
|
const domain = domainConfig.domain;
|
||||||
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let keyPair: { privateKey: string; publicKey: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
||||||
|
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
keyPair = await this.dkimCreator.createDKIMKeys();
|
||||||
|
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
||||||
|
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dkimKeys.set(domain, keyPair.privateKey);
|
||||||
|
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndRotateDkimKeys(): Promise<void> {
|
||||||
|
const domainConfigs = this.domainRegistry.getAllConfigs();
|
||||||
|
|
||||||
|
for (const domainConfig of domainConfigs) {
|
||||||
|
const domain = domainConfig.domain;
|
||||||
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
|
||||||
|
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
|
||||||
|
const keySize = domainConfig.dkim?.keySize || 2048;
|
||||||
|
|
||||||
|
if (!rotateKeys) {
|
||||||
|
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
|
||||||
|
|
||||||
|
if (needsRotation) {
|
||||||
|
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
|
||||||
|
|
||||||
|
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
|
||||||
|
|
||||||
|
domainConfig.dkim = {
|
||||||
|
...domainConfig.dkim,
|
||||||
|
selector: newSelector
|
||||||
|
};
|
||||||
|
|
||||||
|
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
||||||
|
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
|
||||||
|
const publicKeyBase64 = keyPair.publicKey
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||||
|
|
||||||
|
this.dcRouter.dnsServer.registerHandler(
|
||||||
|
`${newSelector}._domainkey.${domain}`,
|
||||||
|
['TXT'],
|
||||||
|
() => ({
|
||||||
|
name: `${newSelector}._domainkey.${domain}`,
|
||||||
|
type: 'TXT',
|
||||||
|
class: 'IN',
|
||||||
|
ttl: ttl,
|
||||||
|
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
|
||||||
|
|
||||||
|
await this.dcRouter.storageManager.set(
|
||||||
|
`/email/dkim/${domain}/public.key`,
|
||||||
|
keyPair.publicKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
|
||||||
|
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
logger.log('debug', `DKIM keys for ${domain} are up to date`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||||
|
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||||
|
const rawEmail = email.toRFC822String();
|
||||||
|
|
||||||
|
const signResult = await this.rustBridge.signDkim({
|
||||||
|
rawMessage: rawEmail,
|
||||||
|
domain,
|
||||||
|
selector,
|
||||||
|
privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signResult.header) {
|
||||||
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
|
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDkimKey(domain: string): string | undefined {
|
||||||
|
return this.dkimKeys.get(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
ts/mail/routing/classes.email.action.executor.ts
Normal file
174
ts/mail/routing/classes.email.action.executor.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import {
|
||||||
|
SecurityLogger,
|
||||||
|
SecurityLogLevel,
|
||||||
|
SecurityEventType
|
||||||
|
} from '../../security/index.js';
|
||||||
|
import type { IEmailAction, IEmailContext } from './interfaces.js';
|
||||||
|
import { Email } from '../core/classes.email.js';
|
||||||
|
import { BounceManager } from '../core/classes.bouncemanager.js';
|
||||||
|
import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js';
|
||||||
|
import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies injected from UnifiedEmailServer to avoid circular imports
|
||||||
|
*/
|
||||||
|
export interface IActionExecutorDeps {
|
||||||
|
sendOutboundEmail: (host: string, port: number, email: Email, options?: {
|
||||||
|
auth?: { user: string; pass: string };
|
||||||
|
dkimDomain?: string;
|
||||||
|
dkimSelector?: string;
|
||||||
|
}) => Promise<ISmtpSendResult>;
|
||||||
|
bounceManager: BounceManager;
|
||||||
|
deliveryQueue: UnifiedDeliveryQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes email routing actions (forward, process, deliver, reject)
|
||||||
|
*/
|
||||||
|
export class EmailActionExecutor {
|
||||||
|
constructor(private deps: IActionExecutorDeps) {}
|
||||||
|
|
||||||
|
async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'forward':
|
||||||
|
await this.handleForwardAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'process':
|
||||||
|
await this.handleProcessAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'deliver':
|
||||||
|
await this.handleDeliverAction(action, email, context);
|
||||||
|
break;
|
||||||
|
case 'reject':
|
||||||
|
await this.handleRejectAction(action, email, context);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${(action as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
if (!action.forward) {
|
||||||
|
throw new Error('Forward action requires forward configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, port = 25, auth, addHeaders } = action.forward;
|
||||||
|
|
||||||
|
logger.log('info', `Forwarding email to ${host}:${port}`);
|
||||||
|
|
||||||
|
// Add forwarding headers
|
||||||
|
if (addHeaders) {
|
||||||
|
for (const [key, value] of Object.entries(addHeaders)) {
|
||||||
|
email.headers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard forwarding headers
|
||||||
|
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
|
||||||
|
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
||||||
|
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send email via Rust SMTP client
|
||||||
|
await this.deps.sendOutboundEmail(host, port, email, {
|
||||||
|
auth: auth as { user: string; pass: string } | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.INFO,
|
||||||
|
type: SecurityEventType.EMAIL_FORWARDING,
|
||||||
|
message: 'Email forwarded successfully',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
targetHost: host,
|
||||||
|
targetPort: port,
|
||||||
|
recipients: email.to
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.ERROR,
|
||||||
|
type: SecurityEventType.EMAIL_FORWARDING,
|
||||||
|
message: 'Email forwarding failed',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
targetHost: host,
|
||||||
|
targetPort: port,
|
||||||
|
error: error.message
|
||||||
|
},
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle as bounce
|
||||||
|
for (const recipient of email.getAllRecipients()) {
|
||||||
|
await this.deps.bounceManager.processSmtpFailure(recipient, error.message, {
|
||||||
|
sender: email.from,
|
||||||
|
originalEmailId: email.headers['Message-ID'] as string
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
logger.log('info', `Processing email with action options`);
|
||||||
|
|
||||||
|
// Apply scanning if requested
|
||||||
|
if (action.process?.scan) {
|
||||||
|
logger.log('info', 'Content scanning requested');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for delivery
|
||||||
|
const queue = action.process?.queue || 'normal';
|
||||||
|
await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
|
||||||
|
|
||||||
|
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
logger.log('info', `Delivering email locally`);
|
||||||
|
|
||||||
|
// Queue for local delivery
|
||||||
|
await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
|
||||||
|
|
||||||
|
logger.log('info', 'Email queued for local delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
|
||||||
|
const code = action.reject?.code || 550;
|
||||||
|
const message = action.reject?.message || 'Message rejected';
|
||||||
|
|
||||||
|
logger.log('info', `Rejecting email with code ${code}: ${message}`);
|
||||||
|
|
||||||
|
SecurityLogger.getInstance().logEvent({
|
||||||
|
level: SecurityLogLevel.WARN,
|
||||||
|
type: SecurityEventType.EMAIL_PROCESSING,
|
||||||
|
message: 'Email rejected by routing rule',
|
||||||
|
ipAddress: context.session.remoteAddress,
|
||||||
|
details: {
|
||||||
|
sessionId: context.session.id,
|
||||||
|
routeName: context.session.matchedRoute?.name,
|
||||||
|
rejectCode: code,
|
||||||
|
rejectMessage: message,
|
||||||
|
from: _email.from,
|
||||||
|
to: _email.to
|
||||||
|
},
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throw error with SMTP code and message
|
||||||
|
const error = new Error(message);
|
||||||
|
(error as any).responseCode = code;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { EmailProcessingMode } from '../delivery/interfaces.js';
|
|
||||||
|
|
||||||
// Re-export EmailProcessingMode type
|
|
||||||
export type { EmailProcessingMode };
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain rule interface for pattern-based routing
|
|
||||||
*/
|
|
||||||
export interface IDomainRule {
|
|
||||||
// Domain pattern (e.g., "*@example.com", "*@*.example.net")
|
|
||||||
pattern: string;
|
|
||||||
|
|
||||||
// Handling mode for this pattern
|
|
||||||
mode: EmailProcessingMode;
|
|
||||||
|
|
||||||
// Forward mode configuration
|
|
||||||
target?: {
|
|
||||||
server: string;
|
|
||||||
port?: number;
|
|
||||||
useTls?: boolean;
|
|
||||||
authentication?: {
|
|
||||||
user?: string;
|
|
||||||
pass?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// MTA mode configuration
|
|
||||||
mtaOptions?: IMtaOptions;
|
|
||||||
|
|
||||||
// Process mode configuration
|
|
||||||
contentScanning?: boolean;
|
|
||||||
scanners?: IContentScanner[];
|
|
||||||
transformations?: ITransformation[];
|
|
||||||
|
|
||||||
// Rate limits for this domain
|
|
||||||
rateLimits?: {
|
|
||||||
maxMessagesPerMinute?: number;
|
|
||||||
maxRecipientsPerMessage?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MTA options interface
|
|
||||||
*/
|
|
||||||
export interface IMtaOptions {
|
|
||||||
domain?: string;
|
|
||||||
allowLocalDelivery?: boolean;
|
|
||||||
localDeliveryPath?: string;
|
|
||||||
dkimSign?: boolean;
|
|
||||||
dkimOptions?: {
|
|
||||||
domainName: string;
|
|
||||||
keySelector: string;
|
|
||||||
privateKey?: string;
|
|
||||||
};
|
|
||||||
smtpBanner?: string;
|
|
||||||
maxConnections?: number;
|
|
||||||
connTimeout?: number;
|
|
||||||
spoolDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Content scanner interface
|
|
||||||
*/
|
|
||||||
export interface IContentScanner {
|
|
||||||
type: 'spam' | 'virus' | 'attachment';
|
|
||||||
threshold?: number;
|
|
||||||
action: 'tag' | 'reject';
|
|
||||||
blockedExtensions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformation interface
|
|
||||||
*/
|
|
||||||
export interface ITransformation {
|
|
||||||
type: string;
|
|
||||||
header?: string;
|
|
||||||
value?: string;
|
|
||||||
domains?: string[];
|
|
||||||
append?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.js';
|
} from '../../security/index.js';
|
||||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import { EmailRouter } from './classes.email.router.js';
|
import { EmailRouter } from './classes.email.router.js';
|
||||||
@@ -23,6 +22,10 @@ import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.de
|
|||||||
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
||||||
import { SmtpState } from '../delivery/interfaces.js';
|
import { SmtpState } from '../delivery/interfaces.js';
|
||||||
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
||||||
|
import { EmailActionExecutor } from './classes.email.action.executor.js';
|
||||||
|
import { DkimManager } from './classes.dkim.manager.js';
|
||||||
|
|
||||||
|
|
||||||
/** External DcRouter interface shape used by UnifiedEmailServer */
|
/** External DcRouter interface shape used by UnifiedEmailServer */
|
||||||
interface DcRouter {
|
interface DcRouter {
|
||||||
storageManager: any;
|
storageManager: any;
|
||||||
@@ -160,12 +163,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Add components needed for sending and securing emails
|
// Add components needed for sending and securing emails
|
||||||
public dkimCreator: DKIMCreator;
|
public dkimCreator: DKIMCreator;
|
||||||
private rustBridge: RustSecurityBridge;
|
private rustBridge: RustSecurityBridge;
|
||||||
private ipReputationChecker: IPReputationChecker;
|
|
||||||
private bounceManager: BounceManager;
|
private bounceManager: BounceManager;
|
||||||
public deliveryQueue: UnifiedDeliveryQueue;
|
public deliveryQueue: UnifiedDeliveryQueue;
|
||||||
public deliverySystem: MultiModeDeliverySystem;
|
public deliverySystem: MultiModeDeliverySystem;
|
||||||
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
||||||
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
|
|
||||||
|
// Extracted subsystems
|
||||||
|
private actionExecutor: EmailActionExecutor;
|
||||||
|
private dkimManager: DkimManager;
|
||||||
|
|
||||||
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -188,13 +193,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Initialize DKIM creator with storage manager
|
// Initialize DKIM creator with storage manager
|
||||||
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager);
|
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager);
|
||||||
|
|
||||||
// Initialize IP reputation checker with storage manager
|
|
||||||
this.ipReputationChecker = IPReputationChecker.getInstance({
|
|
||||||
enableLocalCache: true,
|
|
||||||
enableDNSBL: true,
|
|
||||||
enableIPInfo: true
|
|
||||||
}, dcRouter.storageManager);
|
|
||||||
|
|
||||||
// Initialize bounce manager with storage manager
|
// Initialize bounce manager with storage manager
|
||||||
this.bounceManager = new BounceManager({
|
this.bounceManager = new BounceManager({
|
||||||
maxCacheSize: 10000,
|
maxCacheSize: 10000,
|
||||||
@@ -247,6 +245,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
||||||
|
|
||||||
|
// Initialize action executor
|
||||||
|
this.actionExecutor = new EmailActionExecutor({
|
||||||
|
sendOutboundEmail: this.sendOutboundEmail.bind(this),
|
||||||
|
bounceManager: this.bounceManager,
|
||||||
|
deliveryQueue: this.deliveryQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize DKIM manager
|
||||||
|
this.dkimManager = new DkimManager(this.dkimCreator, this.domainRegistry, dcRouter, this.rustBridge);
|
||||||
|
|
||||||
// Initialize statistics
|
// Initialize statistics
|
||||||
this.stats = {
|
this.stats = {
|
||||||
startTime: new Date(),
|
startTime: new Date(),
|
||||||
@@ -323,70 +331,62 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize the delivery queue
|
await this.startDeliveryPipeline();
|
||||||
|
await this.startRustBridge();
|
||||||
|
await this.initializeDkimAndDns();
|
||||||
|
this.registerBridgeEventHandlers();
|
||||||
|
await this.startSmtpServer();
|
||||||
|
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||||
|
this.emit('started');
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startDeliveryPipeline(): Promise<void> {
|
||||||
await this.deliveryQueue.initialize();
|
await this.deliveryQueue.initialize();
|
||||||
logger.log('info', 'Email delivery queue initialized');
|
logger.log('info', 'Email delivery queue initialized');
|
||||||
|
|
||||||
// Start the delivery system
|
|
||||||
await this.deliverySystem.start();
|
await this.deliverySystem.start();
|
||||||
logger.log('info', 'Email delivery system started');
|
logger.log('info', 'Email delivery system started');
|
||||||
|
}
|
||||||
|
|
||||||
// Start Rust security bridge — required for all security operations
|
private async startRustBridge(): Promise<void> {
|
||||||
const bridgeOk = await this.rustBridge.start();
|
const bridgeOk = await this.rustBridge.start();
|
||||||
if (!bridgeOk) {
|
if (!bridgeOk) {
|
||||||
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
|
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');
|
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
||||||
|
|
||||||
// Listen for bridge state changes to propagate resilience events
|
|
||||||
this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
|
this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
|
||||||
if (newState === 'failed') this.emit('bridgeFailed');
|
if (newState === 'failed') this.emit('bridgeFailed');
|
||||||
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
||||||
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Set up DKIM for all domains
|
private async initializeDkimAndDns(): Promise<void> {
|
||||||
await this.setupDkimForDomains();
|
await this.dkimManager.setupDkimForDomains();
|
||||||
logger.log('info', 'DKIM configuration completed for all domains');
|
logger.log('info', 'DKIM configuration completed for all domains');
|
||||||
|
|
||||||
// Create DNS manager and ensure all DNS records are created
|
|
||||||
const dnsManager = new DnsManager(this.dcRouter);
|
const dnsManager = new DnsManager(this.dcRouter);
|
||||||
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
|
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
|
||||||
logger.log('info', 'DNS records ensured for all configured domains');
|
logger.log('info', 'DNS records ensured for all configured domains');
|
||||||
|
|
||||||
// Apply per-domain rate limits
|
|
||||||
this.applyDomainRateLimits();
|
this.applyDomainRateLimits();
|
||||||
logger.log('info', 'Per-domain rate limits configured');
|
logger.log('info', 'Per-domain rate limits configured');
|
||||||
|
|
||||||
// Check and rotate DKIM keys if needed
|
await this.dkimManager.checkAndRotateDkimKeys();
|
||||||
await this.checkAndRotateDkimKeys();
|
|
||||||
logger.log('info', 'DKIM key rotation check completed');
|
logger.log('info', 'DKIM key rotation check completed');
|
||||||
|
|
||||||
// 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 tlsCertPem: string | undefined;
|
|
||||||
let tlsKeyPem: string | undefined;
|
|
||||||
|
|
||||||
if (hasTlsConfig) {
|
|
||||||
try {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start Rust SMTP server ---
|
private registerBridgeEventHandlers(): void {
|
||||||
// Register event handlers for email reception and auth
|
|
||||||
this.rustBridge.onEmailReceived(async (data) => {
|
this.rustBridge.onEmailReceived(async (data) => {
|
||||||
try {
|
try {
|
||||||
await this.handleRustEmailReceived(data);
|
await this.handleRustEmailReceived(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||||
// Send rejection back to Rust (may fail if bridge is restarting)
|
|
||||||
try {
|
try {
|
||||||
await this.rustBridge.sendEmailProcessingResult({
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
correlationId: data.correlationId,
|
correlationId: data.correlationId,
|
||||||
@@ -416,8 +416,23 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startSmtpServer(): Promise<void> {
|
||||||
|
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
||||||
|
let tlsCertPem: string | undefined;
|
||||||
|
let tlsKeyPem: string | undefined;
|
||||||
|
|
||||||
|
if (hasTlsConfig) {
|
||||||
|
try {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine which ports need STARTTLS and which need implicit TLS
|
|
||||||
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
||||||
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||||
|
|
||||||
@@ -449,12 +464,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
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) {
|
|
||||||
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -512,8 +521,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an emailReceived event from the Rust SMTP server.
|
* 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> {
|
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
||||||
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
||||||
@@ -588,7 +595,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an authRequest event from the Rust SMTP server.
|
* Handle an authRequest event from the Rust SMTP server.
|
||||||
* Validates credentials and sends back the result.
|
|
||||||
*/
|
*/
|
||||||
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
||||||
const { correlationId, username, password, remoteAddr } = data;
|
const { correlationId, username, password, remoteAddr } = data;
|
||||||
@@ -740,14 +746,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First check if this is a bounce notification email
|
// First check if this is a bounce notification email
|
||||||
// Look for common bounce notification subject patterns
|
|
||||||
const subject = email.subject || '';
|
const subject = email.subject || '';
|
||||||
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
||||||
|
|
||||||
if (isBounceLike) {
|
if (isBounceLike) {
|
||||||
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
|
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
|
||||||
|
|
||||||
// Try to process as a bounce
|
|
||||||
const isBounce = await this.processBounceNotification(email);
|
const isBounce = await this.processBounceNotification(email);
|
||||||
|
|
||||||
if (isBounce) {
|
if (isBounce) {
|
||||||
@@ -763,7 +767,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
const route = await this.emailRouter.evaluateRoutes(context);
|
const route = await this.emailRouter.evaluateRoutes(context);
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
// No matching route - reject
|
|
||||||
throw new Error('No matching route for email');
|
throw new Error('No matching route for email');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,221 +774,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
session.matchedRoute = route;
|
session.matchedRoute = route;
|
||||||
|
|
||||||
// Execute action based on route
|
// Execute action based on route
|
||||||
await this.executeAction(route.action, email, context);
|
await this.actionExecutor.executeAction(route.action, email, context);
|
||||||
|
|
||||||
// Return the processed email
|
// Return the processed email
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute action based on route configuration
|
|
||||||
*/
|
|
||||||
private async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'forward':
|
|
||||||
await this.handleForwardAction(action, email, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'process':
|
|
||||||
await this.handleProcessAction(action, email, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'deliver':
|
|
||||||
await this.handleDeliverAction(action, email, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'reject':
|
|
||||||
await this.handleRejectAction(action, email, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown action type: ${(action as any).type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle forward action
|
|
||||||
*/
|
|
||||||
private async handleForwardAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
if (!_action.forward) {
|
|
||||||
throw new Error('Forward action requires forward configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { host, port = 25, auth, addHeaders } = _action.forward;
|
|
||||||
|
|
||||||
logger.log('info', `Forwarding email to ${host}:${port}`);
|
|
||||||
|
|
||||||
// Add forwarding headers
|
|
||||||
if (addHeaders) {
|
|
||||||
for (const [key, value] of Object.entries(addHeaders)) {
|
|
||||||
email.headers[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add standard forwarding headers
|
|
||||||
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
|
|
||||||
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
|
||||||
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send email via Rust SMTP client
|
|
||||||
await this.sendOutboundEmail(host, port, email, {
|
|
||||||
auth: auth as { user: string; pass: string } | undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.INFO,
|
|
||||||
type: SecurityEventType.EMAIL_FORWARDING,
|
|
||||||
message: 'Email forwarded successfully',
|
|
||||||
ipAddress: context.session.remoteAddress,
|
|
||||||
details: {
|
|
||||||
sessionId: context.session.id,
|
|
||||||
routeName: context.session.matchedRoute?.name,
|
|
||||||
targetHost: host,
|
|
||||||
targetPort: port,
|
|
||||||
recipients: email.to
|
|
||||||
},
|
|
||||||
success: true
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.EMAIL_FORWARDING,
|
|
||||||
message: 'Email forwarding failed',
|
|
||||||
ipAddress: context.session.remoteAddress,
|
|
||||||
details: {
|
|
||||||
sessionId: context.session.id,
|
|
||||||
routeName: context.session.matchedRoute?.name,
|
|
||||||
targetHost: host,
|
|
||||||
targetPort: port,
|
|
||||||
error: error.message
|
|
||||||
},
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle as bounce
|
|
||||||
for (const recipient of email.getAllRecipients()) {
|
|
||||||
await this.bounceManager.processSmtpFailure(recipient, error.message, {
|
|
||||||
sender: email.from,
|
|
||||||
originalEmailId: email.headers['Message-ID'] as string
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle process action
|
|
||||||
*/
|
|
||||||
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
logger.log('info', `Processing email with action options`);
|
|
||||||
|
|
||||||
// Apply scanning if requested
|
|
||||||
if (action.process?.scan) {
|
|
||||||
// Use existing content scanner
|
|
||||||
// Note: ContentScanner integration would go here
|
|
||||||
logger.log('info', 'Content scanning requested');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: DKIM signing will be applied at delivery time to ensure signature validity
|
|
||||||
|
|
||||||
// Queue for delivery
|
|
||||||
const queue = action.process?.queue || 'normal';
|
|
||||||
await this.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
|
|
||||||
|
|
||||||
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deliver action
|
|
||||||
*/
|
|
||||||
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
logger.log('info', `Delivering email locally`);
|
|
||||||
|
|
||||||
// Queue for local delivery
|
|
||||||
await this.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
|
|
||||||
|
|
||||||
logger.log('info', 'Email queued for local delivery');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle reject action
|
|
||||||
*/
|
|
||||||
private async handleRejectAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
|
|
||||||
const code = action.reject?.code || 550;
|
|
||||||
const message = action.reject?.message || 'Message rejected';
|
|
||||||
|
|
||||||
logger.log('info', `Rejecting email with code ${code}: ${message}`);
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.EMAIL_PROCESSING,
|
|
||||||
message: 'Email rejected by routing rule',
|
|
||||||
ipAddress: context.session.remoteAddress,
|
|
||||||
details: {
|
|
||||||
sessionId: context.session.id,
|
|
||||||
routeName: context.session.matchedRoute?.name,
|
|
||||||
rejectCode: code,
|
|
||||||
rejectMessage: message,
|
|
||||||
from: email.from,
|
|
||||||
to: email.to
|
|
||||||
},
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Throw error with SMTP code and message
|
|
||||||
const error = new Error(message);
|
|
||||||
(error as any).responseCode = code;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up DKIM configuration for all domains
|
|
||||||
*/
|
|
||||||
private async setupDkimForDomains(): Promise<void> {
|
|
||||||
const domainConfigs = this.domainRegistry.getAllConfigs();
|
|
||||||
|
|
||||||
if (domainConfigs.length === 0) {
|
|
||||||
logger.log('warn', 'No domains configured for DKIM');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const domainConfig of domainConfigs) {
|
|
||||||
const domain = domainConfig.domain;
|
|
||||||
const selector = domainConfig.dkim?.selector || 'default';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if DKIM keys already exist for this domain
|
|
||||||
let keyPair: { privateKey: string; publicKey: string };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to read existing keys
|
|
||||||
keyPair = await this.dkimCreator.readDKIMKeys(domain);
|
|
||||||
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
// Generate new keys if they don't exist
|
|
||||||
keyPair = await this.dkimCreator.createDKIMKeys();
|
|
||||||
// Store them for future use
|
|
||||||
await this.dkimCreator.createAndStoreDKIMKeys(domain);
|
|
||||||
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the private key for signing
|
|
||||||
this.dkimKeys.set(domain, keyPair.privateKey);
|
|
||||||
|
|
||||||
// DNS record creation is now handled by DnsManager
|
|
||||||
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply per-domain rate limits from domain configurations
|
* Apply per-domain rate limits from domain configurations
|
||||||
*/
|
*/
|
||||||
@@ -997,12 +791,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
const domain = domainConfig.domain;
|
const domain = domainConfig.domain;
|
||||||
const rateLimitConfig: any = {};
|
const rateLimitConfig: any = {};
|
||||||
|
|
||||||
// Convert domain-specific rate limits to the format expected by UnifiedRateLimiter
|
|
||||||
if (domainConfig.rateLimits.outbound) {
|
if (domainConfig.rateLimits.outbound) {
|
||||||
if (domainConfig.rateLimits.outbound.messagesPerMinute) {
|
if (domainConfig.rateLimits.outbound.messagesPerMinute) {
|
||||||
rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute;
|
rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute;
|
||||||
}
|
}
|
||||||
// Note: messagesPerHour and messagesPerDay would need additional implementation in rate limiter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domainConfig.rateLimits.inbound) {
|
if (domainConfig.rateLimits.inbound) {
|
||||||
@@ -1017,7 +809,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the rate limits if we have any
|
|
||||||
if (Object.keys(rateLimitConfig).length > 0) {
|
if (Object.keys(rateLimitConfig).length > 0) {
|
||||||
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
|
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
|
||||||
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
|
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
|
||||||
@@ -1026,88 +817,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check and rotate DKIM keys if needed
|
|
||||||
*/
|
|
||||||
private async checkAndRotateDkimKeys(): Promise<void> {
|
|
||||||
const domainConfigs = this.domainRegistry.getAllConfigs();
|
|
||||||
|
|
||||||
for (const domainConfig of domainConfigs) {
|
|
||||||
const domain = domainConfig.domain;
|
|
||||||
const selector = domainConfig.dkim?.selector || 'default';
|
|
||||||
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
|
|
||||||
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
|
|
||||||
const keySize = domainConfig.dkim?.keySize || 2048;
|
|
||||||
|
|
||||||
if (!rotateKeys) {
|
|
||||||
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if keys need rotation
|
|
||||||
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
|
|
||||||
|
|
||||||
if (needsRotation) {
|
|
||||||
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
|
|
||||||
|
|
||||||
// Rotate the keys
|
|
||||||
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
|
|
||||||
|
|
||||||
// Update the domain config with new selector
|
|
||||||
domainConfig.dkim = {
|
|
||||||
...domainConfig.dkim,
|
|
||||||
selector: newSelector
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-register DNS handler for new selector if internal-dns mode
|
|
||||||
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
|
||||||
// Get new public key
|
|
||||||
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
|
|
||||||
const publicKeyBase64 = keyPair.publicKey
|
|
||||||
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
|
||||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
|
||||||
.replace(/\s/g, '');
|
|
||||||
|
|
||||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
|
||||||
|
|
||||||
// Register new selector
|
|
||||||
this.dcRouter.dnsServer.registerHandler(
|
|
||||||
`${newSelector}._domainkey.${domain}`,
|
|
||||||
['TXT'],
|
|
||||||
() => ({
|
|
||||||
name: `${newSelector}._domainkey.${domain}`,
|
|
||||||
type: 'TXT',
|
|
||||||
class: 'IN',
|
|
||||||
ttl: ttl,
|
|
||||||
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
|
|
||||||
|
|
||||||
// Store the updated public key in storage
|
|
||||||
await this.dcRouter.storageManager.set(
|
|
||||||
`/email/dkim/${domain}/public.key`,
|
|
||||||
keyPair.publicKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old keys after grace period (async, don't wait)
|
|
||||||
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
|
|
||||||
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
logger.log('debug', `DKIM keys for ${domain} are up to date`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate SmartProxy routes for email ports
|
* Generate SmartProxy routes for email ports
|
||||||
*/
|
*/
|
||||||
@@ -1121,26 +830,24 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
const actualPortMapping = portMapping || defaultPortMapping;
|
const actualPortMapping = portMapping || defaultPortMapping;
|
||||||
|
|
||||||
// Generate routes for each configured port
|
|
||||||
for (const externalPort of this.options.ports) {
|
for (const externalPort of this.options.ports) {
|
||||||
const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
|
const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
|
||||||
|
|
||||||
let routeName = 'email-route';
|
let routeName = 'email-route';
|
||||||
let tlsMode = 'passthrough';
|
let tlsMode = 'passthrough';
|
||||||
|
|
||||||
// Configure based on port
|
|
||||||
switch (externalPort) {
|
switch (externalPort) {
|
||||||
case 25:
|
case 25:
|
||||||
routeName = 'smtp-route';
|
routeName = 'smtp-route';
|
||||||
tlsMode = 'passthrough'; // STARTTLS
|
tlsMode = 'passthrough';
|
||||||
break;
|
break;
|
||||||
case 587:
|
case 587:
|
||||||
routeName = 'submission-route';
|
routeName = 'submission-route';
|
||||||
tlsMode = 'passthrough'; // STARTTLS
|
tlsMode = 'passthrough';
|
||||||
break;
|
break;
|
||||||
case 465:
|
case 465:
|
||||||
routeName = 'smtps-route';
|
routeName = 'smtps-route';
|
||||||
tlsMode = 'terminate'; // Implicit TLS
|
tlsMode = 'terminate';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
routeName = `email-port-${externalPort}-route`;
|
routeName = `email-port-${externalPort}-route`;
|
||||||
@@ -1171,7 +878,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Update server configuration
|
* Update server configuration
|
||||||
*/
|
*/
|
||||||
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
||||||
// Stop the server if changing ports
|
|
||||||
const portsChanged = options.ports &&
|
const portsChanged = options.ports &&
|
||||||
(!this.options.ports ||
|
(!this.options.ports ||
|
||||||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
||||||
@@ -1182,15 +888,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
this.start();
|
this.start();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update options without restart
|
|
||||||
this.options = { ...this.options, ...options };
|
this.options = { ...this.options, ...options };
|
||||||
|
|
||||||
// Update domain registry if domains changed
|
|
||||||
if (options.domains) {
|
if (options.domains) {
|
||||||
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
|
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email router if routes changed
|
|
||||||
if (options.routes) {
|
if (options.routes) {
|
||||||
this.emailRouter.updateRoutes(options.routes);
|
this.emailRouter.updateRoutes(options.routes);
|
||||||
}
|
}
|
||||||
@@ -1219,21 +922,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
return this.domainRegistry;
|
return this.domainRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update email routes dynamically
|
|
||||||
*/
|
|
||||||
public updateRoutes(routes: IEmailRoute[]): void {
|
|
||||||
this.emailRouter.setRoutes(routes);
|
|
||||||
logger.log('info', `Updated email routes with ${routes.length} routes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an email through the delivery system
|
* Send an email through the delivery system
|
||||||
* @param email The email to send
|
|
||||||
* @param mode The processing mode to use
|
|
||||||
* @param rule Optional rule to apply
|
|
||||||
* @param options Optional sending options
|
|
||||||
* @returns The ID of the queued email
|
|
||||||
*/
|
*/
|
||||||
public async sendEmail(
|
public async sendEmail(
|
||||||
email: Email,
|
email: Email,
|
||||||
@@ -1248,7 +938,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
|
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate the email
|
|
||||||
if (!email.from) {
|
if (!email.from) {
|
||||||
throw new Error('Email must have a sender address');
|
throw new Error('Email must have a sender address');
|
||||||
}
|
}
|
||||||
@@ -1257,12 +946,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
throw new Error('Email must have at least one recipient');
|
throw new Error('Email must have at least one recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any recipients are on the suppression list (unless explicitly skipped)
|
// Check if any recipients are on the suppression list
|
||||||
if (!options?.skipSuppressionCheck) {
|
if (!options?.skipSuppressionCheck) {
|
||||||
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
|
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
|
||||||
|
|
||||||
if (suppressedRecipients.length > 0) {
|
if (suppressedRecipients.length > 0) {
|
||||||
// Filter out suppressed recipients
|
|
||||||
const originalCount = email.to.length;
|
const originalCount = email.to.length;
|
||||||
const suppressed = suppressedRecipients.map(recipient => {
|
const suppressed = suppressedRecipients.map(recipient => {
|
||||||
const info = this.getSuppressionInfo(recipient);
|
const info = this.getSuppressionInfo(recipient);
|
||||||
@@ -1275,26 +963,21 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
|
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
|
||||||
|
|
||||||
// If all recipients are suppressed, throw an error
|
|
||||||
if (suppressedRecipients.length === originalCount) {
|
if (suppressedRecipients.length === originalCount) {
|
||||||
throw new Error('All recipients are on the suppression list');
|
throw new Error('All recipients are on the suppression list');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter the recipients list to only include non-suppressed addresses
|
|
||||||
email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
|
email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the sender domain has DKIM keys and sign the email if needed
|
// Sign with DKIM if configured
|
||||||
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
||||||
const domain = email.from.split('@')[1];
|
const domain = email.from.split('@')[1];
|
||||||
await this.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
|
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique ID for this email
|
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
|
|
||||||
// Queue the email for delivery
|
|
||||||
await this.deliveryQueue.enqueue(email, mode, route);
|
await this.deliveryQueue.enqueue(email, mode, route);
|
||||||
|
|
||||||
logger.log('info', `Email queued with ID: ${id}`);
|
logger.log('info', `Email queued with ID: ${id}`);
|
||||||
@@ -1305,51 +988,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -----------------------------------------------------------------------
|
||||||
* Handle DKIM signing for an email
|
// Bounce/suppression methods
|
||||||
* @param email The email to sign
|
// -----------------------------------------------------------------------
|
||||||
* @param domain The domain to sign with
|
|
||||||
* @param selector The DKIM selector
|
|
||||||
*/
|
|
||||||
private async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
|
|
||||||
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 via Rust bridge
|
|
||||||
const signResult = await this.rustBridge.signDkim({
|
|
||||||
rawMessage: rawEmail,
|
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
privateKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signResult.header) {
|
|
||||||
email.addHeader('DKIM-Signature', signResult.header);
|
|
||||||
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
|
||||||
// Continue without DKIM rather than failing the send
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a bounce notification email
|
|
||||||
* @param bounceEmail The email containing bounce notification information
|
|
||||||
* @returns Processed bounce record or null if not a bounce
|
|
||||||
*/
|
|
||||||
public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
|
public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
|
||||||
logger.log('info', 'Processing potential bounce notification email');
|
logger.log('info', 'Processing potential bounce notification email');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process as a bounce notification (no conversion needed anymore)
|
|
||||||
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
|
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
|
||||||
|
|
||||||
if (bounceRecord) {
|
if (bounceRecord) {
|
||||||
@@ -1358,10 +1004,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
bounceCategory: bounceRecord.bounceCategory
|
bounceCategory: bounceRecord.bounceCategory
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify any registered listeners about the bounce
|
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Log security event
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
@@ -1387,10 +1031,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
message: 'Failed to process bounce notification',
|
message: 'Failed to process bounce notification',
|
||||||
details: {
|
details: { error: error.message, subject: bounceEmail.subject },
|
||||||
error: error.message,
|
|
||||||
subject: bounceEmail.subject
|
|
||||||
},
|
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1398,41 +1039,22 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an SMTP failure as a bounce
|
|
||||||
* @param recipient Recipient email that failed
|
|
||||||
* @param smtpResponse SMTP error response
|
|
||||||
* @param options Additional options for bounce processing
|
|
||||||
* @returns Processed bounce record
|
|
||||||
*/
|
|
||||||
public async processSmtpFailure(
|
public async processSmtpFailure(
|
||||||
recipient: string,
|
recipient: string,
|
||||||
smtpResponse: string,
|
smtpResponse: string,
|
||||||
options: {
|
options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record<string, string> } = {}
|
||||||
sender?: string;
|
|
||||||
originalEmailId?: string;
|
|
||||||
statusCode?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
} = {}
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
|
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process the SMTP failure through the bounce manager
|
const bounceRecord = await this.bounceManager.processSmtpFailure(recipient, smtpResponse, options);
|
||||||
const bounceRecord = await this.bounceManager.processSmtpFailure(
|
|
||||||
recipient,
|
|
||||||
smtpResponse,
|
|
||||||
options
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
|
logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
|
||||||
bounceType: bounceRecord.bounceType
|
bounceType: bounceRecord.bounceType
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify any registered listeners about the bounce
|
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Log security event
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
@@ -1455,11 +1077,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.EMAIL_VALIDATION,
|
type: SecurityEventType.EMAIL_VALIDATION,
|
||||||
message: 'Failed to process SMTP failure',
|
message: 'Failed to process SMTP failure',
|
||||||
details: {
|
details: { recipient, smtpResponse, error: error.message },
|
||||||
recipient,
|
|
||||||
smtpResponse,
|
|
||||||
error: error.message
|
|
||||||
},
|
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1467,85 +1085,36 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an email address is suppressed (has bounced previously)
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Whether the email is suppressed
|
|
||||||
*/
|
|
||||||
public isEmailSuppressed(email: string): boolean {
|
public isEmailSuppressed(email: string): boolean {
|
||||||
return this.bounceManager.isEmailSuppressed(email);
|
return this.bounceManager.isEmailSuppressed(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number } | null {
|
||||||
* Get suppression information for an email
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Suppression information or null if not suppressed
|
|
||||||
*/
|
|
||||||
public getSuppressionInfo(email: string): {
|
|
||||||
reason: string;
|
|
||||||
timestamp: number;
|
|
||||||
expiresAt?: number;
|
|
||||||
} | null {
|
|
||||||
return this.bounceManager.getSuppressionInfo(email);
|
return this.bounceManager.getSuppressionInfo(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public getBounceHistory(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory } | null {
|
||||||
* Get bounce history information for an email
|
|
||||||
* @param email Email address to check
|
|
||||||
* @returns Bounce history or null if no bounces
|
|
||||||
*/
|
|
||||||
public getBounceHistory(email: string): {
|
|
||||||
lastBounce: number;
|
|
||||||
count: number;
|
|
||||||
type: BounceType;
|
|
||||||
category: BounceCategory;
|
|
||||||
} | null {
|
|
||||||
return this.bounceManager.getBounceInfo(email);
|
return this.bounceManager.getBounceInfo(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all suppressed email addresses
|
|
||||||
* @returns Array of suppressed email addresses
|
|
||||||
*/
|
|
||||||
public getSuppressionList(): string[] {
|
public getSuppressionList(): string[] {
|
||||||
return this.bounceManager.getSuppressionList();
|
return this.bounceManager.getSuppressionList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all hard bounced email addresses
|
|
||||||
* @returns Array of hard bounced email addresses
|
|
||||||
*/
|
|
||||||
public getHardBouncedAddresses(): string[] {
|
public getHardBouncedAddresses(): string[] {
|
||||||
return this.bounceManager.getHardBouncedAddresses();
|
return this.bounceManager.getHardBouncedAddresses();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an email to the suppression list
|
|
||||||
* @param email Email address to suppress
|
|
||||||
* @param reason Reason for suppression
|
|
||||||
* @param expiresAt Optional expiration time (undefined for permanent)
|
|
||||||
*/
|
|
||||||
public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
|
public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
|
||||||
this.bounceManager.addToSuppressionList(email, reason, expiresAt);
|
this.bounceManager.addToSuppressionList(email, reason, expiresAt);
|
||||||
logger.log('info', `Added ${email} to suppression list: ${reason}`);
|
logger.log('info', `Added ${email} to suppression list: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an email from the suppression list
|
|
||||||
* @param email Email address to remove from suppression
|
|
||||||
*/
|
|
||||||
public removeFromSuppressionList(email: string): void {
|
public removeFromSuppressionList(email: string): void {
|
||||||
this.bounceManager.removeFromSuppressionList(email);
|
this.bounceManager.removeFromSuppressionList(email);
|
||||||
logger.log('info', `Removed ${email} from suppression list`);
|
logger.log('info', `Removed ${email} from suppression list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Record email bounce
|
|
||||||
* @param domain Sending domain
|
|
||||||
* @param receivingDomain Receiving domain that bounced
|
|
||||||
* @param bounceType Type of bounce (hard/soft)
|
|
||||||
* @param reason Bounce reason
|
|
||||||
*/
|
|
||||||
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
||||||
const bounceRecord = {
|
const bounceRecord = {
|
||||||
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
@@ -1566,7 +1135,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the rate limiter instance
|
* Get the rate limiter instance
|
||||||
* @returns The unified rate limiter
|
|
||||||
*/
|
*/
|
||||||
public getRateLimiter(): UnifiedRateLimiter {
|
public getRateLimiter(): UnifiedRateLimiter {
|
||||||
return this.rateLimiter;
|
return this.rateLimiter;
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export * from './classes.unified.email.server.js';
|
|||||||
export * from './classes.dns.manager.js';
|
export * from './classes.dns.manager.js';
|
||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
export * from './classes.domain.registry.js';
|
export * from './classes.domain.registry.js';
|
||||||
|
export * from './classes.email.action.executor.js';
|
||||||
|
export * from './classes.dkim.manager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
*/
|
|
||||||
export interface IDkimVerificationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
domain?: string;
|
|
||||||
selector?: string;
|
|
||||||
status?: string;
|
|
||||||
details?: any;
|
|
||||||
errorMessage?: string;
|
|
||||||
signatureFields?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DKIM verifier — delegates to the Rust security bridge.
|
|
||||||
*/
|
|
||||||
export class DKIMVerifier {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify DKIM signature for an email via Rust bridge
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
emailData: string,
|
|
||||||
options: {
|
|
||||||
useCache?: boolean;
|
|
||||||
returnDetails?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<IDkimVerificationResult> {
|
|
||||||
try {
|
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
|
||||||
const results = await bridge.verifyDkim(emailData);
|
|
||||||
const first = results[0];
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
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: ${error.message}`);
|
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification error`,
|
|
||||||
details: { error: error.message },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
status: 'temperror',
|
|
||||||
errorMessage: `Verification error: ${error.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No-op — Rust bridge handles its own caching */
|
|
||||||
public clearCache(): void {}
|
|
||||||
|
|
||||||
/** Always 0 — cache is managed by the Rust side */
|
|
||||||
public getCacheSize(): number {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
import { logger } from '../../logger.js';
|
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
|
||||||
import type { Email } from '../core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC policy types
|
|
||||||
*/
|
|
||||||
export enum DmarcPolicy {
|
|
||||||
NONE = 'none',
|
|
||||||
QUARANTINE = 'quarantine',
|
|
||||||
REJECT = 'reject'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC alignment modes
|
|
||||||
*/
|
|
||||||
export enum DmarcAlignment {
|
|
||||||
RELAXED = 'r',
|
|
||||||
STRICT = 's'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC record fields
|
|
||||||
*/
|
|
||||||
export interface DmarcRecord {
|
|
||||||
// Required fields
|
|
||||||
version: string;
|
|
||||||
policy: DmarcPolicy;
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
subdomainPolicy?: DmarcPolicy;
|
|
||||||
pct?: number;
|
|
||||||
adkim?: DmarcAlignment;
|
|
||||||
aspf?: DmarcAlignment;
|
|
||||||
reportInterval?: number;
|
|
||||||
failureOptions?: string;
|
|
||||||
reportUriAggregate?: string[];
|
|
||||||
reportUriForensic?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC verification result
|
|
||||||
*/
|
|
||||||
export interface DmarcResult {
|
|
||||||
hasDmarc: boolean;
|
|
||||||
record?: DmarcRecord;
|
|
||||||
spfDomainAligned: boolean;
|
|
||||||
dkimDomainAligned: boolean;
|
|
||||||
spfPassed: boolean;
|
|
||||||
dkimPassed: boolean;
|
|
||||||
policyEvaluated: DmarcPolicy;
|
|
||||||
actualPolicy: DmarcPolicy;
|
|
||||||
appliedPercentage: number;
|
|
||||||
action: 'pass' | 'quarantine' | 'reject';
|
|
||||||
details: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for verifying and enforcing DMARC policies
|
|
||||||
*/
|
|
||||||
export class DmarcVerifier {
|
|
||||||
// DNS Manager reference for verifying records
|
|
||||||
private dnsManager?: any;
|
|
||||||
|
|
||||||
constructor(dnsManager?: any) {
|
|
||||||
this.dnsManager = dnsManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a DMARC record from a TXT record string
|
|
||||||
* @param record DMARC TXT record string
|
|
||||||
* @returns Parsed DMARC record or null if invalid
|
|
||||||
*/
|
|
||||||
public parseDmarcRecord(record: string): DmarcRecord | null {
|
|
||||||
if (!record.startsWith('v=DMARC1')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initialize record with default values
|
|
||||||
const dmarcRecord: DmarcRecord = {
|
|
||||||
version: 'DMARC1',
|
|
||||||
policy: DmarcPolicy.NONE,
|
|
||||||
pct: 100,
|
|
||||||
adkim: DmarcAlignment.RELAXED,
|
|
||||||
aspf: DmarcAlignment.RELAXED
|
|
||||||
};
|
|
||||||
|
|
||||||
// Split the record into tag/value pairs
|
|
||||||
const parts = record.split(';').map(part => part.trim());
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part || !part.includes('=')) continue;
|
|
||||||
|
|
||||||
const [tag, value] = part.split('=').map(p => p.trim());
|
|
||||||
|
|
||||||
// Process based on tag
|
|
||||||
switch (tag.toLowerCase()) {
|
|
||||||
case 'v':
|
|
||||||
dmarcRecord.version = value;
|
|
||||||
break;
|
|
||||||
case 'p':
|
|
||||||
dmarcRecord.policy = value as DmarcPolicy;
|
|
||||||
break;
|
|
||||||
case 'sp':
|
|
||||||
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
|
|
||||||
break;
|
|
||||||
case 'pct':
|
|
||||||
const pctValue = parseInt(value, 10);
|
|
||||||
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
|
|
||||||
dmarcRecord.pct = pctValue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'adkim':
|
|
||||||
dmarcRecord.adkim = value as DmarcAlignment;
|
|
||||||
break;
|
|
||||||
case 'aspf':
|
|
||||||
dmarcRecord.aspf = value as DmarcAlignment;
|
|
||||||
break;
|
|
||||||
case 'ri':
|
|
||||||
const interval = parseInt(value, 10);
|
|
||||||
if (!isNaN(interval) && interval > 0) {
|
|
||||||
dmarcRecord.reportInterval = interval;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'fo':
|
|
||||||
dmarcRecord.failureOptions = value;
|
|
||||||
break;
|
|
||||||
case 'rua':
|
|
||||||
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
|
|
||||||
if (uri.startsWith('mailto:')) {
|
|
||||||
return uri.substring(7).trim();
|
|
||||||
}
|
|
||||||
return uri.trim();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'ruf':
|
|
||||||
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
|
|
||||||
if (uri.startsWith('mailto:')) {
|
|
||||||
return uri.substring(7).trim();
|
|
||||||
}
|
|
||||||
return uri.trim();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure subdomain policy is set if not explicitly provided
|
|
||||||
if (!dmarcRecord.subdomainPolicy) {
|
|
||||||
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dmarcRecord;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
|
|
||||||
record,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if domains are aligned according to DMARC policy
|
|
||||||
* @param headerDomain Domain from header (From)
|
|
||||||
* @param authDomain Domain from authentication (SPF, DKIM)
|
|
||||||
* @param alignment Alignment mode
|
|
||||||
* @returns Whether the domains are aligned
|
|
||||||
*/
|
|
||||||
private isDomainAligned(
|
|
||||||
headerDomain: string,
|
|
||||||
authDomain: string,
|
|
||||||
alignment: DmarcAlignment
|
|
||||||
): boolean {
|
|
||||||
if (!headerDomain || !authDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For strict alignment, domains must match exactly
|
|
||||||
if (alignment === DmarcAlignment.STRICT) {
|
|
||||||
return headerDomain.toLowerCase() === authDomain.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
|
|
||||||
// or the same as the header domain
|
|
||||||
const headerParts = headerDomain.toLowerCase().split('.');
|
|
||||||
const authParts = authDomain.toLowerCase().split('.');
|
|
||||||
|
|
||||||
// Ensures we have at least two parts (domain and TLD)
|
|
||||||
if (headerParts.length < 2 || authParts.length < 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get organizational domain (last two parts)
|
|
||||||
const headerOrgDomain = headerParts.slice(-2).join('.');
|
|
||||||
const authOrgDomain = authParts.slice(-2).join('.');
|
|
||||||
|
|
||||||
return headerOrgDomain === authOrgDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract domain from an email address
|
|
||||||
* @param email Email address
|
|
||||||
* @returns Domain part of the email
|
|
||||||
*/
|
|
||||||
private getDomainFromEmail(email: string): string {
|
|
||||||
if (!email) return '';
|
|
||||||
|
|
||||||
// Handle name + email format: "John Doe <john@example.com>"
|
|
||||||
const matches = email.match(/<([^>]+)>/);
|
|
||||||
const address = matches ? matches[1] : email;
|
|
||||||
|
|
||||||
const parts = address.split('@');
|
|
||||||
return parts.length > 1 ? parts[1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if DMARC verification should be applied based on percentage
|
|
||||||
* @param record DMARC record
|
|
||||||
* @returns Whether DMARC verification should be applied
|
|
||||||
*/
|
|
||||||
private shouldApplyDmarc(record: DmarcRecord): boolean {
|
|
||||||
if (record.pct === undefined || record.pct === 100) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply DMARC randomly based on percentage
|
|
||||||
const random = Math.floor(Math.random() * 100) + 1;
|
|
||||||
return random <= record.pct;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the action to take based on DMARC policy
|
|
||||||
* @param policy DMARC policy
|
|
||||||
* @returns Action to take
|
|
||||||
*/
|
|
||||||
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
|
|
||||||
switch (policy) {
|
|
||||||
case DmarcPolicy.REJECT:
|
|
||||||
return 'reject';
|
|
||||||
case DmarcPolicy.QUARANTINE:
|
|
||||||
return 'quarantine';
|
|
||||||
case DmarcPolicy.NONE:
|
|
||||||
default:
|
|
||||||
return 'pass';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify DMARC for an incoming email
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param spfResult SPF verification result
|
|
||||||
* @param dkimResult DKIM verification result
|
|
||||||
* @returns DMARC verification result
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
email: Email,
|
|
||||||
spfResult: { domain: string; result: boolean },
|
|
||||||
dkimResult: { domain: string; result: boolean }
|
|
||||||
): Promise<DmarcResult> {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
|
|
||||||
// Initialize result
|
|
||||||
const result: DmarcResult = {
|
|
||||||
hasDmarc: false,
|
|
||||||
spfDomainAligned: false,
|
|
||||||
dkimDomainAligned: false,
|
|
||||||
spfPassed: spfResult.result,
|
|
||||||
dkimPassed: dkimResult.result,
|
|
||||||
policyEvaluated: DmarcPolicy.NONE,
|
|
||||||
actualPolicy: DmarcPolicy.NONE,
|
|
||||||
appliedPercentage: 100,
|
|
||||||
action: 'pass',
|
|
||||||
details: 'DMARC not configured'
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract From domain
|
|
||||||
const fromHeader = email.getFromEmail();
|
|
||||||
const fromDomain = this.getDomainFromEmail(fromHeader);
|
|
||||||
|
|
||||||
if (!fromDomain) {
|
|
||||||
result.error = 'Invalid From domain';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check alignment
|
|
||||||
result.spfDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
spfResult.domain,
|
|
||||||
DmarcAlignment.RELAXED
|
|
||||||
);
|
|
||||||
|
|
||||||
result.dkimDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
dkimResult.domain,
|
|
||||||
DmarcAlignment.RELAXED
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lookup DMARC record
|
|
||||||
const dmarcVerificationResult = this.dnsManager ?
|
|
||||||
await this.dnsManager.verifyDmarcRecord(fromDomain) :
|
|
||||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
|
||||||
|
|
||||||
// If DMARC record exists and is valid
|
|
||||||
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
|
|
||||||
result.hasDmarc = true;
|
|
||||||
|
|
||||||
// Parse DMARC record
|
|
||||||
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
|
|
||||||
|
|
||||||
if (parsedRecord) {
|
|
||||||
result.record = parsedRecord;
|
|
||||||
result.actualPolicy = parsedRecord.policy;
|
|
||||||
result.appliedPercentage = parsedRecord.pct || 100;
|
|
||||||
|
|
||||||
// Override alignment modes if specified in record
|
|
||||||
if (parsedRecord.adkim) {
|
|
||||||
result.dkimDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
dkimResult.domain,
|
|
||||||
parsedRecord.adkim
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedRecord.aspf) {
|
|
||||||
result.spfDomainAligned = this.isDomainAligned(
|
|
||||||
fromDomain,
|
|
||||||
spfResult.domain,
|
|
||||||
parsedRecord.aspf
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine DMARC compliance
|
|
||||||
const spfAligned = result.spfPassed && result.spfDomainAligned;
|
|
||||||
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
|
|
||||||
|
|
||||||
// Email passes DMARC if either SPF or DKIM passes with alignment
|
|
||||||
const dmarcPass = spfAligned || dkimAligned;
|
|
||||||
|
|
||||||
// Use record percentage to determine if policy should be applied
|
|
||||||
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
|
|
||||||
|
|
||||||
if (!dmarcPass) {
|
|
||||||
// DMARC failed, apply policy
|
|
||||||
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
|
|
||||||
result.action = this.determineAction(result.policyEvaluated);
|
|
||||||
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
|
|
||||||
} else {
|
|
||||||
result.policyEvaluated = DmarcPolicy.NONE;
|
|
||||||
result.action = 'pass';
|
|
||||||
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'Invalid DMARC record format';
|
|
||||||
result.details = 'DMARC record invalid';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No DMARC record found or invalid
|
|
||||||
result.details = dmarcVerificationResult.error || 'No DMARC record found';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the DMARC verification
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DMARC,
|
|
||||||
message: result.details,
|
|
||||||
domain: fromDomain,
|
|
||||||
details: {
|
|
||||||
fromDomain,
|
|
||||||
spfDomain: spfResult.domain,
|
|
||||||
dkimDomain: dkimResult.domain,
|
|
||||||
spfPassed: result.spfPassed,
|
|
||||||
dkimPassed: result.dkimPassed,
|
|
||||||
spfAligned: result.spfDomainAligned,
|
|
||||||
dkimAligned: result.dkimDomainAligned,
|
|
||||||
dmarcPolicy: result.policyEvaluated,
|
|
||||||
action: result.action
|
|
||||||
},
|
|
||||||
success: result.action === 'pass'
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error verifying DMARC: ${error.message}`, {
|
|
||||||
error: error.message,
|
|
||||||
emailId: email.getMessageId()
|
|
||||||
});
|
|
||||||
|
|
||||||
result.error = `DMARC verification error: ${error.message}`;
|
|
||||||
|
|
||||||
// Log error
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DMARC,
|
|
||||||
message: `DMARC verification failed with error`,
|
|
||||||
details: {
|
|
||||||
error: error.message,
|
|
||||||
emailId: email.getMessageId()
|
|
||||||
},
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply DMARC policy to an email
|
|
||||||
* @param email Email to apply policy to
|
|
||||||
* @param dmarcResult DMARC verification result
|
|
||||||
* @returns Whether the email should be accepted
|
|
||||||
*/
|
|
||||||
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
|
|
||||||
// Apply action based on DMARC verification result
|
|
||||||
switch (dmarcResult.action) {
|
|
||||||
case 'reject':
|
|
||||||
// Reject the email
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
|
|
||||||
emailId: email.getMessageId(),
|
|
||||||
from: email.getFromEmail(),
|
|
||||||
subject: email.subject
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
|
|
||||||
case 'quarantine':
|
|
||||||
// Quarantine the email (mark as spam)
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
|
|
||||||
// Add spam header
|
|
||||||
if (!email.headers['X-Spam-Flag']) {
|
|
||||||
email.headers['X-Spam-Flag'] = 'YES';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add DMARC reason header
|
|
||||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
|
||||||
|
|
||||||
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
|
|
||||||
emailId: email.getMessageId(),
|
|
||||||
from: email.getFromEmail(),
|
|
||||||
subject: email.subject
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'pass':
|
|
||||||
default:
|
|
||||||
// Accept the email
|
|
||||||
// Add DMARC result header for information
|
|
||||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End-to-end DMARC verification and policy application
|
|
||||||
* This method should be called after SPF and DKIM verification
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param spfResult SPF verification result
|
|
||||||
* @param dkimResult DKIM verification result
|
|
||||||
* @returns Whether the email should be accepted
|
|
||||||
*/
|
|
||||||
public async verifyAndApply(
|
|
||||||
email: Email,
|
|
||||||
spfResult: { domain: string; result: boolean },
|
|
||||||
dkimResult: { domain: string; result: boolean }
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Verify DMARC
|
|
||||||
const dmarcResult = await this.verify(email, spfResult, dkimResult);
|
|
||||||
|
|
||||||
// Apply DMARC policy
|
|
||||||
return this.applyPolicy(email, dmarcResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
||||||
import type { Email } from '../core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SPF result qualifiers
|
* SPF result qualifiers
|
||||||
@@ -127,107 +123,4 @@ export class SpfVerifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SPF for a given email — delegates to Rust bridge
|
|
||||||
*/
|
|
||||||
public async verify(
|
|
||||||
email: Email,
|
|
||||||
ip: string,
|
|
||||||
heloDomain: string
|
|
||||||
): Promise<SpfResult> {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
const mailFrom = email.from || '';
|
|
||||||
const domain = mailFrom.split('@')[1] || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
|
||||||
const result = await bridge.checkSpf({
|
|
||||||
ip,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: 'temperror',
|
|
||||||
explanation: `Error verifying SPF: ${error.message}`,
|
|
||||||
domain,
|
|
||||||
ip,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if email passes SPF verification and apply headers
|
|
||||||
*/
|
|
||||||
public async verifyAndApply(
|
|
||||||
email: Email,
|
|
||||||
ip: string,
|
|
||||||
heloDomain: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const result = await this.verify(email, ip, heloDomain);
|
|
||||||
|
|
||||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
|
||||||
|
|
||||||
switch (result.result) {
|
|
||||||
case 'fail':
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return false;
|
|
||||||
|
|
||||||
case 'softfail':
|
|
||||||
email.mightBeSpam = true;
|
|
||||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'neutral':
|
|
||||||
case 'none':
|
|
||||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'pass':
|
|
||||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case 'temperror':
|
|
||||||
case 'permerror':
|
|
||||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Email security components
|
// Email security components
|
||||||
export * from './classes.dkimcreator.js';
|
export * from './classes.dkimcreator.js';
|
||||||
export * from './classes.dkimverifier.js';
|
|
||||||
export * from './classes.dmarcverifier.js';
|
|
||||||
export * from './classes.spfverifier.js';
|
export * from './classes.spfverifier.js';
|
||||||
Reference in New Issue
Block a user