@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
@@ -40,7 +40,8 @@ export interface IRecipientPermanentFailure {
40
40
 
41
41
  export interface IBounceOperation {
42
42
  operationId: string;
43
- kind: 'smtpFailure';
43
+ kind: 'smtpFailure' | 'deliveryFailure';
44
+ failureClass?: 'localTopology' | 'transport';
44
45
  recipient: string;
45
46
  normalizedRecipient: string;
46
47
  smtpCode: number;
@@ -57,8 +58,14 @@ export interface IBounceOperation {
57
58
  */
58
59
  export interface IQueueItem {
59
60
  id: string;
61
+ /** Stable caller-supplied key used to make enqueue replay-safe. */
62
+ idempotencyKey?: string;
63
+ /** Fingerprint of the exact work associated with the idempotency key. */
64
+ idempotencyFingerprint?: string;
60
65
  processingMode: EmailProcessingMode;
61
66
  processingResult: Email;
67
+ /** Materialized attachment count for queue/detail consumers. */
68
+ attachmentCount?: number;
62
69
  route?: IEmailRoute;
63
70
  status: QueueItemStatus;
64
71
  attempts: number;
@@ -83,9 +90,12 @@ interface IStoredEmail {
83
90
  interface IStoredQueueItemV1 {
84
91
  schemaVersion: 1;
85
92
  id: string;
93
+ idempotencyKey?: string;
94
+ idempotencyFingerprint?: string;
86
95
  processingMode: EmailProcessingMode;
87
96
  processingResult: IStoredEmail;
88
97
  route?: IEmailRoute;
98
+ attachmentCount?: number;
89
99
  status: QueueItemStatus;
90
100
  attempts: number;
91
101
  nextAttempt: number;
@@ -117,9 +127,25 @@ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
117
127
 
118
128
  type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
119
129
 
130
+ interface IStoredQueueIdempotencyTombstone {
131
+ schemaVersion: 1;
132
+ id: string;
133
+ idempotencyKey: string;
134
+ idempotencyFingerprint: string;
135
+ expiresAt: number;
136
+ }
137
+
138
+ export const ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS = 31 * 24 * 60 * 60 * 1000;
139
+ const QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT = 256;
140
+
120
141
  export interface IQueueFailureOptions {
121
142
  /** Unknown failures default to retryable so temporary internal faults do not lose mail. */
122
143
  retryable?: boolean;
144
+ /** Durable work created atomically when unresolved recipients become terminal. */
145
+ terminalFailure?: {
146
+ failureClass: 'localTopology' | 'transport';
147
+ smtpCode?: number;
148
+ };
123
149
  }
124
150
 
125
151
  export interface IQueueFailureTransition {
@@ -150,6 +176,15 @@ export interface IQueueOptions {
150
176
  maxRetryDelay?: number;
151
177
  }
152
178
 
179
+ export interface IQueueEnqueueOptions {
180
+ /** Stable key that resolves to one deterministic queue item id. */
181
+ idempotencyKey?: string;
182
+ /** Required with idempotencyKey to reject reuse for different work. */
183
+ idempotencyFingerprint?: string;
184
+ /** Fail closed unless the queue is backed by managed blob storage. */
185
+ requireDurable?: boolean;
186
+ }
187
+
153
188
  /**
154
189
  * Queue statistics interface
155
190
  */
@@ -216,6 +251,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
216
251
  private totalProcessed: number = 0;
217
252
  private ownsManagedPrefix = false;
218
253
  private lifecycleState: TQueueLifecycleState = 'new';
254
+ private readonly idempotentEnqueueFlights = new Map<string, {
255
+ fingerprint: string;
256
+ promise: Promise<string>;
257
+ }>();
258
+ private idempotencyTombstoneCleanupCursor?: string;
219
259
 
220
260
  /**
221
261
  * Create a new unified delivery queue
@@ -270,6 +310,143 @@ export class UnifiedDeliveryQueue extends EventEmitter {
270
310
  }
271
311
  }
272
312
 
313
+ /** Whether queue entries are committed through caller-managed blob storage. */
314
+ public hasManagedStorage(): boolean {
315
+ return !!this.options.storageManager;
316
+ }
317
+
318
+ /** Whether managed storage can list a backend-bounded page of keys. */
319
+ public hasPaginatedManagedStorage(): boolean {
320
+ return typeof this.options.storageManager?.listPage === 'function';
321
+ }
322
+
323
+ private idempotentQueueId(idempotencyKey: string): string {
324
+ return `idempotent-${createHash('sha256')
325
+ .update('smartmta-queue-v1\0')
326
+ .update(idempotencyKey)
327
+ .digest('hex')}`;
328
+ }
329
+
330
+ private idempotencyTombstonePrefix(): string {
331
+ return `${this.options.storagePrefix.slice(0, -1)}.idempotency/`;
332
+ }
333
+
334
+ private idempotencyTombstoneKey(id: string): string {
335
+ return `${this.idempotencyTombstonePrefix()}${id}.json`;
336
+ }
337
+
338
+ private async readIdempotencyTombstone(
339
+ id: string,
340
+ ): Promise<IStoredQueueIdempotencyTombstone | null> {
341
+ const storageManager = this.options.storageManager;
342
+ if (!storageManager) return null;
343
+ const key = this.idempotencyTombstoneKey(id);
344
+ const value = await storageManager.get(key);
345
+ if (!value) return null;
346
+ const tombstone = this.parseIdempotencyTombstone(value, key, id);
347
+ if (tombstone.expiresAt <= Date.now()) {
348
+ await storageManager.delete(key);
349
+ return null;
350
+ }
351
+ return tombstone;
352
+ }
353
+
354
+ private parseIdempotencyTombstone(
355
+ value: Buffer,
356
+ key: string,
357
+ expectedId: string,
358
+ ): IStoredQueueIdempotencyTombstone {
359
+ let tombstone: IStoredQueueIdempotencyTombstone;
360
+ try {
361
+ tombstone = JSON.parse(value.toString('utf8')) as IStoredQueueIdempotencyTombstone;
362
+ } catch {
363
+ throw new Error(`Invalid queue idempotency tombstone at ${key}`);
364
+ }
365
+ if (
366
+ tombstone?.schemaVersion !== 1
367
+ || tombstone.id !== expectedId
368
+ || typeof tombstone.idempotencyKey !== 'string'
369
+ || !tombstone.idempotencyKey
370
+ || tombstone.idempotencyKey.length > 512
371
+ || this.idempotentQueueId(tombstone.idempotencyKey) !== expectedId
372
+ || typeof tombstone.idempotencyFingerprint !== 'string'
373
+ || !/^[a-f0-9]{64}$/.test(tombstone.idempotencyFingerprint)
374
+ || !Number.isFinite(tombstone.expiresAt)
375
+ ) {
376
+ throw new Error(`Invalid queue idempotency tombstone at ${key}`);
377
+ }
378
+ return tombstone;
379
+ }
380
+
381
+ private async persistIdempotencyTombstone(
382
+ id: string,
383
+ idempotencyKey: string,
384
+ idempotencyFingerprint: string,
385
+ ): Promise<void> {
386
+ const storageManager = this.options.storageManager;
387
+ if (!storageManager) return;
388
+ const tombstone: IStoredQueueIdempotencyTombstone = {
389
+ schemaVersion: 1,
390
+ id,
391
+ idempotencyKey,
392
+ idempotencyFingerprint,
393
+ expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
394
+ };
395
+ await storageManager.set(
396
+ this.idempotencyTombstoneKey(id),
397
+ Buffer.from(JSON.stringify(tombstone)),
398
+ );
399
+ }
400
+
401
+ private async cleanupExpiredIdempotencyTombstones(
402
+ limit = QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT,
403
+ ): Promise<void> {
404
+ const storageManager = this.options.storageManager;
405
+ if (!storageManager?.listPage || limit < 1) return;
406
+ const prefix = this.idempotencyTombstonePrefix();
407
+ const page = await storageManager.listPage(prefix, {
408
+ limit,
409
+ ...(this.idempotencyTombstoneCleanupCursor
410
+ ? { continuationToken: this.idempotencyTombstoneCleanupCursor }
411
+ : {}),
412
+ });
413
+ if (
414
+ !page
415
+ || !Array.isArray(page.keys)
416
+ || page.keys.length > limit
417
+ || page.keys.some((key) => typeof key !== 'string' || !key.startsWith(prefix))
418
+ || new Set(page.keys).size !== page.keys.length
419
+ || (
420
+ page.continuationToken !== undefined
421
+ && (
422
+ typeof page.continuationToken !== 'string'
423
+ || page.continuationToken.length === 0
424
+ || page.continuationToken === this.idempotencyTombstoneCleanupCursor
425
+ )
426
+ )
427
+ ) {
428
+ throw new Error('Invalid paginated queue idempotency tombstone listing');
429
+ }
430
+ this.idempotencyTombstoneCleanupCursor = page.continuationToken;
431
+ const now = Date.now();
432
+ for (const key of page.keys) {
433
+ const value = await storageManager.get(key);
434
+ if (!value) continue;
435
+ try {
436
+ if (!key.endsWith('.json')) {
437
+ throw new Error(`Invalid queue idempotency tombstone key: ${key}`);
438
+ }
439
+ const id = key.slice(prefix.length, -'.json'.length);
440
+ const tombstone = this.parseIdempotencyTombstone(value, key, id);
441
+ if (tombstone.expiresAt <= now) {
442
+ await storageManager.delete(key);
443
+ }
444
+ } catch {
445
+ await storageManager.delete(key);
446
+ }
447
+ }
448
+ }
449
+
273
450
  private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
274
451
  const byRecipient = new Map<string, IRecipientDeliveryState>();
275
452
  const now = new Date();
@@ -286,12 +463,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
286
463
  return [...byRecipient.values()];
287
464
  }
288
465
 
289
- private createBounceOperationId(itemId: string, normalizedRecipient: string): string {
466
+ private createBounceOperationId(
467
+ itemId: string,
468
+ normalizedRecipient: string,
469
+ kind: IBounceOperation['kind'] = 'smtpFailure',
470
+ ): string {
290
471
  return createHash('sha256')
291
472
  .update(itemId)
292
473
  .update('\0')
293
474
  .update(normalizedRecipient)
294
- .update('\0smtpFailure')
475
+ .update(`\0${kind}`)
295
476
  .digest('hex');
296
477
  }
297
478
 
@@ -448,25 +629,116 @@ export class UnifiedDeliveryQueue extends EventEmitter {
448
629
  processingResult: Email,
449
630
  mode: EmailProcessingMode,
450
631
  route?: IEmailRoute,
632
+ enqueueOptions: IQueueEnqueueOptions = {},
451
633
  ): Promise<string> {
452
634
  this.assertMutable();
453
635
  if (!(processingResult instanceof Email)) {
454
636
  throw new Error('Queue processingResult must be an Email instance');
455
637
  }
456
638
 
639
+ if (enqueueOptions.requireDurable && !this.options.storageManager) {
640
+ throw new Error('Durable idempotent enqueue requires managed queue storage');
641
+ }
642
+ if (
643
+ (enqueueOptions.idempotencyKey === undefined)
644
+ !== (enqueueOptions.idempotencyFingerprint === undefined)
645
+ ) {
646
+ throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
647
+ }
648
+ if (enqueueOptions.idempotencyKey !== undefined) {
649
+ if (this.options.storageManager && !this.options.storageManager.listPage) {
650
+ throw new Error(
651
+ 'Managed idempotent enqueue requires paginated managed queue storage',
652
+ );
653
+ }
654
+ if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
655
+ throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
656
+ }
657
+ if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
658
+ throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
659
+ }
660
+
661
+ const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
662
+ const existing = this.queue.get(id);
663
+ if (existing) {
664
+ if (
665
+ existing.idempotencyKey !== enqueueOptions.idempotencyKey
666
+ || existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
667
+ ) {
668
+ throw new Error(`Queue idempotency key collision for ${id}`);
669
+ }
670
+ return id;
671
+ }
672
+
673
+ const activeFlight = this.idempotentEnqueueFlights.get(id);
674
+ if (activeFlight) {
675
+ if (activeFlight.fingerprint !== enqueueOptions.idempotencyFingerprint) {
676
+ throw new Error(`Queue idempotency key collision for ${id}`);
677
+ }
678
+ return activeFlight.promise;
679
+ }
680
+ const flight = (async () => {
681
+ const tombstone = await this.readIdempotencyTombstone(id);
682
+ if (tombstone) {
683
+ if (
684
+ tombstone.idempotencyKey !== enqueueOptions.idempotencyKey
685
+ || tombstone.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
686
+ ) {
687
+ throw new Error(`Queue idempotency key collision for ${id}`);
688
+ }
689
+ return id;
690
+ }
691
+ return this.enqueueNew(processingResult, mode, route, {
692
+ id,
693
+ idempotencyKey: enqueueOptions.idempotencyKey!,
694
+ idempotencyFingerprint: enqueueOptions.idempotencyFingerprint!,
695
+ });
696
+ })();
697
+ this.idempotentEnqueueFlights.set(id, {
698
+ fingerprint: enqueueOptions.idempotencyFingerprint!,
699
+ promise: flight,
700
+ });
701
+ try {
702
+ return await flight;
703
+ } finally {
704
+ if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
705
+ this.idempotentEnqueueFlights.delete(id);
706
+ }
707
+ }
708
+ }
709
+
710
+ return this.enqueueNew(processingResult, mode, route);
711
+ }
712
+
713
+ private async enqueueNew(
714
+ processingResult: Email,
715
+ mode: EmailProcessingMode,
716
+ route?: IEmailRoute,
717
+ idempotency?: {
718
+ id: string;
719
+ idempotencyKey: string;
720
+ idempotencyFingerprint: string;
721
+ },
722
+ ): Promise<string> {
457
723
  // Check if queue is full
458
724
  if (this.queue.size >= this.options.maxQueueSize) {
459
725
  throw new Error('Queue is full');
460
726
  }
461
727
 
462
728
  // Generate a unique ID
463
- const id = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
729
+ const id = idempotency?.id
730
+ || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
464
731
 
465
732
  // Create queue item
466
733
  const item: IQueueItem = {
467
734
  id,
735
+ ...(idempotency ? {
736
+ idempotencyKey: idempotency.idempotencyKey,
737
+ idempotencyFingerprint: idempotency.idempotencyFingerprint,
738
+ } : {}),
468
739
  processingMode: mode,
469
740
  processingResult,
741
+ attachmentCount: processingResult.attachments.length,
470
742
  route,
471
743
  status: 'pending',
472
744
  attempts: 0,
@@ -482,6 +754,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
482
754
 
483
755
  // Commit durable state before exposing the item in memory or emitting acceptance.
484
756
  await this.persistItem(item);
757
+ if (idempotency) {
758
+ await this.persistIdempotencyTombstone(
759
+ id,
760
+ idempotency.idempotencyKey,
761
+ idempotency.idempotencyFingerprint,
762
+ );
763
+ }
485
764
  this.queue.set(id, item);
486
765
 
487
766
  // Update statistics
@@ -638,9 +917,55 @@ export class UnifiedDeliveryQueue extends EventEmitter {
638
917
  } else {
639
918
  // Mark as permanently failed
640
919
  await this.applyPersistedMutation(item, (updatedItem) => {
920
+ const now = new Date();
641
921
  updatedItem.status = 'failed';
642
922
  updatedItem.lastError = error;
643
- updatedItem.updatedAt = new Date();
923
+ updatedItem.updatedAt = now;
924
+
925
+ if (optionsArg.terminalFailure) {
926
+ const states = updatedItem.recipientStates || this.createInitialRecipientStates(
927
+ updatedItem.processingResult,
928
+ );
929
+ const terminal = new Set(updatedItem.terminalRecipients || []);
930
+ const operations = updatedItem.bounceOperations || [];
931
+ const operationIds = new Set(operations.map((operation) => operation.operationId));
932
+ const requestedCode = optionsArg.terminalFailure.smtpCode;
933
+ const smtpCode = Number.isInteger(requestedCode) && requestedCode! >= 500
934
+ ? requestedCode!
935
+ : 554;
936
+
937
+ for (const state of states) {
938
+ if (state.state !== 'unresolved') continue;
939
+ state.state = 'permanentFailure';
940
+ state.smtpCode = smtpCode;
941
+ state.smtpResponse = error;
942
+ state.updatedAt = now;
943
+ terminal.add(state.normalizedRecipient);
944
+
945
+ const operationId = this.createBounceOperationId(
946
+ updatedItem.id,
947
+ state.normalizedRecipient,
948
+ 'deliveryFailure',
949
+ );
950
+ if (operationIds.has(operationId)) continue;
951
+ operations.push({
952
+ operationId,
953
+ kind: 'deliveryFailure',
954
+ failureClass: optionsArg.terminalFailure.failureClass,
955
+ recipient: state.recipient,
956
+ normalizedRecipient: state.normalizedRecipient,
957
+ smtpCode,
958
+ smtpResponse: error,
959
+ state: 'pending',
960
+ attempts: 0,
961
+ nextAttempt: now,
962
+ });
963
+ operationIds.add(operationId);
964
+ }
965
+ updatedItem.recipientStates = states;
966
+ updatedItem.terminalRecipients = [...terminal];
967
+ updatedItem.bounceOperations = operations;
968
+ }
644
969
  });
645
970
 
646
971
  // Update statistics
@@ -811,12 +1136,46 @@ export class UnifiedDeliveryQueue extends EventEmitter {
811
1136
  /** Includes terminal queue items because bounce work outlives delivery status. */
812
1137
  public listPendingBounceOperations(
813
1138
  now: Date = new Date(),
1139
+ limitArg = 100,
814
1140
  ): Array<{ itemId: string; operation: IBounceOperation }> {
815
1141
  const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
1142
+ const limit = Math.max(0, Math.min(Math.trunc(limitArg), 100));
1143
+ if (limit === 0) return result;
1144
+ for (const item of this.queue.values()) {
1145
+ for (const operation of item.bounceOperations || []) {
1146
+ if (
1147
+ operation.kind === 'smtpFailure'
1148
+ && operation.state === 'pending'
1149
+ && operation.nextAttempt <= now
1150
+ ) {
1151
+ result.push({ itemId: item.id, operation: { ...operation } });
1152
+ if (result.length >= limit) return result;
1153
+ }
1154
+ }
1155
+ }
1156
+ return result;
1157
+ }
1158
+
1159
+ /** Durable terminal-delivery notices. These never enter SMTP recipient suppression. */
1160
+ public listPendingDeliveryFailureOperations(
1161
+ now: Date = new Date(),
1162
+ limitArg = 100,
1163
+ ): Array<{
1164
+ itemId: string;
1165
+ operation: IBounceOperation;
1166
+ }> {
1167
+ const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
1168
+ const limit = Math.max(0, Math.min(Math.trunc(limitArg), 100));
1169
+ if (limit === 0) return result;
816
1170
  for (const item of this.queue.values()) {
817
1171
  for (const operation of item.bounceOperations || []) {
818
- if (operation.state === 'pending' && operation.nextAttempt <= now) {
1172
+ if (
1173
+ operation.kind === 'deliveryFailure'
1174
+ && operation.state === 'pending'
1175
+ && operation.nextAttempt <= now
1176
+ ) {
819
1177
  result.push({ itemId: item.id, operation: { ...operation } });
1178
+ if (result.length >= limit) return result;
820
1179
  }
821
1180
  }
822
1181
  }
@@ -884,6 +1243,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
884
1243
  return false;
885
1244
  }
886
1245
 
1246
+ if (item.idempotencyKey && item.idempotencyFingerprint) {
1247
+ await this.persistIdempotencyTombstone(
1248
+ item.id,
1249
+ item.idempotencyKey,
1250
+ item.idempotencyFingerprint,
1251
+ );
1252
+ }
887
1253
  // Delete durable state before making the removal visible in memory.
888
1254
  await this.removeItemFromStorage(id);
889
1255
  this.queue.delete(id);
@@ -914,12 +1280,17 @@ export class UnifiedDeliveryQueue extends EventEmitter {
914
1280
  return {
915
1281
  schemaVersion: 2,
916
1282
  id: item.id,
1283
+ ...(item.idempotencyKey !== undefined ? {
1284
+ idempotencyKey: item.idempotencyKey,
1285
+ idempotencyFingerprint: item.idempotencyFingerprint,
1286
+ } : {}),
917
1287
  processingMode: item.processingMode,
918
1288
  processingResult: {
919
1289
  options: item.processingResult.toEmailOptions(),
920
1290
  messageId: item.processingResult.getMessageId(),
921
1291
  envelopeFrom: item.processingResult.getEnvelopeFrom(),
922
1292
  },
1293
+ attachmentCount: item.attachmentCount ?? item.processingResult.attachments.length,
923
1294
  ...(item.route ? { route: item.route } : {}),
924
1295
  status: item.status,
925
1296
  attempts: item.attempts,
@@ -986,6 +1357,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
986
1357
  if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
987
1358
  throw new Error(`Invalid managed queue item at ${key}: missing id`);
988
1359
  }
1360
+ if (
1361
+ parsed.idempotencyKey !== undefined
1362
+ && (
1363
+ typeof parsed.idempotencyKey !== 'string'
1364
+ || !parsed.idempotencyKey
1365
+ || parsed.idempotencyKey.length > 512
1366
+ || typeof parsed.idempotencyFingerprint !== 'string'
1367
+ || !/^[a-f0-9]{64}$/.test(parsed.idempotencyFingerprint)
1368
+ )
1369
+ ) {
1370
+ throw new Error(`Invalid managed queue item at ${key}: invalid idempotency metadata`);
1371
+ }
1372
+ if (parsed.idempotencyKey === undefined && parsed.idempotencyFingerprint !== undefined) {
1373
+ throw new Error(`Invalid managed queue item at ${key}: orphaned idempotency fingerprint`);
1374
+ }
1375
+ if (parsed.idempotencyKey !== undefined) {
1376
+ const expectedId = this.idempotentQueueId(parsed.idempotencyKey);
1377
+ if (parsed.id !== expectedId) {
1378
+ throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
1379
+ }
1380
+ }
989
1381
  if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
990
1382
  throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
991
1383
  }
@@ -1048,7 +1440,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1048
1440
  return !(
1049
1441
  typeof operation.operationId === 'string'
1050
1442
  && operation.operationId.length > 0
1051
- && operation.kind === 'smtpFailure'
1443
+ && ['smtpFailure', 'deliveryFailure'].includes(operation.kind as string)
1444
+ && (
1445
+ operation.failureClass === undefined
1446
+ || ['localTopology', 'transport'].includes(operation.failureClass as string)
1447
+ )
1052
1448
  && typeof operation.recipient === 'string'
1053
1449
  && typeof operation.normalizedRecipient === 'string'
1054
1450
  && operation.normalizedRecipient === operation.recipient.trim().toLowerCase()
@@ -1102,6 +1498,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1102
1498
  entry.responseCode === undefined
1103
1499
  || (Number.isInteger(entry.responseCode) && (entry.responseCode as number) >= 100)
1104
1500
  )
1501
+ && (
1502
+ entry.transferStats === undefined
1503
+ || (
1504
+ Boolean(entry.transferStats)
1505
+ && typeof entry.transferStats === 'object'
1506
+ && Number.isSafeInteger((entry.transferStats as Record<string, unknown>).bytes)
1507
+ && ((entry.transferStats as Record<string, number>).bytes >= 0)
1508
+ && Number.isSafeInteger((entry.transferStats as Record<string, unknown>).chunks)
1509
+ && ((entry.transferStats as Record<string, number>).chunks >= 0)
1510
+ && Number.isSafeInteger((entry.transferStats as Record<string, unknown>).durationMs)
1511
+ && ((entry.transferStats as Record<string, number>).durationMs >= 0)
1512
+ )
1513
+ )
1105
1514
  );
1106
1515
  };
1107
1516
  const isValidTransaction = (value: unknown): boolean => {
@@ -1189,6 +1598,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1189
1598
  throw new Error(`Invalid managed queue item at ${key}: invalid email payload`);
1190
1599
  }
1191
1600
 
1601
+ if (
1602
+ parsed.attachmentCount !== undefined
1603
+ && (!Number.isSafeInteger(parsed.attachmentCount) || parsed.attachmentCount < 0)
1604
+ ) {
1605
+ throw new Error(`Invalid managed queue item at ${key}: invalid attachment count`);
1606
+ }
1607
+
1192
1608
  const storedOptions = parsed.processingResult.options as IEmailOptions;
1193
1609
  if (storedOptions.attachments !== undefined) {
1194
1610
  if (!Array.isArray(storedOptions.attachments)) {
@@ -1243,8 +1659,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1243
1659
 
1244
1660
  return {
1245
1661
  id: parsed.id,
1662
+ ...(parsed.idempotencyKey !== undefined ? {
1663
+ idempotencyKey: parsed.idempotencyKey,
1664
+ idempotencyFingerprint: parsed.idempotencyFingerprint,
1665
+ } : {}),
1246
1666
  processingMode: parsed.processingMode,
1247
1667
  processingResult,
1668
+ attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
1248
1669
  ...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
1249
1670
  status: parsed.status,
1250
1671
  attempts: parsed.attempts,
@@ -1431,6 +1852,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1431
1852
  for (const id of idsToRemove) {
1432
1853
  await this.removeItem(id);
1433
1854
  }
1855
+ await this.cleanupExpiredIdempotencyTombstones();
1434
1856
 
1435
1857
  if (idsToRemove.length > 0) {
1436
1858
  logger.log('info', `Cleaned up ${idsToRemove.length} old items from delivery queue`);