@push.rocks/smartmta 9.3.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 {
@@ -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();