@push.rocks/smartmta 9.2.0 → 9.4.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.
@@ -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 {
@@ -334,6 +344,19 @@ export interface IUnifiedEmailServerOptions {
334
344
  recipientValidation?: boolean;
335
345
  /** Listener port that should use implicit TLS. Defaults to 465 when present. */
336
346
  securePort?: number;
347
+ /**
348
+ * Listener ports whose inbound transport is already encrypted by a trusted
349
+ * upstream TLS terminator (an edge proxy that terminates TLS and forwards
350
+ * the decrypted stream here). Sessions on these ports count as encrypted:
351
+ * AUTH is offered even though this process performed no handshake, and
352
+ * STARTTLS is not advertised.
353
+ *
354
+ * Only honoured together with `proxyProtocol.required` — the exact-peer
355
+ * PROXY check is what proves the connection came from the terminator.
356
+ * Startup fails when a listed port is not bound, is the implicit-TLS
357
+ * `securePort`, or PROXY protocol is not required.
358
+ */
359
+ tlsTerminatedPorts?: number[];
337
360
  /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
338
361
  recipientValidationTimeoutMs?: number;
339
362
  /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
@@ -924,8 +947,28 @@ export class UnifiedEmailServer extends EventEmitter {
924
947
  senderDomain,
925
948
  });
926
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
+
927
970
  try {
928
- return await this.rustBridge.sendOutboundEmail({
971
+ const result = await this.rustBridge.sendOutboundEmail({
929
972
  host,
930
973
  port,
931
974
  ...(proxy ? { connectHost: proxy.connectHost, connectPort: proxy.connectPort } : {}),
@@ -940,12 +983,99 @@ export class UnifiedEmailServer extends EventEmitter {
940
983
  maxPoolConnections: this.options.outbound?.maxConnections || 10,
941
984
  tlsOpportunistic,
942
985
  ...(proxy ? { disablePooling: true } : {}),
986
+ ...(captureId ? { outboundAuthenticationCaptureId: captureId } : {}),
943
987
  });
988
+ return attachAuthenticationHandle(result);
989
+ } catch (error) {
990
+ throw attachAuthenticationHandle(SmtpDeliveryError.from(error));
944
991
  } finally {
945
992
  await proxy?.close?.();
946
993
  }
947
994
  }
948
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
+
949
1079
  /**
950
1080
  * Start the unified email server
951
1081
  */
@@ -1182,6 +1312,7 @@ export class UnifiedEmailServer extends EventEmitter {
1182
1312
  ? configuredSecurePort
1183
1313
  : (this.options.ports as number[]).find(p => p === 465);
1184
1314
  const smtpPorts = (this.options.ports as number[]).filter(p => p !== securePort);
1315
+ const tlsTerminatedPorts = this.resolveTlsTerminatedPorts(smtpPorts, securePort);
1185
1316
 
1186
1317
  const started = await this.rustBridge.startSmtpServer({
1187
1318
  hostname: this.getAdvertisedHostname(),
@@ -1189,6 +1320,7 @@ export class UnifiedEmailServer extends EventEmitter {
1189
1320
  securePort: securePort,
1190
1321
  tlsCertPem,
1191
1322
  tlsKeyPem,
1323
+ ...(tlsTerminatedPorts.length > 0 ? { tlsTerminatedPorts } : {}),
1192
1324
  maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024,
1193
1325
  maxConnections: this.options.maxConnections || this.options.maxClients || 100,
1194
1326
  maxRecipients: 100,
@@ -1222,6 +1354,44 @@ export class UnifiedEmailServer extends EventEmitter {
1222
1354
  logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
1223
1355
  }
1224
1356
 
1357
+ /**
1358
+ * Validate and resolve the ports whose transport an upstream proxy already
1359
+ * encrypted. Misconfiguration here would let a peer claim an encrypted
1360
+ * channel and be offered AUTH in cleartext, so every case fails startup
1361
+ * rather than degrading silently.
1362
+ */
1363
+ private resolveTlsTerminatedPorts(smtpPorts: number[], securePort: number | undefined): number[] {
1364
+ const configured = this.options.smtp?.tlsTerminatedPorts;
1365
+ if (!configured || configured.length === 0) return [];
1366
+ if (!this.options.smtp?.proxyProtocol?.required) {
1367
+ throw new Error(
1368
+ 'SMTP tlsTerminatedPorts requires smtp.proxyProtocol.required so the upstream TLS terminator is proven by the trusted-peer PROXY check',
1369
+ );
1370
+ }
1371
+ if (!this.options.smtp.proxyProtocol.trustedIps?.length) {
1372
+ throw new Error(
1373
+ 'SMTP tlsTerminatedPorts requires at least one smtp.proxyProtocol.trustedIps entry',
1374
+ );
1375
+ }
1376
+ const resolved: number[] = [];
1377
+ for (const port of configured) {
1378
+ if (securePort !== undefined && port === securePort) {
1379
+ throw new Error(
1380
+ `SMTP tlsTerminatedPorts names port ${port}, which is already the implicit-TLS securePort terminated by this server`,
1381
+ );
1382
+ }
1383
+ if (!smtpPorts.includes(port)) {
1384
+ throw new Error(
1385
+ `SMTP tlsTerminatedPorts names port ${port}, which is not one of the configured SMTP listener ports`,
1386
+ );
1387
+ }
1388
+ if (!resolved.includes(port)) {
1389
+ resolved.push(port);
1390
+ }
1391
+ }
1392
+ return resolved;
1393
+ }
1394
+
1225
1395
  /** Whether the current options require SMTP AUTH support on the listener. */
1226
1396
  private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
1227
1397
  return !!options.auth?.required
@@ -1452,10 +1622,18 @@ export class UnifiedEmailServer extends EventEmitter {
1452
1622
  text: '',
1453
1623
  });
1454
1624
  const session = sessionArg || this.buildPolicySession(currentRecipientContext);
1455
- const route = await this.emailRouter.evaluateRoutes({ email, session });
1625
+ const matchedRoute = await this.emailRouter.evaluateRoutes({ email, session });
1456
1626
  const recipientDomain = this.getAddressDomain(context.rcptTo);
1457
1627
  const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
1458
1628
  const localDomain = isLocalRecipient ? recipientDomain! : null;
1629
+ // A submission-permission route must never claim a recipient this server
1630
+ // itself terminates: its transport is DNS-MX relay, which for such a
1631
+ // recipient resolves straight back to this listener. Authentication grants
1632
+ // permission to relay, it does not turn a local delivery into a remote one.
1633
+ const route = matchedRoute
1634
+ && this.isSubmissionPermissionRouteForLocalRecipient(matchedRoute, recipientDomain, isLocalRecipient)
1635
+ ? null
1636
+ : matchedRoute;
1459
1637
 
1460
1638
  if (route) {
1461
1639
  if (route.action.type === 'reject') {
@@ -1585,6 +1763,49 @@ export class UnifiedEmailServer extends EventEmitter {
1585
1763
  };
1586
1764
  }
1587
1765
 
1766
+ /**
1767
+ * Whether a matched route is a pure submission-permission route that must
1768
+ * not claim a recipient whose inbound mail this server terminates.
1769
+ *
1770
+ * A route qualifies only when ALL of the following hold:
1771
+ * - the recipient domain is registered here AND its inbound policy stores
1772
+ * mail locally, i.e. this server really is the destination. A domain
1773
+ * registered for outbound purposes only (`inboundPolicy` absent, or
1774
+ * `defaultAction: 'reject'` for send-only domains) keeps relaying, because
1775
+ * its real MX legitimately lives elsewhere;
1776
+ * - the action is `process`, the only action whose transport is DNS-MX
1777
+ * relay. `store`, `deliver`, `forward` and `reject` all express a
1778
+ * deliberate disposition for the recipient and always win;
1779
+ * - the route names no recipient scope. `match.recipients` is the only way a
1780
+ * route states which recipients it is for, and a pattern that matches every
1781
+ * address states nothing. Recipient-scoped `process` routes keep winning —
1782
+ * consumers use them for local addresses handled by their own acceptance
1783
+ * pipeline. Encoding recipient intent in `match.headers` is not supported
1784
+ * for this purpose.
1785
+ */
1786
+ private isSubmissionPermissionRouteForLocalRecipient(
1787
+ route: IEmailRoute,
1788
+ recipientDomain: string | undefined,
1789
+ isLocalRecipient: boolean,
1790
+ ): boolean {
1791
+ if (!isLocalRecipient || !recipientDomain) return false;
1792
+ if (route.action.type !== 'process') return false;
1793
+ if (this.domainRegistry.getDomainConfig(recipientDomain)?.inboundPolicy?.defaultAction !== 'store') {
1794
+ return false;
1795
+ }
1796
+ return !this.routeNamesRecipientScope(route.match.recipients);
1797
+ }
1798
+
1799
+ /** Whether a recipient pattern list narrows the route to specific recipients. */
1800
+ private routeNamesRecipientScope(patterns: string | string[] | undefined): boolean {
1801
+ if (patterns === undefined) return false;
1802
+ const patternArray = Array.isArray(patterns) ? patterns : [patterns];
1803
+ return patternArray.some((pattern) => {
1804
+ const normalized = String(pattern || '').trim().toLowerCase();
1805
+ return normalized.length > 0 && normalized !== '*' && normalized !== '*@*';
1806
+ });
1807
+ }
1808
+
1588
1809
  private getRejectionReasonForResolution(
1589
1810
  resolution: IRecipientPolicyResolution,
1590
1811
  ): TRecipientRejectionReason {
@@ -3321,6 +3542,16 @@ export class UnifiedEmailServer extends EventEmitter {
3321
3542
  (!this.options.ports ||
3322
3543
  JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
3323
3544
  const authEnabledBefore = this.isAuthConfigured(this.options);
3545
+ // TLS material and the upstream-terminated port list are listener-level
3546
+ // Rust configuration too. Without a restart a renewed certificate or a
3547
+ // changed port list would be merged into options and silently never take
3548
+ // effect on the running listener.
3549
+ const tlsMaterialChanged = options.tls !== undefined
3550
+ && (options.tls.certPem !== this.options.tls?.certPem
3551
+ || options.tls.keyPem !== this.options.tls?.keyPem);
3552
+ const tlsTerminatedPortsChanged = options.smtp !== undefined
3553
+ && JSON.stringify(options.smtp.tlsTerminatedPorts ?? [])
3554
+ !== JSON.stringify(this.options.smtp?.tlsTerminatedPorts ?? []);
3324
3555
 
3325
3556
  if (portsChanged) {
3326
3557
  this.stop().then(() => {
@@ -3361,15 +3592,16 @@ export class UnifiedEmailServer extends EventEmitter {
3361
3592
  // auth presence transitions (zero -> first account/user or reverse)
3362
3593
  // the running listener must be restarted for AUTH to take effect
3363
3594
  // without a full process restart.
3364
- if (
3365
- options.auth
3366
- && this.smtpListenerActive
3367
- && !this.smtpListenerShutdown
3595
+ const listenerRestartReason = options.auth
3368
3596
  && this.isAuthConfigured(this.options) !== authEnabledBefore
3369
- ) {
3370
- this.scheduleSmtpListenerRestart(
3371
- `SMTP AUTH ${this.isAuthConfigured(this.options) ? 'enabled' : 'disabled'} by configuration update`,
3372
- );
3597
+ ? `SMTP AUTH ${this.isAuthConfigured(this.options) ? 'enabled' : 'disabled'} by configuration update`
3598
+ : tlsMaterialChanged
3599
+ ? 'SMTP TLS material replaced by configuration update'
3600
+ : tlsTerminatedPortsChanged
3601
+ ? 'SMTP upstream-terminated port list changed by configuration update'
3602
+ : undefined;
3603
+ if (listenerRestartReason && this.smtpListenerActive && !this.smtpListenerShutdown) {
3604
+ this.scheduleSmtpListenerRestart(listenerRestartReason);
3373
3605
  }
3374
3606
  }
3375
3607
  }
@@ -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 {
@@ -448,6 +485,8 @@ interface ISmtpServerConfig {
448
485
  securePort?: number;
449
486
  tlsCertPem?: string;
450
487
  tlsKeyPem?: string;
488
+ /** Ports whose transport a trusted upstream proxy already encrypted. */
489
+ tlsTerminatedPorts?: number[];
451
490
  additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
452
491
  maxMessageSize?: number;
453
492
  maxConnections?: number;
@@ -632,6 +671,20 @@ type TMailerCommands = {
632
671
  params: ISmtpSendRawOptions;
633
672
  result: ISmtpSendResult;
634
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
+ };
635
688
  verifySmtpConnection: {
636
689
  params: ISmtpVerifyOptions;
637
690
  result: ISmtpVerifyResult;
@@ -1187,6 +1240,7 @@ export class RustSecurityBridge extends EventEmitter {
1187
1240
  this.ensureRunning();
1188
1241
  try {
1189
1242
  const result = await this.bridge.sendCommand('sendRawEmail', opts);
1243
+
1190
1244
  return {
1191
1245
  ...result,
1192
1246
  transactionLog: normalizeSmtpTranscript(result.transactionLog),
@@ -1196,6 +1250,22 @@ export class RustSecurityBridge extends EventEmitter {
1196
1250
  }
1197
1251
  }
1198
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
+
1199
1269
  /** Verify connectivity to an SMTP server. */
1200
1270
  public async verifySmtpConnection(opts: ISmtpVerifyOptions): Promise<ISmtpVerifyResult> {
1201
1271
  this.ensureRunning();