@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.
- package/changelog.md +20 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/mail/core/classes.bouncemanager.d.ts +5 -12
- package/dist_ts/mail/core/classes.bouncemanager.js +27 -92
- package/dist_ts/mail/core/classes.email.js +2 -2
- 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 +15 -20
- package/dist_ts/mail/delivery/classes.delivery.queue.js +196 -114
- package/dist_ts/mail/delivery/classes.delivery.system.js +5 -5
- 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 +2 -2
- package/dist_ts/mail/routing/classes.dkim.manager.js +2 -3
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +2 -2
- package/dist_ts/mail/routing/classes.dns.manager.js +1 -1
- package/dist_ts/mail/routing/classes.email.router.d.ts +2 -2
- package/dist_ts/mail/routing/classes.email.router.js +4 -5
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +8 -6
- package/dist_ts/mail/routing/classes.unified.email.server.js +12 -41
- package/dist_ts/mail/routing/interfaces.d.ts +0 -2
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +15 -45
- package/dist_ts/mail/security/classes.dkimcreator.js +66 -294
- 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 +1 -2
- package/dist_ts/security/classes.ipreputationchecker.d.ts +6 -6
- package/dist_ts/security/classes.ipreputationchecker.js +24 -84
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +3 -3
- package/package.json +2 -3
- package/readme.md +12 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +4 -0
- package/ts/mail/core/classes.bouncemanager.ts +31 -110
- package/ts/mail/core/classes.email.ts +1 -1
- package/ts/mail/core/classes.templatemanager.ts +42 -57
- package/ts/mail/delivery/classes.delivery.queue.ts +264 -135
- package/ts/mail/delivery/classes.delivery.system.ts +7 -5
- package/ts/mail/interfaces.storage.ts +64 -10
- package/ts/mail/routing/classes.dkim.manager.ts +3 -3
- package/ts/mail/routing/classes.dns.manager.ts +3 -3
- package/ts/mail/routing/classes.email.router.ts +6 -6
- package/ts/mail/routing/classes.unified.email.server.ts +23 -44
- package/ts/mail/routing/interfaces.ts +0 -2
- package/ts/mail/security/classes.dkimcreator.ts +104 -352
- package/ts/paths.ts +2 -41
- package/ts/plugins.ts +1 -6
- package/ts/security/classes.contentscanner.ts +0 -1
- package/ts/security/classes.ipreputationchecker.ts +26 -90
- 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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
4
|
-
import
|
|
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:
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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();
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
this.emit('error', error);
|
|
530
|
+
throw new Error(`Invalid managed queue item at ${key}: ${error.message}`);
|
|
454
531
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
643
|
-
if (this.options.
|
|
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()) {
|