@push.rocks/smartmta 9.4.1 → 9.6.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 (40) hide show
  1. package/changelog.md +41 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_amd64.tsrust-build.json +14 -0
  4. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  5. package/dist_rust/mailer-bin_linux_arm64.tsrust-build.json +14 -0
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/index.d.ts +2 -0
  8. package/dist_ts/index.js +3 -1
  9. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +63 -2
  10. package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
  11. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
  12. package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
  13. package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
  14. package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
  15. package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
  16. package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
  17. package/dist_ts/mail/index.d.ts +1 -0
  18. package/dist_ts/mail/index.js +1 -1
  19. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +180 -4
  20. package/dist_ts/mail/routing/classes.unified.email.server.js +592 -172
  21. package/dist_ts/mail/routing/interfaces.d.ts +41 -0
  22. package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
  23. package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
  24. package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
  25. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  26. package/package.json +6 -6
  27. package/readme.md +78 -4
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/index.ts +2 -0
  30. package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
  31. package/ts/mail/delivery/classes.delivery.system.ts +192 -80
  32. package/ts/mail/delivery/functions.raw-email.ts +111 -0
  33. package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
  34. package/ts/mail/index.ts +1 -0
  35. package/ts/mail/routing/classes.unified.email.server.ts +780 -192
  36. package/ts/mail/routing/interfaces.ts +43 -0
  37. package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
  38. package/ts/security/classes.rustsecuritybridge.ts +6 -0
  39. package/readme.hints.md +0 -37
  40. package/readme.plan.md +0 -30
@@ -28,6 +28,9 @@ import type {
28
28
  IEmailContext,
29
29
  IEmailDomainConfig,
30
30
  ISmtpAuthAccount,
31
+ ISmtpAuthAccountScope,
32
+ ISmtpAuthenticateContext,
33
+ ISmtpAuthenticateDecision,
31
34
  ISmtpScramVerifier,
32
35
  } from './interfaces.js';
33
36
  import { Email } from '../core/classes.email.js';
@@ -38,6 +41,7 @@ import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rus
38
41
  import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
39
42
  import {
40
43
  ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
44
+ DEFAULT_MAX_RAW_MESSAGE_BYTES,
41
45
  UnifiedDeliveryQueue,
42
46
  type IQueueItem,
43
47
  type IQueueOptions,
@@ -49,6 +53,20 @@ import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../d
49
53
  import { EmailActionExecutor } from './classes.email.action.executor.js';
50
54
  import { DkimManager } from './classes.dkim.manager.js';
51
55
  import { callObserverSafely, emitSafely } from '../delivery/functions.safe-observers.js';
56
+ import {
57
+ assertCanonicalRawRfc822Message,
58
+ normalizeRawSmtpEnvelope,
59
+ type IRawSmtpEnvelope,
60
+ } from '../delivery/functions.raw-email.js';
61
+ import {
62
+ kQueueCloseAdmission,
63
+ kQueueEnqueueRaw,
64
+ kQueueReserveRawAccess,
65
+ } from '../delivery/symbols.delivery.queue.js';
66
+ import {
67
+ kSendOutboundEmailInternal,
68
+ kSendRawOutboundEmailInternal,
69
+ } from './symbols.unified.email.server.js';
52
70
 
53
71
 
54
72
  /** External DcRouter interface shape used by UnifiedEmailServer */
@@ -69,7 +87,8 @@ export type TRecipientRejectionReason =
69
87
  | 'policy-error'
70
88
  | 'sender-scope'
71
89
  | 'recipient-scope'
72
- | 'account-disabled';
90
+ | 'account-disabled'
91
+ | 'auth-scope-expired';
73
92
 
74
93
  export interface IResolvedRecipientRoute {
75
94
  recipient: string;
@@ -282,6 +301,30 @@ export interface IOutboundTopologyIdentity {
282
301
  ipAddresses?: string[];
283
302
  }
284
303
 
304
+ interface IOutboundTransportParameters {
305
+ host: string;
306
+ port: number;
307
+ connectHost?: string;
308
+ connectPort?: number;
309
+ secure: boolean;
310
+ domain: string;
311
+ auth?: { user: string; pass: string };
312
+ connectionTimeoutSecs: number;
313
+ socketTimeoutSecs: number;
314
+ poolKey: string;
315
+ maxPoolConnections: number;
316
+ tlsOpportunistic: boolean;
317
+ disablePooling?: boolean;
318
+ outboundAuthenticationCaptureId?: string;
319
+ }
320
+
321
+ interface IOutboundDkimMaterial {
322
+ domain: string;
323
+ selector: string;
324
+ privateKey: string;
325
+ keyType: 'rsa' | 'ed25519';
326
+ }
327
+
285
328
  export interface ISelfMxIdentityMatch {
286
329
  isSelf: boolean;
287
330
  source: 'explicit' | 'dns-derived' | 'dynamic' | null;
@@ -359,6 +402,8 @@ export interface IUnifiedEmailServerOptions {
359
402
  tlsTerminatedPorts?: number[];
360
403
  /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
361
404
  recipientValidationTimeoutMs?: number;
405
+ /** Timeout for `hooks.onAuthenticate` callbacks. Defaults to 5000 ms. */
406
+ authenticationTimeoutMs?: number;
362
407
  /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
363
408
  messageAcceptanceTimeoutMs?: number;
364
409
  /** Maximum complete messages concurrently held across Rust and TypeScript. */
@@ -371,6 +416,24 @@ export interface IUnifiedEmailServerOptions {
371
416
  };
372
417
 
373
418
  hooks?: {
419
+ /**
420
+ * Async credential verification for PLAIN/LOGIN, letting an embedding
421
+ * application own dynamic SMTP credentials that no static configuration
422
+ * can express.
423
+ *
424
+ * Consulted only for usernames that `auth.accounts` and `auth.users` do
425
+ * not declare: configuration always owns the usernames it lists, so a
426
+ * hook can never override or weaken a configured credential. Configuring
427
+ * the hook enables SMTP AUTH on the listener even with no accounts.
428
+ *
429
+ * The returned `scope` is enforced for that session exactly as a
430
+ * configured account's scope is. A throw or a
431
+ * `smtp.authenticationTimeoutMs` timeout fails the attempt closed.
432
+ *
433
+ * SCRAM cannot be served from this hook — see `onAuthenticate` notes in
434
+ * the readme. Hook-owned usernames authenticate over PLAIN/LOGIN only.
435
+ */
436
+ onAuthenticate?: (context: ISmtpAuthenticateContext) => Promise<ISmtpAuthenticateDecision>;
374
437
  /** Async recipient policy hook that may further restrict an accepted built-in decision. */
375
438
  onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
376
439
  /** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
@@ -499,12 +562,41 @@ export class UnifiedEmailServer extends EventEmitter {
499
562
  private smtpListenerActive = false;
500
563
  private smtpListenerShutdown = false;
501
564
  private smtpListenerRestartPromise?: Promise<void>;
565
+ private acceptingOutboundWork = true;
566
+ private readonly outboundWorkFlights = new Set<Promise<unknown>>();
567
+ private readonly outboundAuthenticationFlights = new Set<Promise<IOutboundAuthenticationCheck>>();
568
+ private stopFlight?: Promise<void>;
502
569
  private static readonly VERIFIER_DERIVATION_CONCURRENCY = 4;
503
570
  private static readonly VERIFIER_DERIVATION_MAX_QUEUE = 32;
504
571
  private verifierDerivationActive = 0;
505
572
  private verifierDerivationWaiters: Array<() => void> = [];
506
573
  /** Compiled scope patterns; keys are config-supplied, bounded by config size. */
507
574
  private scopePatternCache: Map<string, RegExp> = new Map();
575
+ /**
576
+ * Scopes granted by `hooks.onAuthenticate`, keyed by the session the
577
+ * credential authenticated. The SMTP listener reports no session-close
578
+ * event, so entries are bounded by both a TTL and a hard cap rather than
579
+ * by session lifetime.
580
+ *
581
+ * Losing an entry must never widen a session, so the absence of a grant is
582
+ * not read as "unrestricted": a session authenticated as a username that
583
+ * configuration does not declare can only have come from the hook, and is
584
+ * refused with a temporary failure until it authenticates again.
585
+ */
586
+ private hookAuthenticatedSessions: Map<
587
+ string,
588
+ { username: string; scope?: ISmtpAuthAccountScope; expiresAt: number }
589
+ > = new Map();
590
+ private static readonly hookAuthSessionTtlMs = 60 * 60 * 1000;
591
+ private static readonly hookAuthSessionMax = 4096;
592
+ private static readonly hookAuthSweepInterval = 256;
593
+ private hookAuthSweepCountdown = UnifiedEmailServer.hookAuthSweepInterval;
594
+ /** The Rust listener's fixed AUTH round-trip deadline. */
595
+ private static readonly listenerAuthDeadlineMs = 5000;
596
+ /** Leave headroom so a hook answering just in time still beats the listener. */
597
+ private static readonly authHookMaxTimeoutMs = 4500;
598
+ private static readonly authHookDefaultTimeoutMs = 4000;
599
+ private loggedAuthTimeoutClamp = false;
508
600
 
509
601
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
510
602
  super();
@@ -525,113 +617,163 @@ export class UnifiedEmailServer extends EventEmitter {
525
617
  // never degrade into silent deny-all scopes or silent auth failures.
526
618
  assertValidAuthAccounts(this.options.auth?.accounts);
527
619
 
528
- // Initialize Rust security bridge (singleton)
529
- this.rustBridge = RustSecurityBridge.getInstance();
620
+ // Subsystem construction below takes ownership of event-loop resources
621
+ // (the bounce manager and the rate limiter arm cleanup intervals in their
622
+ // constructors) while later steps still reject invalid configuration by
623
+ // throwing. A throwing constructor never returns an instance, so nothing
624
+ // can ever call stop() on it: construction is the only owner that can hand
625
+ // those resources back.
626
+ try {
627
+ // Initialize Rust security bridge (singleton)
628
+ this.rustBridge = RustSecurityBridge.getInstance();
530
629
 
531
- // Initialize DKIM creator with storage manager
532
- this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
630
+ // Initialize DKIM creator with storage manager
631
+ this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
533
632
 
534
- // Initialize bounce manager with storage manager
535
- this.bounceManager = new BounceManager({
536
- maxCacheSize: 10000,
537
- cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days
538
- storageManager: dcRouter.storageManager
539
- });
633
+ // Initialize bounce manager with storage manager
634
+ this.bounceManager = new BounceManager({
635
+ maxCacheSize: 10000,
636
+ cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days
637
+ storageManager: dcRouter.storageManager
638
+ });
540
639
 
541
- // Initialize domain registry
542
- this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
640
+ // Initialize domain registry
641
+ this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
543
642
 
544
- // Initialize email router with routes and storage manager. The action
545
- // policy applies to configured, updated, and storage-hydrated routes so
546
- // an unservable route set fails loudly at configuration time.
547
- this.emailRouter = new EmailRouter(options.routes || [], {
548
- storageManager: dcRouter.storageManager,
549
- persistChanges: options.persistRoutes ?? true,
550
- validateAction: (route) => this.assertRouteActionServable(route),
551
- });
643
+ // Initialize email router with routes and storage manager. The action
644
+ // policy applies to configured, updated, and storage-hydrated routes so
645
+ // an unservable route set fails loudly at configuration time.
646
+ this.emailRouter = new EmailRouter(options.routes || [], {
647
+ storageManager: dcRouter.storageManager,
648
+ persistChanges: options.persistRoutes ?? true,
649
+ validateAction: (route) => this.assertRouteActionServable(route),
650
+ });
552
651
 
553
- // Initialize rate limiter
554
- this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || {
555
- global: {
556
- maxConnectionsPerIP: 10,
557
- maxMessagesPerMinute: 100,
558
- maxRecipientsPerMessage: 50,
559
- maxErrorsPerIP: 10,
560
- maxAuthFailuresPerIP: 5,
561
- blockDuration: 300000 // 5 minutes
562
- }
563
- });
652
+ // Initialize rate limiter
653
+ this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || {
654
+ global: {
655
+ maxConnectionsPerIP: 10,
656
+ maxMessagesPerMinute: 100,
657
+ maxRecipientsPerMessage: 50,
658
+ maxErrorsPerIP: 10,
659
+ maxAuthFailuresPerIP: 5,
660
+ blockDuration: 300000 // 5 minutes
661
+ }
662
+ });
564
663
 
565
- // Initialize delivery components
566
- const queueOptions: IQueueOptions = {
567
- maxRetries: 3,
568
- baseRetryDelay: 300000, // 5 minutes
569
- maxRetryDelay: 3600000, // 1 hour
570
- ...options.queue,
571
- };
664
+ // Initialize delivery components
665
+ const queueOptions: IQueueOptions = {
666
+ maxRetries: 3,
667
+ baseRetryDelay: 300000, // 5 minutes
668
+ maxRetryDelay: 3600000, // 1 hour
669
+ ...options.queue,
670
+ };
572
671
 
573
- this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
574
-
575
- const deliveryOptions: IMultiModeDeliveryOptions = {
576
- globalRateLimit: 100, // Default to 100 emails per minute
577
- concurrentDeliveries: 10,
578
- processBounces: true,
579
- bounceHandler: {
580
- processSmtpFailure: async (recipient, smtpResponse, failureOptions) => {
581
- const processed = await this.processSmtpFailure(
582
- recipient,
583
- smtpResponse,
584
- failureOptions,
585
- );
586
- if (!processed) {
587
- throw new Error(`SMTP bounce operation failed for ${recipient}`);
588
- }
672
+ this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
673
+
674
+ const deliveryOptions: IMultiModeDeliveryOptions = {
675
+ globalRateLimit: 100, // Default to 100 emails per minute
676
+ concurrentDeliveries: 10,
677
+ processBounces: true,
678
+ bounceHandler: {
679
+ processSmtpFailure: async (recipient, smtpResponse, failureOptions) => {
680
+ const processed = await this.processSmtpFailure(
681
+ recipient,
682
+ smtpResponse,
683
+ failureOptions,
684
+ );
685
+ if (!processed) {
686
+ throw new Error(`SMTP bounce operation failed for ${recipient}`);
687
+ }
688
+ },
589
689
  },
590
- },
591
- onDeliverySuccess: async (_item, _result) => {
592
- // Delivery success recorded via delivery system
593
- }
594
- };
690
+ onDeliverySuccess: async (_item, _result) => {
691
+ // Delivery success recorded via delivery system
692
+ }
693
+ };
595
694
 
596
- this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
695
+ this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
597
696
 
598
- // Initialize action executor
599
- this.actionExecutor = new EmailActionExecutor({
600
- sendOutboundEmail: this.sendOutboundEmail.bind(this),
601
- bounceManager: this.bounceManager,
602
- deliveryQueue: this.deliveryQueue,
603
- });
697
+ // Initialize action executor
698
+ this.actionExecutor = new EmailActionExecutor({
699
+ sendOutboundEmail: this.sendOutboundEmail.bind(this),
700
+ bounceManager: this.bounceManager,
701
+ deliveryQueue: this.deliveryQueue,
702
+ });
604
703
 
605
- // Initialize DKIM manager
606
- this.dkimManager = new DkimManager(
607
- this.dkimCreator,
608
- this.domainRegistry,
609
- dcRouter,
610
- this.rustBridge,
611
- options.dkimKeyProvisioning || 'automatic',
612
- );
613
- this.dnsManager = new DnsManager(this.dcRouter, undefined, options.dkimKeyProvisioning || 'automatic');
614
-
615
- // Initialize statistics
616
- this.stats = {
617
- startTime: new Date(),
618
- connections: {
619
- current: 0,
620
- total: 0
621
- },
622
- messages: {
623
- processed: 0,
624
- delivered: 0,
625
- failed: 0
626
- },
627
- processingTime: {
628
- avg: 0,
629
- max: 0,
630
- min: 0
704
+ // Initialize DKIM manager
705
+ this.dkimManager = new DkimManager(
706
+ this.dkimCreator,
707
+ this.domainRegistry,
708
+ dcRouter,
709
+ this.rustBridge,
710
+ options.dkimKeyProvisioning || 'automatic',
711
+ );
712
+ this.dnsManager = new DnsManager(this.dcRouter, undefined, options.dkimKeyProvisioning || 'automatic');
713
+
714
+ // Initialize statistics
715
+ this.stats = {
716
+ startTime: new Date(),
717
+ connections: {
718
+ current: 0,
719
+ total: 0
720
+ },
721
+ messages: {
722
+ processed: 0,
723
+ delivered: 0,
724
+ failed: 0
725
+ },
726
+ processingTime: {
727
+ avg: 0,
728
+ max: 0,
729
+ min: 0
730
+ }
731
+ };
732
+
733
+ // We'll create the SMTP servers during the start() method
734
+ } catch (error) {
735
+ this.releaseConstructionOwnedResources();
736
+ throw error;
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Hand back every event-loop resource a partially completed construction has
742
+ * already taken ownership of, so a rejected configuration leaves the process
743
+ * exactly as it found it.
744
+ *
745
+ * Only construction-time owners belong here: the delivery queue and the
746
+ * delivery system arm their timers in `initialize()` and `start()`, which a
747
+ * failed construction never reaches, and the Rust bridge is an unstarted
748
+ * singleton until `start()` spawns it.
749
+ */
750
+ private releaseConstructionOwnedResources(): void {
751
+ const release = (nameArg: string, stepArg: () => void): void => {
752
+ try {
753
+ stepArg();
754
+ } catch (error) {
755
+ logger.log('warn', `${nameArg}: ${getErrorMessage(error)}`);
631
756
  }
632
757
  };
758
+ release('bounce-manager release after failed construction', () => this.bounceManager?.stop());
759
+ release('rate-limiter release after failed construction', () => this.rateLimiter?.stop());
760
+ }
633
761
 
634
- // We'll create the SMTP servers during the start() method
762
+ private assertAcceptingOutboundWork(): void {
763
+ if (!this.acceptingOutboundWork) {
764
+ throw new Error('UnifiedEmailServer is not accepting outbound work');
765
+ }
766
+ }
767
+
768
+ private trackOutboundWork<T>(operationArg: () => Promise<T>): Promise<T> {
769
+ let flight: Promise<T>;
770
+ flight = Promise.resolve()
771
+ .then(operationArg)
772
+ .finally(() => {
773
+ this.outboundWorkFlights.delete(flight);
774
+ });
775
+ this.outboundWorkFlights.add(flight);
776
+ return flight;
635
777
  }
636
778
 
637
779
  public onRecipientRejected(
@@ -890,32 +1032,30 @@ export class UnifiedEmailServer extends EventEmitter {
890
1032
  * Send an outbound email via the Rust SMTP client.
891
1033
  * Uses connection pooling in the Rust binary for efficiency.
892
1034
  */
893
- public async sendOutboundEmail(host: string, port: number, email: Email, options?: {
1035
+ public sendOutboundEmail(host: string, port: number, email: Email, options?: {
894
1036
  auth?: { user: string; pass: string };
895
1037
  dkimDomain?: string;
896
1038
  dkimSelector?: string;
897
1039
  tlsOpportunistic?: boolean;
898
1040
  }): Promise<ISmtpSendResult> {
899
- // Build DKIM config if domain has keys
900
- let dkim: {
901
- domain: string;
902
- selector: string;
903
- privateKey: string;
904
- keyType: 'rsa' | 'ed25519';
905
- } | undefined;
906
- if (options?.dkimDomain) {
907
- const selector = options.dkimSelector || 'default';
908
- const material = await this.dkimCreator.readValidatedDKIMKeysForSelector(
909
- options.dkimDomain,
910
- selector,
911
- );
912
- dkim = {
913
- domain: options.dkimDomain,
914
- selector,
915
- privateKey: material.privateKey,
916
- keyType: material.keyType,
917
- };
918
- }
1041
+ this.assertAcceptingOutboundWork();
1042
+ return this.trackOutboundWork(() => this[kSendOutboundEmailInternal](
1043
+ host,
1044
+ port,
1045
+ email,
1046
+ options,
1047
+ ));
1048
+ }
1049
+
1050
+ public async [kSendOutboundEmailInternal](host: string, port: number, email: Email, options?: {
1051
+ auth?: { user: string; pass: string };
1052
+ dkimDomain?: string;
1053
+ dkimSelector?: string;
1054
+ tlsOpportunistic?: boolean;
1055
+ }): Promise<ISmtpSendResult> {
1056
+ const dkim = options?.dkimDomain
1057
+ ? await this.resolveOutboundDkim(options.dkimDomain, options.dkimSelector || 'default')
1058
+ : undefined;
919
1059
 
920
1060
  // Serialize the Email to the outbound format
921
1061
  const outboundEmail: IOutboundEmail = {
@@ -935,18 +1075,170 @@ export class UnifiedEmailServer extends EventEmitter {
935
1075
  })),
936
1076
  };
937
1077
 
938
- const secure = port === 465;
939
- const tlsOpportunistic = options?.tlsOpportunistic ?? (port === 25);
940
1078
  const senderDomain = email.getFromDomain()?.toLowerCase() || undefined;
941
- const proxy = await this.options.outbound?.connectionProxyProvider?.({
1079
+ return this.sendWithOutboundTransport({
942
1080
  host,
943
1081
  port,
1082
+ envelopeFrom: email.from,
1083
+ senderDomain,
1084
+ auth: options?.auth,
1085
+ tlsOpportunistic: options?.tlsOpportunistic,
1086
+ dkim,
1087
+ send: (transport) => this.rustBridge.sendOutboundEmail({
1088
+ ...transport,
1089
+ email: outboundEmail,
1090
+ dkim,
1091
+ }),
1092
+ });
1093
+ }
1094
+
1095
+ /** Send exact RFC822 bytes through the same outbound transport path as structured mail. */
1096
+ public sendRawOutboundEmail(
1097
+ host: string,
1098
+ port: number,
1099
+ rawMessage: Buffer,
1100
+ envelopeArg: IRawSmtpEnvelope,
1101
+ options: {
1102
+ auth?: { user: string; pass: string };
1103
+ dkimDomain: string;
1104
+ dkimSelector: string;
1105
+ tlsOpportunistic?: boolean;
1106
+ },
1107
+ ): Promise<ISmtpSendResult> {
1108
+ this.assertAcceptingOutboundWork();
1109
+ assertCanonicalRawRfc822Message(rawMessage);
1110
+ const maxRawMessageBytes = this.options.queue.maxRawMessageBytes
1111
+ ?? DEFAULT_MAX_RAW_MESSAGE_BYTES;
1112
+ if (rawMessage.length > maxRawMessageBytes) {
1113
+ throw new Error(`Raw outbound message exceeds ${maxRawMessageBytes} bytes`);
1114
+ }
1115
+ const releaseRawReservation = this.deliveryQueue[kQueueReserveRawAccess](rawMessage.length);
1116
+ let exactRawMessage: Buffer;
1117
+ let envelope: IRawSmtpEnvelope;
1118
+ try {
1119
+ exactRawMessage = Buffer.from(rawMessage);
1120
+ assertCanonicalRawRfc822Message(exactRawMessage);
1121
+ envelope = normalizeRawSmtpEnvelope(envelopeArg);
1122
+ } catch (error) {
1123
+ releaseRawReservation();
1124
+ throw error;
1125
+ }
1126
+ const exactOptions = {
1127
+ ...options,
1128
+ ...(options.auth ? { auth: { ...options.auth } } : {}),
1129
+ };
1130
+ return this.trackOutboundWork(async () => {
1131
+ try {
1132
+ return await this[kSendRawOutboundEmailInternal](
1133
+ host,
1134
+ port,
1135
+ exactRawMessage,
1136
+ envelope,
1137
+ exactOptions,
1138
+ );
1139
+ } finally {
1140
+ releaseRawReservation();
1141
+ }
1142
+ });
1143
+ }
1144
+
1145
+ public async [kSendRawOutboundEmailInternal](
1146
+ host: string,
1147
+ port: number,
1148
+ rawMessage: Buffer,
1149
+ envelopeArg: IRawSmtpEnvelope,
1150
+ options: {
1151
+ auth?: { user: string; pass: string };
1152
+ dkimDomain: string;
1153
+ dkimSelector: string;
1154
+ tlsOpportunistic?: boolean;
1155
+ },
1156
+ ): Promise<ISmtpSendResult> {
1157
+ assertCanonicalRawRfc822Message(rawMessage);
1158
+ const maxRawMessageBytes = this.options.queue.maxRawMessageBytes
1159
+ ?? DEFAULT_MAX_RAW_MESSAGE_BYTES;
1160
+ if (rawMessage.length > maxRawMessageBytes) {
1161
+ throw new Error(`Raw outbound message exceeds ${maxRawMessageBytes} bytes`);
1162
+ }
1163
+ const envelope = normalizeRawSmtpEnvelope(envelopeArg);
1164
+ const senderDomain = envelope.from.split('@')[1];
1165
+ const dkimDomain = options.dkimDomain.toLowerCase();
1166
+ if (dkimDomain !== senderDomain) {
1167
+ throw new SmtpDeliveryError({
1168
+ message: `Raw outbound DKIM domain ${dkimDomain} does not match envelope sender domain ${senderDomain}`,
1169
+ errorType: 'dkim',
1170
+ retryable: false,
1171
+ phase: 'message_body',
1172
+ transactionLog: [],
1173
+ recipients: envelope.to,
1174
+ });
1175
+ }
1176
+ const dkim = await this.resolveOutboundDkim(dkimDomain, options.dkimSelector);
1177
+ return this.sendWithOutboundTransport({
1178
+ host,
1179
+ port,
1180
+ envelopeFrom: envelope.from,
1181
+ senderDomain,
1182
+ auth: options.auth,
1183
+ tlsOpportunistic: options.tlsOpportunistic,
1184
+ dkim,
1185
+ send: (transport) => this.rustBridge.sendRawEmail({
1186
+ ...transport,
1187
+ envelopeFrom: envelope.from,
1188
+ envelopeTo: [...envelope.to],
1189
+ rawMessageBase64: rawMessage.toString('base64'),
1190
+ dkim,
1191
+ }),
1192
+ });
1193
+ }
1194
+
1195
+ private async resolveOutboundDkim(
1196
+ domainArg: string,
1197
+ selectorArg: string,
1198
+ ): Promise<IOutboundDkimMaterial> {
1199
+ try {
1200
+ const material = await this.dkimCreator.readValidatedDKIMKeysForSelector(
1201
+ domainArg,
1202
+ selectorArg,
1203
+ );
1204
+ return {
1205
+ domain: domainArg,
1206
+ selector: selectorArg,
1207
+ privateKey: material.privateKey,
1208
+ keyType: material.keyType,
1209
+ };
1210
+ } catch (error) {
1211
+ throw new SmtpDeliveryError({
1212
+ message: getErrorMessage(error),
1213
+ errorType: 'dkim',
1214
+ retryable: false,
1215
+ phase: 'message_body',
1216
+ transactionLog: [],
1217
+ recipients: [],
1218
+ }, error);
1219
+ }
1220
+ }
1221
+
1222
+ private async sendWithOutboundTransport(optionsArg: {
1223
+ host: string;
1224
+ port: number;
1225
+ envelopeFrom: string;
1226
+ senderDomain?: string;
1227
+ auth?: { user: string; pass: string };
1228
+ tlsOpportunistic?: boolean;
1229
+ dkim?: IOutboundDkimMaterial;
1230
+ send: (transport: IOutboundTransportParameters) => Promise<ISmtpSendResult>;
1231
+ }): Promise<ISmtpSendResult> {
1232
+ const secure = optionsArg.port === 465;
1233
+ const tlsOpportunistic = optionsArg.tlsOpportunistic ?? (optionsArg.port === 25);
1234
+ const proxy = await this.options.outbound?.connectionProxyProvider?.({
1235
+ host: optionsArg.host,
1236
+ port: optionsArg.port,
944
1237
  secure,
945
1238
  ehloDomain: this.getOutboundHostname(),
946
1239
  tlsOpportunistic,
947
- senderDomain,
1240
+ senderDomain: optionsArg.senderDomain,
948
1241
  });
949
-
950
1242
  const captureId = proxy?.sourceEvidence ? plugins.uuid.v4() : undefined;
951
1243
  const attachAuthenticationHandle = <T extends ISmtpSendResult | SmtpDeliveryError>(valueArg: T): T => {
952
1244
  if (!captureId || !proxy?.sourceEvidence) return valueArg;
@@ -954,10 +1246,10 @@ export class UnifiedEmailServer extends EventEmitter {
954
1246
  captureId,
955
1247
  sourceEvidence: proxy.sourceEvidence,
956
1248
  heloDomain: proxy.ehloHostname || this.getOutboundHostname(),
957
- receiverHostname: host,
958
- envelopeFrom: email.from,
959
- dkimDomain: dkim?.domain,
960
- dkimSelector: dkim?.selector,
1249
+ receiverHostname: optionsArg.host,
1250
+ envelopeFrom: optionsArg.envelopeFrom,
1251
+ dkimDomain: optionsArg.dkim?.domain,
1252
+ dkimSelector: optionsArg.dkim?.selector,
961
1253
  });
962
1254
  Object.defineProperty(valueArg, 'outboundAuthenticationHandle', {
963
1255
  value: handle,
@@ -968,18 +1260,16 @@ export class UnifiedEmailServer extends EventEmitter {
968
1260
  };
969
1261
 
970
1262
  try {
971
- const result = await this.rustBridge.sendOutboundEmail({
972
- host,
973
- port,
1263
+ const result = await optionsArg.send({
1264
+ host: optionsArg.host,
1265
+ port: optionsArg.port,
974
1266
  ...(proxy ? { connectHost: proxy.connectHost, connectPort: proxy.connectPort } : {}),
975
1267
  secure,
976
1268
  domain: proxy?.ehloHostname || this.getOutboundHostname(),
977
- auth: options?.auth,
978
- email: outboundEmail,
979
- dkim,
1269
+ auth: optionsArg.auth,
980
1270
  connectionTimeoutSecs: Math.floor((this.options.outbound?.connectionTimeout || 30000) / 1000),
981
1271
  socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
982
- poolKey: proxy?.poolKey || `${host}:${port}`,
1272
+ poolKey: proxy?.poolKey || `${optionsArg.host}:${optionsArg.port}`,
983
1273
  maxPoolConnections: this.options.outbound?.maxConnections || 10,
984
1274
  tlsOpportunistic,
985
1275
  ...(proxy ? { disablePooling: true } : {}),
@@ -1015,7 +1305,8 @@ export class UnifiedEmailServer extends EventEmitter {
1015
1305
  checkedAt: new Date().toISOString(),
1016
1306
  error: errorArg,
1017
1307
  });
1018
- const result = (async (): Promise<IOutboundAuthenticationCheck> => {
1308
+ let result: Promise<IOutboundAuthenticationCheck>;
1309
+ result = (async (): Promise<IOutboundAuthenticationCheck> => {
1019
1310
  let timeout: ReturnType<typeof setTimeout> | undefined;
1020
1311
  try {
1021
1312
  const sourceEvidence = await new Promise<IOutboundConnectionSourceEvidence | null>((resolve, reject) => {
@@ -1071,7 +1362,10 @@ export class UnifiedEmailServer extends EventEmitter {
1071
1362
  } finally {
1072
1363
  if (timeout) clearTimeout(timeout);
1073
1364
  }
1074
- })();
1365
+ })().finally(() => {
1366
+ this.outboundAuthenticationFlights.delete(result);
1367
+ });
1368
+ this.outboundAuthenticationFlights.add(result);
1075
1369
  return { pending, result };
1076
1370
  }
1077
1371
 
@@ -1392,11 +1686,16 @@ export class UnifiedEmailServer extends EventEmitter {
1392
1686
  return resolved;
1393
1687
  }
1394
1688
 
1395
- /** Whether the current options require SMTP AUTH support on the listener. */
1689
+ /**
1690
+ * Whether the current options require SMTP AUTH support on the listener.
1691
+ * A configured `hooks.onAuthenticate` counts on its own: its whole purpose
1692
+ * is to own credentials that no static account list contains.
1693
+ */
1396
1694
  private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
1397
1695
  return !!options.auth?.required
1398
1696
  || !!options.auth?.users?.length
1399
- || !!options.auth?.accounts?.length;
1697
+ || !!options.auth?.accounts?.length
1698
+ || !!options.hooks?.onAuthenticate;
1400
1699
  }
1401
1700
 
1402
1701
  /**
@@ -1442,18 +1741,46 @@ export class UnifiedEmailServer extends EventEmitter {
1442
1741
  /**
1443
1742
  * Stop the unified email server
1444
1743
  */
1445
- public async stop(): Promise<void> {
1744
+ public stop(): Promise<void> {
1745
+ if (this.stopFlight) return this.stopFlight;
1446
1746
  logger.log('info', 'Stopping UnifiedEmailServer');
1747
+ this.acceptingOutboundWork = false;
1748
+ this.deliveryQueue[kQueueCloseAdmission]();
1749
+ this.stopFlight = this.stopOnce();
1750
+ return this.stopFlight;
1751
+ }
1447
1752
 
1448
- try {
1753
+ private async stopOnce(): Promise<void> {
1754
+ const errors: Error[] = [];
1755
+ const runStep = async (nameArg: string, stepArg: () => Promise<void> | void): Promise<void> => {
1756
+ try {
1757
+ await stepArg();
1758
+ } catch (error) {
1759
+ const wrapped = new Error(`${nameArg}: ${getErrorMessage(error)}`, { cause: error });
1760
+ errors.push(wrapped);
1761
+ logger.log('error', wrapped.message);
1762
+ }
1763
+ };
1764
+ const runBestEffortStep = async (
1765
+ nameArg: string,
1766
+ stepArg: () => Promise<void> | void,
1767
+ ): Promise<void> => {
1768
+ try {
1769
+ await stepArg();
1770
+ } catch (error) {
1771
+ logger.log('warn', `${nameArg}: ${getErrorMessage(error)}`);
1772
+ }
1773
+ };
1774
+
1775
+ await runStep('accepted-envelope cleanup drain failed', async () => {
1449
1776
  if (this.acceptedEnvelopeDispatchCleanupTimer) {
1450
1777
  clearInterval(this.acceptedEnvelopeDispatchCleanupTimer);
1451
1778
  this.acceptedEnvelopeDispatchCleanupTimer = undefined;
1452
1779
  }
1453
1780
  await this.acceptedEnvelopeDispatchCleanupPromise;
1454
- // Prevent new listener restarts from being scheduled or executed
1455
- // during shutdown, then drain any in-flight reconfiguration before
1456
- // tearing down (the chain may be extended while awaited).
1781
+ });
1782
+
1783
+ await runStep('SMTP listener reconfiguration drain failed', async () => {
1457
1784
  this.smtpListenerShutdown = true;
1458
1785
  this.smtpListenerActive = false;
1459
1786
  while (this.smtpListenerRestartPromise) {
@@ -1463,55 +1790,49 @@ export class UnifiedEmailServer extends EventEmitter {
1463
1790
  this.smtpListenerRestartPromise = undefined;
1464
1791
  }
1465
1792
  }
1466
- await this.stopDnsValidation();
1467
-
1468
- // Stop the Rust SMTP server first
1469
- try {
1470
- await this.rustBridge.stopSmtpServer();
1471
- logger.log('info', 'Rust SMTP server stopped');
1472
- } catch (err) {
1473
- logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
1474
- }
1475
- this.smtpListenerActive = false;
1476
-
1477
- // Clear the servers array - servers will be garbage collected
1478
- this.servers = [];
1793
+ });
1794
+ await runStep('DNS validation shutdown failed', () => this.stopDnsValidation());
1479
1795
 
1480
- this.unregisterBridgeEventHandlers();
1481
- this.unregisterBridgeStateHandler();
1482
- await this.rustBridge.stop();
1796
+ await runBestEffortStep(
1797
+ 'Rust SMTP listener shutdown skipped or failed',
1798
+ () => this.rustBridge.stopSmtpServer(),
1799
+ );
1800
+ this.smtpListenerActive = false;
1801
+ this.servers = [];
1483
1802
 
1484
- // Stop the delivery system
1485
- if (this.deliverySystem) {
1486
- await this.deliverySystem.stop();
1487
- logger.log('info', 'Email delivery system stopped');
1488
- }
1803
+ await runStep('delivery-system drain failed', async () => {
1804
+ await this.deliverySystem?.stop();
1805
+ });
1489
1806
 
1490
- // Shut down the delivery queue
1491
- if (this.deliveryQueue) {
1492
- await this.deliveryQueue.shutdown();
1493
- logger.log('info', 'Email delivery queue shut down');
1494
- }
1807
+ while (this.outboundWorkFlights.size > 0) {
1808
+ await Promise.allSettled([...this.outboundWorkFlights]);
1809
+ }
1810
+ while (this.outboundAuthenticationFlights.size > 0) {
1811
+ await Promise.allSettled([...this.outboundAuthenticationFlights]);
1812
+ }
1495
1813
 
1496
- this.bounceManager.stop();
1497
- logger.log('info', 'Bounce manager stopped');
1814
+ await runStep('delivery-queue shutdown failed', async () => {
1815
+ await this.deliveryQueue?.shutdown();
1816
+ });
1498
1817
 
1499
- this.rateLimiter.stop();
1500
- logger.log('info', 'Rate limiter stopped');
1818
+ await runStep('bounce-manager shutdown failed', () => this.bounceManager.stop());
1819
+ await runStep('rate-limiter shutdown failed', () => this.rateLimiter.stop());
1820
+ await runBestEffortStep(
1821
+ 'SMTP pool shutdown skipped or failed',
1822
+ () => this.rustBridge.closeSmtpPool(),
1823
+ );
1501
1824
 
1502
- // Close all Rust SMTP client connection pools
1503
- try {
1504
- await this.rustBridge.closeSmtpPool();
1505
- } catch {
1506
- // Bridge may already be stopped
1507
- }
1825
+ await runStep('Rust bridge shutdown failed', async () => {
1826
+ this.unregisterBridgeEventHandlers();
1827
+ this.unregisterBridgeStateHandler();
1828
+ await this.rustBridge.stop();
1829
+ });
1508
1830
 
1509
- logger.log('info', 'UnifiedEmailServer stopped successfully');
1510
- await emitSafely(this, 'stopped');
1511
- } catch (error) {
1512
- logger.log('error', `Error stopping UnifiedEmailServer: ${getErrorMessage(error)}`);
1513
- throw error;
1831
+ await emitSafely(this, 'stopped');
1832
+ if (errors.length > 0) {
1833
+ throw new AggregateError(errors, 'UnifiedEmailServer shutdown completed with errors');
1514
1834
  }
1835
+ logger.log('info', 'UnifiedEmailServer stopped successfully');
1515
1836
  }
1516
1837
 
1517
1838
  // -----------------------------------------------------------------------
@@ -1599,6 +1920,7 @@ export class UnifiedEmailServer extends EventEmitter {
1599
1920
  context.authenticatedUser,
1600
1921
  context.mailFrom,
1601
1922
  context.rcptTo,
1923
+ context.sessionId,
1602
1924
  );
1603
1925
  if (scopeViolation) {
1604
1926
  const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
@@ -1882,6 +2204,115 @@ export class UnifiedEmailServer extends EventEmitter {
1882
2204
  );
1883
2205
  }
1884
2206
 
2207
+ /** Whether the legacy `auth.users` array declares this username. */
2208
+ private hasLegacyAuthUser(username: string): boolean {
2209
+ return (this.options.auth?.users || []).some((user) => user.username === username);
2210
+ }
2211
+
2212
+ /**
2213
+ * Whether configuration claims this username, for the purpose of deciding
2214
+ * that `hooks.onAuthenticate` must not see it.
2215
+ *
2216
+ * Deliberately case-insensitive, while credential verification itself stays
2217
+ * exact. Otherwise `Tenant-A` would slip past an operator's configured
2218
+ * `tenant-a` — including a `tenant-a` the operator had set `enabled: false`
2219
+ * — and reach a hook that may well resolve names case-insensitively.
2220
+ * Configuration owning a name means owning every casing of it: a casing that
2221
+ * matches no exact credential simply fails, and never reaches the hook.
2222
+ */
2223
+ private declaresUsername(username: string): boolean {
2224
+ const normalized = username.toLowerCase();
2225
+ const accounts = this.options.auth?.accounts || [];
2226
+ if (accounts.some((account) => account.username.toLowerCase() === normalized)) return true;
2227
+ const users = this.options.auth?.users || [];
2228
+ return users.some((user) => user.username.toLowerCase() === normalized);
2229
+ }
2230
+
2231
+ /**
2232
+ * Hook timeout, clamped below the Rust listener's own AUTH deadline.
2233
+ *
2234
+ * The listener waits a fixed 5 s for the TypeScript answer
2235
+ * (`rust/crates/mailer-smtp/src/connection.rs`), so a larger value here
2236
+ * could never take effect: the listener would already have failed the
2237
+ * attempt and reset the session while the hook was still running. Clamping
2238
+ * keeps the configured number meaningful instead of silently unreachable.
2239
+ */
2240
+ private getAuthenticationTimeoutMs(): number {
2241
+ const configured = this.options.smtp?.authenticationTimeoutMs;
2242
+ if (!configured) return UnifiedEmailServer.authHookDefaultTimeoutMs;
2243
+ if (configured <= UnifiedEmailServer.authHookMaxTimeoutMs) return configured;
2244
+ if (!this.loggedAuthTimeoutClamp) {
2245
+ this.loggedAuthTimeoutClamp = true;
2246
+ logger.log(
2247
+ 'warn',
2248
+ `smtp.authenticationTimeoutMs ${configured} exceeds the SMTP listener's `
2249
+ + `${UnifiedEmailServer.listenerAuthDeadlineMs}ms AUTH deadline; `
2250
+ + `clamping to ${UnifiedEmailServer.authHookMaxTimeoutMs}ms`,
2251
+ );
2252
+ }
2253
+ return UnifiedEmailServer.authHookMaxTimeoutMs;
2254
+ }
2255
+
2256
+ /**
2257
+ * Enforce the hard cap oldest-first, and sweep expired grants periodically.
2258
+ *
2259
+ * The cap is what bounds memory, and it is O(1) amortised because Map
2260
+ * preserves insertion order. The TTL sweep is hygiene on top — expiry is
2261
+ * always enforced on read — so it runs every `hookAuthSweepInterval`
2262
+ * insertions rather than walking the whole table on every authentication.
2263
+ */
2264
+ private pruneHookAuthSessions(): void {
2265
+ while (this.hookAuthenticatedSessions.size > UnifiedEmailServer.hookAuthSessionMax) {
2266
+ const oldest = this.hookAuthenticatedSessions.keys().next();
2267
+ if (oldest.done) break;
2268
+ this.hookAuthenticatedSessions.delete(oldest.value);
2269
+ }
2270
+ this.hookAuthSweepCountdown -= 1;
2271
+ if (this.hookAuthSweepCountdown > 0) return;
2272
+ this.hookAuthSweepCountdown = UnifiedEmailServer.hookAuthSweepInterval;
2273
+ const now = Date.now();
2274
+ for (const [sessionId, grant] of this.hookAuthenticatedSessions) {
2275
+ if (grant.expiresAt <= now) {
2276
+ this.hookAuthenticatedSessions.delete(sessionId);
2277
+ }
2278
+ }
2279
+ }
2280
+
2281
+ /** Record the scope a hook granted, replacing any earlier grant for the session. */
2282
+ private rememberHookAuthGrant(
2283
+ sessionId: string,
2284
+ username: string,
2285
+ scope: ISmtpAuthAccountScope | undefined,
2286
+ ): void {
2287
+ this.hookAuthenticatedSessions.delete(sessionId);
2288
+ this.hookAuthenticatedSessions.set(sessionId, {
2289
+ username,
2290
+ scope,
2291
+ expiresAt: Date.now() + UnifiedEmailServer.hookAuthSessionTtlMs,
2292
+ });
2293
+ this.pruneHookAuthSessions();
2294
+ }
2295
+
2296
+ /**
2297
+ * Live hook grant for a session, or undefined when none is held. The
2298
+ * username must match the grant: a session may not present one username to
2299
+ * the listener and inherit another's scope.
2300
+ */
2301
+ private findHookAuthGrant(
2302
+ sessionId: string | null | undefined,
2303
+ username: string,
2304
+ ): { username: string; scope?: ISmtpAuthAccountScope } | undefined {
2305
+ if (!sessionId) return undefined;
2306
+ const grant = this.hookAuthenticatedSessions.get(sessionId);
2307
+ if (!grant) return undefined;
2308
+ if (grant.expiresAt <= Date.now()) {
2309
+ this.hookAuthenticatedSessions.delete(sessionId);
2310
+ return undefined;
2311
+ }
2312
+ if (grant.username !== username) return undefined;
2313
+ return grant;
2314
+ }
2315
+
1885
2316
  private normalizeScopeAddress(addressArg: string | null | undefined): string {
1886
2317
  return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
1887
2318
  }
@@ -1910,16 +2341,25 @@ export class UnifiedEmailServer extends EventEmitter {
1910
2341
  /**
1911
2342
  * Envelope-level scope decision for an authenticated session. Returns null
1912
2343
  * when the envelope is permitted (unauthenticated sessions, usernames with
1913
- * no configured account, and accounts without a scope are unrestricted).
2344
+ * no configured account and no `hooks.onAuthenticate`, and accounts without
2345
+ * a scope are unrestricted).
1914
2346
  */
1915
2347
  private resolveAuthAccountScopeDecision(
1916
2348
  authenticatedUser: string | null,
1917
2349
  mailFrom: string,
1918
2350
  rcptTo: string,
2351
+ sessionId?: string | null,
1919
2352
  ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
1920
2353
  if (!authenticatedUser) return null;
1921
2354
  const account = this.findAuthAccount(authenticatedUser);
1922
- if (!account) return null;
2355
+ if (!account) {
2356
+ return this.resolveHookAuthScopeDecision(
2357
+ authenticatedUser,
2358
+ mailFrom,
2359
+ rcptTo,
2360
+ sessionId,
2361
+ );
2362
+ }
1923
2363
  if (account.enabled === false) {
1924
2364
  // The account was disabled after authentication succeeded; the session
1925
2365
  // must not be able to send anything.
@@ -1932,7 +2372,53 @@ export class UnifiedEmailServer extends EventEmitter {
1932
2372
  rejectionReason: 'account-disabled',
1933
2373
  };
1934
2374
  }
1935
- const scope = account.scope;
2375
+ return this.applyScopeToEnvelope(account.scope, authenticatedUser, mailFrom, rcptTo);
2376
+ }
2377
+
2378
+ /**
2379
+ * Envelope-level scope decision for a session authenticated by
2380
+ * `hooks.onAuthenticate`. Reached only for usernames no configured account
2381
+ * declares, so when a hook is configured the session must hold a live
2382
+ * grant; a missing grant is refused temporarily rather than treated as
2383
+ * unrestricted. With no hook configured the pre-existing behaviour stands:
2384
+ * legacy users and unknown usernames are unscoped.
2385
+ */
2386
+ private resolveHookAuthScopeDecision(
2387
+ authenticatedUser: string,
2388
+ mailFrom: string,
2389
+ rcptTo: string,
2390
+ sessionId?: string | null,
2391
+ ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
2392
+ const resolved = this.resolveSessionSenderScope(authenticatedUser, sessionId);
2393
+ if (resolved.kind === 'unrestricted') return null;
2394
+ if (resolved.kind === 'refused') {
2395
+ return {
2396
+ decision: UnifiedEmailServer.authScopeUnavailableDecision(),
2397
+ rejectionReason: 'auth-scope-expired',
2398
+ };
2399
+ }
2400
+ return this.applyScopeToEnvelope(resolved.scope, authenticatedUser, mailFrom, rcptTo);
2401
+ }
2402
+
2403
+ /** Temporary refusal for a session whose hook grant is not available. */
2404
+ private static authScopeUnavailableDecision(): ISmtpDecision {
2405
+ return {
2406
+ accepted: false,
2407
+ smtpCode: 451,
2408
+ smtpMessage: '4.7.1 Authentication scope unavailable; authenticate again',
2409
+ };
2410
+ }
2411
+
2412
+ /**
2413
+ * Shared sender/recipient scope enforcement. An absent scope is
2414
+ * unrestricted, matching `ISmtpAuthAccount.scope`.
2415
+ */
2416
+ private applyScopeToEnvelope(
2417
+ scope: ISmtpAuthAccountScope | undefined,
2418
+ authenticatedUser: string,
2419
+ mailFrom: string,
2420
+ rcptTo: string,
2421
+ ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
1936
2422
  if (!scope) return null;
1937
2423
  if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
1938
2424
  return {
@@ -1960,6 +2446,34 @@ export class UnifiedEmailServer extends EventEmitter {
1960
2446
  return null;
1961
2447
  }
1962
2448
 
2449
+ /**
2450
+ * Sender scope in force for a session.
2451
+ *
2452
+ * The three outcomes are kept distinct on purpose. Collapsing `refused` into
2453
+ * "no scope" would make a session whose hook grant is gone look unrestricted
2454
+ * to any caller that only asks "is there a scope?", which is the opposite of
2455
+ * what a missing grant means.
2456
+ */
2457
+ private resolveSessionSenderScope(
2458
+ username: string,
2459
+ sessionId?: string | null,
2460
+ ):
2461
+ | { kind: 'unrestricted' }
2462
+ | { kind: 'scope'; scope: ISmtpAuthAccountScope }
2463
+ | { kind: 'refused' } {
2464
+ const account = this.findAuthAccount(username);
2465
+ if (account) {
2466
+ if (account.enabled === false || !account.scope) return { kind: 'unrestricted' };
2467
+ return { kind: 'scope', scope: account.scope };
2468
+ }
2469
+ if (!this.options.hooks?.onAuthenticate) return { kind: 'unrestricted' };
2470
+ // Legacy users and other configuration-owned names are unscoped by design.
2471
+ if (this.declaresUsername(username)) return { kind: 'unrestricted' };
2472
+ const grant = this.findHookAuthGrant(sessionId, username);
2473
+ if (!grant) return { kind: 'refused' };
2474
+ return grant.scope ? { kind: 'scope', scope: grant.scope } : { kind: 'unrestricted' };
2475
+ }
2476
+
1963
2477
  /**
1964
2478
  * Extract every mailbox address from the parsed From header. The Email
1965
2479
  * abstraction keeps only the first From mailbox, which is not sufficient
@@ -1993,10 +2507,15 @@ export class UnifiedEmailServer extends EventEmitter {
1993
2507
  ): Promise<ISmtpDecision | null> {
1994
2508
  const username = session.user?.username;
1995
2509
  if (!username) return null;
1996
- const account = this.findAuthAccount(username);
1997
2510
  // Disabled accounts and envelope violations are rejected by
1998
- // resolveRecipientPolicy; this check only adds header alignment.
1999
- if (!account || account.enabled === false || !account.scope) return null;
2511
+ // resolveRecipientPolicy; this check adds header alignment, for configured
2512
+ // accounts and hook grants alike. A session whose hook grant is gone is
2513
+ // refused here in its own right rather than relying on the envelope check
2514
+ // having run first.
2515
+ const resolved = this.resolveSessionSenderScope(username, session.id);
2516
+ if (resolved.kind === 'unrestricted') return null;
2517
+ if (resolved.kind === 'refused') return UnifiedEmailServer.authScopeUnavailableDecision();
2518
+ const scope = resolved.scope;
2000
2519
  let fromMailboxes: string[];
2001
2520
  try {
2002
2521
  fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
@@ -2009,7 +2528,7 @@ export class UnifiedEmailServer extends EventEmitter {
2009
2528
  fromMailboxes = [email.getFromAddress()];
2010
2529
  }
2011
2530
  const outOfScope = fromMailboxes.find(
2012
- (mailbox) => !this.matchesScopePatterns(mailbox, account.scope!.senders),
2531
+ (mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
2013
2532
  );
2014
2533
  if (outOfScope === undefined) {
2015
2534
  return null;
@@ -3185,12 +3704,13 @@ export class UnifiedEmailServer extends EventEmitter {
3185
3704
  * always fail.
3186
3705
  */
3187
3706
  private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
3188
- const { correlationId, username, password, remoteAddr } = data;
3707
+ const { correlationId, sessionId, username, password, remoteAddr } = data;
3189
3708
 
3190
3709
  logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
3191
3710
 
3192
3711
  const account = this.findAuthAccount(username);
3193
3712
  let success = false;
3713
+ let failureMessage: string | undefined;
3194
3714
  if (account) {
3195
3715
  if (account.enabled === false) {
3196
3716
  success = false;
@@ -3199,12 +3719,27 @@ export class UnifiedEmailServer extends EventEmitter {
3199
3719
  } else {
3200
3720
  success = this.timingSafeStringEqual(account.credential.password, password);
3201
3721
  }
3202
- } else {
3722
+ } else if (this.hasLegacyAuthUser(username)) {
3203
3723
  // Legacy users array keeps its existing behavior.
3204
3724
  const users = this.options.auth?.users || [];
3205
3725
  success = users.some(
3206
3726
  u => u.username === username && u.password === password
3207
3727
  );
3728
+ } else if (this.options.hooks?.onAuthenticate && !this.declaresUsername(username)) {
3729
+ // Only usernames configuration does not declare reach the hook. A casing
3730
+ // variant of a configured name is owned by configuration too, so it
3731
+ // falls through here and fails rather than reaching the hook.
3732
+ const outcome = await this.runHookAuthentication({
3733
+ username,
3734
+ password,
3735
+ sessionId,
3736
+ remoteAddress: remoteAddr,
3737
+ });
3738
+ success = outcome.accepted;
3739
+ failureMessage = outcome.message;
3740
+ if (outcome.accepted) {
3741
+ this.rememberHookAuthGrant(sessionId, username, outcome.scope);
3742
+ }
3208
3743
  }
3209
3744
 
3210
3745
  if (success) {
@@ -3213,19 +3748,62 @@ export class UnifiedEmailServer extends EventEmitter {
3213
3748
  success: true,
3214
3749
  });
3215
3750
  } else {
3216
- logger.log('warn', `Auth failed for user=${username} from=${remoteAddr}`);
3751
+ // The listener answers every failed AUTH with the same 535, so a hook's
3752
+ // reason is recorded here rather than disclosed to the client.
3753
+ logger.log(
3754
+ 'warn',
3755
+ `Auth failed for user=${username} from=${remoteAddr}`
3756
+ + (failureMessage ? `: ${failureMessage}` : ''),
3757
+ );
3217
3758
  await this.rustBridge.sendAuthResult({
3218
3759
  correlationId,
3219
3760
  success: false,
3220
- message: 'Invalid credentials',
3761
+ message: failureMessage || 'Invalid credentials',
3221
3762
  });
3222
3763
  }
3223
3764
  }
3224
3765
 
3766
+ /**
3767
+ * Run `hooks.onAuthenticate` under the configured timeout. Any throw, any
3768
+ * timeout and any non-object result fail the attempt closed — an embedder
3769
+ * that cannot answer must never widen access.
3770
+ */
3771
+ private async runHookAuthentication(
3772
+ context: Omit<ISmtpAuthenticateContext, 'abortSignal'>,
3773
+ ): Promise<ISmtpAuthenticateDecision> {
3774
+ try {
3775
+ const decision = await this.runHookWithTimeout(
3776
+ this.getAuthenticationTimeoutMs(),
3777
+ 'Authentication hook timeout',
3778
+ (abortSignal) => this.options.hooks!.onAuthenticate!({ ...context, abortSignal }),
3779
+ );
3780
+ if (!decision || typeof decision !== 'object') {
3781
+ logger.log(
3782
+ 'error',
3783
+ `Authentication hook returned no decision for user=${context.username}`,
3784
+ );
3785
+ return { accepted: false };
3786
+ }
3787
+ return decision;
3788
+ } catch (error) {
3789
+ logger.log(
3790
+ 'error',
3791
+ `Authentication hook failed for user=${context.username}: ${getErrorMessage(error)}`,
3792
+ );
3793
+ return { accepted: false };
3794
+ }
3795
+ }
3796
+
3225
3797
  /**
3226
3798
  * Handle a SCRAM credential request from the Rust SMTP server.
3227
3799
  * Verifier accounts return their stored fields directly; plaintext
3228
3800
  * credentials (accounts or legacy users) derive fields on the fly.
3801
+ *
3802
+ * Usernames owned by `hooks.onAuthenticate` fall through to `found: false`:
3803
+ * SCRAM requires the server to hold the salt, iteration count, stored key
3804
+ * and server key, and a hook that verifies an opaque credential (argon2id,
3805
+ * a remote authority) cannot produce them. Those usernames authenticate
3806
+ * over PLAIN/LOGIN only, which is why the hook receives the password.
3229
3807
  */
3230
3808
  private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
3231
3809
  const { correlationId, username, remoteAddr } = data;
@@ -3663,6 +4241,15 @@ export class UnifiedEmailServer extends EventEmitter {
3663
4241
  /**
3664
4242
  * Send an email through the delivery system
3665
4243
  */
4244
+ public async enqueueRawEmail(
4245
+ rawMessage: Buffer,
4246
+ envelope: IRawSmtpEnvelope,
4247
+ idempotencyKey: string,
4248
+ ): Promise<string> {
4249
+ this.assertAcceptingOutboundWork();
4250
+ return this.deliveryQueue[kQueueEnqueueRaw]({ rawMessage, envelope, idempotencyKey });
4251
+ }
4252
+
3666
4253
  public async sendEmail(
3667
4254
  email: Email,
3668
4255
  mode: EmailProcessingMode = 'mta',
@@ -3673,6 +4260,7 @@ export class UnifiedEmailServer extends EventEmitter {
3673
4260
  isTransactional?: boolean;
3674
4261
  }
3675
4262
  ): Promise<string> {
4263
+ this.assertAcceptingOutboundWork();
3676
4264
  logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
3677
4265
 
3678
4266
  try {