@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,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 { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
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?: IStorageManagerLike;
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?: IStorageManagerLike;
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
- // Load suppression list from storage
137
- // Note: This is async but we can't await in constructor
138
- // The suppression list will be loaded asynchronously
139
- this.loadSuppressionList().catch(error => {
140
- logger.log('error', `Failed to load suppression list on startup: ${error.message}`);
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
- recipient: bounceData.recipient,
164
- sender: bounceData.sender,
165
- domain: bounceData.domain || bounceData.recipient.split('@')[1],
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
- // Store the bounce record
242
+ // Persist every side effect before this deterministic record becomes
243
+ // the operation's completion checkpoint.
219
244
  bounce.processed = true;
220
- this.bounceStore.push(bounce);
221
-
222
- // Update the bounce cache
223
- this.updateBounceCache(bounce);
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
- logger.log('error', `Error processing bounce: ${error.message}`, {
257
- error: error.message,
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
- // Create bounce data from SMTP failure
282
- const bounceData: Partial<BounceRecord> = {
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.message}`);
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
- // Add to suppression list permanently (no expiry)
394
- this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined);
395
-
396
- // Increment bounce count in cache
397
- this.updateBounceCache(bounce);
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 (bounce.retryCount >= this.retryStrategy.maxRetries) {
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, bounce.retryCount),
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
- // Add to suppression list temporarily (with expiry)
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
- public addToSuppressionList(
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
- try {
554
- const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
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
- try {
575
- let entries = null;
576
- let needsMigration = false;
577
-
578
- if (hasStorageManagerMethods(this.storageManager, ['get'])) {
579
- // Try to load from storage manager first
580
- const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
581
-
582
- if (suppressionData) {
583
- entries = JSON.parse(suppressionData);
584
- } else {
585
- // Check if data exists in filesystem and migrate
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
- await plugins.smartfs.file(bouncePath).write(bounceData);
657
- }
658
- } catch (error) {
659
- logger.log('error', `Failed to save bounce record: ${error.message}`);
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
- result.sender.result = await Email.emailValidator.validate(this.from, {
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 (!result.sender.result.isValid) {
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: process.cwd(),
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 TO recipients
904
- if (smartmail.options.to?.length > 0) {
905
- options.to = filterValidEmails(smartmail.options.to.map(extractEmail));
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
- // Convert CC recipients
909
- if (smartmail.options.cc?.length > 0) {
910
- options.cc = filterValidEmails(smartmail.options.cc.map(extractEmail));
914
+ const ccRecipients = smartmail.options.cc;
915
+ if (ccRecipients && ccRecipients.length > 0) {
916
+ options.cc = filterValidEmails(ccRecipients.map(extractEmail));
911
917
  }
912
918
 
913
- // Convert BCC recipients
914
- if (smartmail.options.bcc?.length > 0) {
915
- options.bcc = filterValidEmails(smartmail.options.bcc.map(extractEmail));
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?: boolean;
11
+ details: {
12
+ formatValid: boolean;
12
13
  mxRecords?: string[];
13
14
  disposable?: boolean;
14
15
  role?: boolean;
15
- spamIndicators?: string[];
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.message}`);
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
- logger.log('error', `Email validation error: ${error.message}`);
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: ${error.message}`,
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.message}`);
203
+ logger.log('error', `Error fetching MX records for ${domain}: ${getErrorMessage(error)}`);
202
204
  return [];
203
205
  }
204
206
  }