@push.rocks/smartmta 9.4.1 → 9.6.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 (40) hide show
  1. package/changelog.md +41 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_amd64.tsrust-build.json +14 -0
  4. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  5. package/dist_rust/mailer-bin_linux_arm64.tsrust-build.json +14 -0
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/index.d.ts +2 -0
  8. package/dist_ts/index.js +3 -1
  9. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +63 -2
  10. package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
  11. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
  12. package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
  13. package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
  14. package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
  15. package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
  16. package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
  17. package/dist_ts/mail/index.d.ts +1 -0
  18. package/dist_ts/mail/index.js +1 -1
  19. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +180 -4
  20. package/dist_ts/mail/routing/classes.unified.email.server.js +592 -172
  21. package/dist_ts/mail/routing/interfaces.d.ts +41 -0
  22. package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
  23. package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
  24. package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
  25. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  26. package/package.json +6 -6
  27. package/readme.md +78 -4
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/index.ts +2 -0
  30. package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
  31. package/ts/mail/delivery/classes.delivery.system.ts +192 -80
  32. package/ts/mail/delivery/functions.raw-email.ts +111 -0
  33. package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
  34. package/ts/mail/index.ts +1 -0
  35. package/ts/mail/routing/classes.unified.email.server.ts +780 -192
  36. package/ts/mail/routing/interfaces.ts +43 -0
  37. package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
  38. package/ts/security/classes.rustsecuritybridge.ts +6 -0
  39. package/readme.hints.md +0 -37
  40. package/readme.plan.md +0 -30
@@ -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,15 +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;
251
319
  private processingScanPromise?: Promise<void>;
252
320
  private processingRescanRequested = false;
253
- private processingScanShutdownTimeoutMs = 30_000;
254
321
  private totalProcessed: number = 0;
255
322
  private ownsManagedPrefix = false;
256
323
  private lifecycleState: TQueueLifecycleState = 'new';
@@ -259,6 +326,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
259
326
  promise: Promise<string>;
260
327
  }>();
261
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>;
262
345
 
263
346
  /**
264
347
  * Create a new unified delivery queue
@@ -273,6 +356,24 @@ export class UnifiedDeliveryQueue extends EventEmitter {
273
356
  if (options.storageMode === 'memory' && options.storageManager) {
274
357
  throw new Error('Memory-only queue storage cannot receive an IBlobStorageManager');
275
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
+ }
276
377
 
277
378
  this.options = {
278
379
  storageMode: options.storageMode,
@@ -283,7 +384,9 @@ export class UnifiedDeliveryQueue extends EventEmitter {
283
384
  maxPerDestination: options.maxPerDestination || 100,
284
385
  maxRetries: options.maxRetries || 5,
285
386
  baseRetryDelay: options.baseRetryDelay || 60000, // 1 minute
286
- maxRetryDelay: options.maxRetryDelay || 3600000 // 1 hour
387
+ maxRetryDelay: options.maxRetryDelay || 3600000, // 1 hour
388
+ maxRawMessageBytes,
389
+ rawInFlightByteBudget,
287
390
  };
288
391
 
289
392
  // Initialize statistics
@@ -308,11 +411,32 @@ export class UnifiedDeliveryQueue extends EventEmitter {
308
411
  }
309
412
 
310
413
  private assertMutable(): void {
311
- if (this.lifecycleState === 'shutting-down' || this.lifecycleState === 'closed') {
414
+ if (
415
+ !this.acceptingMutations
416
+ || this.lifecycleState === 'shutting-down'
417
+ || this.lifecycleState === 'closed'
418
+ ) {
312
419
  throw new Error(`Delivery queue is ${this.lifecycleState}; mutations are no longer allowed`);
313
420
  }
314
421
  }
315
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
+
316
440
  /** Whether queue entries are committed through caller-managed blob storage. */
317
441
  public hasManagedStorage(): boolean {
318
442
  return !!this.options.storageManager;
@@ -323,6 +447,93 @@ export class UnifiedDeliveryQueue extends EventEmitter {
323
447
  return typeof this.options.storageManager?.listPage === 'function';
324
448
  }
325
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
+
326
537
  private idempotentQueueId(idempotencyKey: string): string {
327
538
  return `idempotent-${createHash('sha256')
328
539
  .update('smartmta-queue-v1\0')
@@ -558,9 +769,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
558
769
  clearInterval(this.cleanupTimer);
559
770
  }
560
771
  this.cleanupTimer = setInterval(() => {
561
- this.cleanupOldItems(24 * 60 * 60 * 1000).catch((err) => {
562
- logger.log('error', `Auto-cleanup failed: ${err.message}`);
563
- });
772
+ this.runPeriodicCleanup();
564
773
  }, 30 * 60 * 1000);
565
774
 
566
775
  this.processing = true;
@@ -569,6 +778,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
569
778
  logger.log('info', 'Queue processing started');
570
779
  this.wake();
571
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;
796
+ }
572
797
 
573
798
  /**
574
799
  * Stop queue processing
@@ -596,7 +821,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
596
821
  private async processQueue(): Promise<void> {
597
822
  try {
598
823
  const now = new Date();
599
- let readyItems: IQueueItem[] = [];
824
+ const readyItems: IQueueItem[] = [];
600
825
 
601
826
  // Find items ready for processing
602
827
  for (const item of this.queue.values()) {
@@ -616,8 +841,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
616
841
  return;
617
842
  }
618
843
 
619
- // Emit event for ready items
620
- 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
+ }
621
849
  logger.log('info', `Found ${readyItems.length} items ready for processing`);
622
850
 
623
851
  // Update statistics
@@ -664,13 +892,28 @@ export class UnifiedDeliveryQueue extends EventEmitter {
664
892
  * @param mode Processing mode
665
893
  * @param route Email route
666
894
  */
667
- public async enqueue(
895
+ public enqueue(
668
896
  processingResult: Email,
669
897
  mode: EmailProcessingMode,
670
898
  route?: IEmailRoute,
671
899
  enqueueOptions: IQueueEnqueueOptions = {},
672
900
  ): Promise<string> {
673
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> {
674
917
  if (!(processingResult instanceof Email)) {
675
918
  throw new Error('Queue processingResult must be an Email instance');
676
919
  }
@@ -685,68 +928,170 @@ export class UnifiedDeliveryQueue extends EventEmitter {
685
928
  throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
686
929
  }
687
930
  if (enqueueOptions.idempotencyKey !== undefined) {
688
- if (this.options.storageManager && !this.options.storageManager.listPage) {
689
- throw new Error(
690
- 'Managed idempotent enqueue requires paginated managed queue storage',
691
- );
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');
692
958
  }
693
- if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
694
- 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();
695
1003
  }
696
- if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
697
- 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}`);
698
1059
  }
1060
+ return id;
1061
+ }
699
1062
 
700
- const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
701
- const existing = this.queue.get(id);
702
- 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) {
703
1073
  if (
704
- existing.idempotencyKey !== enqueueOptions.idempotencyKey
705
- || existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
1074
+ tombstone.idempotencyKey !== idempotencyKeyArg
1075
+ || tombstone.idempotencyFingerprint !== fingerprintArg
706
1076
  ) {
707
1077
  throw new Error(`Queue idempotency key collision for ${id}`);
708
1078
  }
709
1079
  return id;
710
1080
  }
711
-
712
- const activeFlight = this.idempotentEnqueueFlights.get(id);
713
- if (activeFlight) {
714
- if (activeFlight.fingerprint !== enqueueOptions.idempotencyFingerprint) {
715
- throw new Error(`Queue idempotency key collision for ${id}`);
716
- }
717
- return activeFlight.promise;
718
- }
719
- const flight = (async () => {
720
- const tombstone = await this.readIdempotencyTombstone(id);
721
- if (tombstone) {
722
- if (
723
- tombstone.idempotencyKey !== enqueueOptions.idempotencyKey
724
- || tombstone.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
725
- ) {
726
- throw new Error(`Queue idempotency key collision for ${id}`);
727
- }
728
- return id;
729
- }
730
- return this.enqueueNew(processingResult, mode, route, {
731
- id,
732
- idempotencyKey: enqueueOptions.idempotencyKey!,
733
- idempotencyFingerprint: enqueueOptions.idempotencyFingerprint!,
734
- });
735
- })();
736
- this.idempotentEnqueueFlights.set(id, {
737
- fingerprint: enqueueOptions.idempotencyFingerprint!,
738
- promise: flight,
1081
+ return createArg({
1082
+ id,
1083
+ idempotencyKey: idempotencyKeyArg,
1084
+ idempotencyFingerprint: fingerprintArg,
739
1085
  });
740
- try {
741
- return await flight;
742
- } finally {
743
- if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
744
- this.idempotentEnqueueFlights.delete(id);
745
- }
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);
746
1093
  }
747
1094
  }
748
-
749
- return this.enqueueNew(processingResult, mode, route);
750
1095
  }
751
1096
 
752
1097
  private async enqueueNew(
@@ -758,55 +1103,64 @@ export class UnifiedDeliveryQueue extends EventEmitter {
758
1103
  idempotencyKey: string;
759
1104
  idempotencyFingerprint: string;
760
1105
  },
1106
+ raw?: {
1107
+ rawMessage: Buffer;
1108
+ rawMessageSha256: string;
1109
+ smtpEnvelope: IRawSmtpEnvelope;
1110
+ },
761
1111
  ): Promise<string> {
762
- // Check if queue is full
763
- if (this.queue.size >= this.options.maxQueueSize) {
1112
+ if (this.queue.size + this.pendingNewItemCount >= this.options.maxQueueSize) {
764
1113
  throw new Error('Queue is full');
765
1114
  }
766
-
767
- // Generate a unique ID
768
- const id = idempotency?.id
769
- || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
770
-
771
- // Create queue item
772
- const item: IQueueItem = {
773
- id,
774
- ...(idempotency ? {
775
- idempotencyKey: idempotency.idempotencyKey,
776
- idempotencyFingerprint: idempotency.idempotencyFingerprint,
777
- } : {}),
778
- processingMode: mode,
779
- processingResult,
780
- attachmentCount: processingResult.attachments.length,
781
- route,
782
- status: 'pending',
783
- attempts: 0,
784
- nextAttempt: new Date(),
785
- createdAt: new Date(),
786
- updatedAt: new Date(),
787
- deliveredRecipients: [],
788
- terminalRecipients: [],
789
- recipientStates: this.createInitialRecipientStates(processingResult),
790
- bounceOperations: [],
791
- smtpTransactions: [],
792
- };
793
-
794
- // Commit durable state before exposing the item in memory or emitting acceptance.
795
- await this.persistItem(item);
796
- if (idempotency) {
797
- 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 = {
798
1123
  id,
799
- idempotency.idempotencyKey,
800
- idempotency.idempotencyFingerprint,
801
- );
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--;
802
1157
  }
803
- this.queue.set(id, item);
804
1158
 
805
1159
  // Update statistics
806
1160
  this.updateStats();
807
1161
 
808
1162
  // Emit event
809
- await emitSafely(this, 'itemEnqueued', item);
1163
+ await this.emitItemEventSafely('itemEnqueued', item);
810
1164
  logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
811
1165
  this.wake();
812
1166
 
@@ -818,23 +1172,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
818
1172
  * @param id Item ID
819
1173
  */
820
1174
  public getItem(id: string): IQueueItem | undefined {
821
- return this.queue.get(id);
1175
+ const item = this.queue.get(id);
1176
+ return item ? this[kQueueCreateItemSnapshot](item) : undefined;
822
1177
  }
823
1178
 
824
1179
  public listItems(): IQueueItem[] {
825
- return Array.from(this.queue.values()).map((item) => ({
826
- ...item,
827
- deliveredRecipients: [...(item.deliveredRecipients || [])],
828
- terminalRecipients: [...(item.terminalRecipients || [])],
829
- recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
830
- bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
831
- smtpTransactions: (item.smtpTransactions || []).map((transaction) => ({
832
- ...transaction,
833
- recipients: [...transaction.recipients],
834
- recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
835
- transcript: transaction.transcript.map((entry) => ({ ...entry })),
836
- })),
837
- }));
1180
+ return Array.from(this.queue.values()).map((item) => this[kQueueCreateItemSnapshot](item));
838
1181
  }
839
1182
 
840
1183
  /**
@@ -844,28 +1187,25 @@ export class UnifiedDeliveryQueue extends EventEmitter {
844
1187
  public async markProcessing(id: string): Promise<boolean> {
845
1188
  this.assertMutable();
846
1189
  const item = this.queue.get(id);
1190
+ if (!item) return false;
847
1191
 
848
- if (
849
- !item
850
- || !(
851
- item.status === 'pending'
852
- || (item.status === 'deferred' && item.nextAttempt <= new Date())
853
- )
854
- ) {
855
- return false;
856
- }
857
-
858
- await this.applyPersistedMutation(item, (updatedItem) => {
1192
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
859
1193
  updatedItem.status = 'processing';
860
1194
  updatedItem.attempts++;
861
1195
  updatedItem.updatedAt = new Date();
862
- });
1196
+ }, (currentItem) => (
1197
+ currentItem.status === 'pending'
1198
+ || (currentItem.status === 'deferred' && currentItem.nextAttempt <= new Date())
1199
+ ));
1200
+ if (!updated) {
1201
+ return false;
1202
+ }
863
1203
 
864
1204
  // Update statistics
865
1205
  this.updateStats();
866
1206
 
867
1207
  // Emit event
868
- await emitSafely(this, 'itemProcessing', item);
1208
+ await this.emitItemEventSafely('itemProcessing', item);
869
1209
  logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
870
1210
 
871
1211
  return true;
@@ -883,18 +1223,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
883
1223
  return false;
884
1224
  }
885
1225
 
886
- await this.applyPersistedMutation(item, (updatedItem) => {
1226
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
887
1227
  updatedItem.status = 'delivered';
888
1228
  updatedItem.updatedAt = new Date();
889
1229
  updatedItem.deliveredAt = new Date();
890
- });
1230
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1231
+ if (!updated) return false;
891
1232
 
892
1233
  // Update statistics
893
1234
  this.totalProcessed++;
894
1235
  this.updateStats();
895
1236
 
896
1237
  // Emit event
897
- await emitSafely(this, 'itemDelivered', item);
1238
+ await this.emitItemEventSafely('itemDelivered', item);
898
1239
  logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
899
1240
 
900
1241
  return true;
@@ -942,21 +1283,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
942
1283
  this.options.maxRetryDelay
943
1284
  );
944
1285
 
945
- await this.applyPersistedMutation(item, (updatedItem) => {
1286
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
946
1287
  updatedItem.status = 'deferred';
947
1288
  updatedItem.lastError = error;
948
1289
  updatedItem.nextAttempt = new Date(Date.now() + delay);
949
1290
  updatedItem.updatedAt = new Date();
950
- });
1291
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1292
+ if (!updated) return { updated: false, terminal: true };
951
1293
 
952
1294
  // Emit event
953
- await emitSafely(this, 'itemDeferred', item);
1295
+ await this.emitItemEventSafely('itemDeferred', item);
954
1296
  logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
955
1297
  this.updateStats();
956
1298
  return { updated: true, status: 'deferred', terminal: false };
957
1299
  } else {
958
1300
  // Mark as permanently failed
959
- await this.applyPersistedMutation(item, (updatedItem) => {
1301
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
960
1302
  const now = new Date();
961
1303
  updatedItem.status = 'failed';
962
1304
  updatedItem.lastError = error;
@@ -1006,13 +1348,14 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1006
1348
  updatedItem.terminalRecipients = [...terminal];
1007
1349
  updatedItem.bounceOperations = operations;
1008
1350
  }
1009
- });
1351
+ }, (currentItem) => currentItem.status !== 'delivered' && currentItem.status !== 'failed');
1352
+ if (!updated) return { updated: false, terminal: true };
1010
1353
 
1011
1354
  // Update statistics
1012
1355
  this.totalProcessed++;
1013
1356
 
1014
1357
  // Emit event
1015
- await emitSafely(this, 'itemFailed', item);
1358
+ await this.emitItemEventSafely('itemFailed', item);
1016
1359
  logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
1017
1360
  }
1018
1361
 
@@ -1047,7 +1390,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1047
1390
  ].slice(-20);
1048
1391
  updatedItem.updatedAt = new Date();
1049
1392
  });
1050
- await emitSafely(this, 'smtpTransaction', item, normalizedTransaction);
1393
+ await this.emitItemEventSafely('smtpTransaction', item, normalizedTransaction);
1051
1394
  return true;
1052
1395
  }
1053
1396
 
@@ -1060,8 +1403,6 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1060
1403
  this.assertMutable();
1061
1404
  const item = this.queue.get(id);
1062
1405
  if (!item) return false;
1063
- const transactionIndex = (item.smtpTransactions || []).findIndex((value) => value.id === transactionId);
1064
- if (transactionIndex < 0) return false;
1065
1406
  const normalizedTransaction: ISmtpTransactionAttempt = {
1066
1407
  ...transaction,
1067
1408
  queueItemId: id,
@@ -1072,13 +1413,18 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1072
1413
  : undefined,
1073
1414
  transcript: normalizeSmtpTranscript(transaction.transcript),
1074
1415
  };
1075
- await this.applyPersistedMutation(item, (updatedItem) => {
1416
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1076
1417
  const transactions = [...(updatedItem.smtpTransactions || [])];
1418
+ const transactionIndex = transactions.findIndex((value) => value.id === transactionId);
1419
+ if (transactionIndex < 0) return;
1077
1420
  transactions[transactionIndex] = normalizedTransaction;
1078
1421
  updatedItem.smtpTransactions = transactions.slice(-20);
1079
1422
  updatedItem.updatedAt = new Date();
1080
- });
1081
- 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);
1082
1428
  return true;
1083
1429
  }
1084
1430
 
@@ -1259,11 +1605,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1259
1605
  ): Promise<boolean> {
1260
1606
  this.assertMutable();
1261
1607
  const item = this.queue.get(itemId);
1262
- const operation = item?.bounceOperations?.find(
1263
- (candidate) => candidate.operationId === operationId,
1264
- );
1265
- if (!item || !operation || operation.state === 'completed') return false;
1266
- await this.applyPersistedMutation(item, (updatedItem) => {
1608
+ if (!item) return false;
1609
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1267
1610
  const updatedOperation = updatedItem.bounceOperations?.find(
1268
1611
  (candidate) => candidate.operationId === operationId,
1269
1612
  );
@@ -1272,8 +1615,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1272
1615
  updatedOperation.completedAt = new Date();
1273
1616
  delete updatedOperation.lastError;
1274
1617
  updatedItem.updatedAt = new Date();
1275
- });
1276
- return true;
1618
+ }, (currentItem) => Boolean(currentItem.bounceOperations?.some(
1619
+ (operation) => operation.operationId === operationId && operation.state !== 'completed',
1620
+ )));
1621
+ return updated;
1277
1622
  }
1278
1623
 
1279
1624
  public async markBounceOperationFailed(
@@ -1283,11 +1628,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1283
1628
  ): Promise<boolean> {
1284
1629
  this.assertMutable();
1285
1630
  const item = this.queue.get(itemId);
1286
- const operation = item?.bounceOperations?.find(
1287
- (candidate) => candidate.operationId === operationId,
1288
- );
1289
- if (!item || !operation || operation.state === 'completed') return false;
1290
- await this.applyPersistedMutation(item, (updatedItem) => {
1631
+ if (!item) return false;
1632
+ const updated = await this.applyPersistedMutation(item, (updatedItem) => {
1291
1633
  const updatedOperation = updatedItem.bounceOperations?.find(
1292
1634
  (candidate) => candidate.operationId === operationId,
1293
1635
  );
@@ -1298,8 +1640,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1298
1640
  Date.now() + Math.min(60_000 * (2 ** Math.max(0, updatedOperation.attempts - 1)), 3_600_000),
1299
1641
  );
1300
1642
  updatedItem.updatedAt = new Date();
1301
- });
1302
- return true;
1643
+ }, (currentItem) => Boolean(currentItem.bounceOperations?.some(
1644
+ (operation) => operation.operationId === operationId && operation.state !== 'completed',
1645
+ )));
1646
+ return updated;
1303
1647
  }
1304
1648
 
1305
1649
  /**
@@ -1308,28 +1652,33 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1308
1652
  */
1309
1653
  public async removeItem(id: string): Promise<boolean> {
1310
1654
  this.assertMutable();
1311
- const item = this.queue.get(id);
1312
-
1313
- if (!item) {
1314
- return false;
1315
- }
1316
-
1317
- if (item.idempotencyKey && item.idempotencyFingerprint) {
1318
- await this.persistIdempotencyTombstone(
1319
- item.id,
1320
- item.idempotencyKey,
1321
- item.idempotencyFingerprint,
1322
- );
1323
- }
1324
- // Delete durable state before making the removal visible in memory.
1325
- await this.removeItemFromStorage(id);
1326
- 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;
1327
1676
 
1328
1677
  // Update statistics
1329
1678
  this.updateStats();
1330
1679
 
1331
1680
  // Emit event
1332
- await emitSafely(this, 'itemRemoved', item);
1681
+ await this.emitItemEventSafely('itemRemoved', removedItem);
1333
1682
  logger.log('info', `Item ${id} removed from queue`);
1334
1683
 
1335
1684
  return true;
@@ -1339,16 +1688,34 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1339
1688
  return `${this.options.storagePrefix}${id}.json`;
1340
1689
  }
1341
1690
 
1342
- private async persistItem(item: IQueueItem): Promise<void> {
1691
+ private async persistItem(item: IQueueItem, rawMessageArg?: Buffer): Promise<void> {
1343
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
+ }
1344
1696
  await this.options.storageManager.set(
1345
1697
  this.storageKey(item.id),
1346
- serialize(this.serializeItem(item)),
1698
+ serialize(this.serializeItem(item, rawMessageArg)),
1347
1699
  );
1348
1700
  }
1349
1701
 
1350
- private serializeItem(item: IQueueItem): IStoredQueueItem {
1351
- 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 = {
1352
1719
  schemaVersion: 2,
1353
1720
  id: item.id,
1354
1721
  ...(item.idempotencyKey !== undefined ? {
@@ -1394,31 +1761,240 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1394
1761
  transcript: normalizeSmtpTranscript(transaction.transcript),
1395
1762
  })),
1396
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
+ }
1397
1791
  }
1398
1792
 
1399
1793
  private async applyPersistedMutation(
1400
1794
  item: IQueueItem,
1401
1795
  mutation: (updatedItem: IQueueItem) => void,
1402
- ): Promise<void> {
1796
+ shouldMutateArg: (currentItem: IQueueItem) => boolean = () => true,
1797
+ ): Promise<boolean> {
1403
1798
  this.assertMutable();
1404
- const updatedItem: IQueueItem = {
1405
- ...item,
1406
- deliveredRecipients: [...(item.deliveredRecipients || [])],
1407
- terminalRecipients: [...(item.terminalRecipients || [])],
1408
- recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
1409
- bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
1410
- 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();
1411
1881
  };
1412
- mutation(updatedItem);
1413
- await this.persistItem(updatedItem);
1414
- 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
+ });
1415
1991
  }
1416
1992
 
1417
1993
  private async removeItemFromStorage(id: string): Promise<void> {
1418
1994
  await this.options.storageManager?.delete(this.storageKey(id));
1419
1995
  }
1420
1996
 
1421
- private deserializeItem(key: string, data: Buffer): IQueueItem {
1997
+ private deserializeItem(key: string, data: Buffer): IDeserializedQueueItem {
1422
1998
  let parsed: Record<string, any>;
1423
1999
  try {
1424
2000
  parsed = deserialize(data) as Record<string, any>;
@@ -1449,7 +2025,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1449
2025
  throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
1450
2026
  }
1451
2027
  }
1452
- if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
2028
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) {
1453
2029
  throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
1454
2030
  }
1455
2031
  if (this.storageKey(parsed.id) !== key) {
@@ -1484,7 +2060,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1484
2060
  throw new Error(`Invalid managed queue item at ${key}: invalid terminal recipients`);
1485
2061
  }
1486
2062
 
1487
- if (parsed.schemaVersion === 2) {
2063
+ if (parsed.schemaVersion === 2 || parsed.schemaVersion === 3) {
1488
2064
  if (
1489
2065
  !Array.isArray(parsed.recipientStates)
1490
2066
  || parsed.recipientStates.some((value: unknown) => {
@@ -1711,9 +2287,70 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1711
2287
  throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
1712
2288
  }
1713
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
+
1714
2351
  let recipientStates: IRecipientDeliveryState[];
1715
2352
  let bounceOperations: IBounceOperation[];
1716
- if (parsed.schemaVersion === 2) {
2353
+ if (parsed.schemaVersion === 2 || parsed.schemaVersion === 3) {
1717
2354
  recipientStates = parsed.recipientStates.map((state: IStoredRecipientDeliveryState) => ({
1718
2355
  ...state,
1719
2356
  updatedAt: parseDate(state.updatedAt, 'recipient state updatedAt'),
@@ -1742,7 +2379,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1742
2379
  bounceOperations = [];
1743
2380
  }
1744
2381
 
1745
- return {
2382
+ const item: IQueueItem = {
1746
2383
  id: parsed.id,
1747
2384
  ...(parsed.idempotencyKey !== undefined ? {
1748
2385
  idempotencyKey: parsed.idempotencyKey,
@@ -1750,6 +2387,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1750
2387
  } : {}),
1751
2388
  processingMode: parsed.processingMode,
1752
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
+ } : {}),
1753
2396
  attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
1754
2397
  ...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
1755
2398
  status: parsed.status,
@@ -1778,24 +2421,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1778
2421
  }))
1779
2422
  : [],
1780
2423
  } as IQueueItem;
2424
+ return {
2425
+ item,
2426
+ ...(rawMessage ? { rawMessage } : {}),
2427
+ };
1781
2428
  }
1782
2429
 
1783
2430
  private async loadFromStorage(): Promise<void> {
1784
2431
  const storageManager = this.options.storageManager;
1785
2432
  if (!storageManager) return;
1786
- const keys = (await storageManager.list(this.options.storagePrefix)).sort();
1787
- const recoveredItems: IQueueItem[] = [];
2433
+ let recoveredItemCount = 0;
1788
2434
 
1789
- for (const key of keys) {
2435
+ const loadKey = async (key: string): Promise<void> => {
1790
2436
  if (!key.startsWith(this.options.storagePrefix)) {
1791
2437
  throw new Error(`Blob storage returned a queue key outside ${this.options.storagePrefix}: ${key}`);
1792
2438
  }
1793
- if (!key.endsWith('.json')) continue;
2439
+ if (!key.endsWith('.json')) return;
1794
2440
  const data = await storageManager.get(key);
1795
2441
  if (!data) {
1796
2442
  throw new Error(`Managed queue item disappeared while loading: ${key}`);
1797
2443
  }
1798
- const item = this.deserializeItem(key, data);
2444
+ const { item, rawMessage } = this.deserializeItem(key, data);
1799
2445
  if (this.queue.has(item.id)) {
1800
2446
  throw new Error(`Duplicate managed queue item id: ${item.id}`);
1801
2447
  }
@@ -1807,16 +2453,53 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1807
2453
  item.nextAttempt = new Date();
1808
2454
  item.updatedAt = new Date();
1809
2455
  item.lastError = 'Recovered after process restart during delivery';
1810
- recoveredItems.push(item);
2456
+ await this.persistItem(item, rawMessage);
2457
+ recoveredItemCount++;
1811
2458
  }
1812
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
+ }
1813
2497
  }
1814
2498
 
1815
- await Promise.all(recoveredItems.map((item) => this.persistItem(item)));
1816
2499
  this.updateStats();
1817
2500
  logger.log(
1818
2501
  'info',
1819
- `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)`,
1820
2503
  );
1821
2504
  }
1822
2505
 
@@ -1878,14 +2561,20 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1878
2561
  this.stats.processingActive = this.processing;
1879
2562
 
1880
2563
  // Emit statistics event
1881
- void emitSafely(this, 'statsUpdated', this.stats);
2564
+ void emitSafely(this, 'statsUpdated', this.getStats());
1882
2565
  }
1883
2566
 
1884
2567
  /**
1885
2568
  * Get queue statistics
1886
2569
  */
1887
2570
  public getStats(): IQueueStats {
1888
- 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
+ };
1889
2578
  }
1890
2579
 
1891
2580
  /**
@@ -1950,10 +2639,18 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1950
2639
  /**
1951
2640
  * Shutdown the queue
1952
2641
  */
1953
- public async shutdown(): Promise<void> {
1954
- 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> {
1955
2653
  logger.log('info', 'Shutting down UnifiedDeliveryQueue');
1956
- this.lifecycleState = 'shutting-down';
1957
2654
  let persistenceError: unknown;
1958
2655
 
1959
2656
  try {
@@ -1961,29 +2658,28 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1961
2658
  this.stopProcessing();
1962
2659
  const activeScan = this.processingScanPromise;
1963
2660
  if (activeScan) {
1964
- let drainTimer: NodeJS.Timeout | undefined;
1965
- const scanDrained = await Promise.race([
1966
- activeScan.then(() => true),
1967
- new Promise<false>((resolve) => {
1968
- drainTimer = setTimeout(() => resolve(false), this.processingScanShutdownTimeoutMs);
1969
- }),
1970
- ]);
1971
- if (drainTimer) {
1972
- clearTimeout(drainTimer);
1973
- }
1974
- if (!scanDrained && this.processingScanPromise === activeScan) {
1975
- this.processingScanPromise = undefined;
1976
- logger.log(
1977
- 'warn',
1978
- `Delivery queue scan did not drain within ${this.processingScanShutdownTimeoutMs}ms; shutdown will continue`,
1979
- );
1980
- }
2661
+ await activeScan;
1981
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';
1982
2672
 
1983
2673
  if (this.options.storageManager) {
1984
- await Promise.all(
1985
- [...this.queue.values()].map((item) => this.persistItem(item)),
1986
- );
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
+ }
1987
2683
  }
1988
2684
  } catch (error) {
1989
2685
  // Preserve the original persistence failure; observer diagnostics are
@@ -1995,6 +2691,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1995
2691
  this.updateStats();
1996
2692
  await emitSafely(this, 'shutdown');
1997
2693
  this.lifecycleState = 'closed';
2694
+ this.readyItemsHandler = undefined;
1998
2695
  this.releaseManagedPrefix();
1999
2696
  this.removeAllListeners();
2000
2697
  }