@push.rocks/smartmta 6.5.1 → 7.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 (56) hide show
  1. package/changelog.md +20 -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/index.d.ts +3 -0
  6. package/dist_ts/index.js +4 -1
  7. package/dist_ts/mail/core/classes.bouncemanager.d.ts +5 -12
  8. package/dist_ts/mail/core/classes.bouncemanager.js +27 -92
  9. package/dist_ts/mail/core/classes.email.js +2 -2
  10. package/dist_ts/mail/core/classes.templatemanager.d.ts +10 -6
  11. package/dist_ts/mail/core/classes.templatemanager.js +35 -51
  12. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +15 -20
  13. package/dist_ts/mail/delivery/classes.delivery.queue.js +196 -114
  14. package/dist_ts/mail/delivery/classes.delivery.system.js +5 -5
  15. package/dist_ts/mail/interfaces.storage.d.ts +37 -6
  16. package/dist_ts/mail/interfaces.storage.js +33 -3
  17. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +2 -2
  18. package/dist_ts/mail/routing/classes.dkim.manager.js +2 -3
  19. package/dist_ts/mail/routing/classes.dns.manager.d.ts +2 -2
  20. package/dist_ts/mail/routing/classes.dns.manager.js +1 -1
  21. package/dist_ts/mail/routing/classes.email.router.d.ts +2 -2
  22. package/dist_ts/mail/routing/classes.email.router.js +4 -5
  23. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +8 -6
  24. package/dist_ts/mail/routing/classes.unified.email.server.js +12 -41
  25. package/dist_ts/mail/routing/interfaces.d.ts +0 -2
  26. package/dist_ts/mail/security/classes.dkimcreator.d.ts +15 -45
  27. package/dist_ts/mail/security/classes.dkimcreator.js +66 -294
  28. package/dist_ts/paths.d.ts +0 -12
  29. package/dist_ts/paths.js +3 -36
  30. package/dist_ts/plugins.d.ts +2 -5
  31. package/dist_ts/plugins.js +3 -6
  32. package/dist_ts/security/classes.contentscanner.js +1 -2
  33. package/dist_ts/security/classes.ipreputationchecker.d.ts +6 -6
  34. package/dist_ts/security/classes.ipreputationchecker.js +24 -84
  35. package/dist_ts/security/classes.rustsecuritybridge.d.ts +3 -3
  36. package/package.json +2 -3
  37. package/readme.md +12 -6
  38. package/ts/00_commitinfo_data.ts +1 -1
  39. package/ts/index.ts +4 -0
  40. package/ts/mail/core/classes.bouncemanager.ts +31 -110
  41. package/ts/mail/core/classes.email.ts +1 -1
  42. package/ts/mail/core/classes.templatemanager.ts +42 -57
  43. package/ts/mail/delivery/classes.delivery.queue.ts +264 -135
  44. package/ts/mail/delivery/classes.delivery.system.ts +7 -5
  45. package/ts/mail/interfaces.storage.ts +64 -10
  46. package/ts/mail/routing/classes.dkim.manager.ts +3 -3
  47. package/ts/mail/routing/classes.dns.manager.ts +3 -3
  48. package/ts/mail/routing/classes.email.router.ts +6 -6
  49. package/ts/mail/routing/classes.unified.email.server.ts +23 -44
  50. package/ts/mail/routing/interfaces.ts +0 -2
  51. package/ts/mail/security/classes.dkimcreator.ts +104 -352
  52. package/ts/paths.ts +2 -41
  53. package/ts/plugins.ts +1 -6
  54. package/ts/security/classes.contentscanner.ts +0 -1
  55. package/ts/security/classes.ipreputationchecker.ts +26 -90
  56. package/ts/security/classes.rustsecuritybridge.ts +3 -3
@@ -1,7 +1,6 @@
1
- import * as plugins from '../../plugins.js';
2
- import * as paths from '../../paths.js';
3
1
  import { logger } from '../../logger.js';
4
2
  import { Email, type IEmailOptions, type IAttachment } from './classes.email.js';
3
+ import type { IBlobStorageManager, IStorageManager } from '../interfaces.storage.js';
5
4
 
6
5
  /**
7
6
  * Email template type definition
@@ -18,7 +17,8 @@ export interface IEmailTemplate<T = any> {
18
17
  sampleData?: T;
19
18
  attachments?: Array<{
20
19
  name: string;
21
- path: string;
20
+ contentBase64?: string;
21
+ storageKey?: string;
22
22
  contentType?: string;
23
23
  }>;
24
24
  }
@@ -45,6 +45,8 @@ export enum TemplateCategory {
45
45
  */
46
46
  export class TemplateManager {
47
47
  private templates: Map<string, IEmailTemplate> = new Map();
48
+ private readonly templateStorage?: IStorageManager;
49
+ private readonly attachmentStorage?: IBlobStorageManager;
48
50
  private defaultConfig: {
49
51
  from: string;
50
52
  replyTo?: string;
@@ -57,7 +59,12 @@ export class TemplateManager {
57
59
  replyTo?: string;
58
60
  footerHtml?: string;
59
61
  footerText?: string;
62
+ }, storage?: {
63
+ templateStorage?: IStorageManager;
64
+ attachmentStorage?: IBlobStorageManager;
60
65
  }) {
66
+ this.templateStorage = storage?.templateStorage;
67
+ this.attachmentStorage = storage?.attachmentStorage;
61
68
  // Set default configuration
62
69
  this.defaultConfig = {
63
70
  from: defaultConfig?.from || 'noreply@mail.lossless.com',
@@ -212,25 +219,26 @@ export class TemplateManager {
212
219
 
213
220
  if (template.attachments && template.attachments.length > 0) {
214
221
  for (const attachment of template.attachments) {
215
- try {
216
- const attachmentPath = plugins.path.isAbsolute(attachment.path)
217
- ? attachment.path
218
- : plugins.path.join(paths.MtaAttachmentsDir, attachment.path);
219
-
220
- // Read the file
221
- const fileBuffer = await plugins.fs.promises.readFile(attachmentPath);
222
-
223
- attachments.push({
224
- filename: attachment.name,
225
- content: fileBuffer,
226
- contentType: attachment.contentType || 'application/octet-stream'
227
- });
228
- } catch (error) {
229
- logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`);
222
+ let content: Buffer | null = attachment.contentBase64
223
+ ? Buffer.from(attachment.contentBase64, 'base64')
224
+ : null;
225
+ if (!content && attachment.storageKey) {
226
+ if (!this.attachmentStorage) {
227
+ throw new Error(`Template attachment ${attachment.name} requires blob storage`);
228
+ }
229
+ content = await this.attachmentStorage.get(attachment.storageKey);
230
+ }
231
+ if (!content) {
232
+ throw new Error(`Template attachment ${attachment.name} has no content`);
230
233
  }
234
+ attachments.push({
235
+ filename: attachment.name,
236
+ content,
237
+ contentType: attachment.contentType || 'application/octet-stream',
238
+ });
231
239
  }
232
240
  }
233
-
241
+
234
242
  // Create Email instance with template content
235
243
  const emailOptions: IEmailOptions = {
236
244
  from: template.from || this.defaultConfig.from,
@@ -277,44 +285,21 @@ export class TemplateManager {
277
285
  }
278
286
 
279
287
 
280
- /**
281
- * Load templates from a directory
282
- * @param directory The directory containing template JSON files
283
- */
284
- public async loadTemplatesFromDirectory(directory: string): Promise<void> {
285
- try {
286
- // Ensure directory exists
287
- if (!plugins.fs.existsSync(directory)) {
288
- logger.log('error', `Template directory does not exist: ${directory}`);
289
- return;
290
- }
291
-
292
- // Get all JSON files
293
- const files = plugins.fs.readdirSync(directory)
294
- .filter(file => file.endsWith('.json'));
295
-
296
- for (const file of files) {
297
- try {
298
- const filePath = plugins.path.join(directory, file);
299
- const content = plugins.fs.readFileSync(filePath, 'utf8');
300
- const template = JSON.parse(content) as IEmailTemplate;
301
-
302
- // Validate template
303
- if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) {
304
- logger.log('warn', `Invalid template in ${file}: missing required fields`);
305
- continue;
306
- }
307
-
308
- this.registerTemplate(template);
309
- } catch (error) {
310
- logger.log('error', `Error loading template from ${file}: ${error.message}`);
311
- }
288
+ /** Load JSON templates from the configured managed text store. */
289
+ public async loadTemplatesFromStorage(prefix = '/email/templates/'): Promise<void> {
290
+ if (!this.templateStorage) {
291
+ throw new Error('Template storage is not configured');
292
+ }
293
+ const keys = await this.templateStorage.list(prefix);
294
+ for (const key of keys.filter((candidate) => candidate.endsWith('.json'))) {
295
+ const content = await this.templateStorage.get(key);
296
+ if (!content) continue;
297
+ const template = JSON.parse(content) as IEmailTemplate;
298
+ if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) {
299
+ throw new Error(`Invalid template in ${key}: missing required fields`);
312
300
  }
313
-
314
- logger.log('info', `Loaded ${this.templates.size} email templates`);
315
- } catch (error) {
316
- logger.log('error', `Failed to load templates from directory: ${error.message}`);
317
- throw error;
301
+ this.registerTemplate(template);
318
302
  }
319
303
  }
320
- }
304
+
305
+ }
@@ -1,10 +1,10 @@
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 { deserialize, serialize } from 'node:v8';
3
+ import type { IBlobStorageManager } from '../interfaces.storage.js';
5
4
  import { logger } from '../../logger.js';
6
5
  import { type EmailProcessingMode } from './interfaces.js';
7
6
  import type { IEmailRoute } from '../routing/interfaces.js';
7
+ import { Email, type IEmailOptions } from '../core/classes.email.js';
8
8
 
9
9
  /**
10
10
  * Queue item status
@@ -17,7 +17,7 @@ export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed'
17
17
  export interface IQueueItem {
18
18
  id: string;
19
19
  processingMode: EmailProcessingMode;
20
- processingResult: any;
20
+ processingResult: Email;
21
21
  route?: IEmailRoute;
22
22
  status: QueueItemStatus;
23
23
  attempts: number;
@@ -28,13 +28,36 @@ export interface IQueueItem {
28
28
  deliveredAt?: Date;
29
29
  }
30
30
 
31
+ interface IStoredEmail {
32
+ options: IEmailOptions;
33
+ messageId: string;
34
+ envelopeFrom: string;
35
+ }
36
+
37
+ interface IStoredQueueItem {
38
+ schemaVersion: 1;
39
+ id: string;
40
+ processingMode: EmailProcessingMode;
41
+ processingResult: IStoredEmail;
42
+ route?: IEmailRoute;
43
+ status: QueueItemStatus;
44
+ attempts: number;
45
+ nextAttempt: number;
46
+ lastError?: string;
47
+ createdAt: number;
48
+ updatedAt: number;
49
+ deliveredAt?: number;
50
+ }
51
+
31
52
  /**
32
53
  * Queue options interface
33
54
  */
34
55
  export interface IQueueOptions {
35
- // Storage options
36
- storageType?: 'memory' | 'disk';
37
- persistentPath?: string;
56
+ /** Persistence is always an explicit caller choice. */
57
+ storageMode: 'memory' | 'managed';
58
+ /** Required when storageMode is managed. */
59
+ storageManager?: IBlobStorageManager;
60
+ storagePrefix?: string;
38
61
 
39
62
  // Queue behavior
40
63
  checkInterval?: number;
@@ -50,6 +73,22 @@ export interface IQueueOptions {
50
73
  /**
51
74
  * Queue statistics interface
52
75
  */
76
+ function normalizeStoragePrefix(prefixArg = '/email/queue/'): string {
77
+ if (
78
+ !prefixArg.startsWith('/') ||
79
+ !prefixArg.endsWith('/') ||
80
+ prefixArg.includes('\\') ||
81
+ prefixArg.includes('\0')
82
+ ) {
83
+ throw new Error('Queue storagePrefix must be an absolute managed-storage prefix');
84
+ }
85
+ const segments = prefixArg.slice(1, -1).split('/');
86
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
87
+ throw new Error('Queue storagePrefix contains an invalid path segment');
88
+ }
89
+ return prefixArg;
90
+ }
91
+
53
92
  export interface IQueueStats {
54
93
  queueSize: number;
55
94
  status: {
@@ -75,7 +114,17 @@ export interface IQueueStats {
75
114
  * A unified queue for all email modes
76
115
  */
77
116
  export class UnifiedDeliveryQueue extends EventEmitter {
78
- private options: Required<IQueueOptions>;
117
+ private options: {
118
+ storageMode: 'memory' | 'managed';
119
+ storageManager?: IBlobStorageManager;
120
+ storagePrefix: string;
121
+ checkInterval: number;
122
+ maxQueueSize: number;
123
+ maxPerDestination: number;
124
+ maxRetries: number;
125
+ baseRetryDelay: number;
126
+ maxRetryDelay: number;
127
+ };
79
128
  private queue: Map<string, IQueueItem> = new Map();
80
129
  private checkTimer?: NodeJS.Timeout;
81
130
  private cleanupTimer?: NodeJS.Timeout;
@@ -89,11 +138,18 @@ export class UnifiedDeliveryQueue extends EventEmitter {
89
138
  */
90
139
  constructor(options: IQueueOptions) {
91
140
  super();
92
-
93
- // Set default options
141
+
142
+ if (options.storageMode === 'managed' && !options.storageManager) {
143
+ throw new Error('Managed queue storage requires an IBlobStorageManager');
144
+ }
145
+ if (options.storageMode === 'memory' && options.storageManager) {
146
+ throw new Error('Memory-only queue storage cannot receive an IBlobStorageManager');
147
+ }
148
+
94
149
  this.options = {
95
- storageType: options.storageType || 'memory',
96
- persistentPath: options.persistentPath || path.join(process.cwd(), 'email-queue'),
150
+ storageMode: options.storageMode,
151
+ storageManager: options.storageManager,
152
+ storagePrefix: normalizeStoragePrefix(options.storagePrefix),
97
153
  checkInterval: options.checkInterval || 30000, // 30 seconds
98
154
  maxQueueSize: options.maxQueueSize || 10000,
99
155
  maxPerDestination: options.maxPerDestination || 100,
@@ -130,14 +186,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
130
186
  logger.log('info', 'Initializing UnifiedDeliveryQueue');
131
187
 
132
188
  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();
189
+ if (this.options.storageManager) {
190
+ await this.loadFromStorage();
141
191
  }
142
192
 
143
193
  // Start the queue processing timer
@@ -237,7 +287,15 @@ export class UnifiedDeliveryQueue extends EventEmitter {
237
287
  * @param mode Processing mode
238
288
  * @param route Email route
239
289
  */
240
- public async enqueue(processingResult: any, mode: EmailProcessingMode, route?: IEmailRoute): Promise<string> {
290
+ public async enqueue(
291
+ processingResult: Email,
292
+ mode: EmailProcessingMode,
293
+ route?: IEmailRoute,
294
+ ): Promise<string> {
295
+ if (!(processingResult instanceof Email)) {
296
+ throw new Error('Queue processingResult must be an Email instance');
297
+ }
298
+
241
299
  // Check if queue is full
242
300
  if (this.queue.size >= this.options.maxQueueSize) {
243
301
  throw new Error('Queue is full');
@@ -259,14 +317,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
259
317
  updatedAt: new Date()
260
318
  };
261
319
 
262
- // Add to queue
320
+ // Commit durable state before exposing the item in memory or emitting acceptance.
321
+ await this.persistItem(item);
263
322
  this.queue.set(id, item);
264
323
 
265
- // Persist to disk if using disk storage
266
- if (this.options.storageType === 'disk') {
267
- await this.persistItem(item);
268
- }
269
-
270
324
  // Update statistics
271
325
  this.updateStats();
272
326
 
@@ -300,15 +354,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
300
354
  return false;
301
355
  }
302
356
 
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
- }
357
+ await this.applyPersistedMutation(item, (updatedItem) => {
358
+ updatedItem.status = 'processing';
359
+ updatedItem.attempts++;
360
+ updatedItem.updatedAt = new Date();
361
+ });
312
362
 
313
363
  // Update statistics
314
364
  this.updateStats();
@@ -331,15 +381,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
331
381
  return false;
332
382
  }
333
383
 
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
- }
384
+ await this.applyPersistedMutation(item, (updatedItem) => {
385
+ updatedItem.status = 'delivered';
386
+ updatedItem.updatedAt = new Date();
387
+ updatedItem.deliveredAt = new Date();
388
+ });
343
389
 
344
390
  // Update statistics
345
391
  this.totalProcessed++;
@@ -372,30 +418,23 @@ export class UnifiedDeliveryQueue extends EventEmitter {
372
418
  this.options.maxRetryDelay
373
419
  );
374
420
 
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
- }
421
+ await this.applyPersistedMutation(item, (updatedItem) => {
422
+ updatedItem.status = 'deferred';
423
+ updatedItem.lastError = error;
424
+ updatedItem.nextAttempt = new Date(Date.now() + delay);
425
+ updatedItem.updatedAt = new Date();
426
+ });
385
427
 
386
428
  // Emit event
387
429
  this.emit('itemDeferred', item);
388
430
  logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
389
431
  } else {
390
432
  // 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
- }
433
+ await this.applyPersistedMutation(item, (updatedItem) => {
434
+ updatedItem.status = 'failed';
435
+ updatedItem.lastError = error;
436
+ updatedItem.updatedAt = new Date();
437
+ });
399
438
 
400
439
  // Update statistics
401
440
  this.totalProcessed++;
@@ -422,14 +461,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
422
461
  return false;
423
462
  }
424
463
 
425
- // Remove from queue
464
+ // Delete durable state before making the removal visible in memory.
465
+ await this.removeItemFromStorage(id);
426
466
  this.queue.delete(id);
427
467
 
428
- // Remove from disk if using disk storage
429
- if (this.options.storageType === 'disk') {
430
- await this.removeItemFromDisk(id);
431
- }
432
-
433
468
  // Update statistics
434
469
  this.updateStats();
435
470
 
@@ -440,82 +475,176 @@ export class UnifiedDeliveryQueue extends EventEmitter {
440
475
  return true;
441
476
  }
442
477
 
443
- /**
444
- * Persist an item to disk
445
- * @param item Item to persist
446
- */
478
+ private storageKey(id: string): string {
479
+ return `${this.options.storagePrefix}${id}.json`;
480
+ }
481
+
447
482
  private async persistItem(item: IQueueItem): Promise<void> {
483
+ if (!this.options.storageManager) return;
484
+ await this.options.storageManager.set(
485
+ this.storageKey(item.id),
486
+ serialize(this.serializeItem(item)),
487
+ );
488
+ }
489
+
490
+ private serializeItem(item: IQueueItem): IStoredQueueItem {
491
+ return {
492
+ schemaVersion: 1,
493
+ id: item.id,
494
+ processingMode: item.processingMode,
495
+ processingResult: {
496
+ options: item.processingResult.toEmailOptions(),
497
+ messageId: item.processingResult.getMessageId(),
498
+ envelopeFrom: item.processingResult.getEnvelopeFrom(),
499
+ },
500
+ ...(item.route ? { route: item.route } : {}),
501
+ status: item.status,
502
+ attempts: item.attempts,
503
+ nextAttempt: item.nextAttempt.getTime(),
504
+ ...(item.lastError !== undefined ? { lastError: item.lastError } : {}),
505
+ createdAt: item.createdAt.getTime(),
506
+ updatedAt: item.updatedAt.getTime(),
507
+ ...(item.deliveredAt ? { deliveredAt: item.deliveredAt.getTime() } : {}),
508
+ };
509
+ }
510
+
511
+ private async applyPersistedMutation(
512
+ item: IQueueItem,
513
+ mutation: (updatedItem: IQueueItem) => void,
514
+ ): Promise<void> {
515
+ const updatedItem = { ...item };
516
+ mutation(updatedItem);
517
+ await this.persistItem(updatedItem);
518
+ Object.assign(item, updatedItem);
519
+ }
520
+
521
+ private async removeItemFromStorage(id: string): Promise<void> {
522
+ await this.options.storageManager?.delete(this.storageKey(id));
523
+ }
524
+
525
+ private deserializeItem(key: string, data: Buffer): IQueueItem {
526
+ let parsed: Record<string, any>;
448
527
  try {
449
- const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
450
- await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
528
+ parsed = deserialize(data) as Record<string, any>;
451
529
  } catch (error) {
452
- logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
453
- this.emit('error', error);
530
+ throw new Error(`Invalid managed queue item at ${key}: ${error.message}`);
454
531
  }
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);
532
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
533
+ throw new Error(`Invalid managed queue item at ${key}: missing id`);
534
+ }
535
+ if (parsed.schemaVersion !== 1) {
536
+ throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
537
+ }
538
+ if (this.storageKey(parsed.id) !== key) {
539
+ throw new Error(`Invalid managed queue item at ${key}: id does not match object key`);
540
+ }
541
+ if (!['pending', 'processing', 'delivered', 'failed', 'deferred'].includes(parsed.status)) {
542
+ throw new Error(`Invalid managed queue item at ${key}: unsupported status`);
543
+ }
544
+ if (!['forward', 'mta', 'process'].includes(parsed.processingMode)) {
545
+ throw new Error(`Invalid managed queue item at ${key}: unsupported processing mode`);
546
+ }
547
+ if (!Number.isInteger(parsed.attempts) || parsed.attempts < 0) {
548
+ throw new Error(`Invalid managed queue item at ${key}: invalid attempt count`);
549
+ }
550
+
551
+ const parseDate = (value: unknown, field: string): Date => {
552
+ if (!Number.isFinite(value) || (value as number) < 0) {
553
+ throw new Error(`Invalid managed queue item at ${key}: invalid ${field}`);
467
554
  }
468
- } catch (error) {
469
- logger.log('error', `Failed to remove item ${id} from disk: ${error.message}`);
470
- this.emit('error', error);
555
+ return new Date(value as number);
556
+ };
557
+
558
+ if (
559
+ !parsed.processingResult ||
560
+ typeof parsed.processingResult !== 'object' ||
561
+ !parsed.processingResult.options ||
562
+ typeof parsed.processingResult.options !== 'object' ||
563
+ typeof parsed.processingResult.messageId !== 'string' ||
564
+ !parsed.processingResult.messageId ||
565
+ typeof parsed.processingResult.envelopeFrom !== 'string'
566
+ ) {
567
+ throw new Error(`Invalid managed queue item at ${key}: invalid email payload`);
471
568
  }
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;
569
+
570
+ const storedOptions = parsed.processingResult.options as IEmailOptions;
571
+ if (storedOptions.attachments !== undefined) {
572
+ if (!Array.isArray(storedOptions.attachments)) {
573
+ throw new Error(`Invalid managed queue item at ${key}: invalid attachments`);
482
574
  }
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}`);
575
+ for (const attachment of storedOptions.attachments) {
576
+ if (!attachment || typeof attachment !== 'object' || !Buffer.isBuffer(attachment.content)) {
577
+ throw new Error(`Invalid managed queue item at ${key}: invalid attachment content`);
506
578
  }
507
579
  }
508
-
509
- // Update statistics
510
- this.updateStats();
511
-
512
- logger.log('info', `Loaded ${this.queue.size} items from disk`);
580
+ }
581
+
582
+ let processingResult: Email;
583
+ try {
584
+ processingResult = new Email(storedOptions)
585
+ .setMessageId(parsed.processingResult.messageId)
586
+ .setEnvelopeFrom(parsed.processingResult.envelopeFrom);
513
587
  } catch (error) {
514
- logger.log('error', `Failed to load items from disk: ${error.message}`);
515
- throw error;
588
+ throw new Error(`Invalid managed queue item at ${key}: ${error.message}`);
516
589
  }
590
+
591
+ return {
592
+ id: parsed.id,
593
+ processingMode: parsed.processingMode,
594
+ processingResult,
595
+ ...(parsed.route ? { route: parsed.route as IEmailRoute } : {}),
596
+ status: parsed.status,
597
+ attempts: parsed.attempts,
598
+ ...(typeof parsed.lastError === 'string' ? { lastError: parsed.lastError } : {}),
599
+ createdAt: parseDate(parsed.createdAt, 'createdAt'),
600
+ updatedAt: parseDate(parsed.updatedAt, 'updatedAt'),
601
+ nextAttempt: parseDate(parsed.nextAttempt, 'nextAttempt'),
602
+ ...(parsed.deliveredAt
603
+ ? { deliveredAt: parseDate(parsed.deliveredAt, 'deliveredAt') }
604
+ : {}),
605
+ } as IQueueItem;
517
606
  }
518
-
607
+
608
+ private async loadFromStorage(): Promise<void> {
609
+ const storageManager = this.options.storageManager;
610
+ if (!storageManager) return;
611
+ const keys = (await storageManager.list(this.options.storagePrefix)).sort();
612
+ const recoveredItems: IQueueItem[] = [];
613
+
614
+ for (const key of keys) {
615
+ if (!key.startsWith(this.options.storagePrefix)) {
616
+ throw new Error(`Blob storage returned a queue key outside ${this.options.storagePrefix}: ${key}`);
617
+ }
618
+ if (!key.endsWith('.json')) continue;
619
+ const data = await storageManager.get(key);
620
+ if (!data) {
621
+ throw new Error(`Managed queue item disappeared while loading: ${key}`);
622
+ }
623
+ const item = this.deserializeItem(key, data);
624
+ if (this.queue.has(item.id)) {
625
+ throw new Error(`Duplicate managed queue item id: ${item.id}`);
626
+ }
627
+ if (this.queue.size >= this.options.maxQueueSize) {
628
+ throw new Error(`Managed queue contains more than ${this.options.maxQueueSize} items`);
629
+ }
630
+ if (item.status === 'processing') {
631
+ item.status = 'deferred';
632
+ item.nextAttempt = new Date();
633
+ item.updatedAt = new Date();
634
+ item.lastError = 'Recovered after process restart during delivery';
635
+ recoveredItems.push(item);
636
+ }
637
+ this.queue.set(item.id, item);
638
+ }
639
+
640
+ await Promise.all(recoveredItems.map((item) => this.persistItem(item)));
641
+ this.updateStats();
642
+ logger.log(
643
+ 'info',
644
+ `Loaded ${this.queue.size} items from managed blob storage; recovered ${recoveredItems.length} interrupted item(s)`,
645
+ );
646
+ }
647
+
519
648
  /**
520
649
  * Update queue statistics
521
650
  */
@@ -639,8 +768,8 @@ export class UnifiedDeliveryQueue extends EventEmitter {
639
768
  // Stop processing (clears both check and cleanup timers)
640
769
  this.stopProcessing();
641
770
 
642
- // If using disk storage, make sure all items are persisted
643
- if (this.options.storageType === 'disk') {
771
+ // Persist all items when durable blob storage is configured
772
+ if (this.options.storageManager) {
644
773
  const pendingWrites: Promise<void>[] = [];
645
774
 
646
775
  for (const item of this.queue.values()) {