@push.rocks/smartmta 9.5.0 → 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.
@@ -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';
@@ -84,7 +87,8 @@ export type TRecipientRejectionReason =
84
87
  | 'policy-error'
85
88
  | 'sender-scope'
86
89
  | 'recipient-scope'
87
- | 'account-disabled';
90
+ | 'account-disabled'
91
+ | 'auth-scope-expired';
88
92
 
89
93
  export interface IResolvedRecipientRoute {
90
94
  recipient: string;
@@ -398,6 +402,8 @@ export interface IUnifiedEmailServerOptions {
398
402
  tlsTerminatedPorts?: number[];
399
403
  /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
400
404
  recipientValidationTimeoutMs?: number;
405
+ /** Timeout for `hooks.onAuthenticate` callbacks. Defaults to 5000 ms. */
406
+ authenticationTimeoutMs?: number;
401
407
  /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
402
408
  messageAcceptanceTimeoutMs?: number;
403
409
  /** Maximum complete messages concurrently held across Rust and TypeScript. */
@@ -410,6 +416,24 @@ export interface IUnifiedEmailServerOptions {
410
416
  };
411
417
 
412
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>;
413
437
  /** Async recipient policy hook that may further restrict an accepted built-in decision. */
414
438
  onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
415
439
  /** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
@@ -548,6 +572,31 @@ export class UnifiedEmailServer extends EventEmitter {
548
572
  private verifierDerivationWaiters: Array<() => void> = [];
549
573
  /** Compiled scope patterns; keys are config-supplied, bounded by config size. */
550
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;
551
600
 
552
601
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
553
602
  super();
@@ -568,113 +617,146 @@ export class UnifiedEmailServer extends EventEmitter {
568
617
  // never degrade into silent deny-all scopes or silent auth failures.
569
618
  assertValidAuthAccounts(this.options.auth?.accounts);
570
619
 
571
- // Initialize Rust security bridge (singleton)
572
- 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();
573
629
 
574
- // Initialize DKIM creator with storage manager
575
- this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
630
+ // Initialize DKIM creator with storage manager
631
+ this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
576
632
 
577
- // Initialize bounce manager with storage manager
578
- this.bounceManager = new BounceManager({
579
- maxCacheSize: 10000,
580
- cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days
581
- storageManager: dcRouter.storageManager
582
- });
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
+ });
583
639
 
584
- // Initialize domain registry
585
- this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
640
+ // Initialize domain registry
641
+ this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
586
642
 
587
- // Initialize email router with routes and storage manager. The action
588
- // policy applies to configured, updated, and storage-hydrated routes so
589
- // an unservable route set fails loudly at configuration time.
590
- this.emailRouter = new EmailRouter(options.routes || [], {
591
- storageManager: dcRouter.storageManager,
592
- persistChanges: options.persistRoutes ?? true,
593
- validateAction: (route) => this.assertRouteActionServable(route),
594
- });
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
+ });
595
651
 
596
- // Initialize rate limiter
597
- this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || {
598
- global: {
599
- maxConnectionsPerIP: 10,
600
- maxMessagesPerMinute: 100,
601
- maxRecipientsPerMessage: 50,
602
- maxErrorsPerIP: 10,
603
- maxAuthFailuresPerIP: 5,
604
- blockDuration: 300000 // 5 minutes
605
- }
606
- });
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
+ });
607
663
 
608
- // Initialize delivery components
609
- const queueOptions: IQueueOptions = {
610
- maxRetries: 3,
611
- baseRetryDelay: 300000, // 5 minutes
612
- maxRetryDelay: 3600000, // 1 hour
613
- ...options.queue,
614
- };
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
+ };
615
671
 
616
- this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
617
-
618
- const deliveryOptions: IMultiModeDeliveryOptions = {
619
- globalRateLimit: 100, // Default to 100 emails per minute
620
- concurrentDeliveries: 10,
621
- processBounces: true,
622
- bounceHandler: {
623
- processSmtpFailure: async (recipient, smtpResponse, failureOptions) => {
624
- const processed = await this.processSmtpFailure(
625
- recipient,
626
- smtpResponse,
627
- failureOptions,
628
- );
629
- if (!processed) {
630
- throw new Error(`SMTP bounce operation failed for ${recipient}`);
631
- }
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
+ },
632
689
  },
633
- },
634
- onDeliverySuccess: async (_item, _result) => {
635
- // Delivery success recorded via delivery system
636
- }
637
- };
690
+ onDeliverySuccess: async (_item, _result) => {
691
+ // Delivery success recorded via delivery system
692
+ }
693
+ };
638
694
 
639
- this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
695
+ this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
640
696
 
641
- // Initialize action executor
642
- this.actionExecutor = new EmailActionExecutor({
643
- sendOutboundEmail: this.sendOutboundEmail.bind(this),
644
- bounceManager: this.bounceManager,
645
- deliveryQueue: this.deliveryQueue,
646
- });
697
+ // Initialize action executor
698
+ this.actionExecutor = new EmailActionExecutor({
699
+ sendOutboundEmail: this.sendOutboundEmail.bind(this),
700
+ bounceManager: this.bounceManager,
701
+ deliveryQueue: this.deliveryQueue,
702
+ });
647
703
 
648
- // Initialize DKIM manager
649
- this.dkimManager = new DkimManager(
650
- this.dkimCreator,
651
- this.domainRegistry,
652
- dcRouter,
653
- this.rustBridge,
654
- options.dkimKeyProvisioning || 'automatic',
655
- );
656
- this.dnsManager = new DnsManager(this.dcRouter, undefined, options.dkimKeyProvisioning || 'automatic');
657
-
658
- // Initialize statistics
659
- this.stats = {
660
- startTime: new Date(),
661
- connections: {
662
- current: 0,
663
- total: 0
664
- },
665
- messages: {
666
- processed: 0,
667
- delivered: 0,
668
- failed: 0
669
- },
670
- processingTime: {
671
- avg: 0,
672
- max: 0,
673
- 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)}`);
674
756
  }
675
757
  };
676
-
677
- // We'll create the SMTP servers during the start() method
758
+ release('bounce-manager release after failed construction', () => this.bounceManager?.stop());
759
+ release('rate-limiter release after failed construction', () => this.rateLimiter?.stop());
678
760
  }
679
761
 
680
762
  private assertAcceptingOutboundWork(): void {
@@ -1604,11 +1686,16 @@ export class UnifiedEmailServer extends EventEmitter {
1604
1686
  return resolved;
1605
1687
  }
1606
1688
 
1607
- /** 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
+ */
1608
1694
  private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
1609
1695
  return !!options.auth?.required
1610
1696
  || !!options.auth?.users?.length
1611
- || !!options.auth?.accounts?.length;
1697
+ || !!options.auth?.accounts?.length
1698
+ || !!options.hooks?.onAuthenticate;
1612
1699
  }
1613
1700
 
1614
1701
  /**
@@ -1833,6 +1920,7 @@ export class UnifiedEmailServer extends EventEmitter {
1833
1920
  context.authenticatedUser,
1834
1921
  context.mailFrom,
1835
1922
  context.rcptTo,
1923
+ context.sessionId,
1836
1924
  );
1837
1925
  if (scopeViolation) {
1838
1926
  const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
@@ -2116,6 +2204,115 @@ export class UnifiedEmailServer extends EventEmitter {
2116
2204
  );
2117
2205
  }
2118
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
+
2119
2316
  private normalizeScopeAddress(addressArg: string | null | undefined): string {
2120
2317
  return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
2121
2318
  }
@@ -2144,16 +2341,25 @@ export class UnifiedEmailServer extends EventEmitter {
2144
2341
  /**
2145
2342
  * Envelope-level scope decision for an authenticated session. Returns null
2146
2343
  * when the envelope is permitted (unauthenticated sessions, usernames with
2147
- * 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).
2148
2346
  */
2149
2347
  private resolveAuthAccountScopeDecision(
2150
2348
  authenticatedUser: string | null,
2151
2349
  mailFrom: string,
2152
2350
  rcptTo: string,
2351
+ sessionId?: string | null,
2153
2352
  ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
2154
2353
  if (!authenticatedUser) return null;
2155
2354
  const account = this.findAuthAccount(authenticatedUser);
2156
- if (!account) return null;
2355
+ if (!account) {
2356
+ return this.resolveHookAuthScopeDecision(
2357
+ authenticatedUser,
2358
+ mailFrom,
2359
+ rcptTo,
2360
+ sessionId,
2361
+ );
2362
+ }
2157
2363
  if (account.enabled === false) {
2158
2364
  // The account was disabled after authentication succeeded; the session
2159
2365
  // must not be able to send anything.
@@ -2166,7 +2372,53 @@ export class UnifiedEmailServer extends EventEmitter {
2166
2372
  rejectionReason: 'account-disabled',
2167
2373
  };
2168
2374
  }
2169
- 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 {
2170
2422
  if (!scope) return null;
2171
2423
  if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
2172
2424
  return {
@@ -2194,6 +2446,34 @@ export class UnifiedEmailServer extends EventEmitter {
2194
2446
  return null;
2195
2447
  }
2196
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
+
2197
2477
  /**
2198
2478
  * Extract every mailbox address from the parsed From header. The Email
2199
2479
  * abstraction keeps only the first From mailbox, which is not sufficient
@@ -2227,10 +2507,15 @@ export class UnifiedEmailServer extends EventEmitter {
2227
2507
  ): Promise<ISmtpDecision | null> {
2228
2508
  const username = session.user?.username;
2229
2509
  if (!username) return null;
2230
- const account = this.findAuthAccount(username);
2231
2510
  // Disabled accounts and envelope violations are rejected by
2232
- // resolveRecipientPolicy; this check only adds header alignment.
2233
- 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;
2234
2519
  let fromMailboxes: string[];
2235
2520
  try {
2236
2521
  fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
@@ -2243,7 +2528,7 @@ export class UnifiedEmailServer extends EventEmitter {
2243
2528
  fromMailboxes = [email.getFromAddress()];
2244
2529
  }
2245
2530
  const outOfScope = fromMailboxes.find(
2246
- (mailbox) => !this.matchesScopePatterns(mailbox, account.scope!.senders),
2531
+ (mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
2247
2532
  );
2248
2533
  if (outOfScope === undefined) {
2249
2534
  return null;
@@ -3419,12 +3704,13 @@ export class UnifiedEmailServer extends EventEmitter {
3419
3704
  * always fail.
3420
3705
  */
3421
3706
  private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
3422
- const { correlationId, username, password, remoteAddr } = data;
3707
+ const { correlationId, sessionId, username, password, remoteAddr } = data;
3423
3708
 
3424
3709
  logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
3425
3710
 
3426
3711
  const account = this.findAuthAccount(username);
3427
3712
  let success = false;
3713
+ let failureMessage: string | undefined;
3428
3714
  if (account) {
3429
3715
  if (account.enabled === false) {
3430
3716
  success = false;
@@ -3433,12 +3719,27 @@ export class UnifiedEmailServer extends EventEmitter {
3433
3719
  } else {
3434
3720
  success = this.timingSafeStringEqual(account.credential.password, password);
3435
3721
  }
3436
- } else {
3722
+ } else if (this.hasLegacyAuthUser(username)) {
3437
3723
  // Legacy users array keeps its existing behavior.
3438
3724
  const users = this.options.auth?.users || [];
3439
3725
  success = users.some(
3440
3726
  u => u.username === username && u.password === password
3441
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
+ }
3442
3743
  }
3443
3744
 
3444
3745
  if (success) {
@@ -3447,19 +3748,62 @@ export class UnifiedEmailServer extends EventEmitter {
3447
3748
  success: true,
3448
3749
  });
3449
3750
  } else {
3450
- 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
+ );
3451
3758
  await this.rustBridge.sendAuthResult({
3452
3759
  correlationId,
3453
3760
  success: false,
3454
- message: 'Invalid credentials',
3761
+ message: failureMessage || 'Invalid credentials',
3455
3762
  });
3456
3763
  }
3457
3764
  }
3458
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
+
3459
3797
  /**
3460
3798
  * Handle a SCRAM credential request from the Rust SMTP server.
3461
3799
  * Verifier accounts return their stored fields directly; plaintext
3462
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.
3463
3807
  */
3464
3808
  private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
3465
3809
  const { correlationId, username, remoteAddr } = data;