@push.rocks/smartmta 8.0.1 → 8.1.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 +35 -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 +5 -0
- package/dist_ts/index.js +6 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +15 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +91 -8
- 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/routing/classes.domain.registry.js +5 -1
- package/dist_ts/mail/routing/classes.email.action.executor.js +3 -1
- 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 +85 -3
- package/dist_ts/mail/routing/classes.unified.email.server.js +517 -60
- 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 +5 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +126 -6
- package/ts/mail/delivery/classes.delivery.system.ts +137 -41
- package/ts/mail/routing/classes.domain.registry.ts +4 -0
- package/ts/mail/routing/classes.email.action.executor.ts +4 -0
- package/ts/mail/routing/classes.email.router.ts +33 -20
- package/ts/mail/routing/classes.unified.email.server.ts +703 -72
- 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,7 +10,14 @@ 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
22
|
import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
|
16
23
|
import { Email } from '../core/classes.email.js';
|
|
@@ -35,6 +42,41 @@ interface DcRouter {
|
|
|
35
42
|
options?: any;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
export type TRecipientRouteSource = 'explicit-route' | 'domain-policy' | 'no-route';
|
|
46
|
+
export type TRecipientRejectionReason =
|
|
47
|
+
| 'explicit-route'
|
|
48
|
+
| 'domain-policy'
|
|
49
|
+
| 'no-route'
|
|
50
|
+
| 'domain-not-hosted'
|
|
51
|
+
| 'relay-denied'
|
|
52
|
+
| 'recipient-hook'
|
|
53
|
+
| 'policy-error';
|
|
54
|
+
|
|
55
|
+
export interface IResolvedRecipientRoute {
|
|
56
|
+
recipient: string;
|
|
57
|
+
localDomain: string | null;
|
|
58
|
+
route: IEmailRoute | null;
|
|
59
|
+
source: TRecipientRouteSource;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface IRecipientRejectedEvent {
|
|
63
|
+
timestamp: string;
|
|
64
|
+
sender: string;
|
|
65
|
+
recipient: string;
|
|
66
|
+
localDomain: string | null;
|
|
67
|
+
remoteAddress: string;
|
|
68
|
+
smtpCode: number;
|
|
69
|
+
smtpMessage: string;
|
|
70
|
+
routeName: string | null;
|
|
71
|
+
reason: TRecipientRejectionReason;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface IRecipientPolicyResolution extends IResolvedRecipientRoute {
|
|
75
|
+
decision: ISmtpDecision;
|
|
76
|
+
rejectionReason?: TRecipientRejectionReason;
|
|
77
|
+
policyRouteName?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
/**
|
|
39
81
|
* Extended SMTP session interface with route information
|
|
40
82
|
*/
|
|
@@ -43,6 +85,10 @@ export interface IExtendedSmtpSession extends ISmtpSession {
|
|
|
43
85
|
* Matched route for this session
|
|
44
86
|
*/
|
|
45
87
|
matchedRoute?: IEmailRoute;
|
|
88
|
+
/** Per-envelope-recipient routes resolved before DATA acceptance. */
|
|
89
|
+
resolvedRecipientRoutes?: IResolvedRecipientRoute[];
|
|
90
|
+
/** Results computed by the Rust inbound pipeline before the message event is emitted. */
|
|
91
|
+
securityResults?: IInboundEmailSecurityResult | null;
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
export interface ISmtpDecision {
|
|
@@ -68,9 +114,34 @@ export interface IMessageAcceptanceContext {
|
|
|
68
114
|
email: Email;
|
|
69
115
|
rawMessage: Buffer;
|
|
70
116
|
session: IExtendedSmtpSession;
|
|
117
|
+
matchedRoute?: IEmailRoute;
|
|
118
|
+
/** Full-envelope route resolution. */
|
|
119
|
+
resolvedRecipientRoutes: IResolvedRecipientRoute[];
|
|
71
120
|
abortSignal?: AbortSignal;
|
|
72
121
|
}
|
|
73
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Terminal durable acceptance context for an envelope containing at least one `store` route.
|
|
125
|
+
* The consumer owns persistence and all recipient fan-out before this hook resolves.
|
|
126
|
+
*/
|
|
127
|
+
export interface IAcceptEnvelopeContext {
|
|
128
|
+
email: Email;
|
|
129
|
+
/** Exact bytes received during SMTP DATA, before parsing or transformation. */
|
|
130
|
+
rawMessage: Buffer;
|
|
131
|
+
session: IExtendedSmtpSession;
|
|
132
|
+
envelope: {
|
|
133
|
+
mailFrom: string;
|
|
134
|
+
rcptTo: string[];
|
|
135
|
+
};
|
|
136
|
+
/** Complete resolved plan for every SMTP envelope recipient. */
|
|
137
|
+
resolvedRecipientRoutes: IResolvedRecipientRoute[];
|
|
138
|
+
abortSignal?: AbortSignal;
|
|
139
|
+
/** Immutable attachment count derived from the parsed message. */
|
|
140
|
+
readonly attachmentCount: number;
|
|
141
|
+
/** Rust-computed inbound verdicts, available before durable acceptance. */
|
|
142
|
+
securityResults: IInboundEmailSecurityResult | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
74
145
|
export interface IMessageAcceptanceDecision extends ISmtpDecision {
|
|
75
146
|
/** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
|
|
76
147
|
continueProcessing?: boolean;
|
|
@@ -95,6 +166,18 @@ export interface IOutboundConnectionProxy {
|
|
|
95
166
|
close?: () => Promise<void> | void;
|
|
96
167
|
}
|
|
97
168
|
|
|
169
|
+
export interface IOutboundTopologyIdentity {
|
|
170
|
+
hostnames?: string[];
|
|
171
|
+
ipAddresses?: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ISelfMxIdentityMatch {
|
|
175
|
+
isSelf: boolean;
|
|
176
|
+
source: 'explicit' | 'dns-derived' | 'dynamic' | null;
|
|
177
|
+
indeterminate: boolean;
|
|
178
|
+
evidence: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
98
181
|
/**
|
|
99
182
|
* Options for the unified email server
|
|
100
183
|
*/
|
|
@@ -159,10 +242,14 @@ export interface IUnifiedEmailServerOptions {
|
|
|
159
242
|
};
|
|
160
243
|
|
|
161
244
|
hooks?: {
|
|
162
|
-
/** Async recipient policy hook
|
|
245
|
+
/** Async recipient policy hook that may further restrict an accepted built-in decision. */
|
|
163
246
|
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
164
|
-
/** Async message acceptance hook
|
|
247
|
+
/** Async message acceptance hook for envelopes with no resolved `store` action. */
|
|
165
248
|
onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
|
|
249
|
+
/** Required terminal durable owner for any envelope containing a resolved `store` action. */
|
|
250
|
+
onAcceptEnvelope?: (context: IAcceptEnvelopeContext) => Promise<void>;
|
|
251
|
+
/** Observer-safe callback for SMTP recipient rejections. */
|
|
252
|
+
onRecipientRejected?: (event: IRecipientRejectedEvent) => Promise<void> | void;
|
|
166
253
|
};
|
|
167
254
|
|
|
168
255
|
// Global defaults for all domains
|
|
@@ -181,6 +268,10 @@ export interface IUnifiedEmailServerOptions {
|
|
|
181
268
|
socketTimeout?: number;
|
|
182
269
|
retryAttempts?: number;
|
|
183
270
|
defaultFrom?: string;
|
|
271
|
+
/** Static public listener identities that must never be selected as outbound MX targets. */
|
|
272
|
+
selfListenerIdentities?: IOutboundTopologyIdentity;
|
|
273
|
+
/** Current proven mail-edge identities. Connection proxy targets are deliberately not identities. */
|
|
274
|
+
topologyIdentityProvider?: () => Promise<IOutboundTopologyIdentity | null | undefined>;
|
|
184
275
|
connectionProxyProvider?: (context: IOutboundConnectionProxyContext) => Promise<IOutboundConnectionProxy | null | undefined>;
|
|
185
276
|
};
|
|
186
277
|
|
|
@@ -390,6 +481,18 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
390
481
|
// We'll create the SMTP servers during the start() method
|
|
391
482
|
}
|
|
392
483
|
|
|
484
|
+
public onRecipientRejected(
|
|
485
|
+
listener: (event: IRecipientRejectedEvent) => Promise<void> | void,
|
|
486
|
+
): this {
|
|
487
|
+
return super.on('recipientRejected', listener);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
public offRecipientRejected(
|
|
491
|
+
listener: (event: IRecipientRejectedEvent) => Promise<void> | void,
|
|
492
|
+
): this {
|
|
493
|
+
return super.off('recipientRejected', listener);
|
|
494
|
+
}
|
|
495
|
+
|
|
393
496
|
private getAdvertisedHostname(): string {
|
|
394
497
|
return this.options.hostname;
|
|
395
498
|
}
|
|
@@ -398,6 +501,120 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
398
501
|
return this.options.outbound?.hostname || this.options.hostname;
|
|
399
502
|
}
|
|
400
503
|
|
|
504
|
+
private normalizeTopologyIdentity(valueArg: string): string {
|
|
505
|
+
const value = valueArg.trim().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
506
|
+
if (!value) return '';
|
|
507
|
+
if (plugins.net.isIP(value)) {
|
|
508
|
+
if (plugins.net.isIPv6(value)) {
|
|
509
|
+
try {
|
|
510
|
+
return new URL(`http://[${value}]/`).hostname.slice(1, -1).toLowerCase();
|
|
511
|
+
} catch {
|
|
512
|
+
return value.toLowerCase();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
517
|
+
return plugins.url.domainToASCII(value).toLowerCase();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private getExplicitTopologyIdentity(): Required<IOutboundTopologyIdentity> {
|
|
521
|
+
const configured = this.options.outbound?.selfListenerIdentities;
|
|
522
|
+
return {
|
|
523
|
+
hostnames: [
|
|
524
|
+
this.options.hostname,
|
|
525
|
+
this.options.outbound?.hostname || '',
|
|
526
|
+
...(configured?.hostnames || []),
|
|
527
|
+
].map((value) => this.normalizeTopologyIdentity(value)).filter(Boolean),
|
|
528
|
+
ipAddresses: (configured?.ipAddresses || [])
|
|
529
|
+
.map((value) => this.normalizeTopologyIdentity(value))
|
|
530
|
+
.filter((value) => Boolean(plugins.net.isIP(value))),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async withTopologyTimeout<T>(operation: Promise<T>): Promise<T> {
|
|
535
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
536
|
+
try {
|
|
537
|
+
return await Promise.race([
|
|
538
|
+
operation,
|
|
539
|
+
new Promise<T>((_resolve, reject) => {
|
|
540
|
+
timer = setTimeout(() => reject(new Error('self-MX DNS resolution timed out')), 3_000);
|
|
541
|
+
}),
|
|
542
|
+
]);
|
|
543
|
+
} finally {
|
|
544
|
+
if (timer) clearTimeout(timer);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private async resolveTopologyHostname(hostnameArg: string): Promise<{
|
|
549
|
+
hostnames: Set<string>;
|
|
550
|
+
ipAddresses: Set<string>;
|
|
551
|
+
indeterminate: boolean;
|
|
552
|
+
evidence: string[];
|
|
553
|
+
}> {
|
|
554
|
+
const hostname = this.normalizeTopologyIdentity(hostnameArg);
|
|
555
|
+
const hostnames = new Set<string>();
|
|
556
|
+
const ipAddresses = new Set<string>();
|
|
557
|
+
const evidence: string[] = [];
|
|
558
|
+
if (!hostname) return { hostnames, ipAddresses, indeterminate: false, evidence };
|
|
559
|
+
if (plugins.net.isIP(hostname)) {
|
|
560
|
+
ipAddresses.add(hostname);
|
|
561
|
+
return { hostnames, ipAddresses, indeterminate: false, evidence };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const resolver = new plugins.dns.promises.Resolver();
|
|
565
|
+
const isAbsent = (errorArg: unknown): boolean => {
|
|
566
|
+
const code = (errorArg as NodeJS.ErrnoException)?.code;
|
|
567
|
+
return code === 'ENODATA' || code === 'ENOTFOUND';
|
|
568
|
+
};
|
|
569
|
+
let current = hostname;
|
|
570
|
+
let indeterminate = false;
|
|
571
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
572
|
+
if (hostnames.has(current)) {
|
|
573
|
+
evidence.push(`CNAME loop at ${current}`);
|
|
574
|
+
indeterminate = true;
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
hostnames.add(current);
|
|
578
|
+
let aliases: string[];
|
|
579
|
+
try {
|
|
580
|
+
aliases = await resolver.resolveCname(current);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (!isAbsent(error)) {
|
|
583
|
+
evidence.push(`CNAME lookup failed for ${current}`);
|
|
584
|
+
indeterminate = true;
|
|
585
|
+
}
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
const alias = this.normalizeTopologyIdentity(aliases[0] || '');
|
|
589
|
+
if (!alias) break;
|
|
590
|
+
evidence.push(`${current} CNAME ${alias}`);
|
|
591
|
+
current = alias;
|
|
592
|
+
if (depth === 7) {
|
|
593
|
+
evidence.push('CNAME chain exceeded 8 hops');
|
|
594
|
+
indeterminate = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const resolveAddresses = async (family: 4 | 6): Promise<{ addresses: string[]; indeterminate: boolean }> => {
|
|
599
|
+
try {
|
|
600
|
+
const values = family === 4 ? await resolver.resolve4(current) : await resolver.resolve6(current);
|
|
601
|
+
return { addresses: values, indeterminate: false };
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return { addresses: [], indeterminate: !isAbsent(error) };
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
const [ipv4, ipv6] = await Promise.all([resolveAddresses(4), resolveAddresses(6)]);
|
|
607
|
+
for (const address of [...ipv4.addresses, ...ipv6.addresses]) {
|
|
608
|
+
const normalized = this.normalizeTopologyIdentity(address);
|
|
609
|
+
if (normalized) ipAddresses.add(normalized);
|
|
610
|
+
}
|
|
611
|
+
if (ipv4.indeterminate || ipv6.indeterminate) {
|
|
612
|
+
evidence.push(`address lookup failed for ${current}`);
|
|
613
|
+
indeterminate = true;
|
|
614
|
+
}
|
|
615
|
+
return { hostnames, ipAddresses, indeterminate, evidence };
|
|
616
|
+
}
|
|
617
|
+
|
|
401
618
|
/**
|
|
402
619
|
* Whether a hostname refers to this server's own SMTP identity.
|
|
403
620
|
* Used by MTA delivery to refuse self-addressed relaying: a domain whose
|
|
@@ -405,13 +622,115 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
405
622
|
* full outbound/inbound pipeline forever.
|
|
406
623
|
*/
|
|
407
624
|
public isSelfHostname(hostname: string): boolean {
|
|
408
|
-
const
|
|
409
|
-
const candidate = normalize(hostname);
|
|
625
|
+
const candidate = this.normalizeTopologyIdentity(hostname);
|
|
410
626
|
if (!candidate) return false;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
627
|
+
const explicit = this.getExplicitTopologyIdentity();
|
|
628
|
+
return explicit.hostnames.includes(candidate) || explicit.ipAddresses.includes(candidate);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Resolve a candidate MX against explicit and current proven listener topology. */
|
|
632
|
+
public async resolveSelfMxIdentity(hostnameArg: string): Promise<ISelfMxIdentityMatch> {
|
|
633
|
+
const candidate = this.normalizeTopologyIdentity(hostnameArg);
|
|
634
|
+
const evidence: string[] = [];
|
|
635
|
+
if (!candidate) {
|
|
636
|
+
return { isSelf: false, source: null, indeterminate: true, evidence: ['empty MX identity'] };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const explicit = this.getExplicitTopologyIdentity();
|
|
640
|
+
if (explicit.hostnames.includes(candidate) || explicit.ipAddresses.includes(candidate)) {
|
|
641
|
+
return { isSelf: true, source: 'explicit', indeterminate: false, evidence: [`exact identity ${candidate}`] };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
let dynamic: Required<IOutboundTopologyIdentity> = { hostnames: [], ipAddresses: [] };
|
|
645
|
+
try {
|
|
646
|
+
const topologyIdentityProvider = this.options.outbound?.topologyIdentityProvider;
|
|
647
|
+
const provided = topologyIdentityProvider
|
|
648
|
+
? await this.withTopologyTimeout(topologyIdentityProvider())
|
|
649
|
+
: undefined;
|
|
650
|
+
if (topologyIdentityProvider && provided == null) {
|
|
651
|
+
return {
|
|
652
|
+
isSelf: false,
|
|
653
|
+
source: null,
|
|
654
|
+
indeterminate: true,
|
|
655
|
+
evidence: ['dynamic topology identity provider returned no proof'],
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
dynamic = {
|
|
659
|
+
hostnames: (provided?.hostnames || [])
|
|
660
|
+
.map((value) => this.normalizeTopologyIdentity(value)).filter(Boolean),
|
|
661
|
+
ipAddresses: (provided?.ipAddresses || [])
|
|
662
|
+
.map((value) => this.normalizeTopologyIdentity(value))
|
|
663
|
+
.filter((value) => Boolean(plugins.net.isIP(value))),
|
|
664
|
+
};
|
|
665
|
+
if (
|
|
666
|
+
topologyIdentityProvider
|
|
667
|
+
&& dynamic.hostnames.length === 0
|
|
668
|
+
&& dynamic.ipAddresses.length === 0
|
|
669
|
+
) {
|
|
670
|
+
return {
|
|
671
|
+
isSelf: false,
|
|
672
|
+
source: null,
|
|
673
|
+
indeterminate: true,
|
|
674
|
+
evidence: ['dynamic topology identity provider returned no valid proof'],
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
} catch {
|
|
678
|
+
return { isSelf: false, source: null, indeterminate: true, evidence: ['dynamic topology identity unavailable'] };
|
|
679
|
+
}
|
|
680
|
+
if (dynamic.hostnames.includes(candidate) || dynamic.ipAddresses.includes(candidate)) {
|
|
681
|
+
return { isSelf: true, source: 'dynamic', indeterminate: false, evidence: [`dynamic identity ${candidate}`] };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
const candidateResolution = await this.withTopologyTimeout(this.resolveTopologyHostname(candidate));
|
|
686
|
+
evidence.push(...candidateResolution.evidence);
|
|
687
|
+
const intersects = (left: Set<string>, right: Iterable<string>): string | undefined => {
|
|
688
|
+
for (const value of right) if (left.has(value)) return value;
|
|
689
|
+
return undefined;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const explicitHostname = intersects(candidateResolution.hostnames, explicit.hostnames);
|
|
693
|
+
const explicitIp = intersects(candidateResolution.ipAddresses, explicit.ipAddresses);
|
|
694
|
+
if (explicitHostname || explicitIp) {
|
|
695
|
+
evidence.push(`resolved to explicit identity ${explicitHostname || explicitIp}`);
|
|
696
|
+
return { isSelf: true, source: 'dns-derived', indeterminate: false, evidence };
|
|
697
|
+
}
|
|
698
|
+
const dynamicHostname = intersects(candidateResolution.hostnames, dynamic.hostnames);
|
|
699
|
+
const dynamicIp = intersects(candidateResolution.ipAddresses, dynamic.ipAddresses);
|
|
700
|
+
if (dynamicHostname || dynamicIp) {
|
|
701
|
+
evidence.push(`resolved to dynamic identity ${dynamicHostname || dynamicIp}`);
|
|
702
|
+
return { isSelf: true, source: 'dynamic', indeterminate: false, evidence };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const identities = [
|
|
706
|
+
...explicit.hostnames.map((hostname) => ({ hostname, source: 'explicit' as const })),
|
|
707
|
+
...dynamic.hostnames.map((hostname) => ({ hostname, source: 'dynamic' as const })),
|
|
708
|
+
];
|
|
709
|
+
for (const identity of identities) {
|
|
710
|
+
const resolved = await this.withTopologyTimeout(this.resolveTopologyHostname(identity.hostname));
|
|
711
|
+
const matchedIp = intersects(candidateResolution.ipAddresses, resolved.ipAddresses);
|
|
712
|
+
if (matchedIp) {
|
|
713
|
+
evidence.push(`shares address ${matchedIp} with ${identity.hostname}`);
|
|
714
|
+
return {
|
|
715
|
+
isSelf: true,
|
|
716
|
+
source: identity.source === 'explicit' ? 'dns-derived' : 'dynamic',
|
|
717
|
+
indeterminate: false,
|
|
718
|
+
evidence,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if (resolved.indeterminate) {
|
|
722
|
+
evidence.push(...resolved.evidence);
|
|
723
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (candidateResolution.indeterminate) {
|
|
727
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
728
|
+
}
|
|
729
|
+
return { isSelf: false, source: null, indeterminate: false, evidence };
|
|
730
|
+
} catch (error) {
|
|
731
|
+
evidence.push((error as Error).message);
|
|
732
|
+
return { isSelf: false, source: null, indeterminate: true, evidence };
|
|
733
|
+
}
|
|
415
734
|
}
|
|
416
735
|
|
|
417
736
|
/**
|
|
@@ -455,6 +774,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
455
774
|
text: email.text || '',
|
|
456
775
|
html: email.html || undefined,
|
|
457
776
|
headers: email.headers as Record<string, string> || {},
|
|
777
|
+
attachments: email.attachments.map((attachment) => ({
|
|
778
|
+
filename: attachment.filename,
|
|
779
|
+
contentBase64: attachment.content.toString('base64'),
|
|
780
|
+
contentType: attachment.contentType,
|
|
781
|
+
...(attachment.contentId ? { contentId: attachment.contentId } : {}),
|
|
782
|
+
})),
|
|
458
783
|
};
|
|
459
784
|
|
|
460
785
|
const secure = port === 465;
|
|
@@ -740,7 +1065,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
740
1065
|
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
|
741
1066
|
maxAuthFailures: 3,
|
|
742
1067
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
743
|
-
processingTimeoutSecs: this.options.hooks?.onMessageData
|
|
1068
|
+
processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
|
|
744
1069
|
? Math.ceil(this.getMessageAcceptanceTimeoutMs() / 1000) + 1
|
|
745
1070
|
: 30,
|
|
746
1071
|
maxConcurrentMessages: this.options.smtp?.maxConcurrentMessages ?? 4,
|
|
@@ -803,6 +1128,9 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
803
1128
|
this.bounceManager.stop();
|
|
804
1129
|
logger.log('info', 'Bounce manager stopped');
|
|
805
1130
|
|
|
1131
|
+
this.rateLimiter.stop();
|
|
1132
|
+
logger.log('info', 'Rate limiter stopped');
|
|
1133
|
+
|
|
806
1134
|
// Close all Rust SMTP client connection pools
|
|
807
1135
|
try {
|
|
808
1136
|
await this.rustBridge.closeSmtpPool();
|
|
@@ -835,16 +1163,47 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
835
1163
|
authenticatedUser: data.authenticatedUser,
|
|
836
1164
|
};
|
|
837
1165
|
|
|
838
|
-
const
|
|
839
|
-
|
|
1166
|
+
const resolution = await this.resolveRecipientPolicy(context);
|
|
1167
|
+
let decision = resolution.decision;
|
|
1168
|
+
if (resolution.decision.accepted && this.options.hooks?.onRcptTo) {
|
|
1169
|
+
try {
|
|
1170
|
+
decision = await this.runHookWithTimeout(
|
|
840
1171
|
this.getRecipientValidationTimeoutMs(),
|
|
841
1172
|
'Recipient policy timeout',
|
|
842
|
-
(abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal })
|
|
843
|
-
)
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1173
|
+
(abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal }),
|
|
1174
|
+
);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
logger.log(
|
|
1177
|
+
'error',
|
|
1178
|
+
`Recipient policy callback failed for ${context.rcptTo}: ${getErrorMessage(error)}`,
|
|
1179
|
+
);
|
|
1180
|
+
decision = {
|
|
1181
|
+
accepted: false,
|
|
1182
|
+
smtpCode: 451,
|
|
1183
|
+
smtpMessage: 'Recipient policy error',
|
|
1184
|
+
};
|
|
1185
|
+
await this.notifyRecipientRejected(context, decision, resolution, 'policy-error');
|
|
1186
|
+
await this.rustBridge.sendRcptToResult({
|
|
1187
|
+
correlationId: data.correlationId,
|
|
1188
|
+
accepted: false,
|
|
1189
|
+
smtpCode: 451,
|
|
1190
|
+
smtpMessage: 'Recipient policy error',
|
|
1191
|
+
});
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (!decision.accepted) {
|
|
1197
|
+
const rejectedByHook = resolution.decision.accepted;
|
|
1198
|
+
await this.notifyRecipientRejected(
|
|
1199
|
+
context,
|
|
1200
|
+
decision,
|
|
1201
|
+
resolution,
|
|
1202
|
+
rejectedByHook
|
|
1203
|
+
? 'recipient-hook'
|
|
1204
|
+
: resolution.rejectionReason || this.getRejectionReasonForResolution(resolution),
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
848
1207
|
|
|
849
1208
|
await this.rustBridge.sendRcptToResult({
|
|
850
1209
|
correlationId: data.correlationId,
|
|
@@ -855,65 +1214,158 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
855
1214
|
}
|
|
856
1215
|
|
|
857
1216
|
private async evaluateDefaultRcptToPolicy(context: IRcptToPolicyContext): Promise<ISmtpDecision> {
|
|
1217
|
+
return (await this.resolveRecipientPolicy(context)).decision;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private async resolveRecipientPolicy(
|
|
1221
|
+
context: IRcptToPolicyContext,
|
|
1222
|
+
emailArg?: Email,
|
|
1223
|
+
sessionArg?: IExtendedSmtpSession,
|
|
1224
|
+
): Promise<IRecipientPolicyResolution> {
|
|
858
1225
|
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
859
|
-
const email = new Email({
|
|
1226
|
+
const email = emailArg || new Email({
|
|
860
1227
|
from: context.mailFrom || '<>',
|
|
861
1228
|
to: [context.rcptTo],
|
|
862
1229
|
subject: '',
|
|
863
1230
|
text: '',
|
|
864
1231
|
});
|
|
865
|
-
const session = this.buildPolicySession(currentRecipientContext);
|
|
1232
|
+
const session = sessionArg || this.buildPolicySession(currentRecipientContext);
|
|
866
1233
|
const route = await this.emailRouter.evaluateRoutes({ email, session });
|
|
1234
|
+
const recipientDomain = this.getAddressDomain(context.rcptTo);
|
|
1235
|
+
const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
|
|
1236
|
+
const localDomain = isLocalRecipient ? recipientDomain! : null;
|
|
1237
|
+
|
|
1238
|
+
if (route) {
|
|
1239
|
+
if (route.action.type === 'reject') {
|
|
1240
|
+
return {
|
|
1241
|
+
recipient: context.rcptTo,
|
|
1242
|
+
localDomain,
|
|
1243
|
+
route,
|
|
1244
|
+
source: 'explicit-route',
|
|
1245
|
+
decision: {
|
|
1246
|
+
accepted: false,
|
|
1247
|
+
smtpCode: route.action.reject?.code || 550,
|
|
1248
|
+
smtpMessage: route.action.reject?.message || 'Recipient rejected',
|
|
1249
|
+
},
|
|
1250
|
+
rejectionReason: 'explicit-route',
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!isLocalRecipient && !route.action.allowRelay) {
|
|
1255
|
+
return {
|
|
1256
|
+
recipient: context.rcptTo,
|
|
1257
|
+
localDomain: null,
|
|
1258
|
+
route,
|
|
1259
|
+
source: 'explicit-route',
|
|
1260
|
+
decision: { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' },
|
|
1261
|
+
rejectionReason: 'relay-denied',
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
867
1264
|
|
|
868
|
-
if (!route) {
|
|
869
|
-
return { accepted: false, smtpCode: 550, smtpMessage: 'No route for recipient' };
|
|
870
|
-
}
|
|
871
|
-
if (route.action.type === 'reject') {
|
|
872
1265
|
return {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1266
|
+
recipient: context.rcptTo,
|
|
1267
|
+
localDomain,
|
|
1268
|
+
route,
|
|
1269
|
+
source: 'explicit-route',
|
|
1270
|
+
decision: { accepted: true, smtpCode: 250, smtpMessage: 'OK' },
|
|
876
1271
|
};
|
|
877
1272
|
}
|
|
878
1273
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1274
|
+
if (!isLocalRecipient) {
|
|
1275
|
+
return {
|
|
1276
|
+
recipient: context.rcptTo,
|
|
1277
|
+
localDomain: null,
|
|
1278
|
+
route: null,
|
|
1279
|
+
source: 'no-route',
|
|
1280
|
+
decision: { accepted: false, smtpCode: 550, smtpMessage: 'domain not hosted here' },
|
|
1281
|
+
rejectionReason: 'domain-not-hosted',
|
|
1282
|
+
};
|
|
883
1283
|
}
|
|
884
1284
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1285
|
+
const inboundPolicy = this.domainRegistry.getDomainConfig(recipientDomain!)?.inboundPolicy;
|
|
1286
|
+
if (!inboundPolicy) {
|
|
1287
|
+
return {
|
|
1288
|
+
recipient: context.rcptTo,
|
|
1289
|
+
localDomain,
|
|
1290
|
+
route: null,
|
|
1291
|
+
source: 'no-route',
|
|
1292
|
+
decision: {
|
|
1293
|
+
accepted: false,
|
|
1294
|
+
smtpCode: 550,
|
|
1295
|
+
smtpMessage: `no mailbox configured for ${context.rcptTo}`,
|
|
1296
|
+
},
|
|
1297
|
+
rejectionReason: 'no-route',
|
|
1298
|
+
};
|
|
894
1299
|
}
|
|
895
1300
|
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1301
|
+
const policyRouteName = `__smartmta_inbound_${inboundPolicy.defaultAction}:${recipientDomain}`;
|
|
1302
|
+
if (inboundPolicy.defaultAction === 'reject') {
|
|
1303
|
+
return {
|
|
1304
|
+
recipient: context.rcptTo,
|
|
1305
|
+
localDomain,
|
|
1306
|
+
route: null,
|
|
1307
|
+
source: 'domain-policy',
|
|
1308
|
+
decision: {
|
|
1309
|
+
accepted: false,
|
|
1310
|
+
smtpCode: 550,
|
|
1311
|
+
smtpMessage: `no mailbox configured for ${context.rcptTo}`,
|
|
1312
|
+
},
|
|
1313
|
+
rejectionReason: 'domain-policy',
|
|
1314
|
+
policyRouteName,
|
|
1315
|
+
};
|
|
900
1316
|
}
|
|
901
1317
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1318
|
+
return {
|
|
1319
|
+
recipient: context.rcptTo,
|
|
1320
|
+
localDomain,
|
|
1321
|
+
route: {
|
|
1322
|
+
name: policyRouteName,
|
|
1323
|
+
match: { recipients: `*@${recipientDomain}` },
|
|
1324
|
+
action: {
|
|
1325
|
+
type: 'store',
|
|
1326
|
+
store: { retentionDays: inboundPolicy.retentionDays },
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
source: 'domain-policy',
|
|
1330
|
+
decision: { accepted: true, smtpCode: 250, smtpMessage: 'OK' },
|
|
1331
|
+
policyRouteName,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
911
1334
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1335
|
+
private getRejectionReasonForResolution(
|
|
1336
|
+
resolution: IRecipientPolicyResolution,
|
|
1337
|
+
): TRecipientRejectionReason {
|
|
1338
|
+
if (resolution.source === 'explicit-route') return 'explicit-route';
|
|
1339
|
+
if (resolution.source === 'domain-policy') return 'domain-policy';
|
|
1340
|
+
return resolution.localDomain ? 'no-route' : 'domain-not-hosted';
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
private async notifyRecipientRejected(
|
|
1344
|
+
context: IRcptToPolicyContext,
|
|
1345
|
+
decision: ISmtpDecision,
|
|
1346
|
+
resolution: IRecipientPolicyResolution,
|
|
1347
|
+
reason: TRecipientRejectionReason,
|
|
1348
|
+
): Promise<void> {
|
|
1349
|
+
const event: IRecipientRejectedEvent = {
|
|
1350
|
+
timestamp: new Date().toISOString(),
|
|
1351
|
+
sender: context.mailFrom,
|
|
1352
|
+
recipient: context.rcptTo,
|
|
1353
|
+
localDomain: resolution.localDomain,
|
|
1354
|
+
remoteAddress: context.remoteAddress,
|
|
1355
|
+
smtpCode: decision.smtpCode || 550,
|
|
1356
|
+
smtpMessage: decision.smtpMessage || 'Recipient rejected',
|
|
1357
|
+
routeName: resolution.route?.name || resolution.policyRouteName || null,
|
|
1358
|
+
reason,
|
|
1359
|
+
};
|
|
915
1360
|
|
|
916
|
-
|
|
1361
|
+
if (this.options.hooks?.onRecipientRejected) {
|
|
1362
|
+
await callObserverSafely(
|
|
1363
|
+
'recipientRejected callback',
|
|
1364
|
+
this.options.hooks.onRecipientRejected,
|
|
1365
|
+
event,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
await emitSafely(this, 'recipientRejected', event);
|
|
917
1369
|
}
|
|
918
1370
|
|
|
919
1371
|
private buildPolicySession(context: IRcptToPolicyContext): IExtendedSmtpSession {
|
|
@@ -986,6 +1438,130 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
986
1438
|
}
|
|
987
1439
|
}
|
|
988
1440
|
|
|
1441
|
+
/** Build a route-evaluation view containing only the current SMTP envelope recipient. */
|
|
1442
|
+
private cloneEmailForRoutingRecipient(email: Email, recipient: string): Email {
|
|
1443
|
+
return new Email({
|
|
1444
|
+
from: email.from,
|
|
1445
|
+
to: [recipient],
|
|
1446
|
+
cc: [],
|
|
1447
|
+
bcc: [],
|
|
1448
|
+
subject: email.subject,
|
|
1449
|
+
text: email.text,
|
|
1450
|
+
html: email.html,
|
|
1451
|
+
attachments: email.attachments,
|
|
1452
|
+
headers: { ...email.headers },
|
|
1453
|
+
mightBeSpam: email.mightBeSpam,
|
|
1454
|
+
priority: email.priority,
|
|
1455
|
+
variables: { ...email.variables },
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
private scopeSessionForRoutingRecipient(
|
|
1460
|
+
session: IExtendedSmtpSession,
|
|
1461
|
+
recipient: string,
|
|
1462
|
+
): IExtendedSmtpSession {
|
|
1463
|
+
return {
|
|
1464
|
+
...session,
|
|
1465
|
+
rcptTo: [recipient],
|
|
1466
|
+
envelope: {
|
|
1467
|
+
mailFrom: { ...session.envelope.mailFrom },
|
|
1468
|
+
rcptTo: [{ address: recipient, args: {} }],
|
|
1469
|
+
},
|
|
1470
|
+
matchedRoute: undefined,
|
|
1471
|
+
resolvedRecipientRoutes: undefined,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
private buildDataPolicyContext(
|
|
1476
|
+
session: IExtendedSmtpSession,
|
|
1477
|
+
recipient: string,
|
|
1478
|
+
): IRcptToPolicyContext {
|
|
1479
|
+
return {
|
|
1480
|
+
sessionId: session.id,
|
|
1481
|
+
mailFrom: session.envelope.mailFrom.address,
|
|
1482
|
+
rcptTo: recipient,
|
|
1483
|
+
acceptedRcptTo: session.envelope.rcptTo
|
|
1484
|
+
.map((entry) => entry.address)
|
|
1485
|
+
.filter((address) => address !== recipient),
|
|
1486
|
+
remoteAddress: session.remoteAddress || '',
|
|
1487
|
+
clientHostname: session.clientHostname || null,
|
|
1488
|
+
secure: !!session.secure,
|
|
1489
|
+
authenticated: !!session.authenticated,
|
|
1490
|
+
authenticatedUser: session.user?.username || null,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private async resolveDataRecipientRoutes(
|
|
1495
|
+
email: Email,
|
|
1496
|
+
session: IExtendedSmtpSession,
|
|
1497
|
+
): Promise<IRecipientPolicyResolution[]> {
|
|
1498
|
+
const resolutions: IRecipientPolicyResolution[] = [];
|
|
1499
|
+
for (const envelopeRecipient of session.envelope.rcptTo) {
|
|
1500
|
+
const recipient = envelopeRecipient.address;
|
|
1501
|
+
const scopedEmail = this.cloneEmailForRoutingRecipient(email, recipient);
|
|
1502
|
+
const scopedSession = this.scopeSessionForRoutingRecipient(session, recipient);
|
|
1503
|
+
resolutions.push(await this.resolveRecipientPolicy(
|
|
1504
|
+
this.buildDataPolicyContext(session, recipient),
|
|
1505
|
+
scopedEmail,
|
|
1506
|
+
scopedSession,
|
|
1507
|
+
));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
session.resolvedRecipientRoutes = resolutions.map((resolution) => ({
|
|
1511
|
+
recipient: resolution.recipient,
|
|
1512
|
+
localDomain: resolution.localDomain,
|
|
1513
|
+
route: resolution.route,
|
|
1514
|
+
source: resolution.source,
|
|
1515
|
+
}));
|
|
1516
|
+
const routeNames = new Set(
|
|
1517
|
+
session.resolvedRecipientRoutes.map((resolution) => resolution.route?.name || ''),
|
|
1518
|
+
);
|
|
1519
|
+
if (routeNames.size === 1) {
|
|
1520
|
+
session.matchedRoute = session.resolvedRecipientRoutes[0]?.route || undefined;
|
|
1521
|
+
}
|
|
1522
|
+
return resolutions;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
private async acceptDurableEnvelope(
|
|
1526
|
+
email: Email,
|
|
1527
|
+
rawMessage: Buffer,
|
|
1528
|
+
session: IExtendedSmtpSession,
|
|
1529
|
+
): Promise<void> {
|
|
1530
|
+
const onAcceptEnvelope = this.options.hooks?.onAcceptEnvelope;
|
|
1531
|
+
if (!onAcceptEnvelope) {
|
|
1532
|
+
throw new Error('No durable onAcceptEnvelope hook is configured');
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
let hookContext: IAcceptEnvelopeContext | undefined;
|
|
1536
|
+
await this.runHookWithTimeout(
|
|
1537
|
+
this.getMessageAcceptanceTimeoutMs(),
|
|
1538
|
+
'Envelope acceptance timeout',
|
|
1539
|
+
(abortSignal) => {
|
|
1540
|
+
hookContext = {
|
|
1541
|
+
email,
|
|
1542
|
+
rawMessage,
|
|
1543
|
+
session,
|
|
1544
|
+
envelope: {
|
|
1545
|
+
mailFrom: session.envelope.mailFrom.address,
|
|
1546
|
+
rcptTo: session.envelope.rcptTo.map((recipient) => recipient.address),
|
|
1547
|
+
},
|
|
1548
|
+
resolvedRecipientRoutes: session.resolvedRecipientRoutes || [],
|
|
1549
|
+
attachmentCount: email.attachments.length,
|
|
1550
|
+
securityResults: session.securityResults ?? null,
|
|
1551
|
+
abortSignal,
|
|
1552
|
+
};
|
|
1553
|
+
return onAcceptEnvelope(hookContext);
|
|
1554
|
+
},
|
|
1555
|
+
() => {
|
|
1556
|
+
if (hookContext) {
|
|
1557
|
+
hookContext.rawMessage = Buffer.alloc(0);
|
|
1558
|
+
(hookContext as any).email = undefined;
|
|
1559
|
+
(hookContext as any).session = undefined;
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
989
1565
|
/**
|
|
990
1566
|
* Handle an emailReceived event from the Rust SMTP server.
|
|
991
1567
|
*/
|
|
@@ -1013,18 +1589,71 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1013
1589
|
mailFrom: { address: mailFrom, args: {} },
|
|
1014
1590
|
rcptTo: rcptTo.map(addr => ({ address: addr, args: {} })),
|
|
1015
1591
|
},
|
|
1592
|
+
securityResults: data.securityResults,
|
|
1016
1593
|
};
|
|
1017
1594
|
|
|
1018
1595
|
if (authenticatedUser) {
|
|
1019
1596
|
session.user = { username: authenticatedUser };
|
|
1020
1597
|
}
|
|
1021
1598
|
|
|
1022
|
-
// Attach pre-computed security results from Rust in-process pipeline
|
|
1023
|
-
if (data.securityResults) {
|
|
1024
|
-
(session as any)._precomputedSecurityResults = data.securityResults;
|
|
1025
|
-
}
|
|
1026
1599
|
|
|
1027
1600
|
const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
|
|
1601
|
+
const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
|
|
1602
|
+
const rejectedRecipients = resolvedRecipientRoutes.filter(
|
|
1603
|
+
(resolution) => !resolution.decision.accepted,
|
|
1604
|
+
);
|
|
1605
|
+
if (rejectedRecipients.length > 0) {
|
|
1606
|
+
for (const rejection of rejectedRecipients) {
|
|
1607
|
+
await this.notifyRecipientRejected(
|
|
1608
|
+
this.buildDataPolicyContext(session, rejection.recipient),
|
|
1609
|
+
rejection.decision,
|
|
1610
|
+
rejection,
|
|
1611
|
+
rejection.rejectionReason || this.getRejectionReasonForResolution(rejection),
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
const primaryRejection = rejectedRecipients[0];
|
|
1615
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
1616
|
+
correlationId,
|
|
1617
|
+
accepted: false,
|
|
1618
|
+
smtpCode: primaryRejection.decision.smtpCode || 550,
|
|
1619
|
+
smtpMessage: primaryRejection.decision.smtpMessage || 'Message not accepted',
|
|
1620
|
+
});
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const hasStoreRoute = resolvedRecipientRoutes.some(
|
|
1625
|
+
(resolution) => resolution.route?.action.type === 'store',
|
|
1626
|
+
);
|
|
1627
|
+
if (hasStoreRoute) {
|
|
1628
|
+
try {
|
|
1629
|
+
await this.acceptDurableEnvelope(
|
|
1630
|
+
email,
|
|
1631
|
+
rawMessageBuffer,
|
|
1632
|
+
session,
|
|
1633
|
+
);
|
|
1634
|
+
} catch (acceptanceError) {
|
|
1635
|
+
logger.log(
|
|
1636
|
+
'error',
|
|
1637
|
+
`Durable envelope acceptance failed: ${(acceptanceError as Error).message}`,
|
|
1638
|
+
);
|
|
1639
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
1640
|
+
correlationId,
|
|
1641
|
+
accepted: false,
|
|
1642
|
+
smtpCode: 451,
|
|
1643
|
+
smtpMessage: 'Envelope acceptance temporarily unavailable',
|
|
1644
|
+
});
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
1649
|
+
correlationId,
|
|
1650
|
+
accepted: true,
|
|
1651
|
+
smtpCode: 250,
|
|
1652
|
+
smtpMessage: '2.0.0 Message accepted for delivery',
|
|
1653
|
+
});
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1028
1657
|
let acceptanceDecision: IMessageAcceptanceDecision | undefined;
|
|
1029
1658
|
if (this.options.hooks?.onMessageData) {
|
|
1030
1659
|
let hookContext: IMessageAcceptanceContext | undefined;
|
|
@@ -1037,6 +1666,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1037
1666
|
email,
|
|
1038
1667
|
rawMessage: rawMessageBuffer,
|
|
1039
1668
|
session,
|
|
1669
|
+
matchedRoute: session.matchedRoute,
|
|
1670
|
+
resolvedRecipientRoutes: session.resolvedRecipientRoutes || [],
|
|
1040
1671
|
abortSignal,
|
|
1041
1672
|
};
|
|
1042
1673
|
return this.options.hooks!.onMessageData!(hookContext);
|
|
@@ -1257,28 +1888,28 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1257
1888
|
private async verifyInboundSecurity(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
|
1258
1889
|
try {
|
|
1259
1890
|
// Check for pre-computed results from Rust in-process security pipeline
|
|
1260
|
-
const precomputed =
|
|
1261
|
-
let result: any;
|
|
1891
|
+
const precomputed = session.securityResults;
|
|
1262
1892
|
|
|
1263
1893
|
if (precomputed) {
|
|
1264
1894
|
logger.log('info', 'Using pre-computed security results from Rust in-process pipeline');
|
|
1265
|
-
|
|
1266
|
-
|
|
1895
|
+
}
|
|
1896
|
+
const result: IInboundEmailSecurityResult | IEmailSecurityResult =
|
|
1897
|
+
precomputed || await (async () => {
|
|
1267
1898
|
// Fallback: IPC round-trip to Rust (for backward compat)
|
|
1268
1899
|
const rawMessage = session.emailData || email.toRFC822String();
|
|
1269
|
-
|
|
1900
|
+
return this.rustBridge.verifyEmail({
|
|
1270
1901
|
rawMessage,
|
|
1271
1902
|
ip: session.remoteAddress,
|
|
1272
1903
|
heloDomain: session.clientHostname || '',
|
|
1273
1904
|
hostname: this.options.hostname,
|
|
1274
1905
|
mailFrom: session.envelope?.mailFrom?.address || session.mailFrom || '',
|
|
1275
1906
|
});
|
|
1276
|
-
}
|
|
1907
|
+
})();
|
|
1277
1908
|
|
|
1278
1909
|
// Apply DKIM result headers
|
|
1279
1910
|
if (result.dkim && result.dkim.length > 0) {
|
|
1280
1911
|
const dkimSummary = result.dkim
|
|
1281
|
-
.map((d
|
|
1912
|
+
.map((d) => `${d.status}${d.domain ? ` (${d.domain})` : ''}`)
|
|
1282
1913
|
.join(', ');
|
|
1283
1914
|
email.addHeader('X-DKIM-Result', dkimSummary);
|
|
1284
1915
|
}
|
|
@@ -1308,8 +1939,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1308
1939
|
}
|
|
1309
1940
|
|
|
1310
1941
|
// Apply content scan results (from pre-computed pipeline)
|
|
1311
|
-
if (
|
|
1312
|
-
const scan =
|
|
1942
|
+
if (precomputed?.contentScan) {
|
|
1943
|
+
const scan = precomputed.contentScan;
|
|
1313
1944
|
if (scan.threatScore > 0) {
|
|
1314
1945
|
email.addHeader('X-Spam-Score', String(scan.threatScore));
|
|
1315
1946
|
if (scan.threatType) {
|
|
@@ -1323,8 +1954,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1323
1954
|
}
|
|
1324
1955
|
|
|
1325
1956
|
// Apply IP reputation results (from pre-computed pipeline)
|
|
1326
|
-
if (
|
|
1327
|
-
const rep =
|
|
1957
|
+
if (precomputed?.ipReputation) {
|
|
1958
|
+
const rep = precomputed.ipReputation;
|
|
1328
1959
|
email.addHeader('X-IP-Reputation-Score', String(rep.score));
|
|
1329
1960
|
if (rep.is_spam) {
|
|
1330
1961
|
email.mightBeSpam = true;
|