@push.rocks/smartmta 6.5.2 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +40 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/functions.errors.d.ts +3 -0
- package/dist_ts/functions.errors.js +8 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/mail/core/classes.bouncemanager.d.ts +16 -12
- package/dist_ts/mail/core/classes.bouncemanager.js +146 -129
- package/dist_ts/mail/core/classes.email.js +15 -13
- package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
- package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
- package/dist_ts/mail/core/classes.templatemanager.d.ts +10 -6
- package/dist_ts/mail/core/classes.templatemanager.js +35 -51
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +92 -22
- package/dist_ts/mail/delivery/classes.delivery.queue.js +738 -151
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
- package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
- package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
- package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
- package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
- package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
- package/dist_ts/mail/delivery/interfaces.js +1 -1
- package/dist_ts/mail/interfaces.storage.d.ts +37 -6
- package/dist_ts/mail/interfaces.storage.js +33 -3
- package/dist_ts/mail/routing/classes.dkim.manager.d.ts +10 -6
- package/dist_ts/mail/routing/classes.dkim.manager.js +47 -31
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +7 -5
- package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
- package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
- package/dist_ts/mail/routing/classes.email.router.d.ts +5 -2
- package/dist_ts/mail/routing/classes.email.router.js +17 -12
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +12 -6
- package/dist_ts/mail/routing/classes.unified.email.server.js +69 -78
- package/dist_ts/mail/routing/interfaces.d.ts +0 -2
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +22 -45
- package/dist_ts/mail/security/classes.dkimcreator.js +93 -296
- package/dist_ts/mail/security/classes.spfverifier.js +5 -3
- package/dist_ts/paths.d.ts +0 -12
- package/dist_ts/paths.js +3 -36
- package/dist_ts/plugins.d.ts +2 -5
- package/dist_ts/plugins.js +3 -6
- package/dist_ts/security/classes.contentscanner.js +14 -12
- package/dist_ts/security/classes.ipreputationchecker.d.ts +9 -6
- package/dist_ts/security/classes.ipreputationchecker.js +42 -93
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +52 -4
- package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
- package/dist_ts/security/classes.securitylogger.js +7 -5
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +2 -2
- package/package.json +8 -9
- package/readme.hints.md +4 -3
- package/readme.md +50 -18
- package/readme.plan.md +6 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/functions.errors.ts +8 -0
- package/ts/index.ts +3 -0
- package/ts/mail/core/classes.bouncemanager.ts +188 -155
- package/ts/mail/core/classes.email.ts +20 -14
- package/ts/mail/core/classes.emailvalidator.ts +9 -7
- package/ts/mail/core/classes.templatemanager.ts +42 -57
- package/ts/mail/delivery/classes.delivery.queue.ts +996 -185
- package/ts/mail/delivery/classes.delivery.system.ts +583 -170
- package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
- package/ts/mail/delivery/functions.safe-observers.ts +45 -0
- package/ts/mail/delivery/interfaces.ts +27 -1
- package/ts/mail/interfaces.storage.ts +64 -10
- package/ts/mail/routing/classes.dkim.manager.ts +65 -40
- package/ts/mail/routing/classes.dns.manager.ts +39 -16
- package/ts/mail/routing/classes.email.action.executor.ts +64 -17
- package/ts/mail/routing/classes.email.router.ts +20 -13
- package/ts/mail/routing/classes.unified.email.server.ts +107 -86
- package/ts/mail/routing/interfaces.ts +0 -2
- package/ts/mail/security/classes.dkimcreator.ts +150 -355
- package/ts/mail/security/classes.spfverifier.ts +4 -2
- package/ts/paths.ts +2 -41
- package/ts/plugins.ts +1 -6
- package/ts/security/classes.contentscanner.ts +14 -12
- package/ts/security/classes.ipreputationchecker.ts +46 -99
- package/ts/security/classes.rustsecuritybridge.ts +272 -6
- package/ts/security/classes.securitylogger.ts +6 -4
- package/ts/security/index.ts +5 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import * as paths from '../../paths.js';
|
|
3
2
|
import { logger } from '../../logger.js';
|
|
4
|
-
import {
|
|
3
|
+
import type { IStorageManager } from '../interfaces.storage.js';
|
|
5
4
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
|
6
5
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
7
6
|
import { LRUCache } from 'lru-cache';
|
|
8
7
|
import type { Email } from './classes.email.js';
|
|
8
|
+
import { callObserverSafely } from '../delivery/functions.safe-observers.js';
|
|
9
|
+
import { getErrorMessage } from '../../functions.errors.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Bounce types for categorizing the reasons for bounces
|
|
@@ -43,11 +44,17 @@ export enum BounceCategory {
|
|
|
43
44
|
UNKNOWN = 'unknown'
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// This cache only accelerates hot duplicate checks. Durable correctness always
|
|
48
|
+
// comes from re-reading the processed marker by deterministic operation ID.
|
|
49
|
+
const COMPLETED_OPERATION_CACHE_MAX = 10_000;
|
|
50
|
+
const COMPLETED_OPERATION_CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
46
52
|
/**
|
|
47
53
|
* Bounce data structure
|
|
48
54
|
*/
|
|
49
55
|
export interface BounceRecord {
|
|
50
56
|
id: string;
|
|
57
|
+
operationId?: string;
|
|
51
58
|
originalEmailId?: string;
|
|
52
59
|
recipient: string;
|
|
53
60
|
sender: string;
|
|
@@ -108,13 +115,19 @@ export class BounceManager {
|
|
|
108
115
|
expiresAt?: number; // undefined means permanent
|
|
109
116
|
}> = new Map();
|
|
110
117
|
|
|
111
|
-
private storageManager?:
|
|
118
|
+
private storageManager?: IStorageManager;
|
|
119
|
+
private initializationPromise: Promise<void>;
|
|
120
|
+
private operationPromises = new Map<string, Promise<BounceRecord>>();
|
|
121
|
+
private completedOperations = new LRUCache<string, BounceRecord>({
|
|
122
|
+
max: COMPLETED_OPERATION_CACHE_MAX,
|
|
123
|
+
ttl: COMPLETED_OPERATION_CACHE_TTL,
|
|
124
|
+
});
|
|
112
125
|
|
|
113
126
|
constructor(options?: {
|
|
114
127
|
retryStrategy?: Partial<RetryStrategy>;
|
|
115
128
|
maxCacheSize?: number;
|
|
116
129
|
cacheTTL?: number;
|
|
117
|
-
storageManager?:
|
|
130
|
+
storageManager?: IStorageManager;
|
|
118
131
|
}) {
|
|
119
132
|
// Set retry strategy with defaults
|
|
120
133
|
if (options?.retryStrategy) {
|
|
@@ -133,12 +146,11 @@ export class BounceManager {
|
|
|
133
146
|
// Store storage manager reference
|
|
134
147
|
this.storageManager = options?.storageManager;
|
|
135
148
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
149
|
+
// Capture hydration so server startup can establish a real readiness barrier.
|
|
150
|
+
// A handled mirror prevents an unhandled rejection before initialize() is awaited.
|
|
151
|
+
const hydrationPromise = this.loadSuppressionList();
|
|
152
|
+
hydrationPromise.catch(() => undefined);
|
|
153
|
+
this.initializationPromise = hydrationPromise;
|
|
142
154
|
|
|
143
155
|
// Start periodic cleanup of old bounce records (every 1 hour, removes records older than 7 days)
|
|
144
156
|
this.cleanupInterval = setInterval(() => {
|
|
@@ -149,6 +161,11 @@ export class BounceManager {
|
|
|
149
161
|
}
|
|
150
162
|
}, 60 * 60 * 1000);
|
|
151
163
|
}
|
|
164
|
+
|
|
165
|
+
/** Wait until the managed suppression state has been hydrated. */
|
|
166
|
+
public async initialize(): Promise<void> {
|
|
167
|
+
await this.initializationPromise;
|
|
168
|
+
}
|
|
152
169
|
|
|
153
170
|
/**
|
|
154
171
|
* Process a bounce notification
|
|
@@ -157,12 +174,19 @@ export class BounceManager {
|
|
|
157
174
|
*/
|
|
158
175
|
public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> {
|
|
159
176
|
try {
|
|
177
|
+
const recipient = bounceData.recipient?.trim();
|
|
178
|
+
if (!recipient) throw new Error('Bounce recipient is required');
|
|
179
|
+
const sender = bounceData.sender || '';
|
|
180
|
+
const domain = bounceData.domain || recipient.split('@')[1];
|
|
181
|
+
if (!domain) throw new Error('Bounce recipient domain is required');
|
|
182
|
+
|
|
160
183
|
// Add required fields if missing
|
|
161
184
|
const bounce: BounceRecord = {
|
|
162
185
|
id: bounceData.id || plugins.uuid.v4(),
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
186
|
+
...(bounceData.operationId ? { operationId: bounceData.operationId } : {}),
|
|
187
|
+
recipient,
|
|
188
|
+
sender,
|
|
189
|
+
domain,
|
|
166
190
|
subject: bounceData.subject,
|
|
167
191
|
bounceType: bounceData.bounceType || BounceType.UNKNOWN,
|
|
168
192
|
bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN,
|
|
@@ -215,12 +239,17 @@ export class BounceManager {
|
|
|
215
239
|
break;
|
|
216
240
|
}
|
|
217
241
|
|
|
218
|
-
//
|
|
242
|
+
// Persist every side effect before this deterministic record becomes
|
|
243
|
+
// the operation's completion checkpoint.
|
|
219
244
|
bounce.processed = true;
|
|
220
|
-
this.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
245
|
+
await this.saveBounceRecord(bounce);
|
|
246
|
+
if (!this.bounceStore.some((stored) => stored.id === bounce.id)) {
|
|
247
|
+
this.bounceStore.push(bounce);
|
|
248
|
+
this.updateBounceCache(bounce);
|
|
249
|
+
}
|
|
250
|
+
if (bounce.operationId) {
|
|
251
|
+
this.completedOperations.set(bounce.operationId, bounce);
|
|
252
|
+
}
|
|
224
253
|
|
|
225
254
|
// Log the bounce
|
|
226
255
|
logger.log(
|
|
@@ -234,7 +263,7 @@ export class BounceManager {
|
|
|
234
263
|
);
|
|
235
264
|
|
|
236
265
|
// Enhanced security logging
|
|
237
|
-
SecurityLogger.getInstance().logEvent({
|
|
266
|
+
await callObserverSafely('bounce security log', () => SecurityLogger.getInstance().logEvent({
|
|
238
267
|
level: bounce.bounceCategory === BounceCategory.HARD
|
|
239
268
|
? SecurityLogLevel.WARN
|
|
240
269
|
: SecurityLogLevel.INFO,
|
|
@@ -249,12 +278,13 @@ export class BounceManager {
|
|
|
249
278
|
statusCode: bounce.statusCode
|
|
250
279
|
},
|
|
251
280
|
success: false
|
|
252
|
-
});
|
|
253
|
-
|
|
281
|
+
}));
|
|
282
|
+
|
|
254
283
|
return bounce;
|
|
255
284
|
} catch (error) {
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
const errorMessage = getErrorMessage(error);
|
|
286
|
+
logger.log('error', `Error processing bounce: ${errorMessage}`, {
|
|
287
|
+
error: errorMessage,
|
|
258
288
|
bounceData
|
|
259
289
|
});
|
|
260
290
|
throw error;
|
|
@@ -272,14 +302,66 @@ export class BounceManager {
|
|
|
272
302
|
recipient: string,
|
|
273
303
|
smtpResponse: string,
|
|
274
304
|
options: {
|
|
305
|
+
operationId?: string;
|
|
275
306
|
sender?: string;
|
|
276
307
|
originalEmailId?: string;
|
|
277
308
|
statusCode?: string;
|
|
278
309
|
headers?: Record<string, string>;
|
|
279
310
|
} = {}
|
|
280
311
|
): Promise<BounceRecord> {
|
|
281
|
-
|
|
282
|
-
|
|
312
|
+
const operationId = options.operationId;
|
|
313
|
+
if (!operationId) {
|
|
314
|
+
return this.processBounce({
|
|
315
|
+
recipient,
|
|
316
|
+
sender: options.sender || '',
|
|
317
|
+
domain: recipient.split('@')[1],
|
|
318
|
+
smtpResponse,
|
|
319
|
+
statusCode: options.statusCode,
|
|
320
|
+
headers: options.headers,
|
|
321
|
+
originalEmailId: options.originalEmailId,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const completed = this.completedOperations.get(operationId);
|
|
327
|
+
if (completed) return completed;
|
|
328
|
+
const active = this.operationPromises.get(operationId);
|
|
329
|
+
if (active) return active;
|
|
330
|
+
|
|
331
|
+
const operation = this.processSmtpFailureOperation(
|
|
332
|
+
operationId,
|
|
333
|
+
recipient,
|
|
334
|
+
smtpResponse,
|
|
335
|
+
options,
|
|
336
|
+
);
|
|
337
|
+
this.operationPromises.set(operationId, operation);
|
|
338
|
+
try {
|
|
339
|
+
return await operation;
|
|
340
|
+
} finally {
|
|
341
|
+
this.operationPromises.delete(operationId);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async processSmtpFailureOperation(
|
|
346
|
+
operationId: string,
|
|
347
|
+
recipient: string,
|
|
348
|
+
smtpResponse: string,
|
|
349
|
+
options: {
|
|
350
|
+
sender?: string;
|
|
351
|
+
originalEmailId?: string;
|
|
352
|
+
statusCode?: string;
|
|
353
|
+
headers?: Record<string, string>;
|
|
354
|
+
},
|
|
355
|
+
): Promise<BounceRecord> {
|
|
356
|
+
const stored = await this.readProcessedBounce(operationId);
|
|
357
|
+
if (stored) {
|
|
358
|
+
this.completedOperations.set(operationId, stored);
|
|
359
|
+
return stored;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return this.processBounce({
|
|
363
|
+
id: operationId,
|
|
364
|
+
operationId,
|
|
283
365
|
recipient,
|
|
284
366
|
sender: options.sender || '',
|
|
285
367
|
domain: recipient.split('@')[1],
|
|
@@ -287,13 +369,23 @@ export class BounceManager {
|
|
|
287
369
|
statusCode: options.statusCode,
|
|
288
370
|
headers: options.headers,
|
|
289
371
|
originalEmailId: options.originalEmailId,
|
|
290
|
-
timestamp: Date.now()
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
// Process as a regular bounce
|
|
294
|
-
return this.processBounce(bounceData);
|
|
372
|
+
timestamp: Date.now(),
|
|
373
|
+
});
|
|
295
374
|
}
|
|
296
|
-
|
|
375
|
+
|
|
376
|
+
private async readProcessedBounce(operationId: string): Promise<BounceRecord | null> {
|
|
377
|
+
if (!this.storageManager) return null;
|
|
378
|
+
const serialized = await this.storageManager.get(
|
|
379
|
+
`/email/bounces/records/${operationId}.json`,
|
|
380
|
+
);
|
|
381
|
+
if (!serialized) return null;
|
|
382
|
+
const bounce = JSON.parse(serialized) as BounceRecord;
|
|
383
|
+
if (!bounce.processed || bounce.operationId !== operationId || bounce.id !== operationId) {
|
|
384
|
+
throw new Error(`Invalid processed bounce checkpoint for operation ${operationId}`);
|
|
385
|
+
}
|
|
386
|
+
return bounce;
|
|
387
|
+
}
|
|
388
|
+
|
|
297
389
|
/**
|
|
298
390
|
* Process a bounce notification email
|
|
299
391
|
* @param bounceEmail The email containing bounce information
|
|
@@ -380,7 +472,7 @@ export class BounceManager {
|
|
|
380
472
|
// Process as a regular bounce
|
|
381
473
|
return this.processBounce(bounceData);
|
|
382
474
|
} catch (error) {
|
|
383
|
-
logger.log('error', `Error processing bounce email: ${error
|
|
475
|
+
logger.log('error', `Error processing bounce email: ${getErrorMessage(error)}`);
|
|
384
476
|
return null;
|
|
385
477
|
}
|
|
386
478
|
}
|
|
@@ -390,14 +482,11 @@ export class BounceManager {
|
|
|
390
482
|
* @param bounce The bounce record
|
|
391
483
|
*/
|
|
392
484
|
private async handleHardBounce(bounce: BounceRecord): Promise<void> {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// Save to permanent storage
|
|
400
|
-
await this.saveBounceRecord(bounce);
|
|
485
|
+
await this.addToSuppressionListAndPersist(
|
|
486
|
+
bounce.recipient,
|
|
487
|
+
`Hard bounce: ${bounce.bounceType}`,
|
|
488
|
+
undefined,
|
|
489
|
+
);
|
|
401
490
|
|
|
402
491
|
// Log hard bounce for monitoring
|
|
403
492
|
logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, {
|
|
@@ -412,8 +501,9 @@ export class BounceManager {
|
|
|
412
501
|
* @param bounce The bounce record
|
|
413
502
|
*/
|
|
414
503
|
private async handleSoftBounce(bounce: BounceRecord): Promise<void> {
|
|
504
|
+
const retryCount = bounce.retryCount ?? 0;
|
|
415
505
|
// Check if we've exceeded max retries
|
|
416
|
-
if (
|
|
506
|
+
if (retryCount >= this.retryStrategy.maxRetries) {
|
|
417
507
|
logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`);
|
|
418
508
|
|
|
419
509
|
// Convert to hard bounce after max retries
|
|
@@ -424,18 +514,17 @@ export class BounceManager {
|
|
|
424
514
|
|
|
425
515
|
// Calculate next retry time with exponential backoff
|
|
426
516
|
const delay = Math.min(
|
|
427
|
-
this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor,
|
|
517
|
+
this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, retryCount),
|
|
428
518
|
this.retryStrategy.maxDelay
|
|
429
519
|
);
|
|
430
520
|
|
|
431
|
-
bounce.retryCount
|
|
521
|
+
bounce.retryCount = retryCount + 1;
|
|
432
522
|
bounce.nextRetryTime = Date.now() + delay;
|
|
433
523
|
|
|
434
|
-
|
|
435
|
-
this.addToSuppressionList(
|
|
524
|
+
await this.addToSuppressionListAndPersist(
|
|
436
525
|
bounce.recipient,
|
|
437
526
|
`Soft bounce: ${bounce.bounceType}`,
|
|
438
|
-
bounce.nextRetryTime
|
|
527
|
+
bounce.nextRetryTime,
|
|
439
528
|
);
|
|
440
529
|
|
|
441
530
|
// Log the retry schedule
|
|
@@ -452,16 +541,37 @@ export class BounceManager {
|
|
|
452
541
|
* @param reason Reason for suppression
|
|
453
542
|
* @param expiresAt Expiration timestamp (undefined for permanent)
|
|
454
543
|
*/
|
|
455
|
-
|
|
544
|
+
private setSuppressionListEntry(
|
|
456
545
|
email: string,
|
|
457
546
|
reason: string,
|
|
458
|
-
expiresAt?: number
|
|
547
|
+
expiresAt?: number,
|
|
459
548
|
): void {
|
|
460
549
|
this.suppressionList.set(email.toLowerCase(), {
|
|
461
550
|
reason,
|
|
462
551
|
timestamp: Date.now(),
|
|
463
|
-
expiresAt
|
|
552
|
+
expiresAt,
|
|
464
553
|
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async addToSuppressionListAndPersist(
|
|
557
|
+
email: string,
|
|
558
|
+
reason: string,
|
|
559
|
+
expiresAt?: number,
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
this.setSuppressionListEntry(email, reason, expiresAt);
|
|
562
|
+
await this.saveSuppressionList();
|
|
563
|
+
logger.log('info', `Added ${email} to suppression list`, {
|
|
564
|
+
reason,
|
|
565
|
+
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
public addToSuppressionList(
|
|
570
|
+
email: string,
|
|
571
|
+
reason: string,
|
|
572
|
+
expiresAt?: number
|
|
573
|
+
): void {
|
|
574
|
+
this.setSuppressionListEntry(email, reason, expiresAt);
|
|
465
575
|
|
|
466
576
|
// Save asynchronously without blocking
|
|
467
577
|
this.saveSuppressionList().catch(error => {
|
|
@@ -546,120 +656,42 @@ export class BounceManager {
|
|
|
546
656
|
return suppression;
|
|
547
657
|
}
|
|
548
658
|
|
|
549
|
-
/**
|
|
550
|
-
* Save suppression list to disk
|
|
551
|
-
*/
|
|
659
|
+
/** Save the suppression list through managed storage when durability is configured. */
|
|
552
660
|
private async saveSuppressionList(): Promise<void> {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (hasStorageManagerMethods(this.storageManager, ['set'])) {
|
|
557
|
-
// Use storage manager
|
|
558
|
-
await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
|
|
559
|
-
} else {
|
|
560
|
-
// Fall back to filesystem
|
|
561
|
-
await plugins.smartfs.file(
|
|
562
|
-
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
|
|
563
|
-
).write(suppressionData);
|
|
564
|
-
}
|
|
565
|
-
} catch (error) {
|
|
566
|
-
logger.log('error', `Failed to save suppression list: ${error.message}`);
|
|
567
|
-
}
|
|
661
|
+
if (!this.storageManager) return;
|
|
662
|
+
const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
|
|
663
|
+
await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
|
|
568
664
|
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Load suppression list from disk
|
|
572
|
-
*/
|
|
665
|
+
|
|
666
|
+
/** Load the suppression list from managed storage. */
|
|
573
667
|
private async loadSuppressionList(): Promise<void> {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
|
587
|
-
|
|
588
|
-
if (plugins.fs.existsSync(suppressionPath)) {
|
|
589
|
-
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
|
590
|
-
entries = JSON.parse(data);
|
|
591
|
-
needsMigration = true;
|
|
592
|
-
|
|
593
|
-
logger.log('info', 'Migrating suppression list from filesystem to StorageManager');
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
} else {
|
|
597
|
-
// No storage manager, use filesystem directly
|
|
598
|
-
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
|
599
|
-
|
|
600
|
-
if (plugins.fs.existsSync(suppressionPath)) {
|
|
601
|
-
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
|
602
|
-
entries = JSON.parse(data);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (entries) {
|
|
607
|
-
this.suppressionList = new Map(entries);
|
|
608
|
-
|
|
609
|
-
// Clean expired entries
|
|
610
|
-
const now = Date.now();
|
|
611
|
-
let expiredCount = 0;
|
|
612
|
-
|
|
613
|
-
for (const [email, info] of this.suppressionList.entries()) {
|
|
614
|
-
if (info.expiresAt && now > info.expiresAt) {
|
|
615
|
-
this.suppressionList.delete(email);
|
|
616
|
-
expiredCount++;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
if (expiredCount > 0 || needsMigration) {
|
|
621
|
-
logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`);
|
|
622
|
-
await this.saveSuppressionList();
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`);
|
|
668
|
+
if (!this.storageManager) return;
|
|
669
|
+
const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
|
|
670
|
+
if (!suppressionData) return;
|
|
671
|
+
|
|
672
|
+
const entries = JSON.parse(suppressionData);
|
|
673
|
+
this.suppressionList = new Map(entries);
|
|
674
|
+
const now = Date.now();
|
|
675
|
+
let expiredCount = 0;
|
|
676
|
+
for (const [email, info] of this.suppressionList.entries()) {
|
|
677
|
+
if (info.expiresAt && now > info.expiresAt) {
|
|
678
|
+
this.suppressionList.delete(email);
|
|
679
|
+
expiredCount++;
|
|
626
680
|
}
|
|
627
|
-
} catch (error) {
|
|
628
|
-
logger.log('error', `Failed to load suppression list: ${error.message}`);
|
|
629
681
|
}
|
|
682
|
+
if (expiredCount > 0) await this.saveSuppressionList();
|
|
683
|
+
logger.log('info', `Loaded ${this.suppressionList.size} suppression entries`);
|
|
630
684
|
}
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Save bounce record to disk
|
|
634
|
-
* @param bounce Bounce record to save
|
|
635
|
-
*/
|
|
636
|
-
private async saveBounceRecord(bounce: BounceRecord): Promise<void> {
|
|
637
|
-
try {
|
|
638
|
-
const bounceData = JSON.stringify(bounce, null, 2);
|
|
639
|
-
|
|
640
|
-
if (hasStorageManagerMethods(this.storageManager, ['set'])) {
|
|
641
|
-
// Use storage manager
|
|
642
|
-
await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData);
|
|
643
|
-
} else {
|
|
644
|
-
// Fall back to filesystem
|
|
645
|
-
const bouncePath = plugins.path.join(
|
|
646
|
-
paths.dataDir,
|
|
647
|
-
'emails',
|
|
648
|
-
'bounces',
|
|
649
|
-
`${bounce.id}.json`
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
// Ensure directory exists
|
|
653
|
-
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
|
|
654
|
-
await plugins.smartfs.directory(bounceDir).recursive().create();
|
|
655
685
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
686
|
+
/** Save one bounce record through managed storage when configured. */
|
|
687
|
+
private async saveBounceRecord(bounce: BounceRecord): Promise<void> {
|
|
688
|
+
if (!this.storageManager) return;
|
|
689
|
+
await this.storageManager.set(
|
|
690
|
+
`/email/bounces/records/${bounce.id}.json`,
|
|
691
|
+
JSON.stringify(bounce),
|
|
692
|
+
);
|
|
661
693
|
}
|
|
662
|
-
|
|
694
|
+
|
|
663
695
|
/**
|
|
664
696
|
* Update bounce cache with new bounce information
|
|
665
697
|
* @param bounce Bounce record to update cache with
|
|
@@ -750,5 +782,6 @@ export class BounceManager {
|
|
|
750
782
|
clearInterval(this.cleanupInterval);
|
|
751
783
|
this.cleanupInterval = undefined;
|
|
752
784
|
}
|
|
785
|
+
this.completedOperations.clear();
|
|
753
786
|
}
|
|
754
787
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import { EmailValidator } from './classes.emailvalidator.js';
|
|
2
|
+
import { EmailValidator, type IEmailValidationResult } from './classes.emailvalidator.js';
|
|
3
3
|
|
|
4
4
|
export interface IAttachment {
|
|
5
5
|
filename: string;
|
|
@@ -512,20 +512,25 @@ export class Email {
|
|
|
512
512
|
recipients: Array<{ email: string; result: any }>;
|
|
513
513
|
isValid: boolean;
|
|
514
514
|
}> {
|
|
515
|
-
const result
|
|
515
|
+
const result: {
|
|
516
|
+
sender: { email: string; result: IEmailValidationResult | null };
|
|
517
|
+
recipients: Array<{ email: string; result: IEmailValidationResult }>;
|
|
518
|
+
isValid: boolean;
|
|
519
|
+
} = {
|
|
516
520
|
sender: { email: this.from, result: null },
|
|
517
521
|
recipients: [],
|
|
518
522
|
isValid: true
|
|
519
523
|
};
|
|
520
524
|
|
|
521
525
|
// Validate sender
|
|
522
|
-
|
|
526
|
+
const senderResult = await Email.emailValidator.validate(this.from, {
|
|
523
527
|
checkMx: options.checkMx !== false,
|
|
524
528
|
checkDisposable: options.checkDisposable !== false
|
|
525
529
|
});
|
|
530
|
+
result.sender.result = senderResult;
|
|
526
531
|
|
|
527
532
|
// If sender fails validation, the whole email is considered invalid
|
|
528
|
-
if (!
|
|
533
|
+
if (!senderResult.isValid) {
|
|
529
534
|
result.isValid = false;
|
|
530
535
|
}
|
|
531
536
|
|
|
@@ -617,7 +622,7 @@ export class Email {
|
|
|
617
622
|
const smartAttachment = new plugins.smartfile.SmartFile({
|
|
618
623
|
path: attachment.filename,
|
|
619
624
|
contentBuffer: attachment.content,
|
|
620
|
-
base:
|
|
625
|
+
base: '.',
|
|
621
626
|
});
|
|
622
627
|
|
|
623
628
|
// Set content type if available
|
|
@@ -900,19 +905,20 @@ export class Email {
|
|
|
900
905
|
return emails.filter(email => email && email.length > 0);
|
|
901
906
|
};
|
|
902
907
|
|
|
903
|
-
// Convert
|
|
904
|
-
|
|
905
|
-
|
|
908
|
+
// Convert recipients from Smartmail's optional arrays.
|
|
909
|
+
const toRecipients = smartmail.options.to;
|
|
910
|
+
if (toRecipients && toRecipients.length > 0) {
|
|
911
|
+
options.to = filterValidEmails(toRecipients.map(extractEmail));
|
|
906
912
|
}
|
|
907
913
|
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
options.cc = filterValidEmails(
|
|
914
|
+
const ccRecipients = smartmail.options.cc;
|
|
915
|
+
if (ccRecipients && ccRecipients.length > 0) {
|
|
916
|
+
options.cc = filterValidEmails(ccRecipients.map(extractEmail));
|
|
911
917
|
}
|
|
912
918
|
|
|
913
|
-
|
|
914
|
-
if (
|
|
915
|
-
options.bcc = filterValidEmails(
|
|
919
|
+
const bccRecipients = smartmail.options.bcc;
|
|
920
|
+
if (bccRecipients && bccRecipients.length > 0) {
|
|
921
|
+
options.bcc = filterValidEmails(bccRecipients.map(extractEmail));
|
|
916
922
|
}
|
|
917
923
|
|
|
918
924
|
// Convert attachments (note: this handles the synchronous case only)
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { logger } from '../../logger.js';
|
|
3
3
|
import { LRUCache } from 'lru-cache';
|
|
4
|
+
import { getErrorMessage } from '../../functions.errors.js';
|
|
4
5
|
|
|
5
6
|
export interface IEmailValidationResult {
|
|
6
7
|
isValid: boolean;
|
|
7
8
|
hasMx: boolean;
|
|
8
9
|
hasSpamMarkings: boolean;
|
|
9
10
|
score: number;
|
|
10
|
-
details
|
|
11
|
-
formatValid
|
|
11
|
+
details: {
|
|
12
|
+
formatValid: boolean;
|
|
12
13
|
mxRecords?: string[];
|
|
13
14
|
disposable?: boolean;
|
|
14
15
|
role?: boolean;
|
|
15
|
-
spamIndicators
|
|
16
|
+
spamIndicators: string[];
|
|
16
17
|
errorMessage?: string;
|
|
17
18
|
};
|
|
18
19
|
}
|
|
@@ -102,7 +103,7 @@ export class EmailValidator {
|
|
|
102
103
|
result.details.errorMessage = 'Domain has no MX records';
|
|
103
104
|
}
|
|
104
105
|
} catch (error) {
|
|
105
|
-
logger.log('error', `Error checking MX records: ${error
|
|
106
|
+
logger.log('error', `Error checking MX records: ${getErrorMessage(error)}`);
|
|
106
107
|
result.details.errorMessage = 'Unable to check MX records';
|
|
107
108
|
}
|
|
108
109
|
}
|
|
@@ -160,7 +161,8 @@ export class EmailValidator {
|
|
|
160
161
|
|
|
161
162
|
return result;
|
|
162
163
|
} catch (error) {
|
|
163
|
-
|
|
164
|
+
const errorMessage = getErrorMessage(error);
|
|
165
|
+
logger.log('error', `Email validation error: ${errorMessage}`);
|
|
164
166
|
return {
|
|
165
167
|
isValid: false,
|
|
166
168
|
hasMx: false,
|
|
@@ -168,7 +170,7 @@ export class EmailValidator {
|
|
|
168
170
|
score: 0,
|
|
169
171
|
details: {
|
|
170
172
|
formatValid: false,
|
|
171
|
-
errorMessage: `Validation error: ${
|
|
173
|
+
errorMessage: `Validation error: ${errorMessage}`,
|
|
172
174
|
spamIndicators: ['Validation error']
|
|
173
175
|
}
|
|
174
176
|
};
|
|
@@ -198,7 +200,7 @@ export class EmailValidator {
|
|
|
198
200
|
|
|
199
201
|
return records;
|
|
200
202
|
} catch (error) {
|
|
201
|
-
logger.log('error', `Error fetching MX records for ${domain}: ${error
|
|
203
|
+
logger.log('error', `Error fetching MX records for ${domain}: ${getErrorMessage(error)}`);
|
|
202
204
|
return [];
|
|
203
205
|
}
|
|
204
206
|
}
|