@push.rocks/smartmta 9.3.0 → 9.4.1

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.
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { logger } from '../../logger.js';
4
+ import { getErrorMessage } from '../../functions.errors.js';
4
5
  import {
5
6
  SecurityLogger,
6
7
  SecurityLogLevel,
@@ -16,6 +17,7 @@ import { Email } from '../core/classes.email.js';
16
17
  import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
17
18
  import { SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
18
19
  import type {
20
+ IOutboundAuthenticationCheck,
19
21
  ISmtpRecipientResult,
20
22
  ISmtpSendResult,
21
23
  } from '../../security/classes.rustsecuritybridge.js';
@@ -146,6 +148,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
146
148
  private emailServer?: UnifiedEmailServer;
147
149
  private bounceDrainTimer?: NodeJS.Timeout;
148
150
  private bounceDrainPromise?: Promise<void>;
151
+ private readonly queueItemsReadyHandler = (items: IQueueItem[]): Promise<void> =>
152
+ this.processItems(items);
153
+ private queueListenerRegistered = false;
154
+ private lifecycleGeneration = 0;
155
+ private pendingSmtpAuthenticationUpdates = new Map<string, {
156
+ itemId: string;
157
+ transactionId: string;
158
+ transaction: ISmtpTransactionAttempt;
159
+ }>();
149
160
 
150
161
  /**
151
162
  * Create a new multi-mode delivery system
@@ -218,8 +229,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
218
229
  }
219
230
  };
220
231
 
221
- // Set up event listeners
222
- this.queue.on('itemsReady', this.processItems.bind(this));
232
+ }
233
+
234
+ private registerQueueListener(): void {
235
+ if (this.queueListenerRegistered) return;
236
+ this.queue.on('itemsReady', this.queueItemsReadyHandler);
237
+ this.queueListenerRegistered = true;
238
+ }
239
+
240
+ private unregisterQueueListener(): void {
241
+ if (!this.queueListenerRegistered) return;
242
+ this.queue.off('itemsReady', this.queueItemsReadyHandler);
243
+ this.queueListenerRegistered = false;
223
244
  }
224
245
 
225
246
  private createEmailForRecipients(email: Email, recipients: string[]): Email {
@@ -250,9 +271,43 @@ export class MultiModeDeliverySystem extends EventEmitter {
250
271
  recipients,
251
272
  recipientResults: error.recipientResults,
252
273
  ...(recipientDomain ? { recipientDomain } : {}),
274
+ ...(error.outboundAuthenticationHandle ? { outboundAuthenticationHandle: error.outboundAuthenticationHandle } : {}),
253
275
  }, error);
254
276
  }
255
277
 
278
+ private smtpAuthenticationUpdateKey(itemIdArg: string, transactionIdArg: string): string {
279
+ return `${itemIdArg}\u0000${transactionIdArg}`;
280
+ }
281
+
282
+ private async persistSmtpAuthenticationUpdate(updateArg: {
283
+ itemId: string;
284
+ transactionId: string;
285
+ transaction: ISmtpTransactionAttempt;
286
+ }): Promise<void> {
287
+ const updated = await this.queue.updateSmtpTransaction(
288
+ updateArg.itemId,
289
+ updateArg.transactionId,
290
+ updateArg.transaction,
291
+ );
292
+ if (updated) await emitSafely(this, 'smtpTransactionUpdated', updateArg.transaction);
293
+ }
294
+
295
+ private async flushPendingSmtpAuthenticationUpdates(itemIdArg: string): Promise<void> {
296
+ const updates = [...this.pendingSmtpAuthenticationUpdates.entries()]
297
+ .filter(([, update]) => update.itemId === itemIdArg);
298
+ for (const [key, update] of updates) {
299
+ this.pendingSmtpAuthenticationUpdates.delete(key);
300
+ try {
301
+ await this.persistSmtpAuthenticationUpdate(update);
302
+ } catch (error) {
303
+ logger.log(
304
+ 'warn',
305
+ `Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
306
+ );
307
+ }
308
+ }
309
+ }
310
+
256
311
  private async persistSmtpAttempt(optionsArg: {
257
312
  item: IQueueItem;
258
313
  targetHost: string;
@@ -265,6 +320,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
265
320
  }): Promise<ISmtpTransactionAttempt> {
266
321
  const completedAt = new Date();
267
322
  const error = optionsArg.error;
323
+ const authenticationHandle = error?.outboundAuthenticationHandle || optionsArg.result?.outboundAuthenticationHandle;
268
324
  const attempt: ISmtpTransactionAttempt = {
269
325
  id: plugins.uuid.v4(),
270
326
  queueItemId: optionsArg.item.id,
@@ -285,9 +341,48 @@ export class MultiModeDeliverySystem extends EventEmitter {
285
341
  recipientResults: error?.recipientResults || optionsArg.result?.recipientResults,
286
342
  ...(error ? { failurePhase: error.phase, error: error.message } : {}),
287
343
  transcript: error?.transactionLog || optionsArg.result?.transactionLog || [],
344
+ ...(authenticationHandle ? { outboundAuthentication: authenticationHandle.pending } : {}),
288
345
  };
289
346
  await this.queue.appendSmtpTransaction(optionsArg.item.id, attempt);
290
347
  await emitSafely(this, 'smtpTransactionCompleted', attempt);
348
+ if (authenticationHandle) {
349
+ const persistAuthentication = async (authenticationArg: IOutboundAuthenticationCheck): Promise<void> => {
350
+ const updatedAttempt: ISmtpTransactionAttempt = {
351
+ ...attempt,
352
+ outboundAuthentication: authenticationArg,
353
+ };
354
+ const update = {
355
+ itemId: optionsArg.item.id,
356
+ transactionId: attempt.id,
357
+ transaction: updatedAttempt,
358
+ };
359
+ // Delivery status and recipient checkpoints use whole-item durable
360
+ // mutations. Defer this diagnostic replacement until that lifecycle
361
+ // has settled so it cannot race and restore an earlier queue status.
362
+ if (this.activeDeliveries.has(optionsArg.item.id)) {
363
+ this.pendingSmtpAuthenticationUpdates.set(
364
+ this.smtpAuthenticationUpdateKey(optionsArg.item.id, attempt.id),
365
+ update,
366
+ );
367
+ return;
368
+ }
369
+ await this.persistSmtpAuthenticationUpdate(update);
370
+ };
371
+ void authenticationHandle.result.then(
372
+ persistAuthentication,
373
+ (failureArg) => persistAuthentication({
374
+ state: 'indeterminate',
375
+ overall: 'indeterminate',
376
+ checkedAt: new Date().toISOString(),
377
+ error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
378
+ }),
379
+ ).catch((error) => {
380
+ logger.log(
381
+ 'warn',
382
+ `Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
383
+ );
384
+ });
385
+ }
291
386
  return attempt;
292
387
  }
293
388
 
@@ -429,14 +524,33 @@ export class MultiModeDeliverySystem extends EventEmitter {
429
524
  return;
430
525
  }
431
526
 
527
+ const lifecycleGeneration = ++this.lifecycleGeneration;
432
528
  this.running = true;
433
- await this.drainBounceOutbox();
434
- this.bounceDrainTimer = setInterval(() => {
435
- void this.drainBounceOutbox();
436
- }, 30_000);
529
+ this.registerQueueListener();
530
+ try {
531
+ await this.drainBounceOutbox();
532
+ if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
533
+
534
+ this.bounceDrainTimer = setInterval(() => {
535
+ void this.drainBounceOutbox();
536
+ }, 30_000);
537
+
538
+ await emitSafely(this, 'started');
539
+ if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
437
540
 
438
- await emitSafely(this, 'started');
439
- logger.log('info', 'MultiModeDeliverySystem started successfully');
541
+ this.queue.wake();
542
+ logger.log('info', 'MultiModeDeliverySystem started successfully');
543
+ } catch (error) {
544
+ if (lifecycleGeneration === this.lifecycleGeneration) {
545
+ this.running = false;
546
+ this.unregisterQueueListener();
547
+ if (this.bounceDrainTimer) {
548
+ clearInterval(this.bounceDrainTimer);
549
+ this.bounceDrainTimer = undefined;
550
+ }
551
+ }
552
+ throw error;
553
+ }
440
554
  }
441
555
 
442
556
  /**
@@ -446,11 +560,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
446
560
  logger.log('info', 'Stopping MultiModeDeliverySystem');
447
561
 
448
562
  if (!this.running) {
563
+ this.unregisterQueueListener();
449
564
  logger.log('warn', 'MultiModeDeliverySystem is already stopped');
450
565
  return;
451
566
  }
567
+
568
+ const lifecycleGeneration = ++this.lifecycleGeneration;
452
569
 
453
570
  this.running = false;
571
+ this.unregisterQueueListener();
454
572
  if (this.bounceDrainTimer) {
455
573
  clearInterval(this.bounceDrainTimer);
456
574
  this.bounceDrainTimer = undefined;
@@ -461,6 +579,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
461
579
  clearTimeout(this.throttleResetTimer);
462
580
  this.throttleResetTimer = null;
463
581
  }
582
+ this.throttled = false;
583
+ this.rateLimitCounter = 0;
584
+ this.rateLimitLastCheck = Date.now();
585
+ this.stats.rateLimiting.currentRate = 0;
464
586
 
465
587
  // Wait for active deliveries to complete
466
588
  if (this.activeDeliveries.size > 0) {
@@ -485,6 +607,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
485
607
  }
486
608
 
487
609
  await this.bounceDrainPromise;
610
+ if (lifecycleGeneration !== this.lifecycleGeneration) return;
611
+
488
612
  await emitSafely(this, 'stopped');
489
613
  logger.log('info', 'MultiModeDeliverySystem stopped successfully');
490
614
  }
@@ -504,17 +628,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
504
628
  return;
505
629
  }
506
630
 
507
- // Check rate limiting
508
- if (this.checkRateLimit()) {
509
- logger.log('debug', 'Rate limit exceeded, throttling deliveries');
510
- return;
511
- }
512
-
513
631
  let startedDeliveries = 0;
514
632
 
515
633
  for (const item of items) {
516
634
  if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
517
635
  if (this.activeDeliveries.has(item.id)) continue;
636
+ if (this.checkRateLimit()) {
637
+ logger.log('debug', 'Rate limit exceeded, throttling deliveries');
638
+ break;
639
+ }
518
640
 
519
641
  // Reserve synchronously before the first await so overlapping queue events
520
642
  // cannot dispatch the same item while its durable claim is in flight.
@@ -733,9 +855,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
733
855
  // Remove from active deliveries
734
856
  this.activeDeliveries.delete(item.id);
735
857
  this.stats.activeDeliveries = this.activeDeliveries.size;
858
+ await this.flushPendingSmtpAuthenticationUpdates(item.id);
736
859
 
737
860
  // Update statistics
738
861
  await emitSafely(this, 'statsUpdated', this.stats);
862
+ this.queue.wake();
739
863
  }
740
864
  }
741
865
 
@@ -1321,7 +1445,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
1321
1445
  this.rateLimitCounter = 0;
1322
1446
  this.throttled = false;
1323
1447
  this.stats.rateLimiting.currentRate = 0;
1324
- return false;
1325
1448
  }
1326
1449
 
1327
1450
  // Check if we're already throttled
@@ -1329,15 +1452,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
1329
1452
  return true;
1330
1453
  }
1331
1454
 
1332
- // Increment counter
1333
- this.rateLimitCounter++;
1334
-
1335
- // Calculate current rate (emails per minute)
1336
- const rate = (this.rateLimitCounter / elapsed) * 60000;
1337
- this.stats.rateLimiting.currentRate = rate;
1338
-
1339
- // Check if rate limit is exceeded
1340
- if (rate > this.options.globalRateLimit) {
1455
+ // Use a fixed one-minute window. Extrapolating the first delivery from a
1456
+ // few elapsed milliseconds falsely throttles every freshly started queue.
1457
+ if (this.rateLimitCounter >= this.options.globalRateLimit) {
1341
1458
  this.throttled = true;
1342
1459
  this.stats.rateLimiting.throttled++;
1343
1460
 
@@ -1352,10 +1469,16 @@ export class MultiModeDeliverySystem extends EventEmitter {
1352
1469
  this.rateLimitLastCheck = Date.now();
1353
1470
  this.rateLimitCounter = 0;
1354
1471
  this.stats.rateLimiting.currentRate = 0;
1472
+ if (this.running) {
1473
+ this.queue.wake();
1474
+ }
1355
1475
  }, resetDelay);
1356
1476
 
1357
1477
  return true;
1358
1478
  }
1479
+
1480
+ this.rateLimitCounter++;
1481
+ this.stats.rateLimiting.currentRate = this.rateLimitCounter;
1359
1482
 
1360
1483
  return false;
1361
1484
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type {
6
+ IOutboundAuthenticationCheck,
6
7
  ISmtpRecipientResult,
7
8
  ISmtpTranscriptEntry,
8
9
  TSmtpDeliveryPhase,
@@ -27,6 +28,7 @@ export interface ISmtpTransactionAttempt {
27
28
  failurePhase?: TSmtpDeliveryPhase;
28
29
  error?: string;
29
30
  transcript: ISmtpTranscriptEntry[];
31
+ outboundAuthentication?: IOutboundAuthenticationCheck;
30
32
  }
31
33
 
32
34
  /**
@@ -9,8 +9,10 @@ import {
9
9
  } from '../../security/index.js';
10
10
  import { DKIMCreator } from '../security/classes.dkimcreator.js';
11
11
  import type { IStorageManager } from '../interfaces.storage.js';
12
- import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
12
+ import { RustSecurityBridge, SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
13
13
  import type {
14
+ IOutboundAuthenticationCheck,
15
+ IOutboundAuthenticationHandle,
14
16
  IAuthRequestEvent,
15
17
  IEmailReceivedEvent,
16
18
  IEmailSecurityResult,
@@ -258,6 +260,12 @@ export interface IOutboundConnectionProxyContext {
258
260
  senderDomain?: string;
259
261
  }
260
262
 
263
+ export interface IOutboundConnectionSourceEvidence {
264
+ sourceIp: string;
265
+ addressFamily: 'ipv4' | 'ipv6';
266
+ }
267
+
268
+
261
269
  export interface IOutboundConnectionProxy {
262
270
  connectHost: string;
263
271
  connectPort: number;
@@ -265,6 +273,8 @@ export interface IOutboundConnectionProxy {
265
273
  /** Per-connection EHLO identity override (e.g. the selected egress edge's PTR hostname). */
266
274
  ehloHostname?: string;
267
275
  close?: () => Promise<void> | void;
276
+ /** Actual source identity observed after the proxied connection is established. */
277
+ sourceEvidence?: Promise<IOutboundConnectionSourceEvidence | null>;
268
278
  }
269
279
 
270
280
  export interface IOutboundTopologyIdentity {
@@ -937,8 +947,28 @@ export class UnifiedEmailServer extends EventEmitter {
937
947
  senderDomain,
938
948
  });
939
949
 
950
+ const captureId = proxy?.sourceEvidence ? plugins.uuid.v4() : undefined;
951
+ const attachAuthenticationHandle = <T extends ISmtpSendResult | SmtpDeliveryError>(valueArg: T): T => {
952
+ if (!captureId || !proxy?.sourceEvidence) return valueArg;
953
+ const handle = this.createOutboundAuthenticationHandle({
954
+ captureId,
955
+ sourceEvidence: proxy.sourceEvidence,
956
+ heloDomain: proxy.ehloHostname || this.getOutboundHostname(),
957
+ receiverHostname: host,
958
+ envelopeFrom: email.from,
959
+ dkimDomain: dkim?.domain,
960
+ dkimSelector: dkim?.selector,
961
+ });
962
+ Object.defineProperty(valueArg, 'outboundAuthenticationHandle', {
963
+ value: handle,
964
+ enumerable: false,
965
+ configurable: true,
966
+ });
967
+ return valueArg;
968
+ };
969
+
940
970
  try {
941
- return await this.rustBridge.sendOutboundEmail({
971
+ const result = await this.rustBridge.sendOutboundEmail({
942
972
  host,
943
973
  port,
944
974
  ...(proxy ? { connectHost: proxy.connectHost, connectPort: proxy.connectPort } : {}),
@@ -953,12 +983,99 @@ export class UnifiedEmailServer extends EventEmitter {
953
983
  maxPoolConnections: this.options.outbound?.maxConnections || 10,
954
984
  tlsOpportunistic,
955
985
  ...(proxy ? { disablePooling: true } : {}),
986
+ ...(captureId ? { outboundAuthenticationCaptureId: captureId } : {}),
956
987
  });
988
+ return attachAuthenticationHandle(result);
989
+ } catch (error) {
990
+ throw attachAuthenticationHandle(SmtpDeliveryError.from(error));
957
991
  } finally {
958
992
  await proxy?.close?.();
959
993
  }
960
994
  }
961
995
 
996
+ private createOutboundAuthenticationHandle(
997
+ optionsArg: {
998
+ captureId: string;
999
+ sourceEvidence: Promise<IOutboundConnectionSourceEvidence | null>;
1000
+ heloDomain: string;
1001
+ receiverHostname: string;
1002
+ envelopeFrom: string;
1003
+ dkimDomain?: string;
1004
+ dkimSelector?: string;
1005
+ },
1006
+ ): IOutboundAuthenticationHandle {
1007
+ const pending: IOutboundAuthenticationCheck = {
1008
+ state: 'pending',
1009
+ overall: 'indeterminate',
1010
+ checkedAt: new Date().toISOString(),
1011
+ };
1012
+ const indeterminate = (errorArg: string): IOutboundAuthenticationCheck => ({
1013
+ state: 'indeterminate',
1014
+ overall: 'indeterminate',
1015
+ checkedAt: new Date().toISOString(),
1016
+ error: errorArg,
1017
+ });
1018
+ const result = (async (): Promise<IOutboundAuthenticationCheck> => {
1019
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1020
+ try {
1021
+ const sourceEvidence = await new Promise<IOutboundConnectionSourceEvidence | null>((resolve, reject) => {
1022
+ timeout = setTimeout(() => {
1023
+ reject(new Error('Timed out waiting for the actual outbound source identity'));
1024
+ }, 40_000);
1025
+ timeout.unref?.();
1026
+ optionsArg.sourceEvidence.then(resolve, reject);
1027
+ });
1028
+ if (!sourceEvidence) {
1029
+ await this.rustBridge.discardCapturedOutboundEmail(optionsArg.captureId).catch(() => undefined);
1030
+ return indeterminate('The outbound proxy did not provide source identity evidence');
1031
+ }
1032
+ const verification = await this.rustBridge.verifyCapturedOutboundEmail({
1033
+ captureId: optionsArg.captureId,
1034
+ ip: sourceEvidence.sourceIp,
1035
+ heloDomain: optionsArg.heloDomain,
1036
+ hostname: optionsArg.receiverHostname,
1037
+ mailFrom: optionsArg.envelopeFrom,
1038
+ });
1039
+ if (verification.state !== 'completed' || !verification.result) {
1040
+ return indeterminate(verification.error || 'The outbound authentication self-check was not completed');
1041
+ }
1042
+ const security = verification.result;
1043
+ const dkimPass = !optionsArg.dkimDomain || security.dkim.some((resultArg) => (
1044
+ resultArg.is_valid
1045
+ && resultArg.domain?.toLowerCase() === optionsArg.dkimDomain?.toLowerCase()
1046
+ && (!optionsArg.dkimSelector || resultArg.selector === optionsArg.dkimSelector)
1047
+ ));
1048
+ const spfPass = security.spf?.result.toLowerCase() === 'pass';
1049
+ const dmarcPass = security.dmarc?.passed === true;
1050
+ return {
1051
+ state: 'completed',
1052
+ overall: dkimPass && spfPass && dmarcPass ? 'pass' : 'fail',
1053
+ checkedAt: new Date().toISOString(),
1054
+ signedMessageSha256: verification.signedMessageSha256,
1055
+ identity: {
1056
+ sourceIp: sourceEvidence.sourceIp,
1057
+ addressFamily: sourceEvidence.addressFamily,
1058
+ heloDomain: optionsArg.heloDomain,
1059
+ receiverHostname: optionsArg.receiverHostname,
1060
+ envelopeFrom: optionsArg.envelopeFrom,
1061
+ ...(optionsArg.dkimDomain ? { dkimDomain: optionsArg.dkimDomain } : {}),
1062
+ ...(optionsArg.dkimSelector ? { dkimSelector: optionsArg.dkimSelector } : {}),
1063
+ },
1064
+ dkim: security.dkim,
1065
+ spf: security.spf,
1066
+ dmarc: security.dmarc,
1067
+ };
1068
+ } catch (error) {
1069
+ await this.rustBridge.discardCapturedOutboundEmail(optionsArg.captureId).catch(() => undefined);
1070
+ return indeterminate(getErrorMessage(error).slice(0, 512));
1071
+ } finally {
1072
+ if (timeout) clearTimeout(timeout);
1073
+ }
1074
+ })();
1075
+ return { pending, result };
1076
+ }
1077
+
1078
+
962
1079
  /**
963
1080
  * Start the unified email server
964
1081
  */
@@ -242,6 +242,7 @@ export interface ISmtpDeliveryErrorDetails {
242
242
  recipients?: string[];
243
243
  recipientDomain?: string;
244
244
  recipientResults?: ISmtpRecipientResult[];
245
+ outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
245
246
  }
246
247
 
247
248
  interface ISmtpRecipientResult {
@@ -259,6 +260,7 @@ export class SmtpDeliveryError extends Error implements ISmtpDeliveryErrorDetail
259
260
  public readonly recipients: string[];
260
261
  public readonly recipientDomain?: string;
261
262
  public readonly recipientResults: ISmtpRecipientResult[];
263
+ public readonly outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
262
264
 
263
265
  constructor(details: ISmtpDeliveryErrorDetails, cause?: unknown) {
264
266
  const transactionLog = normalizeSmtpTranscript(details.transactionLog);
@@ -277,6 +279,7 @@ export class SmtpDeliveryError extends Error implements ISmtpDeliveryErrorDetail
277
279
  this.recipients = details.recipients || [];
278
280
  this.recipientDomain = details.recipientDomain;
279
281
  this.recipientResults = (details.recipientResults || []).map((result) => ({ ...result }));
282
+ this.outboundAuthenticationHandle = details.outboundAuthenticationHandle;
280
283
  if (cause !== undefined && !authFailure) {
281
284
  (this as Error & { cause?: unknown }).cause = cause;
282
285
  }
@@ -370,6 +373,31 @@ interface IOutboundEmail {
370
373
  }>;
371
374
  }
372
375
 
376
+ export interface IOutboundAuthenticationCheck {
377
+ state: 'pending' | 'completed' | 'indeterminate' | 'not-evaluated';
378
+ overall: 'pass' | 'fail' | 'indeterminate';
379
+ checkedAt: string;
380
+ signedMessageSha256?: string;
381
+ error?: string;
382
+ identity?: {
383
+ sourceIp: string;
384
+ addressFamily: 'ipv4' | 'ipv6';
385
+ heloDomain: string;
386
+ receiverHostname: string;
387
+ envelopeFrom: string;
388
+ dkimDomain?: string;
389
+ dkimSelector?: string;
390
+ };
391
+ dkim?: IDkimVerificationResult[];
392
+ spf?: ISpfResult | null;
393
+ dmarc?: IDmarcResult | null;
394
+ }
395
+
396
+ export interface IOutboundAuthenticationHandle {
397
+ pending: IOutboundAuthenticationCheck;
398
+ result: Promise<IOutboundAuthenticationCheck>;
399
+ }
400
+
373
401
  interface ISmtpSendResult {
374
402
  accepted: string[];
375
403
  rejected: string[];
@@ -378,6 +406,7 @@ interface ISmtpSendResult {
378
406
  response: string;
379
407
  envelope: { from: string; to: string[] };
380
408
  transactionLog: ISmtpTranscriptEntry[];
409
+ outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
381
410
  }
382
411
 
383
412
  interface ISmtpSendOptions {
@@ -396,6 +425,14 @@ interface ISmtpSendOptions {
396
425
  maxPoolConnections?: number;
397
426
  tlsOpportunistic?: boolean;
398
427
  disablePooling?: boolean;
428
+ outboundAuthenticationCaptureId?: string;
429
+ }
430
+
431
+ interface IVerifyCapturedOutboundEmailResult {
432
+ state: 'completed' | 'indeterminate';
433
+ signedMessageSha256?: string;
434
+ error?: string;
435
+ result?: IEmailSecurityResult;
399
436
  }
400
437
 
401
438
  interface ISmtpSendRawOptions {
@@ -634,6 +671,20 @@ type TMailerCommands = {
634
671
  params: ISmtpSendRawOptions;
635
672
  result: ISmtpSendResult;
636
673
  };
674
+ verifyCapturedOutboundEmail: {
675
+ params: {
676
+ captureId: string;
677
+ ip: string;
678
+ heloDomain: string;
679
+ hostname: string;
680
+ mailFrom: string;
681
+ };
682
+ result: IVerifyCapturedOutboundEmailResult;
683
+ };
684
+ discardCapturedOutboundEmail: {
685
+ params: { captureId: string };
686
+ result: { discarded: boolean };
687
+ };
637
688
  verifySmtpConnection: {
638
689
  params: ISmtpVerifyOptions;
639
690
  result: ISmtpVerifyResult;
@@ -1189,6 +1240,7 @@ export class RustSecurityBridge extends EventEmitter {
1189
1240
  this.ensureRunning();
1190
1241
  try {
1191
1242
  const result = await this.bridge.sendCommand('sendRawEmail', opts);
1243
+
1192
1244
  return {
1193
1245
  ...result,
1194
1246
  transactionLog: normalizeSmtpTranscript(result.transactionLog),
@@ -1198,6 +1250,22 @@ export class RustSecurityBridge extends EventEmitter {
1198
1250
  }
1199
1251
  }
1200
1252
 
1253
+ public async verifyCapturedOutboundEmail(opts: {
1254
+ captureId: string;
1255
+ ip: string;
1256
+ heloDomain: string;
1257
+ hostname: string;
1258
+ mailFrom: string;
1259
+ }): Promise<IVerifyCapturedOutboundEmailResult> {
1260
+ this.ensureRunning();
1261
+ return this.bridge.sendCommand('verifyCapturedOutboundEmail', opts);
1262
+ }
1263
+
1264
+ public async discardCapturedOutboundEmail(captureId: string): Promise<void> {
1265
+ this.ensureRunning();
1266
+ await this.bridge.sendCommand('discardCapturedOutboundEmail', { captureId });
1267
+ }
1268
+
1201
1269
  /** Verify connectivity to an SMTP server. */
1202
1270
  public async verifySmtpConnection(opts: ISmtpVerifyOptions): Promise<ISmtpVerifyResult> {
1203
1271
  this.ensureRunning();