@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.
- package/changelog.md +23 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +71 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.js +794 -179
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +11 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +206 -80
- package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
- package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +1 -1
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +40 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +222 -76
- package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
- package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
- package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
- package/package.json +2 -2
- package/readme.hints.md +8 -0
- package/readme.md +36 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +969 -209
- package/ts/mail/delivery/classes.delivery.system.ts +254 -101
- package/ts/mail/delivery/functions.raw-email.ts +111 -0
- package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
- package/ts/mail/index.ts +1 -0
- package/ts/mail/routing/classes.unified.email.server.ts +329 -85
- package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
- 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
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
await
|
|
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
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
if (
|
|
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
|
-
|
|
666
|
-
||
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
761
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
return false;
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
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
|
|
1311
|
-
|
|
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
|
-
|
|
1796
|
+
shouldMutateArg: (currentItem: IQueueItem) => boolean = () => true,
|
|
1797
|
+
): Promise<boolean> {
|
|
1363
1798
|
this.assertMutable();
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
1747
|
-
const recoveredItems: IQueueItem[] = [];
|
|
2433
|
+
let recoveredItemCount = 0;
|
|
1748
2434
|
|
|
1749
|
-
|
|
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'))
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
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 {
|
|
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
|
|
1912
|
-
if (this.
|
|
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
|
-
|
|
1923
|
-
|
|
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;
|