@push.rocks/smartmta 5.3.3 → 6.0.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.
@@ -10,7 +10,7 @@ import {
10
10
  import { DKIMCreator } from '../security/classes.dkimcreator.js';
11
11
  import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
12
12
  import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
13
- import type { IEmailReceivedEvent, IAuthRequestEvent } from '../../security/classes.rustsecuritybridge.js';
13
+ import type { IEmailReceivedEvent, IRcptToRequestEvent, IAuthRequestEvent, IScramCredentialRequestEvent } from '../../security/classes.rustsecuritybridge.js';
14
14
  import { EmailRouter } from './classes.email.router.js';
15
15
  import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
16
16
  import { Email } from '../core/classes.email.js';
@@ -44,6 +44,37 @@ export interface IExtendedSmtpSession extends ISmtpSession {
44
44
  matchedRoute?: IEmailRoute;
45
45
  }
46
46
 
47
+ export interface ISmtpDecision {
48
+ accepted: boolean;
49
+ smtpCode?: number;
50
+ smtpMessage?: string;
51
+ }
52
+
53
+ export interface IRcptToPolicyContext {
54
+ sessionId: string;
55
+ mailFrom: string;
56
+ rcptTo: string;
57
+ acceptedRcptTo: string[];
58
+ remoteAddress: string;
59
+ clientHostname: string | null;
60
+ secure: boolean;
61
+ authenticated: boolean;
62
+ authenticatedUser: string | null;
63
+ abortSignal?: AbortSignal;
64
+ }
65
+
66
+ export interface IMessageAcceptanceContext {
67
+ email: Email;
68
+ rawMessage: Buffer;
69
+ session: IExtendedSmtpSession;
70
+ abortSignal?: AbortSignal;
71
+ }
72
+
73
+ export interface IMessageAcceptanceDecision extends ISmtpDecision {
74
+ /** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
75
+ continueProcessing?: boolean;
76
+ }
77
+
47
78
  /**
48
79
  * Options for the unified email server
49
80
  */
@@ -87,6 +118,27 @@ export interface IUnifiedEmailServerOptions {
87
118
  // Email routing rules
88
119
  routes: IEmailRoute[];
89
120
 
121
+ smtp?: {
122
+ /** Enable route/hook based RCPT TO validation before DATA. Defaults to true; set false only for fully trusted private listeners. */
123
+ recipientValidation?: boolean;
124
+ /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
125
+ recipientValidationTimeoutMs?: number;
126
+ /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
127
+ messageAcceptanceTimeoutMs?: number;
128
+ /** Require trusted PROXY v1 from the backend proxy before SMTP greeting. */
129
+ proxyProtocol?: {
130
+ required?: boolean;
131
+ trustedIps?: string[];
132
+ };
133
+ };
134
+
135
+ hooks?: {
136
+ /** Async recipient policy hook, called before each RCPT TO is accepted. */
137
+ onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
138
+ /** Async message acceptance hook, called after DATA before the final SMTP response. */
139
+ onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
140
+ };
141
+
90
142
  // Global defaults for all domains
91
143
  defaults?: {
92
144
  dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
@@ -180,6 +232,11 @@ export class UnifiedEmailServer extends EventEmitter {
180
232
  // Extracted subsystems
181
233
  private actionExecutor: EmailActionExecutor;
182
234
  private dkimManager: DkimManager;
235
+ private bridgeStateChangeHandler?: (event: { oldState: string; newState: string }) => void;
236
+ private bridgeRcptToRequestHandler?: (data: IRcptToRequestEvent) => void;
237
+ private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
238
+ private bridgeAuthRequestHandler?: (data: IAuthRequestEvent) => void;
239
+ private bridgeScramCredentialRequestHandler?: (data: IScramCredentialRequestEvent) => void;
183
240
 
184
241
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
185
242
  super();
@@ -360,6 +417,11 @@ export class UnifiedEmailServer extends EventEmitter {
360
417
  this.emit('started');
361
418
  } catch (error) {
362
419
  logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
420
+ try {
421
+ await this.stop();
422
+ } catch (cleanupError) {
423
+ logger.log('warn', `Error cleaning up failed start: ${(cleanupError as Error).message}`);
424
+ }
363
425
  throw error;
364
426
  }
365
427
  }
@@ -379,11 +441,13 @@ export class UnifiedEmailServer extends EventEmitter {
379
441
  }
380
442
  logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
381
443
 
382
- this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
444
+ this.unregisterBridgeStateHandler();
445
+ this.bridgeStateChangeHandler = ({ oldState, newState }: { oldState: string; newState: string }) => {
383
446
  if (newState === 'failed') this.emit('bridgeFailed');
384
447
  else if (newState === 'restarting') this.emit('bridgeRestarting');
385
448
  else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
386
- });
449
+ };
450
+ this.rustBridge.on('stateChange', this.bridgeStateChangeHandler);
387
451
  }
388
452
 
389
453
  private async initializeDkimAndDns(): Promise<void> {
@@ -402,7 +466,28 @@ export class UnifiedEmailServer extends EventEmitter {
402
466
  }
403
467
 
404
468
  private registerBridgeEventHandlers(): void {
405
- this.rustBridge.onEmailReceived(async (data) => {
469
+ this.unregisterBridgeEventHandlers();
470
+
471
+ this.bridgeRcptToRequestHandler = async (data) => {
472
+ try {
473
+ await this.handleRustRcptToRequest(data);
474
+ } catch (err) {
475
+ logger.log('error', `Error handling RCPT policy request from Rust SMTP: ${(err as Error).message}`);
476
+ try {
477
+ await this.rustBridge.sendRcptToResult({
478
+ correlationId: data.correlationId,
479
+ accepted: false,
480
+ smtpCode: 451,
481
+ smtpMessage: 'Recipient policy error',
482
+ });
483
+ } catch (sendErr) {
484
+ logger.log('warn', `Could not send RCPT policy rejection back to Rust: ${(sendErr as Error).message}`);
485
+ }
486
+ }
487
+ };
488
+ this.rustBridge.onRcptToRequest(this.bridgeRcptToRequestHandler);
489
+
490
+ this.bridgeEmailReceivedHandler = async (data) => {
406
491
  try {
407
492
  await this.handleRustEmailReceived(data);
408
493
  } catch (err) {
@@ -418,9 +503,10 @@ export class UnifiedEmailServer extends EventEmitter {
418
503
  logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`);
419
504
  }
420
505
  }
421
- });
506
+ };
507
+ this.rustBridge.onEmailReceived(this.bridgeEmailReceivedHandler);
422
508
 
423
- this.rustBridge.onAuthRequest(async (data) => {
509
+ this.bridgeAuthRequestHandler = async (data) => {
424
510
  try {
425
511
  await this.handleRustAuthRequest(data);
426
512
  } catch (err) {
@@ -435,9 +521,10 @@ export class UnifiedEmailServer extends EventEmitter {
435
521
  logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`);
436
522
  }
437
523
  }
438
- });
524
+ };
525
+ this.rustBridge.onAuthRequest(this.bridgeAuthRequestHandler);
439
526
 
440
- this.rustBridge.onScramCredentialRequest(async (data) => {
527
+ this.bridgeScramCredentialRequestHandler = async (data) => {
441
528
  try {
442
529
  await this.handleScramCredentialRequest(data);
443
530
  } catch (err) {
@@ -451,7 +538,34 @@ export class UnifiedEmailServer extends EventEmitter {
451
538
  logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
452
539
  }
453
540
  }
454
- });
541
+ };
542
+ this.rustBridge.onScramCredentialRequest(this.bridgeScramCredentialRequestHandler);
543
+ }
544
+
545
+ private unregisterBridgeStateHandler(): void {
546
+ if (this.bridgeStateChangeHandler) {
547
+ this.rustBridge.off('stateChange', this.bridgeStateChangeHandler);
548
+ this.bridgeStateChangeHandler = undefined;
549
+ }
550
+ }
551
+
552
+ private unregisterBridgeEventHandlers(): void {
553
+ if (this.bridgeRcptToRequestHandler) {
554
+ this.rustBridge.offRcptToRequest(this.bridgeRcptToRequestHandler);
555
+ this.bridgeRcptToRequestHandler = undefined;
556
+ }
557
+ if (this.bridgeEmailReceivedHandler) {
558
+ this.rustBridge.offEmailReceived(this.bridgeEmailReceivedHandler);
559
+ this.bridgeEmailReceivedHandler = undefined;
560
+ }
561
+ if (this.bridgeAuthRequestHandler) {
562
+ this.rustBridge.offAuthRequest(this.bridgeAuthRequestHandler);
563
+ this.bridgeAuthRequestHandler = undefined;
564
+ }
565
+ if (this.bridgeScramCredentialRequestHandler) {
566
+ this.rustBridge.offScramCredentialRequest(this.bridgeScramCredentialRequestHandler);
567
+ this.bridgeScramCredentialRequestHandler = undefined;
568
+ }
455
569
  }
456
570
 
457
571
  private async startSmtpServer(): Promise<void> {
@@ -486,7 +600,14 @@ export class UnifiedEmailServer extends EventEmitter {
486
600
  authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
487
601
  maxAuthFailures: 3,
488
602
  socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
489
- processingTimeoutSecs: 30,
603
+ processingTimeoutSecs: this.options.hooks?.onMessageData
604
+ ? Math.ceil(this.getMessageAcceptanceTimeoutMs() / 1000) + 1
605
+ : 30,
606
+ recipientValidationEnabled: this.isRecipientValidationEnabled(),
607
+ recipientValidationTimeoutSecs: this.options.smtp?.recipientValidationTimeoutMs
608
+ ? Math.ceil(this.options.smtp.recipientValidationTimeoutMs / 1000)
609
+ : 5,
610
+ proxyProtocol: this.options.smtp?.proxyProtocol,
490
611
  rateLimits: this.options.rateLimits ? {
491
612
  maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
492
613
  maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
@@ -520,8 +641,8 @@ export class UnifiedEmailServer extends EventEmitter {
520
641
  // Clear the servers array - servers will be garbage collected
521
642
  this.servers = [];
522
643
 
523
- // Remove bridge state change listener and stop bridge
524
- this.rustBridge.removeAllListeners('stateChange');
644
+ this.unregisterBridgeEventHandlers();
645
+ this.unregisterBridgeStateHandler();
525
646
  await this.rustBridge.stop();
526
647
 
527
648
  // Stop the delivery system
@@ -558,6 +679,170 @@ export class UnifiedEmailServer extends EventEmitter {
558
679
  // Rust SMTP server event handlers
559
680
  // -----------------------------------------------------------------------
560
681
 
682
+ private async handleRustRcptToRequest(data: IRcptToRequestEvent): Promise<void> {
683
+ const context: IRcptToPolicyContext = {
684
+ sessionId: data.sessionId,
685
+ mailFrom: data.mailFrom,
686
+ rcptTo: data.rcptTo,
687
+ acceptedRcptTo: data.acceptedRcptTo || [],
688
+ remoteAddress: data.remoteAddr,
689
+ clientHostname: data.clientHostname,
690
+ secure: data.secure,
691
+ authenticated: !!data.authenticatedUser,
692
+ authenticatedUser: data.authenticatedUser,
693
+ };
694
+
695
+ const hookDecision = this.options.hooks?.onRcptTo
696
+ ? await this.runHookWithTimeout(
697
+ this.getRecipientValidationTimeoutMs(),
698
+ 'Recipient policy timeout',
699
+ (abortSignal) => this.options.hooks!.onRcptTo!({ ...context, abortSignal })
700
+ )
701
+ : await this.evaluateDefaultRcptToPolicy(context);
702
+ const decision = this.options.hooks?.onRcptTo
703
+ ? await this.enforceAcceptedRcptToRelayPolicy(context, hookDecision)
704
+ : hookDecision;
705
+
706
+ await this.rustBridge.sendRcptToResult({
707
+ correlationId: data.correlationId,
708
+ accepted: decision.accepted,
709
+ smtpCode: decision.smtpCode,
710
+ smtpMessage: decision.smtpMessage,
711
+ });
712
+ }
713
+
714
+ private async evaluateDefaultRcptToPolicy(context: IRcptToPolicyContext): Promise<ISmtpDecision> {
715
+ const currentRecipientContext = { ...context, acceptedRcptTo: [] };
716
+ const email = new Email({
717
+ from: context.mailFrom || '<>',
718
+ to: [context.rcptTo],
719
+ subject: '',
720
+ text: '',
721
+ });
722
+ const session = this.buildPolicySession(currentRecipientContext);
723
+ const route = await this.emailRouter.evaluateRoutes({ email, session });
724
+
725
+ if (!route) {
726
+ return { accepted: false, smtpCode: 550, smtpMessage: 'No route for recipient' };
727
+ }
728
+ if (route.action.type === 'reject') {
729
+ return {
730
+ accepted: false,
731
+ smtpCode: route.action.reject?.code || 550,
732
+ smtpMessage: route.action.reject?.message || 'Recipient rejected',
733
+ };
734
+ }
735
+
736
+ const recipientDomain = this.getAddressDomain(context.rcptTo);
737
+ const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
738
+ if (!isLocalRecipient && !route.action.allowRelay) {
739
+ return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
740
+ }
741
+
742
+ return { accepted: true, smtpCode: 250, smtpMessage: 'OK' };
743
+ }
744
+
745
+ private async enforceAcceptedRcptToRelayPolicy(
746
+ context: IRcptToPolicyContext,
747
+ decision: ISmtpDecision
748
+ ): Promise<ISmtpDecision> {
749
+ if (!decision.accepted) {
750
+ return decision;
751
+ }
752
+
753
+ const recipientDomain = this.getAddressDomain(context.rcptTo);
754
+ const isLocalRecipient = !!recipientDomain && this.domainRegistry.isDomainRegistered(recipientDomain);
755
+ if (isLocalRecipient) {
756
+ return decision;
757
+ }
758
+
759
+ const currentRecipientContext = { ...context, acceptedRcptTo: [] };
760
+ const email = new Email({
761
+ from: context.mailFrom || '<>',
762
+ to: [context.rcptTo],
763
+ subject: '',
764
+ text: '',
765
+ });
766
+ const session = this.buildPolicySession(currentRecipientContext);
767
+ const route = await this.emailRouter.evaluateRoutes({ email, session });
768
+
769
+ if (route?.action.type !== 'reject' && route?.action.allowRelay) {
770
+ return decision;
771
+ }
772
+
773
+ return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
774
+ }
775
+
776
+ private buildPolicySession(context: IRcptToPolicyContext): IExtendedSmtpSession {
777
+ const session: IExtendedSmtpSession = {
778
+ id: context.sessionId,
779
+ state: SmtpState.RCPT_TO,
780
+ mailFrom: context.mailFrom,
781
+ rcptTo: [...context.acceptedRcptTo, context.rcptTo],
782
+ emailData: '',
783
+ useTLS: context.secure,
784
+ connectionEnded: false,
785
+ remoteAddress: context.remoteAddress,
786
+ clientHostname: context.clientHostname || '',
787
+ secure: context.secure,
788
+ authenticated: context.authenticated,
789
+ envelope: {
790
+ mailFrom: { address: context.mailFrom, args: {} },
791
+ rcptTo: [...context.acceptedRcptTo, context.rcptTo].map(address => ({ address, args: {} })),
792
+ },
793
+ };
794
+ if (context.authenticatedUser) {
795
+ session.user = { username: context.authenticatedUser };
796
+ }
797
+ return session;
798
+ }
799
+
800
+ private getAddressDomain(address: string): string | undefined {
801
+ const match = address.trim().toLowerCase().match(/@([^>\s]+)>?$/);
802
+ return match?.[1];
803
+ }
804
+
805
+ private getRecipientValidationTimeoutMs(): number {
806
+ return this.options.smtp?.recipientValidationTimeoutMs || 5000;
807
+ }
808
+
809
+ private isRecipientValidationEnabled(): boolean {
810
+ return this.options.smtp?.recipientValidation !== false;
811
+ }
812
+
813
+ private getMessageAcceptanceTimeoutMs(): number {
814
+ return this.options.smtp?.messageAcceptanceTimeoutMs || 30000;
815
+ }
816
+
817
+ private async runHookWithTimeout<T>(
818
+ timeoutMs: number,
819
+ timeoutMessage: string,
820
+ hookRunner: (abortSignal: AbortSignal) => Promise<T>,
821
+ onTimeout?: () => void
822
+ ): Promise<T> {
823
+ const abortController = new AbortController();
824
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
825
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
826
+ timeoutHandle = setTimeout(() => {
827
+ abortController.abort();
828
+ onTimeout?.();
829
+ reject(new Error(timeoutMessage));
830
+ }, timeoutMs);
831
+ });
832
+
833
+ try {
834
+ return await Promise.race([
835
+ hookRunner(abortController.signal),
836
+ timeoutPromise,
837
+ ]);
838
+ } finally {
839
+ if (timeoutHandle) {
840
+ clearTimeout(timeoutHandle);
841
+ }
842
+ abortController.abort();
843
+ }
844
+ }
845
+
561
846
  /**
562
847
  * Handle an emailReceived event from the Rust SMTP server.
563
848
  */
@@ -572,12 +857,14 @@ export class UnifiedEmailServer extends EventEmitter {
572
857
  if (data.data.type === 'inline' && data.data.base64) {
573
858
  rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
574
859
  } else if (data.data.type === 'file' && data.data.path) {
575
- rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
576
- // Clean up temp file
577
860
  try {
578
- plugins.fs.unlinkSync(data.data.path);
579
- } catch {
580
- // Ignore cleanup errors
861
+ rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
862
+ } finally {
863
+ try {
864
+ plugins.fs.unlinkSync(data.data.path);
865
+ } catch {
866
+ // Ignore cleanup errors
867
+ }
581
868
  }
582
869
  } else {
583
870
  throw new Error('Invalid email data transport');
@@ -611,15 +898,70 @@ export class UnifiedEmailServer extends EventEmitter {
611
898
  (session as any)._precomputedSecurityResults = data.securityResults;
612
899
  }
613
900
 
614
- // Process the email through the routing system
615
- await this.processEmailByMode(rawMessageBuffer, session);
901
+ const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
902
+ let acceptanceDecision: IMessageAcceptanceDecision | undefined;
903
+ if (this.options.hooks?.onMessageData) {
904
+ let hookContext: IMessageAcceptanceContext | undefined;
905
+ try {
906
+ acceptanceDecision = await this.runHookWithTimeout(
907
+ this.getMessageAcceptanceTimeoutMs(),
908
+ 'Message acceptance timeout',
909
+ (abortSignal) => {
910
+ hookContext = {
911
+ email,
912
+ rawMessage: rawMessageBuffer,
913
+ session,
914
+ abortSignal,
915
+ };
916
+ return this.options.hooks!.onMessageData!(hookContext);
917
+ },
918
+ () => {
919
+ if (hookContext) {
920
+ hookContext.rawMessage = Buffer.alloc(0);
921
+ (hookContext as any).email = undefined;
922
+ (hookContext as any).session = undefined;
923
+ }
924
+ }
925
+ );
926
+ } catch (acceptanceError) {
927
+ logger.log('error', `Message acceptance hook failed: ${(acceptanceError as Error).message}`);
928
+ await this.rustBridge.sendEmailProcessingResult({
929
+ correlationId,
930
+ accepted: false,
931
+ smtpCode: 451,
932
+ smtpMessage: 'Message acceptance temporarily unavailable',
933
+ });
934
+ return;
935
+ }
936
+ }
937
+
938
+ if (acceptanceDecision && !acceptanceDecision.accepted) {
939
+ await this.rustBridge.sendEmailProcessingResult({
940
+ correlationId,
941
+ accepted: false,
942
+ smtpCode: acceptanceDecision.smtpCode || 451,
943
+ smtpMessage: acceptanceDecision.smtpMessage || 'Message not accepted',
944
+ });
945
+ return;
946
+ }
947
+
948
+ if (!acceptanceDecision || acceptanceDecision.continueProcessing) {
949
+ try {
950
+ await this.processEmailByMode(email, session);
951
+ } catch (processingError) {
952
+ if (!acceptanceDecision) {
953
+ throw processingError;
954
+ }
955
+ logger.log('error', `Post-acceptance processing failed: ${(processingError as Error).message}`);
956
+ }
957
+ }
616
958
 
617
959
  // Send acceptance back to Rust
618
960
  await this.rustBridge.sendEmailProcessingResult({
619
961
  correlationId,
620
962
  accepted: true,
621
963
  smtpCode: 250,
622
- smtpMessage: '2.0.0 Message accepted for delivery',
964
+ smtpMessage: acceptanceDecision?.smtpMessage || '2.0.0 Message accepted for delivery',
623
965
  });
624
966
  } catch (err) {
625
967
  logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
@@ -632,6 +974,80 @@ export class UnifiedEmailServer extends EventEmitter {
632
974
  }
633
975
  }
634
976
 
977
+ private async createEmailFromBuffer(emailData: Buffer, session: IExtendedSmtpSession): Promise<Email> {
978
+ try {
979
+ const parsed = await plugins.mailparser.simpleParser(emailData);
980
+ const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
981
+ const parsedTo = ((parsed.to as any)?.value || [])
982
+ .map((recipient: any) => recipient.address)
983
+ .filter(Boolean) as string[];
984
+ const parsedCc = ((parsed.cc as any)?.value || [])
985
+ .map((recipient: any) => recipient.address)
986
+ .filter(Boolean) as string[];
987
+ const parsedHeaders: Record<string, string> = {};
988
+ for (const [headerName, headerValue] of parsed.headers.entries()) {
989
+ if (this.isStandardParsedHeader(headerName)) {
990
+ continue;
991
+ }
992
+ parsedHeaders[headerName] = this.stringifyParsedHeaderValue(headerValue);
993
+ }
994
+ return new Email({
995
+ from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
996
+ to: envelopeRecipients.length > 0
997
+ ? envelopeRecipients
998
+ : parsedTo,
999
+ cc: parsedCc,
1000
+ bcc: [],
1001
+ subject: parsed.subject || '',
1002
+ text: parsed.text || '',
1003
+ html: parsed.html || undefined,
1004
+ headers: parsedHeaders,
1005
+ attachments: parsed.attachments?.map(att => ({
1006
+ filename: att.filename || '',
1007
+ content: att.content,
1008
+ contentType: att.contentType
1009
+ })) || []
1010
+ });
1011
+ } catch (error) {
1012
+ logger.log('error', `Error parsing email data: ${(error as Error).message}`);
1013
+ throw new Error(`Error parsing email data: ${(error as Error).message}`);
1014
+ }
1015
+ }
1016
+
1017
+ private isStandardParsedHeader(headerName: string): boolean {
1018
+ return new Set([
1019
+ 'from',
1020
+ 'to',
1021
+ 'cc',
1022
+ 'bcc',
1023
+ 'subject',
1024
+ 'date',
1025
+ 'message-id',
1026
+ 'return-path',
1027
+ 'reply-to',
1028
+ 'sender',
1029
+ 'mime-version',
1030
+ 'content-type',
1031
+ 'content-transfer-encoding',
1032
+ ]).has(headerName.toLowerCase());
1033
+ }
1034
+
1035
+ private stringifyParsedHeaderValue(headerValue: unknown): string {
1036
+ if (Array.isArray(headerValue)) {
1037
+ return headerValue.map(value => this.stringifyParsedHeaderValue(value)).join(', ');
1038
+ }
1039
+ if (headerValue instanceof Date) {
1040
+ return headerValue.toUTCString();
1041
+ }
1042
+ if (typeof headerValue === 'string') {
1043
+ return headerValue;
1044
+ }
1045
+ if (headerValue && typeof headerValue === 'object' && 'text' in headerValue) {
1046
+ return String((headerValue as { text?: unknown }).text || '');
1047
+ }
1048
+ return headerValue === undefined || headerValue === null ? '' : String(headerValue);
1049
+ }
1050
+
635
1051
  /**
636
1052
  * Handle an authRequest event from the Rust SMTP server.
637
1053
  */
@@ -803,25 +1219,7 @@ export class UnifiedEmailServer extends EventEmitter {
803
1219
  // Convert Buffer to Email if needed
804
1220
  let email: Email;
805
1221
  if (Buffer.isBuffer(emailData)) {
806
- // Parse the email data buffer into an Email object
807
- try {
808
- const parsed = await plugins.mailparser.simpleParser(emailData);
809
- email = new Email({
810
- from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
811
- to: session.envelope.rcptTo[0]?.address || '',
812
- subject: parsed.subject || '',
813
- text: parsed.text || '',
814
- html: parsed.html || undefined,
815
- attachments: parsed.attachments?.map(att => ({
816
- filename: att.filename || '',
817
- content: att.content,
818
- contentType: att.contentType
819
- })) || []
820
- });
821
- } catch (error) {
822
- logger.log('error', `Error parsing email data: ${error.message}`);
823
- throw new Error(`Error parsing email data: ${error.message}`);
824
- }
1222
+ email = await this.createEmailFromBuffer(emailData, session);
825
1223
  } else {
826
1224
  email = emailData;
827
1225
  }
@@ -45,6 +45,8 @@ export interface IEmailMatch {
45
45
  export interface IEmailAction {
46
46
  /** Type of action to perform */
47
47
  type: 'forward' | 'deliver' | 'reject' | 'process';
48
+ /** Explicitly permits this route to relay to non-local recipients. */
49
+ allowRelay?: boolean;
48
50
 
49
51
  /** Forward action configuration */
50
52
  forward?: {
@@ -199,4 +201,4 @@ export interface IEmailDomainConfig {
199
201
  recipientsPerMessage?: number;
200
202
  };
201
203
  };
202
- }
204
+ }