@push.rocks/smartmta 5.3.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +29 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/mail/core/classes.bouncemanager.d.ts +2 -1
- package/dist_ts/mail/core/classes.bouncemanager.js +6 -5
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +3 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +4 -1
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +2 -1
- package/dist_ts/mail/interfaces.storage.d.ts +7 -0
- package/dist_ts/mail/interfaces.storage.js +4 -0
- package/dist_ts/mail/routing/classes.dkim.manager.d.ts +2 -1
- package/dist_ts/mail/routing/classes.dkim.manager.js +17 -8
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +1 -5
- package/dist_ts/mail/routing/classes.dns.manager.js +2 -2
- package/dist_ts/mail/routing/classes.email.router.d.ts +2 -1
- package/dist_ts/mail/routing/classes.email.router.js +6 -5
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +80 -3
- package/dist_ts/mail/routing/classes.unified.email.server.js +339 -47
- package/dist_ts/mail/routing/interfaces.d.ts +2 -0
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +10 -5
- package/dist_ts/mail/security/classes.dkimcreator.js +91 -70
- package/dist_ts/security/classes.ipreputationchecker.d.ts +4 -3
- package/dist_ts/security/classes.ipreputationchecker.js +4 -3
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +33 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +18 -1
- package/package.json +19 -21
- package/readme.hints.md +1 -1
- package/readme.md +100 -30
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mail/core/classes.bouncemanager.ts +7 -6
- package/ts/mail/delivery/classes.delivery.queue.ts +7 -3
- package/ts/mail/index.ts +2 -1
- package/ts/mail/interfaces.storage.ts +13 -0
- package/ts/mail/routing/classes.dkim.manager.ts +24 -11
- package/ts/mail/routing/classes.dns.manager.ts +3 -8
- package/ts/mail/routing/classes.email.router.ts +7 -6
- package/ts/mail/routing/classes.unified.email.server.ts +446 -50
- package/ts/mail/routing/interfaces.ts +3 -1
- package/ts/mail/security/classes.dkimcreator.ts +115 -91
- package/ts/security/classes.ipreputationchecker.ts +7 -6
- package/ts/security/classes.rustsecuritybridge.ts +53 -0
|
@@ -8,17 +8,18 @@ import {
|
|
|
8
8
|
SecurityEventType
|
|
9
9
|
} from '../../security/index.js';
|
|
10
10
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
|
11
|
+
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
|
|
11
12
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
12
|
-
import type { IEmailReceivedEvent, IAuthRequestEvent,
|
|
13
|
+
import type { IEmailReceivedEvent, IRcptToRequestEvent, IAuthRequestEvent, IScramCredentialRequestEvent } from '../../security/classes.rustsecuritybridge.js';
|
|
13
14
|
import { EmailRouter } from './classes.email.router.js';
|
|
14
|
-
import type { IEmailRoute,
|
|
15
|
+
import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
|
15
16
|
import { Email } from '../core/classes.email.js';
|
|
16
17
|
import { DomainRegistry } from './classes.domain.registry.js';
|
|
17
18
|
import { DnsManager } from './classes.dns.manager.js';
|
|
18
19
|
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
|
|
19
20
|
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
|
|
20
|
-
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
|
21
|
-
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
|
|
21
|
+
import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
|
22
|
+
import { UnifiedDeliveryQueue, type IQueueItem, type IQueueOptions, type IQueueStats } from '../delivery/classes.delivery.queue.js';
|
|
22
23
|
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
|
23
24
|
import { SmtpState } from '../delivery/interfaces.js';
|
|
24
25
|
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
|
@@ -28,7 +29,7 @@ import { DkimManager } from './classes.dkim.manager.js';
|
|
|
28
29
|
|
|
29
30
|
/** External DcRouter interface shape used by UnifiedEmailServer */
|
|
30
31
|
interface DcRouter {
|
|
31
|
-
storageManager:
|
|
32
|
+
storageManager: IStorageManagerLike;
|
|
32
33
|
dnsServer?: any;
|
|
33
34
|
options?: any;
|
|
34
35
|
}
|
|
@@ -43,17 +44,51 @@ export interface IExtendedSmtpSession extends ISmtpSession {
|
|
|
43
44
|
matchedRoute?: IEmailRoute;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export interface ISmtpDecision {
|
|
48
|
+
accepted: boolean;
|
|
49
|
+
smtpCode?: number;
|
|
50
|
+
smtpMessage?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IRcptToPolicyContext {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
mailFrom: string;
|
|
56
|
+
rcptTo: string;
|
|
57
|
+
acceptedRcptTo: string[];
|
|
58
|
+
remoteAddress: string;
|
|
59
|
+
clientHostname: string | null;
|
|
60
|
+
secure: boolean;
|
|
61
|
+
authenticated: boolean;
|
|
62
|
+
authenticatedUser: string | null;
|
|
63
|
+
abortSignal?: AbortSignal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IMessageAcceptanceContext {
|
|
67
|
+
email: Email;
|
|
68
|
+
rawMessage: Buffer;
|
|
69
|
+
session: IExtendedSmtpSession;
|
|
70
|
+
abortSignal?: AbortSignal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IMessageAcceptanceDecision extends ISmtpDecision {
|
|
74
|
+
/** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
|
|
75
|
+
continueProcessing?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
/**
|
|
47
79
|
* Options for the unified email server
|
|
48
80
|
*/
|
|
49
81
|
export interface IUnifiedEmailServerOptions {
|
|
50
82
|
// Base server options
|
|
51
83
|
ports: number[];
|
|
84
|
+
/** Public SMTP hostname used for greeting/banner and as the default outbound identity. */
|
|
52
85
|
hostname: string;
|
|
53
86
|
domains: IEmailDomainConfig[]; // Domain configurations
|
|
54
87
|
banner?: string;
|
|
55
88
|
debug?: boolean;
|
|
56
89
|
useSocketHandler?: boolean; // Use socket-handler mode instead of port listening
|
|
90
|
+
/** Persist router changes back into storage when a storage manager is available. */
|
|
91
|
+
persistRoutes?: boolean;
|
|
57
92
|
|
|
58
93
|
// Authentication options
|
|
59
94
|
auth?: {
|
|
@@ -83,6 +118,27 @@ export interface IUnifiedEmailServerOptions {
|
|
|
83
118
|
// Email routing rules
|
|
84
119
|
routes: IEmailRoute[];
|
|
85
120
|
|
|
121
|
+
smtp?: {
|
|
122
|
+
/** Enable route/hook based RCPT TO validation before DATA. Defaults to true; set false only for fully trusted private listeners. */
|
|
123
|
+
recipientValidation?: boolean;
|
|
124
|
+
/** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
|
|
125
|
+
recipientValidationTimeoutMs?: number;
|
|
126
|
+
/** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
|
|
127
|
+
messageAcceptanceTimeoutMs?: number;
|
|
128
|
+
/** Require trusted PROXY v1 from the backend proxy before SMTP greeting. */
|
|
129
|
+
proxyProtocol?: {
|
|
130
|
+
required?: boolean;
|
|
131
|
+
trustedIps?: string[];
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
hooks?: {
|
|
136
|
+
/** Async recipient policy hook, called before each RCPT TO is accepted. */
|
|
137
|
+
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
138
|
+
/** Async message acceptance hook, called after DATA before the final SMTP response. */
|
|
139
|
+
onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
|
|
140
|
+
};
|
|
141
|
+
|
|
86
142
|
// Global defaults for all domains
|
|
87
143
|
defaults?: {
|
|
88
144
|
dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
|
|
@@ -92,6 +148,8 @@ export interface IUnifiedEmailServerOptions {
|
|
|
92
148
|
|
|
93
149
|
// Outbound settings
|
|
94
150
|
outbound?: {
|
|
151
|
+
/** Override the SMTP identity used for outbound delivery. Defaults to `hostname`. */
|
|
152
|
+
hostname?: string;
|
|
95
153
|
maxConnections?: number;
|
|
96
154
|
connectionTimeout?: number;
|
|
97
155
|
socketTimeout?: number;
|
|
@@ -99,6 +157,9 @@ export interface IUnifiedEmailServerOptions {
|
|
|
99
157
|
defaultFrom?: string;
|
|
100
158
|
};
|
|
101
159
|
|
|
160
|
+
// Delivery queue
|
|
161
|
+
queue?: IQueueOptions;
|
|
162
|
+
|
|
102
163
|
// Rate limiting (global limits, can be overridden per domain)
|
|
103
164
|
rateLimits?: IHierarchicalRateLimits;
|
|
104
165
|
}
|
|
@@ -171,6 +232,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
171
232
|
// Extracted subsystems
|
|
172
233
|
private actionExecutor: EmailActionExecutor;
|
|
173
234
|
private dkimManager: DkimManager;
|
|
235
|
+
private bridgeStateChangeHandler?: (event: { oldState: string; newState: string }) => void;
|
|
236
|
+
private bridgeRcptToRequestHandler?: (data: IRcptToRequestEvent) => void;
|
|
237
|
+
private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
|
|
238
|
+
private bridgeAuthRequestHandler?: (data: IAuthRequestEvent) => void;
|
|
239
|
+
private bridgeScramCredentialRequestHandler?: (data: IScramCredentialRequestEvent) => void;
|
|
174
240
|
|
|
175
241
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
176
242
|
super();
|
|
@@ -206,7 +272,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
206
272
|
// Initialize email router with routes and storage manager
|
|
207
273
|
this.emailRouter = new EmailRouter(options.routes || [], {
|
|
208
274
|
storageManager: dcRouter.storageManager,
|
|
209
|
-
persistChanges:
|
|
275
|
+
persistChanges: options.persistRoutes ?? hasStorageManagerMethods(dcRouter.storageManager, ['get', 'set'])
|
|
210
276
|
});
|
|
211
277
|
|
|
212
278
|
// Initialize rate limiter
|
|
@@ -226,7 +292,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
226
292
|
storageType: 'memory', // Default to memory storage
|
|
227
293
|
maxRetries: 3,
|
|
228
294
|
baseRetryDelay: 300000, // 5 minutes
|
|
229
|
-
maxRetryDelay: 3600000 // 1 hour
|
|
295
|
+
maxRetryDelay: 3600000, // 1 hour
|
|
296
|
+
...options.queue,
|
|
230
297
|
};
|
|
231
298
|
|
|
232
299
|
this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
|
|
@@ -277,6 +344,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
277
344
|
// We'll create the SMTP servers during the start() method
|
|
278
345
|
}
|
|
279
346
|
|
|
347
|
+
private getAdvertisedHostname(): string {
|
|
348
|
+
return this.options.hostname;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private getOutboundHostname(): string {
|
|
352
|
+
return this.options.outbound?.hostname || this.options.hostname;
|
|
353
|
+
}
|
|
354
|
+
|
|
280
355
|
/**
|
|
281
356
|
* Send an outbound email via the Rust SMTP client.
|
|
282
357
|
* Uses connection pooling in the Rust binary for efficiency.
|
|
@@ -314,7 +389,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
314
389
|
host,
|
|
315
390
|
port,
|
|
316
391
|
secure: port === 465,
|
|
317
|
-
domain: this.
|
|
392
|
+
domain: this.getOutboundHostname(),
|
|
318
393
|
auth: options?.auth,
|
|
319
394
|
email: outboundEmail,
|
|
320
395
|
dkim,
|
|
@@ -342,6 +417,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
342
417
|
this.emit('started');
|
|
343
418
|
} catch (error) {
|
|
344
419
|
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
|
420
|
+
try {
|
|
421
|
+
await this.stop();
|
|
422
|
+
} catch (cleanupError) {
|
|
423
|
+
logger.log('warn', `Error cleaning up failed start: ${(cleanupError as Error).message}`);
|
|
424
|
+
}
|
|
345
425
|
throw error;
|
|
346
426
|
}
|
|
347
427
|
}
|
|
@@ -361,11 +441,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
361
441
|
}
|
|
362
442
|
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
|
363
443
|
|
|
364
|
-
this.
|
|
444
|
+
this.unregisterBridgeStateHandler();
|
|
445
|
+
this.bridgeStateChangeHandler = ({ oldState, newState }: { oldState: string; newState: string }) => {
|
|
365
446
|
if (newState === 'failed') this.emit('bridgeFailed');
|
|
366
447
|
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
|
367
448
|
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
|
368
|
-
}
|
|
449
|
+
};
|
|
450
|
+
this.rustBridge.on('stateChange', this.bridgeStateChangeHandler);
|
|
369
451
|
}
|
|
370
452
|
|
|
371
453
|
private async initializeDkimAndDns(): Promise<void> {
|
|
@@ -384,7 +466,28 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
384
466
|
}
|
|
385
467
|
|
|
386
468
|
private registerBridgeEventHandlers(): void {
|
|
387
|
-
this.
|
|
469
|
+
this.unregisterBridgeEventHandlers();
|
|
470
|
+
|
|
471
|
+
this.bridgeRcptToRequestHandler = async (data) => {
|
|
472
|
+
try {
|
|
473
|
+
await this.handleRustRcptToRequest(data);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
logger.log('error', `Error handling RCPT policy request from Rust SMTP: ${(err as Error).message}`);
|
|
476
|
+
try {
|
|
477
|
+
await this.rustBridge.sendRcptToResult({
|
|
478
|
+
correlationId: data.correlationId,
|
|
479
|
+
accepted: false,
|
|
480
|
+
smtpCode: 451,
|
|
481
|
+
smtpMessage: 'Recipient policy error',
|
|
482
|
+
});
|
|
483
|
+
} catch (sendErr) {
|
|
484
|
+
logger.log('warn', `Could not send RCPT policy rejection back to Rust: ${(sendErr as Error).message}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
this.rustBridge.onRcptToRequest(this.bridgeRcptToRequestHandler);
|
|
489
|
+
|
|
490
|
+
this.bridgeEmailReceivedHandler = async (data) => {
|
|
388
491
|
try {
|
|
389
492
|
await this.handleRustEmailReceived(data);
|
|
390
493
|
} catch (err) {
|
|
@@ -400,9 +503,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
400
503
|
logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`);
|
|
401
504
|
}
|
|
402
505
|
}
|
|
403
|
-
}
|
|
506
|
+
};
|
|
507
|
+
this.rustBridge.onEmailReceived(this.bridgeEmailReceivedHandler);
|
|
404
508
|
|
|
405
|
-
this.
|
|
509
|
+
this.bridgeAuthRequestHandler = async (data) => {
|
|
406
510
|
try {
|
|
407
511
|
await this.handleRustAuthRequest(data);
|
|
408
512
|
} catch (err) {
|
|
@@ -417,9 +521,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
417
521
|
logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`);
|
|
418
522
|
}
|
|
419
523
|
}
|
|
420
|
-
}
|
|
524
|
+
};
|
|
525
|
+
this.rustBridge.onAuthRequest(this.bridgeAuthRequestHandler);
|
|
421
526
|
|
|
422
|
-
this.
|
|
527
|
+
this.bridgeScramCredentialRequestHandler = async (data) => {
|
|
423
528
|
try {
|
|
424
529
|
await this.handleScramCredentialRequest(data);
|
|
425
530
|
} catch (err) {
|
|
@@ -433,7 +538,34 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
433
538
|
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
|
|
434
539
|
}
|
|
435
540
|
}
|
|
436
|
-
}
|
|
541
|
+
};
|
|
542
|
+
this.rustBridge.onScramCredentialRequest(this.bridgeScramCredentialRequestHandler);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private unregisterBridgeStateHandler(): void {
|
|
546
|
+
if (this.bridgeStateChangeHandler) {
|
|
547
|
+
this.rustBridge.off('stateChange', this.bridgeStateChangeHandler);
|
|
548
|
+
this.bridgeStateChangeHandler = undefined;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private unregisterBridgeEventHandlers(): void {
|
|
553
|
+
if (this.bridgeRcptToRequestHandler) {
|
|
554
|
+
this.rustBridge.offRcptToRequest(this.bridgeRcptToRequestHandler);
|
|
555
|
+
this.bridgeRcptToRequestHandler = undefined;
|
|
556
|
+
}
|
|
557
|
+
if (this.bridgeEmailReceivedHandler) {
|
|
558
|
+
this.rustBridge.offEmailReceived(this.bridgeEmailReceivedHandler);
|
|
559
|
+
this.bridgeEmailReceivedHandler = undefined;
|
|
560
|
+
}
|
|
561
|
+
if (this.bridgeAuthRequestHandler) {
|
|
562
|
+
this.rustBridge.offAuthRequest(this.bridgeAuthRequestHandler);
|
|
563
|
+
this.bridgeAuthRequestHandler = undefined;
|
|
564
|
+
}
|
|
565
|
+
if (this.bridgeScramCredentialRequestHandler) {
|
|
566
|
+
this.rustBridge.offScramCredentialRequest(this.bridgeScramCredentialRequestHandler);
|
|
567
|
+
this.bridgeScramCredentialRequestHandler = undefined;
|
|
568
|
+
}
|
|
437
569
|
}
|
|
438
570
|
|
|
439
571
|
private async startSmtpServer(): Promise<void> {
|
|
@@ -455,7 +587,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
455
587
|
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
|
456
588
|
|
|
457
589
|
const started = await this.rustBridge.startSmtpServer({
|
|
458
|
-
hostname: this.
|
|
590
|
+
hostname: this.getAdvertisedHostname(),
|
|
459
591
|
ports: smtpPorts,
|
|
460
592
|
securePort: securePort,
|
|
461
593
|
tlsCertPem,
|
|
@@ -468,7 +600,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
468
600
|
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
|
469
601
|
maxAuthFailures: 3,
|
|
470
602
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
471
|
-
processingTimeoutSecs:
|
|
603
|
+
processingTimeoutSecs: this.options.hooks?.onMessageData
|
|
604
|
+
? Math.ceil(this.getMessageAcceptanceTimeoutMs() / 1000) + 1
|
|
605
|
+
: 30,
|
|
606
|
+
recipientValidationEnabled: this.isRecipientValidationEnabled(),
|
|
607
|
+
recipientValidationTimeoutSecs: this.options.smtp?.recipientValidationTimeoutMs
|
|
608
|
+
? Math.ceil(this.options.smtp.recipientValidationTimeoutMs / 1000)
|
|
609
|
+
: 5,
|
|
610
|
+
proxyProtocol: this.options.smtp?.proxyProtocol,
|
|
472
611
|
rateLimits: this.options.rateLimits ? {
|
|
473
612
|
maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
|
|
474
613
|
maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
|
|
@@ -502,8 +641,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
502
641
|
// Clear the servers array - servers will be garbage collected
|
|
503
642
|
this.servers = [];
|
|
504
643
|
|
|
505
|
-
|
|
506
|
-
this.
|
|
644
|
+
this.unregisterBridgeEventHandlers();
|
|
645
|
+
this.unregisterBridgeStateHandler();
|
|
507
646
|
await this.rustBridge.stop();
|
|
508
647
|
|
|
509
648
|
// Stop the delivery system
|
|
@@ -518,6 +657,9 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
518
657
|
logger.log('info', 'Email delivery queue shut down');
|
|
519
658
|
}
|
|
520
659
|
|
|
660
|
+
this.bounceManager.stop();
|
|
661
|
+
logger.log('info', 'Bounce manager stopped');
|
|
662
|
+
|
|
521
663
|
// Close all Rust SMTP client connection pools
|
|
522
664
|
try {
|
|
523
665
|
await this.rustBridge.closeSmtpPool();
|
|
@@ -537,6 +679,170 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
537
679
|
// Rust SMTP server event handlers
|
|
538
680
|
// -----------------------------------------------------------------------
|
|
539
681
|
|
|
682
|
+
private async handleRustRcptToRequest(data: IRcptToRequestEvent): Promise<void> {
|
|
683
|
+
const context: IRcptToPolicyContext = {
|
|
684
|
+
sessionId: data.sessionId,
|
|
685
|
+
mailFrom: data.mailFrom,
|
|
686
|
+
rcptTo: data.rcptTo,
|
|
687
|
+
acceptedRcptTo: data.acceptedRcptTo || [],
|
|
688
|
+
remoteAddress: data.remoteAddr,
|
|
689
|
+
clientHostname: data.clientHostname,
|
|
690
|
+
secure: data.secure,
|
|
691
|
+
authenticated: !!data.authenticatedUser,
|
|
692
|
+
authenticatedUser: data.authenticatedUser,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const hookDecision = this.options.hooks?.onRcptTo
|
|
696
|
+
? await this.runHookWithTimeout(
|
|
697
|
+
this.getRecipientValidationTimeoutMs(),
|
|
698
|
+
'Recipient policy timeout',
|
|
699
|
+
(abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal })
|
|
700
|
+
)
|
|
701
|
+
: await this.evaluateDefaultRcptToPolicy(context);
|
|
702
|
+
const decision = this.options.hooks?.onRcptTo
|
|
703
|
+
? await this.enforceAcceptedRcptToRelayPolicy(context, hookDecision)
|
|
704
|
+
: hookDecision;
|
|
705
|
+
|
|
706
|
+
await this.rustBridge.sendRcptToResult({
|
|
707
|
+
correlationId: data.correlationId,
|
|
708
|
+
accepted: decision.accepted,
|
|
709
|
+
smtpCode: decision.smtpCode,
|
|
710
|
+
smtpMessage: decision.smtpMessage,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private async evaluateDefaultRcptToPolicy(context: IRcptToPolicyContext): Promise<ISmtpDecision> {
|
|
715
|
+
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
716
|
+
const email = new Email({
|
|
717
|
+
from: context.mailFrom || '<>',
|
|
718
|
+
to: [context.rcptTo],
|
|
719
|
+
subject: '',
|
|
720
|
+
text: '',
|
|
721
|
+
});
|
|
722
|
+
const session = this.buildPolicySession(currentRecipientContext);
|
|
723
|
+
const route = await this.emailRouter.evaluateRoutes({ email, session });
|
|
724
|
+
|
|
725
|
+
if (!route) {
|
|
726
|
+
return { accepted: false, smtpCode: 550, smtpMessage: 'No route for recipient' };
|
|
727
|
+
}
|
|
728
|
+
if (route.action.type === 'reject') {
|
|
729
|
+
return {
|
|
730
|
+
accepted: false,
|
|
731
|
+
smtpCode: route.action.reject?.code || 550,
|
|
732
|
+
smtpMessage: route.action.reject?.message || 'Recipient rejected',
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const recipientDomain = this.getAddressDomain(context.rcptTo);
|
|
737
|
+
const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
|
|
738
|
+
if (!isLocalRecipient && !route.action.allowRelay) {
|
|
739
|
+
return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return { accepted: true, smtpCode: 250, smtpMessage: 'OK' };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async enforceAcceptedRcptToRelayPolicy(
|
|
746
|
+
context: IRcptToPolicyContext,
|
|
747
|
+
decision: ISmtpDecision
|
|
748
|
+
): Promise<ISmtpDecision> {
|
|
749
|
+
if (!decision.accepted) {
|
|
750
|
+
return decision;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const recipientDomain = this.getAddressDomain(context.rcptTo);
|
|
754
|
+
const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
|
|
755
|
+
if (isLocalRecipient) {
|
|
756
|
+
return decision;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
760
|
+
const email = new Email({
|
|
761
|
+
from: context.mailFrom || '<>',
|
|
762
|
+
to: [context.rcptTo],
|
|
763
|
+
subject: '',
|
|
764
|
+
text: '',
|
|
765
|
+
});
|
|
766
|
+
const session = this.buildPolicySession(currentRecipientContext);
|
|
767
|
+
const route = await this.emailRouter.evaluateRoutes({ email, session });
|
|
768
|
+
|
|
769
|
+
if (route?.action.type !== 'reject' && route?.action.allowRelay) {
|
|
770
|
+
return decision;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private buildPolicySession(context: IRcptToPolicyContext): IExtendedSmtpSession {
|
|
777
|
+
const session: IExtendedSmtpSession = {
|
|
778
|
+
id: context.sessionId,
|
|
779
|
+
state: SmtpState.RCPT_TO,
|
|
780
|
+
mailFrom: context.mailFrom,
|
|
781
|
+
rcptTo: [...context.acceptedRcptTo, context.rcptTo],
|
|
782
|
+
emailData: '',
|
|
783
|
+
useTLS: context.secure,
|
|
784
|
+
connectionEnded: false,
|
|
785
|
+
remoteAddress: context.remoteAddress,
|
|
786
|
+
clientHostname: context.clientHostname || '',
|
|
787
|
+
secure: context.secure,
|
|
788
|
+
authenticated: context.authenticated,
|
|
789
|
+
envelope: {
|
|
790
|
+
mailFrom: { address: context.mailFrom, args: {} },
|
|
791
|
+
rcptTo: [...context.acceptedRcptTo, context.rcptTo].map(address => ({ address, args: {} })),
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
if (context.authenticatedUser) {
|
|
795
|
+
session.user = { username: context.authenticatedUser };
|
|
796
|
+
}
|
|
797
|
+
return session;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private getAddressDomain(address: string): string | undefined {
|
|
801
|
+
const match = address.trim().toLowerCase().match(/@([^>\s]+)>?$/);
|
|
802
|
+
return match?.[1];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private getRecipientValidationTimeoutMs(): number {
|
|
806
|
+
return this.options.smtp?.recipientValidationTimeoutMs || 5000;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private isRecipientValidationEnabled(): boolean {
|
|
810
|
+
return this.options.smtp?.recipientValidation !== false;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private getMessageAcceptanceTimeoutMs(): number {
|
|
814
|
+
return this.options.smtp?.messageAcceptanceTimeoutMs || 30000;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private async runHookWithTimeout<T>(
|
|
818
|
+
timeoutMs: number,
|
|
819
|
+
timeoutMessage: string,
|
|
820
|
+
hookRunner: (abortSignal: AbortSignal) => Promise<T>,
|
|
821
|
+
onTimeout?: () => void
|
|
822
|
+
): Promise<T> {
|
|
823
|
+
const abortController = new AbortController();
|
|
824
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
825
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
826
|
+
timeoutHandle = setTimeout(() => {
|
|
827
|
+
abortController.abort();
|
|
828
|
+
onTimeout?.();
|
|
829
|
+
reject(new Error(timeoutMessage));
|
|
830
|
+
}, timeoutMs);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
return await Promise.race([
|
|
835
|
+
hookRunner(abortController.signal),
|
|
836
|
+
timeoutPromise,
|
|
837
|
+
]);
|
|
838
|
+
} finally {
|
|
839
|
+
if (timeoutHandle) {
|
|
840
|
+
clearTimeout(timeoutHandle);
|
|
841
|
+
}
|
|
842
|
+
abortController.abort();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
540
846
|
/**
|
|
541
847
|
* Handle an emailReceived event from the Rust SMTP server.
|
|
542
848
|
*/
|
|
@@ -551,12 +857,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
551
857
|
if (data.data.type === 'inline' && data.data.base64) {
|
|
552
858
|
rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
|
|
553
859
|
} else if (data.data.type === 'file' && data.data.path) {
|
|
554
|
-
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
|
555
|
-
// Clean up temp file
|
|
556
860
|
try {
|
|
557
|
-
plugins.fs.
|
|
558
|
-
}
|
|
559
|
-
|
|
861
|
+
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
|
862
|
+
} finally {
|
|
863
|
+
try {
|
|
864
|
+
plugins.fs.unlinkSync(data.data.path);
|
|
865
|
+
} catch {
|
|
866
|
+
// Ignore cleanup errors
|
|
867
|
+
}
|
|
560
868
|
}
|
|
561
869
|
} else {
|
|
562
870
|
throw new Error('Invalid email data transport');
|
|
@@ -590,15 +898,70 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
590
898
|
(session as any)._precomputedSecurityResults = data.securityResults;
|
|
591
899
|
}
|
|
592
900
|
|
|
593
|
-
|
|
594
|
-
|
|
901
|
+
const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
|
|
902
|
+
let acceptanceDecision: IMessageAcceptanceDecision | undefined;
|
|
903
|
+
if (this.options.hooks?.onMessageData) {
|
|
904
|
+
let hookContext: IMessageAcceptanceContext | undefined;
|
|
905
|
+
try {
|
|
906
|
+
acceptanceDecision = await this.runHookWithTimeout(
|
|
907
|
+
this.getMessageAcceptanceTimeoutMs(),
|
|
908
|
+
'Message acceptance timeout',
|
|
909
|
+
(abortSignal) => {
|
|
910
|
+
hookContext = {
|
|
911
|
+
email,
|
|
912
|
+
rawMessage: rawMessageBuffer,
|
|
913
|
+
session,
|
|
914
|
+
abortSignal,
|
|
915
|
+
};
|
|
916
|
+
return this.options.hooks!.onMessageData!(hookContext);
|
|
917
|
+
},
|
|
918
|
+
() => {
|
|
919
|
+
if (hookContext) {
|
|
920
|
+
hookContext.rawMessage = Buffer.alloc(0);
|
|
921
|
+
(hookContext as any).email = undefined;
|
|
922
|
+
(hookContext as any).session = undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
);
|
|
926
|
+
} catch (acceptanceError) {
|
|
927
|
+
logger.log('error', `Message acceptance hook failed: ${(acceptanceError as Error).message}`);
|
|
928
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
929
|
+
correlationId,
|
|
930
|
+
accepted: false,
|
|
931
|
+
smtpCode: 451,
|
|
932
|
+
smtpMessage: 'Message acceptance temporarily unavailable',
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (acceptanceDecision && !acceptanceDecision.accepted) {
|
|
939
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
940
|
+
correlationId,
|
|
941
|
+
accepted: false,
|
|
942
|
+
smtpCode: acceptanceDecision.smtpCode || 451,
|
|
943
|
+
smtpMessage: acceptanceDecision.smtpMessage || 'Message not accepted',
|
|
944
|
+
});
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (!acceptanceDecision || acceptanceDecision.continueProcessing) {
|
|
949
|
+
try {
|
|
950
|
+
await this.processEmailByMode(email, session);
|
|
951
|
+
} catch (processingError) {
|
|
952
|
+
if (!acceptanceDecision) {
|
|
953
|
+
throw processingError;
|
|
954
|
+
}
|
|
955
|
+
logger.log('error', `Post-acceptance processing failed: ${(processingError as Error).message}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
595
958
|
|
|
596
959
|
// Send acceptance back to Rust
|
|
597
960
|
await this.rustBridge.sendEmailProcessingResult({
|
|
598
961
|
correlationId,
|
|
599
962
|
accepted: true,
|
|
600
963
|
smtpCode: 250,
|
|
601
|
-
smtpMessage: '2.0.0 Message accepted for delivery',
|
|
964
|
+
smtpMessage: acceptanceDecision?.smtpMessage || '2.0.0 Message accepted for delivery',
|
|
602
965
|
});
|
|
603
966
|
} catch (err) {
|
|
604
967
|
logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
|
|
@@ -611,6 +974,38 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
611
974
|
}
|
|
612
975
|
}
|
|
613
976
|
|
|
977
|
+
private async createEmailFromBuffer(emailData: Buffer, session: IExtendedSmtpSession): Promise<Email> {
|
|
978
|
+
try {
|
|
979
|
+
const parsed = await plugins.mailparser.simpleParser(emailData);
|
|
980
|
+
const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
|
|
981
|
+
const parsedTo = ((parsed.to as any)?.value || [])
|
|
982
|
+
.map((recipient: any) => recipient.address)
|
|
983
|
+
.filter(Boolean) as string[];
|
|
984
|
+
const parsedCc = ((parsed.cc as any)?.value || [])
|
|
985
|
+
.map((recipient: any) => recipient.address)
|
|
986
|
+
.filter(Boolean) as string[];
|
|
987
|
+
return new Email({
|
|
988
|
+
from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
|
|
989
|
+
to: envelopeRecipients.length > 0
|
|
990
|
+
? envelopeRecipients
|
|
991
|
+
: parsedTo,
|
|
992
|
+
cc: parsedCc,
|
|
993
|
+
bcc: [],
|
|
994
|
+
subject: parsed.subject || '',
|
|
995
|
+
text: parsed.text || '',
|
|
996
|
+
html: parsed.html || undefined,
|
|
997
|
+
attachments: parsed.attachments?.map(att => ({
|
|
998
|
+
filename: att.filename || '',
|
|
999
|
+
content: att.content,
|
|
1000
|
+
contentType: att.contentType
|
|
1001
|
+
})) || []
|
|
1002
|
+
});
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
logger.log('error', `Error parsing email data: ${(error as Error).message}`);
|
|
1005
|
+
throw new Error(`Error parsing email data: ${(error as Error).message}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
614
1009
|
/**
|
|
615
1010
|
* Handle an authRequest event from the Rust SMTP server.
|
|
616
1011
|
*/
|
|
@@ -782,25 +1177,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
782
1177
|
// Convert Buffer to Email if needed
|
|
783
1178
|
let email: Email;
|
|
784
1179
|
if (Buffer.isBuffer(emailData)) {
|
|
785
|
-
|
|
786
|
-
try {
|
|
787
|
-
const parsed = await plugins.mailparser.simpleParser(emailData);
|
|
788
|
-
email = new Email({
|
|
789
|
-
from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
|
|
790
|
-
to: session.envelope.rcptTo[0]?.address || '',
|
|
791
|
-
subject: parsed.subject || '',
|
|
792
|
-
text: parsed.text || '',
|
|
793
|
-
html: parsed.html || undefined,
|
|
794
|
-
attachments: parsed.attachments?.map(att => ({
|
|
795
|
-
filename: att.filename || '',
|
|
796
|
-
content: att.content,
|
|
797
|
-
contentType: att.contentType
|
|
798
|
-
})) || []
|
|
799
|
-
});
|
|
800
|
-
} catch (error) {
|
|
801
|
-
logger.log('error', `Error parsing email data: ${error.message}`);
|
|
802
|
-
throw new Error(`Error parsing email data: ${error.message}`);
|
|
803
|
-
}
|
|
1180
|
+
email = await this.createEmailFromBuffer(emailData, session);
|
|
804
1181
|
} else {
|
|
805
1182
|
email = emailData;
|
|
806
1183
|
}
|
|
@@ -973,6 +1350,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
973
1350
|
this.emailRouter.updateRoutes(routes);
|
|
974
1351
|
}
|
|
975
1352
|
|
|
1353
|
+
public getEmailRoutes(): IEmailRoute[] {
|
|
1354
|
+
return this.emailRouter.getRoutes();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
976
1357
|
/**
|
|
977
1358
|
* Get server statistics
|
|
978
1359
|
*/
|
|
@@ -980,6 +1361,22 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
980
1361
|
return { ...this.stats };
|
|
981
1362
|
}
|
|
982
1363
|
|
|
1364
|
+
public getQueueStats(): IQueueStats {
|
|
1365
|
+
return this.deliveryQueue.getStats();
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
public getQueueItems(): IQueueItem[] {
|
|
1369
|
+
return this.deliveryQueue.listItems();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
public getQueueItem(id: string): IQueueItem | undefined {
|
|
1373
|
+
return this.deliveryQueue.getItem(id);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
public getDeliveryStats(): IDeliveryStats {
|
|
1377
|
+
return this.deliverySystem.getStats();
|
|
1378
|
+
}
|
|
1379
|
+
|
|
983
1380
|
/**
|
|
984
1381
|
* Get domain registry
|
|
985
1382
|
*/
|
|
@@ -1039,11 +1436,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1039
1436
|
// Sign with DKIM if configured
|
|
1040
1437
|
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
|
1041
1438
|
const domain = email.from.split('@')[1];
|
|
1042
|
-
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || '
|
|
1439
|
+
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'default');
|
|
1043
1440
|
}
|
|
1044
1441
|
|
|
1045
|
-
const id =
|
|
1046
|
-
await this.deliveryQueue.enqueue(email, mode, route);
|
|
1442
|
+
const id = await this.deliveryQueue.enqueue(email, mode, route);
|
|
1047
1443
|
|
|
1048
1444
|
logger.log('info', `Email queued with ID: ${id}`);
|
|
1049
1445
|
return id;
|