@push.rocks/smartmta 7.0.0 → 8.0.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 +36 -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/functions.errors.d.ts +3 -0
- package/dist_ts/functions.errors.js +8 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/mail/core/classes.bouncemanager.d.ts +11 -0
- package/dist_ts/mail/core/classes.bouncemanager.js +120 -38
- package/dist_ts/mail/core/classes.email.js +14 -12
- package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
- package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +77 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +551 -46
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
- package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
- package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
- package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
- package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
- package/dist_ts/mail/delivery/index.d.ts +1 -0
- package/dist_ts/mail/delivery/index.js +2 -1
- package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
- package/dist_ts/mail/delivery/interfaces.js +1 -1
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +1 -1
- package/dist_ts/mail/routing/classes.dkim.manager.d.ts +8 -4
- package/dist_ts/mail/routing/classes.dkim.manager.js +46 -29
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +5 -3
- package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
- package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
- package/dist_ts/mail/routing/classes.email.router.d.ts +3 -0
- package/dist_ts/mail/routing/classes.email.router.js +15 -9
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +4 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +61 -40
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +7 -0
- package/dist_ts/mail/security/classes.dkimcreator.js +33 -8
- package/dist_ts/mail/security/classes.spfverifier.js +5 -3
- package/dist_ts/security/classes.contentscanner.js +14 -11
- package/dist_ts/security/classes.ipreputationchecker.d.ts +3 -0
- package/dist_ts/security/classes.ipreputationchecker.js +20 -11
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +49 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
- package/dist_ts/security/classes.securitylogger.js +7 -5
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +2 -2
- package/package.json +8 -8
- package/readme.hints.md +4 -3
- package/readme.md +41 -13
- package/readme.plan.md +6 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/functions.errors.ts +8 -0
- package/ts/index.ts +3 -0
- package/ts/mail/core/classes.bouncemanager.ts +157 -45
- package/ts/mail/core/classes.email.ts +19 -13
- package/ts/mail/core/classes.emailvalidator.ts +9 -7
- package/ts/mail/delivery/classes.delivery.queue.ts +740 -58
- package/ts/mail/delivery/classes.delivery.system.ts +583 -170
- package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
- package/ts/mail/delivery/functions.safe-observers.ts +45 -0
- package/ts/mail/delivery/index.ts +1 -0
- package/ts/mail/delivery/interfaces.ts +27 -1
- package/ts/mail/index.ts +1 -0
- package/ts/mail/routing/classes.dkim.manager.ts +62 -37
- package/ts/mail/routing/classes.dns.manager.ts +36 -13
- package/ts/mail/routing/classes.email.action.executor.ts +64 -17
- package/ts/mail/routing/classes.email.router.ts +15 -8
- package/ts/mail/routing/classes.unified.email.server.ts +90 -44
- package/ts/mail/security/classes.dkimcreator.ts +50 -7
- package/ts/mail/security/classes.spfverifier.ts +4 -2
- package/ts/security/classes.contentscanner.ts +14 -11
- package/ts/security/classes.ipreputationchecker.ts +21 -10
- package/ts/security/classes.rustsecuritybridge.ts +269 -3
- package/ts/security/classes.securitylogger.ts +6 -4
- package/ts/security/index.ts +6 -1
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { deserialize, serialize } from 'node:v8';
|
|
3
4
|
import type { IBlobStorageManager } from '../interfaces.storage.js';
|
|
4
5
|
import { logger } from '../../logger.js';
|
|
5
6
|
import { type EmailProcessingMode } from './interfaces.js';
|
|
7
|
+
import type { ISmtpTransactionAttempt } from './interfaces.js';
|
|
8
|
+
import {
|
|
9
|
+
normalizeSmtpTranscript,
|
|
10
|
+
SMTP_TRANSCRIPT_MAX_ENTRIES,
|
|
11
|
+
SMTP_TRANSCRIPT_MAX_ENTRY_CODE_POINTS,
|
|
12
|
+
SMTP_TRANSCRIPT_MAX_UTF8_BYTES,
|
|
13
|
+
} from '../../security/classes.rustsecuritybridge.js';
|
|
6
14
|
import type { IEmailRoute } from '../routing/interfaces.js';
|
|
7
15
|
import { Email, type IEmailOptions } from '../core/classes.email.js';
|
|
16
|
+
import { emitSafely } from './functions.safe-observers.js';
|
|
17
|
+
import { getErrorMessage } from '../../functions.errors.js';
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
20
|
* Queue item status
|
|
11
21
|
*/
|
|
12
22
|
export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
|
13
23
|
|
|
24
|
+
export type TRecipientDeliveryState = 'unresolved' | 'delivered' | 'permanentFailure';
|
|
25
|
+
|
|
26
|
+
export interface IRecipientDeliveryState {
|
|
27
|
+
recipient: string;
|
|
28
|
+
normalizedRecipient: string;
|
|
29
|
+
state: TRecipientDeliveryState;
|
|
30
|
+
smtpCode?: number;
|
|
31
|
+
smtpResponse?: string;
|
|
32
|
+
updatedAt: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IRecipientPermanentFailure {
|
|
36
|
+
recipient: string;
|
|
37
|
+
smtpCode: number;
|
|
38
|
+
smtpResponse: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IBounceOperation {
|
|
42
|
+
operationId: string;
|
|
43
|
+
kind: 'smtpFailure';
|
|
44
|
+
recipient: string;
|
|
45
|
+
normalizedRecipient: string;
|
|
46
|
+
smtpCode: number;
|
|
47
|
+
smtpResponse: string;
|
|
48
|
+
state: 'pending' | 'completed';
|
|
49
|
+
attempts: number;
|
|
50
|
+
nextAttempt: Date;
|
|
51
|
+
lastError?: string;
|
|
52
|
+
completedAt?: Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
/**
|
|
15
56
|
* Queue item interface
|
|
16
57
|
*/
|
|
@@ -26,6 +67,11 @@ export interface IQueueItem {
|
|
|
26
67
|
createdAt: Date;
|
|
27
68
|
updatedAt: Date;
|
|
28
69
|
deliveredAt?: Date;
|
|
70
|
+
deliveredRecipients?: string[];
|
|
71
|
+
terminalRecipients?: string[];
|
|
72
|
+
recipientStates?: IRecipientDeliveryState[];
|
|
73
|
+
bounceOperations?: IBounceOperation[];
|
|
74
|
+
smtpTransactions?: ISmtpTransactionAttempt[];
|
|
29
75
|
}
|
|
30
76
|
|
|
31
77
|
interface IStoredEmail {
|
|
@@ -34,7 +80,7 @@ interface IStoredEmail {
|
|
|
34
80
|
envelopeFrom: string;
|
|
35
81
|
}
|
|
36
82
|
|
|
37
|
-
interface
|
|
83
|
+
interface IStoredQueueItemV1 {
|
|
38
84
|
schemaVersion: 1;
|
|
39
85
|
id: string;
|
|
40
86
|
processingMode: EmailProcessingMode;
|
|
@@ -47,6 +93,40 @@ interface IStoredQueueItem {
|
|
|
47
93
|
createdAt: number;
|
|
48
94
|
updatedAt: number;
|
|
49
95
|
deliveredAt?: number;
|
|
96
|
+
deliveredRecipients?: string[];
|
|
97
|
+
terminalRecipients?: string[];
|
|
98
|
+
smtpTransactions?: ISmtpTransactionAttempt[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface IStoredRecipientDeliveryState extends Omit<IRecipientDeliveryState, 'updatedAt'> {
|
|
102
|
+
updatedAt: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface IStoredBounceOperation extends Omit<IBounceOperation, 'nextAttempt' | 'completedAt'> {
|
|
106
|
+
nextAttempt: number;
|
|
107
|
+
completedAt?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface IStoredQueueItemV2 extends Omit<IStoredQueueItemV1, 'schemaVersion'> {
|
|
111
|
+
schemaVersion: 2;
|
|
112
|
+
recipientStates: IStoredRecipientDeliveryState[];
|
|
113
|
+
bounceOperations: IStoredBounceOperation[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
|
|
117
|
+
|
|
118
|
+
type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
|
|
119
|
+
|
|
120
|
+
export interface IQueueFailureOptions {
|
|
121
|
+
/** Unknown failures default to retryable so temporary internal faults do not lose mail. */
|
|
122
|
+
retryable?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface IQueueFailureTransition {
|
|
126
|
+
updated: boolean;
|
|
127
|
+
status?: 'deferred' | 'failed';
|
|
128
|
+
terminal: boolean;
|
|
129
|
+
terminalReason?: 'permanent' | 'retry_exhausted';
|
|
50
130
|
}
|
|
51
131
|
|
|
52
132
|
/**
|
|
@@ -110,6 +190,9 @@ export interface IQueueStats {
|
|
|
110
190
|
processingActive: boolean;
|
|
111
191
|
}
|
|
112
192
|
|
|
193
|
+
const managedQueueOwners =
|
|
194
|
+
new WeakMap<IBlobStorageManager, Map<string, UnifiedDeliveryQueue>>();
|
|
195
|
+
|
|
113
196
|
/**
|
|
114
197
|
* A unified queue for all email modes
|
|
115
198
|
*/
|
|
@@ -131,6 +214,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
131
214
|
private stats: IQueueStats;
|
|
132
215
|
private processing: boolean = false;
|
|
133
216
|
private totalProcessed: number = 0;
|
|
217
|
+
private ownsManagedPrefix = false;
|
|
218
|
+
private lifecycleState: TQueueLifecycleState = 'new';
|
|
134
219
|
|
|
135
220
|
/**
|
|
136
221
|
* Create a new unified delivery queue
|
|
@@ -178,26 +263,98 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
178
263
|
processingActive: false
|
|
179
264
|
};
|
|
180
265
|
}
|
|
266
|
+
|
|
267
|
+
private assertMutable(): void {
|
|
268
|
+
if (this.lifecycleState === 'shutting-down' || this.lifecycleState === 'closed') {
|
|
269
|
+
throw new Error(`Delivery queue is ${this.lifecycleState}; mutations are no longer allowed`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
|
|
274
|
+
const byRecipient = new Map<string, IRecipientDeliveryState>();
|
|
275
|
+
const now = new Date();
|
|
276
|
+
for (const recipient of email.getAllRecipients()) {
|
|
277
|
+
const normalizedRecipient = recipient.trim().toLowerCase();
|
|
278
|
+
if (!normalizedRecipient || byRecipient.has(normalizedRecipient)) continue;
|
|
279
|
+
byRecipient.set(normalizedRecipient, {
|
|
280
|
+
recipient,
|
|
281
|
+
normalizedRecipient,
|
|
282
|
+
state: 'unresolved',
|
|
283
|
+
updatedAt: now,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return [...byRecipient.values()];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private createBounceOperationId(itemId: string, normalizedRecipient: string): string {
|
|
290
|
+
return createHash('sha256')
|
|
291
|
+
.update(itemId)
|
|
292
|
+
.update('\0')
|
|
293
|
+
.update(normalizedRecipient)
|
|
294
|
+
.update('\0smtpFailure')
|
|
295
|
+
.digest('hex');
|
|
296
|
+
}
|
|
181
297
|
|
|
182
298
|
/**
|
|
183
299
|
* Initialize the queue
|
|
184
300
|
*/
|
|
185
|
-
|
|
301
|
+
private claimManagedPrefix(): void {
|
|
302
|
+
const storageManager = this.options.storageManager;
|
|
303
|
+
if (!storageManager || this.ownsManagedPrefix) return;
|
|
304
|
+
let owners = managedQueueOwners.get(storageManager);
|
|
305
|
+
if (!owners) {
|
|
306
|
+
owners = new Map();
|
|
307
|
+
managedQueueOwners.set(storageManager, owners);
|
|
308
|
+
}
|
|
309
|
+
const existingOwner = owners.get(this.options.storagePrefix);
|
|
310
|
+
if (existingOwner && existingOwner !== this) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Managed queue prefix ${this.options.storagePrefix} already has an active delivery owner`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
owners.set(this.options.storagePrefix, this);
|
|
316
|
+
this.ownsManagedPrefix = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private releaseManagedPrefix(): void {
|
|
320
|
+
const storageManager = this.options.storageManager;
|
|
321
|
+
if (!storageManager || !this.ownsManagedPrefix) return;
|
|
322
|
+
const owners = managedQueueOwners.get(storageManager);
|
|
323
|
+
if (owners?.get(this.options.storagePrefix) === this) {
|
|
324
|
+
owners.delete(this.options.storagePrefix);
|
|
325
|
+
if (owners.size === 0) managedQueueOwners.delete(storageManager);
|
|
326
|
+
}
|
|
327
|
+
this.ownsManagedPrefix = false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
public async initialize(optionsArg: { startProcessing?: boolean } = {}): Promise<void> {
|
|
186
331
|
logger.log('info', 'Initializing UnifiedDeliveryQueue');
|
|
187
|
-
|
|
332
|
+
|
|
333
|
+
if (this.lifecycleState !== 'new') {
|
|
334
|
+
throw new Error(`Delivery queue cannot initialize from ${this.lifecycleState} state`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.claimManagedPrefix();
|
|
188
338
|
try {
|
|
189
339
|
if (this.options.storageManager) {
|
|
190
340
|
await this.loadFromStorage();
|
|
191
341
|
}
|
|
342
|
+
this.lifecycleState = 'initialized';
|
|
192
343
|
|
|
193
|
-
//
|
|
194
|
-
|
|
344
|
+
// Embedders may hydrate durable state before delivery dependencies are ready.
|
|
345
|
+
if (optionsArg.startProcessing !== false) {
|
|
346
|
+
this.startProcessing();
|
|
347
|
+
}
|
|
195
348
|
|
|
196
349
|
// Emit initialized event
|
|
197
|
-
this
|
|
350
|
+
await emitSafely(this, 'initialized');
|
|
198
351
|
logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
|
|
199
352
|
} catch (error) {
|
|
200
|
-
|
|
353
|
+
this.queue.clear();
|
|
354
|
+
this.updateStats();
|
|
355
|
+
this.lifecycleState = 'new';
|
|
356
|
+
this.releaseManagedPrefix();
|
|
357
|
+
logger.log('error', `Failed to initialize queue: ${(error as Error).message}`);
|
|
201
358
|
throw error;
|
|
202
359
|
}
|
|
203
360
|
}
|
|
@@ -224,7 +381,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
224
381
|
|
|
225
382
|
this.processing = true;
|
|
226
383
|
this.stats.processingActive = true;
|
|
227
|
-
this
|
|
384
|
+
void emitSafely(this, 'processingStarted');
|
|
228
385
|
logger.log('info', 'Queue processing started');
|
|
229
386
|
}
|
|
230
387
|
|
|
@@ -243,7 +400,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
243
400
|
|
|
244
401
|
this.processing = false;
|
|
245
402
|
this.stats.processingActive = false;
|
|
246
|
-
this
|
|
403
|
+
void emitSafely(this, 'processingStopped');
|
|
247
404
|
logger.log('info', 'Queue processing stopped');
|
|
248
405
|
}
|
|
249
406
|
|
|
@@ -270,14 +427,14 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
270
427
|
readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
271
428
|
|
|
272
429
|
// Emit event for ready items
|
|
273
|
-
this
|
|
430
|
+
await emitSafely(this, 'itemsReady', readyItems);
|
|
274
431
|
logger.log('info', `Found ${readyItems.length} items ready for processing`);
|
|
275
432
|
|
|
276
433
|
// Update statistics
|
|
277
434
|
this.updateStats();
|
|
278
435
|
} catch (error) {
|
|
279
|
-
logger.log('error', `Error processing queue: ${error
|
|
280
|
-
this
|
|
436
|
+
logger.log('error', `Error processing queue: ${getErrorMessage(error)}`);
|
|
437
|
+
await emitSafely(this, 'error', error);
|
|
281
438
|
}
|
|
282
439
|
}
|
|
283
440
|
|
|
@@ -292,6 +449,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
292
449
|
mode: EmailProcessingMode,
|
|
293
450
|
route?: IEmailRoute,
|
|
294
451
|
): Promise<string> {
|
|
452
|
+
this.assertMutable();
|
|
295
453
|
if (!(processingResult instanceof Email)) {
|
|
296
454
|
throw new Error('Queue processingResult must be an Email instance');
|
|
297
455
|
}
|
|
@@ -314,7 +472,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
314
472
|
attempts: 0,
|
|
315
473
|
nextAttempt: new Date(),
|
|
316
474
|
createdAt: new Date(),
|
|
317
|
-
updatedAt: new Date()
|
|
475
|
+
updatedAt: new Date(),
|
|
476
|
+
deliveredRecipients: [],
|
|
477
|
+
terminalRecipients: [],
|
|
478
|
+
recipientStates: this.createInitialRecipientStates(processingResult),
|
|
479
|
+
bounceOperations: [],
|
|
480
|
+
smtpTransactions: [],
|
|
318
481
|
};
|
|
319
482
|
|
|
320
483
|
// Commit durable state before exposing the item in memory or emitting acceptance.
|
|
@@ -325,7 +488,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
325
488
|
this.updateStats();
|
|
326
489
|
|
|
327
490
|
// Emit event
|
|
328
|
-
this
|
|
491
|
+
await emitSafely(this, 'itemEnqueued', item);
|
|
329
492
|
logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
|
|
330
493
|
|
|
331
494
|
return id;
|
|
@@ -340,7 +503,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
340
503
|
}
|
|
341
504
|
|
|
342
505
|
public listItems(): IQueueItem[] {
|
|
343
|
-
return Array.from(this.queue.values()).map((item) => ({
|
|
506
|
+
return Array.from(this.queue.values()).map((item) => ({
|
|
507
|
+
...item,
|
|
508
|
+
deliveredRecipients: [...(item.deliveredRecipients || [])],
|
|
509
|
+
terminalRecipients: [...(item.terminalRecipients || [])],
|
|
510
|
+
recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
|
|
511
|
+
bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
|
|
512
|
+
smtpTransactions: (item.smtpTransactions || []).map((transaction) => ({
|
|
513
|
+
...transaction,
|
|
514
|
+
recipients: [...transaction.recipients],
|
|
515
|
+
recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
|
|
516
|
+
transcript: transaction.transcript.map((entry) => ({ ...entry })),
|
|
517
|
+
})),
|
|
518
|
+
}));
|
|
344
519
|
}
|
|
345
520
|
|
|
346
521
|
/**
|
|
@@ -348,9 +523,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
348
523
|
* @param id Item ID
|
|
349
524
|
*/
|
|
350
525
|
public async markProcessing(id: string): Promise<boolean> {
|
|
526
|
+
this.assertMutable();
|
|
351
527
|
const item = this.queue.get(id);
|
|
352
528
|
|
|
353
|
-
if (
|
|
529
|
+
if (
|
|
530
|
+
!item
|
|
531
|
+
|| !(
|
|
532
|
+
item.status === 'pending'
|
|
533
|
+
|| (item.status === 'deferred' && item.nextAttempt <= new Date())
|
|
534
|
+
)
|
|
535
|
+
) {
|
|
354
536
|
return false;
|
|
355
537
|
}
|
|
356
538
|
|
|
@@ -364,7 +546,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
364
546
|
this.updateStats();
|
|
365
547
|
|
|
366
548
|
// Emit event
|
|
367
|
-
this
|
|
549
|
+
await emitSafely(this, 'itemProcessing', item);
|
|
368
550
|
logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
|
|
369
551
|
|
|
370
552
|
return true;
|
|
@@ -375,9 +557,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
375
557
|
* @param id Item ID
|
|
376
558
|
*/
|
|
377
559
|
public async markDelivered(id: string): Promise<boolean> {
|
|
560
|
+
this.assertMutable();
|
|
378
561
|
const item = this.queue.get(id);
|
|
379
562
|
|
|
380
|
-
if (!item) {
|
|
563
|
+
if (!item || item.status === 'delivered' || item.status === 'failed') {
|
|
381
564
|
return false;
|
|
382
565
|
}
|
|
383
566
|
|
|
@@ -392,7 +575,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
392
575
|
this.updateStats();
|
|
393
576
|
|
|
394
577
|
// Emit event
|
|
395
|
-
this
|
|
578
|
+
await emitSafely(this, 'itemDelivered', item);
|
|
396
579
|
logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
|
|
397
580
|
|
|
398
581
|
return true;
|
|
@@ -403,15 +586,37 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
403
586
|
* @param id Item ID
|
|
404
587
|
* @param error Error message
|
|
405
588
|
*/
|
|
406
|
-
public async markFailed(
|
|
589
|
+
public async markFailed(
|
|
590
|
+
id: string,
|
|
591
|
+
error: string,
|
|
592
|
+
optionsArg: IQueueFailureOptions = {},
|
|
593
|
+
): Promise<boolean> {
|
|
594
|
+
return (await this.markFailedWithTransition(id, error, optionsArg)).updated;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Rich internal disposition used by the delivery system without breaking markFailed(). */
|
|
598
|
+
public async markFailedWithTransition(
|
|
599
|
+
id: string,
|
|
600
|
+
error: string,
|
|
601
|
+
optionsArg: IQueueFailureOptions = {},
|
|
602
|
+
): Promise<IQueueFailureTransition> {
|
|
603
|
+
this.assertMutable();
|
|
407
604
|
const item = this.queue.get(id);
|
|
408
605
|
|
|
409
606
|
if (!item) {
|
|
410
|
-
return false;
|
|
607
|
+
return { updated: false, terminal: false };
|
|
608
|
+
}
|
|
609
|
+
if (item.status === 'delivered' || item.status === 'failed') {
|
|
610
|
+
return {
|
|
611
|
+
updated: false,
|
|
612
|
+
status: item.status === 'failed' ? 'failed' : undefined,
|
|
613
|
+
terminal: true,
|
|
614
|
+
};
|
|
411
615
|
}
|
|
412
616
|
|
|
413
617
|
// Determine if we should retry
|
|
414
|
-
|
|
618
|
+
const retryable = optionsArg.retryable !== false;
|
|
619
|
+
if (retryable && item.attempts < this.options.maxRetries) {
|
|
415
620
|
// Calculate next retry time with exponential backoff
|
|
416
621
|
const delay = Math.min(
|
|
417
622
|
this.options.baseRetryDelay * Math.pow(2, item.attempts - 1),
|
|
@@ -426,8 +631,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
426
631
|
});
|
|
427
632
|
|
|
428
633
|
// Emit event
|
|
429
|
-
this
|
|
634
|
+
await emitSafely(this, 'itemDeferred', item);
|
|
430
635
|
logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
|
|
636
|
+
this.updateStats();
|
|
637
|
+
return { updated: true, status: 'deferred', terminal: false };
|
|
431
638
|
} else {
|
|
432
639
|
// Mark as permanently failed
|
|
433
640
|
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
@@ -440,21 +647,237 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
440
647
|
this.totalProcessed++;
|
|
441
648
|
|
|
442
649
|
// Emit event
|
|
443
|
-
this
|
|
650
|
+
await emitSafely(this, 'itemFailed', item);
|
|
444
651
|
logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
|
|
445
652
|
}
|
|
446
|
-
|
|
447
|
-
// Update statistics
|
|
653
|
+
|
|
448
654
|
this.updateStats();
|
|
449
|
-
|
|
655
|
+
return {
|
|
656
|
+
updated: true,
|
|
657
|
+
status: 'failed',
|
|
658
|
+
terminal: true,
|
|
659
|
+
terminalReason: retryable ? 'retry_exhausted' : 'permanent',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Persist a completed SMTP attempt before any queue status event is emitted. */
|
|
664
|
+
public async appendSmtpTransaction(
|
|
665
|
+
id: string,
|
|
666
|
+
transaction: ISmtpTransactionAttempt,
|
|
667
|
+
): Promise<boolean> {
|
|
668
|
+
this.assertMutable();
|
|
669
|
+
const item = this.queue.get(id);
|
|
670
|
+
if (!item) return false;
|
|
671
|
+
const normalizedTransaction: ISmtpTransactionAttempt = {
|
|
672
|
+
...transaction,
|
|
673
|
+
queueItemId: id,
|
|
674
|
+
recipients: [...transaction.recipients],
|
|
675
|
+
recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
|
|
676
|
+
transcript: normalizeSmtpTranscript(transaction.transcript),
|
|
677
|
+
};
|
|
678
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
679
|
+
updatedItem.smtpTransactions = [
|
|
680
|
+
...(updatedItem.smtpTransactions || []),
|
|
681
|
+
normalizedTransaction,
|
|
682
|
+
].slice(-20);
|
|
683
|
+
updatedItem.updatedAt = new Date();
|
|
684
|
+
});
|
|
685
|
+
await emitSafely(this, 'smtpTransaction', item, normalizedTransaction);
|
|
450
686
|
return true;
|
|
451
687
|
}
|
|
452
|
-
|
|
688
|
+
|
|
689
|
+
/** Record recipients accepted after the SMTP final-response 250 checkpoint. */
|
|
690
|
+
public async markRecipientsDelivered(id: string, recipients: string[]): Promise<boolean> {
|
|
691
|
+
this.assertMutable();
|
|
692
|
+
const item = this.queue.get(id);
|
|
693
|
+
if (!item) return false;
|
|
694
|
+
const normalizedRecipients = new Set(
|
|
695
|
+
recipients.map((recipient) => recipient.trim().toLowerCase()).filter(Boolean),
|
|
696
|
+
);
|
|
697
|
+
if (normalizedRecipients.size === 0) return false;
|
|
698
|
+
|
|
699
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
700
|
+
const states = updatedItem.recipientStates || this.createInitialRecipientStates(
|
|
701
|
+
updatedItem.processingResult,
|
|
702
|
+
);
|
|
703
|
+
const delivered = new Set(updatedItem.deliveredRecipients || []);
|
|
704
|
+
const now = new Date();
|
|
705
|
+
for (const state of states) {
|
|
706
|
+
if (
|
|
707
|
+
normalizedRecipients.has(state.normalizedRecipient)
|
|
708
|
+
&& state.state === 'unresolved'
|
|
709
|
+
) {
|
|
710
|
+
state.state = 'delivered';
|
|
711
|
+
state.updatedAt = now;
|
|
712
|
+
delete state.smtpCode;
|
|
713
|
+
delete state.smtpResponse;
|
|
714
|
+
delivered.add(state.normalizedRecipient);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
updatedItem.recipientStates = states;
|
|
718
|
+
updatedItem.deliveredRecipients = [...delivered];
|
|
719
|
+
updatedItem.updatedAt = now;
|
|
720
|
+
});
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Atomically checkpoint permanent recipient failures together with their
|
|
726
|
+
* durable bounce work. This is the sole 5xx recipient transition.
|
|
727
|
+
*/
|
|
728
|
+
public async markRecipientsTerminal(
|
|
729
|
+
id: string,
|
|
730
|
+
failures: Array<string | IRecipientPermanentFailure>,
|
|
731
|
+
): Promise<string[]> {
|
|
732
|
+
this.assertMutable();
|
|
733
|
+
const item = this.queue.get(id);
|
|
734
|
+
if (!item || failures.length === 0) return [];
|
|
735
|
+
const normalizedFailures = failures.map((failure) => (
|
|
736
|
+
typeof failure === 'string'
|
|
737
|
+
? {
|
|
738
|
+
recipient: failure,
|
|
739
|
+
smtpCode: 550,
|
|
740
|
+
smtpResponse: 'SMTP recipient permanently rejected',
|
|
741
|
+
}
|
|
742
|
+
: failure
|
|
743
|
+
));
|
|
744
|
+
const newlyTerminal: string[] = [];
|
|
745
|
+
|
|
746
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
747
|
+
const states = updatedItem.recipientStates || this.createInitialRecipientStates(
|
|
748
|
+
updatedItem.processingResult,
|
|
749
|
+
);
|
|
750
|
+
const terminal = new Set(updatedItem.terminalRecipients || []);
|
|
751
|
+
const operations = updatedItem.bounceOperations || [];
|
|
752
|
+
const operationIds = new Set(operations.map((operation) => operation.operationId));
|
|
753
|
+
const now = new Date();
|
|
754
|
+
|
|
755
|
+
for (const failure of normalizedFailures) {
|
|
756
|
+
const normalizedRecipient = failure.recipient.trim().toLowerCase();
|
|
757
|
+
const state = states.find(
|
|
758
|
+
(candidate) => candidate.normalizedRecipient === normalizedRecipient,
|
|
759
|
+
);
|
|
760
|
+
if (!state || state.state !== 'unresolved') continue;
|
|
761
|
+
|
|
762
|
+
state.state = 'permanentFailure';
|
|
763
|
+
state.smtpCode = failure.smtpCode;
|
|
764
|
+
state.smtpResponse = failure.smtpResponse;
|
|
765
|
+
state.updatedAt = now;
|
|
766
|
+
terminal.add(normalizedRecipient);
|
|
767
|
+
newlyTerminal.push(state.recipient);
|
|
768
|
+
|
|
769
|
+
const operationId = this.createBounceOperationId(updatedItem.id, normalizedRecipient);
|
|
770
|
+
if (!operationIds.has(operationId)) {
|
|
771
|
+
operations.push({
|
|
772
|
+
operationId,
|
|
773
|
+
kind: 'smtpFailure',
|
|
774
|
+
recipient: state.recipient,
|
|
775
|
+
normalizedRecipient,
|
|
776
|
+
smtpCode: failure.smtpCode,
|
|
777
|
+
smtpResponse: failure.smtpResponse,
|
|
778
|
+
state: 'pending',
|
|
779
|
+
attempts: 0,
|
|
780
|
+
nextAttempt: now,
|
|
781
|
+
});
|
|
782
|
+
operationIds.add(operationId);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
updatedItem.recipientStates = states;
|
|
787
|
+
updatedItem.terminalRecipients = [...terminal];
|
|
788
|
+
updatedItem.bounceOperations = operations;
|
|
789
|
+
updatedItem.updatedAt = now;
|
|
790
|
+
});
|
|
791
|
+
return newlyTerminal;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
public getRecipientDisposition(id: string): {
|
|
795
|
+
unresolved: string[];
|
|
796
|
+
delivered: string[];
|
|
797
|
+
permanentFailures: string[];
|
|
798
|
+
} {
|
|
799
|
+
const item = this.queue.get(id);
|
|
800
|
+
if (!item) return { unresolved: [], delivered: [], permanentFailures: [] };
|
|
801
|
+
const states = item.recipientStates || this.createInitialRecipientStates(item.processingResult);
|
|
802
|
+
return {
|
|
803
|
+
unresolved: states.filter((state) => state.state === 'unresolved').map((state) => state.recipient),
|
|
804
|
+
delivered: states.filter((state) => state.state === 'delivered').map((state) => state.recipient),
|
|
805
|
+
permanentFailures: states
|
|
806
|
+
.filter((state) => state.state === 'permanentFailure')
|
|
807
|
+
.map((state) => state.recipient),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Includes terminal queue items because bounce work outlives delivery status. */
|
|
812
|
+
public listPendingBounceOperations(
|
|
813
|
+
now: Date = new Date(),
|
|
814
|
+
): Array<{ itemId: string; operation: IBounceOperation }> {
|
|
815
|
+
const result: Array<{ itemId: string; operation: IBounceOperation }> = [];
|
|
816
|
+
for (const item of this.queue.values()) {
|
|
817
|
+
for (const operation of item.bounceOperations || []) {
|
|
818
|
+
if (operation.state === 'pending' && operation.nextAttempt <= now) {
|
|
819
|
+
result.push({ itemId: item.id, operation: { ...operation } });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return result;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
public async markBounceOperationCompleted(
|
|
827
|
+
itemId: string,
|
|
828
|
+
operationId: string,
|
|
829
|
+
): Promise<boolean> {
|
|
830
|
+
this.assertMutable();
|
|
831
|
+
const item = this.queue.get(itemId);
|
|
832
|
+
const operation = item?.bounceOperations?.find(
|
|
833
|
+
(candidate) => candidate.operationId === operationId,
|
|
834
|
+
);
|
|
835
|
+
if (!item || !operation || operation.state === 'completed') return false;
|
|
836
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
837
|
+
const updatedOperation = updatedItem.bounceOperations?.find(
|
|
838
|
+
(candidate) => candidate.operationId === operationId,
|
|
839
|
+
);
|
|
840
|
+
if (!updatedOperation) return;
|
|
841
|
+
updatedOperation.state = 'completed';
|
|
842
|
+
updatedOperation.completedAt = new Date();
|
|
843
|
+
delete updatedOperation.lastError;
|
|
844
|
+
updatedItem.updatedAt = new Date();
|
|
845
|
+
});
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
public async markBounceOperationFailed(
|
|
850
|
+
itemId: string,
|
|
851
|
+
operationId: string,
|
|
852
|
+
error: string,
|
|
853
|
+
): Promise<boolean> {
|
|
854
|
+
this.assertMutable();
|
|
855
|
+
const item = this.queue.get(itemId);
|
|
856
|
+
const operation = item?.bounceOperations?.find(
|
|
857
|
+
(candidate) => candidate.operationId === operationId,
|
|
858
|
+
);
|
|
859
|
+
if (!item || !operation || operation.state === 'completed') return false;
|
|
860
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
861
|
+
const updatedOperation = updatedItem.bounceOperations?.find(
|
|
862
|
+
(candidate) => candidate.operationId === operationId,
|
|
863
|
+
);
|
|
864
|
+
if (!updatedOperation) return;
|
|
865
|
+
updatedOperation.attempts++;
|
|
866
|
+
updatedOperation.lastError = error;
|
|
867
|
+
updatedOperation.nextAttempt = new Date(
|
|
868
|
+
Date.now() + Math.min(60_000 * (2 ** Math.max(0, updatedOperation.attempts - 1)), 3_600_000),
|
|
869
|
+
);
|
|
870
|
+
updatedItem.updatedAt = new Date();
|
|
871
|
+
});
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
453
875
|
/**
|
|
454
876
|
* Remove an item from the queue
|
|
455
877
|
* @param id Item ID
|
|
456
878
|
*/
|
|
457
879
|
public async removeItem(id: string): Promise<boolean> {
|
|
880
|
+
this.assertMutable();
|
|
458
881
|
const item = this.queue.get(id);
|
|
459
882
|
|
|
460
883
|
if (!item) {
|
|
@@ -469,7 +892,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
469
892
|
this.updateStats();
|
|
470
893
|
|
|
471
894
|
// Emit event
|
|
472
|
-
this
|
|
895
|
+
await emitSafely(this, 'itemRemoved', item);
|
|
473
896
|
logger.log('info', `Item ${id} removed from queue`);
|
|
474
897
|
|
|
475
898
|
return true;
|
|
@@ -489,7 +912,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
489
912
|
|
|
490
913
|
private serializeItem(item: IQueueItem): IStoredQueueItem {
|
|
491
914
|
return {
|
|
492
|
-
schemaVersion:
|
|
915
|
+
schemaVersion: 2,
|
|
493
916
|
id: item.id,
|
|
494
917
|
processingMode: item.processingMode,
|
|
495
918
|
processingResult: {
|
|
@@ -505,6 +928,29 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
505
928
|
createdAt: item.createdAt.getTime(),
|
|
506
929
|
updatedAt: item.updatedAt.getTime(),
|
|
507
930
|
...(item.deliveredAt ? { deliveredAt: item.deliveredAt.getTime() } : {}),
|
|
931
|
+
deliveredRecipients: item.deliveredRecipients || [],
|
|
932
|
+
terminalRecipients: item.terminalRecipients || [],
|
|
933
|
+
recipientStates: (item.recipientStates || this.createInitialRecipientStates(
|
|
934
|
+
item.processingResult,
|
|
935
|
+
)).map((state) => ({
|
|
936
|
+
...state,
|
|
937
|
+
updatedAt: state.updatedAt.getTime(),
|
|
938
|
+
})),
|
|
939
|
+
bounceOperations: (item.bounceOperations || []).map((operation) => {
|
|
940
|
+
const { nextAttempt, completedAt, ...storedOperation } = operation;
|
|
941
|
+
return {
|
|
942
|
+
...storedOperation,
|
|
943
|
+
nextAttempt: nextAttempt.getTime(),
|
|
944
|
+
...(completedAt ? { completedAt: completedAt.getTime() } : {}),
|
|
945
|
+
};
|
|
946
|
+
}),
|
|
947
|
+
smtpTransactions: (item.smtpTransactions || []).slice(-20).map((transaction) => ({
|
|
948
|
+
...transaction,
|
|
949
|
+
queueItemId: item.id,
|
|
950
|
+
recipients: [...transaction.recipients],
|
|
951
|
+
recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
|
|
952
|
+
transcript: normalizeSmtpTranscript(transaction.transcript),
|
|
953
|
+
})),
|
|
508
954
|
};
|
|
509
955
|
}
|
|
510
956
|
|
|
@@ -512,7 +958,15 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
512
958
|
item: IQueueItem,
|
|
513
959
|
mutation: (updatedItem: IQueueItem) => void,
|
|
514
960
|
): Promise<void> {
|
|
515
|
-
|
|
961
|
+
this.assertMutable();
|
|
962
|
+
const updatedItem: IQueueItem = {
|
|
963
|
+
...item,
|
|
964
|
+
deliveredRecipients: [...(item.deliveredRecipients || [])],
|
|
965
|
+
terminalRecipients: [...(item.terminalRecipients || [])],
|
|
966
|
+
recipientStates: (item.recipientStates || []).map((state) => ({ ...state })),
|
|
967
|
+
bounceOperations: (item.bounceOperations || []).map((operation) => ({ ...operation })),
|
|
968
|
+
smtpTransactions: [...(item.smtpTransactions || [])],
|
|
969
|
+
};
|
|
516
970
|
mutation(updatedItem);
|
|
517
971
|
await this.persistItem(updatedItem);
|
|
518
972
|
Object.assign(item, updatedItem);
|
|
@@ -527,12 +981,12 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
527
981
|
try {
|
|
528
982
|
parsed = deserialize(data) as Record<string, any>;
|
|
529
983
|
} catch (error) {
|
|
530
|
-
throw new Error(`Invalid managed queue item at ${key}: ${error
|
|
984
|
+
throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
|
|
531
985
|
}
|
|
532
986
|
if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
|
|
533
987
|
throw new Error(`Invalid managed queue item at ${key}: missing id`);
|
|
534
988
|
}
|
|
535
|
-
if (parsed.schemaVersion !== 1) {
|
|
989
|
+
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
|
|
536
990
|
throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
|
|
537
991
|
}
|
|
538
992
|
if (this.storageKey(parsed.id) !== key) {
|
|
@@ -548,6 +1002,174 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
548
1002
|
throw new Error(`Invalid managed queue item at ${key}: invalid attempt count`);
|
|
549
1003
|
}
|
|
550
1004
|
|
|
1005
|
+
if (
|
|
1006
|
+
parsed.deliveredRecipients !== undefined
|
|
1007
|
+
&& (
|
|
1008
|
+
!Array.isArray(parsed.deliveredRecipients)
|
|
1009
|
+
|| parsed.deliveredRecipients.some((value: unknown) => typeof value !== 'string')
|
|
1010
|
+
)
|
|
1011
|
+
) {
|
|
1012
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid delivered recipients`);
|
|
1013
|
+
}
|
|
1014
|
+
if (
|
|
1015
|
+
parsed.terminalRecipients !== undefined
|
|
1016
|
+
&& (
|
|
1017
|
+
!Array.isArray(parsed.terminalRecipients)
|
|
1018
|
+
|| parsed.terminalRecipients.some((value: unknown) => typeof value !== 'string')
|
|
1019
|
+
)
|
|
1020
|
+
) {
|
|
1021
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid terminal recipients`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (parsed.schemaVersion === 2) {
|
|
1025
|
+
if (
|
|
1026
|
+
!Array.isArray(parsed.recipientStates)
|
|
1027
|
+
|| parsed.recipientStates.some((value: unknown) => {
|
|
1028
|
+
if (!value || typeof value !== 'object') return true;
|
|
1029
|
+
const state = value as Record<string, unknown>;
|
|
1030
|
+
return !(
|
|
1031
|
+
typeof state.recipient === 'string'
|
|
1032
|
+
&& typeof state.normalizedRecipient === 'string'
|
|
1033
|
+
&& state.normalizedRecipient === state.recipient.trim().toLowerCase()
|
|
1034
|
+
&& ['unresolved', 'delivered', 'permanentFailure'].includes(state.state as string)
|
|
1035
|
+
&& Number.isFinite(state.updatedAt)
|
|
1036
|
+
&& (state.smtpCode === undefined || Number.isInteger(state.smtpCode))
|
|
1037
|
+
&& (state.smtpResponse === undefined || typeof state.smtpResponse === 'string')
|
|
1038
|
+
);
|
|
1039
|
+
})
|
|
1040
|
+
) {
|
|
1041
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid recipient states`);
|
|
1042
|
+
}
|
|
1043
|
+
if (
|
|
1044
|
+
!Array.isArray(parsed.bounceOperations)
|
|
1045
|
+
|| parsed.bounceOperations.some((value: unknown) => {
|
|
1046
|
+
if (!value || typeof value !== 'object') return true;
|
|
1047
|
+
const operation = value as Record<string, unknown>;
|
|
1048
|
+
return !(
|
|
1049
|
+
typeof operation.operationId === 'string'
|
|
1050
|
+
&& operation.operationId.length > 0
|
|
1051
|
+
&& operation.kind === 'smtpFailure'
|
|
1052
|
+
&& typeof operation.recipient === 'string'
|
|
1053
|
+
&& typeof operation.normalizedRecipient === 'string'
|
|
1054
|
+
&& operation.normalizedRecipient === operation.recipient.trim().toLowerCase()
|
|
1055
|
+
&& Number.isInteger(operation.smtpCode)
|
|
1056
|
+
&& (operation.smtpCode as number) >= 500
|
|
1057
|
+
&& typeof operation.smtpResponse === 'string'
|
|
1058
|
+
&& ['pending', 'completed'].includes(operation.state as string)
|
|
1059
|
+
&& Number.isInteger(operation.attempts)
|
|
1060
|
+
&& (operation.attempts as number) >= 0
|
|
1061
|
+
&& Number.isFinite(operation.nextAttempt)
|
|
1062
|
+
&& (operation.lastError === undefined || typeof operation.lastError === 'string')
|
|
1063
|
+
&& (operation.completedAt === undefined || Number.isFinite(operation.completedAt))
|
|
1064
|
+
);
|
|
1065
|
+
})
|
|
1066
|
+
) {
|
|
1067
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid bounce operations`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const deliveryPhases = new Set([
|
|
1072
|
+
'connect',
|
|
1073
|
+
'greeting',
|
|
1074
|
+
'ehlo',
|
|
1075
|
+
'starttls',
|
|
1076
|
+
'tls_handshake',
|
|
1077
|
+
'post_tls_ehlo',
|
|
1078
|
+
'auth',
|
|
1079
|
+
'mail_from',
|
|
1080
|
+
'rcpt_to',
|
|
1081
|
+
'data_command',
|
|
1082
|
+
'message_body',
|
|
1083
|
+
'final_response',
|
|
1084
|
+
'rset',
|
|
1085
|
+
'quit',
|
|
1086
|
+
'unknown',
|
|
1087
|
+
]);
|
|
1088
|
+
const isValidTimestamp = (value: unknown): value is string => (
|
|
1089
|
+
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
|
1090
|
+
);
|
|
1091
|
+
const isValidTranscriptEntry = (value: unknown): boolean => {
|
|
1092
|
+
if (!value || typeof value !== 'object') return false;
|
|
1093
|
+
const entry = value as Record<string, unknown>;
|
|
1094
|
+
return (
|
|
1095
|
+
typeof entry.timestampMs === 'number'
|
|
1096
|
+
&& Number.isFinite(entry.timestampMs)
|
|
1097
|
+
&& deliveryPhases.has(entry.phase as string)
|
|
1098
|
+
&& ['client', 'server', 'system'].includes(entry.direction as string)
|
|
1099
|
+
&& typeof entry.text === 'string'
|
|
1100
|
+
&& [...entry.text].length <= SMTP_TRANSCRIPT_MAX_ENTRY_CODE_POINTS
|
|
1101
|
+
&& (
|
|
1102
|
+
entry.responseCode === undefined
|
|
1103
|
+
|| (Number.isInteger(entry.responseCode) && (entry.responseCode as number) >= 100)
|
|
1104
|
+
)
|
|
1105
|
+
);
|
|
1106
|
+
};
|
|
1107
|
+
const isValidTransaction = (value: unknown): boolean => {
|
|
1108
|
+
if (!value || typeof value !== 'object') return false;
|
|
1109
|
+
const transaction = value as Record<string, unknown>;
|
|
1110
|
+
return (
|
|
1111
|
+
typeof transaction.id === 'string'
|
|
1112
|
+
&& transaction.id.length > 0
|
|
1113
|
+
&& transaction.queueItemId === parsed.id
|
|
1114
|
+
&& Number.isInteger(transaction.queueAttempt)
|
|
1115
|
+
&& (transaction.queueAttempt as number) >= 0
|
|
1116
|
+
&& typeof transaction.targetHost === 'string'
|
|
1117
|
+
&& transaction.targetHost.length > 0
|
|
1118
|
+
&& Number.isInteger(transaction.targetPort)
|
|
1119
|
+
&& (transaction.targetPort as number) >= 1
|
|
1120
|
+
&& (transaction.targetPort as number) <= 65_535
|
|
1121
|
+
&& (
|
|
1122
|
+
transaction.recipientDomain === undefined
|
|
1123
|
+
|| typeof transaction.recipientDomain === 'string'
|
|
1124
|
+
)
|
|
1125
|
+
&& Array.isArray(transaction.recipients)
|
|
1126
|
+
&& transaction.recipients.every((recipient) => typeof recipient === 'string')
|
|
1127
|
+
&& isValidTimestamp(transaction.startedAt)
|
|
1128
|
+
&& isValidTimestamp(transaction.completedAt)
|
|
1129
|
+
&& typeof transaction.durationMs === 'number'
|
|
1130
|
+
&& Number.isFinite(transaction.durationMs)
|
|
1131
|
+
&& transaction.durationMs >= 0
|
|
1132
|
+
&& ['succeeded', 'failed'].includes(transaction.outcome as string)
|
|
1133
|
+
&& typeof transaction.retryable === 'boolean'
|
|
1134
|
+
&& (transaction.errorType === undefined || typeof transaction.errorType === 'string')
|
|
1135
|
+
&& (transaction.smtpCode === undefined || Number.isInteger(transaction.smtpCode))
|
|
1136
|
+
&& (
|
|
1137
|
+
transaction.recipientResults === undefined
|
|
1138
|
+
|| (
|
|
1139
|
+
Array.isArray(transaction.recipientResults)
|
|
1140
|
+
&& transaction.recipientResults.every((value) => {
|
|
1141
|
+
if (!value || typeof value !== 'object') return false;
|
|
1142
|
+
const result = value as Record<string, unknown>;
|
|
1143
|
+
return (
|
|
1144
|
+
typeof result.recipient === 'string'
|
|
1145
|
+
&& typeof result.accepted === 'boolean'
|
|
1146
|
+
&& Number.isInteger(result.responseCode)
|
|
1147
|
+
);
|
|
1148
|
+
})
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
&& (transaction.failurePhase === undefined || deliveryPhases.has(transaction.failurePhase as string))
|
|
1152
|
+
&& (transaction.error === undefined || typeof transaction.error === 'string')
|
|
1153
|
+
&& Array.isArray(transaction.transcript)
|
|
1154
|
+
&& transaction.transcript.length <= SMTP_TRANSCRIPT_MAX_ENTRIES
|
|
1155
|
+
&& transaction.transcript.every(isValidTranscriptEntry)
|
|
1156
|
+
&& transaction.transcript.reduce(
|
|
1157
|
+
(sum, entry) => sum + Buffer.byteLength((entry as { text: string }).text, 'utf8'),
|
|
1158
|
+
0,
|
|
1159
|
+
) <= SMTP_TRANSCRIPT_MAX_UTF8_BYTES
|
|
1160
|
+
);
|
|
1161
|
+
};
|
|
1162
|
+
if (
|
|
1163
|
+
parsed.smtpTransactions !== undefined
|
|
1164
|
+
&& (
|
|
1165
|
+
!Array.isArray(parsed.smtpTransactions)
|
|
1166
|
+
|| parsed.smtpTransactions.length > 20
|
|
1167
|
+
|| parsed.smtpTransactions.some((value: unknown) => !isValidTransaction(value))
|
|
1168
|
+
)
|
|
1169
|
+
) {
|
|
1170
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid SMTP transactions`);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
551
1173
|
const parseDate = (value: unknown, field: string): Date => {
|
|
552
1174
|
if (!Number.isFinite(value) || (value as number) < 0) {
|
|
553
1175
|
throw new Error(`Invalid managed queue item at ${key}: invalid ${field}`);
|
|
@@ -585,7 +1207,38 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
585
1207
|
.setMessageId(parsed.processingResult.messageId)
|
|
586
1208
|
.setEnvelopeFrom(parsed.processingResult.envelopeFrom);
|
|
587
1209
|
} catch (error) {
|
|
588
|
-
throw new Error(`Invalid managed queue item at ${key}: ${error
|
|
1210
|
+
throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
let recipientStates: IRecipientDeliveryState[];
|
|
1214
|
+
let bounceOperations: IBounceOperation[];
|
|
1215
|
+
if (parsed.schemaVersion === 2) {
|
|
1216
|
+
recipientStates = parsed.recipientStates.map((state: IStoredRecipientDeliveryState) => ({
|
|
1217
|
+
...state,
|
|
1218
|
+
updatedAt: parseDate(state.updatedAt, 'recipient state updatedAt'),
|
|
1219
|
+
}));
|
|
1220
|
+
bounceOperations = parsed.bounceOperations.map((operation: IStoredBounceOperation) => ({
|
|
1221
|
+
...operation,
|
|
1222
|
+
nextAttempt: parseDate(operation.nextAttempt, 'bounce operation nextAttempt'),
|
|
1223
|
+
...(operation.completedAt !== undefined
|
|
1224
|
+
? { completedAt: parseDate(operation.completedAt, 'bounce operation completedAt') }
|
|
1225
|
+
: {}),
|
|
1226
|
+
}));
|
|
1227
|
+
} else {
|
|
1228
|
+
// V1 hydration deliberately starts with an empty outbox. Bounce work is
|
|
1229
|
+
// never synthesized retroactively without a durable recipient transition.
|
|
1230
|
+
recipientStates = this.createInitialRecipientStates(processingResult);
|
|
1231
|
+
const delivered = new Set(
|
|
1232
|
+
(parsed.deliveredRecipients || []).map((recipient: string) => recipient.toLowerCase()),
|
|
1233
|
+
);
|
|
1234
|
+
const terminal = new Set(
|
|
1235
|
+
(parsed.terminalRecipients || []).map((recipient: string) => recipient.toLowerCase()),
|
|
1236
|
+
);
|
|
1237
|
+
for (const state of recipientStates) {
|
|
1238
|
+
if (delivered.has(state.normalizedRecipient)) state.state = 'delivered';
|
|
1239
|
+
else if (terminal.has(state.normalizedRecipient)) state.state = 'permanentFailure';
|
|
1240
|
+
}
|
|
1241
|
+
bounceOperations = [];
|
|
589
1242
|
}
|
|
590
1243
|
|
|
591
1244
|
return {
|
|
@@ -602,6 +1255,22 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
602
1255
|
...(parsed.deliveredAt
|
|
603
1256
|
? { deliveredAt: parseDate(parsed.deliveredAt, 'deliveredAt') }
|
|
604
1257
|
: {}),
|
|
1258
|
+
deliveredRecipients: Array.isArray(parsed.deliveredRecipients)
|
|
1259
|
+
? [...parsed.deliveredRecipients]
|
|
1260
|
+
: [],
|
|
1261
|
+
terminalRecipients: Array.isArray(parsed.terminalRecipients)
|
|
1262
|
+
? [...parsed.terminalRecipients]
|
|
1263
|
+
: [],
|
|
1264
|
+
recipientStates,
|
|
1265
|
+
bounceOperations,
|
|
1266
|
+
smtpTransactions: Array.isArray(parsed.smtpTransactions)
|
|
1267
|
+
? parsed.smtpTransactions.slice(-20).map((transaction) => ({
|
|
1268
|
+
...transaction,
|
|
1269
|
+
recipients: [...transaction.recipients],
|
|
1270
|
+
recipientResults: transaction.recipientResults?.map((result) => ({ ...result })),
|
|
1271
|
+
transcript: normalizeSmtpTranscript(transaction.transcript),
|
|
1272
|
+
}))
|
|
1273
|
+
: [],
|
|
605
1274
|
} as IQueueItem;
|
|
606
1275
|
}
|
|
607
1276
|
|
|
@@ -703,7 +1372,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
703
1372
|
this.stats.processingActive = this.processing;
|
|
704
1373
|
|
|
705
1374
|
// Emit statistics event
|
|
706
|
-
this
|
|
1375
|
+
void emitSafely(this, 'statsUpdated', this.stats);
|
|
707
1376
|
}
|
|
708
1377
|
|
|
709
1378
|
/**
|
|
@@ -717,6 +1386,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
717
1386
|
* Pause queue processing
|
|
718
1387
|
*/
|
|
719
1388
|
public pause(): void {
|
|
1389
|
+
this.assertMutable();
|
|
720
1390
|
if (this.processing) {
|
|
721
1391
|
this.stopProcessing();
|
|
722
1392
|
logger.log('info', 'Queue processing paused');
|
|
@@ -727,6 +1397,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
727
1397
|
* Resume queue processing
|
|
728
1398
|
*/
|
|
729
1399
|
public resume(): void {
|
|
1400
|
+
this.assertMutable();
|
|
730
1401
|
if (!this.processing) {
|
|
731
1402
|
this.startProcessing();
|
|
732
1403
|
logger.log('info', 'Queue processing resumed');
|
|
@@ -738,12 +1409,20 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
738
1409
|
* @param maxAge Maximum age in milliseconds (default: 7 days)
|
|
739
1410
|
*/
|
|
740
1411
|
public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
|
|
1412
|
+
this.assertMutable();
|
|
741
1413
|
const cutoff = new Date(Date.now() - maxAge);
|
|
742
1414
|
|
|
743
1415
|
// Collect IDs first to avoid modifying the Map during iteration
|
|
744
1416
|
const idsToRemove: string[] = [];
|
|
745
1417
|
for (const item of this.queue.values()) {
|
|
746
|
-
|
|
1418
|
+
const hasPendingBounceWork = (item.bounceOperations || []).some(
|
|
1419
|
+
(operation) => operation.state !== 'completed',
|
|
1420
|
+
);
|
|
1421
|
+
if (
|
|
1422
|
+
['delivered', 'failed'].includes(item.status)
|
|
1423
|
+
&& item.updatedAt < cutoff
|
|
1424
|
+
&& !hasPendingBounceWork
|
|
1425
|
+
) {
|
|
747
1426
|
idsToRemove.push(item.id);
|
|
748
1427
|
}
|
|
749
1428
|
}
|
|
@@ -763,31 +1442,34 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
763
1442
|
* Shutdown the queue
|
|
764
1443
|
*/
|
|
765
1444
|
public async shutdown(): Promise<void> {
|
|
1445
|
+
if (this.lifecycleState === 'closed') return;
|
|
766
1446
|
logger.log('info', 'Shutting down UnifiedDeliveryQueue');
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1447
|
+
this.lifecycleState = 'shutting-down';
|
|
1448
|
+
let persistenceError: unknown;
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
// Stop processing (clears both check and cleanup timers).
|
|
1452
|
+
this.stopProcessing();
|
|
1453
|
+
|
|
1454
|
+
if (this.options.storageManager) {
|
|
1455
|
+
await Promise.all(
|
|
1456
|
+
[...this.queue.values()].map((item) => this.persistItem(item)),
|
|
1457
|
+
);
|
|
777
1458
|
}
|
|
778
|
-
|
|
779
|
-
//
|
|
780
|
-
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
// Preserve the original persistence failure; observer diagnostics are
|
|
1461
|
+
// isolated and can never replace it.
|
|
1462
|
+
persistenceError = error;
|
|
1463
|
+
logger.log('error', `Failed to persist delivery queue during shutdown: ${(error as Error).message}`);
|
|
1464
|
+
} finally {
|
|
1465
|
+
this.queue.clear();
|
|
1466
|
+
this.updateStats();
|
|
1467
|
+
await emitSafely(this, 'shutdown');
|
|
1468
|
+
this.lifecycleState = 'closed';
|
|
1469
|
+
this.releaseManagedPrefix();
|
|
781
1470
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
this.queue.clear();
|
|
785
|
-
|
|
786
|
-
// Update statistics
|
|
787
|
-
this.updateStats();
|
|
788
|
-
|
|
789
|
-
// Emit shutdown event
|
|
790
|
-
this.emit('shutdown');
|
|
1471
|
+
|
|
1472
|
+
if (persistenceError) throw persistenceError;
|
|
791
1473
|
logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
|
|
792
1474
|
}
|
|
793
1475
|
}
|