@push.rocks/smartmta 9.4.0 → 9.5.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 (35) hide show
  1. package/changelog.md +23 -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 +1 -0
  6. package/dist_ts/index.js +2 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +71 -1
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +794 -179
  9. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +11 -0
  10. package/dist_ts/mail/delivery/classes.delivery.system.js +206 -80
  11. package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
  12. package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
  13. package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
  14. package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
  15. package/dist_ts/mail/index.d.ts +1 -0
  16. package/dist_ts/mail/index.js +1 -1
  17. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +40 -0
  18. package/dist_ts/mail/routing/classes.unified.email.server.js +222 -76
  19. package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
  20. package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
  21. package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
  22. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  23. package/package.json +2 -2
  24. package/readme.hints.md +8 -0
  25. package/readme.md +36 -3
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/index.ts +1 -0
  28. package/ts/mail/delivery/classes.delivery.queue.ts +969 -209
  29. package/ts/mail/delivery/classes.delivery.system.ts +254 -101
  30. package/ts/mail/delivery/functions.raw-email.ts +111 -0
  31. package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
  32. package/ts/mail/index.ts +1 -0
  33. package/ts/mail/routing/classes.unified.email.server.ts +329 -85
  34. package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
  35. package/ts/security/classes.rustsecuritybridge.ts +6 -0
@@ -1,7 +1,10 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { deserialize, serialize } from 'node:v8';
4
- import type { IBlobStorageManager } from '../interfaces.storage.js';
4
+ import {
5
+ SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT,
6
+ type IBlobStorageManager,
7
+ } from '../interfaces.storage.js';
5
8
  import { logger } from '../../logger.js';
6
9
  import { type EmailProcessingMode } from './interfaces.js';
7
10
  import type { ISmtpTransactionAttempt } from './interfaces.js';
@@ -15,6 +18,22 @@ import type { IEmailRoute } from '../routing/interfaces.js';
15
18
  import { Email, type IEmailOptions } from '../core/classes.email.js';
16
19
  import { emitSafely } from './functions.safe-observers.js';
17
20
  import { getErrorMessage } from '../../functions.errors.js';
21
+ import { SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
22
+ import {
23
+ assertCanonicalRawRfc822Message,
24
+ cloneRawSmtpEnvelope,
25
+ createRawQueueFingerprint,
26
+ normalizeRawSmtpEnvelope,
27
+ type IRawSmtpEnvelope,
28
+ } from './functions.raw-email.js';
29
+ import {
30
+ kQueueCloseAdmission,
31
+ kQueueCreateItemSnapshot,
32
+ kQueueEnqueueRaw,
33
+ kQueueReserveRawAccess,
34
+ kQueueSetReadyItemsHandler,
35
+ kQueueWithRawMessage,
36
+ } from './symbols.delivery.queue.js';
18
37
 
19
38
  /**
20
39
  * Queue item status
@@ -64,6 +83,14 @@ export interface IQueueItem {
64
83
  idempotencyFingerprint?: string;
65
84
  processingMode: EmailProcessingMode;
66
85
  processingResult: Email;
86
+ /** Present only for durable exact-byte outbound queue items. */
87
+ messageFormat?: 'raw-rfc822';
88
+ /** SHA-256 of the immutable unsigned RFC822 bytes. */
89
+ rawMessageSha256?: string;
90
+ /** Exact immutable unsigned RFC822 byte length. */
91
+ rawMessageLength?: number;
92
+ /** Canonical SMTP envelope, independent from message headers. */
93
+ smtpEnvelope?: IRawSmtpEnvelope;
67
94
  /** Materialized attachment count for queue/detail consumers. */
68
95
  attachmentCount?: number;
69
96
  route?: IEmailRoute;
@@ -123,7 +150,21 @@ interface IStoredQueueItemV2 extends Omit<IStoredQueueItemV1, 'schemaVersion'> {
123
150
  bounceOperations: IStoredBounceOperation[];
124
151
  }
125
152
 
126
- type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
153
+ interface IStoredQueueItemV3 extends Omit<IStoredQueueItemV2, 'schemaVersion'> {
154
+ schemaVersion: 3;
155
+ messageFormat: 'raw-rfc822';
156
+ rawMessageSha256: string;
157
+ rawMessageLength: number;
158
+ smtpEnvelope: IRawSmtpEnvelope;
159
+ rawMessage: Buffer;
160
+ }
161
+
162
+ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2 | IStoredQueueItemV3;
163
+
164
+ interface IDeserializedQueueItem {
165
+ item: IQueueItem;
166
+ rawMessage?: Buffer;
167
+ }
127
168
 
128
169
  type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
129
170
 
@@ -137,6 +178,20 @@ interface IStoredQueueIdempotencyTombstone {
137
178
 
138
179
  export const ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS = 31 * 24 * 60 * 60 * 1000;
139
180
  const QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT = 256;
181
+ export const DEFAULT_MAX_RAW_MESSAGE_BYTES = 32 * 1024 * 1024;
182
+ const RAW_ENQUEUE_RESERVATION_MULTIPLIER = 3;
183
+ const RAW_ACCESS_RESERVATION_MULTIPLIER = 6;
184
+ const RAW_BYTE_WAITER_LIMIT = 1_024;
185
+ const RAW_RESERVATION_LIMIT = 1_024;
186
+ export const DEFAULT_RAW_IN_FLIGHT_BYTE_BUDGET = 192 * 1024 * 1024;
187
+
188
+ const weightedRawBytes = (bytesArg: number, multiplierArg: number): number => {
189
+ const weightedBytes = bytesArg * multiplierArg;
190
+ if (!Number.isSafeInteger(weightedBytes) || weightedBytes < 1) {
191
+ throw new Error('Raw payload memory reservation exceeds the safe integer range');
192
+ }
193
+ return weightedBytes;
194
+ };
140
195
 
141
196
  export interface IQueueFailureOptions {
142
197
  /** Unknown failures default to retryable so temporary internal faults do not lose mail. */
@@ -169,6 +224,10 @@ export interface IQueueOptions {
169
224
  checkInterval?: number;
170
225
  maxQueueSize?: number;
171
226
  maxPerDestination?: number;
227
+ /** Maximum exact RFC822 bytes accepted by enqueueRawEmail. Defaults to 32 MiB. */
228
+ maxRawMessageBytes?: number;
229
+ /** Conservative memory budget for active raw operations. Defaults to 192 MiB. */
230
+ rawInFlightByteBudget?: number;
172
231
 
173
232
  // Delivery attempts
174
233
  maxRetries?: number;
@@ -185,6 +244,12 @@ export interface IQueueEnqueueOptions {
185
244
  requireDurable?: boolean;
186
245
  }
187
246
 
247
+ interface IRawQueueEnqueueOptions {
248
+ rawMessage: Buffer;
249
+ envelope: IRawSmtpEnvelope;
250
+ idempotencyKey: string;
251
+ }
252
+
188
253
  /**
189
254
  * Queue statistics interface
190
255
  */
@@ -242,12 +307,17 @@ export class UnifiedDeliveryQueue extends EventEmitter {
242
307
  maxRetries: number;
243
308
  baseRetryDelay: number;
244
309
  maxRetryDelay: number;
310
+ maxRawMessageBytes: number;
311
+ rawInFlightByteBudget: number;
245
312
  };
246
313
  private queue: Map<string, IQueueItem> = new Map();
247
314
  private checkTimer?: NodeJS.Timeout;
248
315
  private cleanupTimer?: NodeJS.Timeout;
316
+ private cleanupFlight?: Promise<void>;
249
317
  private stats: IQueueStats;
250
318
  private processing: boolean = false;
319
+ private processingScanPromise?: Promise<void>;
320
+ private processingRescanRequested = false;
251
321
  private totalProcessed: number = 0;
252
322
  private ownsManagedPrefix = false;
253
323
  private lifecycleState: TQueueLifecycleState = 'new';
@@ -256,6 +326,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
256
326
  promise: Promise<string>;
257
327
  }>();
258
328
  private idempotencyTombstoneCleanupCursor?: string;
329
+ private readonly itemMutationFlights = new Map<string, Promise<void>>();
330
+ private rawBytesInFlight = 0;
331
+ private rawByteWaiters: Array<{
332
+ bytes: number;
333
+ resolve: () => void;
334
+ reject: (error: Error) => void;
335
+ }> = [];
336
+ private rawReservationCount = 0;
337
+ private rawReservationDrainWaiters: Array<() => void> = [];
338
+ private readyItemsHandler?: (itemIds: string[]) => Promise<void>;
339
+ private acceptingNewItems = true;
340
+ private acceptingMutations = true;
341
+ private acceptingRawReservations = true;
342
+ private readonly enqueueFlights = new Set<Promise<string>>();
343
+ private pendingNewItemCount = 0;
344
+ private shutdownFlight?: Promise<void>;
259
345
 
260
346
  /**
261
347
  * Create a new unified delivery queue
@@ -270,6 +356,24 @@ export class UnifiedDeliveryQueue extends EventEmitter {
270
356
  if (options.storageMode === 'memory' && options.storageManager) {
271
357
  throw new Error('Memory-only queue storage cannot receive an IBlobStorageManager');
272
358
  }
359
+ const maxRawMessageBytes = options.maxRawMessageBytes ?? DEFAULT_MAX_RAW_MESSAGE_BYTES;
360
+ const minimumRawInFlightByteBudget = weightedRawBytes(
361
+ maxRawMessageBytes,
362
+ RAW_ACCESS_RESERVATION_MULTIPLIER,
363
+ );
364
+ const rawInFlightByteBudget = options.rawInFlightByteBudget
365
+ ?? Math.max(DEFAULT_RAW_IN_FLIGHT_BYTE_BUDGET, minimumRawInFlightByteBudget);
366
+ if (!Number.isSafeInteger(maxRawMessageBytes) || maxRawMessageBytes < 1) {
367
+ throw new Error('Queue maxRawMessageBytes must be a positive safe integer');
368
+ }
369
+ if (
370
+ !Number.isSafeInteger(rawInFlightByteBudget)
371
+ || rawInFlightByteBudget < minimumRawInFlightByteBudget
372
+ ) {
373
+ throw new Error(
374
+ `Queue rawInFlightByteBudget must be a safe integer at least ${minimumRawInFlightByteBudget}`,
375
+ );
376
+ }
273
377
 
274
378
  this.options = {
275
379
  storageMode: options.storageMode,
@@ -280,7 +384,9 @@ export class UnifiedDeliveryQueue extends EventEmitter {
280
384
  maxPerDestination: options.maxPerDestination || 100,
281
385
  maxRetries: options.maxRetries || 5,
282
386
  baseRetryDelay: options.baseRetryDelay || 60000, // 1 minute
283
- maxRetryDelay: options.maxRetryDelay || 3600000 // 1 hour
387
+ maxRetryDelay: options.maxRetryDelay || 3600000, // 1 hour
388
+ maxRawMessageBytes,
389
+ rawInFlightByteBudget,
284
390
  };
285
391
 
286
392
  // Initialize statistics
@@ -305,11 +411,32 @@ export class UnifiedDeliveryQueue extends EventEmitter {
305
411
  }
306
412
 
307
413
  private assertMutable(): void {
308
- if (this.lifecycleState === 'shutting-down' || this.lifecycleState === 'closed') {
414
+ if (
415
+ !this.acceptingMutations
416
+ || this.lifecycleState === 'shutting-down'
417
+ || this.lifecycleState === 'closed'
418
+ ) {
309
419
  throw new Error(`Delivery queue is ${this.lifecycleState}; mutations are no longer allowed`);
310
420
  }
311
421
  }
312
422
 
423
+ private assertAcceptingNewItems(): void {
424
+ if (!this.acceptingNewItems) {
425
+ throw new Error('Delivery queue is not accepting new items');
426
+ }
427
+ }
428
+
429
+ private trackEnqueueFlight(operationArg: () => Promise<string>): Promise<string> {
430
+ let flight: Promise<string>;
431
+ flight = Promise.resolve()
432
+ .then(operationArg)
433
+ .finally(() => {
434
+ this.enqueueFlights.delete(flight);
435
+ });
436
+ this.enqueueFlights.add(flight);
437
+ return flight;
438
+ }
439
+
313
440
  /** Whether queue entries are committed through caller-managed blob storage. */
314
441
  public hasManagedStorage(): boolean {
315
442
  return !!this.options.storageManager;
@@ -320,6 +447,93 @@ export class UnifiedDeliveryQueue extends EventEmitter {
320
447
  return typeof this.options.storageManager?.listPage === 'function';
321
448
  }
322
449
 
450
+ public [kQueueCloseAdmission](): void {
451
+ this.acceptingNewItems = false;
452
+ }
453
+
454
+ public [kQueueSetReadyItemsHandler](handlerArg?: (itemIds: string[]) => Promise<void>): void {
455
+ this.readyItemsHandler = handlerArg;
456
+ }
457
+
458
+ private cloneEmail(emailArg: Email): Email {
459
+ const options = deserialize(serialize(emailArg.toEmailOptions())) as IEmailOptions;
460
+ return new Email(options)
461
+ .setMessageId(emailArg.getMessageId())
462
+ .setEnvelopeFrom(emailArg.getEnvelopeFrom());
463
+ }
464
+
465
+ public [kQueueCreateItemSnapshot](itemArg: IQueueItem): IQueueItem {
466
+ return {
467
+ ...itemArg,
468
+ processingResult: this.cloneEmail(itemArg.processingResult),
469
+ ...(itemArg.smtpEnvelope
470
+ ? { smtpEnvelope: cloneRawSmtpEnvelope(itemArg.smtpEnvelope) }
471
+ : {}),
472
+ ...(itemArg.route
473
+ ? { route: deserialize(serialize(itemArg.route)) as IEmailRoute }
474
+ : {}),
475
+ nextAttempt: new Date(itemArg.nextAttempt),
476
+ createdAt: new Date(itemArg.createdAt),
477
+ updatedAt: new Date(itemArg.updatedAt),
478
+ ...(itemArg.deliveredAt ? { deliveredAt: new Date(itemArg.deliveredAt) } : {}),
479
+ deliveredRecipients: [...(itemArg.deliveredRecipients || [])],
480
+ terminalRecipients: [...(itemArg.terminalRecipients || [])],
481
+ recipientStates: (itemArg.recipientStates || []).map((state) => ({
482
+ ...state,
483
+ updatedAt: new Date(state.updatedAt),
484
+ })),
485
+ bounceOperations: (itemArg.bounceOperations || []).map((operation) => ({
486
+ ...operation,
487
+ nextAttempt: new Date(operation.nextAttempt),
488
+ ...(operation.completedAt ? { completedAt: new Date(operation.completedAt) } : {}),
489
+ })),
490
+ smtpTransactions: (itemArg.smtpTransactions || []).map((transaction) => ({
491
+ ...transaction,
492
+ recipients: [...transaction.recipients],
493
+ recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
494
+ outboundAuthentication: transaction.outboundAuthentication
495
+ ? deserialize(serialize(transaction.outboundAuthentication))
496
+ : undefined,
497
+ transcript: transaction.transcript.map((entry) => ({
498
+ ...entry,
499
+ ...(entry.transferStats ? { transferStats: { ...entry.transferStats } } : {}),
500
+ })),
501
+ })),
502
+ };
503
+ }
504
+
505
+ private async emitItemEventSafely(
506
+ eventNameArg: string,
507
+ itemArg: IQueueItem,
508
+ ...additionalArgsArg: unknown[]
509
+ ): Promise<void> {
510
+ for (const rawListener of this.rawListeners(eventNameArg)) {
511
+ try {
512
+ await Reflect.apply(rawListener, this, [
513
+ this[kQueueCreateItemSnapshot](itemArg),
514
+ ...deserialize(serialize(additionalArgsArg)),
515
+ ]);
516
+ } catch (error) {
517
+ logger.log(
518
+ 'error',
519
+ `Observer for ${eventNameArg} failed: ${getErrorMessage(error)}`,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
525
+ private async emitReadyItemsSafely(itemsArg: IQueueItem[]): Promise<void> {
526
+ for (const rawListener of this.rawListeners('itemsReady')) {
527
+ try {
528
+ await Reflect.apply(rawListener, this, [
529
+ itemsArg.map((item) => this[kQueueCreateItemSnapshot](item)),
530
+ ]);
531
+ } catch (error) {
532
+ logger.log('error', `Observer for itemsReady failed: ${getErrorMessage(error)}`);
533
+ }
534
+ }
535
+ }
536
+
323
537
  private idempotentQueueId(idempotencyKey: string): string {
324
538
  return `idempotent-${createHash('sha256')
325
539
  .update('smartmta-queue-v1\0')
@@ -548,22 +762,37 @@ export class UnifiedDeliveryQueue extends EventEmitter {
548
762
  clearInterval(this.checkTimer);
549
763
  }
550
764
 
551
- this.checkTimer = setInterval(() => this.processQueue(), this.options.checkInterval);
765
+ this.checkTimer = setInterval(() => this.wake(), this.options.checkInterval);
552
766
 
553
767
  // Start periodic cleanup of delivered/failed items (every 30 minutes)
554
768
  if (this.cleanupTimer) {
555
769
  clearInterval(this.cleanupTimer);
556
770
  }
557
771
  this.cleanupTimer = setInterval(() => {
558
- this.cleanupOldItems(24 * 60 * 60 * 1000).catch((err) => {
559
- logger.log('error', `Auto-cleanup failed: ${err.message}`);
560
- });
772
+ this.runPeriodicCleanup();
561
773
  }, 30 * 60 * 1000);
562
774
 
563
775
  this.processing = true;
564
776
  this.stats.processingActive = true;
565
777
  void emitSafely(this, 'processingStarted');
566
778
  logger.log('info', 'Queue processing started');
779
+ this.wake();
780
+ }
781
+
782
+ private runPeriodicCleanup(): void {
783
+ if (this.cleanupFlight) return;
784
+ let cleanupFlight: Promise<void>;
785
+ cleanupFlight = this.cleanupOldItems(24 * 60 * 60 * 1000)
786
+ .then(() => undefined)
787
+ .catch((error) => {
788
+ logger.log('error', `Auto-cleanup failed: ${getErrorMessage(error)}`);
789
+ })
790
+ .finally(() => {
791
+ if (this.cleanupFlight === cleanupFlight) {
792
+ this.cleanupFlight = undefined;
793
+ }
794
+ });
795
+ this.cleanupFlight = cleanupFlight;
567
796
  }
568
797
 
569
798
  /**
@@ -580,6 +809,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
580
809
  }
581
810
 
582
811
  this.processing = false;
812
+ this.processingRescanRequested = false;
583
813
  this.stats.processingActive = false;
584
814
  void emitSafely(this, 'processingStopped');
585
815
  logger.log('info', 'Queue processing stopped');
@@ -591,7 +821,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
591
821
  private async processQueue(): Promise<void> {
592
822
  try {
593
823
  const now = new Date();
594
- let readyItems: IQueueItem[] = [];
824
+ const readyItems: IQueueItem[] = [];
595
825
 
596
826
  // Find items ready for processing
597
827
  for (const item of this.queue.values()) {
@@ -606,9 +836,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
606
836
 
607
837
  // Sort by oldest first
608
838
  readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
839
+
840
+ if (!this.processing) {
841
+ return;
842
+ }
609
843
 
610
- // Emit event for ready items
611
- await emitSafely(this, 'itemsReady', readyItems);
844
+ const readyItemIds = readyItems.map((item) => item.id);
845
+ await this.readyItemsHandler?.([...readyItemIds]);
846
+ if (this.listenerCount('itemsReady') > 0) {
847
+ await this.emitReadyItemsSafely(readyItems);
848
+ }
612
849
  logger.log('info', `Found ${readyItems.length} items ready for processing`);
613
850
 
614
851
  // Update statistics
@@ -618,6 +855,36 @@ export class UnifiedDeliveryQueue extends EventEmitter {
618
855
  await emitSafely(this, 'error', error);
619
856
  }
620
857
  }
858
+
859
+ /**
860
+ * Wake the normal eligibility scan without bypassing pause or retry timing.
861
+ * The interval remains the recovery path for retries and any missed wakeup.
862
+ */
863
+ public wake(): void {
864
+ if (!this.processing) return;
865
+ this.processingRescanRequested = true;
866
+ if (this.processingScanPromise) return;
867
+
868
+ // Defer observer-capable work until after the active-scan guard is stored.
869
+ // Otherwise a synchronous itemsReady observer can re-enter wake() here.
870
+ const scanPromise = Promise.resolve().then(() => this.runProcessingScans());
871
+ this.processingScanPromise = scanPromise;
872
+ void scanPromise.finally(() => {
873
+ if (this.processingScanPromise === scanPromise) {
874
+ this.processingScanPromise = undefined;
875
+ }
876
+ if (this.processing && this.processingRescanRequested) {
877
+ this.wake();
878
+ }
879
+ });
880
+ }
881
+
882
+ private async runProcessingScans(): Promise<void> {
883
+ while (this.processing && this.processingRescanRequested) {
884
+ this.processingRescanRequested = false;
885
+ await this.processQueue();
886
+ }
887
+ }
621
888
 
622
889
  /**
623
890
  * Add an item to the queue
@@ -625,13 +892,28 @@ export class UnifiedDeliveryQueue extends EventEmitter {
625
892
  * @param mode Processing mode
626
893
  * @param route Email route
627
894
  */
628
- public async enqueue(
895
+ public enqueue(
629
896
  processingResult: Email,
630
897
  mode: EmailProcessingMode,
631
898
  route?: IEmailRoute,
632
899
  enqueueOptions: IQueueEnqueueOptions = {},
633
900
  ): Promise<string> {
634
901
  this.assertMutable();
902
+ this.assertAcceptingNewItems();
903
+ return this.trackEnqueueFlight(() => this.enqueueAccepted(
904
+ processingResult,
905
+ mode,
906
+ route,
907
+ enqueueOptions,
908
+ ));
909
+ }
910
+
911
+ private async enqueueAccepted(
912
+ processingResult: Email,
913
+ mode: EmailProcessingMode,
914
+ route: IEmailRoute | undefined,
915
+ enqueueOptions: IQueueEnqueueOptions,
916
+ ): Promise<string> {
635
917
  if (!(processingResult instanceof Email)) {
636
918
  throw new Error('Queue processingResult must be an Email instance');
637
919
  }
@@ -646,68 +928,170 @@ export class UnifiedDeliveryQueue extends EventEmitter {
646
928
  throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
647
929
  }
648
930
  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
- );
931
+ return this.enqueueIdempotent(
932
+ enqueueOptions.idempotencyKey,
933
+ enqueueOptions.idempotencyFingerprint!,
934
+ (idempotency) => this.enqueueNew(processingResult, mode, route, idempotency),
935
+ );
936
+ }
937
+
938
+ return this.enqueueNew(processingResult, mode, route);
939
+ }
940
+
941
+ public [kQueueEnqueueRaw](optionsArg: IRawQueueEnqueueOptions): Promise<string> {
942
+ this.assertMutable();
943
+ this.assertAcceptingNewItems();
944
+ if (!this.hasManagedStorage()) {
945
+ throw new Error('Raw outbound queue requires managed queue storage');
946
+ }
947
+ if (!this.hasPaginatedManagedStorage()) {
948
+ throw new Error('Raw outbound queue requires paginated managed queue storage');
949
+ }
950
+ if (!optionsArg.idempotencyKey || optionsArg.idempotencyKey.length > 512) {
951
+ throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
952
+ }
953
+ try {
954
+ assertCanonicalRawRfc822Message(optionsArg.rawMessage);
955
+ } catch (error) {
956
+ if (!Buffer.isBuffer(optionsArg.rawMessage)) {
957
+ throw new Error('Raw outbound queue message must be a Buffer');
653
958
  }
654
- if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
655
- throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
959
+ throw error;
960
+ }
961
+ if (optionsArg.rawMessage.length > this.options.maxRawMessageBytes) {
962
+ throw new Error(
963
+ `Raw outbound queue message exceeds ${this.options.maxRawMessageBytes} bytes`,
964
+ );
965
+ }
966
+ const releaseRawReservation = this.reserveRawBytesImmediately(
967
+ weightedRawBytes(optionsArg.rawMessage.length, RAW_ENQUEUE_RESERVATION_MULTIPLIER),
968
+ );
969
+ let rawMessage: Buffer;
970
+ let smtpEnvelope: IRawSmtpEnvelope;
971
+ let rawMessageSha256: string;
972
+ let fingerprint: string;
973
+ let processingResult: Email;
974
+ try {
975
+ rawMessage = Buffer.from(optionsArg.rawMessage);
976
+ assertCanonicalRawRfc822Message(rawMessage);
977
+ smtpEnvelope = normalizeRawSmtpEnvelope(optionsArg.envelope);
978
+ rawMessageSha256 = createHash('sha256').update(rawMessage).digest('hex');
979
+ fingerprint = createRawQueueFingerprint(rawMessage, smtpEnvelope);
980
+ processingResult = new Email({
981
+ from: smtpEnvelope.from,
982
+ to: smtpEnvelope.to,
983
+ subject: '',
984
+ text: '',
985
+ }).setEnvelopeFrom(smtpEnvelope.from);
986
+ } catch (error) {
987
+ releaseRawReservation();
988
+ throw error;
989
+ }
990
+
991
+ return this.trackEnqueueFlight(async () => {
992
+ try {
993
+ return await this.enqueueRawAccepted({
994
+ rawMessage,
995
+ smtpEnvelope,
996
+ idempotencyKey: optionsArg.idempotencyKey,
997
+ rawMessageSha256,
998
+ fingerprint,
999
+ processingResult,
1000
+ });
1001
+ } finally {
1002
+ releaseRawReservation();
656
1003
  }
657
- if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
658
- throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
1004
+ });
1005
+ }
1006
+
1007
+ private async enqueueRawAccepted(optionsArg: {
1008
+ rawMessage: Buffer;
1009
+ smtpEnvelope: IRawSmtpEnvelope;
1010
+ idempotencyKey: string;
1011
+ rawMessageSha256: string;
1012
+ fingerprint: string;
1013
+ processingResult: Email;
1014
+ }): Promise<string> {
1015
+ return this.enqueueIdempotent(
1016
+ optionsArg.idempotencyKey,
1017
+ optionsArg.fingerprint,
1018
+ (idempotency) => this.enqueueNew(
1019
+ optionsArg.processingResult,
1020
+ 'mta',
1021
+ undefined,
1022
+ idempotency,
1023
+ {
1024
+ rawMessage: optionsArg.rawMessage,
1025
+ rawMessageSha256: optionsArg.rawMessageSha256,
1026
+ smtpEnvelope: optionsArg.smtpEnvelope,
1027
+ },
1028
+ ),
1029
+ );
1030
+ }
1031
+
1032
+ private async enqueueIdempotent(
1033
+ idempotencyKeyArg: string,
1034
+ fingerprintArg: string,
1035
+ createArg: (idempotency: {
1036
+ id: string;
1037
+ idempotencyKey: string;
1038
+ idempotencyFingerprint: string;
1039
+ }) => Promise<string>,
1040
+ ): Promise<string> {
1041
+ if (this.options.storageManager && !this.options.storageManager.listPage) {
1042
+ throw new Error('Managed idempotent enqueue requires paginated managed queue storage');
1043
+ }
1044
+ if (!idempotencyKeyArg || idempotencyKeyArg.length > 512) {
1045
+ throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
1046
+ }
1047
+ if (!/^[a-f0-9]{64}$/.test(fingerprintArg)) {
1048
+ throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
1049
+ }
1050
+
1051
+ const id = this.idempotentQueueId(idempotencyKeyArg);
1052
+ const existing = this.queue.get(id);
1053
+ if (existing) {
1054
+ if (
1055
+ existing.idempotencyKey !== idempotencyKeyArg
1056
+ || existing.idempotencyFingerprint !== fingerprintArg
1057
+ ) {
1058
+ throw new Error(`Queue idempotency key collision for ${id}`);
659
1059
  }
1060
+ return id;
1061
+ }
660
1062
 
661
- const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
662
- const existing = this.queue.get(id);
663
- if (existing) {
1063
+ const activeFlight = this.idempotentEnqueueFlights.get(id);
1064
+ if (activeFlight) {
1065
+ if (activeFlight.fingerprint !== fingerprintArg) {
1066
+ throw new Error(`Queue idempotency key collision for ${id}`);
1067
+ }
1068
+ return activeFlight.promise;
1069
+ }
1070
+ const flight = (async () => {
1071
+ const tombstone = await this.readIdempotencyTombstone(id);
1072
+ if (tombstone) {
664
1073
  if (
665
- existing.idempotencyKey !== enqueueOptions.idempotencyKey
666
- || existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
1074
+ tombstone.idempotencyKey !== idempotencyKeyArg
1075
+ || tombstone.idempotencyFingerprint !== fingerprintArg
667
1076
  ) {
668
1077
  throw new Error(`Queue idempotency key collision for ${id}`);
669
1078
  }
670
1079
  return id;
671
1080
  }
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,
1081
+ return createArg({
1082
+ id,
1083
+ idempotencyKey: idempotencyKeyArg,
1084
+ idempotencyFingerprint: fingerprintArg,
700
1085
  });
701
- try {
702
- return await flight;
703
- } finally {
704
- if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
705
- this.idempotentEnqueueFlights.delete(id);
706
- }
1086
+ })();
1087
+ this.idempotentEnqueueFlights.set(id, { fingerprint: fingerprintArg, promise: flight });
1088
+ try {
1089
+ return await flight;
1090
+ } finally {
1091
+ if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
1092
+ this.idempotentEnqueueFlights.delete(id);
707
1093
  }
708
1094
  }
709
-
710
- return this.enqueueNew(processingResult, mode, route);
711
1095
  }
712
1096
 
713
1097
  private async enqueueNew(
@@ -719,56 +1103,66 @@ export class UnifiedDeliveryQueue extends EventEmitter {
719
1103
  idempotencyKey: string;
720
1104
  idempotencyFingerprint: string;
721
1105
  },
1106
+ raw?: {
1107
+ rawMessage: Buffer;
1108
+ rawMessageSha256: string;
1109
+ smtpEnvelope: IRawSmtpEnvelope;
1110
+ },
722
1111
  ): Promise<string> {
723
- // Check if queue is full
724
- if (this.queue.size >= this.options.maxQueueSize) {
1112
+ if (this.queue.size + this.pendingNewItemCount >= this.options.maxQueueSize) {
725
1113
  throw new Error('Queue is full');
726
1114
  }
727
-
728
- // Generate a unique ID
729
- const id = idempotency?.id
730
- || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
731
-
732
- // Create queue item
733
- const item: IQueueItem = {
734
- id,
735
- ...(idempotency ? {
736
- idempotencyKey: idempotency.idempotencyKey,
737
- idempotencyFingerprint: idempotency.idempotencyFingerprint,
738
- } : {}),
739
- processingMode: mode,
740
- processingResult,
741
- attachmentCount: processingResult.attachments.length,
742
- route,
743
- status: 'pending',
744
- attempts: 0,
745
- nextAttempt: new Date(),
746
- createdAt: new Date(),
747
- updatedAt: new Date(),
748
- deliveredRecipients: [],
749
- terminalRecipients: [],
750
- recipientStates: this.createInitialRecipientStates(processingResult),
751
- bounceOperations: [],
752
- smtpTransactions: [],
753
- };
754
-
755
- // Commit durable state before exposing the item in memory or emitting acceptance.
756
- await this.persistItem(item);
757
- if (idempotency) {
758
- await this.persistIdempotencyTombstone(
1115
+ this.pendingNewItemCount++;
1116
+ let slotPending = true;
1117
+ let id: string;
1118
+ let item: IQueueItem;
1119
+ try {
1120
+ id = idempotency?.id
1121
+ || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
1122
+ item = {
759
1123
  id,
760
- idempotency.idempotencyKey,
761
- idempotency.idempotencyFingerprint,
762
- );
1124
+ ...(idempotency ? {
1125
+ idempotencyKey: idempotency.idempotencyKey,
1126
+ idempotencyFingerprint: idempotency.idempotencyFingerprint,
1127
+ } : {}),
1128
+ processingMode: mode,
1129
+ processingResult,
1130
+ ...(raw ? {
1131
+ messageFormat: 'raw-rfc822' as const,
1132
+ rawMessageSha256: raw.rawMessageSha256,
1133
+ rawMessageLength: raw.rawMessage.length,
1134
+ smtpEnvelope: cloneRawSmtpEnvelope(raw.smtpEnvelope),
1135
+ } : {}),
1136
+ attachmentCount: raw ? 0 : processingResult.attachments.length,
1137
+ route,
1138
+ status: 'pending',
1139
+ attempts: 0,
1140
+ nextAttempt: new Date(),
1141
+ createdAt: new Date(),
1142
+ updatedAt: new Date(),
1143
+ deliveredRecipients: [],
1144
+ terminalRecipients: [],
1145
+ recipientStates: this.createInitialRecipientStates(processingResult),
1146
+ bounceOperations: [],
1147
+ smtpTransactions: [],
1148
+ };
1149
+
1150
+ // Commit durable state before exposing the item in memory or emitting acceptance.
1151
+ await this.persistItem(item, raw?.rawMessage);
1152
+ this.queue.set(id, item);
1153
+ this.pendingNewItemCount--;
1154
+ slotPending = false;
1155
+ } finally {
1156
+ if (slotPending) this.pendingNewItemCount--;
763
1157
  }
764
- this.queue.set(id, item);
765
1158
 
766
1159
  // Update statistics
767
1160
  this.updateStats();
768
1161
 
769
1162
  // Emit event
770
- await emitSafely(this, 'itemEnqueued', item);
1163
+ await this.emitItemEventSafely('itemEnqueued', item);
771
1164
  logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
1165
+ this.wake();
772
1166
 
773
1167
  return id;
774
1168
  }
@@ -778,23 +1172,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
778
1172
  * @param id Item ID
779
1173
  */
780
1174
  public getItem(id: string): IQueueItem | undefined {
781
- return this.queue.get(id);
1175
+ const item = this.queue.get(id);
1176
+ return item ? this[kQueueCreateItemSnapshot](item) : undefined;
782
1177
  }
783
1178
 
784
1179
  public listItems(): IQueueItem[] {
785
- return Array.from(this.queue.values()).map((item) => ({
786
- ...item,
787
- deliveredRecipients: [...(item.deliveredRecipients || [])],
788
- terminalRecipients: [...(item.terminalRecipients || [])],
789
- recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
790
- bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
791
- smtpTransactions: (item.smtpTransactions || []).map((transaction) => ({
792
- ...transaction,
793
- recipients: [...transaction.recipients],
794
- recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
795
- transcript: transaction.transcript.map((entry) => ({ ...entry })),
796
- })),
797
- }));
1180
+ return Array.from(this.queue.values()).map((item) => this[kQueueCreateItemSnapshot](item));
798
1181
  }
799
1182
 
800
1183
  /**
@@ -804,28 +1187,25 @@ export class UnifiedDeliveryQueue extends EventEmitter {
804
1187
  public async markProcessing(id: string): Promise<boolean> {
805
1188
  this.assertMutable();
806
1189
  const item = this.queue.get(id);
1190
+ if (!item) return false;
807
1191
 
808
- if (
809
- !item
810
- || !(
811
- item.status === 'pending'
812
- || (item.status === 'deferred' && item.nextAttempt <= new Date())
813
- )
814
- ) {
815
- return false;
816
- }
817
-
818
- await this.applyPersistedMutation(item, (updatedItem) => {
1192
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
819
1193
  updatedItem.status = 'processing';
820
1194
  updatedItem.attempts++;
821
1195
  updatedItem.updatedAt = new Date();
822
- });
1196
+ }, (currentItem) => (
1197
+ currentItem.status === 'pending'
1198
+ || (currentItem.status === 'deferred' && currentItem.nextAttempt <= new Date())
1199
+ ));
1200
+ if (!updated) {
1201
+ return false;
1202
+ }
823
1203
 
824
1204
  // Update statistics
825
1205
  this.updateStats();
826
1206
 
827
1207
  // Emit event
828
- await emitSafely(this, 'itemProcessing', item);
1208
+ await this.emitItemEventSafely('itemProcessing', item);
829
1209
  logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
830
1210
 
831
1211
  return true;
@@ -843,18 +1223,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
843
1223
  return false;
844
1224
  }
845
1225
 
846
- await this.applyPersistedMutation(item, (updatedItem) => {
1226
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
847
1227
  updatedItem.status = 'delivered';
848
1228
  updatedItem.updatedAt = new Date();
849
1229
  updatedItem.deliveredAt = new Date();
850
- });
1230
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1231
+ if (!updated) return false;
851
1232
 
852
1233
  // Update statistics
853
1234
  this.totalProcessed++;
854
1235
  this.updateStats();
855
1236
 
856
1237
  // Emit event
857
- await emitSafely(this, 'itemDelivered', item);
1238
+ await this.emitItemEventSafely('itemDelivered', item);
858
1239
  logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
859
1240
 
860
1241
  return true;
@@ -902,21 +1283,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
902
1283
  this.options.maxRetryDelay
903
1284
  );
904
1285
 
905
- await this.applyPersistedMutation(item, (updatedItem) => {
1286
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
906
1287
  updatedItem.status = 'deferred';
907
1288
  updatedItem.lastError = error;
908
1289
  updatedItem.nextAttempt = new Date(Date.now() + delay);
909
1290
  updatedItem.updatedAt = new Date();
910
- });
1291
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1292
+ if (!updated) return { updated: false, terminal: true };
911
1293
 
912
1294
  // Emit event
913
- await emitSafely(this, 'itemDeferred', item);
1295
+ await this.emitItemEventSafely('itemDeferred', item);
914
1296
  logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
915
1297
  this.updateStats();
916
1298
  return { updated: true, status: 'deferred', terminal: false };
917
1299
  } else {
918
1300
  // Mark as permanently failed
919
- await this.applyPersistedMutation(item, (updatedItem) => {
1301
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
920
1302
  const now = new Date();
921
1303
  updatedItem.status = 'failed';
922
1304
  updatedItem.lastError = error;
@@ -966,13 +1348,14 @@ export class UnifiedDeliveryQueue extends EventEmitter {
966
1348
  updatedItem.terminalRecipients = [...terminal];
967
1349
  updatedItem.bounceOperations = operations;
968
1350
  }
969
- });
1351
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1352
+ if (!updated) return { updated: false, terminal: true };
970
1353
 
971
1354
  // Update statistics
972
1355
  this.totalProcessed++;
973
1356
 
974
1357
  // Emit event
975
- await emitSafely(this, 'itemFailed', item);
1358
+ await this.emitItemEventSafely('itemFailed', item);
976
1359
  logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
977
1360
  }
978
1361
 
@@ -1007,7 +1390,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1007
1390
  ].slice(-20);
1008
1391
  updatedItem.updatedAt = new Date();
1009
1392
  });
1010
- await emitSafely(this, 'smtpTransaction', item, normalizedTransaction);
1393
+ await this.emitItemEventSafely('smtpTransaction', item, normalizedTransaction);
1011
1394
  return true;
1012
1395
  }
1013
1396
 
@@ -1020,8 +1403,6 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1020
1403
  this.assertMutable();
1021
1404
  const item = this.queue.get(id);
1022
1405
  if (!item) return false;
1023
- const transactionIndex = (item.smtpTransactions || []).findIndex((value) => value.id === transactionId);
1024
- if (transactionIndex < 0) return false;
1025
1406
  const normalizedTransaction: ISmtpTransactionAttempt = {
1026
1407
  ...transaction,
1027
1408
  queueItemId: id,
@@ -1032,13 +1413,18 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1032
1413
  : undefined,
1033
1414
  transcript: normalizeSmtpTranscript(transaction.transcript),
1034
1415
  };
1035
- await this.applyPersistedMutation(item, (updatedItem) => {
1416
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1036
1417
  const transactions = [...(updatedItem.smtpTransactions || [])];
1418
+ const transactionIndex = transactions.findIndex((value) => value.id === transactionId);
1419
+ if (transactionIndex < 0) return;
1037
1420
  transactions[transactionIndex] = normalizedTransaction;
1038
1421
  updatedItem.smtpTransactions = transactions.slice(-20);
1039
1422
  updatedItem.updatedAt = new Date();
1040
- });
1041
- await emitSafely(this, 'smtpTransactionUpdated', item, normalizedTransaction);
1423
+ }, (currentItem) => (
1424
+ (currentItem.smtpTransactions || []).some((value) => value.id === transactionId)
1425
+ ));
1426
+ if (!updated) return false;
1427
+ await this.emitItemEventSafely('smtpTransactionUpdated', item, normalizedTransaction);
1042
1428
  return true;
1043
1429
  }
1044
1430
 
@@ -1219,11 +1605,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1219
1605
  ): Promise<boolean> {
1220
1606
  this.assertMutable();
1221
1607
  const item = this.queue.get(itemId);
1222
- const operation = item?.bounceOperations?.find(
1223
- (candidate) => candidate.operationId === operationId,
1224
- );
1225
- if (!item || !operation || operation.state === 'completed') return false;
1226
- await this.applyPersistedMutation(item, (updatedItem) => {
1608
+ if (!item) return false;
1609
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1227
1610
  const updatedOperation = updatedItem.bounceOperations?.find(
1228
1611
  (candidate) => candidate.operationId === operationId,
1229
1612
  );
@@ -1232,8 +1615,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1232
1615
  updatedOperation.completedAt = new Date();
1233
1616
  delete updatedOperation.lastError;
1234
1617
  updatedItem.updatedAt = new Date();
1235
- });
1236
- return true;
1618
+ }, (currentItem) => Boolean(currentItem.bounceOperations?.some(
1619
+ (operation) => operation.operationId === operationId && operation.state !== 'completed',
1620
+ )));
1621
+ return updated;
1237
1622
  }
1238
1623
 
1239
1624
  public async markBounceOperationFailed(
@@ -1243,11 +1628,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1243
1628
  ): Promise<boolean> {
1244
1629
  this.assertMutable();
1245
1630
  const item = this.queue.get(itemId);
1246
- const operation = item?.bounceOperations?.find(
1247
- (candidate) => candidate.operationId === operationId,
1248
- );
1249
- if (!item || !operation || operation.state === 'completed') return false;
1250
- await this.applyPersistedMutation(item, (updatedItem) => {
1631
+ if (!item) return false;
1632
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1251
1633
  const updatedOperation = updatedItem.bounceOperations?.find(
1252
1634
  (candidate) => candidate.operationId === operationId,
1253
1635
  );
@@ -1258,8 +1640,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1258
1640
  Date.now() + Math.min(60_000 * (2 ** Math.max(0, updatedOperation.attempts - 1)), 3_600_000),
1259
1641
  );
1260
1642
  updatedItem.updatedAt = new Date();
1261
- });
1262
- return true;
1643
+ }, (currentItem) => Boolean(currentItem.bounceOperations?.some(
1644
+ (operation) => operation.operationId === operationId && operation.state !== 'completed',
1645
+ )));
1646
+ return updated;
1263
1647
  }
1264
1648
 
1265
1649
  /**
@@ -1268,28 +1652,33 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1268
1652
  */
1269
1653
  public async removeItem(id: string): Promise<boolean> {
1270
1654
  this.assertMutable();
1271
- const item = this.queue.get(id);
1272
-
1273
- if (!item) {
1274
- return false;
1275
- }
1276
-
1277
- if (item.idempotencyKey && item.idempotencyFingerprint) {
1278
- await this.persistIdempotencyTombstone(
1279
- item.id,
1280
- item.idempotencyKey,
1281
- item.idempotencyFingerprint,
1282
- );
1283
- }
1284
- // Delete durable state before making the removal visible in memory.
1285
- await this.removeItemFromStorage(id);
1286
- this.queue.delete(id);
1655
+ let removedItem: IQueueItem | undefined;
1656
+ const removed = await this.withItemMutex(id, async () => {
1657
+ const item = this.queue.get(id);
1658
+ if (!item) return false;
1659
+ if (item.status === 'processing') {
1660
+ throw new Error(`Cannot remove queue item ${id} while it is processing`);
1661
+ }
1662
+ if (item.idempotencyKey && item.idempotencyFingerprint) {
1663
+ await this.persistIdempotencyTombstone(
1664
+ item.id,
1665
+ item.idempotencyKey,
1666
+ item.idempotencyFingerprint,
1667
+ );
1668
+ }
1669
+ // Delete durable state before making the removal visible in memory.
1670
+ await this.removeItemFromStorage(id);
1671
+ removedItem = item;
1672
+ this.queue.delete(id);
1673
+ return true;
1674
+ });
1675
+ if (!removed || !removedItem) return false;
1287
1676
 
1288
1677
  // Update statistics
1289
1678
  this.updateStats();
1290
1679
 
1291
1680
  // Emit event
1292
- await emitSafely(this, 'itemRemoved', item);
1681
+ await this.emitItemEventSafely('itemRemoved', removedItem);
1293
1682
  logger.log('info', `Item ${id} removed from queue`);
1294
1683
 
1295
1684
  return true;
@@ -1299,16 +1688,34 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1299
1688
  return `${this.options.storagePrefix}${id}.json`;
1300
1689
  }
1301
1690
 
1302
- private async persistItem(item: IQueueItem): Promise<void> {
1691
+ private async persistItem(item: IQueueItem, rawMessageArg?: Buffer): Promise<void> {
1303
1692
  if (!this.options.storageManager) return;
1693
+ if (item.messageFormat === 'raw-rfc822' && !rawMessageArg) {
1694
+ throw new Error(`Raw queue item ${item.id} requires its exact payload for persistence`);
1695
+ }
1304
1696
  await this.options.storageManager.set(
1305
1697
  this.storageKey(item.id),
1306
- serialize(this.serializeItem(item)),
1698
+ serialize(this.serializeItem(item, rawMessageArg)),
1307
1699
  );
1308
1700
  }
1309
1701
 
1310
- private serializeItem(item: IQueueItem): IStoredQueueItem {
1311
- return {
1702
+ private async persistCurrentItem(itemArg: IQueueItem): Promise<void> {
1703
+ if (itemArg.messageFormat !== 'raw-rfc822') {
1704
+ await this.persistItem(itemArg);
1705
+ return;
1706
+ }
1707
+ await this.withRawByteReservation(
1708
+ weightedRawBytes(itemArg.rawMessageLength!, RAW_ACCESS_RESERVATION_MULTIPLIER),
1709
+ async () => {
1710
+ const rawMessage = await this.readVerifiedRawMessage(itemArg);
1711
+ await this.persistItem(itemArg, rawMessage);
1712
+ },
1713
+ true,
1714
+ );
1715
+ }
1716
+
1717
+ private serializeItem(item: IQueueItem, rawMessageArg?: Buffer): IStoredQueueItem {
1718
+ const storedItem: IStoredQueueItemV2 = {
1312
1719
  schemaVersion: 2,
1313
1720
  id: item.id,
1314
1721
  ...(item.idempotencyKey !== undefined ? {
@@ -1354,31 +1761,240 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1354
1761
  transcript: normalizeSmtpTranscript(transaction.transcript),
1355
1762
  })),
1356
1763
  };
1764
+ if (item.messageFormat !== 'raw-rfc822') return storedItem;
1765
+ if (!rawMessageArg) {
1766
+ throw new Error(`Raw queue item ${item.id} cannot be persisted without its exact payload`);
1767
+ }
1768
+ return {
1769
+ ...storedItem,
1770
+ schemaVersion: 3,
1771
+ messageFormat: 'raw-rfc822',
1772
+ rawMessageSha256: item.rawMessageSha256!,
1773
+ rawMessageLength: item.rawMessageLength!,
1774
+ smtpEnvelope: cloneRawSmtpEnvelope(item.smtpEnvelope!),
1775
+ rawMessage: rawMessageArg,
1776
+ };
1777
+ }
1778
+
1779
+ private async withItemMutex<T>(itemIdArg: string, operationArg: () => Promise<T>): Promise<T> {
1780
+ const previous = this.itemMutationFlights.get(itemIdArg) || Promise.resolve();
1781
+ const result = previous.catch(() => undefined).then(operationArg);
1782
+ const settled = result.then(() => undefined, () => undefined);
1783
+ this.itemMutationFlights.set(itemIdArg, settled);
1784
+ try {
1785
+ return await result;
1786
+ } finally {
1787
+ if (this.itemMutationFlights.get(itemIdArg) === settled) {
1788
+ this.itemMutationFlights.delete(itemIdArg);
1789
+ }
1790
+ }
1357
1791
  }
1358
1792
 
1359
1793
  private async applyPersistedMutation(
1360
1794
  item: IQueueItem,
1361
1795
  mutation: (updatedItem: IQueueItem) => void,
1362
- ): Promise<void> {
1796
+ shouldMutateArg: (currentItem: IQueueItem) => boolean = () => true,
1797
+ ): Promise<boolean> {
1363
1798
  this.assertMutable();
1364
- const updatedItem: IQueueItem = {
1365
- ...item,
1366
- deliveredRecipients: [...(item.deliveredRecipients || [])],
1367
- terminalRecipients: [...(item.terminalRecipients || [])],
1368
- recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
1369
- bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
1370
- smtpTransactions: [...(item.smtpTransactions || [])],
1799
+ return this.withItemMutex(item.id, async () => {
1800
+ const currentItem = this.queue.get(item.id);
1801
+ if (!currentItem || !shouldMutateArg(currentItem)) return false;
1802
+ const updatedItem = this[kQueueCreateItemSnapshot](currentItem);
1803
+ mutation(updatedItem);
1804
+ await this.persistCurrentItem(updatedItem);
1805
+ for (const key of Object.keys(currentItem) as Array<keyof IQueueItem>) {
1806
+ delete currentItem[key];
1807
+ }
1808
+ Object.assign(currentItem, updatedItem);
1809
+ return true;
1810
+ });
1811
+ }
1812
+
1813
+ private createRawIntegrityError(itemArg: IQueueItem, detailArg: string): SmtpDeliveryError {
1814
+ return new SmtpDeliveryError({
1815
+ message: `Raw queue payload integrity failure for item ${itemArg.id}: ${detailArg}`,
1816
+ errorType: 'raw_queue_integrity',
1817
+ retryable: false,
1818
+ phase: 'message_body',
1819
+ transactionLog: [],
1820
+ recipients: itemArg.smtpEnvelope?.to || [],
1821
+ });
1822
+ }
1823
+
1824
+ private async readVerifiedRawMessage(itemArg: IQueueItem): Promise<Buffer> {
1825
+ const storageManager = this.options.storageManager;
1826
+ if (!storageManager) {
1827
+ throw this.createRawIntegrityError(itemArg, 'managed storage is unavailable');
1828
+ }
1829
+ const data = await storageManager.get(this.storageKey(itemArg.id));
1830
+ if (!data) {
1831
+ throw this.createRawIntegrityError(itemArg, 'atomic queue blob is missing');
1832
+ }
1833
+ let stored: IDeserializedQueueItem;
1834
+ try {
1835
+ stored = this.deserializeItem(this.storageKey(itemArg.id), data);
1836
+ } catch (error) {
1837
+ throw this.createRawIntegrityError(itemArg, getErrorMessage(error));
1838
+ }
1839
+ if (
1840
+ stored.item.messageFormat !== 'raw-rfc822'
1841
+ || !stored.rawMessage
1842
+ || stored.item.rawMessageSha256 !== itemArg.rawMessageSha256
1843
+ || stored.item.rawMessageLength !== itemArg.rawMessageLength
1844
+ || stored.item.idempotencyFingerprint !== itemArg.idempotencyFingerprint
1845
+ || JSON.stringify(stored.item.smtpEnvelope) !== JSON.stringify(itemArg.smtpEnvelope)
1846
+ ) {
1847
+ throw this.createRawIntegrityError(itemArg, 'stored metadata does not match memory state');
1848
+ }
1849
+ return stored.rawMessage;
1850
+ }
1851
+
1852
+ private claimRawReservation(allowDuringShutdownArg = false): () => void {
1853
+ if (
1854
+ this.lifecycleState === 'closed'
1855
+ || (!allowDuringShutdownArg && !this.acceptingRawReservations)
1856
+ ) {
1857
+ throw new Error(`Delivery queue is ${this.lifecycleState}; raw payload access is unavailable`);
1858
+ }
1859
+ if (this.rawReservationCount >= RAW_RESERVATION_LIMIT) {
1860
+ throw new Error(`Raw payload reservation limit ${RAW_RESERVATION_LIMIT} reached`);
1861
+ }
1862
+ this.rawReservationCount++;
1863
+ let released = false;
1864
+ return () => {
1865
+ if (released) return;
1866
+ released = true;
1867
+ this.rawReservationCount--;
1868
+ if (this.rawReservationCount === 0) {
1869
+ for (const resolve of this.rawReservationDrainWaiters.splice(0)) resolve();
1870
+ }
1871
+ };
1872
+ }
1873
+
1874
+ private createRawByteRelease(bytesArg: number, releaseReservationArg: () => void): () => void {
1875
+ let released = false;
1876
+ return () => {
1877
+ if (released) return;
1878
+ released = true;
1879
+ this.releaseRawBytes(bytesArg);
1880
+ releaseReservationArg();
1371
1881
  };
1372
- mutation(updatedItem);
1373
- await this.persistItem(updatedItem);
1374
- Object.assign(item, updatedItem);
1882
+ }
1883
+
1884
+ private async reserveRawBytes(
1885
+ bytesArg: number,
1886
+ allowDuringShutdownArg = false,
1887
+ ): Promise<() => void> {
1888
+ if (!Number.isSafeInteger(bytesArg) || bytesArg < 1) {
1889
+ throw new Error('Raw payload memory reservation must be a positive safe integer');
1890
+ }
1891
+ if (bytesArg > this.options.rawInFlightByteBudget) {
1892
+ throw new Error(
1893
+ `Raw payload memory reservation ${bytesArg} exceeds queue budget ${this.options.rawInFlightByteBudget}`,
1894
+ );
1895
+ }
1896
+ const releaseReservation = this.claimRawReservation(allowDuringShutdownArg);
1897
+ if (this.rawByteWaiters.length === 0 && this.rawBytesInFlight + bytesArg <= this.options.rawInFlightByteBudget) {
1898
+ this.rawBytesInFlight += bytesArg;
1899
+ return this.createRawByteRelease(bytesArg, releaseReservation);
1900
+ }
1901
+ if (this.rawByteWaiters.length >= RAW_BYTE_WAITER_LIMIT) {
1902
+ releaseReservation();
1903
+ throw new Error(`Raw payload memory waiter limit ${RAW_BYTE_WAITER_LIMIT} reached`);
1904
+ }
1905
+ return new Promise<() => void>((resolve, reject) => {
1906
+ this.rawByteWaiters.push({
1907
+ bytes: bytesArg,
1908
+ resolve: () => resolve(this.createRawByteRelease(bytesArg, releaseReservation)),
1909
+ reject: (error) => {
1910
+ releaseReservation();
1911
+ reject(error);
1912
+ },
1913
+ });
1914
+ });
1915
+ }
1916
+
1917
+ private reserveRawBytesImmediately(bytesArg: number): () => void {
1918
+ if (!Number.isSafeInteger(bytesArg) || bytesArg < 1) {
1919
+ throw new Error('Raw payload memory reservation must be a positive safe integer');
1920
+ }
1921
+ if (bytesArg > this.options.rawInFlightByteBudget) {
1922
+ throw new Error(
1923
+ `Raw payload memory reservation ${bytesArg} exceeds queue budget ${this.options.rawInFlightByteBudget}`,
1924
+ );
1925
+ }
1926
+ const releaseReservation = this.claimRawReservation();
1927
+ if (
1928
+ this.rawByteWaiters.length > 0
1929
+ || this.rawBytesInFlight + bytesArg > this.options.rawInFlightByteBudget
1930
+ ) {
1931
+ releaseReservation();
1932
+ throw new Error('Raw payload memory budget is currently exhausted');
1933
+ }
1934
+ this.rawBytesInFlight += bytesArg;
1935
+ return this.createRawByteRelease(bytesArg, releaseReservation);
1936
+ }
1937
+
1938
+ public [kQueueReserveRawAccess](rawMessageLengthArg: number): () => void {
1939
+ return this.reserveRawBytesImmediately(
1940
+ weightedRawBytes(rawMessageLengthArg, RAW_ACCESS_RESERVATION_MULTIPLIER),
1941
+ );
1942
+ }
1943
+
1944
+ private releaseRawBytes(bytesArg: number): void {
1945
+ this.rawBytesInFlight -= bytesArg;
1946
+ while (this.rawByteWaiters.length > 0) {
1947
+ const waiter = this.rawByteWaiters[0];
1948
+ if (this.rawBytesInFlight + waiter.bytes > this.options.rawInFlightByteBudget) break;
1949
+ this.rawByteWaiters.shift();
1950
+ this.rawBytesInFlight += waiter.bytes;
1951
+ waiter.resolve();
1952
+ }
1953
+ }
1954
+
1955
+ private async withRawByteReservation<T>(
1956
+ bytesArg: number,
1957
+ callbackArg: () => Promise<T>,
1958
+ allowDuringShutdownArg = false,
1959
+ ): Promise<T> {
1960
+ const releaseReservation = await this.reserveRawBytes(bytesArg, allowDuringShutdownArg);
1961
+ try {
1962
+ return await callbackArg();
1963
+ } finally {
1964
+ releaseReservation();
1965
+ }
1966
+ }
1967
+
1968
+ private async waitForRawReservationsToDrain(): Promise<void> {
1969
+ if (this.rawReservationCount === 0) return;
1970
+ await new Promise<void>((resolve) => {
1971
+ this.rawReservationDrainWaiters.push(resolve);
1972
+ });
1973
+ }
1974
+
1975
+ public async [kQueueWithRawMessage]<T>(
1976
+ itemIdArg: string,
1977
+ callbackArg: (rawMessage: Buffer) => Promise<T>,
1978
+ ): Promise<T> {
1979
+ const item = this.queue.get(itemIdArg);
1980
+ if (!item || item.messageFormat !== 'raw-rfc822' || !item.rawMessageLength) {
1981
+ throw new Error(`Queue item ${itemIdArg} is not a raw outbound item`);
1982
+ }
1983
+ const reservedBytes = weightedRawBytes(
1984
+ item.rawMessageLength,
1985
+ RAW_ACCESS_RESERVATION_MULTIPLIER,
1986
+ );
1987
+ return this.withRawByteReservation(reservedBytes, async () => {
1988
+ const rawMessage = await this.readVerifiedRawMessage(item);
1989
+ return await callbackArg(rawMessage);
1990
+ });
1375
1991
  }
1376
1992
 
1377
1993
  private async removeItemFromStorage(id: string): Promise<void> {
1378
1994
  await this.options.storageManager?.delete(this.storageKey(id));
1379
1995
  }
1380
1996
 
1381
- private deserializeItem(key: string, data: Buffer): IQueueItem {
1997
+ private deserializeItem(key: string, data: Buffer): IDeserializedQueueItem {
1382
1998
  let parsed: Record<string, any>;
1383
1999
  try {
1384
2000
  parsed = deserialize(data) as Record<string, any>;
@@ -1409,7 +2025,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1409
2025
  throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
1410
2026
  }
1411
2027
  }
1412
- if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
2028
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) {
1413
2029
  throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
1414
2030
  }
1415
2031
  if (this.storageKey(parsed.id) !== key) {
@@ -1444,7 +2060,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1444
2060
  throw new Error(`Invalid managed queue item at ${key}: invalid terminal recipients`);
1445
2061
  }
1446
2062
 
1447
- if (parsed.schemaVersion === 2) {
2063
+ if (parsed.schemaVersion === 2 || parsed.schemaVersion === 3) {
1448
2064
  if (
1449
2065
  !Array.isArray(parsed.recipientStates)
1450
2066
  || parsed.recipientStates.some((value: unknown) => {
@@ -1671,9 +2287,70 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1671
2287
  throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
1672
2288
  }
1673
2289
 
2290
+ let rawMessage: Buffer | undefined;
2291
+ let smtpEnvelope: IRawSmtpEnvelope | undefined;
2292
+ if (parsed.schemaVersion === 3) {
2293
+ if (!this.hasPaginatedManagedStorage()) {
2294
+ throw new Error(`Invalid managed queue item at ${key}: raw queue requires paginated storage`);
2295
+ }
2296
+ if (
2297
+ parsed.messageFormat !== 'raw-rfc822'
2298
+ || parsed.processingMode !== 'mta'
2299
+ || parsed.route !== undefined
2300
+ || parsed.attachmentCount !== 0
2301
+ || typeof parsed.rawMessageSha256 !== 'string'
2302
+ || !/^[a-f0-9]{64}$/.test(parsed.rawMessageSha256)
2303
+ || !Number.isSafeInteger(parsed.rawMessageLength)
2304
+ || parsed.rawMessageLength < 1
2305
+ || parsed.rawMessageLength > this.options.maxRawMessageBytes
2306
+ || !Buffer.isBuffer(parsed.rawMessage)
2307
+ || parsed.rawMessage.length !== parsed.rawMessageLength
2308
+ ) {
2309
+ throw new Error(`Invalid managed queue item at ${key}: invalid raw payload metadata`);
2310
+ }
2311
+ try {
2312
+ assertCanonicalRawRfc822Message(parsed.rawMessage);
2313
+ } catch (error) {
2314
+ throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
2315
+ }
2316
+ let normalizedEnvelope: IRawSmtpEnvelope;
2317
+ try {
2318
+ normalizedEnvelope = normalizeRawSmtpEnvelope(parsed.smtpEnvelope);
2319
+ } catch (error) {
2320
+ throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
2321
+ }
2322
+ if (JSON.stringify(normalizedEnvelope) !== JSON.stringify(parsed.smtpEnvelope)) {
2323
+ throw new Error(`Invalid managed queue item at ${key}: SMTP envelope is not canonical`);
2324
+ }
2325
+ const digest = createHash('sha256').update(parsed.rawMessage).digest('hex');
2326
+ if (digest !== parsed.rawMessageSha256) {
2327
+ throw new Error(`Invalid managed queue item at ${key}: raw payload digest mismatch`);
2328
+ }
2329
+ if (
2330
+ parsed.idempotencyFingerprint
2331
+ !== createRawQueueFingerprint(parsed.rawMessage, normalizedEnvelope)
2332
+ ) {
2333
+ throw new Error(`Invalid managed queue item at ${key}: raw queue fingerprint mismatch`);
2334
+ }
2335
+ if (
2336
+ storedOptions.from !== normalizedEnvelope.from
2337
+ || JSON.stringify(Array.isArray(storedOptions.to) ? storedOptions.to : [storedOptions.to])
2338
+ !== JSON.stringify(normalizedEnvelope.to)
2339
+ || storedOptions.subject !== ''
2340
+ || storedOptions.text !== ''
2341
+ || storedOptions.html !== undefined
2342
+ || storedOptions.attachments !== undefined
2343
+ || storedOptions.headers !== undefined
2344
+ ) {
2345
+ throw new Error(`Invalid managed queue item at ${key}: raw queue Email metadata is not minimal`);
2346
+ }
2347
+ rawMessage = parsed.rawMessage;
2348
+ smtpEnvelope = normalizedEnvelope;
2349
+ }
2350
+
1674
2351
  let recipientStates: IRecipientDeliveryState[];
1675
2352
  let bounceOperations: IBounceOperation[];
1676
- if (parsed.schemaVersion === 2) {
2353
+ if (parsed.schemaVersion === 2 || parsed.schemaVersion === 3) {
1677
2354
  recipientStates = parsed.recipientStates.map((state: IStoredRecipientDeliveryState) => ({
1678
2355
  ...state,
1679
2356
  updatedAt: parseDate(state.updatedAt, 'recipient state updatedAt'),
@@ -1702,7 +2379,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1702
2379
  bounceOperations = [];
1703
2380
  }
1704
2381
 
1705
- return {
2382
+ const item: IQueueItem = {
1706
2383
  id: parsed.id,
1707
2384
  ...(parsed.idempotencyKey !== undefined ? {
1708
2385
  idempotencyKey: parsed.idempotencyKey,
@@ -1710,6 +2387,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1710
2387
  } : {}),
1711
2388
  processingMode: parsed.processingMode,
1712
2389
  processingResult,
2390
+ ...(parsed.schemaVersion === 3 ? {
2391
+ messageFormat: 'raw-rfc822' as const,
2392
+ rawMessageSha256: parsed.rawMessageSha256,
2393
+ rawMessageLength: parsed.rawMessageLength,
2394
+ smtpEnvelope: cloneRawSmtpEnvelope(smtpEnvelope!),
2395
+ } : {}),
1713
2396
  attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
1714
2397
  ...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
1715
2398
  status: parsed.status,
@@ -1738,24 +2421,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1738
2421
  }))
1739
2422
  : [],
1740
2423
  } as IQueueItem;
2424
+ return {
2425
+ item,
2426
+ ...(rawMessage ? { rawMessage } : {}),
2427
+ };
1741
2428
  }
1742
2429
 
1743
2430
  private async loadFromStorage(): Promise<void> {
1744
2431
  const storageManager = this.options.storageManager;
1745
2432
  if (!storageManager) return;
1746
- const keys = (await storageManager.list(this.options.storagePrefix)).sort();
1747
- const recoveredItems: IQueueItem[] = [];
2433
+ let recoveredItemCount = 0;
1748
2434
 
1749
- for (const key of keys) {
2435
+ const loadKey = async (key: string): Promise<void> => {
1750
2436
  if (!key.startsWith(this.options.storagePrefix)) {
1751
2437
  throw new Error(`Blob storage returned a queue key outside ${this.options.storagePrefix}: ${key}`);
1752
2438
  }
1753
- if (!key.endsWith('.json')) continue;
2439
+ if (!key.endsWith('.json')) return;
1754
2440
  const data = await storageManager.get(key);
1755
2441
  if (!data) {
1756
2442
  throw new Error(`Managed queue item disappeared while loading: ${key}`);
1757
2443
  }
1758
- const item = this.deserializeItem(key, data);
2444
+ const { item, rawMessage } = this.deserializeItem(key, data);
1759
2445
  if (this.queue.has(item.id)) {
1760
2446
  throw new Error(`Duplicate managed queue item id: ${item.id}`);
1761
2447
  }
@@ -1767,16 +2453,53 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1767
2453
  item.nextAttempt = new Date();
1768
2454
  item.updatedAt = new Date();
1769
2455
  item.lastError = 'Recovered after process restart during delivery';
1770
- recoveredItems.push(item);
2456
+ await this.persistItem(item, rawMessage);
2457
+ recoveredItemCount++;
1771
2458
  }
1772
2459
  this.queue.set(item.id, item);
2460
+ };
2461
+
2462
+ if (storageManager.listPage) {
2463
+ let continuationToken: string | undefined;
2464
+ const seenTokens = new Set<string>();
2465
+ do {
2466
+ const page = await storageManager.listPage(this.options.storagePrefix, {
2467
+ limit: SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT,
2468
+ ...(continuationToken ? { continuationToken } : {}),
2469
+ });
2470
+ if (
2471
+ !page
2472
+ || !Array.isArray(page.keys)
2473
+ || page.keys.length > SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT
2474
+ || page.keys.some((key) => typeof key !== 'string')
2475
+ || new Set(page.keys).size !== page.keys.length
2476
+ ) {
2477
+ throw new Error('Invalid paginated managed queue listing');
2478
+ }
2479
+ for (const key of [...page.keys].sort()) await loadKey(key);
2480
+ if (page.continuationToken !== undefined) {
2481
+ if (
2482
+ typeof page.continuationToken !== 'string'
2483
+ || !page.continuationToken
2484
+ || page.keys.length === 0
2485
+ || seenTokens.has(page.continuationToken)
2486
+ ) {
2487
+ throw new Error('Invalid paginated managed queue continuation token');
2488
+ }
2489
+ seenTokens.add(page.continuationToken);
2490
+ }
2491
+ continuationToken = page.continuationToken;
2492
+ } while (continuationToken);
2493
+ } else {
2494
+ for (const key of (await storageManager.list(this.options.storagePrefix)).sort()) {
2495
+ await loadKey(key);
2496
+ }
1773
2497
  }
1774
2498
 
1775
- await Promise.all(recoveredItems.map((item) => this.persistItem(item)));
1776
2499
  this.updateStats();
1777
2500
  logger.log(
1778
2501
  'info',
1779
- `Loaded ${this.queue.size} items from managed blob storage; recovered ${recoveredItems.length} interrupted item(s)`,
2502
+ `Loaded ${this.queue.size} items from managed blob storage; recovered ${recoveredItemCount} interrupted item(s)`,
1780
2503
  );
1781
2504
  }
1782
2505
 
@@ -1838,14 +2561,20 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1838
2561
  this.stats.processingActive = this.processing;
1839
2562
 
1840
2563
  // Emit statistics event
1841
- void emitSafely(this, 'statsUpdated', this.stats);
2564
+ void emitSafely(this, 'statsUpdated', this.getStats());
1842
2565
  }
1843
2566
 
1844
2567
  /**
1845
2568
  * Get queue statistics
1846
2569
  */
1847
2570
  public getStats(): IQueueStats {
1848
- return { ...this.stats };
2571
+ return {
2572
+ ...this.stats,
2573
+ status: { ...this.stats.status },
2574
+ modes: { ...this.stats.modes },
2575
+ ...(this.stats.oldestItem ? { oldestItem: new Date(this.stats.oldestItem) } : {}),
2576
+ ...(this.stats.newestItem ? { newestItem: new Date(this.stats.newestItem) } : {}),
2577
+ };
1849
2578
  }
1850
2579
 
1851
2580
  /**
@@ -1867,6 +2596,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1867
2596
  if (!this.processing) {
1868
2597
  this.startProcessing();
1869
2598
  logger.log('info', 'Queue processing resumed');
2599
+ } else {
2600
+ this.wake();
1870
2601
  }
1871
2602
  }
1872
2603
 
@@ -1908,20 +2639,47 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1908
2639
  /**
1909
2640
  * Shutdown the queue
1910
2641
  */
1911
- public async shutdown(): Promise<void> {
1912
- if (this.lifecycleState === 'closed') return;
2642
+ public shutdown(): Promise<void> {
2643
+ if (this.shutdownFlight) return this.shutdownFlight;
2644
+ if (this.lifecycleState === 'closed') return Promise.resolve();
2645
+ this.acceptingNewItems = false;
2646
+ this.acceptingMutations = false;
2647
+ this.acceptingRawReservations = false;
2648
+ this.shutdownFlight = this.shutdownOnce();
2649
+ return this.shutdownFlight;
2650
+ }
2651
+
2652
+ private async shutdownOnce(): Promise<void> {
1913
2653
  logger.log('info', 'Shutting down UnifiedDeliveryQueue');
1914
- this.lifecycleState = 'shutting-down';
1915
2654
  let persistenceError: unknown;
1916
2655
 
1917
2656
  try {
1918
2657
  // Stop processing (clears both check and cleanup timers).
1919
2658
  this.stopProcessing();
2659
+ const activeScan = this.processingScanPromise;
2660
+ if (activeScan) {
2661
+ await activeScan;
2662
+ }
2663
+ await this.cleanupFlight;
2664
+ while (this.enqueueFlights.size > 0) {
2665
+ await Promise.allSettled([...this.enqueueFlights]);
2666
+ }
2667
+ while (this.itemMutationFlights.size > 0) {
2668
+ await Promise.allSettled([...this.itemMutationFlights.values()]);
2669
+ }
2670
+ await this.waitForRawReservationsToDrain();
2671
+ this.lifecycleState = 'shutting-down';
1920
2672
 
1921
2673
  if (this.options.storageManager) {
1922
- await Promise.all(
1923
- [...this.queue.values()].map((item) => this.persistItem(item)),
1924
- );
2674
+ for (const itemId of [...this.queue.keys()]) {
2675
+ await this.withItemMutex(itemId, async () => {
2676
+ const currentItem = this.queue.get(itemId);
2677
+ // Raw records are immutable single blobs and every transition is
2678
+ // already persisted. Avoid rereading large payloads during shutdown.
2679
+ if (!currentItem || currentItem.messageFormat === 'raw-rfc822') return;
2680
+ await this.persistItem(currentItem);
2681
+ });
2682
+ }
1925
2683
  }
1926
2684
  } catch (error) {
1927
2685
  // Preserve the original persistence failure; observer diagnostics are
@@ -1933,7 +2691,9 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1933
2691
  this.updateStats();
1934
2692
  await emitSafely(this, 'shutdown');
1935
2693
  this.lifecycleState = 'closed';
2694
+ this.readyItemsHandler = undefined;
1936
2695
  this.releaseManagedPrefix();
2696
+ this.removeAllListeners();
1937
2697
  }
1938
2698
 
1939
2699
  if (persistenceError) throw persistenceError;