@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.
- package/changelog.md +41 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_amd64.tsrust-build.json +14 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64.tsrust-build.json +14 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +63 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
- 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 +180 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +592 -172
- package/dist_ts/mail/routing/interfaces.d.ts +41 -0
- 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 +6 -6
- package/readme.md +78 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
- package/ts/mail/delivery/classes.delivery.system.ts +192 -80
- 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 +780 -192
- package/ts/mail/routing/interfaces.ts +43 -0
- package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
- package/ts/security/classes.rustsecuritybridge.ts +6 -0
- package/readme.hints.md +0 -37
- 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
|
|
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,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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
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
|
+
}
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
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) {
|
|
703
1073
|
if (
|
|
704
|
-
|
|
705
|
-
||
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
800
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
return false;
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
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
|
|
1351
|
-
|
|
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
|
-
|
|
1796
|
+
shouldMutateArg: (currentItem: IQueueItem) => boolean = () => true,
|
|
1797
|
+
): Promise<boolean> {
|
|
1403
1798
|
this.assertMutable();
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
1787
|
-
const recoveredItems: IQueueItem[] = [];
|
|
2433
|
+
let recoveredItemCount = 0;
|
|
1788
2434
|
|
|
1789
|
-
|
|
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'))
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
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 {
|
|
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
|
|
1954
|
-
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> {
|
|
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
|
-
|
|
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
|
-
|
|
1985
|
-
|
|
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
|
}
|