@push.rocks/smartmta 8.0.0 → 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.
Files changed (45) hide show
  1. package/changelog.md +41 -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 +5 -0
  6. package/dist_ts/index.js +6 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +15 -2
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +91 -8
  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/delivery/index.d.ts +1 -0
  12. package/dist_ts/mail/delivery/index.js +2 -1
  13. package/dist_ts/mail/index.d.ts +1 -0
  14. package/dist_ts/mail/index.js +1 -1
  15. package/dist_ts/mail/routing/classes.domain.registry.js +5 -1
  16. package/dist_ts/mail/routing/classes.email.action.executor.js +3 -1
  17. package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
  18. package/dist_ts/mail/routing/classes.email.router.js +28 -21
  19. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +85 -3
  20. package/dist_ts/mail/routing/classes.unified.email.server.js +517 -60
  21. package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
  22. package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
  23. package/dist_ts/mail/routing/interfaces.d.ts +11 -1
  24. package/dist_ts/plugins.d.ts +2 -1
  25. package/dist_ts/plugins.js +3 -2
  26. package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
  27. package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
  28. package/dist_ts/security/index.d.ts +1 -1
  29. package/dist_ts/security/index.js +1 -1
  30. package/package.json +1 -1
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/index.ts +5 -0
  33. package/ts/mail/delivery/classes.delivery.queue.ts +126 -6
  34. package/ts/mail/delivery/classes.delivery.system.ts +137 -41
  35. package/ts/mail/delivery/index.ts +1 -0
  36. package/ts/mail/index.ts +1 -0
  37. package/ts/mail/routing/classes.domain.registry.ts +4 -0
  38. package/ts/mail/routing/classes.email.action.executor.ts +4 -0
  39. package/ts/mail/routing/classes.email.router.ts +33 -20
  40. package/ts/mail/routing/classes.unified.email.server.ts +703 -72
  41. package/ts/mail/routing/functions.routing.validation.ts +36 -0
  42. package/ts/mail/routing/interfaces.ts +13 -1
  43. package/ts/plugins.ts +2 -0
  44. package/ts/security/classes.rustsecuritybridge.ts +46 -1
  45. package/ts/security/index.ts +3 -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 { 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
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, called before each RCPT TO is accepted. */
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, called after DATA before the final SMTP response. */
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 normalize = (name: string) => name.toLowerCase().replace(/\.$/, '');
409
- const candidate = normalize(hostname);
625
+ const candidate = this.normalizeTopologyIdentity(hostname);
410
626
  if (!candidate) return false;
411
- return (
412
- candidate === normalize(this.options.hostname) ||
413
- candidate === normalize(this.options.outbound?.hostname || '')
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 hookDecision = this.options.hooks?.onRcptTo
839
- ? await this.runHookWithTimeout(
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
- : await this.evaluateDefaultRcptToPolicy(context);
845
- const decision = this.options.hooks?.onRcptTo
846
- ? await this.enforceAcceptedRcptToRelayPolicy(context, hookDecision)
847
- : hookDecision;
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
- accepted: false,
874
- smtpCode: route.action.reject?.code || 550,
875
- smtpMessage: route.action.reject?.message || 'Recipient rejected',
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
- 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' };
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
- 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;
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 recipientDomain = this.getAddressDomain(context.rcptTo);
897
- const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
898
- if (isLocalRecipient) {
899
- return decision;
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
- 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 });
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
- if (route?.action.type !== 'reject' && route?.action.allowRelay) {
913
- return decision;
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
- return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
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 = (session as any)._precomputedSecurityResults;
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
- result = precomputed;
1266
- } else {
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
- result = await this.rustBridge.verifyEmail({
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: any) => `${d.status}${d.domain ? ` (${d.domain})` : ''}`)
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 (result.contentScan) {
1312
- const scan = result.contentScan;
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 (result.ipReputation) {
1327
- const rep = result.ipReputation;
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;