@push.rocks/smartmta 5.3.3 → 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 +14 -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/routing/classes.unified.email.server.d.ts +63 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +308 -40
- package/dist_ts/mail/routing/interfaces.d.ts +2 -0
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +33 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +18 -1
- package/package.json +12 -14
- package/readme.md +83 -30
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mail/routing/classes.unified.email.server.ts +395 -39
- package/ts/mail/routing/interfaces.ts +3 -1
- package/ts/security/classes.rustsecuritybridge.ts +53 -0
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
|
11
11
|
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
|
|
12
12
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
13
|
-
import type { IEmailReceivedEvent, IAuthRequestEvent } from '../../security/classes.rustsecuritybridge.js';
|
|
13
|
+
import type { IEmailReceivedEvent, IRcptToRequestEvent, IAuthRequestEvent, IScramCredentialRequestEvent } from '../../security/classes.rustsecuritybridge.js';
|
|
14
14
|
import { EmailRouter } from './classes.email.router.js';
|
|
15
15
|
import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
|
16
16
|
import { Email } from '../core/classes.email.js';
|
|
@@ -44,6 +44,37 @@ export interface IExtendedSmtpSession extends ISmtpSession {
|
|
|
44
44
|
matchedRoute?: IEmailRoute;
|
|
45
45
|
}
|
|
46
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
|
+
|
|
47
78
|
/**
|
|
48
79
|
* Options for the unified email server
|
|
49
80
|
*/
|
|
@@ -87,6 +118,27 @@ export interface IUnifiedEmailServerOptions {
|
|
|
87
118
|
// Email routing rules
|
|
88
119
|
routes: IEmailRoute[];
|
|
89
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
|
+
|
|
90
142
|
// Global defaults for all domains
|
|
91
143
|
defaults?: {
|
|
92
144
|
dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
|
|
@@ -180,6 +232,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
180
232
|
// Extracted subsystems
|
|
181
233
|
private actionExecutor: EmailActionExecutor;
|
|
182
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;
|
|
183
240
|
|
|
184
241
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
185
242
|
super();
|
|
@@ -360,6 +417,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
360
417
|
this.emit('started');
|
|
361
418
|
} catch (error) {
|
|
362
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
|
+
}
|
|
363
425
|
throw error;
|
|
364
426
|
}
|
|
365
427
|
}
|
|
@@ -379,11 +441,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
379
441
|
}
|
|
380
442
|
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
|
381
443
|
|
|
382
|
-
this.
|
|
444
|
+
this.unregisterBridgeStateHandler();
|
|
445
|
+
this.bridgeStateChangeHandler = ({ oldState, newState }: { oldState: string; newState: string }) => {
|
|
383
446
|
if (newState === 'failed') this.emit('bridgeFailed');
|
|
384
447
|
else if (newState === 'restarting') this.emit('bridgeRestarting');
|
|
385
448
|
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
|
|
386
|
-
}
|
|
449
|
+
};
|
|
450
|
+
this.rustBridge.on('stateChange', this.bridgeStateChangeHandler);
|
|
387
451
|
}
|
|
388
452
|
|
|
389
453
|
private async initializeDkimAndDns(): Promise<void> {
|
|
@@ -402,7 +466,28 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
402
466
|
}
|
|
403
467
|
|
|
404
468
|
private registerBridgeEventHandlers(): void {
|
|
405
|
-
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) => {
|
|
406
491
|
try {
|
|
407
492
|
await this.handleRustEmailReceived(data);
|
|
408
493
|
} catch (err) {
|
|
@@ -418,9 +503,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
418
503
|
logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`);
|
|
419
504
|
}
|
|
420
505
|
}
|
|
421
|
-
}
|
|
506
|
+
};
|
|
507
|
+
this.rustBridge.onEmailReceived(this.bridgeEmailReceivedHandler);
|
|
422
508
|
|
|
423
|
-
this.
|
|
509
|
+
this.bridgeAuthRequestHandler = async (data) => {
|
|
424
510
|
try {
|
|
425
511
|
await this.handleRustAuthRequest(data);
|
|
426
512
|
} catch (err) {
|
|
@@ -435,9 +521,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
435
521
|
logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`);
|
|
436
522
|
}
|
|
437
523
|
}
|
|
438
|
-
}
|
|
524
|
+
};
|
|
525
|
+
this.rustBridge.onAuthRequest(this.bridgeAuthRequestHandler);
|
|
439
526
|
|
|
440
|
-
this.
|
|
527
|
+
this.bridgeScramCredentialRequestHandler = async (data) => {
|
|
441
528
|
try {
|
|
442
529
|
await this.handleScramCredentialRequest(data);
|
|
443
530
|
} catch (err) {
|
|
@@ -451,7 +538,34 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
451
538
|
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
|
|
452
539
|
}
|
|
453
540
|
}
|
|
454
|
-
}
|
|
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
|
+
}
|
|
455
569
|
}
|
|
456
570
|
|
|
457
571
|
private async startSmtpServer(): Promise<void> {
|
|
@@ -486,7 +600,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
486
600
|
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
|
487
601
|
maxAuthFailures: 3,
|
|
488
602
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
489
|
-
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,
|
|
490
611
|
rateLimits: this.options.rateLimits ? {
|
|
491
612
|
maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
|
|
492
613
|
maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
|
|
@@ -520,8 +641,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
520
641
|
// Clear the servers array - servers will be garbage collected
|
|
521
642
|
this.servers = [];
|
|
522
643
|
|
|
523
|
-
|
|
524
|
-
this.
|
|
644
|
+
this.unregisterBridgeEventHandlers();
|
|
645
|
+
this.unregisterBridgeStateHandler();
|
|
525
646
|
await this.rustBridge.stop();
|
|
526
647
|
|
|
527
648
|
// Stop the delivery system
|
|
@@ -558,6 +679,170 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
558
679
|
// Rust SMTP server event handlers
|
|
559
680
|
// -----------------------------------------------------------------------
|
|
560
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
|
+
|
|
561
846
|
/**
|
|
562
847
|
* Handle an emailReceived event from the Rust SMTP server.
|
|
563
848
|
*/
|
|
@@ -572,12 +857,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
572
857
|
if (data.data.type === 'inline' && data.data.base64) {
|
|
573
858
|
rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
|
|
574
859
|
} else if (data.data.type === 'file' && data.data.path) {
|
|
575
|
-
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
|
576
|
-
// Clean up temp file
|
|
577
860
|
try {
|
|
578
|
-
plugins.fs.
|
|
579
|
-
}
|
|
580
|
-
|
|
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
|
+
}
|
|
581
868
|
}
|
|
582
869
|
} else {
|
|
583
870
|
throw new Error('Invalid email data transport');
|
|
@@ -611,15 +898,70 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
611
898
|
(session as any)._precomputedSecurityResults = data.securityResults;
|
|
612
899
|
}
|
|
613
900
|
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
}
|
|
616
958
|
|
|
617
959
|
// Send acceptance back to Rust
|
|
618
960
|
await this.rustBridge.sendEmailProcessingResult({
|
|
619
961
|
correlationId,
|
|
620
962
|
accepted: true,
|
|
621
963
|
smtpCode: 250,
|
|
622
|
-
smtpMessage: '2.0.0 Message accepted for delivery',
|
|
964
|
+
smtpMessage: acceptanceDecision?.smtpMessage || '2.0.0 Message accepted for delivery',
|
|
623
965
|
});
|
|
624
966
|
} catch (err) {
|
|
625
967
|
logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
|
|
@@ -632,6 +974,38 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
632
974
|
}
|
|
633
975
|
}
|
|
634
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
|
+
|
|
635
1009
|
/**
|
|
636
1010
|
* Handle an authRequest event from the Rust SMTP server.
|
|
637
1011
|
*/
|
|
@@ -803,25 +1177,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
803
1177
|
// Convert Buffer to Email if needed
|
|
804
1178
|
let email: Email;
|
|
805
1179
|
if (Buffer.isBuffer(emailData)) {
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
const parsed = await plugins.mailparser.simpleParser(emailData);
|
|
809
|
-
email = new Email({
|
|
810
|
-
from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
|
|
811
|
-
to: session.envelope.rcptTo[0]?.address || '',
|
|
812
|
-
subject: parsed.subject || '',
|
|
813
|
-
text: parsed.text || '',
|
|
814
|
-
html: parsed.html || undefined,
|
|
815
|
-
attachments: parsed.attachments?.map(att => ({
|
|
816
|
-
filename: att.filename || '',
|
|
817
|
-
content: att.content,
|
|
818
|
-
contentType: att.contentType
|
|
819
|
-
})) || []
|
|
820
|
-
});
|
|
821
|
-
} catch (error) {
|
|
822
|
-
logger.log('error', `Error parsing email data: ${error.message}`);
|
|
823
|
-
throw new Error(`Error parsing email data: ${error.message}`);
|
|
824
|
-
}
|
|
1180
|
+
email = await this.createEmailFromBuffer(emailData, session);
|
|
825
1181
|
} else {
|
|
826
1182
|
email = emailData;
|
|
827
1183
|
}
|
|
@@ -45,6 +45,8 @@ export interface IEmailMatch {
|
|
|
45
45
|
export interface IEmailAction {
|
|
46
46
|
/** Type of action to perform */
|
|
47
47
|
type: 'forward' | 'deliver' | 'reject' | 'process';
|
|
48
|
+
/** Explicitly permits this route to relay to non-local recipients. */
|
|
49
|
+
allowRelay?: boolean;
|
|
48
50
|
|
|
49
51
|
/** Forward action configuration */
|
|
50
52
|
forward?: {
|
|
@@ -199,4 +201,4 @@ export interface IEmailDomainConfig {
|
|
|
199
201
|
recipientsPerMessage?: number;
|
|
200
202
|
};
|
|
201
203
|
};
|
|
202
|
-
}
|
|
204
|
+
}
|
|
@@ -158,6 +158,12 @@ interface ISmtpServerConfig {
|
|
|
158
158
|
maxAuthFailures?: number;
|
|
159
159
|
socketTimeoutSecs?: number;
|
|
160
160
|
processingTimeoutSecs?: number;
|
|
161
|
+
recipientValidationEnabled?: boolean;
|
|
162
|
+
recipientValidationTimeoutSecs?: number;
|
|
163
|
+
proxyProtocol?: {
|
|
164
|
+
required?: boolean;
|
|
165
|
+
trustedIps?: string[];
|
|
166
|
+
};
|
|
161
167
|
rateLimits?: IRateLimitConfig;
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -187,6 +193,18 @@ interface IEmailReceivedEvent {
|
|
|
187
193
|
securityResults: any | null;
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
interface IRcptToRequestEvent {
|
|
197
|
+
correlationId: string;
|
|
198
|
+
sessionId: string;
|
|
199
|
+
mailFrom: string;
|
|
200
|
+
rcptTo: string;
|
|
201
|
+
acceptedRcptTo: string[];
|
|
202
|
+
remoteAddr: string;
|
|
203
|
+
clientHostname: string | null;
|
|
204
|
+
secure: boolean;
|
|
205
|
+
authenticatedUser: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
interface IAuthRequestEvent {
|
|
191
209
|
correlationId: string;
|
|
192
210
|
sessionId: string;
|
|
@@ -274,6 +292,15 @@ type TMailerCommands = {
|
|
|
274
292
|
};
|
|
275
293
|
result: { resolved: boolean };
|
|
276
294
|
};
|
|
295
|
+
rcptToResult: {
|
|
296
|
+
params: {
|
|
297
|
+
correlationId: string;
|
|
298
|
+
accepted: boolean;
|
|
299
|
+
smtpCode?: number;
|
|
300
|
+
smtpMessage?: string;
|
|
301
|
+
};
|
|
302
|
+
result: { resolved: boolean };
|
|
303
|
+
};
|
|
277
304
|
authResult: {
|
|
278
305
|
params: {
|
|
279
306
|
correlationId: string;
|
|
@@ -859,6 +886,19 @@ export class RustSecurityBridge extends EventEmitter {
|
|
|
859
886
|
await this.bridge.sendCommand('emailProcessingResult', opts);
|
|
860
887
|
}
|
|
861
888
|
|
|
889
|
+
/**
|
|
890
|
+
* Send the result of recipient policy validation back to the Rust SMTP server.
|
|
891
|
+
*/
|
|
892
|
+
public async sendRcptToResult(opts: {
|
|
893
|
+
correlationId: string;
|
|
894
|
+
accepted: boolean;
|
|
895
|
+
smtpCode?: number;
|
|
896
|
+
smtpMessage?: string;
|
|
897
|
+
}): Promise<void> {
|
|
898
|
+
this.ensureRunning();
|
|
899
|
+
await this.bridge.sendCommand('rcptToResult', opts);
|
|
900
|
+
}
|
|
901
|
+
|
|
862
902
|
/**
|
|
863
903
|
* Send the result of authentication validation back to the Rust SMTP server.
|
|
864
904
|
*/
|
|
@@ -905,6 +945,13 @@ export class RustSecurityBridge extends EventEmitter {
|
|
|
905
945
|
this.bridge.on('management:emailReceived', handler);
|
|
906
946
|
}
|
|
907
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Register a handler for RCPT TO policy requests from the Rust SMTP server.
|
|
950
|
+
*/
|
|
951
|
+
public onRcptToRequest(handler: (data: IRcptToRequestEvent) => void): void {
|
|
952
|
+
this.bridge.on('management:rcptToRequest', handler);
|
|
953
|
+
}
|
|
954
|
+
|
|
908
955
|
/**
|
|
909
956
|
* Register a handler for authRequest events from the Rust SMTP server.
|
|
910
957
|
* The handler must call sendAuthResult() with the correlationId.
|
|
@@ -926,6 +973,11 @@ export class RustSecurityBridge extends EventEmitter {
|
|
|
926
973
|
this.bridge.off('management:emailReceived', handler);
|
|
927
974
|
}
|
|
928
975
|
|
|
976
|
+
/** Remove an RCPT TO policy request handler. */
|
|
977
|
+
public offRcptToRequest(handler: (data: IRcptToRequestEvent) => void): void {
|
|
978
|
+
this.bridge.off('management:rcptToRequest', handler);
|
|
979
|
+
}
|
|
980
|
+
|
|
929
981
|
/** Remove an authRequest event handler. */
|
|
930
982
|
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
|
931
983
|
this.bridge.off('management:authRequest', handler);
|
|
@@ -952,6 +1004,7 @@ export type {
|
|
|
952
1004
|
IRateLimitConfig,
|
|
953
1005
|
IEmailData,
|
|
954
1006
|
IEmailReceivedEvent,
|
|
1007
|
+
IRcptToRequestEvent,
|
|
955
1008
|
IAuthRequestEvent,
|
|
956
1009
|
IScramCredentialRequestEvent,
|
|
957
1010
|
IOutboundEmail,
|