@push.rocks/smartmta 8.0.1 → 8.1.1
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 +51 -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 +6 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +43 -3
- package/dist_ts/mail/delivery/classes.delivery.queue.js +310 -10
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -2
- package/dist_ts/mail/delivery/classes.delivery.system.js +106 -36
- package/dist_ts/mail/interfaces.storage.d.ts +34 -0
- package/dist_ts/mail/interfaces.storage.js +89 -3
- package/dist_ts/mail/routing/classes.domain.registry.js +5 -1
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
- package/dist_ts/mail/routing/classes.email.action.executor.js +38 -13
- package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
- package/dist_ts/mail/routing/classes.email.router.js +28 -21
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +156 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +1001 -61
- package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
- package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
- package/dist_ts/mail/routing/interfaces.d.ts +11 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +1 -1
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +6 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +429 -7
- package/ts/mail/delivery/classes.delivery.system.ts +137 -41
- package/ts/mail/interfaces.storage.ts +148 -2
- package/ts/mail/routing/classes.domain.registry.ts +4 -0
- package/ts/mail/routing/classes.email.action.executor.ts +97 -13
- package/ts/mail/routing/classes.email.router.ts +33 -20
- package/ts/mail/routing/classes.unified.email.server.ts +1367 -74
- package/ts/mail/routing/functions.routing.validation.ts +36 -0
- package/ts/mail/routing/interfaces.ts +13 -1
- package/ts/plugins.ts +2 -0
- package/ts/security/classes.rustsecuritybridge.ts +46 -1
- package/ts/security/index.ts +2 -0
|
@@ -40,7 +40,8 @@ export interface IRecipientPermanentFailure {
|
|
|
40
40
|
|
|
41
41
|
export interface IBounceOperation {
|
|
42
42
|
operationId: string;
|
|
43
|
-
kind: 'smtpFailure';
|
|
43
|
+
kind: 'smtpFailure' | 'deliveryFailure';
|
|
44
|
+
failureClass?: 'localTopology' | 'transport';
|
|
44
45
|
recipient: string;
|
|
45
46
|
normalizedRecipient: string;
|
|
46
47
|
smtpCode: number;
|
|
@@ -57,8 +58,14 @@ export interface IBounceOperation {
|
|
|
57
58
|
*/
|
|
58
59
|
export interface IQueueItem {
|
|
59
60
|
id: string;
|
|
61
|
+
/** Stable caller-supplied key used to make enqueue replay-safe. */
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
/** Fingerprint of the exact work associated with the idempotency key. */
|
|
64
|
+
idempotencyFingerprint?: string;
|
|
60
65
|
processingMode: EmailProcessingMode;
|
|
61
66
|
processingResult: Email;
|
|
67
|
+
/** Materialized attachment count for queue/detail consumers. */
|
|
68
|
+
attachmentCount?: number;
|
|
62
69
|
route?: IEmailRoute;
|
|
63
70
|
status: QueueItemStatus;
|
|
64
71
|
attempts: number;
|
|
@@ -83,9 +90,12 @@ interface IStoredEmail {
|
|
|
83
90
|
interface IStoredQueueItemV1 {
|
|
84
91
|
schemaVersion: 1;
|
|
85
92
|
id: string;
|
|
93
|
+
idempotencyKey?: string;
|
|
94
|
+
idempotencyFingerprint?: string;
|
|
86
95
|
processingMode: EmailProcessingMode;
|
|
87
96
|
processingResult: IStoredEmail;
|
|
88
97
|
route?: IEmailRoute;
|
|
98
|
+
attachmentCount?: number;
|
|
89
99
|
status: QueueItemStatus;
|
|
90
100
|
attempts: number;
|
|
91
101
|
nextAttempt: number;
|
|
@@ -117,9 +127,25 @@ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
|
|
|
117
127
|
|
|
118
128
|
type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
|
|
119
129
|
|
|
130
|
+
interface IStoredQueueIdempotencyTombstone {
|
|
131
|
+
schemaVersion: 1;
|
|
132
|
+
id: string;
|
|
133
|
+
idempotencyKey: string;
|
|
134
|
+
idempotencyFingerprint: string;
|
|
135
|
+
expiresAt: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS = 31 * 24 * 60 * 60 * 1000;
|
|
139
|
+
const QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT = 256;
|
|
140
|
+
|
|
120
141
|
export interface IQueueFailureOptions {
|
|
121
142
|
/** Unknown failures default to retryable so temporary internal faults do not lose mail. */
|
|
122
143
|
retryable?: boolean;
|
|
144
|
+
/** Durable work created atomically when unresolved recipients become terminal. */
|
|
145
|
+
terminalFailure?: {
|
|
146
|
+
failureClass: 'localTopology' | 'transport';
|
|
147
|
+
smtpCode?: number;
|
|
148
|
+
};
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
export interface IQueueFailureTransition {
|
|
@@ -150,6 +176,15 @@ export interface IQueueOptions {
|
|
|
150
176
|
maxRetryDelay?: number;
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
export interface IQueueEnqueueOptions {
|
|
180
|
+
/** Stable key that resolves to one deterministic queue item id. */
|
|
181
|
+
idempotencyKey?: string;
|
|
182
|
+
/** Required with idempotencyKey to reject reuse for different work. */
|
|
183
|
+
idempotencyFingerprint?: string;
|
|
184
|
+
/** Fail closed unless the queue is backed by managed blob storage. */
|
|
185
|
+
requireDurable?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
153
188
|
/**
|
|
154
189
|
* Queue statistics interface
|
|
155
190
|
*/
|
|
@@ -216,6 +251,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
216
251
|
private totalProcessed: number = 0;
|
|
217
252
|
private ownsManagedPrefix = false;
|
|
218
253
|
private lifecycleState: TQueueLifecycleState = 'new';
|
|
254
|
+
private readonly idempotentEnqueueFlights = new Map<string, {
|
|
255
|
+
fingerprint: string;
|
|
256
|
+
promise: Promise<string>;
|
|
257
|
+
}>();
|
|
258
|
+
private idempotencyTombstoneCleanupCursor?: string;
|
|
219
259
|
|
|
220
260
|
/**
|
|
221
261
|
* Create a new unified delivery queue
|
|
@@ -270,6 +310,143 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
270
310
|
}
|
|
271
311
|
}
|
|
272
312
|
|
|
313
|
+
/** Whether queue entries are committed through caller-managed blob storage. */
|
|
314
|
+
public hasManagedStorage(): boolean {
|
|
315
|
+
return !!this.options.storageManager;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Whether managed storage can list a backend-bounded page of keys. */
|
|
319
|
+
public hasPaginatedManagedStorage(): boolean {
|
|
320
|
+
return typeof this.options.storageManager?.listPage === 'function';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private idempotentQueueId(idempotencyKey: string): string {
|
|
324
|
+
return `idempotent-${createHash('sha256')
|
|
325
|
+
.update('smartmta-queue-v1\0')
|
|
326
|
+
.update(idempotencyKey)
|
|
327
|
+
.digest('hex')}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private idempotencyTombstonePrefix(): string {
|
|
331
|
+
return `${this.options.storagePrefix.slice(0, -1)}.idempotency/`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private idempotencyTombstoneKey(id: string): string {
|
|
335
|
+
return `${this.idempotencyTombstonePrefix()}${id}.json`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async readIdempotencyTombstone(
|
|
339
|
+
id: string,
|
|
340
|
+
): Promise<IStoredQueueIdempotencyTombstone | null> {
|
|
341
|
+
const storageManager = this.options.storageManager;
|
|
342
|
+
if (!storageManager) return null;
|
|
343
|
+
const key = this.idempotencyTombstoneKey(id);
|
|
344
|
+
const value = await storageManager.get(key);
|
|
345
|
+
if (!value) return null;
|
|
346
|
+
const tombstone = this.parseIdempotencyTombstone(value, key, id);
|
|
347
|
+
if (tombstone.expiresAt <= Date.now()) {
|
|
348
|
+
await storageManager.delete(key);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return tombstone;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private parseIdempotencyTombstone(
|
|
355
|
+
value: Buffer,
|
|
356
|
+
key: string,
|
|
357
|
+
expectedId: string,
|
|
358
|
+
): IStoredQueueIdempotencyTombstone {
|
|
359
|
+
let tombstone: IStoredQueueIdempotencyTombstone;
|
|
360
|
+
try {
|
|
361
|
+
tombstone = JSON.parse(value.toString('utf8')) as IStoredQueueIdempotencyTombstone;
|
|
362
|
+
} catch {
|
|
363
|
+
throw new Error(`Invalid queue idempotency tombstone at ${key}`);
|
|
364
|
+
}
|
|
365
|
+
if (
|
|
366
|
+
tombstone?.schemaVersion !== 1
|
|
367
|
+
|| tombstone.id !== expectedId
|
|
368
|
+
|| typeof tombstone.idempotencyKey !== 'string'
|
|
369
|
+
|| !tombstone.idempotencyKey
|
|
370
|
+
|| tombstone.idempotencyKey.length > 512
|
|
371
|
+
|| this.idempotentQueueId(tombstone.idempotencyKey) !== expectedId
|
|
372
|
+
|| typeof tombstone.idempotencyFingerprint !== 'string'
|
|
373
|
+
|| !/^[a-f0-9]{64}$/.test(tombstone.idempotencyFingerprint)
|
|
374
|
+
|| !Number.isFinite(tombstone.expiresAt)
|
|
375
|
+
) {
|
|
376
|
+
throw new Error(`Invalid queue idempotency tombstone at ${key}`);
|
|
377
|
+
}
|
|
378
|
+
return tombstone;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async persistIdempotencyTombstone(
|
|
382
|
+
id: string,
|
|
383
|
+
idempotencyKey: string,
|
|
384
|
+
idempotencyFingerprint: string,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const storageManager = this.options.storageManager;
|
|
387
|
+
if (!storageManager) return;
|
|
388
|
+
const tombstone: IStoredQueueIdempotencyTombstone = {
|
|
389
|
+
schemaVersion: 1,
|
|
390
|
+
id,
|
|
391
|
+
idempotencyKey,
|
|
392
|
+
idempotencyFingerprint,
|
|
393
|
+
expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
|
|
394
|
+
};
|
|
395
|
+
await storageManager.set(
|
|
396
|
+
this.idempotencyTombstoneKey(id),
|
|
397
|
+
Buffer.from(JSON.stringify(tombstone)),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async cleanupExpiredIdempotencyTombstones(
|
|
402
|
+
limit = QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT,
|
|
403
|
+
): Promise<void> {
|
|
404
|
+
const storageManager = this.options.storageManager;
|
|
405
|
+
if (!storageManager?.listPage || limit < 1) return;
|
|
406
|
+
const prefix = this.idempotencyTombstonePrefix();
|
|
407
|
+
const page = await storageManager.listPage(prefix, {
|
|
408
|
+
limit,
|
|
409
|
+
...(this.idempotencyTombstoneCleanupCursor
|
|
410
|
+
? { continuationToken: this.idempotencyTombstoneCleanupCursor }
|
|
411
|
+
: {}),
|
|
412
|
+
});
|
|
413
|
+
if (
|
|
414
|
+
!page
|
|
415
|
+
|| !Array.isArray(page.keys)
|
|
416
|
+
|| page.keys.length > limit
|
|
417
|
+
|| page.keys.some((key) => typeof key !== 'string' || !key.startsWith(prefix))
|
|
418
|
+
|| new Set(page.keys).size !== page.keys.length
|
|
419
|
+
|| (
|
|
420
|
+
page.continuationToken !== undefined
|
|
421
|
+
&& (
|
|
422
|
+
typeof page.continuationToken !== 'string'
|
|
423
|
+
|| page.continuationToken.length === 0
|
|
424
|
+
|| page.continuationToken === this.idempotencyTombstoneCleanupCursor
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
) {
|
|
428
|
+
throw new Error('Invalid paginated queue idempotency tombstone listing');
|
|
429
|
+
}
|
|
430
|
+
this.idempotencyTombstoneCleanupCursor = page.continuationToken;
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
for (const key of page.keys) {
|
|
433
|
+
const value = await storageManager.get(key);
|
|
434
|
+
if (!value) continue;
|
|
435
|
+
try {
|
|
436
|
+
if (!key.endsWith('.json')) {
|
|
437
|
+
throw new Error(`Invalid queue idempotency tombstone key: ${key}`);
|
|
438
|
+
}
|
|
439
|
+
const id = key.slice(prefix.length, -'.json'.length);
|
|
440
|
+
const tombstone = this.parseIdempotencyTombstone(value, key, id);
|
|
441
|
+
if (tombstone.expiresAt <= now) {
|
|
442
|
+
await storageManager.delete(key);
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
await storageManager.delete(key);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
273
450
|
private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
|
|
274
451
|
const byRecipient = new Map<string, IRecipientDeliveryState>();
|
|
275
452
|
const now = new Date();
|
|
@@ -286,12 +463,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
286
463
|
return [...byRecipient.values()];
|
|
287
464
|
}
|
|
288
465
|
|
|
289
|
-
private createBounceOperationId(
|
|
466
|
+
private createBounceOperationId(
|
|
467
|
+
itemId: string,
|
|
468
|
+
normalizedRecipient: string,
|
|
469
|
+
kind: IBounceOperation['kind'] = 'smtpFailure',
|
|
470
|
+
): string {
|
|
290
471
|
return createHash('sha256')
|
|
291
472
|
.update(itemId)
|
|
292
473
|
.update('\0')
|
|
293
474
|
.update(normalizedRecipient)
|
|
294
|
-
.update(
|
|
475
|
+
.update(`\0${kind}`)
|
|
295
476
|
.digest('hex');
|
|
296
477
|
}
|
|
297
478
|
|
|
@@ -448,25 +629,116 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
448
629
|
processingResult: Email,
|
|
449
630
|
mode: EmailProcessingMode,
|
|
450
631
|
route?: IEmailRoute,
|
|
632
|
+
enqueueOptions: IQueueEnqueueOptions = {},
|
|
451
633
|
): Promise<string> {
|
|
452
634
|
this.assertMutable();
|
|
453
635
|
if (!(processingResult instanceof Email)) {
|
|
454
636
|
throw new Error('Queue processingResult must be an Email instance');
|
|
455
637
|
}
|
|
456
638
|
|
|
639
|
+
if (enqueueOptions.requireDurable && !this.options.storageManager) {
|
|
640
|
+
throw new Error('Durable idempotent enqueue requires managed queue storage');
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
(enqueueOptions.idempotencyKey === undefined)
|
|
644
|
+
!== (enqueueOptions.idempotencyFingerprint === undefined)
|
|
645
|
+
) {
|
|
646
|
+
throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
|
|
647
|
+
}
|
|
648
|
+
if (enqueueOptions.idempotencyKey !== undefined) {
|
|
649
|
+
if (this.options.storageManager && !this.options.storageManager.listPage) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
'Managed idempotent enqueue requires paginated managed queue storage',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
|
|
655
|
+
throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
|
|
656
|
+
}
|
|
657
|
+
if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
|
|
658
|
+
throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
|
|
662
|
+
const existing = this.queue.get(id);
|
|
663
|
+
if (existing) {
|
|
664
|
+
if (
|
|
665
|
+
existing.idempotencyKey !== enqueueOptions.idempotencyKey
|
|
666
|
+
|| existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
|
|
667
|
+
) {
|
|
668
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
669
|
+
}
|
|
670
|
+
return id;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const activeFlight = this.idempotentEnqueueFlights.get(id);
|
|
674
|
+
if (activeFlight) {
|
|
675
|
+
if (activeFlight.fingerprint !== enqueueOptions.idempotencyFingerprint) {
|
|
676
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
677
|
+
}
|
|
678
|
+
return activeFlight.promise;
|
|
679
|
+
}
|
|
680
|
+
const flight = (async () => {
|
|
681
|
+
const tombstone = await this.readIdempotencyTombstone(id);
|
|
682
|
+
if (tombstone) {
|
|
683
|
+
if (
|
|
684
|
+
tombstone.idempotencyKey !== enqueueOptions.idempotencyKey
|
|
685
|
+
|| tombstone.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
|
|
686
|
+
) {
|
|
687
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
688
|
+
}
|
|
689
|
+
return id;
|
|
690
|
+
}
|
|
691
|
+
return this.enqueueNew(processingResult, mode, route, {
|
|
692
|
+
id,
|
|
693
|
+
idempotencyKey: enqueueOptions.idempotencyKey!,
|
|
694
|
+
idempotencyFingerprint: enqueueOptions.idempotencyFingerprint!,
|
|
695
|
+
});
|
|
696
|
+
})();
|
|
697
|
+
this.idempotentEnqueueFlights.set(id, {
|
|
698
|
+
fingerprint: enqueueOptions.idempotencyFingerprint!,
|
|
699
|
+
promise: flight,
|
|
700
|
+
});
|
|
701
|
+
try {
|
|
702
|
+
return await flight;
|
|
703
|
+
} finally {
|
|
704
|
+
if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
|
|
705
|
+
this.idempotentEnqueueFlights.delete(id);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return this.enqueueNew(processingResult, mode, route);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private async enqueueNew(
|
|
714
|
+
processingResult: Email,
|
|
715
|
+
mode: EmailProcessingMode,
|
|
716
|
+
route?: IEmailRoute,
|
|
717
|
+
idempotency?: {
|
|
718
|
+
id: string;
|
|
719
|
+
idempotencyKey: string;
|
|
720
|
+
idempotencyFingerprint: string;
|
|
721
|
+
},
|
|
722
|
+
): Promise<string> {
|
|
457
723
|
// Check if queue is full
|
|
458
724
|
if (this.queue.size >= this.options.maxQueueSize) {
|
|
459
725
|
throw new Error('Queue is full');
|
|
460
726
|
}
|
|
461
727
|
|
|
462
728
|
// Generate a unique ID
|
|
463
|
-
const id =
|
|
729
|
+
const id = idempotency?.id
|
|
730
|
+
|| `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
464
731
|
|
|
465
732
|
// Create queue item
|
|
466
733
|
const item: IQueueItem = {
|
|
467
734
|
id,
|
|
735
|
+
...(idempotency ? {
|
|
736
|
+
idempotencyKey: idempotency.idempotencyKey,
|
|
737
|
+
idempotencyFingerprint: idempotency.idempotencyFingerprint,
|
|
738
|
+
} : {}),
|
|
468
739
|
processingMode: mode,
|
|
469
740
|
processingResult,
|
|
741
|
+
attachmentCount: processingResult.attachments.length,
|
|
470
742
|
route,
|
|
471
743
|
status: 'pending',
|
|
472
744
|
attempts: 0,
|
|
@@ -482,6 +754,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
482
754
|
|
|
483
755
|
// Commit durable state before exposing the item in memory or emitting acceptance.
|
|
484
756
|
await this.persistItem(item);
|
|
757
|
+
if (idempotency) {
|
|
758
|
+
await this.persistIdempotencyTombstone(
|
|
759
|
+
id,
|
|
760
|
+
idempotency.idempotencyKey,
|
|
761
|
+
idempotency.idempotencyFingerprint,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
485
764
|
this.queue.set(id, item);
|
|
486
765
|
|
|
487
766
|
// Update statistics
|
|
@@ -638,9 +917,55 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
638
917
|
} else {
|
|
639
918
|
// Mark as permanently failed
|
|
640
919
|
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
920
|
+
const now = new Date();
|
|
641
921
|
updatedItem.status = 'failed';
|
|
642
922
|
updatedItem.lastError = error;
|
|
643
|
-
updatedItem.updatedAt =
|
|
923
|
+
updatedItem.updatedAt = now;
|
|
924
|
+
|
|
925
|
+
if (optionsArg.terminalFailure) {
|
|
926
|
+
const states = updatedItem.recipientStates || this.createInitialRecipientStates(
|
|
927
|
+
updatedItem.processingResult,
|
|
928
|
+
);
|
|
929
|
+
const terminal = new Set(updatedItem.terminalRecipients || []);
|
|
930
|
+
const operations = updatedItem.bounceOperations || [];
|
|
931
|
+
const operationIds = new Set(operations.map((operation) => operation.operationId));
|
|
932
|
+
const requestedCode = optionsArg.terminalFailure.smtpCode;
|
|
933
|
+
const smtpCode = Number.isInteger(requestedCode) && requestedCode! >= 500
|
|
934
|
+
? requestedCode!
|
|
935
|
+
: 554;
|
|
936
|
+
|
|
937
|
+
for (const state of states) {
|
|
938
|
+
if (state.state !== 'unresolved') continue;
|
|
939
|
+
state.state = 'permanentFailure';
|
|
940
|
+
state.smtpCode = smtpCode;
|
|
941
|
+
state.smtpResponse = error;
|
|
942
|
+
state.updatedAt = now;
|
|
943
|
+
terminal.add(state.normalizedRecipient);
|
|
944
|
+
|
|
945
|
+
const operationId = this.createBounceOperationId(
|
|
946
|
+
updatedItem.id,
|
|
947
|
+
state.normalizedRecipient,
|
|
948
|
+
'deliveryFailure',
|
|
949
|
+
);
|
|
950
|
+
if (operationIds.has(operationId)) continue;
|
|
951
|
+
operations.push({
|
|
952
|
+
operationId,
|
|
953
|
+
kind: 'deliveryFailure',
|
|
954
|
+
failureClass: optionsArg.terminalFailure.failureClass,
|
|
955
|
+
recipient: state.recipient,
|
|
956
|
+
normalizedRecipient: state.normalizedRecipient,
|
|
957
|
+
smtpCode,
|
|
958
|
+
smtpResponse: error,
|
|
959
|
+
state: 'pending',
|
|
960
|
+
attempts: 0,
|
|
961
|
+
nextAttempt: now,
|
|
962
|
+
});
|
|
963
|
+
operationIds.add(operationId);
|
|
964
|
+
}
|
|
965
|
+
updatedItem.recipientStates = states;
|
|
966
|
+
updatedItem.terminalRecipients = [...terminal];
|
|
967
|
+
updatedItem.bounceOperations = operations;
|
|
968
|
+
}
|
|
644
969
|
});
|
|
645
970
|
|
|
646
971
|
// Update statistics
|
|
@@ -811,12 +1136,46 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
811
1136
|
/** Includes terminal queue items because bounce work outlives delivery status. */
|
|
812
1137
|
public listPendingBounceOperations(
|
|
813
1138
|
now: Date = new Date(),
|
|
1139
|
+
limitArg = 100,
|
|
814
1140
|
): Array<{ itemId: string; operation: IBounceOperation }> {
|
|
815
1141
|
const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
|
|
1142
|
+
const limit = Math.max(0, Math.min(Math.trunc(limitArg), 100));
|
|
1143
|
+
if (limit === 0) return result;
|
|
1144
|
+
for (const item of this.queue.values()) {
|
|
1145
|
+
for (const operation of item.bounceOperations || []) {
|
|
1146
|
+
if (
|
|
1147
|
+
operation.kind === 'smtpFailure'
|
|
1148
|
+
&& operation.state === 'pending'
|
|
1149
|
+
&& operation.nextAttempt <= now
|
|
1150
|
+
) {
|
|
1151
|
+
result.push({ itemId: item.id, operation: { ...operation } });
|
|
1152
|
+
if (result.length >= limit) return result;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return result;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Durable terminal-delivery notices. These never enter SMTP recipient suppression. */
|
|
1160
|
+
public listPendingDeliveryFailureOperations(
|
|
1161
|
+
now: Date = new Date(),
|
|
1162
|
+
limitArg = 100,
|
|
1163
|
+
): Array<{
|
|
1164
|
+
itemId: string;
|
|
1165
|
+
operation: IBounceOperation;
|
|
1166
|
+
}> {
|
|
1167
|
+
const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
|
|
1168
|
+
const limit = Math.max(0, Math.min(Math.trunc(limitArg), 100));
|
|
1169
|
+
if (limit === 0) return result;
|
|
816
1170
|
for (const item of this.queue.values()) {
|
|
817
1171
|
for (const operation of item.bounceOperations || []) {
|
|
818
|
-
if (
|
|
1172
|
+
if (
|
|
1173
|
+
operation.kind === 'deliveryFailure'
|
|
1174
|
+
&& operation.state === 'pending'
|
|
1175
|
+
&& operation.nextAttempt <= now
|
|
1176
|
+
) {
|
|
819
1177
|
result.push({ itemId: item.id, operation: { ...operation } });
|
|
1178
|
+
if (result.length >= limit) return result;
|
|
820
1179
|
}
|
|
821
1180
|
}
|
|
822
1181
|
}
|
|
@@ -884,6 +1243,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
884
1243
|
return false;
|
|
885
1244
|
}
|
|
886
1245
|
|
|
1246
|
+
if (item.idempotencyKey && item.idempotencyFingerprint) {
|
|
1247
|
+
await this.persistIdempotencyTombstone(
|
|
1248
|
+
item.id,
|
|
1249
|
+
item.idempotencyKey,
|
|
1250
|
+
item.idempotencyFingerprint,
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
887
1253
|
// Delete durable state before making the removal visible in memory.
|
|
888
1254
|
await this.removeItemFromStorage(id);
|
|
889
1255
|
this.queue.delete(id);
|
|
@@ -914,12 +1280,17 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
914
1280
|
return {
|
|
915
1281
|
schemaVersion: 2,
|
|
916
1282
|
id: item.id,
|
|
1283
|
+
...(item.idempotencyKey !== undefined ? {
|
|
1284
|
+
idempotencyKey: item.idempotencyKey,
|
|
1285
|
+
idempotencyFingerprint: item.idempotencyFingerprint,
|
|
1286
|
+
} : {}),
|
|
917
1287
|
processingMode: item.processingMode,
|
|
918
1288
|
processingResult: {
|
|
919
1289
|
options: item.processingResult.toEmailOptions(),
|
|
920
1290
|
messageId: item.processingResult.getMessageId(),
|
|
921
1291
|
envelopeFrom: item.processingResult.getEnvelopeFrom(),
|
|
922
1292
|
},
|
|
1293
|
+
attachmentCount: item.attachmentCount ?? item.processingResult.attachments.length,
|
|
923
1294
|
...(item.route ? { route: item.route } : {}),
|
|
924
1295
|
status: item.status,
|
|
925
1296
|
attempts: item.attempts,
|
|
@@ -986,6 +1357,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
986
1357
|
if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
|
|
987
1358
|
throw new Error(`Invalid managed queue item at ${key}: missing id`);
|
|
988
1359
|
}
|
|
1360
|
+
if (
|
|
1361
|
+
parsed.idempotencyKey !== undefined
|
|
1362
|
+
&& (
|
|
1363
|
+
typeof parsed.idempotencyKey !== 'string'
|
|
1364
|
+
|| !parsed.idempotencyKey
|
|
1365
|
+
|| parsed.idempotencyKey.length > 512
|
|
1366
|
+
|| typeof parsed.idempotencyFingerprint !== 'string'
|
|
1367
|
+
|| !/^[a-f0-9]{64}$/.test(parsed.idempotencyFingerprint)
|
|
1368
|
+
)
|
|
1369
|
+
) {
|
|
1370
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid idempotency metadata`);
|
|
1371
|
+
}
|
|
1372
|
+
if (parsed.idempotencyKey === undefined && parsed.idempotencyFingerprint !== undefined) {
|
|
1373
|
+
throw new Error(`Invalid managed queue item at ${key}: orphaned idempotency fingerprint`);
|
|
1374
|
+
}
|
|
1375
|
+
if (parsed.idempotencyKey !== undefined) {
|
|
1376
|
+
const expectedId = this.idempotentQueueId(parsed.idempotencyKey);
|
|
1377
|
+
if (parsed.id !== expectedId) {
|
|
1378
|
+
throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
989
1381
|
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
|
|
990
1382
|
throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
|
|
991
1383
|
}
|
|
@@ -1048,7 +1440,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1048
1440
|
return !(
|
|
1049
1441
|
typeof operation.operationId === 'string'
|
|
1050
1442
|
&& operation.operationId.length > 0
|
|
1051
|
-
&& operation.kind
|
|
1443
|
+
&& ['smtpFailure', 'deliveryFailure'].includes(operation.kind as string)
|
|
1444
|
+
&& (
|
|
1445
|
+
operation.failureClass === undefined
|
|
1446
|
+
|| ['localTopology', 'transport'].includes(operation.failureClass as string)
|
|
1447
|
+
)
|
|
1052
1448
|
&& typeof operation.recipient === 'string'
|
|
1053
1449
|
&& typeof operation.normalizedRecipient === 'string'
|
|
1054
1450
|
&& operation.normalizedRecipient === operation.recipient.trim().toLowerCase()
|
|
@@ -1102,6 +1498,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1102
1498
|
entry.responseCode === undefined
|
|
1103
1499
|
|| (Number.isInteger(entry.responseCode) && (entry.responseCode as number) >= 100)
|
|
1104
1500
|
)
|
|
1501
|
+
&& (
|
|
1502
|
+
entry.transferStats === undefined
|
|
1503
|
+
|| (
|
|
1504
|
+
Boolean(entry.transferStats)
|
|
1505
|
+
&& typeof entry.transferStats === 'object'
|
|
1506
|
+
&& Number.isSafeInteger((entry.transferStats as Record<string, unknown>).bytes)
|
|
1507
|
+
&& ((entry.transferStats as Record<string, number>).bytes >= 0)
|
|
1508
|
+
&& Number.isSafeInteger((entry.transferStats as Record<string, unknown>).chunks)
|
|
1509
|
+
&& ((entry.transferStats as Record<string, number>).chunks >= 0)
|
|
1510
|
+
&& Number.isSafeInteger((entry.transferStats as Record<string, unknown>).durationMs)
|
|
1511
|
+
&& ((entry.transferStats as Record<string, number>).durationMs >= 0)
|
|
1512
|
+
)
|
|
1513
|
+
)
|
|
1105
1514
|
);
|
|
1106
1515
|
};
|
|
1107
1516
|
const isValidTransaction = (value: unknown): boolean => {
|
|
@@ -1189,6 +1598,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1189
1598
|
throw new Error(`Invalid managed queue item at ${key}: invalid email payload`);
|
|
1190
1599
|
}
|
|
1191
1600
|
|
|
1601
|
+
if (
|
|
1602
|
+
parsed.attachmentCount !== undefined
|
|
1603
|
+
&& (!Number.isSafeInteger(parsed.attachmentCount) || parsed.attachmentCount < 0)
|
|
1604
|
+
) {
|
|
1605
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid attachment count`);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1192
1608
|
const storedOptions = parsed.processingResult.options as IEmailOptions;
|
|
1193
1609
|
if (storedOptions.attachments !== undefined) {
|
|
1194
1610
|
if (!Array.isArray(storedOptions.attachments)) {
|
|
@@ -1243,8 +1659,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1243
1659
|
|
|
1244
1660
|
return {
|
|
1245
1661
|
id: parsed.id,
|
|
1662
|
+
...(parsed.idempotencyKey !== undefined ? {
|
|
1663
|
+
idempotencyKey: parsed.idempotencyKey,
|
|
1664
|
+
idempotencyFingerprint: parsed.idempotencyFingerprint,
|
|
1665
|
+
} : {}),
|
|
1246
1666
|
processingMode: parsed.processingMode,
|
|
1247
1667
|
processingResult,
|
|
1668
|
+
attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
|
|
1248
1669
|
...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
|
|
1249
1670
|
status: parsed.status,
|
|
1250
1671
|
attempts: parsed.attempts,
|
|
@@ -1431,6 +1852,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1431
1852
|
for (const id of idsToRemove) {
|
|
1432
1853
|
await this.removeItem(id);
|
|
1433
1854
|
}
|
|
1855
|
+
await this.cleanupExpiredIdempotencyTombstones();
|
|
1434
1856
|
|
|
1435
1857
|
if (idsToRemove.length > 0) {
|
|
1436
1858
|
logger.log('info', `Cleaned up ${idsToRemove.length} old items from delivery queue`);
|