@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.
Files changed (43) hide show
  1. package/changelog.md +51 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/index.d.ts +6 -0
  6. package/dist_ts/index.js +7 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +43 -3
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +310 -10
  9. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -2
  10. package/dist_ts/mail/delivery/classes.delivery.system.js +106 -36
  11. package/dist_ts/mail/interfaces.storage.d.ts +34 -0
  12. package/dist_ts/mail/interfaces.storage.js +89 -3
  13. package/dist_ts/mail/routing/classes.domain.registry.js +5 -1
  14. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
  15. package/dist_ts/mail/routing/classes.email.action.executor.js +38 -13
  16. package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
  17. package/dist_ts/mail/routing/classes.email.router.js +28 -21
  18. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +156 -4
  19. package/dist_ts/mail/routing/classes.unified.email.server.js +1001 -61
  20. package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
  21. package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
  22. package/dist_ts/mail/routing/interfaces.d.ts +11 -1
  23. package/dist_ts/plugins.d.ts +2 -1
  24. package/dist_ts/plugins.js +3 -2
  25. package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
  26. package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
  27. package/dist_ts/security/index.d.ts +1 -1
  28. package/dist_ts/security/index.js +1 -1
  29. package/package.json +1 -1
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/index.ts +6 -0
  32. package/ts/mail/delivery/classes.delivery.queue.ts +429 -7
  33. package/ts/mail/delivery/classes.delivery.system.ts +137 -41
  34. package/ts/mail/interfaces.storage.ts +148 -2
  35. package/ts/mail/routing/classes.domain.registry.ts +4 -0
  36. package/ts/mail/routing/classes.email.action.executor.ts +97 -13
  37. package/ts/mail/routing/classes.email.router.ts +33 -20
  38. package/ts/mail/routing/classes.unified.email.server.ts +1367 -74
  39. package/ts/mail/routing/functions.routing.validation.ts +36 -0
  40. package/ts/mail/routing/interfaces.ts +13 -1
  41. package/ts/plugins.ts +2 -0
  42. package/ts/security/classes.rustsecuritybridge.ts +46 -1
  43. 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 { IEmailReceivedEvent, IRcptToRequestEvent, IAuthRequestEvent, IScramCredentialRequestEvent } from '../../security/classes.rustsecuritybridge.js';
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 { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
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 { UnifiedDeliveryQueue, type IQueueItem, type IQueueOptions, type IQueueStats } from '../delivery/classes.delivery.queue.js';
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, called before each RCPT TO is accepted. */
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, called after DATA before the final SMTP response. */
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 normalize = (name: string) => name.toLowerCase().replace(/\.$/, '');
409
- const candidate = normalize(hostname);
707
+ const candidate = this.normalizeTopologyIdentity(hostname);
410
708
  if (!candidate) return false;
411
- return (
412
- candidate === normalize(this.options.hostname) ||
413
- candidate === normalize(this.options.outbound?.hostname || '')
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 hookDecision = this.options.hooks?.onRcptTo
839
- ? await this.runHookWithTimeout(
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
- : await this.evaluateDefaultRcptToPolicy(context);
845
- const decision = this.options.hooks?.onRcptTo
846
- ? await this.enforceAcceptedRcptToRelayPolicy(context, hookDecision)
847
- : hookDecision;
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
- accepted: false,
874
- smtpCode: route.action.reject?.code || 550,
875
- smtpMessage: route.action.reject?.message || 'Recipient rejected',
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
- const recipientDomain = this.getAddressDomain(context.rcptTo);
880
- const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
881
- if (!isLocalRecipient && !route.action.allowRelay) {
882
- return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
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
- return { accepted: true, smtpCode: 250, smtpMessage: 'OK' };
886
- }
887
-
888
- private async enforceAcceptedRcptToRelayPolicy(
889
- context: IRcptToPolicyContext,
890
- decision: ISmtpDecision
891
- ): Promise<ISmtpDecision> {
892
- if (!decision.accepted) {
893
- return decision;
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 recipientDomain = this.getAddressDomain(context.rcptTo);
897
- const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
898
- if (isLocalRecipient) {
899
- return decision;
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
- const currentRecipientContext = { ...context, acceptedRcptTo: [] };
903
- const email = new Email({
904
- from: context.mailFrom || '<>',
905
- to: [context.rcptTo],
906
- subject: '',
907
- text: '',
908
- });
909
- const session = this.buildPolicySession(currentRecipientContext);
910
- const route = await this.emailRouter.evaluateRoutes({ email, session });
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
- if (route?.action.type !== 'reject' && route?.action.allowRelay) {
913
- return decision;
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
- return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
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 = (session as any)._precomputedSecurityResults;
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
- result = precomputed;
1266
- } else {
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
- result = await this.rustBridge.verifyEmail({
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: any) => `${d.status}${d.domain ? ` (${d.domain})` : ''}`)
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 (result.contentScan) {
1312
- const scan = result.contentScan;
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 (result.ipReputation) {
1327
- const rep = result.ipReputation;
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;