@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,6 +10,7 @@ import {
10
10
  UnifiedDeliveryQueue,
11
11
  type IQueueItem,
12
12
  type IRecipientPermanentFailure,
13
+ type IBounceOperation,
13
14
  } from './classes.delivery.queue.js';
14
15
  import { Email } from '../core/classes.email.js';
15
16
  import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
@@ -41,6 +42,17 @@ export interface IDeliveryHandler {
41
42
  deliver(item: IQueueItem): Promise<any>;
42
43
  }
43
44
 
45
+ export interface IDeliveryFailureNotice {
46
+ itemId: string;
47
+ operation: Readonly<IBounceOperation>;
48
+ sender: string;
49
+ headers: Readonly<Record<string, string>>;
50
+ }
51
+
52
+ export interface IDeliveryFailureHandler {
53
+ processDeliveryFailure(notice: IDeliveryFailureNotice): Promise<void>;
54
+ }
55
+
44
56
  /**
45
57
  * Delivery options
46
58
  */
@@ -71,7 +83,9 @@ export interface IMultiModeDeliveryOptions {
71
83
  bounceHandler?: {
72
84
  processSmtpFailure: (recipient: string, smtpResponse: string, options: any) => Promise<any>;
73
85
  };
74
-
86
+ /** Durable, idempotent terminal-delivery notice consumer. */
87
+ deliveryFailureHandler?: IDeliveryFailureHandler;
88
+
75
89
  // Event hooks
76
90
  onDeliveryStart?: (item: IQueueItem) => Promise<void>;
77
91
  onDeliverySuccess?: (item: IQueueItem, result: any) => Promise<void>;
@@ -112,9 +126,10 @@ export interface IDeliveryStats {
112
126
  */
113
127
  type TResolvedMultiModeDeliveryOptions = Omit<
114
128
  Required<IMultiModeDeliveryOptions>,
115
- 'bounceHandler'
129
+ 'bounceHandler' | 'deliveryFailureHandler'
116
130
  > & {
117
131
  bounceHandler?: NonNullable<IMultiModeDeliveryOptions['bounceHandler']>;
132
+ deliveryFailureHandler: IDeliveryFailureHandler;
118
133
  };
119
134
 
120
135
  export class MultiModeDeliverySystem extends EventEmitter {
@@ -165,6 +180,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
165
180
  perPatternRateLimit: options.perPatternRateLimit || {},
166
181
  processBounces: options.processBounces !== false, // Default to true
167
182
  bounceHandler: options.bounceHandler,
183
+ deliveryFailureHandler: options.deliveryFailureHandler || {
184
+ processDeliveryFailure: async (notice) => {
185
+ logger.log('warn', `Terminal delivery failure persisted for ${notice.operation.recipient}`);
186
+ await emitSafely(this, 'deliveryFailureNotice', notice);
187
+ },
188
+ },
168
189
  onDeliveryStart: options.onDeliveryStart || (async () => {}),
169
190
  onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
170
191
  onDeliveryFailed: options.onDeliveryFailed || (async () => {})
@@ -313,34 +334,68 @@ export class MultiModeDeliverySystem extends EventEmitter {
313
334
  }
314
335
 
315
336
  /**
316
- * Drain durable bounce work with one in-process flight. Custom handlers
337
+ * Drain durable failure work with one in-process flight. Custom handlers
317
338
  * receive operationId and must use it for their own idempotency.
318
339
  */
319
340
  public async drainBounceOutbox(): Promise<void> {
320
341
  if (this.bounceDrainPromise) return this.bounceDrainPromise;
321
342
  const drain = (async () => {
322
- if (!this.options.processBounces || !this.options.bounceHandler) return;
323
- for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
343
+ if (this.options.processBounces && this.options.bounceHandler) {
344
+ for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
345
+ const item = this.queue.getItem(itemId);
346
+ if (!item) continue;
347
+ const email = item.processingResult as Email;
348
+ try {
349
+ await this.options.bounceHandler.processSmtpFailure(
350
+ operation.recipient,
351
+ operation.smtpResponse,
352
+ {
353
+ operationId: operation.operationId,
354
+ sender: email.from,
355
+ originalEmailId: item.id,
356
+ headers: email.headers,
357
+ statusCode: String(operation.smtpCode),
358
+ },
359
+ );
360
+ await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
361
+ } catch (error) {
362
+ logger.log(
363
+ 'error',
364
+ `Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
365
+ );
366
+ try {
367
+ await this.queue.markBounceOperationFailed(
368
+ itemId,
369
+ operation.operationId,
370
+ (error as Error).message,
371
+ );
372
+ } catch (checkpointError) {
373
+ logger.log(
374
+ 'error',
375
+ `Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
376
+ );
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ for (const { itemId, operation } of this.queue.listPendingDeliveryFailureOperations()) {
324
383
  const item = this.queue.getItem(itemId);
325
384
  if (!item) continue;
326
385
  const email = item.processingResult as Email;
386
+ const notice: IDeliveryFailureNotice = {
387
+ itemId,
388
+ operation,
389
+ sender: email.from,
390
+ headers: { ...email.headers },
391
+ };
327
392
  try {
328
- await this.options.bounceHandler.processSmtpFailure(
329
- operation.recipient,
330
- operation.smtpResponse,
331
- {
332
- operationId: operation.operationId,
333
- sender: email.from,
334
- originalEmailId: item.id,
335
- headers: email.headers,
336
- statusCode: String(operation.smtpCode),
337
- },
338
- );
393
+ await this.options.deliveryFailureHandler.processDeliveryFailure(notice);
339
394
  await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
340
395
  } catch (error) {
341
396
  logger.log(
342
397
  'error',
343
- `Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
398
+ `Delivery-failure operation ${operation.operationId} failed: ${(error as Error).message}`,
344
399
  );
345
400
  try {
346
401
  await this.queue.markBounceOperationFailed(
@@ -351,7 +406,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
351
406
  } catch (checkpointError) {
352
407
  logger.log(
353
408
  'error',
354
- `Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
409
+ `Could not checkpoint failed delivery-failure operation ${operation.operationId}: ${(checkpointError as Error).message}`,
355
410
  );
356
411
  }
357
412
  }
@@ -619,6 +674,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
619
674
 
620
675
  const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
621
676
  retryable: deliveryError.retryable,
677
+ terminalFailure: {
678
+ failureClass: deliveryError.errorType.startsWith('self_mx_')
679
+ ? 'localTopology'
680
+ : 'transport',
681
+ ...(deliveryError.smtpCode !== undefined ? { smtpCode: deliveryError.smtpCode } : {}),
682
+ },
622
683
  });
623
684
  if (this.running) void this.drainBounceOutbox();
624
685
  if (!transition.updated) return;
@@ -832,6 +893,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
832
893
  && typeof failure.smtpCode === 'number'
833
894
  && failure.smtpCode >= 500
834
895
  ));
896
+ const allLocalTopologyFailures = normalized.every((failure) => (
897
+ failure.errorType.startsWith('self_mx_')
898
+ ));
835
899
  const selected = normalized.find((failure) => failure.retryable)
836
900
  || normalized[normalized.length - 1];
837
901
  const smtpCode = allPermanentRecipientRejections
@@ -839,7 +903,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
839
903
  : selected.smtpCode;
840
904
  return new SmtpDeliveryError({
841
905
  message: normalized.map((failure) => failure.message).join('; '),
842
- errorType: allPermanentRecipientRejections ? 'protocol' : 'smtp_aggregate',
906
+ errorType: allPermanentRecipientRejections
907
+ ? 'protocol'
908
+ : allLocalTopologyFailures
909
+ ? selected.errorType
910
+ : 'smtp_aggregate',
843
911
  retryable,
844
912
  ...(smtpCode !== undefined ? { smtpCode } : {}),
845
913
  phase: allPermanentRecipientRejections ? 'rcpt_to' : selected.phase,
@@ -903,37 +971,65 @@ export class MultiModeDeliverySystem extends EventEmitter {
903
971
 
904
972
  for (const [domain, recipients] of domainGroups) {
905
973
  const allMxHosts = await this.resolveMxForDomain(domain);
974
+ let recipientsForMx = [...recipients];
975
+ const domainFailures: SmtpDeliveryError[] = [];
976
+ const acceptedForDomain: string[] = [];
977
+ const mxHosts: typeof allMxHosts = [];
978
+
979
+ // RFC 5321 §5.3.6: skip local MX identities but continue to non-local
980
+ // backup MX hosts. Proxy connection targets are intentionally absent
981
+ // from this identity check.
982
+ for (const mx of allMxHosts) {
983
+ const identity = await this.emailServer.resolveSelfMxIdentity(mx.exchange);
984
+ if (!identity.isSelf && !identity.indeterminate) {
985
+ mxHosts.push(mx);
986
+ continue;
987
+ }
906
988
 
907
- // RFC 5321 §5.3.6: never relay to an MX that is this server itself —
908
- // that is a mail loop (observed live: a managed domain whose MX record
909
- // points at our own mail hostname re-enters the inbound pipeline).
910
- const mxHosts = allMxHosts.filter(mx => !this.emailServer!.isSelfHostname(mx.exchange));
911
- if (mxHosts.length === 0) {
912
- const selfMx = allMxHosts.map(mx => mx.exchange).join(', ');
989
+ const startedAt = new Date();
990
+ const retryable = identity.source !== 'explicit';
991
+ const errorType = identity.indeterminate
992
+ ? 'self_mx_dns_indeterminate'
993
+ : identity.source === 'explicit'
994
+ ? 'self_mx_config'
995
+ : 'self_mx_loop';
996
+ const reason = identity.indeterminate
997
+ ? 'could not be safely classified against current DNS and listener topology'
998
+ : identity.source === 'explicit'
999
+ ? 'exactly matches a configured listener identity'
1000
+ : 'resolves to the current listener topology';
1001
+ const evidence = identity.evidence.length > 0
1002
+ ? ` (${identity.evidence.join('; ')})`
1003
+ : '';
913
1004
  const loopError = new SmtpDeliveryError({
914
- message: `MX for ${domain} (${selfMx || 'none'}) points back at this server — refusing self-delivery to avoid a mail loop`,
915
- errorType: 'config',
916
- retryable: false,
1005
+ message: identity.indeterminate
1006
+ ? `MX ${mx.exchange} for ${domain} ${reason}${evidence}`
1007
+ : `MX ${mx.exchange} for ${domain} points back at this server because it ${reason}${evidence}`,
1008
+ errorType,
1009
+ retryable,
917
1010
  phase: 'connect',
918
- transactionLog: [],
1011
+ transactionLog: [{
1012
+ timestampMs: Date.now(),
1013
+ phase: 'connect',
1014
+ direction: 'system',
1015
+ text: `Skipped ${mx.exchange}: ${reason}`,
1016
+ }],
919
1017
  recipients,
920
1018
  recipientDomain: domain,
921
1019
  });
922
- unresolvedFailures.push(loopError);
923
- unresolvedRecipients.push(...recipients);
924
- results.push({
925
- domain,
926
- success: false,
927
- error: loopError.message,
1020
+ await this.persistSmtpAttempt({
1021
+ item,
1022
+ targetHost: mx.exchange,
1023
+ targetPort: 25,
1024
+ recipientDomain: domain,
1025
+ recipients,
1026
+ startedAt,
1027
+ error: loopError,
928
1028
  });
929
- logger.log('error', `MTA: refusing self-delivery for ${domain} — MX (${selfMx}) is this server's own hostname (mail loop)`);
930
- continue;
1029
+ domainFailures.push(loopError);
1030
+ logger.log(retryable ? 'warn' : 'error', `MTA: skipped MX ${mx.exchange} for ${domain}: ${loopError.message}`);
931
1031
  }
932
1032
 
933
- let recipientsForMx = [...recipients];
934
- const domainFailures: SmtpDeliveryError[] = [];
935
- const acceptedForDomain: string[] = [];
936
-
937
1033
  for (const mx of mxHosts) {
938
1034
  if (recipientsForMx.length === 0) break;
939
1035
  const startedAt = new Date();
@@ -9,6 +9,23 @@ export interface IStorageManager {
9
9
  get(key: string): Promise<string | null>;
10
10
  set(key: string, value: string): Promise<void>;
11
11
  list(prefix: string): Promise<string[]>;
12
+ /**
13
+ * Return at most `limit` keys from one backend-bounded page.
14
+ *
15
+ * Continuation tokens are opaque, scoped to the supplied prefix, and must
16
+ * only be passed back to the same storage implementation. A returned token
17
+ * must advance a non-overlapping traversal, remain valid after any keys in
18
+ * its page are deleted, and eventually exhaust an otherwise stable prefix.
19
+ * Offset-based tokens do not satisfy this deletion-stability requirement.
20
+ *
21
+ * This optional method preserves compatibility for existing callers;
22
+ * features which need bounded cleanup must require it explicitly and must
23
+ * never fall back to list().
24
+ */
25
+ listPage?(
26
+ prefix: string,
27
+ options: IStorageListPageOptions,
28
+ ): Promise<IStorageListPageResult>;
12
29
  delete(key: string): Promise<void>;
13
30
  }
14
31
 
@@ -17,18 +34,131 @@ export interface IBlobStorageManager {
17
34
  get(key: string): Promise<Buffer | null>;
18
35
  set(key: string, value: Buffer): Promise<void>;
19
36
  list(prefix: string): Promise<string[]>;
37
+ /** Backend-bounded counterpart to list(); see IStorageManager.listPage. */
38
+ listPage?(
39
+ prefix: string,
40
+ options: IStorageListPageOptions,
41
+ ): Promise<IStorageListPageResult>;
20
42
  delete(key: string): Promise<void>;
21
43
  }
22
44
 
45
+ export interface IStorageListPageOptions {
46
+ /** Positive maximum number of keys the backend may return. */
47
+ readonly limit: number;
48
+ /** Opaque token returned by the immediately preceding page. */
49
+ readonly continuationToken?: string;
50
+ }
51
+
52
+ export interface IStorageListPageResult {
53
+ /** Unique keys under the requested prefix; length must not exceed limit. */
54
+ readonly keys: string[];
55
+ /** Omitted when the prefix has been exhausted. */
56
+ readonly continuationToken?: string;
57
+ }
58
+
59
+ /** Hard upper bound for one SmartMTA storage listing page. */
60
+ export const SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT = 1_000;
61
+
62
+ function assertValidListPageOptions(options: IStorageListPageOptions): void {
63
+ if (
64
+ !Number.isInteger(options.limit)
65
+ || options.limit < 1
66
+ || options.limit > SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT
67
+ ) {
68
+ throw new Error(
69
+ `Storage list page limit must be an integer from 1 to ${SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT}`,
70
+ );
71
+ }
72
+ if (
73
+ options.continuationToken !== undefined
74
+ && (
75
+ typeof options.continuationToken !== 'string'
76
+ || options.continuationToken.length === 0
77
+ )
78
+ ) {
79
+ throw new Error('Storage list page continuation token must be a non-empty string');
80
+ }
81
+ }
82
+
83
+ function lowerBound(sortedKeys: readonly string[], target: string): number {
84
+ let low = 0;
85
+ let high = sortedKeys.length;
86
+ while (low < high) {
87
+ const middle = low + Math.floor((high - low) / 2);
88
+ if (sortedKeys[middle] < target) low = middle + 1;
89
+ else high = middle;
90
+ }
91
+ return low;
92
+ }
93
+
94
+ function upperBound(sortedKeys: readonly string[], target: string): number {
95
+ let low = 0;
96
+ let high = sortedKeys.length;
97
+ while (low < high) {
98
+ const middle = low + Math.floor((high - low) / 2);
99
+ if (sortedKeys[middle] <= target) low = middle + 1;
100
+ else high = middle;
101
+ }
102
+ return low;
103
+ }
104
+
105
+ function insertSortedKey(sortedKeys: string[], key: string): void {
106
+ sortedKeys.splice(lowerBound(sortedKeys, key), 0, key);
107
+ }
108
+
109
+ function removeSortedKey(sortedKeys: string[], key: string): void {
110
+ const index = lowerBound(sortedKeys, key);
111
+ if (sortedKeys[index] === key) sortedKeys.splice(index, 1);
112
+ }
113
+
114
+ function listSortedPrefixPage(
115
+ sortedKeys: readonly string[],
116
+ prefix: string,
117
+ options: IStorageListPageOptions,
118
+ ): IStorageListPageResult {
119
+ assertValidListPageOptions(options);
120
+ if (
121
+ options.continuationToken !== undefined
122
+ && !options.continuationToken.startsWith(prefix)
123
+ ) {
124
+ throw new Error('Storage list page continuation token does not belong to the prefix');
125
+ }
126
+ let index = options.continuationToken === undefined
127
+ ? lowerBound(sortedKeys, prefix)
128
+ : Math.max(
129
+ lowerBound(sortedKeys, prefix),
130
+ upperBound(sortedKeys, options.continuationToken),
131
+ );
132
+ const keys: string[] = [];
133
+ while (index < sortedKeys.length && keys.length < options.limit) {
134
+ const key = sortedKeys[index];
135
+ if (!key.startsWith(prefix)) break;
136
+ keys.push(key);
137
+ index++;
138
+ }
139
+ const hasMore = (
140
+ index < sortedKeys.length
141
+ && sortedKeys[index].startsWith(prefix)
142
+ );
143
+ return {
144
+ keys,
145
+ ...(hasMore && keys.length > 0
146
+ ? { continuationToken: keys[keys.length - 1] }
147
+ : {}),
148
+ };
149
+ }
150
+
23
151
  /** In-memory small-state store for tests and explicitly non-durable runtimes. */
24
152
  export class MemoryStorageManager implements IStorageManager {
25
153
  private readonly entries = new Map<string, string>();
154
+ private readonly sortedKeys: string[] = [];
26
155
 
27
156
  public async get(key: string): Promise<string | null> {
28
157
  return this.entries.get(key) ?? null;
29
158
  }
30
159
 
31
160
  public async set(key: string, value: string): Promise<void> {
161
+ if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
32
162
  this.entries.set(key, value);
33
163
  }
34
164
 
@@ -36,14 +166,22 @@ export class MemoryStorageManager implements IStorageManager {
36
166
  return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
37
167
  }
38
168
 
169
+ public async listPage(
170
+ prefix: string,
171
+ options: IStorageListPageOptions,
172
+ ): Promise<IStorageListPageResult> {
173
+ return listSortedPrefixPage(this.sortedKeys, prefix, options);
174
+ }
175
+
39
176
  public async delete(key: string): Promise<void> {
40
- this.entries.delete(key);
177
+ if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
41
178
  }
42
179
  }
43
180
 
44
181
  /** In-memory blob store for tests and explicitly non-durable runtimes. */
45
182
  export class MemoryBlobStorageManager implements IBlobStorageManager {
46
183
  private readonly entries = new Map<string, Buffer>();
184
+ private readonly sortedKeys: string[] = [];
47
185
 
48
186
  public async get(key: string): Promise<Buffer | null> {
49
187
  const value = this.entries.get(key);
@@ -51,6 +189,7 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
51
189
  }
52
190
 
53
191
  public async set(key: string, value: Buffer): Promise<void> {
192
+ if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
54
193
  this.entries.set(key, Buffer.from(value));
55
194
  }
56
195
 
@@ -58,8 +197,15 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
58
197
  return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
59
198
  }
60
199
 
200
+ public async listPage(
201
+ prefix: string,
202
+ options: IStorageListPageOptions,
203
+ ): Promise<IStorageListPageResult> {
204
+ return listSortedPrefixPage(this.sortedKeys, prefix, options);
205
+ }
206
+
61
207
  public async delete(key: string): Promise<void> {
62
- this.entries.delete(key);
208
+ if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
63
209
  }
64
210
  }
65
211
 
@@ -1,5 +1,6 @@
1
1
  import type { IEmailDomainConfig } from './interfaces.js';
2
2
  import { logger } from '../../logger.js';
3
+ import { assertValidInboundPolicy } from './functions.routing.validation.js';
3
4
 
4
5
  /**
5
6
  * Registry for email domain configurations
@@ -30,6 +31,9 @@ export class DomainRegistry {
30
31
 
31
32
  // Process and store domain configurations
32
33
  for (const config of domainConfigs) {
34
+ if (config.inboundPolicy) {
35
+ assertValidInboundPolicy(config.inboundPolicy, config.domain);
36
+ }
33
37
  const processedConfig = this.applyDefaults(config);
34
38
  this.domains.set(config.domain.toLowerCase(), processedConfig);
35
39
  logger.log('info', `Registered domain: ${config.domain} with DNS mode: ${processedConfig.dnsMode}`);
@@ -30,6 +30,22 @@ export interface IActionExecutorDeps {
30
30
  deliveryQueue: UnifiedDeliveryQueue;
31
31
  }
32
32
 
33
+ export interface IActionExecutionOptions {
34
+ /**
35
+ * Queue the action with a deterministic durable id instead of performing
36
+ * direct delivery. Used when replaying an already-accepted envelope.
37
+ */
38
+ durableQueue?: {
39
+ idempotencyKey: string;
40
+ fingerprint: string;
41
+ };
42
+ }
43
+
44
+ export interface IActionExecutionResult {
45
+ outcome: 'completed' | 'queued';
46
+ queueId?: string;
47
+ }
48
+
33
49
  /**
34
50
  * Executes email routing actions (forward, process, deliver, reject)
35
51
  */
@@ -61,26 +77,45 @@ export class EmailActionExecutor {
61
77
  }
62
78
  }
63
79
 
64
- async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
80
+ async executeAction(
81
+ action: IEmailAction,
82
+ email: Email,
83
+ context: IEmailContext,
84
+ ): Promise<void> {
85
+ await this.executeActionWithResult(action, email, context);
86
+ }
87
+
88
+ async executeActionWithResult(
89
+ action: IEmailAction,
90
+ email: Email,
91
+ context: IEmailContext,
92
+ options: IActionExecutionOptions = {},
93
+ ): Promise<IActionExecutionResult> {
65
94
  switch (action.type) {
66
95
  case 'forward':
67
- await this.handleForwardAction(action, email, context);
68
- break;
96
+ return this.handleForwardAction(action, email, context, options);
69
97
  case 'process':
70
- await this.handleProcessAction(action, email, context);
71
- break;
98
+ return this.handleProcessAction(action, email, context, options);
72
99
  case 'deliver':
73
- await this.handleDeliverAction(action, email, context);
74
- break;
100
+ return this.handleDeliverAction(action, email, context, options);
101
+ case 'store':
102
+ throw new Error(
103
+ 'Store actions require raw SMTP context and an awaited hooks.onAcceptEnvelope handler',
104
+ );
75
105
  case 'reject':
76
106
  await this.handleRejectAction(action, email, context);
77
- break;
107
+ throw new Error('Reject action unexpectedly returned');
78
108
  default:
79
109
  throw new Error(`Unknown action type: ${(action as any).type}`);
80
110
  }
81
111
  }
82
112
 
83
- private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
113
+ private async handleForwardAction(
114
+ action: IEmailAction,
115
+ email: Email,
116
+ context: IEmailContext,
117
+ options: IActionExecutionOptions,
118
+ ): Promise<IActionExecutionResult> {
84
119
  if (!action.forward) {
85
120
  throw new Error('Forward action requires forward configuration');
86
121
  }
@@ -101,6 +136,20 @@ export class EmailActionExecutor {
101
136
  email.headers['X-Forwarded-To'] = email.to.join(', ');
102
137
  email.headers['X-Forwarded-Date'] = new Date().toISOString();
103
138
 
139
+ if (options.durableQueue) {
140
+ const queueId = await this.deps.deliveryQueue.enqueue(
141
+ email,
142
+ 'forward',
143
+ context.session.matchedRoute!,
144
+ {
145
+ idempotencyKey: options.durableQueue.idempotencyKey,
146
+ idempotencyFingerprint: options.durableQueue.fingerprint,
147
+ requireDurable: true,
148
+ },
149
+ );
150
+ return { outcome: 'queued', queueId };
151
+ }
152
+
104
153
  try {
105
154
  // Send email via Rust SMTP client
106
155
  const result = await this.deps.sendOutboundEmail(host, port, email, {
@@ -124,6 +173,7 @@ export class EmailActionExecutor {
124
173
  },
125
174
  success: true
126
175
  }));
176
+ return { outcome: 'completed' };
127
177
  } catch (error) {
128
178
  const errorMessage = getErrorMessage(error);
129
179
  logger.log('error', `Failed to forward email: ${errorMessage}`);
@@ -168,7 +218,12 @@ export class EmailActionExecutor {
168
218
  }
169
219
  }
170
220
 
171
- private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
221
+ private async handleProcessAction(
222
+ action: IEmailAction,
223
+ email: Email,
224
+ context: IEmailContext,
225
+ options: IActionExecutionOptions,
226
+ ): Promise<IActionExecutionResult> {
172
227
  logger.log('info', `Processing email with action options`);
173
228
 
174
229
  // Apply scanning if requested
@@ -178,18 +233,47 @@ export class EmailActionExecutor {
178
233
 
179
234
  // Queue for delivery
180
235
  const queue = action.process?.queue || 'normal';
181
- await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
236
+ const queueId = await this.deps.deliveryQueue.enqueue(
237
+ email,
238
+ 'process',
239
+ context.session.matchedRoute!,
240
+ options.durableQueue
241
+ ? {
242
+ idempotencyKey: options.durableQueue.idempotencyKey,
243
+ idempotencyFingerprint: options.durableQueue.fingerprint,
244
+ requireDurable: true,
245
+ }
246
+ : {},
247
+ );
182
248
 
183
249
  logger.log('info', `Email queued for delivery in ${queue} queue`);
250
+ return { outcome: 'queued', queueId };
184
251
  }
185
252
 
186
- private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
253
+ private async handleDeliverAction(
254
+ _action: IEmailAction,
255
+ email: Email,
256
+ context: IEmailContext,
257
+ options: IActionExecutionOptions,
258
+ ): Promise<IActionExecutionResult> {
187
259
  logger.log('info', `Delivering email locally`);
188
260
 
189
261
  // Queue for local delivery
190
- await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
262
+ const queueId = await this.deps.deliveryQueue.enqueue(
263
+ email,
264
+ 'mta',
265
+ context.session.matchedRoute!,
266
+ options.durableQueue
267
+ ? {
268
+ idempotencyKey: options.durableQueue.idempotencyKey,
269
+ idempotencyFingerprint: options.durableQueue.fingerprint,
270
+ requireDurable: true,
271
+ }
272
+ : {},
273
+ );
191
274
 
192
275
  logger.log('info', 'Email queued for local delivery');
276
+ return { outcome: 'queued', queueId };
193
277
  }
194
278
 
195
279
  private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {