@push.rocks/smartmta 5.1.3 → 5.2.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 (98) hide show
  1. package/changelog.md +7 -0
  2. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  3. package/dist_ts/00_commitinfo_data.js +9 -0
  4. package/dist_ts/index.d.ts +3 -0
  5. package/dist_ts/index.js +4 -0
  6. package/dist_ts/logger.d.ts +17 -0
  7. package/dist_ts/logger.js +76 -0
  8. package/dist_ts/mail/core/classes.bouncemanager.d.ts +185 -0
  9. package/dist_ts/mail/core/classes.bouncemanager.js +569 -0
  10. package/dist_ts/mail/core/classes.email.d.ts +291 -0
  11. package/dist_ts/mail/core/classes.email.js +802 -0
  12. package/dist_ts/mail/core/classes.emailvalidator.d.ts +61 -0
  13. package/dist_ts/mail/core/classes.emailvalidator.js +184 -0
  14. package/dist_ts/mail/core/classes.templatemanager.d.ts +95 -0
  15. package/dist_ts/mail/core/classes.templatemanager.js +240 -0
  16. package/dist_ts/mail/core/index.d.ts +4 -0
  17. package/dist_ts/mail/core/index.js +6 -0
  18. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +163 -0
  19. package/dist_ts/mail/delivery/classes.delivery.queue.js +488 -0
  20. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +160 -0
  21. package/dist_ts/mail/delivery/classes.delivery.system.js +630 -0
  22. package/dist_ts/mail/delivery/classes.unified.rate.limiter.d.ts +200 -0
  23. package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +820 -0
  24. package/dist_ts/mail/delivery/index.d.ts +4 -0
  25. package/dist_ts/mail/delivery/index.js +6 -0
  26. package/dist_ts/mail/delivery/interfaces.d.ts +140 -0
  27. package/dist_ts/mail/delivery/interfaces.js +17 -0
  28. package/dist_ts/mail/index.d.ts +7 -0
  29. package/dist_ts/mail/index.js +12 -0
  30. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +25 -0
  31. package/dist_ts/mail/routing/classes.dkim.manager.js +127 -0
  32. package/dist_ts/mail/routing/classes.dns.manager.d.ts +79 -0
  33. package/dist_ts/mail/routing/classes.dns.manager.js +415 -0
  34. package/dist_ts/mail/routing/classes.domain.registry.d.ts +54 -0
  35. package/dist_ts/mail/routing/classes.domain.registry.js +119 -0
  36. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +33 -0
  37. package/dist_ts/mail/routing/classes.email.action.executor.js +137 -0
  38. package/dist_ts/mail/routing/classes.email.router.d.ts +171 -0
  39. package/dist_ts/mail/routing/classes.email.router.js +494 -0
  40. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +241 -0
  41. package/dist_ts/mail/routing/classes.unified.email.server.js +935 -0
  42. package/dist_ts/mail/routing/index.d.ts +7 -0
  43. package/dist_ts/mail/routing/index.js +9 -0
  44. package/dist_ts/mail/routing/interfaces.d.ts +187 -0
  45. package/dist_ts/mail/routing/interfaces.js +2 -0
  46. package/dist_ts/mail/security/classes.dkimcreator.d.ts +72 -0
  47. package/dist_ts/mail/security/classes.dkimcreator.js +360 -0
  48. package/dist_ts/mail/security/classes.spfverifier.d.ts +62 -0
  49. package/dist_ts/mail/security/classes.spfverifier.js +87 -0
  50. package/dist_ts/mail/security/index.d.ts +2 -0
  51. package/dist_ts/mail/security/index.js +4 -0
  52. package/dist_ts/paths.d.ts +14 -0
  53. package/dist_ts/paths.js +39 -0
  54. package/dist_ts/plugins.d.ts +24 -0
  55. package/dist_ts/plugins.js +28 -0
  56. package/dist_ts/security/classes.contentscanner.d.ts +130 -0
  57. package/dist_ts/security/classes.contentscanner.js +338 -0
  58. package/dist_ts/security/classes.ipreputationchecker.d.ts +73 -0
  59. package/dist_ts/security/classes.ipreputationchecker.js +263 -0
  60. package/dist_ts/security/classes.rustsecuritybridge.d.ts +398 -0
  61. package/dist_ts/security/classes.rustsecuritybridge.js +484 -0
  62. package/dist_ts/security/classes.securitylogger.d.ts +140 -0
  63. package/dist_ts/security/classes.securitylogger.js +235 -0
  64. package/dist_ts/security/index.d.ts +4 -0
  65. package/dist_ts/security/index.js +5 -0
  66. package/package.json +6 -1
  67. package/ts/00_commitinfo_data.ts +8 -0
  68. package/ts/index.ts +3 -0
  69. package/ts/logger.ts +91 -0
  70. package/ts/mail/core/classes.bouncemanager.ts +731 -0
  71. package/ts/mail/core/classes.email.ts +942 -0
  72. package/ts/mail/core/classes.emailvalidator.ts +239 -0
  73. package/ts/mail/core/classes.templatemanager.ts +320 -0
  74. package/ts/mail/core/index.ts +5 -0
  75. package/ts/mail/delivery/classes.delivery.queue.ts +645 -0
  76. package/ts/mail/delivery/classes.delivery.system.ts +816 -0
  77. package/ts/mail/delivery/classes.unified.rate.limiter.ts +1053 -0
  78. package/ts/mail/delivery/index.ts +5 -0
  79. package/ts/mail/delivery/interfaces.ts +167 -0
  80. package/ts/mail/index.ts +17 -0
  81. package/ts/mail/routing/classes.dkim.manager.ts +157 -0
  82. package/ts/mail/routing/classes.dns.manager.ts +573 -0
  83. package/ts/mail/routing/classes.domain.registry.ts +139 -0
  84. package/ts/mail/routing/classes.email.action.executor.ts +175 -0
  85. package/ts/mail/routing/classes.email.router.ts +575 -0
  86. package/ts/mail/routing/classes.unified.email.server.ts +1207 -0
  87. package/ts/mail/routing/index.ts +9 -0
  88. package/ts/mail/routing/interfaces.ts +202 -0
  89. package/ts/mail/security/classes.dkimcreator.ts +447 -0
  90. package/ts/mail/security/classes.spfverifier.ts +126 -0
  91. package/ts/mail/security/index.ts +3 -0
  92. package/ts/paths.ts +48 -0
  93. package/ts/plugins.ts +53 -0
  94. package/ts/security/classes.contentscanner.ts +400 -0
  95. package/ts/security/classes.ipreputationchecker.ts +315 -0
  96. package/ts/security/classes.rustsecuritybridge.ts +943 -0
  97. package/ts/security/classes.securitylogger.ts +299 -0
  98. package/ts/security/index.ts +40 -0
@@ -0,0 +1,731 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import * as paths from '../../paths.js';
3
+ import { logger } from '../../logger.js';
4
+ import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
5
+ import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
6
+ import { LRUCache } from 'lru-cache';
7
+ import type { Email } from './classes.email.js';
8
+
9
+ /**
10
+ * Bounce types for categorizing the reasons for bounces
11
+ */
12
+ export enum BounceType {
13
+ // Hard bounces (permanent failures)
14
+ INVALID_RECIPIENT = 'invalid_recipient',
15
+ DOMAIN_NOT_FOUND = 'domain_not_found',
16
+ MAILBOX_FULL = 'mailbox_full',
17
+ MAILBOX_INACTIVE = 'mailbox_inactive',
18
+ BLOCKED = 'blocked',
19
+ SPAM_RELATED = 'spam_related',
20
+ POLICY_RELATED = 'policy_related',
21
+
22
+ // Soft bounces (temporary failures)
23
+ SERVER_UNAVAILABLE = 'server_unavailable',
24
+ TEMPORARY_FAILURE = 'temporary_failure',
25
+ QUOTA_EXCEEDED = 'quota_exceeded',
26
+ NETWORK_ERROR = 'network_error',
27
+ TIMEOUT = 'timeout',
28
+
29
+ // Special cases
30
+ AUTO_RESPONSE = 'auto_response',
31
+ CHALLENGE_RESPONSE = 'challenge_response',
32
+ UNKNOWN = 'unknown'
33
+ }
34
+
35
+ /**
36
+ * Hard vs soft bounce classification
37
+ */
38
+ export enum BounceCategory {
39
+ HARD = 'hard',
40
+ SOFT = 'soft',
41
+ AUTO_RESPONSE = 'auto_response',
42
+ UNKNOWN = 'unknown'
43
+ }
44
+
45
+ /**
46
+ * Bounce data structure
47
+ */
48
+ export interface BounceRecord {
49
+ id: string;
50
+ originalEmailId?: string;
51
+ recipient: string;
52
+ sender: string;
53
+ domain: string;
54
+ subject?: string;
55
+ bounceType: BounceType;
56
+ bounceCategory: BounceCategory;
57
+ timestamp: number;
58
+ smtpResponse?: string;
59
+ diagnosticCode?: string;
60
+ statusCode?: string;
61
+ headers?: Record<string, string>;
62
+ processed: boolean;
63
+ retryCount?: number;
64
+ nextRetryTime?: number;
65
+ }
66
+
67
+ /**
68
+ * Retry strategy configuration for soft bounces
69
+ */
70
+ interface RetryStrategy {
71
+ maxRetries: number;
72
+ initialDelay: number; // milliseconds
73
+ maxDelay: number; // milliseconds
74
+ backoffFactor: number;
75
+ }
76
+
77
+ /**
78
+ * Manager for handling email bounces
79
+ */
80
+ export class BounceManager {
81
+ // Retry strategy with exponential backoff
82
+ private retryStrategy: RetryStrategy = {
83
+ maxRetries: 5,
84
+ initialDelay: 15 * 60 * 1000, // 15 minutes
85
+ maxDelay: 24 * 60 * 60 * 1000, // 24 hours
86
+ backoffFactor: 2
87
+ };
88
+
89
+ // Store of bounced emails
90
+ private bounceStore: BounceRecord[] = [];
91
+
92
+ // Cache of recently bounced email addresses to avoid sending to known bad addresses
93
+ private bounceCache: LRUCache<string, {
94
+ lastBounce: number;
95
+ count: number;
96
+ type: BounceType;
97
+ category: BounceCategory;
98
+ }>;
99
+
100
+ // Suppression list for addresses that should not receive emails
101
+ private suppressionList: Map<string, {
102
+ reason: string;
103
+ timestamp: number;
104
+ expiresAt?: number; // undefined means permanent
105
+ }> = new Map();
106
+
107
+ private storageManager?: any; // StorageManager instance
108
+
109
+ constructor(options?: {
110
+ retryStrategy?: Partial<RetryStrategy>;
111
+ maxCacheSize?: number;
112
+ cacheTTL?: number;
113
+ storageManager?: any;
114
+ }) {
115
+ // Set retry strategy with defaults
116
+ if (options?.retryStrategy) {
117
+ this.retryStrategy = {
118
+ ...this.retryStrategy,
119
+ ...options.retryStrategy
120
+ };
121
+ }
122
+
123
+ // Initialize bounce cache with LRU (least recently used) caching
124
+ this.bounceCache = new LRUCache<string, any>({
125
+ max: options?.maxCacheSize || 10000,
126
+ ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default
127
+ });
128
+
129
+ // Store storage manager reference
130
+ this.storageManager = options?.storageManager;
131
+
132
+ // Load suppression list from storage
133
+ // Note: This is async but we can't await in constructor
134
+ // The suppression list will be loaded asynchronously
135
+ this.loadSuppressionList().catch(error => {
136
+ logger.log('error', `Failed to load suppression list on startup: ${error.message}`);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Process a bounce notification
142
+ * @param bounceData Bounce data to process
143
+ * @returns Processed bounce record
144
+ */
145
+ public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> {
146
+ try {
147
+ // Add required fields if missing
148
+ const bounce: BounceRecord = {
149
+ id: bounceData.id || plugins.uuid.v4(),
150
+ recipient: bounceData.recipient,
151
+ sender: bounceData.sender,
152
+ domain: bounceData.domain || bounceData.recipient.split('@')[1],
153
+ subject: bounceData.subject,
154
+ bounceType: bounceData.bounceType || BounceType.UNKNOWN,
155
+ bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN,
156
+ timestamp: bounceData.timestamp || Date.now(),
157
+ smtpResponse: bounceData.smtpResponse,
158
+ diagnosticCode: bounceData.diagnosticCode,
159
+ statusCode: bounceData.statusCode,
160
+ headers: bounceData.headers,
161
+ processed: false,
162
+ originalEmailId: bounceData.originalEmailId,
163
+ retryCount: bounceData.retryCount || 0,
164
+ nextRetryTime: bounceData.nextRetryTime
165
+ };
166
+
167
+ // Determine bounce type and category via Rust bridge if not provided
168
+ if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
169
+ const bridge = RustSecurityBridge.getInstance();
170
+ const rustResult = await bridge.detectBounce({
171
+ smtpResponse: bounce.smtpResponse,
172
+ diagnosticCode: bounce.diagnosticCode,
173
+ statusCode: bounce.statusCode,
174
+ });
175
+ bounce.bounceType = rustResult.bounce_type as BounceType;
176
+ bounce.bounceCategory = rustResult.category as BounceCategory;
177
+ }
178
+
179
+ // Process the bounce based on category
180
+ switch (bounce.bounceCategory) {
181
+ case BounceCategory.HARD:
182
+ // Handle hard bounce - add to suppression list
183
+ await this.handleHardBounce(bounce);
184
+ break;
185
+
186
+ case BounceCategory.SOFT:
187
+ // Handle soft bounce - schedule retry if eligible
188
+ await this.handleSoftBounce(bounce);
189
+ break;
190
+
191
+ case BounceCategory.AUTO_RESPONSE:
192
+ // Handle auto-response - typically no action needed
193
+ logger.log('info', `Auto-response detected for ${bounce.recipient}`);
194
+ break;
195
+
196
+ default:
197
+ // Unknown bounce type - log for investigation
198
+ logger.log('warn', `Unknown bounce type for ${bounce.recipient}`, {
199
+ bounceType: bounce.bounceType,
200
+ smtpResponse: bounce.smtpResponse
201
+ });
202
+ break;
203
+ }
204
+
205
+ // Store the bounce record
206
+ bounce.processed = true;
207
+ this.bounceStore.push(bounce);
208
+
209
+ // Update the bounce cache
210
+ this.updateBounceCache(bounce);
211
+
212
+ // Log the bounce
213
+ logger.log(
214
+ bounce.bounceCategory === BounceCategory.HARD ? 'warn' : 'info',
215
+ `Email bounce processed: ${bounce.bounceCategory} bounce for ${bounce.recipient}`,
216
+ {
217
+ bounceType: bounce.bounceType,
218
+ domain: bounce.domain,
219
+ category: bounce.bounceCategory
220
+ }
221
+ );
222
+
223
+ // Enhanced security logging
224
+ SecurityLogger.getInstance().logEvent({
225
+ level: bounce.bounceCategory === BounceCategory.HARD
226
+ ? SecurityLogLevel.WARN
227
+ : SecurityLogLevel.INFO,
228
+ type: SecurityEventType.EMAIL_VALIDATION,
229
+ message: `Email bounce detected: ${bounce.bounceCategory} bounce for recipient`,
230
+ domain: bounce.domain,
231
+ details: {
232
+ recipient: bounce.recipient,
233
+ bounceType: bounce.bounceType,
234
+ smtpResponse: bounce.smtpResponse,
235
+ diagnosticCode: bounce.diagnosticCode,
236
+ statusCode: bounce.statusCode
237
+ },
238
+ success: false
239
+ });
240
+
241
+ return bounce;
242
+ } catch (error) {
243
+ logger.log('error', `Error processing bounce: ${error.message}`, {
244
+ error: error.message,
245
+ bounceData
246
+ });
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Process an SMTP failure as a bounce
253
+ * @param recipient Recipient email
254
+ * @param smtpResponse SMTP error response
255
+ * @param options Additional options
256
+ * @returns Processed bounce record
257
+ */
258
+ public async processSmtpFailure(
259
+ recipient: string,
260
+ smtpResponse: string,
261
+ options: {
262
+ sender?: string;
263
+ originalEmailId?: string;
264
+ statusCode?: string;
265
+ headers?: Record<string, string>;
266
+ } = {}
267
+ ): Promise<BounceRecord> {
268
+ // Create bounce data from SMTP failure
269
+ const bounceData: Partial<BounceRecord> = {
270
+ recipient,
271
+ sender: options.sender || '',
272
+ domain: recipient.split('@')[1],
273
+ smtpResponse,
274
+ statusCode: options.statusCode,
275
+ headers: options.headers,
276
+ originalEmailId: options.originalEmailId,
277
+ timestamp: Date.now()
278
+ };
279
+
280
+ // Process as a regular bounce
281
+ return this.processBounce(bounceData);
282
+ }
283
+
284
+ /**
285
+ * Process a bounce notification email
286
+ * @param bounceEmail The email containing bounce information
287
+ * @returns Processed bounce record or null if not a bounce
288
+ */
289
+ public async processBounceEmail(bounceEmail: Email): Promise<BounceRecord | null> {
290
+ try {
291
+ // Check if this is a bounce notification
292
+ const subject = bounceEmail.getSubject();
293
+ const body = bounceEmail.getBody();
294
+
295
+ // Check for common bounce notification subject patterns
296
+ const isBounceSubject = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
297
+
298
+ if (!isBounceSubject) {
299
+ // Not a bounce notification based on subject
300
+ return null;
301
+ }
302
+
303
+ // Extract original recipient from the body or headers
304
+ let recipient = '';
305
+ let originalMessageId = '';
306
+
307
+ // Extract recipient from common bounce formats
308
+ const recipientMatch = body.match(/(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/i);
309
+ if (recipientMatch && recipientMatch[1]) {
310
+ recipient = recipientMatch[1];
311
+ }
312
+
313
+ // Extract diagnostic code
314
+ let diagnosticCode = '';
315
+ const diagnosticMatch = body.match(/diagnostic(?:-|\\s+)code:\s*(.+)(?:\n|$)/i);
316
+ if (diagnosticMatch && diagnosticMatch[1]) {
317
+ diagnosticCode = diagnosticMatch[1].trim();
318
+ }
319
+
320
+ // Extract SMTP status code
321
+ let statusCode = '';
322
+ const statusMatch = body.match(/status(?:-|\\s+)code:\s*([0-9.]+)/i);
323
+ if (statusMatch && statusMatch[1]) {
324
+ statusCode = statusMatch[1].trim();
325
+ }
326
+
327
+ // If recipient not found in standard patterns, try DSN (Delivery Status Notification) format
328
+ if (!recipient) {
329
+ // Look for DSN format with Original-Recipient or Final-Recipient fields
330
+ const originalRecipientMatch = body.match(/original-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
331
+ const finalRecipientMatch = body.match(/final-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
332
+
333
+ if (originalRecipientMatch && originalRecipientMatch[1]) {
334
+ recipient = originalRecipientMatch[1];
335
+ } else if (finalRecipientMatch && finalRecipientMatch[1]) {
336
+ recipient = finalRecipientMatch[1];
337
+ }
338
+ }
339
+
340
+ // If still no recipient, can't process as bounce
341
+ if (!recipient) {
342
+ logger.log('warn', 'Could not extract recipient from bounce notification', {
343
+ subject,
344
+ sender: bounceEmail.from
345
+ });
346
+ return null;
347
+ }
348
+
349
+ // Extract original message ID if available
350
+ const messageIdMatch = body.match(/original[ -]message[ -]id:[ \t]*<?([^>]+)>?/i);
351
+ if (messageIdMatch && messageIdMatch[1]) {
352
+ originalMessageId = messageIdMatch[1].trim();
353
+ }
354
+
355
+ // Create bounce data
356
+ const bounceData: Partial<BounceRecord> = {
357
+ recipient,
358
+ sender: bounceEmail.from,
359
+ domain: recipient.split('@')[1],
360
+ subject: bounceEmail.getSubject(),
361
+ diagnosticCode,
362
+ statusCode,
363
+ timestamp: Date.now(),
364
+ headers: {}
365
+ };
366
+
367
+ // Process as a regular bounce
368
+ return this.processBounce(bounceData);
369
+ } catch (error) {
370
+ logger.log('error', `Error processing bounce email: ${error.message}`);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Handle a hard bounce by adding to suppression list
377
+ * @param bounce The bounce record
378
+ */
379
+ private async handleHardBounce(bounce: BounceRecord): Promise<void> {
380
+ // Add to suppression list permanently (no expiry)
381
+ this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined);
382
+
383
+ // Increment bounce count in cache
384
+ this.updateBounceCache(bounce);
385
+
386
+ // Save to permanent storage
387
+ await this.saveBounceRecord(bounce);
388
+
389
+ // Log hard bounce for monitoring
390
+ logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, {
391
+ domain: bounce.domain,
392
+ smtpResponse: bounce.smtpResponse,
393
+ diagnosticCode: bounce.diagnosticCode
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Handle a soft bounce by scheduling a retry if eligible
399
+ * @param bounce The bounce record
400
+ */
401
+ private async handleSoftBounce(bounce: BounceRecord): Promise<void> {
402
+ // Check if we've exceeded max retries
403
+ if (bounce.retryCount >= this.retryStrategy.maxRetries) {
404
+ logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`);
405
+
406
+ // Convert to hard bounce after max retries
407
+ bounce.bounceCategory = BounceCategory.HARD;
408
+ await this.handleHardBounce(bounce);
409
+ return;
410
+ }
411
+
412
+ // Calculate next retry time with exponential backoff
413
+ const delay = Math.min(
414
+ this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, bounce.retryCount),
415
+ this.retryStrategy.maxDelay
416
+ );
417
+
418
+ bounce.retryCount++;
419
+ bounce.nextRetryTime = Date.now() + delay;
420
+
421
+ // Add to suppression list temporarily (with expiry)
422
+ this.addToSuppressionList(
423
+ bounce.recipient,
424
+ `Soft bounce: ${bounce.bounceType}`,
425
+ bounce.nextRetryTime
426
+ );
427
+
428
+ // Log the retry schedule
429
+ logger.log('info', `Scheduled retry ${bounce.retryCount} for ${bounce.recipient} at ${new Date(bounce.nextRetryTime).toISOString()}`, {
430
+ bounceType: bounce.bounceType,
431
+ retryCount: bounce.retryCount,
432
+ nextRetry: bounce.nextRetryTime
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Add an email address to the suppression list
438
+ * @param email Email address to suppress
439
+ * @param reason Reason for suppression
440
+ * @param expiresAt Expiration timestamp (undefined for permanent)
441
+ */
442
+ public addToSuppressionList(
443
+ email: string,
444
+ reason: string,
445
+ expiresAt?: number
446
+ ): void {
447
+ this.suppressionList.set(email.toLowerCase(), {
448
+ reason,
449
+ timestamp: Date.now(),
450
+ expiresAt
451
+ });
452
+
453
+ // Save asynchronously without blocking
454
+ this.saveSuppressionList().catch(error => {
455
+ logger.log('error', `Failed to save suppression list after adding ${email}: ${error.message}`);
456
+ });
457
+
458
+ logger.log('info', `Added ${email} to suppression list`, {
459
+ reason,
460
+ expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent'
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Remove an email address from the suppression list
466
+ * @param email Email address to remove
467
+ */
468
+ public removeFromSuppressionList(email: string): void {
469
+ const wasRemoved = this.suppressionList.delete(email.toLowerCase());
470
+
471
+ if (wasRemoved) {
472
+ // Save asynchronously without blocking
473
+ this.saveSuppressionList().catch(error => {
474
+ logger.log('error', `Failed to save suppression list after removing ${email}: ${error.message}`);
475
+ });
476
+ logger.log('info', `Removed ${email} from suppression list`);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Check if an email is on the suppression list
482
+ * @param email Email address to check
483
+ * @returns Whether the email is suppressed
484
+ */
485
+ public isEmailSuppressed(email: string): boolean {
486
+ const lowercaseEmail = email.toLowerCase();
487
+ const suppression = this.suppressionList.get(lowercaseEmail);
488
+
489
+ if (!suppression) {
490
+ return false;
491
+ }
492
+
493
+ // Check if suppression has expired
494
+ if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
495
+ this.suppressionList.delete(lowercaseEmail);
496
+ // Save asynchronously without blocking
497
+ this.saveSuppressionList().catch(error => {
498
+ logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`);
499
+ });
500
+ return false;
501
+ }
502
+
503
+ return true;
504
+ }
505
+
506
+ /**
507
+ * Get suppression information for an email
508
+ * @param email Email address to check
509
+ * @returns Suppression information or null if not suppressed
510
+ */
511
+ public getSuppressionInfo(email: string): {
512
+ reason: string;
513
+ timestamp: number;
514
+ expiresAt?: number;
515
+ } | null {
516
+ const lowercaseEmail = email.toLowerCase();
517
+ const suppression = this.suppressionList.get(lowercaseEmail);
518
+
519
+ if (!suppression) {
520
+ return null;
521
+ }
522
+
523
+ // Check if suppression has expired
524
+ if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
525
+ this.suppressionList.delete(lowercaseEmail);
526
+ // Save asynchronously without blocking
527
+ this.saveSuppressionList().catch(error => {
528
+ logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`);
529
+ });
530
+ return null;
531
+ }
532
+
533
+ return suppression;
534
+ }
535
+
536
+ /**
537
+ * Save suppression list to disk
538
+ */
539
+ private async saveSuppressionList(): Promise<void> {
540
+ try {
541
+ const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
542
+
543
+ if (this.storageManager) {
544
+ // Use storage manager
545
+ await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData);
546
+ } else {
547
+ // Fall back to filesystem
548
+ await plugins.smartfs.file(
549
+ plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
550
+ ).write(suppressionData);
551
+ }
552
+ } catch (error) {
553
+ logger.log('error', `Failed to save suppression list: ${error.message}`);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Load suppression list from disk
559
+ */
560
+ private async loadSuppressionList(): Promise<void> {
561
+ try {
562
+ let entries = null;
563
+ let needsMigration = false;
564
+
565
+ if (this.storageManager) {
566
+ // Try to load from storage manager first
567
+ const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json');
568
+
569
+ if (suppressionData) {
570
+ entries = JSON.parse(suppressionData);
571
+ } else {
572
+ // Check if data exists in filesystem and migrate
573
+ const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
574
+
575
+ if (plugins.fs.existsSync(suppressionPath)) {
576
+ const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
577
+ entries = JSON.parse(data);
578
+ needsMigration = true;
579
+
580
+ logger.log('info', 'Migrating suppression list from filesystem to StorageManager');
581
+ }
582
+ }
583
+ } else {
584
+ // No storage manager, use filesystem directly
585
+ const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
586
+
587
+ if (plugins.fs.existsSync(suppressionPath)) {
588
+ const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
589
+ entries = JSON.parse(data);
590
+ }
591
+ }
592
+
593
+ if (entries) {
594
+ this.suppressionList = new Map(entries);
595
+
596
+ // Clean expired entries
597
+ const now = Date.now();
598
+ let expiredCount = 0;
599
+
600
+ for (const [email, info] of this.suppressionList.entries()) {
601
+ if (info.expiresAt && now > info.expiresAt) {
602
+ this.suppressionList.delete(email);
603
+ expiredCount++;
604
+ }
605
+ }
606
+
607
+ if (expiredCount > 0 || needsMigration) {
608
+ logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`);
609
+ await this.saveSuppressionList();
610
+ }
611
+
612
+ logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`);
613
+ }
614
+ } catch (error) {
615
+ logger.log('error', `Failed to load suppression list: ${error.message}`);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Save bounce record to disk
621
+ * @param bounce Bounce record to save
622
+ */
623
+ private async saveBounceRecord(bounce: BounceRecord): Promise<void> {
624
+ try {
625
+ const bounceData = JSON.stringify(bounce, null, 2);
626
+
627
+ if (this.storageManager) {
628
+ // Use storage manager
629
+ await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData);
630
+ } else {
631
+ // Fall back to filesystem
632
+ const bouncePath = plugins.path.join(
633
+ paths.dataDir,
634
+ 'emails',
635
+ 'bounces',
636
+ `${bounce.id}.json`
637
+ );
638
+
639
+ // Ensure directory exists
640
+ const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
641
+ await plugins.smartfs.directory(bounceDir).recursive().create();
642
+
643
+ await plugins.smartfs.file(bouncePath).write(bounceData);
644
+ }
645
+ } catch (error) {
646
+ logger.log('error', `Failed to save bounce record: ${error.message}`);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Update bounce cache with new bounce information
652
+ * @param bounce Bounce record to update cache with
653
+ */
654
+ private updateBounceCache(bounce: BounceRecord): void {
655
+ const email = bounce.recipient.toLowerCase();
656
+ const existing = this.bounceCache.get(email);
657
+
658
+ if (existing) {
659
+ // Update existing cache entry
660
+ existing.lastBounce = bounce.timestamp;
661
+ existing.count++;
662
+ existing.type = bounce.bounceType;
663
+ existing.category = bounce.bounceCategory;
664
+ } else {
665
+ // Create new cache entry
666
+ this.bounceCache.set(email, {
667
+ lastBounce: bounce.timestamp,
668
+ count: 1,
669
+ type: bounce.bounceType,
670
+ category: bounce.bounceCategory
671
+ });
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Check bounce history for an email address
677
+ * @param email Email address to check
678
+ * @returns Bounce information or null if no bounces
679
+ */
680
+ public getBounceInfo(email: string): {
681
+ lastBounce: number;
682
+ count: number;
683
+ type: BounceType;
684
+ category: BounceCategory;
685
+ } | null {
686
+ return this.bounceCache.get(email.toLowerCase()) || null;
687
+ }
688
+
689
+ /**
690
+ * Get all known hard bounced addresses
691
+ * @returns Array of hard bounced email addresses
692
+ */
693
+ public getHardBouncedAddresses(): string[] {
694
+ const hardBounced: string[] = [];
695
+
696
+ for (const [email, info] of this.bounceCache.entries()) {
697
+ if (info.category === BounceCategory.HARD) {
698
+ hardBounced.push(email);
699
+ }
700
+ }
701
+
702
+ return hardBounced;
703
+ }
704
+
705
+ /**
706
+ * Get suppression list
707
+ * @returns Array of suppressed email addresses
708
+ */
709
+ public getSuppressionList(): string[] {
710
+ return Array.from(this.suppressionList.keys());
711
+ }
712
+
713
+ /**
714
+ * Clear old bounce records (for maintenance)
715
+ * @param olderThan Timestamp to remove records older than
716
+ * @returns Number of records removed
717
+ */
718
+ public clearOldBounceRecords(olderThan: number): number {
719
+ let removed = 0;
720
+
721
+ this.bounceStore = this.bounceStore.filter(bounce => {
722
+ if (bounce.timestamp < olderThan) {
723
+ removed++;
724
+ return false;
725
+ }
726
+ return true;
727
+ });
728
+
729
+ return removed;
730
+ }
731
+ }