@push.rocks/smartmta 8.0.1 → 8.1.1
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 +51 -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/index.d.ts +6 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +43 -3
- package/dist_ts/mail/delivery/classes.delivery.queue.js +310 -10
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -2
- package/dist_ts/mail/delivery/classes.delivery.system.js +106 -36
- package/dist_ts/mail/interfaces.storage.d.ts +34 -0
- package/dist_ts/mail/interfaces.storage.js +89 -3
- package/dist_ts/mail/routing/classes.domain.registry.js +5 -1
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
- package/dist_ts/mail/routing/classes.email.action.executor.js +38 -13
- package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
- package/dist_ts/mail/routing/classes.email.router.js +28 -21
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +156 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +1001 -61
- package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
- package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
- package/dist_ts/mail/routing/interfaces.d.ts +11 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +1 -1
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +6 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +429 -7
- package/ts/mail/delivery/classes.delivery.system.ts +137 -41
- package/ts/mail/interfaces.storage.ts +148 -2
- package/ts/mail/routing/classes.domain.registry.ts +4 -0
- package/ts/mail/routing/classes.email.action.executor.ts +97 -13
- package/ts/mail/routing/classes.email.router.ts +33 -20
- package/ts/mail/routing/classes.unified.email.server.ts +1367 -74
- package/ts/mail/routing/functions.routing.validation.ts +36 -0
- package/ts/mail/routing/interfaces.ts +13 -1
- package/ts/plugins.ts +2 -0
- package/ts/security/classes.rustsecuritybridge.ts +46 -1
- package/ts/security/index.ts +2 -0
|
@@ -10,16 +10,34 @@ import {
|
|
|
10
10
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
|
11
11
|
import type { IStorageManager } from '../interfaces.storage.js';
|
|
12
12
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
IAuthRequestEvent,
|
|
15
|
+
IEmailReceivedEvent,
|
|
16
|
+
IEmailSecurityResult,
|
|
17
|
+
IInboundEmailSecurityResult,
|
|
18
|
+
IRcptToRequestEvent,
|
|
19
|
+
IScramCredentialRequestEvent,
|
|
20
|
+
} from '../../security/classes.rustsecuritybridge.js';
|
|
14
21
|
import { EmailRouter } from './classes.email.router.js';
|
|
15
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
IEmailRoute,
|
|
24
|
+
IEmailAction,
|
|
25
|
+
IEmailContext,
|
|
26
|
+
IEmailDomainConfig,
|
|
27
|
+
} from './interfaces.js';
|
|
16
28
|
import { Email } from '../core/classes.email.js';
|
|
17
29
|
import { DomainRegistry } from './classes.domain.registry.js';
|
|
18
30
|
import { DnsManager } from './classes.dns.manager.js';
|
|
19
31
|
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
|
|
20
32
|
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
|
|
21
33
|
import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
|
22
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
|
|
36
|
+
UnifiedDeliveryQueue,
|
|
37
|
+
type IQueueItem,
|
|
38
|
+
type IQueueOptions,
|
|
39
|
+
type IQueueStats,
|
|
40
|
+
} from '../delivery/classes.delivery.queue.js';
|
|
23
41
|
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
|
24
42
|
import { SmtpState } from '../delivery/interfaces.js';
|
|
25
43
|
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
|
|
@@ -35,6 +53,41 @@ interface DcRouter {
|
|
|
35
53
|
options?: any;
|
|
36
54
|
}
|
|
37
55
|
|
|
56
|
+
export type TRecipientRouteSource = 'explicit-route' | 'domain-policy' | 'no-route';
|
|
57
|
+
export type TRecipientRejectionReason =
|
|
58
|
+
| 'explicit-route'
|
|
59
|
+
| 'domain-policy'
|
|
60
|
+
| 'no-route'
|
|
61
|
+
| 'domain-not-hosted'
|
|
62
|
+
| 'relay-denied'
|
|
63
|
+
| 'recipient-hook'
|
|
64
|
+
| 'policy-error';
|
|
65
|
+
|
|
66
|
+
export interface IResolvedRecipientRoute {
|
|
67
|
+
recipient: string;
|
|
68
|
+
localDomain: string | null;
|
|
69
|
+
route: IEmailRoute | null;
|
|
70
|
+
source: TRecipientRouteSource;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IRecipientRejectedEvent {
|
|
74
|
+
timestamp: string;
|
|
75
|
+
sender: string;
|
|
76
|
+
recipient: string;
|
|
77
|
+
localDomain: string | null;
|
|
78
|
+
remoteAddress: string;
|
|
79
|
+
smtpCode: number;
|
|
80
|
+
smtpMessage: string;
|
|
81
|
+
routeName: string | null;
|
|
82
|
+
reason: TRecipientRejectionReason;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface IRecipientPolicyResolution extends IResolvedRecipientRoute {
|
|
86
|
+
decision: ISmtpDecision;
|
|
87
|
+
rejectionReason?: TRecipientRejectionReason;
|
|
88
|
+
policyRouteName?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
/**
|
|
39
92
|
* Extended SMTP session interface with route information
|
|
40
93
|
*/
|
|
@@ -43,6 +96,10 @@ export interface IExtendedSmtpSession extends ISmtpSession {
|
|
|
43
96
|
* Matched route for this session
|
|
44
97
|
*/
|
|
45
98
|
matchedRoute?: IEmailRoute;
|
|
99
|
+
/** Per-envelope-recipient routes resolved before DATA acceptance. */
|
|
100
|
+
resolvedRecipientRoutes?: IResolvedRecipientRoute[];
|
|
101
|
+
/** Results computed by the Rust inbound pipeline before the message event is emitted. */
|
|
102
|
+
securityResults?: IInboundEmailSecurityResult | null;
|
|
46
103
|
}
|
|
47
104
|
|
|
48
105
|
export interface ISmtpDecision {
|
|
@@ -68,9 +125,102 @@ export interface IMessageAcceptanceContext {
|
|
|
68
125
|
email: Email;
|
|
69
126
|
rawMessage: Buffer;
|
|
70
127
|
session: IExtendedSmtpSession;
|
|
128
|
+
matchedRoute?: IEmailRoute;
|
|
129
|
+
/** Full-envelope route resolution. */
|
|
130
|
+
resolvedRecipientRoutes: IResolvedRecipientRoute[];
|
|
131
|
+
abortSignal?: AbortSignal;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Terminal durable acceptance context for an envelope containing at least one `store` route.
|
|
136
|
+
* The consumer owns persistence and all recipient fan-out before this hook resolves.
|
|
137
|
+
*/
|
|
138
|
+
export interface IAcceptEnvelopeContext {
|
|
139
|
+
email: Email;
|
|
140
|
+
/** Exact bytes received during SMTP DATA, before parsing or transformation. */
|
|
141
|
+
rawMessage: Buffer;
|
|
142
|
+
session: IExtendedSmtpSession;
|
|
143
|
+
envelope: {
|
|
144
|
+
mailFrom: string;
|
|
145
|
+
rcptTo: string[];
|
|
146
|
+
};
|
|
147
|
+
/** Complete resolved plan for every SMTP envelope recipient. */
|
|
148
|
+
resolvedRecipientRoutes: IResolvedRecipientRoute[];
|
|
71
149
|
abortSignal?: AbortSignal;
|
|
150
|
+
/** Immutable attachment count derived from the parsed message. */
|
|
151
|
+
readonly attachmentCount: number;
|
|
152
|
+
/** Rust-computed inbound verdicts, available before durable acceptance. */
|
|
153
|
+
securityResults: IInboundEmailSecurityResult | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* JSON-safe, immutable snapshot of one accepted envelope recipient's exact
|
|
158
|
+
* routing decision. The snapshot is safe to persist and replay without
|
|
159
|
+
* consulting the live route table.
|
|
160
|
+
*/
|
|
161
|
+
export interface IAcceptedEnvelopeRecipientPlan {
|
|
162
|
+
readonly recipient: string;
|
|
163
|
+
readonly localDomain: string | null;
|
|
164
|
+
readonly routeName: string;
|
|
165
|
+
readonly source: TRecipientRouteSource;
|
|
166
|
+
readonly action: IEmailAction;
|
|
167
|
+
readonly idempotencyKey: string;
|
|
72
168
|
}
|
|
73
169
|
|
|
170
|
+
/** Serializable SMTP metadata needed to recreate an isolated action session. */
|
|
171
|
+
export interface IAcceptedEnvelopeDispatchMetadata {
|
|
172
|
+
readonly mailFrom: string;
|
|
173
|
+
readonly session?: {
|
|
174
|
+
readonly id?: string;
|
|
175
|
+
readonly remoteAddress?: string;
|
|
176
|
+
readonly clientHostname?: string;
|
|
177
|
+
readonly secure?: boolean;
|
|
178
|
+
readonly authenticated?: boolean;
|
|
179
|
+
readonly authenticatedUser?: string | null;
|
|
180
|
+
readonly user?: Readonly<{
|
|
181
|
+
username: string;
|
|
182
|
+
[key: string]: unknown;
|
|
183
|
+
}>;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type TAcceptedEnvelopeRecipientDispatchStatus =
|
|
188
|
+
| 'stored'
|
|
189
|
+
| 'queued'
|
|
190
|
+
| 'rejected'
|
|
191
|
+
| 'failed';
|
|
192
|
+
|
|
193
|
+
export interface IAcceptedEnvelopeRecipientDispatchResult {
|
|
194
|
+
recipient: string;
|
|
195
|
+
localDomain: string | null;
|
|
196
|
+
routeName: string;
|
|
197
|
+
source: TRecipientRouteSource;
|
|
198
|
+
actionType: IEmailAction['type'];
|
|
199
|
+
idempotencyKey: string;
|
|
200
|
+
status: TAcceptedEnvelopeRecipientDispatchStatus;
|
|
201
|
+
replayed: boolean;
|
|
202
|
+
queueId?: string;
|
|
203
|
+
smtpCode?: number;
|
|
204
|
+
message?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface IAcceptedEnvelopeDispatchResult {
|
|
208
|
+
complete: boolean;
|
|
209
|
+
results: IAcceptedEnvelopeRecipientDispatchResult[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface IStoredAcceptedEnvelopeDispatchResult {
|
|
213
|
+
schemaVersion: 1;
|
|
214
|
+
fingerprint: string;
|
|
215
|
+
expiresAt: number;
|
|
216
|
+
result: Omit<IAcceptedEnvelopeRecipientDispatchResult, 'replayed'>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX =
|
|
220
|
+
'/email/accepted-envelope-dispatch/v1/';
|
|
221
|
+
const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
222
|
+
const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT = 256;
|
|
223
|
+
|
|
74
224
|
export interface IMessageAcceptanceDecision extends ISmtpDecision {
|
|
75
225
|
/** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
|
|
76
226
|
continueProcessing?: boolean;
|
|
@@ -95,6 +245,18 @@ export interface IOutboundConnectionProxy {
|
|
|
95
245
|
close?: () => Promise<void> | void;
|
|
96
246
|
}
|
|
97
247
|
|
|
248
|
+
export interface IOutboundTopologyIdentity {
|
|
249
|
+
hostnames?: string[];
|
|
250
|
+
ipAddresses?: string[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface ISelfMxIdentityMatch {
|
|
254
|
+
isSelf: boolean;
|
|
255
|
+
source: 'explicit' | 'dns-derived' | 'dynamic' | null;
|
|
256
|
+
indeterminate: boolean;
|
|
257
|
+
evidence: string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
98
260
|
/**
|
|
99
261
|
* Options for the unified email server
|
|
100
262
|
*/
|
|
@@ -159,10 +321,14 @@ export interface IUnifiedEmailServerOptions {
|
|
|
159
321
|
};
|
|
160
322
|
|
|
161
323
|
hooks?: {
|
|
162
|
-
/** Async recipient policy hook
|
|
324
|
+
/** Async recipient policy hook that may further restrict an accepted built-in decision. */
|
|
163
325
|
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
164
|
-
/** Async message acceptance hook
|
|
326
|
+
/** Async message acceptance hook for envelopes with no resolved `store` action. */
|
|
165
327
|
onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
|
|
328
|
+
/** Required terminal durable owner for any envelope containing a resolved `store` action. */
|
|
329
|
+
onAcceptEnvelope?: (context: IAcceptEnvelopeContext) => Promise<void>;
|
|
330
|
+
/** Observer-safe callback for SMTP recipient rejections. */
|
|
331
|
+
onRecipientRejected?: (event: IRecipientRejectedEvent) => Promise<void> | void;
|
|
166
332
|
};
|
|
167
333
|
|
|
168
334
|
// Global defaults for all domains
|
|
@@ -181,6 +347,10 @@ export interface IUnifiedEmailServerOptions {
|
|
|
181
347
|
socketTimeout?: number;
|
|
182
348
|
retryAttempts?: number;
|
|
183
349
|
defaultFrom?: string;
|
|
350
|
+
/** Static public listener identities that must never be selected as outbound MX targets. */
|
|
351
|
+
selfListenerIdentities?: IOutboundTopologyIdentity;
|
|
352
|
+
/** Current proven mail-edge identities. Connection proxy targets are deliberately not identities. */
|
|
353
|
+
topologyIdentityProvider?: () => Promise<IOutboundTopologyIdentity | null | undefined>;
|
|
184
354
|
connectionProxyProvider?: (context: IOutboundConnectionProxyContext) => Promise<IOutboundConnectionProxy | null | undefined>;
|
|
185
355
|
};
|
|
186
356
|
|
|
@@ -268,6 +438,9 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
268
438
|
private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
|
|
269
439
|
private bridgeAuthRequestHandler?: (data: IAuthRequestEvent) => void;
|
|
270
440
|
private bridgeScramCredentialRequestHandler?: (data: IScramCredentialRequestEvent) => void;
|
|
441
|
+
private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
|
|
442
|
+
private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
|
|
443
|
+
private acceptedEnvelopeDispatchCleanupCursor?: string;
|
|
271
444
|
|
|
272
445
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
273
446
|
super();
|
|
@@ -390,6 +563,18 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
390
563
|
// We'll create the SMTP servers during the start() method
|
|
391
564
|
}
|
|
392
565
|
|
|
566
|
+
public onRecipientRejected(
|
|
567
|
+
listener: (event: IRecipientRejectedEvent) => Promise<void> | void,
|
|
568
|
+
): this {
|
|
569
|
+
return super.on('recipientRejected', listener);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
public offRecipientRejected(
|
|
573
|
+
listener: (event: IRecipientRejectedEvent) => Promise<void> | void,
|
|
574
|
+
): this {
|
|
575
|
+
return super.off('recipientRejected', listener);
|
|
576
|
+
}
|
|
577
|
+
|
|
393
578
|
private getAdvertisedHostname(): string {
|
|
394
579
|
return this.options.hostname;
|
|
395
580
|
}
|
|
@@ -398,6 +583,120 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
398
583
|
return this.options.outbound?.hostname || this.options.hostname;
|
|
399
584
|
}
|
|
400
585
|
|
|
586
|
+
private normalizeTopologyIdentity(valueArg: string): string {
|
|
587
|
+
const value = valueArg.trim().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
588
|
+
if (!value) return '';
|
|
589
|
+
if (plugins.net.isIP(value)) {
|
|
590
|
+
if (plugins.net.isIPv6(value)) {
|
|
591
|
+
try {
|
|
592
|
+
return new URL(`http://[${value}]/`).hostname.slice(1, -1).toLowerCase();
|
|
593
|
+
} catch {
|
|
594
|
+
return value.toLowerCase();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
return plugins.url.domainToASCII(value).toLowerCase();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private getExplicitTopologyIdentity(): Required<IOutboundTopologyIdentity> {
|
|
603
|
+
const configured = this.options.outbound?.selfListenerIdentities;
|
|
604
|
+
return {
|
|
605
|
+
hostnames: [
|
|
606
|
+
this.options.hostname,
|
|
607
|
+
this.options.outbound?.hostname || '',
|
|
608
|
+
...(configured?.hostnames || []),
|
|
609
|
+
].map((value) => this.normalizeTopologyIdentity(value)).filter(Boolean),
|
|
610
|
+
ipAddresses: (configured?.ipAddresses || [])
|
|
611
|
+
.map((value) => this.normalizeTopologyIdentity(value))
|
|
612
|
+
.filter((value) => Boolean(plugins.net.isIP(value))),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async withTopologyTimeout<T>(operation: Promise<T>): Promise<T> {
|
|
617
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
618
|
+
try {
|
|
619
|
+
return await Promise.race([
|
|
620
|
+
operation,
|
|
621
|
+
new Promise<T>((_resolve, reject) => {
|
|
622
|
+
timer = setTimeout(() => reject(new Error('self-MX DNS resolution timed out')), 3_000);
|
|
623
|
+
}),
|
|
624
|
+
]);
|
|
625
|
+
} finally {
|
|
626
|
+
if (timer) clearTimeout(timer);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async resolveTopologyHostname(hostnameArg: string): Promise<{
|
|
631
|
+
hostnames: Set<string>;
|
|
632
|
+
ipAddresses: Set<string>;
|
|
633
|
+
indeterminate: boolean;
|
|
634
|
+
evidence: string[];
|
|
635
|
+
}> {
|
|
636
|
+
const hostname = this.normalizeTopologyIdentity(hostnameArg);
|
|
637
|
+
const hostnames = new Set<string>();
|
|
638
|
+
const ipAddresses = new Set<string>();
|
|
639
|
+
const evidence: string[] = [];
|
|
640
|
+
if (!hostname) return { hostnames, ipAddresses, indeterminate: false, evidence };
|
|
641
|
+
if (plugins.net.isIP(hostname)) {
|
|
642
|
+
ipAddresses.add(hostname);
|
|
643
|
+
return { hostnames, ipAddresses, indeterminate: false, evidence };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const resolver = new plugins.dns.promises.Resolver();
|
|
647
|
+
const isAbsent = (errorArg: unknown): boolean => {
|
|
648
|
+
const code = (errorArg as NodeJS.ErrnoException)?.code;
|
|
649
|
+
return code === 'ENODATA' || code === 'ENOTFOUND';
|
|
650
|
+
};
|
|
651
|
+
let current = hostname;
|
|
652
|
+
let indeterminate = false;
|
|
653
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
654
|
+
if (hostnames.has(current)) {
|
|
655
|
+
evidence.push(`CNAME loop at ${current}`);
|
|
656
|
+
indeterminate = true;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
hostnames.add(current);
|
|
660
|
+
let aliases: string[];
|
|
661
|
+
try {
|
|
662
|
+
aliases = await resolver.resolveCname(current);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (!isAbsent(error)) {
|
|
665
|
+
evidence.push(`CNAME lookup failed for ${current}`);
|
|
666
|
+
indeterminate = true;
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
const alias = this.normalizeTopologyIdentity(aliases[0] || '');
|
|
671
|
+
if (!alias) break;
|
|
672
|
+
evidence.push(`${current} CNAME ${alias}`);
|
|
673
|
+
current = alias;
|
|
674
|
+
if (depth === 7) {
|
|
675
|
+
evidence.push('CNAME chain exceeded 8 hops');
|
|
676
|
+
indeterminate = true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const resolveAddresses = async (family: 4 | 6): Promise<{ addresses: string[]; indeterminate: boolean }> => {
|
|
681
|
+
try {
|
|
682
|
+
const values = family === 4 ? await resolver.resolve4(current) : await resolver.resolve6(current);
|
|
683
|
+
return { addresses: values, indeterminate: false };
|
|
684
|
+
} catch (error) {
|
|
685
|
+
return { addresses: [], indeterminate: !isAbsent(error) };
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
const [ipv4, ipv6] = await Promise.all([resolveAddresses(4), resolveAddresses(6)]);
|
|
689
|
+
for (const address of [...ipv4.addresses, ...ipv6.addresses]) {
|
|
690
|
+
const normalized = this.normalizeTopologyIdentity(address);
|
|
691
|
+
if (normalized) ipAddresses.add(normalized);
|
|
692
|
+
}
|
|
693
|
+
if (ipv4.indeterminate || ipv6.indeterminate) {
|
|
694
|
+
evidence.push(`address lookup failed for ${current}`);
|
|
695
|
+
indeterminate = true;
|
|
696
|
+
}
|
|
697
|
+
return { hostnames, ipAddresses, indeterminate, evidence };
|
|
698
|
+
}
|
|
699
|
+
|
|
401
700
|
/**
|
|
402
701
|
* Whether a hostname refers to this server's own SMTP identity.
|
|
403
702
|
* Used by MTA delivery to refuse self-addressed relaying: a domain whose
|
|
@@ -405,13 +704,115 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
405
704
|
* full outbound/inbound pipeline forever.
|
|
406
705
|
*/
|
|
407
706
|
public isSelfHostname(hostname: string): boolean {
|
|
408
|
-
const
|
|
409
|
-
const candidate = normalize(hostname);
|
|
707
|
+
const candidate = this.normalizeTopologyIdentity(hostname);
|
|
410
708
|
if (!candidate) return false;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
709
|
+
const explicit = this.getExplicitTopologyIdentity();
|
|
710
|
+
return explicit.hostnames.includes(candidate) || explicit.ipAddresses.includes(candidate);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Resolve a candidate MX against explicit and current proven listener topology. */
|
|
714
|
+
public async resolveSelfMxIdentity(hostnameArg: string): Promise<ISelfMxIdentityMatch> {
|
|
715
|
+
const candidate = this.normalizeTopologyIdentity(hostnameArg);
|
|
716
|
+
const evidence: string[] = [];
|
|
717
|
+
if (!candidate) {
|
|
718
|
+
return { isSelf: false, source: null, indeterminate: true, evidence: ['empty MX identity'] };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const explicit = this.getExplicitTopologyIdentity();
|
|
722
|
+
if (explicit.hostnames.includes(candidate) || explicit.ipAddresses.includes(candidate)) {
|
|
723
|
+
return { isSelf: true, source: 'explicit', indeterminate: false, evidence: [`exact identity ${candidate}`] };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let dynamic: Required<IOutboundTopologyIdentity> = { hostnames: [], ipAddresses: [] };
|
|
727
|
+
try {
|
|
728
|
+
const topologyIdentityProvider = this.options.outbound?.topologyIdentityProvider;
|
|
729
|
+
const provided = topologyIdentityProvider
|
|
730
|
+
? await this.withTopologyTimeout(topologyIdentityProvider())
|
|
731
|
+
: undefined;
|
|
732
|
+
if (topologyIdentityProvider && provided == null) {
|
|
733
|
+
return {
|
|
734
|
+
isSelf: false,
|
|
735
|
+
source: null,
|
|
736
|
+
indeterminate: true,
|
|
737
|
+
evidence: ['dynamic topology identity provider returned no proof'],
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
dynamic = {
|
|
741
|
+
hostnames: (provided?.hostnames || [])
|
|
742
|
+
.map((value) => this.normalizeTopologyIdentity(value)).filter(Boolean),
|
|
743
|
+
ipAddresses: (provided?.ipAddresses || [])
|
|
744
|
+
.map((value) => this.normalizeTopologyIdentity(value))
|
|
745
|
+
.filter((value) => Boolean(plugins.net.isIP(value))),
|
|
746
|
+
};
|
|
747
|
+
if (
|
|
748
|
+
topologyIdentityProvider
|
|
749
|
+
&& dynamic.hostnames.length === 0
|
|
750
|
+
&& dynamic.ipAddresses.length === 0
|
|
751
|
+
) {
|
|
752
|
+
return {
|
|
753
|
+
isSelf: false,
|
|
754
|
+
source: null,
|
|
755
|
+
indeterminate: true,
|
|
756
|
+
evidence: ['dynamic topology identity provider returned no valid proof'],
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
760
|
+
return { isSelf: false, source: null, indeterminate: true, evidence: ['dynamic topology identity unavailable'] };
|
|
761
|
+
}
|
|
762
|
+
if (dynamic.hostnames.includes(candidate) || dynamic.ipAddresses.includes(candidate)) {
|
|
763
|
+
return { isSelf: true, source: 'dynamic', indeterminate: false, evidence: [`dynamic identity ${candidate}`] };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const candidateResolution = await this.withTopologyTimeout(this.resolveTopologyHostname(candidate));
|
|
768
|
+
evidence.push(...candidateResolution.evidence);
|
|
769
|
+
const intersects = (left: Set<string>, right: Iterable<string>): string | undefined => {
|
|
770
|
+
for (const value of right) if (left.has(value)) return value;
|
|
771
|
+
return undefined;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const explicitHostname = intersects(candidateResolution.hostnames, explicit.hostnames);
|
|
775
|
+
const explicitIp = intersects(candidateResolution.ipAddresses, explicit.ipAddresses);
|
|
776
|
+
if (explicitHostname || explicitIp) {
|
|
777
|
+
evidence.push(`resolved to explicit identity ${explicitHostname || explicitIp}`);
|
|
778
|
+
return { isSelf: true, source: 'dns-derived', indeterminate: false, evidence };
|
|
779
|
+
}
|
|
780
|
+
const dynamicHostname = intersects(candidateResolution.hostnames, dynamic.hostnames);
|
|
781
|
+
const dynamicIp = intersects(candidateResolution.ipAddresses, dynamic.ipAddresses);
|
|
782
|
+
if (dynamicHostname || dynamicIp) {
|
|
783
|
+
evidence.push(`resolved to dynamic identity ${dynamicHostname || dynamicIp}`);
|
|
784
|
+
return { isSelf: true, source: 'dynamic', indeterminate: false, evidence };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const identities = [
|
|
788
|
+
...explicit.hostnames.map((hostname) => ({ hostname, source: 'explicit' as const })),
|
|
789
|
+
...dynamic.hostnames.map((hostname) => ({ hostname, source: 'dynamic' as const })),
|
|
790
|
+
];
|
|
791
|
+
for (const identity of identities) {
|
|
792
|
+
const resolved = await this.withTopologyTimeout(this.resolveTopologyHostname(identity.hostname));
|
|
793
|
+
const matchedIp = intersects(candidateResolution.ipAddresses, resolved.ipAddresses);
|
|
794
|
+
if (matchedIp) {
|
|
795
|
+
evidence.push(`shares address ${matchedIp} with ${identity.hostname}`);
|
|
796
|
+
return {
|
|
797
|
+
isSelf: true,
|
|
798
|
+
source: identity.source === 'explicit' ? 'dns-derived' : 'dynamic',
|
|
799
|
+
indeterminate: false,
|
|
800
|
+
evidence,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
if (resolved.indeterminate) {
|
|
804
|
+
evidence.push(...resolved.evidence);
|
|
805
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (candidateResolution.indeterminate) {
|
|
809
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
810
|
+
}
|
|
811
|
+
return { isSelf: false, source: null, indeterminate: false, evidence };
|
|
812
|
+
} catch (error) {
|
|
813
|
+
evidence.push((error as Error).message);
|
|
814
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
815
|
+
}
|
|
415
816
|
}
|
|
416
817
|
|
|
417
818
|
/**
|
|
@@ -455,6 +856,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
455
856
|
text: email.text || '',
|
|
456
857
|
html: email.html || undefined,
|
|
457
858
|
headers: email.headers as Record<string, string> || {},
|
|
859
|
+
attachments: email.attachments.map((attachment) => ({
|
|
860
|
+
filename: attachment.filename,
|
|
861
|
+
contentBase64: attachment.content.toString('base64'),
|
|
862
|
+
contentType: attachment.contentType,
|
|
863
|
+
...(attachment.contentId ? { contentId: attachment.contentId } : {}),
|
|
864
|
+
})),
|
|
458
865
|
};
|
|
459
866
|
|
|
460
867
|
const secure = port === 465;
|
|
@@ -499,6 +906,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
499
906
|
|
|
500
907
|
try {
|
|
501
908
|
await this.startDeliveryPipeline();
|
|
909
|
+
this.startAcceptedEnvelopeDispatchCleanup();
|
|
502
910
|
await this.startRustBridge();
|
|
503
911
|
await this.initializeDkimAndDns();
|
|
504
912
|
await this.startDeliveryProcessing();
|
|
@@ -740,7 +1148,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
740
1148
|
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
|
741
1149
|
maxAuthFailures: 3,
|
|
742
1150
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
743
|
-
processingTimeoutSecs: this.options.hooks?.onMessageData
|
|
1151
|
+
processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
|
|
744
1152
|
? Math.ceil(this.getMessageAcceptanceTimeoutMs() / 1000) + 1
|
|
745
1153
|
: 30,
|
|
746
1154
|
maxConcurrentMessages: this.options.smtp?.maxConcurrentMessages ?? 4,
|
|
@@ -771,6 +1179,11 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
771
1179
|
logger.log('info', 'Stopping UnifiedEmailServer');
|
|
772
1180
|
|
|
773
1181
|
try {
|
|
1182
|
+
if (this.acceptedEnvelopeDispatchCleanupTimer) {
|
|
1183
|
+
clearInterval(this.acceptedEnvelopeDispatchCleanupTimer);
|
|
1184
|
+
this.acceptedEnvelopeDispatchCleanupTimer = undefined;
|
|
1185
|
+
}
|
|
1186
|
+
await this.acceptedEnvelopeDispatchCleanupPromise;
|
|
774
1187
|
await this.stopDnsValidation();
|
|
775
1188
|
|
|
776
1189
|
// Stop the Rust SMTP server first
|
|
@@ -803,6 +1216,9 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
803
1216
|
this.bounceManager.stop();
|
|
804
1217
|
logger.log('info', 'Bounce manager stopped');
|
|
805
1218
|
|
|
1219
|
+
this.rateLimiter.stop();
|
|
1220
|
+
logger.log('info', 'Rate limiter stopped');
|
|
1221
|
+
|
|
806
1222
|
// Close all Rust SMTP client connection pools
|
|
807
1223
|
try {
|
|
808
1224
|
await this.rustBridge.closeSmtpPool();
|
|
@@ -835,16 +1251,47 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
835
1251
|
authenticatedUser: data.authenticatedUser,
|
|
836
1252
|
};
|
|
837
1253
|
|
|
838
|
-
const
|
|
839
|
-
|
|
1254
|
+
const resolution = await this.resolveRecipientPolicy(context);
|
|
1255
|
+
let decision = resolution.decision;
|
|
1256
|
+
if (resolution.decision.accepted && this.options.hooks?.onRcptTo) {
|
|
1257
|
+
try {
|
|
1258
|
+
decision = await this.runHookWithTimeout(
|
|
840
1259
|
this.getRecipientValidationTimeoutMs(),
|
|
841
1260
|
'Recipient policy timeout',
|
|
842
|
-
(abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal })
|
|
843
|
-
)
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1261
|
+
(abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal }),
|
|
1262
|
+
);
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
logger.log(
|
|
1265
|
+
'error',
|
|
1266
|
+
`Recipient policy callback failed for ${context.rcptTo}: ${getErrorMessage(error)}`,
|
|
1267
|
+
);
|
|
1268
|
+
decision = {
|
|
1269
|
+
accepted: false,
|
|
1270
|
+
smtpCode: 451,
|
|
1271
|
+
smtpMessage: 'Recipient policy error',
|
|
1272
|
+
};
|
|
1273
|
+
await this.notifyRecipientRejected(context, decision, resolution, 'policy-error');
|
|
1274
|
+
await this.rustBridge.sendRcptToResult({
|
|
1275
|
+
correlationId: data.correlationId,
|
|
1276
|
+
accepted: false,
|
|
1277
|
+
smtpCode: 451,
|
|
1278
|
+
smtpMessage: 'Recipient policy error',
|
|
1279
|
+
});
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (!decision.accepted) {
|
|
1285
|
+
const rejectedByHook = resolution.decision.accepted;
|
|
1286
|
+
await this.notifyRecipientRejected(
|
|
1287
|
+
context,
|
|
1288
|
+
decision,
|
|
1289
|
+
resolution,
|
|
1290
|
+
rejectedByHook
|
|
1291
|
+
? 'recipient-hook'
|
|
1292
|
+
: resolution.rejectionReason || this.getRejectionReasonForResolution(resolution),
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
848
1295
|
|
|
849
1296
|
await this.rustBridge.sendRcptToResult({
|
|
850
1297
|
correlationId: data.correlationId,
|
|
@@ -855,65 +1302,158 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
855
1302
|
}
|
|
856
1303
|
|
|
857
1304
|
private async evaluateDefaultRcptToPolicy(context: IRcptToPolicyContext): Promise<ISmtpDecision> {
|
|
1305
|
+
return (await this.resolveRecipientPolicy(context)).decision;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private async resolveRecipientPolicy(
|
|
1309
|
+
context: IRcptToPolicyContext,
|
|
1310
|
+
emailArg?: Email,
|
|
1311
|
+
sessionArg?: IExtendedSmtpSession,
|
|
1312
|
+
): Promise<IRecipientPolicyResolution> {
|
|
858
1313
|
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
859
|
-
const email = new Email({
|
|
1314
|
+
const email = emailArg || new Email({
|
|
860
1315
|
from: context.mailFrom || '<>',
|
|
861
1316
|
to: [context.rcptTo],
|
|
862
1317
|
subject: '',
|
|
863
1318
|
text: '',
|
|
864
1319
|
});
|
|
865
|
-
const session = this.buildPolicySession(currentRecipientContext);
|
|
1320
|
+
const session = sessionArg || this.buildPolicySession(currentRecipientContext);
|
|
866
1321
|
const route = await this.emailRouter.evaluateRoutes({ email, session });
|
|
1322
|
+
const recipientDomain = this.getAddressDomain(context.rcptTo);
|
|
1323
|
+
const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
|
|
1324
|
+
const localDomain = isLocalRecipient ? recipientDomain! : null;
|
|
1325
|
+
|
|
1326
|
+
if (route) {
|
|
1327
|
+
if (route.action.type === 'reject') {
|
|
1328
|
+
return {
|
|
1329
|
+
recipient: context.rcptTo,
|
|
1330
|
+
localDomain,
|
|
1331
|
+
route,
|
|
1332
|
+
source: 'explicit-route',
|
|
1333
|
+
decision: {
|
|
1334
|
+
accepted: false,
|
|
1335
|
+
smtpCode: route.action.reject?.code || 550,
|
|
1336
|
+
smtpMessage: route.action.reject?.message || 'Recipient rejected',
|
|
1337
|
+
},
|
|
1338
|
+
rejectionReason: 'explicit-route',
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (!isLocalRecipient && !route.action.allowRelay) {
|
|
1343
|
+
return {
|
|
1344
|
+
recipient: context.rcptTo,
|
|
1345
|
+
localDomain: null,
|
|
1346
|
+
route,
|
|
1347
|
+
source: 'explicit-route',
|
|
1348
|
+
decision: { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' },
|
|
1349
|
+
rejectionReason: 'relay-denied',
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
867
1352
|
|
|
868
|
-
if (!route) {
|
|
869
|
-
return { accepted: false, smtpCode: 550, smtpMessage: 'No route for recipient' };
|
|
870
|
-
}
|
|
871
|
-
if (route.action.type === 'reject') {
|
|
872
1353
|
return {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1354
|
+
recipient: context.rcptTo,
|
|
1355
|
+
localDomain,
|
|
1356
|
+
route,
|
|
1357
|
+
source: 'explicit-route',
|
|
1358
|
+
decision: { accepted: true, smtpCode: 250, smtpMessage: 'OK' },
|
|
876
1359
|
};
|
|
877
1360
|
}
|
|
878
1361
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1362
|
+
if (!isLocalRecipient) {
|
|
1363
|
+
return {
|
|
1364
|
+
recipient: context.rcptTo,
|
|
1365
|
+
localDomain: null,
|
|
1366
|
+
route: null,
|
|
1367
|
+
source: 'no-route',
|
|
1368
|
+
decision: { accepted: false, smtpCode: 550, smtpMessage: 'domain not hosted here' },
|
|
1369
|
+
rejectionReason: 'domain-not-hosted',
|
|
1370
|
+
};
|
|
883
1371
|
}
|
|
884
1372
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1373
|
+
const inboundPolicy = this.domainRegistry.getDomainConfig(recipientDomain!)?.inboundPolicy;
|
|
1374
|
+
if (!inboundPolicy) {
|
|
1375
|
+
return {
|
|
1376
|
+
recipient: context.rcptTo,
|
|
1377
|
+
localDomain,
|
|
1378
|
+
route: null,
|
|
1379
|
+
source: 'no-route',
|
|
1380
|
+
decision: {
|
|
1381
|
+
accepted: false,
|
|
1382
|
+
smtpCode: 550,
|
|
1383
|
+
smtpMessage: `no mailbox configured for ${context.rcptTo}`,
|
|
1384
|
+
},
|
|
1385
|
+
rejectionReason: 'no-route',
|
|
1386
|
+
};
|
|
894
1387
|
}
|
|
895
1388
|
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1389
|
+
const policyRouteName = `__smartmta_inbound_${inboundPolicy.defaultAction}:${recipientDomain}`;
|
|
1390
|
+
if (inboundPolicy.defaultAction === 'reject') {
|
|
1391
|
+
return {
|
|
1392
|
+
recipient: context.rcptTo,
|
|
1393
|
+
localDomain,
|
|
1394
|
+
route: null,
|
|
1395
|
+
source: 'domain-policy',
|
|
1396
|
+
decision: {
|
|
1397
|
+
accepted: false,
|
|
1398
|
+
smtpCode: 550,
|
|
1399
|
+
smtpMessage: `no mailbox configured for ${context.rcptTo}`,
|
|
1400
|
+
},
|
|
1401
|
+
rejectionReason: 'domain-policy',
|
|
1402
|
+
policyRouteName,
|
|
1403
|
+
};
|
|
900
1404
|
}
|
|
901
1405
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1406
|
+
return {
|
|
1407
|
+
recipient: context.rcptTo,
|
|
1408
|
+
localDomain,
|
|
1409
|
+
route: {
|
|
1410
|
+
name: policyRouteName,
|
|
1411
|
+
match: { recipients: `*@${recipientDomain}` },
|
|
1412
|
+
action: {
|
|
1413
|
+
type: 'store',
|
|
1414
|
+
store: { retentionDays: inboundPolicy.retentionDays },
|
|
1415
|
+
},
|
|
1416
|
+
},
|
|
1417
|
+
source: 'domain-policy',
|
|
1418
|
+
decision: { accepted: true, smtpCode: 250, smtpMessage: 'OK' },
|
|
1419
|
+
policyRouteName,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
911
1422
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1423
|
+
private getRejectionReasonForResolution(
|
|
1424
|
+
resolution: IRecipientPolicyResolution,
|
|
1425
|
+
): TRecipientRejectionReason {
|
|
1426
|
+
if (resolution.source === 'explicit-route') return 'explicit-route';
|
|
1427
|
+
if (resolution.source === 'domain-policy') return 'domain-policy';
|
|
1428
|
+
return resolution.localDomain ? 'no-route' : 'domain-not-hosted';
|
|
1429
|
+
}
|
|
915
1430
|
|
|
916
|
-
|
|
1431
|
+
private async notifyRecipientRejected(
|
|
1432
|
+
context: IRcptToPolicyContext,
|
|
1433
|
+
decision: ISmtpDecision,
|
|
1434
|
+
resolution: IRecipientPolicyResolution,
|
|
1435
|
+
reason: TRecipientRejectionReason,
|
|
1436
|
+
): Promise<void> {
|
|
1437
|
+
const event: IRecipientRejectedEvent = {
|
|
1438
|
+
timestamp: new Date().toISOString(),
|
|
1439
|
+
sender: context.mailFrom,
|
|
1440
|
+
recipient: context.rcptTo,
|
|
1441
|
+
localDomain: resolution.localDomain,
|
|
1442
|
+
remoteAddress: context.remoteAddress,
|
|
1443
|
+
smtpCode: decision.smtpCode || 550,
|
|
1444
|
+
smtpMessage: decision.smtpMessage || 'Recipient rejected',
|
|
1445
|
+
routeName: resolution.route?.name || resolution.policyRouteName || null,
|
|
1446
|
+
reason,
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
if (this.options.hooks?.onRecipientRejected) {
|
|
1450
|
+
await callObserverSafely(
|
|
1451
|
+
'recipientRejected callback',
|
|
1452
|
+
this.options.hooks.onRecipientRejected,
|
|
1453
|
+
event,
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
await emitSafely(this, 'recipientRejected', event);
|
|
917
1457
|
}
|
|
918
1458
|
|
|
919
1459
|
private buildPolicySession(context: IRcptToPolicyContext): IExtendedSmtpSession {
|
|
@@ -986,6 +1526,704 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
986
1526
|
}
|
|
987
1527
|
}
|
|
988
1528
|
|
|
1529
|
+
/** Build a route-evaluation view containing only the current SMTP envelope recipient. */
|
|
1530
|
+
private cloneEmailForRoutingRecipient(email: Email, recipient: string): Email {
|
|
1531
|
+
return new Email({
|
|
1532
|
+
from: email.from,
|
|
1533
|
+
to: [recipient],
|
|
1534
|
+
cc: [],
|
|
1535
|
+
bcc: [],
|
|
1536
|
+
subject: email.subject,
|
|
1537
|
+
text: email.text,
|
|
1538
|
+
html: email.html,
|
|
1539
|
+
attachments: email.attachments.map((attachment) => ({
|
|
1540
|
+
...attachment,
|
|
1541
|
+
content: Buffer.from(attachment.content),
|
|
1542
|
+
})),
|
|
1543
|
+
headers: { ...email.headers },
|
|
1544
|
+
mightBeSpam: email.mightBeSpam,
|
|
1545
|
+
priority: email.priority,
|
|
1546
|
+
variables: structuredClone(email.variables),
|
|
1547
|
+
})
|
|
1548
|
+
.setMessageId(email.getMessageId())
|
|
1549
|
+
.setEnvelopeFrom(email.getEnvelopeFrom());
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
private scopeSessionForRoutingRecipient(
|
|
1553
|
+
session: IExtendedSmtpSession,
|
|
1554
|
+
recipient: string,
|
|
1555
|
+
): IExtendedSmtpSession {
|
|
1556
|
+
return {
|
|
1557
|
+
...session,
|
|
1558
|
+
...(session.user ? { user: structuredClone(session.user) } : {}),
|
|
1559
|
+
...(session.securityResults
|
|
1560
|
+
? { securityResults: structuredClone(session.securityResults) }
|
|
1561
|
+
: {}),
|
|
1562
|
+
rcptTo: [recipient],
|
|
1563
|
+
envelope: {
|
|
1564
|
+
mailFrom: { ...session.envelope.mailFrom },
|
|
1565
|
+
rcptTo: [{ address: recipient, args: {} }],
|
|
1566
|
+
},
|
|
1567
|
+
matchedRoute: undefined,
|
|
1568
|
+
resolvedRecipientRoutes: undefined,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private buildDataPolicyContext(
|
|
1573
|
+
session: IExtendedSmtpSession,
|
|
1574
|
+
recipient: string,
|
|
1575
|
+
): IRcptToPolicyContext {
|
|
1576
|
+
return {
|
|
1577
|
+
sessionId: session.id,
|
|
1578
|
+
mailFrom: session.envelope.mailFrom.address,
|
|
1579
|
+
rcptTo: recipient,
|
|
1580
|
+
acceptedRcptTo: session.envelope.rcptTo
|
|
1581
|
+
.map((entry) => entry.address)
|
|
1582
|
+
.filter((address) => address !== recipient),
|
|
1583
|
+
remoteAddress: session.remoteAddress || '',
|
|
1584
|
+
clientHostname: session.clientHostname || null,
|
|
1585
|
+
secure: !!session.secure,
|
|
1586
|
+
authenticated: !!session.authenticated,
|
|
1587
|
+
authenticatedUser: session.user?.username || null,
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private async resolveDataRecipientRoutes(
|
|
1592
|
+
email: Email,
|
|
1593
|
+
session: IExtendedSmtpSession,
|
|
1594
|
+
): Promise<IRecipientPolicyResolution[]> {
|
|
1595
|
+
const resolutions: IRecipientPolicyResolution[] = [];
|
|
1596
|
+
for (const envelopeRecipient of session.envelope.rcptTo) {
|
|
1597
|
+
const recipient = envelopeRecipient.address;
|
|
1598
|
+
const scopedEmail = this.cloneEmailForRoutingRecipient(email, recipient);
|
|
1599
|
+
const scopedSession = this.scopeSessionForRoutingRecipient(session, recipient);
|
|
1600
|
+
resolutions.push(await this.resolveRecipientPolicy(
|
|
1601
|
+
this.buildDataPolicyContext(session, recipient),
|
|
1602
|
+
scopedEmail,
|
|
1603
|
+
scopedSession,
|
|
1604
|
+
));
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
session.resolvedRecipientRoutes = resolutions.map((resolution) => ({
|
|
1608
|
+
recipient: resolution.recipient,
|
|
1609
|
+
localDomain: resolution.localDomain,
|
|
1610
|
+
route: resolution.route,
|
|
1611
|
+
source: resolution.source,
|
|
1612
|
+
}));
|
|
1613
|
+
const routeNames = new Set(
|
|
1614
|
+
session.resolvedRecipientRoutes.map((resolution) => resolution.route?.name || ''),
|
|
1615
|
+
);
|
|
1616
|
+
if (routeNames.size === 1) {
|
|
1617
|
+
session.matchedRoute = session.resolvedRecipientRoutes[0]?.route || undefined;
|
|
1618
|
+
}
|
|
1619
|
+
return resolutions;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private async acceptDurableEnvelope(
|
|
1623
|
+
email: Email,
|
|
1624
|
+
rawMessage: Buffer,
|
|
1625
|
+
session: IExtendedSmtpSession,
|
|
1626
|
+
): Promise<void> {
|
|
1627
|
+
const onAcceptEnvelope = this.options.hooks?.onAcceptEnvelope;
|
|
1628
|
+
if (!onAcceptEnvelope) {
|
|
1629
|
+
throw new Error('No durable onAcceptEnvelope hook is configured');
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
let hookContext: IAcceptEnvelopeContext | undefined;
|
|
1633
|
+
await this.runHookWithTimeout(
|
|
1634
|
+
this.getMessageAcceptanceTimeoutMs(),
|
|
1635
|
+
'Envelope acceptance timeout',
|
|
1636
|
+
(abortSignal) => {
|
|
1637
|
+
hookContext = {
|
|
1638
|
+
email,
|
|
1639
|
+
rawMessage,
|
|
1640
|
+
session,
|
|
1641
|
+
envelope: {
|
|
1642
|
+
mailFrom: session.envelope.mailFrom.address,
|
|
1643
|
+
rcptTo: session.envelope.rcptTo.map((recipient) => recipient.address),
|
|
1644
|
+
},
|
|
1645
|
+
resolvedRecipientRoutes: session.resolvedRecipientRoutes || [],
|
|
1646
|
+
attachmentCount: email.attachments.length,
|
|
1647
|
+
securityResults: session.securityResults ?? null,
|
|
1648
|
+
abortSignal,
|
|
1649
|
+
};
|
|
1650
|
+
return onAcceptEnvelope(hookContext);
|
|
1651
|
+
},
|
|
1652
|
+
() => {
|
|
1653
|
+
if (hookContext) {
|
|
1654
|
+
hookContext.rawMessage = Buffer.alloc(0);
|
|
1655
|
+
(hookContext as any).email = undefined;
|
|
1656
|
+
(hookContext as any).session = undefined;
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
private cloneJsonSafe<T>(
|
|
1663
|
+
value: T,
|
|
1664
|
+
path = 'value',
|
|
1665
|
+
seen = new WeakSet<object>(),
|
|
1666
|
+
): T {
|
|
1667
|
+
if (
|
|
1668
|
+
value === null
|
|
1669
|
+
|| typeof value === 'string'
|
|
1670
|
+
|| typeof value === 'boolean'
|
|
1671
|
+
) {
|
|
1672
|
+
return value;
|
|
1673
|
+
}
|
|
1674
|
+
if (typeof value === 'number') {
|
|
1675
|
+
if (!Number.isFinite(value)) {
|
|
1676
|
+
throw new Error(`${path} must contain only finite JSON numbers`);
|
|
1677
|
+
}
|
|
1678
|
+
return value;
|
|
1679
|
+
}
|
|
1680
|
+
if (Array.isArray(value)) {
|
|
1681
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
|
1682
|
+
throw new Error(`${path} must use the standard Array prototype`);
|
|
1683
|
+
}
|
|
1684
|
+
if (seen.has(value)) {
|
|
1685
|
+
throw new Error(`${path} must not contain circular references`);
|
|
1686
|
+
}
|
|
1687
|
+
seen.add(value);
|
|
1688
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
1689
|
+
if (ownKeys.some((key) => typeof key === 'symbol')) {
|
|
1690
|
+
throw new Error(`${path} must not contain symbol keys`);
|
|
1691
|
+
}
|
|
1692
|
+
const elementKeys = ownKeys.filter((key) => key !== 'length') as string[];
|
|
1693
|
+
if (
|
|
1694
|
+
elementKeys.length !== value.length
|
|
1695
|
+
|| elementKeys.some((key, index) => key !== String(index))
|
|
1696
|
+
) {
|
|
1697
|
+
throw new Error(`${path} must be a dense JSON array without custom keys`);
|
|
1698
|
+
}
|
|
1699
|
+
const clone: unknown[] = [];
|
|
1700
|
+
for (let index = 0; index < value.length; index++) {
|
|
1701
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
1702
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
1703
|
+
throw new Error(`${path}[${index}] must be an enumerable data property`);
|
|
1704
|
+
}
|
|
1705
|
+
clone.push(this.cloneJsonSafe(
|
|
1706
|
+
descriptor.value,
|
|
1707
|
+
`${path}[${index}]`,
|
|
1708
|
+
seen,
|
|
1709
|
+
));
|
|
1710
|
+
}
|
|
1711
|
+
seen.delete(value);
|
|
1712
|
+
return Object.freeze(clone) as T;
|
|
1713
|
+
}
|
|
1714
|
+
if (typeof value === 'object') {
|
|
1715
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1716
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1717
|
+
throw new Error(`${path} must contain only JSON-safe plain objects`);
|
|
1718
|
+
}
|
|
1719
|
+
if (seen.has(value)) {
|
|
1720
|
+
throw new Error(`${path} must not contain circular references`);
|
|
1721
|
+
}
|
|
1722
|
+
seen.add(value);
|
|
1723
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
1724
|
+
if (ownKeys.some((key) => typeof key === 'symbol')) {
|
|
1725
|
+
throw new Error(`${path} must not contain symbol keys`);
|
|
1726
|
+
}
|
|
1727
|
+
const clone = Object.create(null) as Record<string, unknown>;
|
|
1728
|
+
for (const key of ownKeys as string[]) {
|
|
1729
|
+
if (key === '__proto__') {
|
|
1730
|
+
throw new Error(`${path} must not contain __proto__ keys`);
|
|
1731
|
+
}
|
|
1732
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1733
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
1734
|
+
throw new Error(`${path}.${key} must be an enumerable data property`);
|
|
1735
|
+
}
|
|
1736
|
+
if (descriptor.value === undefined) {
|
|
1737
|
+
throw new Error(`${path}.${key} must not be undefined`);
|
|
1738
|
+
}
|
|
1739
|
+
clone[key] = this.cloneJsonSafe(
|
|
1740
|
+
descriptor.value,
|
|
1741
|
+
`${path}.${key}`,
|
|
1742
|
+
seen,
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
seen.delete(value);
|
|
1746
|
+
return Object.freeze(clone) as T;
|
|
1747
|
+
}
|
|
1748
|
+
throw new Error(`${path} contains a non-JSON-safe value`);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
private stableJson(value: unknown): string {
|
|
1752
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
1753
|
+
if (Array.isArray(value)) {
|
|
1754
|
+
return `[${value.map((entry) => this.stableJson(entry)).join(',')}]`;
|
|
1755
|
+
}
|
|
1756
|
+
const record = value as Record<string, unknown>;
|
|
1757
|
+
return `{${Object.keys(record)
|
|
1758
|
+
.sort()
|
|
1759
|
+
.map((key) => `${JSON.stringify(key)}:${this.stableJson(record[key])}`)
|
|
1760
|
+
.join(',')}}`;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
private validateAcceptedEnvelopeAction(
|
|
1764
|
+
action: IEmailAction,
|
|
1765
|
+
path: string,
|
|
1766
|
+
): void {
|
|
1767
|
+
if (!['forward', 'deliver', 'reject', 'process', 'store'].includes(action.type)) {
|
|
1768
|
+
throw new Error(`${path}.type is unsupported`);
|
|
1769
|
+
}
|
|
1770
|
+
if (action.type === 'forward') {
|
|
1771
|
+
if (!action.forward?.host?.trim()) {
|
|
1772
|
+
throw new Error(`${path}.forward.host is required`);
|
|
1773
|
+
}
|
|
1774
|
+
if (
|
|
1775
|
+
action.forward.port !== undefined
|
|
1776
|
+
&& (
|
|
1777
|
+
!Number.isInteger(action.forward.port)
|
|
1778
|
+
|| action.forward.port < 1
|
|
1779
|
+
|| action.forward.port > 65_535
|
|
1780
|
+
)
|
|
1781
|
+
) {
|
|
1782
|
+
throw new Error(`${path}.forward.port is invalid`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (action.type === 'store') {
|
|
1786
|
+
if (
|
|
1787
|
+
!Number.isInteger(action.store?.retentionDays)
|
|
1788
|
+
|| (action.store?.retentionDays || 0) < 1
|
|
1789
|
+
) {
|
|
1790
|
+
throw new Error(`${path}.store.retentionDays must be a positive integer`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
if (action.type === 'reject') {
|
|
1794
|
+
if (
|
|
1795
|
+
!Number.isInteger(action.reject?.code)
|
|
1796
|
+
|| (action.reject?.code || 0) < 400
|
|
1797
|
+
|| (action.reject?.code || 0) > 599
|
|
1798
|
+
|| !action.reject?.message?.trim()
|
|
1799
|
+
) {
|
|
1800
|
+
throw new Error(`${path}.reject must contain a 4xx/5xx code and message`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
private normalizeAcceptedEnvelopePlan(
|
|
1806
|
+
plan: readonly IAcceptedEnvelopeRecipientPlan[],
|
|
1807
|
+
): readonly IAcceptedEnvelopeRecipientPlan[] {
|
|
1808
|
+
if (!Array.isArray(plan) || plan.length === 0) {
|
|
1809
|
+
throw new Error('Accepted envelope dispatch plan must contain at least one recipient');
|
|
1810
|
+
}
|
|
1811
|
+
const keys = new Set<string>();
|
|
1812
|
+
const normalized = plan.map((entry, index) => {
|
|
1813
|
+
const path = `plan[${index}]`;
|
|
1814
|
+
if (!entry || typeof entry !== 'object') {
|
|
1815
|
+
throw new Error(`${path} must be an object`);
|
|
1816
|
+
}
|
|
1817
|
+
const atIndex = entry.recipient?.lastIndexOf('@') ?? -1;
|
|
1818
|
+
if (
|
|
1819
|
+
!entry.recipient?.trim()
|
|
1820
|
+
|| atIndex < 1
|
|
1821
|
+
|| atIndex === entry.recipient.length - 1
|
|
1822
|
+
) {
|
|
1823
|
+
throw new Error(`${path}.recipient is invalid`);
|
|
1824
|
+
}
|
|
1825
|
+
if (entry.localDomain !== null && !entry.localDomain?.trim()) {
|
|
1826
|
+
throw new Error(`${path}.localDomain is invalid`);
|
|
1827
|
+
}
|
|
1828
|
+
if (!entry.routeName?.trim()) {
|
|
1829
|
+
throw new Error(`${path}.routeName is required`);
|
|
1830
|
+
}
|
|
1831
|
+
if (!['explicit-route', 'domain-policy'].includes(entry.source)) {
|
|
1832
|
+
throw new Error(`${path}.source cannot represent an accepted route`);
|
|
1833
|
+
}
|
|
1834
|
+
if (!entry.idempotencyKey || entry.idempotencyKey.length > 512) {
|
|
1835
|
+
throw new Error(`${path}.idempotencyKey must contain 1 to 512 characters`);
|
|
1836
|
+
}
|
|
1837
|
+
if (keys.has(entry.idempotencyKey)) {
|
|
1838
|
+
throw new Error(`${path}.idempotencyKey is duplicated`);
|
|
1839
|
+
}
|
|
1840
|
+
keys.add(entry.idempotencyKey);
|
|
1841
|
+
const action = this.cloneJsonSafe(entry.action, `${path}.action`);
|
|
1842
|
+
this.validateAcceptedEnvelopeAction(action, `${path}.action`);
|
|
1843
|
+
return Object.freeze({
|
|
1844
|
+
recipient: entry.recipient,
|
|
1845
|
+
localDomain: entry.localDomain,
|
|
1846
|
+
routeName: entry.routeName,
|
|
1847
|
+
source: entry.source,
|
|
1848
|
+
action,
|
|
1849
|
+
idempotencyKey: entry.idempotencyKey,
|
|
1850
|
+
});
|
|
1851
|
+
});
|
|
1852
|
+
return Object.freeze(normalized);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* Snapshot the already-resolved acceptance plan for durable consumer storage.
|
|
1857
|
+
* No live route lookup is performed when the returned plan is dispatched.
|
|
1858
|
+
*/
|
|
1859
|
+
public createAcceptedEnvelopeDispatchPlan(
|
|
1860
|
+
context: IAcceptEnvelopeContext,
|
|
1861
|
+
idempotencyKeys: readonly string[],
|
|
1862
|
+
): readonly IAcceptedEnvelopeRecipientPlan[] {
|
|
1863
|
+
if (context.resolvedRecipientRoutes.length !== idempotencyKeys.length) {
|
|
1864
|
+
throw new Error('One idempotency key is required for every resolved recipient route');
|
|
1865
|
+
}
|
|
1866
|
+
return this.normalizeAcceptedEnvelopePlan(
|
|
1867
|
+
context.resolvedRecipientRoutes.map((resolution, index) => {
|
|
1868
|
+
if (!resolution.route) {
|
|
1869
|
+
throw new Error(
|
|
1870
|
+
`Accepted recipient ${resolution.recipient} has no resolved route action`,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
return {
|
|
1874
|
+
recipient: resolution.recipient,
|
|
1875
|
+
localDomain: resolution.localDomain,
|
|
1876
|
+
routeName: resolution.route.name,
|
|
1877
|
+
source: resolution.source,
|
|
1878
|
+
action: resolution.route.action,
|
|
1879
|
+
idempotencyKey: idempotencyKeys[index],
|
|
1880
|
+
};
|
|
1881
|
+
}),
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
private acceptedEnvelopeDispatchStorageKey(idempotencyKey: string): string {
|
|
1886
|
+
const digest = plugins.crypto
|
|
1887
|
+
.createHash('sha256')
|
|
1888
|
+
.update('smartmta-accepted-envelope-dispatch-v1\0')
|
|
1889
|
+
.update(idempotencyKey)
|
|
1890
|
+
.digest('hex');
|
|
1891
|
+
return `${ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX}${digest}.json`;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
private startAcceptedEnvelopeDispatchCleanup(): void {
|
|
1895
|
+
if (this.acceptedEnvelopeDispatchCleanupTimer) return;
|
|
1896
|
+
this.acceptedEnvelopeDispatchCleanupTimer = setInterval(() => {
|
|
1897
|
+
void this.cleanupExpiredAcceptedEnvelopeDispatchResults();
|
|
1898
|
+
}, ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS);
|
|
1899
|
+
this.acceptedEnvelopeDispatchCleanupTimer.unref?.();
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private async cleanupExpiredAcceptedEnvelopeDispatchResults(
|
|
1903
|
+
limit = ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT,
|
|
1904
|
+
): Promise<void> {
|
|
1905
|
+
if (!this.dcRouter.storageManager.listPage) return;
|
|
1906
|
+
if (this.acceptedEnvelopeDispatchCleanupPromise) {
|
|
1907
|
+
return this.acceptedEnvelopeDispatchCleanupPromise;
|
|
1908
|
+
}
|
|
1909
|
+
const cleanup = (async () => {
|
|
1910
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
1911
|
+
throw new Error('Accepted-envelope cleanup limit must be a positive integer');
|
|
1912
|
+
}
|
|
1913
|
+
const now = Date.now();
|
|
1914
|
+
const page = await this.dcRouter.storageManager.listPage!(
|
|
1915
|
+
ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX,
|
|
1916
|
+
{
|
|
1917
|
+
limit,
|
|
1918
|
+
...(this.acceptedEnvelopeDispatchCleanupCursor
|
|
1919
|
+
? { continuationToken: this.acceptedEnvelopeDispatchCleanupCursor }
|
|
1920
|
+
: {}),
|
|
1921
|
+
},
|
|
1922
|
+
);
|
|
1923
|
+
if (
|
|
1924
|
+
!page
|
|
1925
|
+
|| !Array.isArray(page.keys)
|
|
1926
|
+
|| page.keys.length > limit
|
|
1927
|
+
|| page.keys.some((key) => (
|
|
1928
|
+
typeof key !== 'string'
|
|
1929
|
+
|| !key.startsWith(ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX)
|
|
1930
|
+
))
|
|
1931
|
+
|| new Set(page.keys).size !== page.keys.length
|
|
1932
|
+
|| (
|
|
1933
|
+
page.continuationToken !== undefined
|
|
1934
|
+
&& (
|
|
1935
|
+
typeof page.continuationToken !== 'string'
|
|
1936
|
+
|| page.continuationToken.length === 0
|
|
1937
|
+
|| page.continuationToken === this.acceptedEnvelopeDispatchCleanupCursor
|
|
1938
|
+
)
|
|
1939
|
+
)
|
|
1940
|
+
) {
|
|
1941
|
+
throw new Error('Invalid paginated accepted-envelope checkpoint listing');
|
|
1942
|
+
}
|
|
1943
|
+
this.acceptedEnvelopeDispatchCleanupCursor = page.continuationToken;
|
|
1944
|
+
for (const key of page.keys) {
|
|
1945
|
+
const value = await this.dcRouter.storageManager.get(key);
|
|
1946
|
+
if (!value) continue;
|
|
1947
|
+
try {
|
|
1948
|
+
if (!key.endsWith('.json')) {
|
|
1949
|
+
throw new Error(`Invalid accepted-envelope checkpoint key: ${key}`);
|
|
1950
|
+
}
|
|
1951
|
+
const stored = JSON.parse(value) as Partial<IStoredAcceptedEnvelopeDispatchResult>;
|
|
1952
|
+
if (
|
|
1953
|
+
stored.schemaVersion !== 1
|
|
1954
|
+
|| typeof stored.fingerprint !== 'string'
|
|
1955
|
+
|| !/^[a-f0-9]{64}$/.test(stored.fingerprint)
|
|
1956
|
+
|| !Number.isFinite(stored.expiresAt)
|
|
1957
|
+
|| !stored.result
|
|
1958
|
+
|| typeof stored.result !== 'object'
|
|
1959
|
+
|| stored.expiresAt! <= now
|
|
1960
|
+
) {
|
|
1961
|
+
await this.dcRouter.storageManager.delete(key);
|
|
1962
|
+
}
|
|
1963
|
+
} catch {
|
|
1964
|
+
await this.dcRouter.storageManager.delete(key);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
})();
|
|
1968
|
+
this.acceptedEnvelopeDispatchCleanupPromise = cleanup
|
|
1969
|
+
.catch((error) => {
|
|
1970
|
+
logger.log(
|
|
1971
|
+
'warn',
|
|
1972
|
+
`Accepted-envelope dispatch checkpoint cleanup failed: ${getErrorMessage(error)}`,
|
|
1973
|
+
);
|
|
1974
|
+
})
|
|
1975
|
+
.finally(() => {
|
|
1976
|
+
this.acceptedEnvelopeDispatchCleanupPromise = undefined;
|
|
1977
|
+
});
|
|
1978
|
+
return this.acceptedEnvelopeDispatchCleanupPromise;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
private async readAcceptedEnvelopeDispatchResult(
|
|
1982
|
+
entry: IAcceptedEnvelopeRecipientPlan,
|
|
1983
|
+
fingerprint: string,
|
|
1984
|
+
): Promise<IAcceptedEnvelopeRecipientDispatchResult | null> {
|
|
1985
|
+
const key = this.acceptedEnvelopeDispatchStorageKey(entry.idempotencyKey);
|
|
1986
|
+
const storedText = await this.dcRouter.storageManager.get(key);
|
|
1987
|
+
if (!storedText) return null;
|
|
1988
|
+
let stored: IStoredAcceptedEnvelopeDispatchResult;
|
|
1989
|
+
try {
|
|
1990
|
+
stored = JSON.parse(storedText) as IStoredAcceptedEnvelopeDispatchResult;
|
|
1991
|
+
} catch {
|
|
1992
|
+
throw new Error(`Accepted-envelope dispatch checkpoint is malformed for ${entry.recipient}`);
|
|
1993
|
+
}
|
|
1994
|
+
if (Number.isFinite(stored.expiresAt) && stored.expiresAt <= Date.now()) {
|
|
1995
|
+
await this.dcRouter.storageManager.delete(key);
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
if (
|
|
1999
|
+
stored?.schemaVersion !== 1
|
|
2000
|
+
|| stored.fingerprint !== fingerprint
|
|
2001
|
+
|| !Number.isFinite(stored.expiresAt)
|
|
2002
|
+
|| stored.result?.idempotencyKey !== entry.idempotencyKey
|
|
2003
|
+
|| stored.result?.recipient !== entry.recipient
|
|
2004
|
+
|| stored.result?.localDomain !== entry.localDomain
|
|
2005
|
+
|| stored.result?.routeName !== entry.routeName
|
|
2006
|
+
|| stored.result?.source !== entry.source
|
|
2007
|
+
|| stored.result?.actionType !== entry.action.type
|
|
2008
|
+
|| (stored.result as Record<string, unknown> | undefined)?.replayed !== undefined
|
|
2009
|
+
|| !['stored', 'queued', 'rejected'].includes(stored.result?.status)
|
|
2010
|
+
|| (
|
|
2011
|
+
stored.result?.status === 'queued'
|
|
2012
|
+
&& (
|
|
2013
|
+
typeof stored.result.queueId !== 'string'
|
|
2014
|
+
|| !stored.result.queueId
|
|
2015
|
+
)
|
|
2016
|
+
)
|
|
2017
|
+
|| (
|
|
2018
|
+
stored.result?.status !== 'queued'
|
|
2019
|
+
&& stored.result?.queueId !== undefined
|
|
2020
|
+
)
|
|
2021
|
+
|| (
|
|
2022
|
+
stored.result?.status === 'rejected'
|
|
2023
|
+
&& !Number.isInteger(stored.result.smtpCode)
|
|
2024
|
+
)
|
|
2025
|
+
) {
|
|
2026
|
+
throw new Error(
|
|
2027
|
+
`Accepted-envelope idempotency key was reused or has invalid state for ${entry.recipient}`,
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
return { ...stored.result, replayed: true };
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
private async writeAcceptedEnvelopeDispatchResult(
|
|
2034
|
+
result: IAcceptedEnvelopeRecipientDispatchResult,
|
|
2035
|
+
fingerprint: string,
|
|
2036
|
+
): Promise<void> {
|
|
2037
|
+
const { replayed: _replayed, ...storedResult } = result;
|
|
2038
|
+
const stored: IStoredAcceptedEnvelopeDispatchResult = {
|
|
2039
|
+
schemaVersion: 1,
|
|
2040
|
+
fingerprint,
|
|
2041
|
+
expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
|
|
2042
|
+
result: storedResult,
|
|
2043
|
+
};
|
|
2044
|
+
await this.dcRouter.storageManager.set(
|
|
2045
|
+
this.acceptedEnvelopeDispatchStorageKey(result.idempotencyKey),
|
|
2046
|
+
JSON.stringify(stored),
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Dispatch a durably accepted raw envelope using only its persisted exact
|
|
2052
|
+
* action snapshots. Each recipient receives an isolated Email and session.
|
|
2053
|
+
* Queue-backed actions require managed queue storage and are replay-safe.
|
|
2054
|
+
*/
|
|
2055
|
+
public async dispatchAcceptedEnvelope(
|
|
2056
|
+
rawMessage: Uint8Array,
|
|
2057
|
+
planArg: readonly IAcceptedEnvelopeRecipientPlan[],
|
|
2058
|
+
metadataArg: IAcceptedEnvelopeDispatchMetadata,
|
|
2059
|
+
): Promise<IAcceptedEnvelopeDispatchResult> {
|
|
2060
|
+
if (!(rawMessage instanceof Uint8Array) || rawMessage.byteLength === 0) {
|
|
2061
|
+
throw new Error('Accepted envelope raw message must contain exact RFC822 bytes');
|
|
2062
|
+
}
|
|
2063
|
+
const rawCopy = Buffer.from(rawMessage);
|
|
2064
|
+
const plan = this.normalizeAcceptedEnvelopePlan(planArg);
|
|
2065
|
+
const metadata = this.cloneJsonSafe(metadataArg, 'metadata');
|
|
2066
|
+
if (!metadata?.mailFrom && metadata?.mailFrom !== '') {
|
|
2067
|
+
throw new Error('Accepted envelope metadata.mailFrom is required');
|
|
2068
|
+
}
|
|
2069
|
+
if (!this.dcRouter.storageManager.listPage) {
|
|
2070
|
+
throw new Error(
|
|
2071
|
+
'Accepted-envelope dispatch requires paginated small-state storage',
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
const hasQueueAction = plan.some((entry) => (
|
|
2075
|
+
entry.action.type === 'forward'
|
|
2076
|
+
|| entry.action.type === 'deliver'
|
|
2077
|
+
|| entry.action.type === 'process'
|
|
2078
|
+
));
|
|
2079
|
+
if (hasQueueAction) {
|
|
2080
|
+
if (!this.deliveryQueue.hasManagedStorage()) {
|
|
2081
|
+
throw new Error(
|
|
2082
|
+
'Accepted-envelope queue actions require managed queue storage',
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
if (!this.deliveryQueue.hasPaginatedManagedStorage()) {
|
|
2086
|
+
throw new Error(
|
|
2087
|
+
'Accepted-envelope queue actions require paginated managed queue storage',
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const rawDigest = plugins.crypto.createHash('sha256').update(rawCopy).digest('hex');
|
|
2093
|
+
const fingerprints = plan.map((entry) => plugins.crypto
|
|
2094
|
+
.createHash('sha256')
|
|
2095
|
+
.update(this.stableJson({
|
|
2096
|
+
rawDigest,
|
|
2097
|
+
metadata,
|
|
2098
|
+
entry,
|
|
2099
|
+
}))
|
|
2100
|
+
.digest('hex'));
|
|
2101
|
+
|
|
2102
|
+
const results: Array<IAcceptedEnvelopeRecipientDispatchResult | null> = [];
|
|
2103
|
+
for (let index = 0; index < plan.length; index++) {
|
|
2104
|
+
results.push(await this.readAcceptedEnvelopeDispatchResult(
|
|
2105
|
+
plan[index],
|
|
2106
|
+
fingerprints[index],
|
|
2107
|
+
));
|
|
2108
|
+
}
|
|
2109
|
+
if (results.every(Boolean)) {
|
|
2110
|
+
return {
|
|
2111
|
+
complete: true,
|
|
2112
|
+
results: results as IAcceptedEnvelopeRecipientDispatchResult[],
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
const baseSession: IExtendedSmtpSession = {
|
|
2117
|
+
id: metadata.session?.id || `accepted-envelope-${rawDigest.slice(0, 16)}`,
|
|
2118
|
+
state: SmtpState.FINISHED,
|
|
2119
|
+
mailFrom: metadata.mailFrom,
|
|
2120
|
+
rcptTo: plan.map((entry) => entry.recipient),
|
|
2121
|
+
emailData: '',
|
|
2122
|
+
useTLS: !!metadata.session?.secure,
|
|
2123
|
+
connectionEnded: true,
|
|
2124
|
+
remoteAddress: metadata.session?.remoteAddress || '',
|
|
2125
|
+
clientHostname: metadata.session?.clientHostname || '',
|
|
2126
|
+
secure: !!metadata.session?.secure,
|
|
2127
|
+
authenticated: !!metadata.session?.authenticated,
|
|
2128
|
+
envelope: {
|
|
2129
|
+
mailFrom: { address: metadata.mailFrom, args: {} },
|
|
2130
|
+
rcptTo: plan.map((entry) => ({ address: entry.recipient, args: {} })),
|
|
2131
|
+
},
|
|
2132
|
+
};
|
|
2133
|
+
if (metadata.session?.user) {
|
|
2134
|
+
baseSession.user = structuredClone(metadata.session.user) as IExtendedSmtpSession['user'];
|
|
2135
|
+
} else if (metadata.session?.authenticatedUser) {
|
|
2136
|
+
baseSession.user = { username: metadata.session.authenticatedUser };
|
|
2137
|
+
}
|
|
2138
|
+
let parsedEmail: Email | undefined;
|
|
2139
|
+
|
|
2140
|
+
for (let index = 0; index < plan.length; index++) {
|
|
2141
|
+
if (results[index]) continue;
|
|
2142
|
+
const entry = plan[index];
|
|
2143
|
+
const fingerprint = fingerprints[index];
|
|
2144
|
+
const commonResult = {
|
|
2145
|
+
recipient: entry.recipient,
|
|
2146
|
+
localDomain: entry.localDomain,
|
|
2147
|
+
routeName: entry.routeName,
|
|
2148
|
+
source: entry.source,
|
|
2149
|
+
actionType: entry.action.type,
|
|
2150
|
+
idempotencyKey: entry.idempotencyKey,
|
|
2151
|
+
replayed: false,
|
|
2152
|
+
} as const;
|
|
2153
|
+
|
|
2154
|
+
try {
|
|
2155
|
+
let result: IAcceptedEnvelopeRecipientDispatchResult;
|
|
2156
|
+
if (entry.action.type === 'store') {
|
|
2157
|
+
result = { ...commonResult, status: 'stored' };
|
|
2158
|
+
} else {
|
|
2159
|
+
parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
|
|
2160
|
+
const route: IEmailRoute = Object.freeze({
|
|
2161
|
+
name: entry.routeName,
|
|
2162
|
+
match: Object.freeze({ recipients: entry.recipient }),
|
|
2163
|
+
action: entry.action,
|
|
2164
|
+
});
|
|
2165
|
+
const email = this.cloneEmailForRoutingRecipient(parsedEmail, entry.recipient);
|
|
2166
|
+
const session = this.scopeSessionForRoutingRecipient(baseSession, entry.recipient);
|
|
2167
|
+
session.id = `${baseSession.id}:${index}`;
|
|
2168
|
+
session.matchedRoute = route;
|
|
2169
|
+
session.resolvedRecipientRoutes = [{
|
|
2170
|
+
recipient: entry.recipient,
|
|
2171
|
+
localDomain: entry.localDomain,
|
|
2172
|
+
route,
|
|
2173
|
+
source: entry.source,
|
|
2174
|
+
}];
|
|
2175
|
+
try {
|
|
2176
|
+
const execution = await this.actionExecutor.executeActionWithResult(
|
|
2177
|
+
entry.action,
|
|
2178
|
+
email,
|
|
2179
|
+
{ email, session },
|
|
2180
|
+
{
|
|
2181
|
+
durableQueue: {
|
|
2182
|
+
idempotencyKey: entry.idempotencyKey,
|
|
2183
|
+
fingerprint,
|
|
2184
|
+
},
|
|
2185
|
+
},
|
|
2186
|
+
);
|
|
2187
|
+
if (execution.outcome !== 'queued' || !execution.queueId) {
|
|
2188
|
+
throw new Error(
|
|
2189
|
+
`Accepted-envelope ${entry.action.type} action did not durably enqueue`,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
result = {
|
|
2193
|
+
...commonResult,
|
|
2194
|
+
status: 'queued',
|
|
2195
|
+
queueId: execution.queueId,
|
|
2196
|
+
};
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (entry.action.type !== 'reject') throw error;
|
|
2199
|
+
result = {
|
|
2200
|
+
...commonResult,
|
|
2201
|
+
status: 'rejected',
|
|
2202
|
+
smtpCode: Number((error as any)?.responseCode)
|
|
2203
|
+
|| entry.action.reject?.code
|
|
2204
|
+
|| 550,
|
|
2205
|
+
message: getErrorMessage(error),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
await this.writeAcceptedEnvelopeDispatchResult(result, fingerprint);
|
|
2210
|
+
results[index] = result;
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
results[index] = {
|
|
2213
|
+
...commonResult,
|
|
2214
|
+
status: 'failed',
|
|
2215
|
+
message: getErrorMessage(error),
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
const finalResults = results as IAcceptedEnvelopeRecipientDispatchResult[];
|
|
2221
|
+
return {
|
|
2222
|
+
complete: finalResults.every((result) => result.status !== 'failed'),
|
|
2223
|
+
results: finalResults,
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
|
|
989
2227
|
/**
|
|
990
2228
|
* Handle an emailReceived event from the Rust SMTP server.
|
|
991
2229
|
*/
|
|
@@ -1013,18 +2251,71 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1013
2251
|
mailFrom: { address: mailFrom, args: {} },
|
|
1014
2252
|
rcptTo: rcptTo.map(addr => ({ address: addr, args: {} })),
|
|
1015
2253
|
},
|
|
2254
|
+
securityResults: data.securityResults,
|
|
1016
2255
|
};
|
|
1017
2256
|
|
|
1018
2257
|
if (authenticatedUser) {
|
|
1019
2258
|
session.user = { username: authenticatedUser };
|
|
1020
2259
|
}
|
|
1021
2260
|
|
|
1022
|
-
// Attach pre-computed security results from Rust in-process pipeline
|
|
1023
|
-
if (data.securityResults) {
|
|
1024
|
-
(session as any)._precomputedSecurityResults = data.securityResults;
|
|
1025
|
-
}
|
|
1026
2261
|
|
|
1027
2262
|
const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
|
|
2263
|
+
const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
|
|
2264
|
+
const rejectedRecipients = resolvedRecipientRoutes.filter(
|
|
2265
|
+
(resolution) => !resolution.decision.accepted,
|
|
2266
|
+
);
|
|
2267
|
+
if (rejectedRecipients.length > 0) {
|
|
2268
|
+
for (const rejection of rejectedRecipients) {
|
|
2269
|
+
await this.notifyRecipientRejected(
|
|
2270
|
+
this.buildDataPolicyContext(session, rejection.recipient),
|
|
2271
|
+
rejection.decision,
|
|
2272
|
+
rejection,
|
|
2273
|
+
rejection.rejectionReason || this.getRejectionReasonForResolution(rejection),
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
const primaryRejection = rejectedRecipients[0];
|
|
2277
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
2278
|
+
correlationId,
|
|
2279
|
+
accepted: false,
|
|
2280
|
+
smtpCode: primaryRejection.decision.smtpCode || 550,
|
|
2281
|
+
smtpMessage: primaryRejection.decision.smtpMessage || 'Message not accepted',
|
|
2282
|
+
});
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const hasStoreRoute = resolvedRecipientRoutes.some(
|
|
2287
|
+
(resolution) => resolution.route?.action.type === 'store',
|
|
2288
|
+
);
|
|
2289
|
+
if (hasStoreRoute) {
|
|
2290
|
+
try {
|
|
2291
|
+
await this.acceptDurableEnvelope(
|
|
2292
|
+
email,
|
|
2293
|
+
rawMessageBuffer,
|
|
2294
|
+
session,
|
|
2295
|
+
);
|
|
2296
|
+
} catch (acceptanceError) {
|
|
2297
|
+
logger.log(
|
|
2298
|
+
'error',
|
|
2299
|
+
`Durable envelope acceptance failed: ${(acceptanceError as Error).message}`,
|
|
2300
|
+
);
|
|
2301
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
2302
|
+
correlationId,
|
|
2303
|
+
accepted: false,
|
|
2304
|
+
smtpCode: 451,
|
|
2305
|
+
smtpMessage: 'Envelope acceptance temporarily unavailable',
|
|
2306
|
+
});
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
2311
|
+
correlationId,
|
|
2312
|
+
accepted: true,
|
|
2313
|
+
smtpCode: 250,
|
|
2314
|
+
smtpMessage: '2.0.0 Message accepted for delivery',
|
|
2315
|
+
});
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1028
2319
|
let acceptanceDecision: IMessageAcceptanceDecision | undefined;
|
|
1029
2320
|
if (this.options.hooks?.onMessageData) {
|
|
1030
2321
|
let hookContext: IMessageAcceptanceContext | undefined;
|
|
@@ -1037,6 +2328,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1037
2328
|
email,
|
|
1038
2329
|
rawMessage: rawMessageBuffer,
|
|
1039
2330
|
session,
|
|
2331
|
+
matchedRoute: session.matchedRoute,
|
|
2332
|
+
resolvedRecipientRoutes: session.resolvedRecipientRoutes || [],
|
|
1040
2333
|
abortSignal,
|
|
1041
2334
|
};
|
|
1042
2335
|
return this.options.hooks!.onMessageData!(hookContext);
|
|
@@ -1257,28 +2550,28 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1257
2550
|
private async verifyInboundSecurity(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
|
1258
2551
|
try {
|
|
1259
2552
|
// Check for pre-computed results from Rust in-process security pipeline
|
|
1260
|
-
const precomputed =
|
|
1261
|
-
let result: any;
|
|
2553
|
+
const precomputed = session.securityResults;
|
|
1262
2554
|
|
|
1263
2555
|
if (precomputed) {
|
|
1264
2556
|
logger.log('info', 'Using pre-computed security results from Rust in-process pipeline');
|
|
1265
|
-
|
|
1266
|
-
|
|
2557
|
+
}
|
|
2558
|
+
const result: IInboundEmailSecurityResult | IEmailSecurityResult =
|
|
2559
|
+
precomputed || await (async () => {
|
|
1267
2560
|
// Fallback: IPC round-trip to Rust (for backward compat)
|
|
1268
2561
|
const rawMessage = session.emailData || email.toRFC822String();
|
|
1269
|
-
|
|
2562
|
+
return this.rustBridge.verifyEmail({
|
|
1270
2563
|
rawMessage,
|
|
1271
2564
|
ip: session.remoteAddress,
|
|
1272
2565
|
heloDomain: session.clientHostname || '',
|
|
1273
2566
|
hostname: this.options.hostname,
|
|
1274
2567
|
mailFrom: session.envelope?.mailFrom?.address || session.mailFrom || '',
|
|
1275
2568
|
});
|
|
1276
|
-
}
|
|
2569
|
+
})();
|
|
1277
2570
|
|
|
1278
2571
|
// Apply DKIM result headers
|
|
1279
2572
|
if (result.dkim && result.dkim.length > 0) {
|
|
1280
2573
|
const dkimSummary = result.dkim
|
|
1281
|
-
.map((d
|
|
2574
|
+
.map((d) => `${d.status}${d.domain ? ` (${d.domain})` : ''}`)
|
|
1282
2575
|
.join(', ');
|
|
1283
2576
|
email.addHeader('X-DKIM-Result', dkimSummary);
|
|
1284
2577
|
}
|
|
@@ -1308,8 +2601,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1308
2601
|
}
|
|
1309
2602
|
|
|
1310
2603
|
// Apply content scan results (from pre-computed pipeline)
|
|
1311
|
-
if (
|
|
1312
|
-
const scan =
|
|
2604
|
+
if (precomputed?.contentScan) {
|
|
2605
|
+
const scan = precomputed.contentScan;
|
|
1313
2606
|
if (scan.threatScore > 0) {
|
|
1314
2607
|
email.addHeader('X-Spam-Score', String(scan.threatScore));
|
|
1315
2608
|
if (scan.threatType) {
|
|
@@ -1323,8 +2616,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1323
2616
|
}
|
|
1324
2617
|
|
|
1325
2618
|
// Apply IP reputation results (from pre-computed pipeline)
|
|
1326
|
-
if (
|
|
1327
|
-
const rep =
|
|
2619
|
+
if (precomputed?.ipReputation) {
|
|
2620
|
+
const rep = precomputed.ipReputation;
|
|
1328
2621
|
email.addHeader('X-IP-Reputation-Score', String(rep.score));
|
|
1329
2622
|
if (rep.is_spam) {
|
|
1330
2623
|
email.mightBeSpam = true;
|