@push.rocks/smartmta 7.0.0 → 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 (70) hide show
  1. package/changelog.md +30 -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 +11 -0
  10. package/dist_ts/mail/core/classes.bouncemanager.js +120 -38
  11. package/dist_ts/mail/core/classes.email.js +14 -12
  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/delivery/classes.delivery.queue.d.ts +77 -2
  15. package/dist_ts/mail/delivery/classes.delivery.queue.js +551 -46
  16. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
  17. package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
  18. package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
  19. package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
  20. package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
  21. package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
  22. package/dist_ts/mail/delivery/interfaces.js +1 -1
  23. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +8 -4
  24. package/dist_ts/mail/routing/classes.dkim.manager.js +46 -29
  25. package/dist_ts/mail/routing/classes.dns.manager.d.ts +5 -3
  26. package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
  27. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
  28. package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
  29. package/dist_ts/mail/routing/classes.email.router.d.ts +3 -0
  30. package/dist_ts/mail/routing/classes.email.router.js +15 -9
  31. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +4 -0
  32. package/dist_ts/mail/routing/classes.unified.email.server.js +61 -40
  33. package/dist_ts/mail/security/classes.dkimcreator.d.ts +7 -0
  34. package/dist_ts/mail/security/classes.dkimcreator.js +33 -8
  35. package/dist_ts/mail/security/classes.spfverifier.js +5 -3
  36. package/dist_ts/security/classes.contentscanner.js +14 -11
  37. package/dist_ts/security/classes.ipreputationchecker.d.ts +3 -0
  38. package/dist_ts/security/classes.ipreputationchecker.js +20 -11
  39. package/dist_ts/security/classes.rustsecuritybridge.d.ts +49 -1
  40. package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
  41. package/dist_ts/security/classes.securitylogger.js +7 -5
  42. package/dist_ts/security/index.d.ts +1 -1
  43. package/dist_ts/security/index.js +2 -2
  44. package/package.json +8 -8
  45. package/readme.hints.md +4 -3
  46. package/readme.md +41 -13
  47. package/readme.plan.md +6 -0
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/functions.errors.ts +8 -0
  50. package/ts/index.ts +3 -0
  51. package/ts/mail/core/classes.bouncemanager.ts +157 -45
  52. package/ts/mail/core/classes.email.ts +19 -13
  53. package/ts/mail/core/classes.emailvalidator.ts +9 -7
  54. package/ts/mail/delivery/classes.delivery.queue.ts +740 -58
  55. package/ts/mail/delivery/classes.delivery.system.ts +583 -170
  56. package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
  57. package/ts/mail/delivery/functions.safe-observers.ts +45 -0
  58. package/ts/mail/delivery/interfaces.ts +27 -1
  59. package/ts/mail/routing/classes.dkim.manager.ts +62 -37
  60. package/ts/mail/routing/classes.dns.manager.ts +36 -13
  61. package/ts/mail/routing/classes.email.action.executor.ts +64 -17
  62. package/ts/mail/routing/classes.email.router.ts +15 -8
  63. package/ts/mail/routing/classes.unified.email.server.ts +90 -44
  64. package/ts/mail/security/classes.dkimcreator.ts +50 -7
  65. package/ts/mail/security/classes.spfverifier.ts +4 -2
  66. package/ts/security/classes.contentscanner.ts +14 -11
  67. package/ts/security/classes.ipreputationchecker.ts +21 -10
  68. package/ts/security/classes.rustsecuritybridge.ts +269 -3
  69. package/ts/security/classes.securitylogger.ts +6 -4
  70. package/ts/security/index.ts +5 -1
@@ -6,10 +6,20 @@ import {
6
6
  SecurityLogLevel,
7
7
  SecurityEventType
8
8
  } from '../../security/index.js';
9
- import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
9
+ import {
10
+ UnifiedDeliveryQueue,
11
+ type IQueueItem,
12
+ type IRecipientPermanentFailure,
13
+ } from './classes.delivery.queue.js';
10
14
  import { Email } from '../core/classes.email.js';
11
15
  import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
12
- import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
16
+ import { SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
17
+ import type {
18
+ ISmtpRecipientResult,
19
+ ISmtpSendResult,
20
+ } from '../../security/classes.rustsecuritybridge.js';
21
+ import type { ISmtpTransactionAttempt } from './interfaces.js';
22
+ import { callObserverSafely, emitSafely } from './functions.safe-observers.js';
13
23
 
14
24
  const dns = plugins.dns;
15
25
 
@@ -100,9 +110,16 @@ export interface IDeliveryStats {
100
110
  /**
101
111
  * Handles delivery for all email processing modes
102
112
  */
113
+ type TResolvedMultiModeDeliveryOptions = Omit<
114
+ Required<IMultiModeDeliveryOptions>,
115
+ 'bounceHandler'
116
+ > & {
117
+ bounceHandler?: NonNullable<IMultiModeDeliveryOptions['bounceHandler']>;
118
+ };
119
+
103
120
  export class MultiModeDeliverySystem extends EventEmitter {
104
121
  private queue: UnifiedDeliveryQueue;
105
- private options: Required<IMultiModeDeliveryOptions>;
122
+ private options: TResolvedMultiModeDeliveryOptions;
106
123
  private stats: IDeliveryStats;
107
124
  private deliveryTimes: number[] = [];
108
125
  private activeDeliveries: Set<string> = new Set();
@@ -112,6 +129,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
112
129
  private rateLimitLastCheck: number = Date.now();
113
130
  private rateLimitCounter: number = 0;
114
131
  private emailServer?: UnifiedEmailServer;
132
+ private bounceDrainTimer?: NodeJS.Timeout;
133
+ private bounceDrainPromise?: Promise<void>;
115
134
 
116
135
  /**
117
136
  * Create a new multi-mode delivery system
@@ -145,7 +164,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
145
164
  globalRateLimit: options.globalRateLimit || 100, // 100 emails per minute
146
165
  perPatternRateLimit: options.perPatternRateLimit || {},
147
166
  processBounces: options.processBounces !== false, // Default to true
148
- bounceHandler: options.bounceHandler || null,
167
+ bounceHandler: options.bounceHandler,
149
168
  onDeliveryStart: options.onDeliveryStart || (async () => {}),
150
169
  onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
151
170
  onDeliveryFailed: options.onDeliveryFailed || (async () => {})
@@ -181,7 +200,169 @@ export class MultiModeDeliverySystem extends EventEmitter {
181
200
  // Set up event listeners
182
201
  this.queue.on('itemsReady', this.processItems.bind(this));
183
202
  }
184
-
203
+
204
+ private createEmailForRecipients(email: Email, recipients: string[]): Email {
205
+ const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
206
+ return new Email({
207
+ ...email.toEmailOptions(),
208
+ to: email.to.filter((recipient) => recipientSet.has(recipient.toLowerCase())),
209
+ cc: (email.cc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
210
+ bcc: (email.bcc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
211
+ })
212
+ .setMessageId(email.getMessageId())
213
+ .setEnvelopeFrom(email.getEnvelopeFrom());
214
+ }
215
+
216
+ private contextualizeSmtpError(
217
+ errorArg: unknown,
218
+ recipients: string[],
219
+ recipientDomain?: string,
220
+ ): SmtpDeliveryError {
221
+ const error = SmtpDeliveryError.from(errorArg);
222
+ return new SmtpDeliveryError({
223
+ message: error.message,
224
+ errorType: error.errorType,
225
+ retryable: error.retryable,
226
+ ...(error.smtpCode !== undefined ? { smtpCode: error.smtpCode } : {}),
227
+ phase: error.phase,
228
+ transactionLog: error.transactionLog,
229
+ recipients,
230
+ recipientResults: error.recipientResults,
231
+ ...(recipientDomain ? { recipientDomain } : {}),
232
+ }, error);
233
+ }
234
+
235
+ private async persistSmtpAttempt(optionsArg: {
236
+ item: IQueueItem;
237
+ targetHost: string;
238
+ targetPort: number;
239
+ recipients: string[];
240
+ recipientDomain?: string;
241
+ startedAt: Date;
242
+ result?: ISmtpSendResult;
243
+ error?: SmtpDeliveryError;
244
+ }): Promise<ISmtpTransactionAttempt> {
245
+ const completedAt = new Date();
246
+ const error = optionsArg.error;
247
+ const attempt: ISmtpTransactionAttempt = {
248
+ id: plugins.uuid.v4(),
249
+ queueItemId: optionsArg.item.id,
250
+ queueAttempt: optionsArg.item.attempts,
251
+ targetHost: optionsArg.targetHost,
252
+ targetPort: optionsArg.targetPort,
253
+ ...(optionsArg.recipientDomain
254
+ ? { recipientDomain: optionsArg.recipientDomain }
255
+ : {}),
256
+ recipients: [...optionsArg.recipients],
257
+ startedAt: optionsArg.startedAt.toISOString(),
258
+ completedAt: completedAt.toISOString(),
259
+ durationMs: completedAt.getTime() - optionsArg.startedAt.getTime(),
260
+ outcome: error ? 'failed' : 'succeeded',
261
+ retryable: error?.retryable ?? false,
262
+ ...(error?.errorType ? { errorType: error.errorType } : {}),
263
+ ...(error?.smtpCode !== undefined ? { smtpCode: error.smtpCode } : {}),
264
+ recipientResults: error?.recipientResults || optionsArg.result?.recipientResults,
265
+ ...(error ? { failurePhase: error.phase, error: error.message } : {}),
266
+ transcript: error?.transactionLog || optionsArg.result?.transactionLog || [],
267
+ };
268
+ await this.queue.appendSmtpTransaction(optionsArg.item.id, attempt);
269
+ await emitSafely(this, 'smtpTransactionCompleted', attempt);
270
+ return attempt;
271
+ }
272
+
273
+ private splitRecipientResults(
274
+ results: ISmtpRecipientResult[] | undefined,
275
+ rejectedFallback: string[] = [],
276
+ ): { accepted: string[]; permanent: ISmtpRecipientResult[]; transient: string[] } {
277
+ if (!results || results.length === 0) {
278
+ return { accepted: [], permanent: [], transient: [...rejectedFallback] };
279
+ }
280
+ const representedRecipients = new Set(
281
+ results.map((result) => result.recipient.toLowerCase()),
282
+ );
283
+ return {
284
+ accepted: results.filter((result) => result.accepted).map((result) => result.recipient),
285
+ permanent: results.filter(
286
+ (result) => !result.accepted && result.responseCode >= 500,
287
+ ),
288
+ transient: [
289
+ ...results
290
+ .filter((result) => !result.accepted && result.responseCode < 500)
291
+ .map((result) => result.recipient),
292
+ ...rejectedFallback.filter(
293
+ (recipient) => !representedRecipients.has(recipient.toLowerCase()),
294
+ ),
295
+ ],
296
+ };
297
+ }
298
+
299
+ private async checkpointPermanentRecipientFailures(
300
+ item: IQueueItem,
301
+ results: ISmtpRecipientResult[],
302
+ ): Promise<string[]> {
303
+ const failures: IRecipientPermanentFailure[] = results.map((result) => ({
304
+ recipient: result.recipient,
305
+ smtpCode: result.responseCode,
306
+ smtpResponse: `SMTP recipient rejected with ${result.responseCode}`,
307
+ }));
308
+ const newlyTerminal = await this.queue.markRecipientsTerminal(item.id, failures);
309
+ if (newlyTerminal.length > 0 && this.running) {
310
+ void this.drainBounceOutbox();
311
+ }
312
+ return newlyTerminal;
313
+ }
314
+
315
+ /**
316
+ * Drain durable bounce work with one in-process flight. Custom handlers
317
+ * receive operationId and must use it for their own idempotency.
318
+ */
319
+ public async drainBounceOutbox(): Promise<void> {
320
+ if (this.bounceDrainPromise) return this.bounceDrainPromise;
321
+ const drain = (async () => {
322
+ if (!this.options.processBounces || !this.options.bounceHandler) return;
323
+ for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
324
+ const item = this.queue.getItem(itemId);
325
+ if (!item) continue;
326
+ const email = item.processingResult as Email;
327
+ try {
328
+ await this.options.bounceHandler.processSmtpFailure(
329
+ operation.recipient,
330
+ operation.smtpResponse,
331
+ {
332
+ operationId: operation.operationId,
333
+ sender: email.from,
334
+ originalEmailId: item.id,
335
+ headers: email.headers,
336
+ statusCode: String(operation.smtpCode),
337
+ },
338
+ );
339
+ await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
340
+ } catch (error) {
341
+ logger.log(
342
+ 'error',
343
+ `Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
344
+ );
345
+ try {
346
+ await this.queue.markBounceOperationFailed(
347
+ itemId,
348
+ operation.operationId,
349
+ (error as Error).message,
350
+ );
351
+ } catch (checkpointError) {
352
+ logger.log(
353
+ 'error',
354
+ `Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
355
+ );
356
+ }
357
+ }
358
+ }
359
+ })();
360
+ this.bounceDrainPromise = drain.finally(() => {
361
+ this.bounceDrainPromise = undefined;
362
+ });
363
+ return this.bounceDrainPromise;
364
+ }
365
+
185
366
  /**
186
367
  * Start the delivery system
187
368
  */
@@ -194,9 +375,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
194
375
  }
195
376
 
196
377
  this.running = true;
197
-
198
- // Emit started event
199
- this.emit('started');
378
+ await this.drainBounceOutbox();
379
+ this.bounceDrainTimer = setInterval(() => {
380
+ void this.drainBounceOutbox();
381
+ }, 30_000);
382
+
383
+ await emitSafely(this, 'started');
200
384
  logger.log('info', 'MultiModeDeliverySystem started successfully');
201
385
  }
202
386
 
@@ -212,6 +396,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
212
396
  }
213
397
 
214
398
  this.running = false;
399
+ if (this.bounceDrainTimer) {
400
+ clearInterval(this.bounceDrainTimer);
401
+ this.bounceDrainTimer = undefined;
402
+ }
215
403
 
216
404
  // Clear throttle reset timer to prevent it firing after stop
217
405
  if (this.throttleResetTimer) {
@@ -241,8 +429,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
241
429
  });
242
430
  }
243
431
 
244
- // Emit stopped event
245
- this.emit('stopped');
432
+ await this.bounceDrainPromise;
433
+ await emitSafely(this, 'stopped');
246
434
  logger.log('info', 'MultiModeDeliverySystem stopped successfully');
247
435
  }
248
436
 
@@ -267,33 +455,46 @@ export class MultiModeDeliverySystem extends EventEmitter {
267
455
  return;
268
456
  }
269
457
 
270
- // Calculate how many more deliveries we can start
271
- const availableSlots = this.options.concurrentDeliveries - this.activeDeliveries.size;
272
- const itemsToProcess = items.slice(0, availableSlots);
273
-
274
- if (itemsToProcess.length === 0) {
275
- return;
276
- }
277
-
278
- logger.log('info', `Processing ${itemsToProcess.length} items for delivery`);
279
-
280
- // Process each item
281
- for (const item of itemsToProcess) {
282
- // Mark as processing
283
- await this.queue.markProcessing(item.id);
284
-
285
- // Add to active deliveries
458
+ let startedDeliveries = 0;
459
+
460
+ for (const item of items) {
461
+ if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
462
+ if (this.activeDeliveries.has(item.id)) continue;
463
+
464
+ // Reserve synchronously before the first await so overlapping queue events
465
+ // cannot dispatch the same item while its durable claim is in flight.
286
466
  this.activeDeliveries.add(item.id);
287
467
  this.stats.activeDeliveries = this.activeDeliveries.size;
288
-
468
+
469
+ let claimed = false;
470
+ try {
471
+ claimed = await this.queue.markProcessing(item.id);
472
+ } catch (error) {
473
+ this.activeDeliveries.delete(item.id);
474
+ this.stats.activeDeliveries = this.activeDeliveries.size;
475
+ logger.log('error', `Failed to claim queue item ${item.id}: ${(error as Error).message}`);
476
+ continue;
477
+ }
478
+
479
+ if (!claimed) {
480
+ this.activeDeliveries.delete(item.id);
481
+ this.stats.activeDeliveries = this.activeDeliveries.size;
482
+ continue;
483
+ }
484
+
485
+ startedDeliveries++;
289
486
  // Deliver asynchronously
290
487
  this.deliverItem(item).catch(err => {
291
488
  logger.log('error', `Unhandled error in delivery: ${err.message}`);
292
489
  });
293
490
  }
491
+
492
+ if (startedDeliveries > 0) {
493
+ logger.log('info', `Processing ${startedDeliveries} items for delivery`);
494
+ }
294
495
 
295
496
  // Update statistics
296
- this.emit('statsUpdated', this.stats);
497
+ await emitSafely(this, 'statsUpdated', this.stats);
297
498
  }
298
499
 
299
500
  /**
@@ -305,10 +506,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
305
506
 
306
507
  try {
307
508
  // Call delivery start hook
308
- await this.options.onDeliveryStart(item);
509
+ await callObserverSafely('delivery start hook', this.options.onDeliveryStart, item);
309
510
 
310
511
  // Emit delivery start event
311
- this.emit('deliveryStart', item);
512
+ await emitSafely(this, 'deliveryStart', item);
312
513
  logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
313
514
 
314
515
  // Choose the appropriate handler based on mode
@@ -331,8 +532,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
331
532
  throw new Error(`Unknown processing mode: ${item.processingMode}`);
332
533
  }
333
534
 
334
- // Mark as delivered
335
- await this.queue.markDelivered(item.id);
535
+ // Built-in SMTP handlers checkpoint every recipient before returning.
536
+ // A partial success is terminal-success only when no recipient remains
537
+ // unresolved; all-permanent with no delivery is terminal failure.
538
+ if (result?.recipientDispositionManaged) {
539
+ const disposition = this.queue.getRecipientDisposition(item.id);
540
+ if (disposition.unresolved.length > 0) {
541
+ throw new SmtpDeliveryError({
542
+ message: `SMTP delivery left ${disposition.unresolved.length} recipient(s) unresolved`,
543
+ errorType: 'smtp_aggregate',
544
+ retryable: true,
545
+ phase: 'rcpt_to',
546
+ transactionLog: [],
547
+ recipients: disposition.unresolved,
548
+ });
549
+ }
550
+ if (disposition.delivered.length === 0 && disposition.permanentFailures.length > 0) {
551
+ throw new SmtpDeliveryError({
552
+ message: 'All recipients were permanently rejected',
553
+ errorType: 'protocol',
554
+ retryable: false,
555
+ smtpCode: 550,
556
+ phase: 'rcpt_to',
557
+ transactionLog: [],
558
+ recipients: disposition.permanentFailures,
559
+ });
560
+ }
561
+ }
562
+
563
+ const deliveryTransitioned = await this.queue.markDelivered(item.id);
564
+ if (!deliveryTransitioned) return;
336
565
 
337
566
  // Update statistics
338
567
  this.stats.totalSuccessful++;
@@ -343,14 +572,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
343
572
  this.deliveryTimes.push(deliveryTime);
344
573
  this.updateDeliveryTimeStats();
345
574
 
346
- // Call delivery success hook
347
- await this.options.onDeliverySuccess(item, result);
348
-
349
- // Emit delivery success event
350
- this.emit('deliverySuccess', item, result);
575
+ await callObserverSafely(
576
+ 'delivery success hook',
577
+ this.options.onDeliverySuccess,
578
+ item,
579
+ result,
580
+ );
581
+
582
+ await emitSafely(this, 'deliverySuccess', item, result);
351
583
  logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
352
584
 
353
- SecurityLogger.getInstance().logEvent({
585
+ await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
354
586
  level: SecurityLogLevel.INFO,
355
587
  type: SecurityEventType.EMAIL_DELIVERY,
356
588
  message: 'Email delivery successful',
@@ -361,75 +593,85 @@ export class MultiModeDeliverySystem extends EventEmitter {
361
593
  deliveryTime
362
594
  },
363
595
  success: true
364
- });
365
- } catch (error: any) {
596
+ }));
597
+ } catch (error: unknown) {
366
598
  // Calculate delivery attempt time even for failures
367
599
  const deliveryTime = Date.now() - startTime;
600
+ const deliveryError = SmtpDeliveryError.from(error);
368
601
 
369
- // Mark as failed
370
- await this.queue.markFailed(item.id, error.message);
371
-
372
- // Update statistics
373
- this.stats.totalFailed++;
374
- this.stats.byMode[item.processingMode].failed++;
375
-
376
- // Call delivery failed hook
377
- await this.options.onDeliveryFailed(item, error.message);
602
+ if (
603
+ deliveryError.phase === 'rcpt_to'
604
+ && typeof deliveryError.smtpCode === 'number'
605
+ && deliveryError.smtpCode >= 500
606
+ ) {
607
+ const permanentRecipients = deliveryError.recipients.length > 0
608
+ ? deliveryError.recipients
609
+ : item.processingResult.getAllRecipients();
610
+ await this.queue.markRecipientsTerminal(
611
+ item.id,
612
+ permanentRecipients.map((recipient) => ({
613
+ recipient,
614
+ smtpCode: deliveryError.smtpCode!,
615
+ smtpResponse: deliveryError.message,
616
+ })),
617
+ );
618
+ }
619
+
620
+ const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
621
+ retryable: deliveryError.retryable,
622
+ });
623
+ if (this.running) void this.drainBounceOutbox();
624
+ if (!transition.updated) return;
378
625
 
379
- // Process as bounce if enabled and we have a bounce handler
380
- if (this.options.processBounces && this.options.bounceHandler) {
381
- try {
382
- const email = item.processingResult as Email;
383
-
384
- // Extract recipient and error message
385
- // For multiple recipients, we'd need more sophisticated parsing
386
- const recipient = email.to.length > 0 ? email.to[0] : '';
387
-
388
- if (recipient) {
389
- logger.log('info', `Processing delivery failure as bounce for recipient ${recipient}`);
390
-
391
- // Process SMTP failure through bounce handler
392
- await this.options.bounceHandler.processSmtpFailure(
393
- recipient,
394
- error.message,
395
- {
396
- sender: email.from,
397
- originalEmailId: item.id,
398
- headers: email.headers
399
- }
400
- );
401
-
402
- logger.log('info', `Bounce record created for failed delivery to ${recipient}`);
403
- }
404
- } catch (bounceError) {
405
- logger.log('error', `Failed to process bounce: ${bounceError.message}`);
406
- }
626
+ // Intermediate retry attempts are deferrals, not failed deliveries.
627
+ if (transition.terminal) {
628
+ this.stats.totalFailed++;
629
+ this.stats.byMode[item.processingMode].failed++;
630
+ await callObserverSafely(
631
+ 'delivery failure hook',
632
+ this.options.onDeliveryFailed,
633
+ item,
634
+ deliveryError.message,
635
+ );
407
636
  }
408
637
 
409
638
  // Emit delivery failed event
410
- this.emit('deliveryFailed', item, error);
411
- logger.log('error', `Item ${item.id} delivery failed: ${error.message}`);
639
+ if (transition.terminal) {
640
+ await emitSafely(this, 'deliveryFailed', item, deliveryError);
641
+ await emitSafely(this, 'deliveryTerminalFailure', item, deliveryError, transition);
642
+ } else {
643
+ await emitSafely(this, 'deliveryDeferred', item, deliveryError, transition);
644
+ }
645
+ logger.log(
646
+ transition.terminal ? 'error' : 'warn',
647
+ `Item ${item.id} delivery ${transition.terminal ? 'failed' : 'deferred'}: ${deliveryError.message}`,
648
+ );
412
649
 
413
- SecurityLogger.getInstance().logEvent({
414
- level: SecurityLogLevel.ERROR,
650
+ await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
651
+ level: transition.terminal ? SecurityLogLevel.ERROR : SecurityLogLevel.WARN,
415
652
  type: SecurityEventType.EMAIL_DELIVERY,
416
- message: 'Email delivery failed',
653
+ message: transition.terminal ? 'Email delivery failed' : 'Email delivery deferred',
417
654
  details: {
418
655
  itemId: item.id,
419
656
  mode: item.processingMode,
420
657
  routeName: item.route?.name || 'unknown',
421
- error: error.message,
658
+ error: deliveryError.message,
659
+ errorType: deliveryError.errorType,
660
+ retryable: deliveryError.retryable,
661
+ phase: deliveryError.phase,
662
+ smtpCode: deliveryError.smtpCode,
663
+ queueStatus: transition.status,
422
664
  deliveryTime
423
665
  },
424
666
  success: false
425
- });
667
+ }));
426
668
  } finally {
427
669
  // Remove from active deliveries
428
670
  this.activeDeliveries.delete(item.id);
429
671
  this.stats.activeDeliveries = this.activeDeliveries.size;
430
672
 
431
673
  // Update statistics
432
- this.emit('statsUpdated', this.stats);
674
+ await emitSafely(this, 'statsUpdated', this.stats);
433
675
  }
434
676
  }
435
677
 
@@ -451,8 +693,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
451
693
  throw new Error('No target server configured for forward mode');
452
694
  }
453
695
 
696
+ const deliveredRecipients = new Set(
697
+ (item.deliveredRecipients || []).map((recipient) => recipient.toLowerCase()),
698
+ );
699
+ const recipients = email
700
+ .getAllRecipients()
701
+ .filter((recipient) => !deliveredRecipients.has(recipient.toLowerCase()));
702
+ if (recipients.length === 0) {
703
+ return { targetServer, targetPort, recipients: 0, rejectedRecipients: [] };
704
+ }
454
705
  logger.log('info', `Forwarding email to ${targetServer}:${targetPort}`);
455
706
 
707
+ const startedAt = new Date();
456
708
  try {
457
709
  if (!this.emailServer) {
458
710
  throw new Error('No email server available for forward delivery');
@@ -460,7 +712,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
460
712
 
461
713
  // Build DKIM options from route config
462
714
  const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
463
- ? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
715
+ ? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.getFromDomain() || undefined)
464
716
  : undefined;
465
717
  const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
466
718
 
@@ -468,10 +720,44 @@ export class MultiModeDeliverySystem extends EventEmitter {
468
720
  const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
469
721
 
470
722
  // Send via Rust SMTP client
471
- const result = await this.emailServer.sendOutboundEmail(targetServer, targetPort, email, {
723
+ const result = await this.emailServer.sendOutboundEmail(
724
+ targetServer,
725
+ targetPort,
726
+ this.createEmailForRecipients(email, recipients),
727
+ {
472
728
  auth,
473
729
  dkimDomain,
474
730
  dkimSelector,
731
+ },
732
+ );
733
+
734
+ await this.queue.markRecipientsDelivered(item.id, result.accepted);
735
+ if (result.rejected.length > 0) {
736
+ const partialError = new SmtpDeliveryError({
737
+ message: `SMTP target rejected ${result.rejected.length} recipient(s) without a retained response code`,
738
+ errorType: 'partial_recipient_rejection',
739
+ retryable: true,
740
+ phase: 'rcpt_to',
741
+ transactionLog: result.transactionLog,
742
+ recipients: result.rejected,
743
+ });
744
+ await this.persistSmtpAttempt({
745
+ item,
746
+ targetHost: targetServer,
747
+ targetPort,
748
+ recipients,
749
+ startedAt,
750
+ error: partialError,
751
+ });
752
+ throw partialError;
753
+ }
754
+ await this.persistSmtpAttempt({
755
+ item,
756
+ targetHost: targetServer,
757
+ targetPort,
758
+ recipients,
759
+ startedAt,
760
+ result,
475
761
  });
476
762
 
477
763
  logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
@@ -483,9 +769,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
483
769
  messageId: result.messageId,
484
770
  rejectedRecipients: result.rejected,
485
771
  };
486
- } catch (error: any) {
487
- logger.log('error', `Failed to forward email: ${error.message}`);
488
- throw error;
772
+ } catch (error: unknown) {
773
+ const smtpError = this.contextualizeSmtpError(error, recipients);
774
+ if (!(error instanceof SmtpDeliveryError && error.errorType === 'partial_recipient_rejection')) {
775
+ await this.persistSmtpAttempt({
776
+ item,
777
+ targetHost: targetServer,
778
+ targetPort,
779
+ recipients,
780
+ startedAt,
781
+ error: smtpError,
782
+ });
783
+ }
784
+ logger.log('error', `Failed to forward email: ${smtpError.message}`);
785
+ throw smtpError;
489
786
  }
490
787
  }
491
788
 
@@ -519,6 +816,39 @@ export class MultiModeDeliverySystem extends EventEmitter {
519
816
  return groups;
520
817
  }
521
818
 
819
+ private aggregateSmtpFailures(
820
+ failures: SmtpDeliveryError[],
821
+ recipients: string[],
822
+ recipientDomain?: string,
823
+ ): SmtpDeliveryError {
824
+ const normalized = failures.length > 0
825
+ ? failures
826
+ : [SmtpDeliveryError.from(new Error('SMTP delivery failed without error details'))];
827
+ const retryable = normalized.some((failure) => failure.retryable);
828
+ const allPermanentRecipientRejections = normalized.every((failure) => (
829
+ !failure.retryable
830
+ && failure.errorType === 'protocol'
831
+ && failure.phase === 'rcpt_to'
832
+ && typeof failure.smtpCode === 'number'
833
+ && failure.smtpCode >= 500
834
+ ));
835
+ const selected = normalized.find((failure) => failure.retryable)
836
+ || normalized[normalized.length - 1];
837
+ const smtpCode = allPermanentRecipientRejections
838
+ ? Math.max(...normalized.map((failure) => failure.smtpCode || 500))
839
+ : selected.smtpCode;
840
+ return new SmtpDeliveryError({
841
+ message: normalized.map((failure) => failure.message).join('; '),
842
+ errorType: allPermanentRecipientRejections ? 'protocol' : 'smtp_aggregate',
843
+ retryable,
844
+ ...(smtpCode !== undefined ? { smtpCode } : {}),
845
+ phase: allPermanentRecipientRejections ? 'rcpt_to' : selected.phase,
846
+ transactionLog: [],
847
+ recipients,
848
+ ...(recipientDomain ? { recipientDomain } : {}),
849
+ }, selected);
850
+ }
851
+
522
852
  /**
523
853
  * Default handler for MTA mode delivery
524
854
  * @param item Queue item
@@ -533,8 +863,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
533
863
  }
534
864
 
535
865
  // Build DKIM options from route config
536
- const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
537
- ? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
866
+ const effectiveDkimSigning = Boolean(
867
+ item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
868
+ );
869
+ const dkimDomain = effectiveDkimSigning
870
+ ? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
538
871
  : undefined;
539
872
  const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
540
873
 
@@ -543,8 +876,30 @@ export class MultiModeDeliverySystem extends EventEmitter {
543
876
  throw new Error('No recipients specified for MTA delivery');
544
877
  }
545
878
 
546
- const domainGroups = this.groupRecipientsByDomain(allRecipients);
879
+ const deliveredRecipients = new Set(
880
+ (item.deliveredRecipients || []).map((recipient) => recipient.toLowerCase()),
881
+ );
882
+ const terminalRecipients = new Set(
883
+ (item.terminalRecipients || []).map((recipient) => recipient.toLowerCase()),
884
+ );
885
+ const pendingRecipients = allRecipients.filter(
886
+ (recipient) => (
887
+ !deliveredRecipients.has(recipient.toLowerCase())
888
+ && !terminalRecipients.has(recipient.toLowerCase())
889
+ ),
890
+ );
891
+ if (pendingRecipients.length === 0) {
892
+ return {
893
+ recipientDispositionManaged: true,
894
+ recipients: allRecipients.length,
895
+ domainResults: [],
896
+ };
897
+ }
898
+
899
+ const domainGroups = this.groupRecipientsByDomain(pendingRecipients);
547
900
  const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
901
+ const unresolvedFailures: SmtpDeliveryError[] = [];
902
+ const unresolvedRecipients: string[] = [];
548
903
 
549
904
  for (const [domain, recipients] of domainGroups) {
550
905
  const allMxHosts = await this.resolveMxForDomain(domain);
@@ -555,66 +910,172 @@ export class MultiModeDeliverySystem extends EventEmitter {
555
910
  const mxHosts = allMxHosts.filter(mx => !this.emailServer!.isSelfHostname(mx.exchange));
556
911
  if (mxHosts.length === 0) {
557
912
  const selfMx = allMxHosts.map(mx => mx.exchange).join(', ');
913
+ const loopError = new SmtpDeliveryError({
914
+ message: `MX for ${domain} (${selfMx || 'none'}) points back at this server — refusing self-delivery to avoid a mail loop`,
915
+ errorType: 'config',
916
+ retryable: false,
917
+ phase: 'connect',
918
+ transactionLog: [],
919
+ recipients,
920
+ recipientDomain: domain,
921
+ });
922
+ unresolvedFailures.push(loopError);
923
+ unresolvedRecipients.push(...recipients);
558
924
  results.push({
559
925
  domain,
560
926
  success: false,
561
- error: `MX for ${domain} (${selfMx || 'none'}) points back at this server — refusing self-delivery to avoid a mail loop`,
927
+ error: loopError.message,
562
928
  });
563
929
  logger.log('error', `MTA: refusing self-delivery for ${domain} — MX (${selfMx}) is this server's own hostname (mail loop)`);
564
930
  continue;
565
931
  }
566
932
 
567
- let delivered = false;
568
- let lastError: string | undefined;
933
+ let recipientsForMx = [...recipients];
934
+ const domainFailures: SmtpDeliveryError[] = [];
935
+ const acceptedForDomain: string[] = [];
569
936
 
570
937
  for (const mx of mxHosts) {
938
+ if (recipientsForMx.length === 0) break;
939
+ const startedAt = new Date();
571
940
  try {
572
941
  logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
573
942
 
574
943
  // Create a temporary Email scoped to this domain's recipients
575
- const domainEmail = new Email({
576
- from: email.from,
577
- to: recipients.filter(r => email.to.includes(r)),
578
- cc: recipients.filter(r => (email.cc || []).includes(r)),
579
- bcc: recipients.filter(r => (email.bcc || []).includes(r)),
580
- subject: email.subject,
581
- text: email.text,
582
- html: email.html,
583
- });
944
+ const domainEmail = this.createEmailForRecipients(email, recipientsForMx);
584
945
 
585
946
  const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
586
947
  dkimDomain,
587
948
  dkimSelector,
588
949
  });
589
950
 
590
- results.push({
591
- domain,
592
- success: true,
593
- accepted: result.accepted,
594
- rejected: result.rejected,
951
+ const disposition = this.splitRecipientResults(
952
+ result.recipientResults,
953
+ result.rejected,
954
+ );
955
+ const accepted = disposition.accepted.length > 0
956
+ ? disposition.accepted
957
+ : result.accepted;
958
+ await this.queue.markRecipientsDelivered(item.id, accepted);
959
+ acceptedForDomain.push(...accepted);
960
+ await this.checkpointPermanentRecipientFailures(
961
+ item,
962
+ disposition.permanent,
963
+ );
964
+ await this.persistSmtpAttempt({
965
+ item,
966
+ targetHost: mx.exchange,
967
+ targetPort: 25,
968
+ recipientDomain: domain,
969
+ recipients: recipientsForMx,
970
+ startedAt,
971
+ result,
595
972
  });
596
- delivered = true;
597
- logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`);
598
- break;
599
- } catch (err: any) {
600
- lastError = err.message;
601
- logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`);
973
+ recipientsForMx = disposition.transient;
974
+ if (recipientsForMx.length === 0) {
975
+ logger.log('info', `MTA: completed recipient dispositions for ${domain} via ${mx.exchange}`);
976
+ break;
977
+ }
978
+ domainFailures.push(new SmtpDeliveryError({
979
+ message: `MX ${mx.exchange} temporarily rejected ${recipientsForMx.length} recipient(s)`,
980
+ errorType: 'protocol',
981
+ retryable: true,
982
+ smtpCode: result.recipientResults
983
+ ? Math.min(
984
+ ...result.recipientResults
985
+ .filter((entry) => recipientsForMx.includes(entry.recipient))
986
+ .map((entry) => entry.responseCode),
987
+ )
988
+ : 451,
989
+ phase: 'rcpt_to',
990
+ transactionLog: result.transactionLog,
991
+ recipients: recipientsForMx,
992
+ recipientDomain: domain,
993
+ recipientResults: result.recipientResults?.filter(
994
+ (entry) => recipientsForMx.includes(entry.recipient),
995
+ ),
996
+ }));
997
+ } catch (error: unknown) {
998
+ const smtpError = this.contextualizeSmtpError(error, recipientsForMx, domain);
999
+ if (!(error instanceof SmtpDeliveryError && error.errorType === 'partial_recipient_rejection')) {
1000
+ await this.persistSmtpAttempt({
1001
+ item,
1002
+ targetHost: mx.exchange,
1003
+ targetPort: 25,
1004
+ recipientDomain: domain,
1005
+ recipients: recipientsForMx,
1006
+ startedAt,
1007
+ error: smtpError,
1008
+ });
1009
+ }
1010
+ const disposition = smtpError.phase === 'rcpt_to'
1011
+ ? (
1012
+ smtpError.recipientResults.length > 0
1013
+ ? this.splitRecipientResults(smtpError.recipientResults, smtpError.recipients)
1014
+ : (
1015
+ typeof smtpError.smtpCode === 'number' && smtpError.smtpCode >= 500
1016
+ ? {
1017
+ accepted: [],
1018
+ permanent: recipientsForMx.map((recipient) => ({
1019
+ recipient,
1020
+ accepted: false,
1021
+ responseCode: smtpError.smtpCode!,
1022
+ })),
1023
+ transient: [],
1024
+ }
1025
+ : { accepted: [], permanent: [], transient: [...recipientsForMx] }
1026
+ )
1027
+ )
1028
+ : { accepted: [], permanent: [], transient: [] };
1029
+ if (disposition.accepted.length > 0) {
1030
+ await this.queue.markRecipientsDelivered(item.id, disposition.accepted);
1031
+ acceptedForDomain.push(...disposition.accepted);
1032
+ }
1033
+ await this.checkpointPermanentRecipientFailures(
1034
+ item,
1035
+ disposition.permanent,
1036
+ );
1037
+ if (smtpError.phase === 'rcpt_to') {
1038
+ recipientsForMx = disposition.transient;
1039
+ }
1040
+ if (recipientsForMx.length > 0) {
1041
+ domainFailures.push(smtpError);
1042
+ }
1043
+ logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${smtpError.message}`);
602
1044
  }
603
1045
  }
604
1046
 
605
- if (!delivered) {
606
- results.push({ domain, success: false, error: lastError });
1047
+ if (recipientsForMx.length > 0) {
1048
+ const domainError = this.aggregateSmtpFailures(
1049
+ domainFailures,
1050
+ recipientsForMx,
1051
+ domain,
1052
+ );
1053
+ unresolvedFailures.push(domainError);
1054
+ unresolvedRecipients.push(...recipientsForMx);
1055
+ results.push({
1056
+ domain,
1057
+ success: false,
1058
+ error: domainError.message,
1059
+ accepted: acceptedForDomain,
1060
+ rejected: recipientsForMx,
1061
+ });
607
1062
  logger.log('error', `MTA: all MX hosts failed for ${domain}`);
1063
+ } else {
1064
+ results.push({
1065
+ domain,
1066
+ success: true,
1067
+ accepted: acceptedForDomain,
1068
+ rejected: [],
1069
+ });
608
1070
  }
609
1071
  }
610
1072
 
611
- const allFailed = results.every(r => !r.success);
612
- if (allFailed) {
613
- const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
614
- throw new Error(`MTA delivery failed for all domains: ${summary}`);
1073
+ if (unresolvedRecipients.length > 0) {
1074
+ throw this.aggregateSmtpFailures(unresolvedFailures, unresolvedRecipients);
615
1075
  }
616
1076
 
617
1077
  return {
1078
+ recipientDispositionManaged: true,
618
1079
  recipients: allRecipients.length,
619
1080
  domainResults: results,
620
1081
  };
@@ -684,11 +1145,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
684
1145
  }
685
1146
  }
686
1147
 
687
- // Apply DKIM signing if configured (after all transformations)
688
- if (item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim) {
689
- await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
690
- }
691
-
692
1148
  logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
693
1149
 
694
1150
  // After scanning + transformations, deliver via MTA
@@ -706,49 +1162,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
706
1162
  return filename.substring(filename.lastIndexOf('.')).toLowerCase();
707
1163
  }
708
1164
 
709
- /**
710
- * Apply DKIM signing to an email
711
- */
712
- private async applyDkimSigning(email: Email, mtaOptions: any): Promise<void> {
713
- if (!this.emailServer) {
714
- logger.log('warn', 'Cannot apply DKIM signing without email server reference');
715
- return;
716
- }
717
-
718
- const domainName = mtaOptions.dkimOptions?.domainName || email.from.split('@')[1];
719
- const keySelector = mtaOptions.dkimOptions?.keySelector || 'default';
720
-
721
- try {
722
- // Ensure DKIM keys exist for the exact selector advertised in the signature.
723
- await this.emailServer.dkimCreator.handleDKIMKeysForSelector(domainName, keySelector);
724
-
725
- // Load the private key for that same selector.
726
- const dkimPrivateKey = (
727
- await this.emailServer.dkimCreator.readDKIMKeysForSelector(domainName, keySelector)
728
- ).privateKey;
729
-
730
- // Convert Email to raw format for signing
731
- const rawEmail = email.toRFC822String();
732
-
733
- // Sign via Rust bridge
734
- const bridge = RustSecurityBridge.getInstance();
735
- const signResult = await bridge.signDkim({
736
- rawMessage: rawEmail,
737
- domain: domainName,
738
- selector: keySelector,
739
- privateKey: dkimPrivateKey,
740
- });
741
-
742
- if (signResult.header) {
743
- email.addHeader('DKIM-Signature', signResult.header);
744
- logger.log('info', `Successfully added DKIM signature for ${domainName}`);
745
- }
746
- } catch (error) {
747
- logger.log('error', `Failed to apply DKIM signature: ${error.message}`);
748
- // Don't throw - allow email to be sent without DKIM if signing fails
749
- }
750
- }
751
-
752
1165
  /**
753
1166
  * Update delivery time statistics
754
1167
  */