@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.
Files changed (84) hide show
  1. package/changelog.md +40 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/functions.errors.d.ts +3 -0
  6. package/dist_ts/functions.errors.js +8 -0
  7. package/dist_ts/index.d.ts +3 -0
  8. package/dist_ts/index.js +4 -1
  9. package/dist_ts/mail/core/classes.bouncemanager.d.ts +16 -12
  10. package/dist_ts/mail/core/classes.bouncemanager.js +146 -129
  11. package/dist_ts/mail/core/classes.email.js +15 -13
  12. package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
  13. package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
  14. package/dist_ts/mail/core/classes.templatemanager.d.ts +10 -6
  15. package/dist_ts/mail/core/classes.templatemanager.js +35 -51
  16. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +92 -22
  17. package/dist_ts/mail/delivery/classes.delivery.queue.js +738 -151
  18. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
  19. package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
  20. package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
  21. package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
  22. package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
  23. package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
  24. package/dist_ts/mail/delivery/interfaces.js +1 -1
  25. package/dist_ts/mail/interfaces.storage.d.ts +37 -6
  26. package/dist_ts/mail/interfaces.storage.js +33 -3
  27. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +10 -6
  28. package/dist_ts/mail/routing/classes.dkim.manager.js +47 -31
  29. package/dist_ts/mail/routing/classes.dns.manager.d.ts +7 -5
  30. package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
  31. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
  32. package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
  33. package/dist_ts/mail/routing/classes.email.router.d.ts +5 -2
  34. package/dist_ts/mail/routing/classes.email.router.js +17 -12
  35. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +12 -6
  36. package/dist_ts/mail/routing/classes.unified.email.server.js +69 -78
  37. package/dist_ts/mail/routing/interfaces.d.ts +0 -2
  38. package/dist_ts/mail/security/classes.dkimcreator.d.ts +22 -45
  39. package/dist_ts/mail/security/classes.dkimcreator.js +93 -296
  40. package/dist_ts/mail/security/classes.spfverifier.js +5 -3
  41. package/dist_ts/paths.d.ts +0 -12
  42. package/dist_ts/paths.js +3 -36
  43. package/dist_ts/plugins.d.ts +2 -5
  44. package/dist_ts/plugins.js +3 -6
  45. package/dist_ts/security/classes.contentscanner.js +14 -12
  46. package/dist_ts/security/classes.ipreputationchecker.d.ts +9 -6
  47. package/dist_ts/security/classes.ipreputationchecker.js +42 -93
  48. package/dist_ts/security/classes.rustsecuritybridge.d.ts +52 -4
  49. package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
  50. package/dist_ts/security/classes.securitylogger.js +7 -5
  51. package/dist_ts/security/index.d.ts +1 -1
  52. package/dist_ts/security/index.js +2 -2
  53. package/package.json +8 -9
  54. package/readme.hints.md +4 -3
  55. package/readme.md +50 -18
  56. package/readme.plan.md +6 -0
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/functions.errors.ts +8 -0
  59. package/ts/index.ts +3 -0
  60. package/ts/mail/core/classes.bouncemanager.ts +188 -155
  61. package/ts/mail/core/classes.email.ts +20 -14
  62. package/ts/mail/core/classes.emailvalidator.ts +9 -7
  63. package/ts/mail/core/classes.templatemanager.ts +42 -57
  64. package/ts/mail/delivery/classes.delivery.queue.ts +996 -185
  65. package/ts/mail/delivery/classes.delivery.system.ts +583 -170
  66. package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
  67. package/ts/mail/delivery/functions.safe-observers.ts +45 -0
  68. package/ts/mail/delivery/interfaces.ts +27 -1
  69. package/ts/mail/interfaces.storage.ts +64 -10
  70. package/ts/mail/routing/classes.dkim.manager.ts +65 -40
  71. package/ts/mail/routing/classes.dns.manager.ts +39 -16
  72. package/ts/mail/routing/classes.email.action.executor.ts +64 -17
  73. package/ts/mail/routing/classes.email.router.ts +20 -13
  74. package/ts/mail/routing/classes.unified.email.server.ts +107 -86
  75. package/ts/mail/routing/interfaces.ts +0 -2
  76. package/ts/mail/security/classes.dkimcreator.ts +150 -355
  77. package/ts/mail/security/classes.spfverifier.ts +4 -2
  78. package/ts/paths.ts +2 -41
  79. package/ts/plugins.ts +1 -6
  80. package/ts/security/classes.contentscanner.ts +14 -12
  81. package/ts/security/classes.ipreputationchecker.ts +46 -99
  82. package/ts/security/classes.rustsecuritybridge.ts +272 -6
  83. package/ts/security/classes.securitylogger.ts +6 -4
  84. 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 * as fs from 'node:fs';
4
- import * as path from 'node:path';
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: any;
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
- // Storage options
36
- storageType?: 'memory' | 'disk';
37
- persistentPath?: string;
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: Required<IQueueOptions>;
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
- // Set default options
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
- storageType: options.storageType || 'memory',
96
- persistentPath: options.persistentPath || path.join(process.cwd(), 'email-queue'),
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
- public async initialize(): Promise<void> {
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
- // Create persistent storage directory if using disk storage
134
- if (this.options.storageType === 'disk') {
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
- // Start the queue processing timer
144
- this.startProcessing();
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.emit('initialized');
350
+ await emitSafely(this, 'initialized');
148
351
  logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
149
352
  } catch (error) {
150
- logger.log('error', `Failed to initialize queue: ${error.message}`);
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.emit('processingStarted');
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.emit('processingStopped');
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.emit('itemsReady', readyItems);
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.message}`);
230
- this.emit('error', error);
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(processingResult: any, mode: EmailProcessingMode, route?: IEmailRoute): Promise<string> {
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
- // Add to queue
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.emit('itemEnqueued', item);
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) => ({ ...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 (!item) {
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
- // Update status
304
- item.status = 'processing';
305
- item.attempts++;
306
- item.updatedAt = new Date();
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.emit('itemProcessing', item);
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
- // Update status
335
- item.status = 'delivered';
336
- item.updatedAt = new Date();
337
- item.deliveredAt = new Date();
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.emit('itemDelivered', item);
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(id: string, error: string): Promise<boolean> {
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
- if (item.attempts < this.options.maxRetries) {
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
- // Update status
376
- item.status = 'deferred';
377
- item.lastError = error;
378
- item.nextAttempt = new Date(Date.now() + delay);
379
- item.updatedAt = new Date();
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.emit('itemDeferred', item);
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.status = 'failed';
392
- item.lastError = error;
393
- item.updatedAt = new Date();
394
-
395
- // Persist changes if using disk storage
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.emit('itemFailed', item);
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
- // Remove from queue
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.emit('itemRemoved', item);
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
- * Persist an item to disk
445
- * @param item Item to persist
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
- const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
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
- logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
453
- this.emit('error', error);
984
+ throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
454
985
  }
455
- }
456
-
457
- /**
458
- * Remove an item from disk
459
- * @param id Item ID
460
- */
461
- private async removeItemFromDisk(id: string): Promise<void> {
462
- try {
463
- const filePath = path.join(this.options.persistentPath, `${id}.json`);
464
-
465
- if (fs.existsSync(filePath)) {
466
- await fs.promises.unlink(filePath);
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
- * Load queue items from disk
476
- */
477
- private async loadFromDisk(): Promise<void> {
478
- try {
479
- // Check if directory exists
480
- if (!fs.existsSync(this.options.persistentPath)) {
481
- return;
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
- // Get all JSON files
485
- const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json'));
486
-
487
- // Load each file
488
- for (const file of files) {
489
- try {
490
- const filePath = path.join(this.options.persistentPath, file);
491
- const data = await fs.promises.readFile(filePath, 'utf8');
492
- const item = JSON.parse(data) as IQueueItem;
493
-
494
- // Convert date strings to Date objects
495
- item.createdAt = new Date(item.createdAt);
496
- item.updatedAt = new Date(item.updatedAt);
497
- item.nextAttempt = new Date(item.nextAttempt);
498
- if (item.deliveredAt) {
499
- item.deliveredAt = new Date(item.deliveredAt);
500
- }
501
-
502
- // Add to queue
503
- this.queue.set(item.id, item);
504
- } catch (error) {
505
- logger.log('error', `Failed to load item from ${file}: ${error.message}`);
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
- // Update statistics
510
- this.updateStats();
511
-
512
- logger.log('info', `Loaded ${this.queue.size} items from disk`);
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
- logger.log('error', `Failed to load items from disk: ${error.message}`);
515
- throw 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 = [];
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.emit('statsUpdated', this.stats);
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
- if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) {
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
- // Stop processing (clears both check and cleanup timers)
640
- this.stopProcessing();
641
-
642
- // If using disk storage, make sure all items are persisted
643
- if (this.options.storageType === 'disk') {
644
- const pendingWrites: Promise<void>[] = [];
645
-
646
- for (const item of this.queue.values()) {
647
- pendingWrites.push(this.persistItem(item));
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
- // Wait for all writes to complete
651
- await Promise.all(pendingWrites);
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
- // Clear the queue (memory only)
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
  }