@push.rocks/smartmta 7.0.0 → 8.0.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 (70) hide show
  1. package/changelog.md +30 -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/functions.errors.d.ts +3 -0
  6. package/dist_ts/functions.errors.js +8 -0
  7. package/dist_ts/index.d.ts +3 -0
  8. package/dist_ts/index.js +4 -1
  9. package/dist_ts/mail/core/classes.bouncemanager.d.ts +11 -0
  10. package/dist_ts/mail/core/classes.bouncemanager.js +120 -38
  11. package/dist_ts/mail/core/classes.email.js +14 -12
  12. package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
  13. package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
  14. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +77 -2
  15. package/dist_ts/mail/delivery/classes.delivery.queue.js +551 -46
  16. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
  17. package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
  18. package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
  19. package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
  20. package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
  21. package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
  22. package/dist_ts/mail/delivery/interfaces.js +1 -1
  23. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +8 -4
  24. package/dist_ts/mail/routing/classes.dkim.manager.js +46 -29
  25. package/dist_ts/mail/routing/classes.dns.manager.d.ts +5 -3
  26. package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
  27. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
  28. package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
  29. package/dist_ts/mail/routing/classes.email.router.d.ts +3 -0
  30. package/dist_ts/mail/routing/classes.email.router.js +15 -9
  31. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +4 -0
  32. package/dist_ts/mail/routing/classes.unified.email.server.js +61 -40
  33. package/dist_ts/mail/security/classes.dkimcreator.d.ts +7 -0
  34. package/dist_ts/mail/security/classes.dkimcreator.js +33 -8
  35. package/dist_ts/mail/security/classes.spfverifier.js +5 -3
  36. package/dist_ts/security/classes.contentscanner.js +14 -11
  37. package/dist_ts/security/classes.ipreputationchecker.d.ts +3 -0
  38. package/dist_ts/security/classes.ipreputationchecker.js +20 -11
  39. package/dist_ts/security/classes.rustsecuritybridge.d.ts +49 -1
  40. package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
  41. package/dist_ts/security/classes.securitylogger.js +7 -5
  42. package/dist_ts/security/index.d.ts +1 -1
  43. package/dist_ts/security/index.js +2 -2
  44. package/package.json +8 -8
  45. package/readme.hints.md +4 -3
  46. package/readme.md +41 -13
  47. package/readme.plan.md +6 -0
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/functions.errors.ts +8 -0
  50. package/ts/index.ts +3 -0
  51. package/ts/mail/core/classes.bouncemanager.ts +157 -45
  52. package/ts/mail/core/classes.email.ts +19 -13
  53. package/ts/mail/core/classes.emailvalidator.ts +9 -7
  54. package/ts/mail/delivery/classes.delivery.queue.ts +740 -58
  55. package/ts/mail/delivery/classes.delivery.system.ts +583 -170
  56. package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
  57. package/ts/mail/delivery/functions.safe-observers.ts +45 -0
  58. package/ts/mail/delivery/interfaces.ts +27 -1
  59. package/ts/mail/routing/classes.dkim.manager.ts +62 -37
  60. package/ts/mail/routing/classes.dns.manager.ts +36 -13
  61. package/ts/mail/routing/classes.email.action.executor.ts +64 -17
  62. package/ts/mail/routing/classes.email.router.ts +15 -8
  63. package/ts/mail/routing/classes.unified.email.server.ts +90 -44
  64. package/ts/mail/security/classes.dkimcreator.ts +50 -7
  65. package/ts/mail/security/classes.spfverifier.ts +4 -2
  66. package/ts/security/classes.contentscanner.ts +14 -11
  67. package/ts/security/classes.ipreputationchecker.ts +21 -10
  68. package/ts/security/classes.rustsecuritybridge.ts +269 -3
  69. package/ts/security/classes.securitylogger.ts +6 -4
  70. package/ts/security/index.ts +5 -1
@@ -1,16 +1,57 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { createHash } from 'node:crypto';
2
3
  import { deserialize, serialize } from 'node:v8';
3
4
  import type { IBlobStorageManager } from '../interfaces.storage.js';
4
5
  import { logger } from '../../logger.js';
5
6
  import { type EmailProcessingMode } from './interfaces.js';
7
+ import type { ISmtpTransactionAttempt } from './interfaces.js';
8
+ import {
9
+ normalizeSmtpTranscript,
10
+ SMTP_TRANSCRIPT_MAX_ENTRIES,
11
+ SMTP_TRANSCRIPT_MAX_ENTRY_CODE_POINTS,
12
+ SMTP_TRANSCRIPT_MAX_UTF8_BYTES,
13
+ } from '../../security/classes.rustsecuritybridge.js';
6
14
  import type { IEmailRoute } from '../routing/interfaces.js';
7
15
  import { Email, type IEmailOptions } from '../core/classes.email.js';
16
+ import { emitSafely } from './functions.safe-observers.js';
17
+ import { getErrorMessage } from '../../functions.errors.js';
8
18
 
9
19
  /**
10
20
  * Queue item status
11
21
  */
12
22
  export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
13
23
 
24
+ export type TRecipientDeliveryState = 'unresolved' | 'delivered' | 'permanentFailure';
25
+
26
+ export interface IRecipientDeliveryState {
27
+ recipient: string;
28
+ normalizedRecipient: string;
29
+ state: TRecipientDeliveryState;
30
+ smtpCode?: number;
31
+ smtpResponse?: string;
32
+ updatedAt: Date;
33
+ }
34
+
35
+ export interface IRecipientPermanentFailure {
36
+ recipient: string;
37
+ smtpCode: number;
38
+ smtpResponse: string;
39
+ }
40
+
41
+ export interface IBounceOperation {
42
+ operationId: string;
43
+ kind: 'smtpFailure';
44
+ recipient: string;
45
+ normalizedRecipient: string;
46
+ smtpCode: number;
47
+ smtpResponse: string;
48
+ state: 'pending' | 'completed';
49
+ attempts: number;
50
+ nextAttempt: Date;
51
+ lastError?: string;
52
+ completedAt?: Date;
53
+ }
54
+
14
55
  /**
15
56
  * Queue item interface
16
57
  */
@@ -26,6 +67,11 @@ export interface IQueueItem {
26
67
  createdAt: Date;
27
68
  updatedAt: Date;
28
69
  deliveredAt?: Date;
70
+ deliveredRecipients?: string[];
71
+ terminalRecipients?: string[];
72
+ recipientStates?: IRecipientDeliveryState[];
73
+ bounceOperations?: IBounceOperation[];
74
+ smtpTransactions?: ISmtpTransactionAttempt[];
29
75
  }
30
76
 
31
77
  interface IStoredEmail {
@@ -34,7 +80,7 @@ interface IStoredEmail {
34
80
  envelopeFrom: string;
35
81
  }
36
82
 
37
- interface IStoredQueueItem {
83
+ interface IStoredQueueItemV1 {
38
84
  schemaVersion: 1;
39
85
  id: string;
40
86
  processingMode: EmailProcessingMode;
@@ -47,6 +93,40 @@ interface IStoredQueueItem {
47
93
  createdAt: number;
48
94
  updatedAt: number;
49
95
  deliveredAt?: number;
96
+ deliveredRecipients?: string[];
97
+ terminalRecipients?: string[];
98
+ smtpTransactions?: ISmtpTransactionAttempt[];
99
+ }
100
+
101
+ interface IStoredRecipientDeliveryState extends Omit<IRecipientDeliveryState, 'updatedAt'> {
102
+ updatedAt: number;
103
+ }
104
+
105
+ interface IStoredBounceOperation extends Omit<IBounceOperation, 'nextAttempt' | 'completedAt'> {
106
+ nextAttempt: number;
107
+ completedAt?: number;
108
+ }
109
+
110
+ interface IStoredQueueItemV2 extends Omit<IStoredQueueItemV1, 'schemaVersion'> {
111
+ schemaVersion: 2;
112
+ recipientStates: IStoredRecipientDeliveryState[];
113
+ bounceOperations: IStoredBounceOperation[];
114
+ }
115
+
116
+ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
117
+
118
+ type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
119
+
120
+ export interface IQueueFailureOptions {
121
+ /** Unknown failures default to retryable so temporary internal faults do not lose mail. */
122
+ retryable?: boolean;
123
+ }
124
+
125
+ export interface IQueueFailureTransition {
126
+ updated: boolean;
127
+ status?: 'deferred' | 'failed';
128
+ terminal: boolean;
129
+ terminalReason?: 'permanent' | 'retry_exhausted';
50
130
  }
51
131
 
52
132
  /**
@@ -110,6 +190,9 @@ export interface IQueueStats {
110
190
  processingActive: boolean;
111
191
  }
112
192
 
193
+ const managedQueueOwners =
194
+ new WeakMap<IBlobStorageManager, Map<string, UnifiedDeliveryQueue>>();
195
+
113
196
  /**
114
197
  * A unified queue for all email modes
115
198
  */
@@ -131,6 +214,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
131
214
  private stats: IQueueStats;
132
215
  private processing: boolean = false;
133
216
  private totalProcessed: number = 0;
217
+ private ownsManagedPrefix = false;
218
+ private lifecycleState: TQueueLifecycleState = 'new';
134
219
 
135
220
  /**
136
221
  * Create a new unified delivery queue
@@ -178,26 +263,98 @@ export class UnifiedDeliveryQueue extends EventEmitter {
178
263
  processingActive: false
179
264
  };
180
265
  }
266
+
267
+ private assertMutable(): void {
268
+ if (this.lifecycleState === 'shutting-down' || this.lifecycleState === 'closed') {
269
+ throw new Error(`Delivery queue is ${this.lifecycleState}; mutations are no longer allowed`);
270
+ }
271
+ }
272
+
273
+ private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
274
+ const byRecipient = new Map<string, IRecipientDeliveryState>();
275
+ const now = new Date();
276
+ for (const recipient of email.getAllRecipients()) {
277
+ const normalizedRecipient = recipient.trim().toLowerCase();
278
+ if (!normalizedRecipient || byRecipient.has(normalizedRecipient)) continue;
279
+ byRecipient.set(normalizedRecipient, {
280
+ recipient,
281
+ normalizedRecipient,
282
+ state: 'unresolved',
283
+ updatedAt: now,
284
+ });
285
+ }
286
+ return [...byRecipient.values()];
287
+ }
288
+
289
+ private createBounceOperationId(itemId: string, normalizedRecipient: string): string {
290
+ return createHash('sha256')
291
+ .update(itemId)
292
+ .update('\0')
293
+ .update(normalizedRecipient)
294
+ .update('\0smtpFailure')
295
+ .digest('hex');
296
+ }
181
297
 
182
298
  /**
183
299
  * Initialize the queue
184
300
  */
185
- public async initialize(): Promise<void> {
301
+ private claimManagedPrefix(): void {
302
+ const storageManager = this.options.storageManager;
303
+ if (!storageManager || this.ownsManagedPrefix) return;
304
+ let owners = managedQueueOwners.get(storageManager);
305
+ if (!owners) {
306
+ owners = new Map();
307
+ managedQueueOwners.set(storageManager, owners);
308
+ }
309
+ const existingOwner = owners.get(this.options.storagePrefix);
310
+ if (existingOwner && existingOwner !== this) {
311
+ throw new Error(
312
+ `Managed queue prefix ${this.options.storagePrefix} already has an active delivery owner`,
313
+ );
314
+ }
315
+ owners.set(this.options.storagePrefix, this);
316
+ this.ownsManagedPrefix = true;
317
+ }
318
+
319
+ private releaseManagedPrefix(): void {
320
+ const storageManager = this.options.storageManager;
321
+ if (!storageManager || !this.ownsManagedPrefix) return;
322
+ const owners = managedQueueOwners.get(storageManager);
323
+ if (owners?.get(this.options.storagePrefix) === this) {
324
+ owners.delete(this.options.storagePrefix);
325
+ if (owners.size === 0) managedQueueOwners.delete(storageManager);
326
+ }
327
+ this.ownsManagedPrefix = false;
328
+ }
329
+
330
+ public async initialize(optionsArg: { startProcessing?: boolean } = {}): Promise<void> {
186
331
  logger.log('info', 'Initializing UnifiedDeliveryQueue');
187
-
332
+
333
+ if (this.lifecycleState !== 'new') {
334
+ throw new Error(`Delivery queue cannot initialize from ${this.lifecycleState} state`);
335
+ }
336
+
337
+ this.claimManagedPrefix();
188
338
  try {
189
339
  if (this.options.storageManager) {
190
340
  await this.loadFromStorage();
191
341
  }
342
+ this.lifecycleState = 'initialized';
192
343
 
193
- // Start the queue processing timer
194
- this.startProcessing();
344
+ // Embedders may hydrate durable state before delivery dependencies are ready.
345
+ if (optionsArg.startProcessing !== false) {
346
+ this.startProcessing();
347
+ }
195
348
 
196
349
  // Emit initialized event
197
- this.emit('initialized');
350
+ await emitSafely(this, 'initialized');
198
351
  logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
199
352
  } catch (error) {
200
- logger.log('error', `Failed to initialize queue: ${error.message}`);
353
+ this.queue.clear();
354
+ this.updateStats();
355
+ this.lifecycleState = 'new';
356
+ this.releaseManagedPrefix();
357
+ logger.log('error', `Failed to initialize queue: ${(error as Error).message}`);
201
358
  throw error;
202
359
  }
203
360
  }
@@ -224,7 +381,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
224
381
 
225
382
  this.processing = true;
226
383
  this.stats.processingActive = true;
227
- this.emit('processingStarted');
384
+ void emitSafely(this, 'processingStarted');
228
385
  logger.log('info', 'Queue processing started');
229
386
  }
230
387
 
@@ -243,7 +400,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
243
400
 
244
401
  this.processing = false;
245
402
  this.stats.processingActive = false;
246
- this.emit('processingStopped');
403
+ void emitSafely(this, 'processingStopped');
247
404
  logger.log('info', 'Queue processing stopped');
248
405
  }
249
406
 
@@ -270,14 +427,14 @@ export class UnifiedDeliveryQueue extends EventEmitter {
270
427
  readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
271
428
 
272
429
  // Emit event for ready items
273
- this.emit('itemsReady', readyItems);
430
+ await emitSafely(this, 'itemsReady', readyItems);
274
431
  logger.log('info', `Found ${readyItems.length} items ready for processing`);
275
432
 
276
433
  // Update statistics
277
434
  this.updateStats();
278
435
  } catch (error) {
279
- logger.log('error', `Error processing queue: ${error.message}`);
280
- this.emit('error', error);
436
+ logger.log('error', `Error processing queue: ${getErrorMessage(error)}`);
437
+ await emitSafely(this, 'error', error);
281
438
  }
282
439
  }
283
440
 
@@ -292,6 +449,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
292
449
  mode: EmailProcessingMode,
293
450
  route?: IEmailRoute,
294
451
  ): Promise<string> {
452
+ this.assertMutable();
295
453
  if (!(processingResult instanceof Email)) {
296
454
  throw new Error('Queue processingResult must be an Email instance');
297
455
  }
@@ -314,7 +472,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
314
472
  attempts: 0,
315
473
  nextAttempt: new Date(),
316
474
  createdAt: new Date(),
317
- updatedAt: new Date()
475
+ updatedAt: new Date(),
476
+ deliveredRecipients: [],
477
+ terminalRecipients: [],
478
+ recipientStates: this.createInitialRecipientStates(processingResult),
479
+ bounceOperations: [],
480
+ smtpTransactions: [],
318
481
  };
319
482
 
320
483
  // Commit durable state before exposing the item in memory or emitting acceptance.
@@ -325,7 +488,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
325
488
  this.updateStats();
326
489
 
327
490
  // Emit event
328
- this.emit('itemEnqueued', item);
491
+ await emitSafely(this, 'itemEnqueued', item);
329
492
  logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
330
493
 
331
494
  return id;
@@ -340,7 +503,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
340
503
  }
341
504
 
342
505
  public listItems(): IQueueItem[] {
343
- return Array.from(this.queue.values()).map((item) => ({ ...item }));
506
+ return Array.from(this.queue.values()).map((item) => ({
507
+ ...item,
508
+ deliveredRecipients: [...(item.deliveredRecipients || [])],
509
+ terminalRecipients: [...(item.terminalRecipients || [])],
510
+ recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
511
+ bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
512
+ smtpTransactions: (item.smtpTransactions || []).map((transaction) => ({
513
+ ...transaction,
514
+ recipients: [...transaction.recipients],
515
+ recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
516
+ transcript: transaction.transcript.map((entry) => ({ ...entry })),
517
+ })),
518
+ }));
344
519
  }
345
520
 
346
521
  /**
@@ -348,9 +523,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
348
523
  * @param id Item ID
349
524
  */
350
525
  public async markProcessing(id: string): Promise<boolean> {
526
+ this.assertMutable();
351
527
  const item = this.queue.get(id);
352
528
 
353
- if (!item) {
529
+ if (
530
+ !item
531
+ || !(
532
+ item.status === 'pending'
533
+ || (item.status === 'deferred' && item.nextAttempt <= new Date())
534
+ )
535
+ ) {
354
536
  return false;
355
537
  }
356
538
 
@@ -364,7 +546,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
364
546
  this.updateStats();
365
547
 
366
548
  // Emit event
367
- this.emit('itemProcessing', item);
549
+ await emitSafely(this, 'itemProcessing', item);
368
550
  logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
369
551
 
370
552
  return true;
@@ -375,9 +557,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
375
557
  * @param id Item ID
376
558
  */
377
559
  public async markDelivered(id: string): Promise<boolean> {
560
+ this.assertMutable();
378
561
  const item = this.queue.get(id);
379
562
 
380
- if (!item) {
563
+ if (!item || item.status === 'delivered' || item.status === 'failed') {
381
564
  return false;
382
565
  }
383
566
 
@@ -392,7 +575,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
392
575
  this.updateStats();
393
576
 
394
577
  // Emit event
395
- this.emit('itemDelivered', item);
578
+ await emitSafely(this, 'itemDelivered', item);
396
579
  logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
397
580
 
398
581
  return true;
@@ -403,15 +586,37 @@ export class UnifiedDeliveryQueue extends EventEmitter {
403
586
  * @param id Item ID
404
587
  * @param error Error message
405
588
  */
406
- public async markFailed(id: string, error: string): Promise<boolean> {
589
+ public async markFailed(
590
+ id: string,
591
+ error: string,
592
+ optionsArg: IQueueFailureOptions = {},
593
+ ): Promise<boolean> {
594
+ return (await this.markFailedWithTransition(id, error, optionsArg)).updated;
595
+ }
596
+
597
+ /** Rich internal disposition used by the delivery system without breaking markFailed(). */
598
+ public async markFailedWithTransition(
599
+ id: string,
600
+ error: string,
601
+ optionsArg: IQueueFailureOptions = {},
602
+ ): Promise<IQueueFailureTransition> {
603
+ this.assertMutable();
407
604
  const item = this.queue.get(id);
408
605
 
409
606
  if (!item) {
410
- return false;
607
+ return { updated: false, terminal: false };
608
+ }
609
+ if (item.status === 'delivered' || item.status === 'failed') {
610
+ return {
611
+ updated: false,
612
+ status: item.status === 'failed' ? 'failed' : undefined,
613
+ terminal: true,
614
+ };
411
615
  }
412
616
 
413
617
  // Determine if we should retry
414
- if (item.attempts < this.options.maxRetries) {
618
+ const retryable = optionsArg.retryable !== false;
619
+ if (retryable && item.attempts < this.options.maxRetries) {
415
620
  // Calculate next retry time with exponential backoff
416
621
  const delay = Math.min(
417
622
  this.options.baseRetryDelay * Math.pow(2, item.attempts - 1),
@@ -426,8 +631,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
426
631
  });
427
632
 
428
633
  // Emit event
429
- this.emit('itemDeferred', item);
634
+ await emitSafely(this, 'itemDeferred', item);
430
635
  logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
636
+ this.updateStats();
637
+ return { updated: true, status: 'deferred', terminal: false };
431
638
  } else {
432
639
  // Mark as permanently failed
433
640
  await this.applyPersistedMutation(item, (updatedItem) => {
@@ -440,21 +647,237 @@ export class UnifiedDeliveryQueue extends EventEmitter {
440
647
  this.totalProcessed++;
441
648
 
442
649
  // Emit event
443
- this.emit('itemFailed', item);
650
+ await emitSafely(this, 'itemFailed', item);
444
651
  logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
445
652
  }
446
-
447
- // Update statistics
653
+
448
654
  this.updateStats();
449
-
655
+ return {
656
+ updated: true,
657
+ status: 'failed',
658
+ terminal: true,
659
+ terminalReason: retryable ? 'retry_exhausted' : 'permanent',
660
+ };
661
+ }
662
+
663
+ /** Persist a completed SMTP attempt before any queue status event is emitted. */
664
+ public async appendSmtpTransaction(
665
+ id: string,
666
+ transaction: ISmtpTransactionAttempt,
667
+ ): Promise<boolean> {
668
+ this.assertMutable();
669
+ const item = this.queue.get(id);
670
+ if (!item) return false;
671
+ const normalizedTransaction: ISmtpTransactionAttempt = {
672
+ ...transaction,
673
+ queueItemId: id,
674
+ recipients: [...transaction.recipients],
675
+ recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
676
+ transcript: normalizeSmtpTranscript(transaction.transcript),
677
+ };
678
+ await this.applyPersistedMutation(item, (updatedItem) => {
679
+ updatedItem.smtpTransactions = [
680
+ ...(updatedItem.smtpTransactions || []),
681
+ normalizedTransaction,
682
+ ].slice(-20);
683
+ updatedItem.updatedAt = new Date();
684
+ });
685
+ await emitSafely(this, 'smtpTransaction', item, normalizedTransaction);
450
686
  return true;
451
687
  }
452
-
688
+
689
+ /** Record recipients accepted after the SMTP final-response 250 checkpoint. */
690
+ public async markRecipientsDelivered(id: string, recipients: string[]): Promise<boolean> {
691
+ this.assertMutable();
692
+ const item = this.queue.get(id);
693
+ if (!item) return false;
694
+ const normalizedRecipients = new Set(
695
+ recipients.map((recipient) => recipient.trim().toLowerCase()).filter(Boolean),
696
+ );
697
+ if (normalizedRecipients.size === 0) return false;
698
+
699
+ await this.applyPersistedMutation(item, (updatedItem) => {
700
+ const states = updatedItem.recipientStates || this.createInitialRecipientStates(
701
+ updatedItem.processingResult,
702
+ );
703
+ const delivered = new Set(updatedItem.deliveredRecipients || []);
704
+ const now = new Date();
705
+ for (const state of states) {
706
+ if (
707
+ normalizedRecipients.has(state.normalizedRecipient)
708
+ && state.state === 'unresolved'
709
+ ) {
710
+ state.state = 'delivered';
711
+ state.updatedAt = now;
712
+ delete state.smtpCode;
713
+ delete state.smtpResponse;
714
+ delivered.add(state.normalizedRecipient);
715
+ }
716
+ }
717
+ updatedItem.recipientStates = states;
718
+ updatedItem.deliveredRecipients = [...delivered];
719
+ updatedItem.updatedAt = now;
720
+ });
721
+ return true;
722
+ }
723
+
724
+ /**
725
+ * Atomically checkpoint permanent recipient failures together with their
726
+ * durable bounce work. This is the sole 5xx recipient transition.
727
+ */
728
+ public async markRecipientsTerminal(
729
+ id: string,
730
+ failures: Array<string | IRecipientPermanentFailure>,
731
+ ): Promise<string[]> {
732
+ this.assertMutable();
733
+ const item = this.queue.get(id);
734
+ if (!item || failures.length === 0) return [];
735
+ const normalizedFailures = failures.map((failure) => (
736
+ typeof failure === 'string'
737
+ ? {
738
+ recipient: failure,
739
+ smtpCode: 550,
740
+ smtpResponse: 'SMTP recipient permanently rejected',
741
+ }
742
+ : failure
743
+ ));
744
+ const newlyTerminal: string[] = [];
745
+
746
+ await this.applyPersistedMutation(item, (updatedItem) => {
747
+ const states = updatedItem.recipientStates || this.createInitialRecipientStates(
748
+ updatedItem.processingResult,
749
+ );
750
+ const terminal = new Set(updatedItem.terminalRecipients || []);
751
+ const operations = updatedItem.bounceOperations || [];
752
+ const operationIds = new Set(operations.map((operation) => operation.operationId));
753
+ const now = new Date();
754
+
755
+ for (const failure of normalizedFailures) {
756
+ const normalizedRecipient = failure.recipient.trim().toLowerCase();
757
+ const state = states.find(
758
+ (candidate) => candidate.normalizedRecipient === normalizedRecipient,
759
+ );
760
+ if (!state || state.state !== 'unresolved') continue;
761
+
762
+ state.state = 'permanentFailure';
763
+ state.smtpCode = failure.smtpCode;
764
+ state.smtpResponse = failure.smtpResponse;
765
+ state.updatedAt = now;
766
+ terminal.add(normalizedRecipient);
767
+ newlyTerminal.push(state.recipient);
768
+
769
+ const operationId = this.createBounceOperationId(updatedItem.id, normalizedRecipient);
770
+ if (!operationIds.has(operationId)) {
771
+ operations.push({
772
+ operationId,
773
+ kind: 'smtpFailure',
774
+ recipient: state.recipient,
775
+ normalizedRecipient,
776
+ smtpCode: failure.smtpCode,
777
+ smtpResponse: failure.smtpResponse,
778
+ state: 'pending',
779
+ attempts: 0,
780
+ nextAttempt: now,
781
+ });
782
+ operationIds.add(operationId);
783
+ }
784
+ }
785
+
786
+ updatedItem.recipientStates = states;
787
+ updatedItem.terminalRecipients = [...terminal];
788
+ updatedItem.bounceOperations = operations;
789
+ updatedItem.updatedAt = now;
790
+ });
791
+ return newlyTerminal;
792
+ }
793
+
794
+ public getRecipientDisposition(id: string): {
795
+ unresolved: string[];
796
+ delivered: string[];
797
+ permanentFailures: string[];
798
+ } {
799
+ const item = this.queue.get(id);
800
+ if (!item) return { unresolved: [], delivered: [], permanentFailures: [] };
801
+ const states = item.recipientStates || this.createInitialRecipientStates(item.processingResult);
802
+ return {
803
+ unresolved: states.filter((state) => state.state === 'unresolved').map((state) => state.recipient),
804
+ delivered: states.filter((state) => state.state === 'delivered').map((state) => state.recipient),
805
+ permanentFailures: states
806
+ .filter((state) => state.state === 'permanentFailure')
807
+ .map((state) => state.recipient),
808
+ };
809
+ }
810
+
811
+ /** Includes terminal queue items because bounce work outlives delivery status. */
812
+ public listPendingBounceOperations(
813
+ now: Date = new Date(),
814
+ ): Array<{ itemId: string; operation: IBounceOperation }> {
815
+ const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
816
+ for (const item of this.queue.values()) {
817
+ for (const operation of item.bounceOperations || []) {
818
+ if (operation.state === 'pending' && operation.nextAttempt <= now) {
819
+ result.push({ itemId: item.id, operation: { ...operation } });
820
+ }
821
+ }
822
+ }
823
+ return result;
824
+ }
825
+
826
+ public async markBounceOperationCompleted(
827
+ itemId: string,
828
+ operationId: string,
829
+ ): Promise<boolean> {
830
+ this.assertMutable();
831
+ const item = this.queue.get(itemId);
832
+ const operation = item?.bounceOperations?.find(
833
+ (candidate) => candidate.operationId === operationId,
834
+ );
835
+ if (!item || !operation || operation.state === 'completed') return false;
836
+ await this.applyPersistedMutation(item, (updatedItem) => {
837
+ const updatedOperation = updatedItem.bounceOperations?.find(
838
+ (candidate) => candidate.operationId === operationId,
839
+ );
840
+ if (!updatedOperation) return;
841
+ updatedOperation.state = 'completed';
842
+ updatedOperation.completedAt = new Date();
843
+ delete updatedOperation.lastError;
844
+ updatedItem.updatedAt = new Date();
845
+ });
846
+ return true;
847
+ }
848
+
849
+ public async markBounceOperationFailed(
850
+ itemId: string,
851
+ operationId: string,
852
+ error: string,
853
+ ): Promise<boolean> {
854
+ this.assertMutable();
855
+ const item = this.queue.get(itemId);
856
+ const operation = item?.bounceOperations?.find(
857
+ (candidate) => candidate.operationId === operationId,
858
+ );
859
+ if (!item || !operation || operation.state === 'completed') return false;
860
+ await this.applyPersistedMutation(item, (updatedItem) => {
861
+ const updatedOperation = updatedItem.bounceOperations?.find(
862
+ (candidate) => candidate.operationId === operationId,
863
+ );
864
+ if (!updatedOperation) return;
865
+ updatedOperation.attempts++;
866
+ updatedOperation.lastError = error;
867
+ updatedOperation.nextAttempt = new Date(
868
+ Date.now() + Math.min(60_000 * (2 ** Math.max(0, updatedOperation.attempts - 1)), 3_600_000),
869
+ );
870
+ updatedItem.updatedAt = new Date();
871
+ });
872
+ return true;
873
+ }
874
+
453
875
  /**
454
876
  * Remove an item from the queue
455
877
  * @param id Item ID
456
878
  */
457
879
  public async removeItem(id: string): Promise<boolean> {
880
+ this.assertMutable();
458
881
  const item = this.queue.get(id);
459
882
 
460
883
  if (!item) {
@@ -469,7 +892,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
469
892
  this.updateStats();
470
893
 
471
894
  // Emit event
472
- this.emit('itemRemoved', item);
895
+ await emitSafely(this, 'itemRemoved', item);
473
896
  logger.log('info', `Item ${id} removed from queue`);
474
897
 
475
898
  return true;
@@ -489,7 +912,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
489
912
 
490
913
  private serializeItem(item: IQueueItem): IStoredQueueItem {
491
914
  return {
492
- schemaVersion: 1,
915
+ schemaVersion: 2,
493
916
  id: item.id,
494
917
  processingMode: item.processingMode,
495
918
  processingResult: {
@@ -505,6 +928,29 @@ export class UnifiedDeliveryQueue extends EventEmitter {
505
928
  createdAt: item.createdAt.getTime(),
506
929
  updatedAt: item.updatedAt.getTime(),
507
930
  ...(item.deliveredAt ? { deliveredAt: item.deliveredAt.getTime() } : {}),
931
+ deliveredRecipients: item.deliveredRecipients || [],
932
+ terminalRecipients: item.terminalRecipients || [],
933
+ recipientStates: (item.recipientStates || this.createInitialRecipientStates(
934
+ item.processingResult,
935
+ )).map((state) => ({
936
+ ...state,
937
+ updatedAt: state.updatedAt.getTime(),
938
+ })),
939
+ bounceOperations: (item.bounceOperations || []).map((operation) => {
940
+ const { nextAttempt, completedAt, ...storedOperation } = operation;
941
+ return {
942
+ ...storedOperation,
943
+ nextAttempt: nextAttempt.getTime(),
944
+ ...(completedAt ? { completedAt: completedAt.getTime() } : {}),
945
+ };
946
+ }),
947
+ smtpTransactions: (item.smtpTransactions || []).slice(-20).map((transaction) => ({
948
+ ...transaction,
949
+ queueItemId: item.id,
950
+ recipients: [...transaction.recipients],
951
+ recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
952
+ transcript: normalizeSmtpTranscript(transaction.transcript),
953
+ })),
508
954
  };
509
955
  }
510
956
 
@@ -512,7 +958,15 @@ export class UnifiedDeliveryQueue extends EventEmitter {
512
958
  item: IQueueItem,
513
959
  mutation: (updatedItem: IQueueItem) => void,
514
960
  ): Promise<void> {
515
- const updatedItem = { ...item };
961
+ this.assertMutable();
962
+ const updatedItem: IQueueItem = {
963
+ ...item,
964
+ deliveredRecipients: [...(item.deliveredRecipients || [])],
965
+ terminalRecipients: [...(item.terminalRecipients || [])],
966
+ recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
967
+ bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
968
+ smtpTransactions: [...(item.smtpTransactions || [])],
969
+ };
516
970
  mutation(updatedItem);
517
971
  await this.persistItem(updatedItem);
518
972
  Object.assign(item, updatedItem);
@@ -527,12 +981,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
527
981
  try {
528
982
  parsed = deserialize(data) as Record<string, any>;
529
983
  } catch (error) {
530
- throw new Error(`Invalid managed queue item at ${key}: ${error.message}`);
984
+ throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
531
985
  }
532
986
  if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
533
987
  throw new Error(`Invalid managed queue item at ${key}: missing id`);
534
988
  }
535
- if (parsed.schemaVersion !== 1) {
989
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
536
990
  throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
537
991
  }
538
992
  if (this.storageKey(parsed.id) !== key) {
@@ -548,6 +1002,174 @@ export class UnifiedDeliveryQueue extends EventEmitter {
548
1002
  throw new Error(`Invalid managed queue item at ${key}: invalid attempt count`);
549
1003
  }
550
1004
 
1005
+ if (
1006
+ parsed.deliveredRecipients !== undefined
1007
+ && (
1008
+ !Array.isArray(parsed.deliveredRecipients)
1009
+ || parsed.deliveredRecipients.some((value: unknown) => typeof value !== 'string')
1010
+ )
1011
+ ) {
1012
+ throw new Error(`Invalid managed queue item at ${key}: invalid delivered recipients`);
1013
+ }
1014
+ if (
1015
+ parsed.terminalRecipients !== undefined
1016
+ && (
1017
+ !Array.isArray(parsed.terminalRecipients)
1018
+ || parsed.terminalRecipients.some((value: unknown) => typeof value !== 'string')
1019
+ )
1020
+ ) {
1021
+ throw new Error(`Invalid managed queue item at ${key}: invalid terminal recipients`);
1022
+ }
1023
+
1024
+ if (parsed.schemaVersion === 2) {
1025
+ if (
1026
+ !Array.isArray(parsed.recipientStates)
1027
+ || parsed.recipientStates.some((value: unknown) => {
1028
+ if (!value || typeof value !== 'object') return true;
1029
+ const state = value as Record<string, unknown>;
1030
+ return !(
1031
+ typeof state.recipient === 'string'
1032
+ && typeof state.normalizedRecipient === 'string'
1033
+ && state.normalizedRecipient === state.recipient.trim().toLowerCase()
1034
+ && ['unresolved', 'delivered', 'permanentFailure'].includes(state.state as string)
1035
+ && Number.isFinite(state.updatedAt)
1036
+ && (state.smtpCode === undefined || Number.isInteger(state.smtpCode))
1037
+ && (state.smtpResponse === undefined || typeof state.smtpResponse === 'string')
1038
+ );
1039
+ })
1040
+ ) {
1041
+ throw new Error(`Invalid managed queue item at ${key}: invalid recipient states`);
1042
+ }
1043
+ if (
1044
+ !Array.isArray(parsed.bounceOperations)
1045
+ || parsed.bounceOperations.some((value: unknown) => {
1046
+ if (!value || typeof value !== 'object') return true;
1047
+ const operation = value as Record<string, unknown>;
1048
+ return !(
1049
+ typeof operation.operationId === 'string'
1050
+ && operation.operationId.length > 0
1051
+ && operation.kind === 'smtpFailure'
1052
+ && typeof operation.recipient === 'string'
1053
+ && typeof operation.normalizedRecipient === 'string'
1054
+ && operation.normalizedRecipient === operation.recipient.trim().toLowerCase()
1055
+ && Number.isInteger(operation.smtpCode)
1056
+ && (operation.smtpCode as number) >= 500
1057
+ && typeof operation.smtpResponse === 'string'
1058
+ && ['pending', 'completed'].includes(operation.state as string)
1059
+ && Number.isInteger(operation.attempts)
1060
+ && (operation.attempts as number) >= 0
1061
+ && Number.isFinite(operation.nextAttempt)
1062
+ && (operation.lastError === undefined || typeof operation.lastError === 'string')
1063
+ && (operation.completedAt === undefined || Number.isFinite(operation.completedAt))
1064
+ );
1065
+ })
1066
+ ) {
1067
+ throw new Error(`Invalid managed queue item at ${key}: invalid bounce operations`);
1068
+ }
1069
+ }
1070
+
1071
+ const deliveryPhases = new Set([
1072
+ 'connect',
1073
+ 'greeting',
1074
+ 'ehlo',
1075
+ 'starttls',
1076
+ 'tls_handshake',
1077
+ 'post_tls_ehlo',
1078
+ 'auth',
1079
+ 'mail_from',
1080
+ 'rcpt_to',
1081
+ 'data_command',
1082
+ 'message_body',
1083
+ 'final_response',
1084
+ 'rset',
1085
+ 'quit',
1086
+ 'unknown',
1087
+ ]);
1088
+ const isValidTimestamp = (value: unknown): value is string => (
1089
+ typeof value === 'string' && Number.isFinite(Date.parse(value))
1090
+ );
1091
+ const isValidTranscriptEntry = (value: unknown): boolean => {
1092
+ if (!value || typeof value !== 'object') return false;
1093
+ const entry = value as Record<string, unknown>;
1094
+ return (
1095
+ typeof entry.timestampMs === 'number'
1096
+ && Number.isFinite(entry.timestampMs)
1097
+ && deliveryPhases.has(entry.phase as string)
1098
+ && ['client', 'server', 'system'].includes(entry.direction as string)
1099
+ && typeof entry.text === 'string'
1100
+ && [...entry.text].length <= SMTP_TRANSCRIPT_MAX_ENTRY_CODE_POINTS
1101
+ && (
1102
+ entry.responseCode === undefined
1103
+ || (Number.isInteger(entry.responseCode) && (entry.responseCode as number) >= 100)
1104
+ )
1105
+ );
1106
+ };
1107
+ const isValidTransaction = (value: unknown): boolean => {
1108
+ if (!value || typeof value !== 'object') return false;
1109
+ const transaction = value as Record<string, unknown>;
1110
+ return (
1111
+ typeof transaction.id === 'string'
1112
+ && transaction.id.length > 0
1113
+ && transaction.queueItemId === parsed.id
1114
+ && Number.isInteger(transaction.queueAttempt)
1115
+ && (transaction.queueAttempt as number) >= 0
1116
+ && typeof transaction.targetHost === 'string'
1117
+ && transaction.targetHost.length > 0
1118
+ && Number.isInteger(transaction.targetPort)
1119
+ && (transaction.targetPort as number) >= 1
1120
+ && (transaction.targetPort as number) <= 65_535
1121
+ && (
1122
+ transaction.recipientDomain === undefined
1123
+ || typeof transaction.recipientDomain === 'string'
1124
+ )
1125
+ && Array.isArray(transaction.recipients)
1126
+ && transaction.recipients.every((recipient) => typeof recipient === 'string')
1127
+ && isValidTimestamp(transaction.startedAt)
1128
+ && isValidTimestamp(transaction.completedAt)
1129
+ && typeof transaction.durationMs === 'number'
1130
+ && Number.isFinite(transaction.durationMs)
1131
+ && transaction.durationMs >= 0
1132
+ && ['succeeded', 'failed'].includes(transaction.outcome as string)
1133
+ && typeof transaction.retryable === 'boolean'
1134
+ && (transaction.errorType === undefined || typeof transaction.errorType === 'string')
1135
+ && (transaction.smtpCode === undefined || Number.isInteger(transaction.smtpCode))
1136
+ && (
1137
+ transaction.recipientResults === undefined
1138
+ || (
1139
+ Array.isArray(transaction.recipientResults)
1140
+ && transaction.recipientResults.every((value) => {
1141
+ if (!value || typeof value !== 'object') return false;
1142
+ const result = value as Record<string, unknown>;
1143
+ return (
1144
+ typeof result.recipient === 'string'
1145
+ && typeof result.accepted === 'boolean'
1146
+ && Number.isInteger(result.responseCode)
1147
+ );
1148
+ })
1149
+ )
1150
+ )
1151
+ && (transaction.failurePhase === undefined || deliveryPhases.has(transaction.failurePhase as string))
1152
+ && (transaction.error === undefined || typeof transaction.error === 'string')
1153
+ && Array.isArray(transaction.transcript)
1154
+ && transaction.transcript.length <= SMTP_TRANSCRIPT_MAX_ENTRIES
1155
+ && transaction.transcript.every(isValidTranscriptEntry)
1156
+ && transaction.transcript.reduce(
1157
+ (sum, entry) => sum + Buffer.byteLength((entry as { text: string }).text, 'utf8'),
1158
+ 0,
1159
+ ) <= SMTP_TRANSCRIPT_MAX_UTF8_BYTES
1160
+ );
1161
+ };
1162
+ if (
1163
+ parsed.smtpTransactions !== undefined
1164
+ && (
1165
+ !Array.isArray(parsed.smtpTransactions)
1166
+ || parsed.smtpTransactions.length > 20
1167
+ || parsed.smtpTransactions.some((value: unknown) => !isValidTransaction(value))
1168
+ )
1169
+ ) {
1170
+ throw new Error(`Invalid managed queue item at ${key}: invalid SMTP transactions`);
1171
+ }
1172
+
551
1173
  const parseDate = (value: unknown, field: string): Date => {
552
1174
  if (!Number.isFinite(value) || (value as number) < 0) {
553
1175
  throw new Error(`Invalid managed queue item at ${key}: invalid ${field}`);
@@ -585,7 +1207,38 @@ export class UnifiedDeliveryQueue extends EventEmitter {
585
1207
  .setMessageId(parsed.processingResult.messageId)
586
1208
  .setEnvelopeFrom(parsed.processingResult.envelopeFrom);
587
1209
  } catch (error) {
588
- throw new Error(`Invalid managed queue item at ${key}: ${error.message}`);
1210
+ throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
1211
+ }
1212
+
1213
+ let recipientStates: IRecipientDeliveryState[];
1214
+ let bounceOperations: IBounceOperation[];
1215
+ if (parsed.schemaVersion === 2) {
1216
+ recipientStates = parsed.recipientStates.map((state: IStoredRecipientDeliveryState) => ({
1217
+ ...state,
1218
+ updatedAt: parseDate(state.updatedAt, 'recipient state updatedAt'),
1219
+ }));
1220
+ bounceOperations = parsed.bounceOperations.map((operation: IStoredBounceOperation) => ({
1221
+ ...operation,
1222
+ nextAttempt: parseDate(operation.nextAttempt, 'bounce operation nextAttempt'),
1223
+ ...(operation.completedAt !== undefined
1224
+ ? { completedAt: parseDate(operation.completedAt, 'bounce operation completedAt') }
1225
+ : {}),
1226
+ }));
1227
+ } else {
1228
+ // V1 hydration deliberately starts with an empty outbox. Bounce work is
1229
+ // never synthesized retroactively without a durable recipient transition.
1230
+ recipientStates = this.createInitialRecipientStates(processingResult);
1231
+ const delivered = new Set(
1232
+ (parsed.deliveredRecipients || []).map((recipient: string) => recipient.toLowerCase()),
1233
+ );
1234
+ const terminal = new Set(
1235
+ (parsed.terminalRecipients || []).map((recipient: string) => recipient.toLowerCase()),
1236
+ );
1237
+ for (const state of recipientStates) {
1238
+ if (delivered.has(state.normalizedRecipient)) state.state = 'delivered';
1239
+ else if (terminal.has(state.normalizedRecipient)) state.state = 'permanentFailure';
1240
+ }
1241
+ bounceOperations = [];
589
1242
  }
590
1243
 
591
1244
  return {
@@ -602,6 +1255,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
602
1255
  ...(parsed.deliveredAt
603
1256
  ? { deliveredAt: parseDate(parsed.deliveredAt, 'deliveredAt') }
604
1257
  : {}),
1258
+ deliveredRecipients: Array.isArray(parsed.deliveredRecipients)
1259
+ ? [...parsed.deliveredRecipients]
1260
+ : [],
1261
+ terminalRecipients: Array.isArray(parsed.terminalRecipients)
1262
+ ? [...parsed.terminalRecipients]
1263
+ : [],
1264
+ recipientStates,
1265
+ bounceOperations,
1266
+ smtpTransactions: Array.isArray(parsed.smtpTransactions)
1267
+ ? parsed.smtpTransactions.slice(-20).map((transaction) => ({
1268
+ ...transaction,
1269
+ recipients: [...transaction.recipients],
1270
+ recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
1271
+ transcript: normalizeSmtpTranscript(transaction.transcript),
1272
+ }))
1273
+ : [],
605
1274
  } as IQueueItem;
606
1275
  }
607
1276
 
@@ -703,7 +1372,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
703
1372
  this.stats.processingActive = this.processing;
704
1373
 
705
1374
  // Emit statistics event
706
- this.emit('statsUpdated', this.stats);
1375
+ void emitSafely(this, 'statsUpdated', this.stats);
707
1376
  }
708
1377
 
709
1378
  /**
@@ -717,6 +1386,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
717
1386
  * Pause queue processing
718
1387
  */
719
1388
  public pause(): void {
1389
+ this.assertMutable();
720
1390
  if (this.processing) {
721
1391
  this.stopProcessing();
722
1392
  logger.log('info', 'Queue processing paused');
@@ -727,6 +1397,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
727
1397
  * Resume queue processing
728
1398
  */
729
1399
  public resume(): void {
1400
+ this.assertMutable();
730
1401
  if (!this.processing) {
731
1402
  this.startProcessing();
732
1403
  logger.log('info', 'Queue processing resumed');
@@ -738,12 +1409,20 @@ export class UnifiedDeliveryQueue extends EventEmitter {
738
1409
  * @param maxAge Maximum age in milliseconds (default: 7 days)
739
1410
  */
740
1411
  public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
1412
+ this.assertMutable();
741
1413
  const cutoff = new Date(Date.now() - maxAge);
742
1414
 
743
1415
  // Collect IDs first to avoid modifying the Map during iteration
744
1416
  const idsToRemove: string[] = [];
745
1417
  for (const item of this.queue.values()) {
746
- if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) {
1418
+ const hasPendingBounceWork = (item.bounceOperations || []).some(
1419
+ (operation) => operation.state !== 'completed',
1420
+ );
1421
+ if (
1422
+ ['delivered', 'failed'].includes(item.status)
1423
+ && item.updatedAt < cutoff
1424
+ && !hasPendingBounceWork
1425
+ ) {
747
1426
  idsToRemove.push(item.id);
748
1427
  }
749
1428
  }
@@ -763,31 +1442,34 @@ export class UnifiedDeliveryQueue extends EventEmitter {
763
1442
  * Shutdown the queue
764
1443
  */
765
1444
  public async shutdown(): Promise<void> {
1445
+ if (this.lifecycleState === 'closed') return;
766
1446
  logger.log('info', 'Shutting down UnifiedDeliveryQueue');
767
-
768
- // Stop processing (clears both check and cleanup timers)
769
- this.stopProcessing();
770
-
771
- // Persist all items when durable blob storage is configured
772
- if (this.options.storageManager) {
773
- const pendingWrites: Promise<void>[] = [];
774
-
775
- for (const item of this.queue.values()) {
776
- pendingWrites.push(this.persistItem(item));
1447
+ this.lifecycleState = 'shutting-down';
1448
+ let persistenceError: unknown;
1449
+
1450
+ try {
1451
+ // Stop processing (clears both check and cleanup timers).
1452
+ this.stopProcessing();
1453
+
1454
+ if (this.options.storageManager) {
1455
+ await Promise.all(
1456
+ [...this.queue.values()].map((item) => this.persistItem(item)),
1457
+ );
777
1458
  }
778
-
779
- // Wait for all writes to complete
780
- await Promise.all(pendingWrites);
1459
+ } catch (error) {
1460
+ // Preserve the original persistence failure; observer diagnostics are
1461
+ // isolated and can never replace it.
1462
+ persistenceError = error;
1463
+ logger.log('error', `Failed to persist delivery queue during shutdown: ${(error as Error).message}`);
1464
+ } finally {
1465
+ this.queue.clear();
1466
+ this.updateStats();
1467
+ await emitSafely(this, 'shutdown');
1468
+ this.lifecycleState = 'closed';
1469
+ this.releaseManagedPrefix();
781
1470
  }
782
-
783
- // Clear the queue (memory only)
784
- this.queue.clear();
785
-
786
- // Update statistics
787
- this.updateStats();
788
-
789
- // Emit shutdown event
790
- this.emit('shutdown');
1471
+
1472
+ if (persistenceError) throw persistenceError;
791
1473
  logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
792
1474
  }
793
1475
  }