@push.rocks/smartmta 6.5.2 → 8.0.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 +40 -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 +16 -12
- package/dist_ts/mail/core/classes.bouncemanager.js +146 -129
- package/dist_ts/mail/core/classes.email.js +15 -13
- 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/core/classes.templatemanager.d.ts +10 -6
- package/dist_ts/mail/core/classes.templatemanager.js +35 -51
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +92 -22
- package/dist_ts/mail/delivery/classes.delivery.queue.js +738 -151
- 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/interfaces.d.ts +21 -0
- package/dist_ts/mail/delivery/interfaces.js +1 -1
- package/dist_ts/mail/interfaces.storage.d.ts +37 -6
- package/dist_ts/mail/interfaces.storage.js +33 -3
- package/dist_ts/mail/routing/classes.dkim.manager.d.ts +10 -6
- package/dist_ts/mail/routing/classes.dkim.manager.js +47 -31
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +7 -5
- 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 +5 -2
- package/dist_ts/mail/routing/classes.email.router.js +17 -12
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +12 -6
- package/dist_ts/mail/routing/classes.unified.email.server.js +69 -78
- package/dist_ts/mail/routing/interfaces.d.ts +0 -2
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +22 -45
- package/dist_ts/mail/security/classes.dkimcreator.js +93 -296
- package/dist_ts/mail/security/classes.spfverifier.js +5 -3
- package/dist_ts/paths.d.ts +0 -12
- package/dist_ts/paths.js +3 -36
- package/dist_ts/plugins.d.ts +2 -5
- package/dist_ts/plugins.js +3 -6
- package/dist_ts/security/classes.contentscanner.js +14 -12
- package/dist_ts/security/classes.ipreputationchecker.d.ts +9 -6
- package/dist_ts/security/classes.ipreputationchecker.js +42 -93
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +52 -4
- 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 -9
- package/readme.hints.md +4 -3
- package/readme.md +50 -18
- 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 +188 -155
- package/ts/mail/core/classes.email.ts +20 -14
- package/ts/mail/core/classes.emailvalidator.ts +9 -7
- package/ts/mail/core/classes.templatemanager.ts +42 -57
- package/ts/mail/delivery/classes.delivery.queue.ts +996 -185
- 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/interfaces.ts +27 -1
- package/ts/mail/interfaces.storage.ts +64 -10
- package/ts/mail/routing/classes.dkim.manager.ts +65 -40
- package/ts/mail/routing/classes.dns.manager.ts +39 -16
- package/ts/mail/routing/classes.email.action.executor.ts +64 -17
- package/ts/mail/routing/classes.email.router.ts +20 -13
- package/ts/mail/routing/classes.unified.email.server.ts +107 -86
- package/ts/mail/routing/interfaces.ts +0 -2
- package/ts/mail/security/classes.dkimcreator.ts +150 -355
- package/ts/mail/security/classes.spfverifier.ts +4 -2
- package/ts/paths.ts +2 -41
- package/ts/plugins.ts +1 -6
- package/ts/security/classes.contentscanner.ts +14 -12
- package/ts/security/classes.ipreputationchecker.ts +46 -99
- package/ts/security/classes.rustsecuritybridge.ts +272 -6
- package/ts/security/classes.securitylogger.ts +6 -4
- package/ts/security/index.ts +5 -1
|
@@ -1,23 +1,64 @@
|
|
|
1
|
-
import * as plugins from '../../plugins.js';
|
|
2
1
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { deserialize, serialize } from 'node:v8';
|
|
4
|
+
import type { IBlobStorageManager } from '../interfaces.storage.js';
|
|
5
5
|
import { logger } from '../../logger.js';
|
|
6
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';
|
|
7
14
|
import type { IEmailRoute } from '../routing/interfaces.js';
|
|
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
|
*/
|
|
17
58
|
export interface IQueueItem {
|
|
18
59
|
id: string;
|
|
19
60
|
processingMode: EmailProcessingMode;
|
|
20
|
-
processingResult:
|
|
61
|
+
processingResult: Email;
|
|
21
62
|
route?: IEmailRoute;
|
|
22
63
|
status: QueueItemStatus;
|
|
23
64
|
attempts: number;
|
|
@@ -26,15 +67,77 @@ 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[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface IStoredEmail {
|
|
78
|
+
options: IEmailOptions;
|
|
79
|
+
messageId: string;
|
|
80
|
+
envelopeFrom: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface IStoredQueueItemV1 {
|
|
84
|
+
schemaVersion: 1;
|
|
85
|
+
id: string;
|
|
86
|
+
processingMode: EmailProcessingMode;
|
|
87
|
+
processingResult: IStoredEmail;
|
|
88
|
+
route?: IEmailRoute;
|
|
89
|
+
status: QueueItemStatus;
|
|
90
|
+
attempts: number;
|
|
91
|
+
nextAttempt: number;
|
|
92
|
+
lastError?: string;
|
|
93
|
+
createdAt: number;
|
|
94
|
+
updatedAt: number;
|
|
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';
|
|
29
130
|
}
|
|
30
131
|
|
|
31
132
|
/**
|
|
32
133
|
* Queue options interface
|
|
33
134
|
*/
|
|
34
135
|
export interface IQueueOptions {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
136
|
+
/** Persistence is always an explicit caller choice. */
|
|
137
|
+
storageMode: 'memory' | 'managed';
|
|
138
|
+
/** Required when storageMode is managed. */
|
|
139
|
+
storageManager?: IBlobStorageManager;
|
|
140
|
+
storagePrefix?: string;
|
|
38
141
|
|
|
39
142
|
// Queue behavior
|
|
40
143
|
checkInterval?: number;
|
|
@@ -50,6 +153,22 @@ export interface IQueueOptions {
|
|
|
50
153
|
/**
|
|
51
154
|
* Queue statistics interface
|
|
52
155
|
*/
|
|
156
|
+
function normalizeStoragePrefix(prefixArg = '/email/queue/'): string {
|
|
157
|
+
if (
|
|
158
|
+
!prefixArg.startsWith('/') ||
|
|
159
|
+
!prefixArg.endsWith('/') ||
|
|
160
|
+
prefixArg.includes('\\') ||
|
|
161
|
+
prefixArg.includes('\0')
|
|
162
|
+
) {
|
|
163
|
+
throw new Error('Queue storagePrefix must be an absolute managed-storage prefix');
|
|
164
|
+
}
|
|
165
|
+
const segments = prefixArg.slice(1, -1).split('/');
|
|
166
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
167
|
+
throw new Error('Queue storagePrefix contains an invalid path segment');
|
|
168
|
+
}
|
|
169
|
+
return prefixArg;
|
|
170
|
+
}
|
|
171
|
+
|
|
53
172
|
export interface IQueueStats {
|
|
54
173
|
queueSize: number;
|
|
55
174
|
status: {
|
|
@@ -71,17 +190,32 @@ export interface IQueueStats {
|
|
|
71
190
|
processingActive: boolean;
|
|
72
191
|
}
|
|
73
192
|
|
|
193
|
+
const managedQueueOwners =
|
|
194
|
+
new WeakMap<IBlobStorageManager, Map<string, UnifiedDeliveryQueue>>();
|
|
195
|
+
|
|
74
196
|
/**
|
|
75
197
|
* A unified queue for all email modes
|
|
76
198
|
*/
|
|
77
199
|
export class UnifiedDeliveryQueue extends EventEmitter {
|
|
78
|
-
private options:
|
|
200
|
+
private options: {
|
|
201
|
+
storageMode: 'memory' | 'managed';
|
|
202
|
+
storageManager?: IBlobStorageManager;
|
|
203
|
+
storagePrefix: string;
|
|
204
|
+
checkInterval: number;
|
|
205
|
+
maxQueueSize: number;
|
|
206
|
+
maxPerDestination: number;
|
|
207
|
+
maxRetries: number;
|
|
208
|
+
baseRetryDelay: number;
|
|
209
|
+
maxRetryDelay: number;
|
|
210
|
+
};
|
|
79
211
|
private queue: Map<string, IQueueItem> = new Map();
|
|
80
212
|
private checkTimer?: NodeJS.Timeout;
|
|
81
213
|
private cleanupTimer?: NodeJS.Timeout;
|
|
82
214
|
private stats: IQueueStats;
|
|
83
215
|
private processing: boolean = false;
|
|
84
216
|
private totalProcessed: number = 0;
|
|
217
|
+
private ownsManagedPrefix = false;
|
|
218
|
+
private lifecycleState: TQueueLifecycleState = 'new';
|
|
85
219
|
|
|
86
220
|
/**
|
|
87
221
|
* Create a new unified delivery queue
|
|
@@ -89,11 +223,18 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
89
223
|
*/
|
|
90
224
|
constructor(options: IQueueOptions) {
|
|
91
225
|
super();
|
|
92
|
-
|
|
93
|
-
|
|
226
|
+
|
|
227
|
+
if (options.storageMode === 'managed' && !options.storageManager) {
|
|
228
|
+
throw new Error('Managed queue storage requires an IBlobStorageManager');
|
|
229
|
+
}
|
|
230
|
+
if (options.storageMode === 'memory' && options.storageManager) {
|
|
231
|
+
throw new Error('Memory-only queue storage cannot receive an IBlobStorageManager');
|
|
232
|
+
}
|
|
233
|
+
|
|
94
234
|
this.options = {
|
|
95
|
-
|
|
96
|
-
|
|
235
|
+
storageMode: options.storageMode,
|
|
236
|
+
storageManager: options.storageManager,
|
|
237
|
+
storagePrefix: normalizeStoragePrefix(options.storagePrefix),
|
|
97
238
|
checkInterval: options.checkInterval || 30000, // 30 seconds
|
|
98
239
|
maxQueueSize: options.maxQueueSize || 10000,
|
|
99
240
|
maxPerDestination: options.maxPerDestination || 100,
|
|
@@ -122,32 +263,98 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
122
263
|
processingActive: false
|
|
123
264
|
};
|
|
124
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
|
+
}
|
|
125
297
|
|
|
126
298
|
/**
|
|
127
299
|
* Initialize the queue
|
|
128
300
|
*/
|
|
129
|
-
|
|
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> {
|
|
130
331
|
logger.log('info', 'Initializing UnifiedDeliveryQueue');
|
|
131
|
-
|
|
332
|
+
|
|
333
|
+
if (this.lifecycleState !== 'new') {
|
|
334
|
+
throw new Error(`Delivery queue cannot initialize from ${this.lifecycleState} state`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.claimManagedPrefix();
|
|
132
338
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!fs.existsSync(this.options.persistentPath)) {
|
|
136
|
-
fs.mkdirSync(this.options.persistentPath, { recursive: true });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Load existing items from disk
|
|
140
|
-
await this.loadFromDisk();
|
|
339
|
+
if (this.options.storageManager) {
|
|
340
|
+
await this.loadFromStorage();
|
|
141
341
|
}
|
|
342
|
+
this.lifecycleState = 'initialized';
|
|
142
343
|
|
|
143
|
-
//
|
|
144
|
-
|
|
344
|
+
// Embedders may hydrate durable state before delivery dependencies are ready.
|
|
345
|
+
if (optionsArg.startProcessing !== false) {
|
|
346
|
+
this.startProcessing();
|
|
347
|
+
}
|
|
145
348
|
|
|
146
349
|
// Emit initialized event
|
|
147
|
-
this
|
|
350
|
+
await emitSafely(this, 'initialized');
|
|
148
351
|
logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
|
|
149
352
|
} catch (error) {
|
|
150
|
-
|
|
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}`);
|
|
151
358
|
throw error;
|
|
152
359
|
}
|
|
153
360
|
}
|
|
@@ -174,7 +381,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
174
381
|
|
|
175
382
|
this.processing = true;
|
|
176
383
|
this.stats.processingActive = true;
|
|
177
|
-
this
|
|
384
|
+
void emitSafely(this, 'processingStarted');
|
|
178
385
|
logger.log('info', 'Queue processing started');
|
|
179
386
|
}
|
|
180
387
|
|
|
@@ -193,7 +400,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
193
400
|
|
|
194
401
|
this.processing = false;
|
|
195
402
|
this.stats.processingActive = false;
|
|
196
|
-
this
|
|
403
|
+
void emitSafely(this, 'processingStopped');
|
|
197
404
|
logger.log('info', 'Queue processing stopped');
|
|
198
405
|
}
|
|
199
406
|
|
|
@@ -220,14 +427,14 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
220
427
|
readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
221
428
|
|
|
222
429
|
// Emit event for ready items
|
|
223
|
-
this
|
|
430
|
+
await emitSafely(this, 'itemsReady', readyItems);
|
|
224
431
|
logger.log('info', `Found ${readyItems.length} items ready for processing`);
|
|
225
432
|
|
|
226
433
|
// Update statistics
|
|
227
434
|
this.updateStats();
|
|
228
435
|
} catch (error) {
|
|
229
|
-
logger.log('error', `Error processing queue: ${error
|
|
230
|
-
this
|
|
436
|
+
logger.log('error', `Error processing queue: ${getErrorMessage(error)}`);
|
|
437
|
+
await emitSafely(this, 'error', error);
|
|
231
438
|
}
|
|
232
439
|
}
|
|
233
440
|
|
|
@@ -237,7 +444,16 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
237
444
|
* @param mode Processing mode
|
|
238
445
|
* @param route Email route
|
|
239
446
|
*/
|
|
240
|
-
public async enqueue(
|
|
447
|
+
public async enqueue(
|
|
448
|
+
processingResult: Email,
|
|
449
|
+
mode: EmailProcessingMode,
|
|
450
|
+
route?: IEmailRoute,
|
|
451
|
+
): Promise<string> {
|
|
452
|
+
this.assertMutable();
|
|
453
|
+
if (!(processingResult instanceof Email)) {
|
|
454
|
+
throw new Error('Queue processingResult must be an Email instance');
|
|
455
|
+
}
|
|
456
|
+
|
|
241
457
|
// Check if queue is full
|
|
242
458
|
if (this.queue.size >= this.options.maxQueueSize) {
|
|
243
459
|
throw new Error('Queue is full');
|
|
@@ -256,22 +472,23 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
256
472
|
attempts: 0,
|
|
257
473
|
nextAttempt: new Date(),
|
|
258
474
|
createdAt: new Date(),
|
|
259
|
-
updatedAt: new Date()
|
|
475
|
+
updatedAt: new Date(),
|
|
476
|
+
deliveredRecipients: [],
|
|
477
|
+
terminalRecipients: [],
|
|
478
|
+
recipientStates: this.createInitialRecipientStates(processingResult),
|
|
479
|
+
bounceOperations: [],
|
|
480
|
+
smtpTransactions: [],
|
|
260
481
|
};
|
|
261
482
|
|
|
262
|
-
//
|
|
483
|
+
// Commit durable state before exposing the item in memory or emitting acceptance.
|
|
484
|
+
await this.persistItem(item);
|
|
263
485
|
this.queue.set(id, item);
|
|
264
486
|
|
|
265
|
-
// Persist to disk if using disk storage
|
|
266
|
-
if (this.options.storageType === 'disk') {
|
|
267
|
-
await this.persistItem(item);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
487
|
// Update statistics
|
|
271
488
|
this.updateStats();
|
|
272
489
|
|
|
273
490
|
// Emit event
|
|
274
|
-
this
|
|
491
|
+
await emitSafely(this, 'itemEnqueued', item);
|
|
275
492
|
logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
|
|
276
493
|
|
|
277
494
|
return id;
|
|
@@ -286,7 +503,19 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
286
503
|
}
|
|
287
504
|
|
|
288
505
|
public listItems(): IQueueItem[] {
|
|
289
|
-
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
|
+
}));
|
|
290
519
|
}
|
|
291
520
|
|
|
292
521
|
/**
|
|
@@ -294,27 +523,30 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
294
523
|
* @param id Item ID
|
|
295
524
|
*/
|
|
296
525
|
public async markProcessing(id: string): Promise<boolean> {
|
|
526
|
+
this.assertMutable();
|
|
297
527
|
const item = this.queue.get(id);
|
|
298
528
|
|
|
299
|
-
if (
|
|
529
|
+
if (
|
|
530
|
+
!item
|
|
531
|
+
|| !(
|
|
532
|
+
item.status === 'pending'
|
|
533
|
+
|| (item.status === 'deferred' && item.nextAttempt <= new Date())
|
|
534
|
+
)
|
|
535
|
+
) {
|
|
300
536
|
return false;
|
|
301
537
|
}
|
|
302
538
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Persist changes if using disk storage
|
|
309
|
-
if (this.options.storageType === 'disk') {
|
|
310
|
-
await this.persistItem(item);
|
|
311
|
-
}
|
|
539
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
540
|
+
updatedItem.status = 'processing';
|
|
541
|
+
updatedItem.attempts++;
|
|
542
|
+
updatedItem.updatedAt = new Date();
|
|
543
|
+
});
|
|
312
544
|
|
|
313
545
|
// Update statistics
|
|
314
546
|
this.updateStats();
|
|
315
547
|
|
|
316
548
|
// Emit event
|
|
317
|
-
this
|
|
549
|
+
await emitSafely(this, 'itemProcessing', item);
|
|
318
550
|
logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
|
|
319
551
|
|
|
320
552
|
return true;
|
|
@@ -325,28 +557,25 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
325
557
|
* @param id Item ID
|
|
326
558
|
*/
|
|
327
559
|
public async markDelivered(id: string): Promise<boolean> {
|
|
560
|
+
this.assertMutable();
|
|
328
561
|
const item = this.queue.get(id);
|
|
329
562
|
|
|
330
|
-
if (!item) {
|
|
563
|
+
if (!item || item.status === 'delivered' || item.status === 'failed') {
|
|
331
564
|
return false;
|
|
332
565
|
}
|
|
333
566
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// Persist changes if using disk storage
|
|
340
|
-
if (this.options.storageType === 'disk') {
|
|
341
|
-
await this.persistItem(item);
|
|
342
|
-
}
|
|
567
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
568
|
+
updatedItem.status = 'delivered';
|
|
569
|
+
updatedItem.updatedAt = new Date();
|
|
570
|
+
updatedItem.deliveredAt = new Date();
|
|
571
|
+
});
|
|
343
572
|
|
|
344
573
|
// Update statistics
|
|
345
574
|
this.totalProcessed++;
|
|
346
575
|
this.updateStats();
|
|
347
576
|
|
|
348
577
|
// Emit event
|
|
349
|
-
this
|
|
578
|
+
await emitSafely(this, 'itemDelivered', item);
|
|
350
579
|
logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
|
|
351
580
|
|
|
352
581
|
return true;
|
|
@@ -357,165 +586,734 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
357
586
|
* @param id Item ID
|
|
358
587
|
* @param error Error message
|
|
359
588
|
*/
|
|
360
|
-
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();
|
|
361
604
|
const item = this.queue.get(id);
|
|
362
605
|
|
|
363
606
|
if (!item) {
|
|
364
|
-
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
|
+
};
|
|
365
615
|
}
|
|
366
616
|
|
|
367
617
|
// Determine if we should retry
|
|
368
|
-
|
|
618
|
+
const retryable = optionsArg.retryable !== false;
|
|
619
|
+
if (retryable && item.attempts < this.options.maxRetries) {
|
|
369
620
|
// Calculate next retry time with exponential backoff
|
|
370
621
|
const delay = Math.min(
|
|
371
622
|
this.options.baseRetryDelay * Math.pow(2, item.attempts - 1),
|
|
372
623
|
this.options.maxRetryDelay
|
|
373
624
|
);
|
|
374
625
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// Persist changes if using disk storage
|
|
382
|
-
if (this.options.storageType === 'disk') {
|
|
383
|
-
await this.persistItem(item);
|
|
384
|
-
}
|
|
626
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
627
|
+
updatedItem.status = 'deferred';
|
|
628
|
+
updatedItem.lastError = error;
|
|
629
|
+
updatedItem.nextAttempt = new Date(Date.now() + delay);
|
|
630
|
+
updatedItem.updatedAt = new Date();
|
|
631
|
+
});
|
|
385
632
|
|
|
386
633
|
// Emit event
|
|
387
|
-
this
|
|
634
|
+
await emitSafely(this, 'itemDeferred', item);
|
|
388
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 };
|
|
389
638
|
} else {
|
|
390
639
|
// Mark as permanently failed
|
|
391
|
-
item
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (this.options.storageType === 'disk') {
|
|
397
|
-
await this.persistItem(item);
|
|
398
|
-
}
|
|
640
|
+
await this.applyPersistedMutation(item, (updatedItem) => {
|
|
641
|
+
updatedItem.status = 'failed';
|
|
642
|
+
updatedItem.lastError = error;
|
|
643
|
+
updatedItem.updatedAt = new Date();
|
|
644
|
+
});
|
|
399
645
|
|
|
400
646
|
// Update statistics
|
|
401
647
|
this.totalProcessed++;
|
|
402
648
|
|
|
403
649
|
// Emit event
|
|
404
|
-
this
|
|
650
|
+
await emitSafely(this, 'itemFailed', item);
|
|
405
651
|
logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
|
|
406
652
|
}
|
|
407
|
-
|
|
408
|
-
// Update statistics
|
|
653
|
+
|
|
409
654
|
this.updateStats();
|
|
410
|
-
|
|
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);
|
|
411
686
|
return true;
|
|
412
687
|
}
|
|
413
|
-
|
|
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
|
+
|
|
414
875
|
/**
|
|
415
876
|
* Remove an item from the queue
|
|
416
877
|
* @param id Item ID
|
|
417
878
|
*/
|
|
418
879
|
public async removeItem(id: string): Promise<boolean> {
|
|
880
|
+
this.assertMutable();
|
|
419
881
|
const item = this.queue.get(id);
|
|
420
882
|
|
|
421
883
|
if (!item) {
|
|
422
884
|
return false;
|
|
423
885
|
}
|
|
424
886
|
|
|
425
|
-
//
|
|
887
|
+
// Delete durable state before making the removal visible in memory.
|
|
888
|
+
await this.removeItemFromStorage(id);
|
|
426
889
|
this.queue.delete(id);
|
|
427
890
|
|
|
428
|
-
// Remove from disk if using disk storage
|
|
429
|
-
if (this.options.storageType === 'disk') {
|
|
430
|
-
await this.removeItemFromDisk(id);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
891
|
// Update statistics
|
|
434
892
|
this.updateStats();
|
|
435
893
|
|
|
436
894
|
// Emit event
|
|
437
|
-
this
|
|
895
|
+
await emitSafely(this, 'itemRemoved', item);
|
|
438
896
|
logger.log('info', `Item ${id} removed from queue`);
|
|
439
897
|
|
|
440
898
|
return true;
|
|
441
899
|
}
|
|
442
900
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
901
|
+
private storageKey(id: string): string {
|
|
902
|
+
return `${this.options.storagePrefix}${id}.json`;
|
|
903
|
+
}
|
|
904
|
+
|
|
447
905
|
private async persistItem(item: IQueueItem): Promise<void> {
|
|
906
|
+
if (!this.options.storageManager) return;
|
|
907
|
+
await this.options.storageManager.set(
|
|
908
|
+
this.storageKey(item.id),
|
|
909
|
+
serialize(this.serializeItem(item)),
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private serializeItem(item: IQueueItem): IStoredQueueItem {
|
|
914
|
+
return {
|
|
915
|
+
schemaVersion: 2,
|
|
916
|
+
id: item.id,
|
|
917
|
+
processingMode: item.processingMode,
|
|
918
|
+
processingResult: {
|
|
919
|
+
options: item.processingResult.toEmailOptions(),
|
|
920
|
+
messageId: item.processingResult.getMessageId(),
|
|
921
|
+
envelopeFrom: item.processingResult.getEnvelopeFrom(),
|
|
922
|
+
},
|
|
923
|
+
...(item.route ? { route: item.route } : {}),
|
|
924
|
+
status: item.status,
|
|
925
|
+
attempts: item.attempts,
|
|
926
|
+
nextAttempt: item.nextAttempt.getTime(),
|
|
927
|
+
...(item.lastError !== undefined ? { lastError: item.lastError } : {}),
|
|
928
|
+
createdAt: item.createdAt.getTime(),
|
|
929
|
+
updatedAt: item.updatedAt.getTime(),
|
|
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
|
+
})),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private async applyPersistedMutation(
|
|
958
|
+
item: IQueueItem,
|
|
959
|
+
mutation: (updatedItem: IQueueItem) => void,
|
|
960
|
+
): Promise<void> {
|
|
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
|
+
};
|
|
970
|
+
mutation(updatedItem);
|
|
971
|
+
await this.persistItem(updatedItem);
|
|
972
|
+
Object.assign(item, updatedItem);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async removeItemFromStorage(id: string): Promise<void> {
|
|
976
|
+
await this.options.storageManager?.delete(this.storageKey(id));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private deserializeItem(key: string, data: Buffer): IQueueItem {
|
|
980
|
+
let parsed: Record<string, any>;
|
|
448
981
|
try {
|
|
449
|
-
|
|
450
|
-
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
|
|
982
|
+
parsed = deserialize(data) as Record<string, any>;
|
|
451
983
|
} catch (error) {
|
|
452
|
-
|
|
453
|
-
this.emit('error', error);
|
|
984
|
+
throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
|
|
454
985
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
986
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
|
|
987
|
+
throw new Error(`Invalid managed queue item at ${key}: missing id`);
|
|
988
|
+
}
|
|
989
|
+
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
|
|
990
|
+
throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
|
|
991
|
+
}
|
|
992
|
+
if (this.storageKey(parsed.id) !== key) {
|
|
993
|
+
throw new Error(`Invalid managed queue item at ${key}: id does not match object key`);
|
|
994
|
+
}
|
|
995
|
+
if (!['pending', 'processing', 'delivered', 'failed', 'deferred'].includes(parsed.status)) {
|
|
996
|
+
throw new Error(`Invalid managed queue item at ${key}: unsupported status`);
|
|
997
|
+
}
|
|
998
|
+
if (!['forward', 'mta', 'process'].includes(parsed.processingMode)) {
|
|
999
|
+
throw new Error(`Invalid managed queue item at ${key}: unsupported processing mode`);
|
|
1000
|
+
}
|
|
1001
|
+
if (!Number.isInteger(parsed.attempts) || parsed.attempts < 0) {
|
|
1002
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid attempt count`);
|
|
1003
|
+
}
|
|
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`);
|
|
467
1068
|
}
|
|
468
|
-
} catch (error) {
|
|
469
|
-
logger.log('error', `Failed to remove item ${id} from disk: ${error.message}`);
|
|
470
|
-
this.emit('error', error);
|
|
471
1069
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
+
|
|
1173
|
+
const parseDate = (value: unknown, field: string): Date => {
|
|
1174
|
+
if (!Number.isFinite(value) || (value as number) < 0) {
|
|
1175
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid ${field}`);
|
|
482
1176
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
1177
|
+
return new Date(value as number);
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
if (
|
|
1181
|
+
!parsed.processingResult ||
|
|
1182
|
+
typeof parsed.processingResult !== 'object' ||
|
|
1183
|
+
!parsed.processingResult.options ||
|
|
1184
|
+
typeof parsed.processingResult.options !== 'object' ||
|
|
1185
|
+
typeof parsed.processingResult.messageId !== 'string' ||
|
|
1186
|
+
!parsed.processingResult.messageId ||
|
|
1187
|
+
typeof parsed.processingResult.envelopeFrom !== 'string'
|
|
1188
|
+
) {
|
|
1189
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid email payload`);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const storedOptions = parsed.processingResult.options as IEmailOptions;
|
|
1193
|
+
if (storedOptions.attachments !== undefined) {
|
|
1194
|
+
if (!Array.isArray(storedOptions.attachments)) {
|
|
1195
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid attachments`);
|
|
1196
|
+
}
|
|
1197
|
+
for (const attachment of storedOptions.attachments) {
|
|
1198
|
+
if (!attachment || typeof attachment !== 'object' || !Buffer.isBuffer(attachment.content)) {
|
|
1199
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid attachment content`);
|
|
506
1200
|
}
|
|
507
1201
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
let processingResult: Email;
|
|
1205
|
+
try {
|
|
1206
|
+
processingResult = new Email(storedOptions)
|
|
1207
|
+
.setMessageId(parsed.processingResult.messageId)
|
|
1208
|
+
.setEnvelopeFrom(parsed.processingResult.envelopeFrom);
|
|
513
1209
|
} catch (error) {
|
|
514
|
-
|
|
515
|
-
|
|
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 = [];
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return {
|
|
1245
|
+
id: parsed.id,
|
|
1246
|
+
processingMode: parsed.processingMode,
|
|
1247
|
+
processingResult,
|
|
1248
|
+
...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
|
|
1249
|
+
status: parsed.status,
|
|
1250
|
+
attempts: parsed.attempts,
|
|
1251
|
+
...(typeof parsed.lastError === 'string' ? { lastError: parsed.lastError } : {}),
|
|
1252
|
+
createdAt: parseDate(parsed.createdAt, 'createdAt'),
|
|
1253
|
+
updatedAt: parseDate(parsed.updatedAt, 'updatedAt'),
|
|
1254
|
+
nextAttempt: parseDate(parsed.nextAttempt, 'nextAttempt'),
|
|
1255
|
+
...(parsed.deliveredAt
|
|
1256
|
+
? { deliveredAt: parseDate(parsed.deliveredAt, 'deliveredAt') }
|
|
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
|
+
: [],
|
|
1274
|
+
} as IQueueItem;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
private async loadFromStorage(): Promise<void> {
|
|
1278
|
+
const storageManager = this.options.storageManager;
|
|
1279
|
+
if (!storageManager) return;
|
|
1280
|
+
const keys = (await storageManager.list(this.options.storagePrefix)).sort();
|
|
1281
|
+
const recoveredItems: IQueueItem[] = [];
|
|
1282
|
+
|
|
1283
|
+
for (const key of keys) {
|
|
1284
|
+
if (!key.startsWith(this.options.storagePrefix)) {
|
|
1285
|
+
throw new Error(`Blob storage returned a queue key outside ${this.options.storagePrefix}: ${key}`);
|
|
1286
|
+
}
|
|
1287
|
+
if (!key.endsWith('.json')) continue;
|
|
1288
|
+
const data = await storageManager.get(key);
|
|
1289
|
+
if (!data) {
|
|
1290
|
+
throw new Error(`Managed queue item disappeared while loading: ${key}`);
|
|
1291
|
+
}
|
|
1292
|
+
const item = this.deserializeItem(key, data);
|
|
1293
|
+
if (this.queue.has(item.id)) {
|
|
1294
|
+
throw new Error(`Duplicate managed queue item id: ${item.id}`);
|
|
1295
|
+
}
|
|
1296
|
+
if (this.queue.size >= this.options.maxQueueSize) {
|
|
1297
|
+
throw new Error(`Managed queue contains more than ${this.options.maxQueueSize} items`);
|
|
1298
|
+
}
|
|
1299
|
+
if (item.status === 'processing') {
|
|
1300
|
+
item.status = 'deferred';
|
|
1301
|
+
item.nextAttempt = new Date();
|
|
1302
|
+
item.updatedAt = new Date();
|
|
1303
|
+
item.lastError = 'Recovered after process restart during delivery';
|
|
1304
|
+
recoveredItems.push(item);
|
|
1305
|
+
}
|
|
1306
|
+
this.queue.set(item.id, item);
|
|
516
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
await Promise.all(recoveredItems.map((item) => this.persistItem(item)));
|
|
1310
|
+
this.updateStats();
|
|
1311
|
+
logger.log(
|
|
1312
|
+
'info',
|
|
1313
|
+
`Loaded ${this.queue.size} items from managed blob storage; recovered ${recoveredItems.length} interrupted item(s)`,
|
|
1314
|
+
);
|
|
517
1315
|
}
|
|
518
|
-
|
|
1316
|
+
|
|
519
1317
|
/**
|
|
520
1318
|
* Update queue statistics
|
|
521
1319
|
*/
|
|
@@ -574,7 +1372,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
574
1372
|
this.stats.processingActive = this.processing;
|
|
575
1373
|
|
|
576
1374
|
// Emit statistics event
|
|
577
|
-
this
|
|
1375
|
+
void emitSafely(this, 'statsUpdated', this.stats);
|
|
578
1376
|
}
|
|
579
1377
|
|
|
580
1378
|
/**
|
|
@@ -588,6 +1386,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
588
1386
|
* Pause queue processing
|
|
589
1387
|
*/
|
|
590
1388
|
public pause(): void {
|
|
1389
|
+
this.assertMutable();
|
|
591
1390
|
if (this.processing) {
|
|
592
1391
|
this.stopProcessing();
|
|
593
1392
|
logger.log('info', 'Queue processing paused');
|
|
@@ -598,6 +1397,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
598
1397
|
* Resume queue processing
|
|
599
1398
|
*/
|
|
600
1399
|
public resume(): void {
|
|
1400
|
+
this.assertMutable();
|
|
601
1401
|
if (!this.processing) {
|
|
602
1402
|
this.startProcessing();
|
|
603
1403
|
logger.log('info', 'Queue processing resumed');
|
|
@@ -609,12 +1409,20 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
609
1409
|
* @param maxAge Maximum age in milliseconds (default: 7 days)
|
|
610
1410
|
*/
|
|
611
1411
|
public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
|
|
1412
|
+
this.assertMutable();
|
|
612
1413
|
const cutoff = new Date(Date.now() - maxAge);
|
|
613
1414
|
|
|
614
1415
|
// Collect IDs first to avoid modifying the Map during iteration
|
|
615
1416
|
const idsToRemove: string[] = [];
|
|
616
1417
|
for (const item of this.queue.values()) {
|
|
617
|
-
|
|
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
|
+
) {
|
|
618
1426
|
idsToRemove.push(item.id);
|
|
619
1427
|
}
|
|
620
1428
|
}
|
|
@@ -634,31 +1442,34 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
634
1442
|
* Shutdown the queue
|
|
635
1443
|
*/
|
|
636
1444
|
public async shutdown(): Promise<void> {
|
|
1445
|
+
if (this.lifecycleState === 'closed') return;
|
|
637
1446
|
logger.log('info', 'Shutting down UnifiedDeliveryQueue');
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
+
);
|
|
648
1458
|
}
|
|
649
|
-
|
|
650
|
-
//
|
|
651
|
-
|
|
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();
|
|
652
1470
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
this.queue.clear();
|
|
656
|
-
|
|
657
|
-
// Update statistics
|
|
658
|
-
this.updateStats();
|
|
659
|
-
|
|
660
|
-
// Emit shutdown event
|
|
661
|
-
this.emit('shutdown');
|
|
1471
|
+
|
|
1472
|
+
if (persistenceError) throw persistenceError;
|
|
662
1473
|
logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
|
|
663
1474
|
}
|
|
664
1475
|
}
|