@push.rocks/smartmta 8.2.1 → 9.1.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.
@@ -18,12 +18,15 @@ import type {
18
18
  IRcptToRequestEvent,
19
19
  IScramCredentialRequestEvent,
20
20
  } from '../../security/classes.rustsecuritybridge.js';
21
- import { EmailRouter } from './classes.email.router.js';
21
+ import { EmailRouter, addressGlobToRegExp } from './classes.email.router.js';
22
+ import { assertValidAuthAccounts } from './functions.routing.validation.js';
22
23
  import type {
23
24
  IEmailRoute,
24
25
  IEmailAction,
25
26
  IEmailContext,
26
27
  IEmailDomainConfig,
28
+ ISmtpAuthAccount,
29
+ ISmtpScramVerifier,
27
30
  } from './interfaces.js';
28
31
  import { Email } from '../core/classes.email.js';
29
32
  import { DomainRegistry } from './classes.domain.registry.js';
@@ -61,7 +64,10 @@ export type TRecipientRejectionReason =
61
64
  | 'domain-not-hosted'
62
65
  | 'relay-denied'
63
66
  | 'recipient-hook'
64
- | 'policy-error';
67
+ | 'policy-error'
68
+ | 'sender-scope'
69
+ | 'recipient-scope'
70
+ | 'account-disabled';
65
71
 
66
72
  export interface IResolvedRecipientRoute {
67
73
  recipient: string;
@@ -147,8 +153,9 @@ export class AcceptEnvelopeRejectionError extends Error {
147
153
  }
148
154
 
149
155
  /**
150
- * Terminal durable acceptance context for an envelope containing at least one `store` route.
151
- * The consumer owns persistence and all recipient fan-out before this hook resolves.
156
+ * Terminal durable acceptance context for an envelope containing at least one
157
+ * `store` or `deliver` route. The consumer owns persistence and all recipient
158
+ * fan-out before this hook resolves.
152
159
  */
153
160
  export interface IAcceptEnvelopeContext {
154
161
  email: Email;
@@ -294,6 +301,11 @@ export interface IUnifiedEmailServerOptions {
294
301
  required?: boolean;
295
302
  methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
296
303
  users?: Array<{username: string, password: string}>;
304
+ /**
305
+ * Authenticated SMTP accounts with hashed credentials and send scopes.
306
+ * Coexists with legacy `users`; accounts win on username collision.
307
+ */
308
+ accounts?: ISmtpAuthAccount[];
297
309
  };
298
310
 
299
311
  // TLS options
@@ -338,9 +350,14 @@ export interface IUnifiedEmailServerOptions {
338
350
  hooks?: {
339
351
  /** Async recipient policy hook that may further restrict an accepted built-in decision. */
340
352
  onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
341
- /** Async message acceptance hook for envelopes with no resolved `store` action. */
353
+ /** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
342
354
  onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
343
- /** Required terminal durable owner for any envelope containing a resolved `store` action. */
355
+ /**
356
+ * Required terminal durable owner for any envelope containing a resolved
357
+ * `store` or `deliver` action. The hook must durably persist the exact
358
+ * raw message before resolving; its resolution is what authorizes the
359
+ * SMTP 250 for those envelopes.
360
+ */
344
361
  onAcceptEnvelope?: (context: IAcceptEnvelopeContext) => Promise<void>;
345
362
  /** Observer-safe callback for SMTP recipient rejections. */
346
363
  onRecipientRejected?: (event: IRecipientRejectedEvent) => Promise<void> | void;
@@ -456,6 +473,15 @@ export class UnifiedEmailServer extends EventEmitter {
456
473
  private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
457
474
  private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
458
475
  private acceptedEnvelopeDispatchCleanupCursor?: string;
476
+ private smtpListenerActive = false;
477
+ private smtpListenerShutdown = false;
478
+ private smtpListenerRestartPromise?: Promise<void>;
479
+ private static readonly VERIFIER_DERIVATION_CONCURRENCY = 4;
480
+ private static readonly VERIFIER_DERIVATION_MAX_QUEUE = 32;
481
+ private verifierDerivationActive = 0;
482
+ private verifierDerivationWaiters: Array<() => void> = [];
483
+ /** Compiled scope patterns; keys are config-supplied, bounded by config size. */
484
+ private scopePatternCache: Map<string, RegExp> = new Map();
459
485
 
460
486
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
461
487
  super();
@@ -472,6 +498,10 @@ export class UnifiedEmailServer extends EventEmitter {
472
498
  socketTimeout: options.socketTimeout || 60000 // 1 minute
473
499
  };
474
500
 
501
+ // Auth account misconfiguration must fail loudly at configuration time,
502
+ // never degrade into silent deny-all scopes or silent auth failures.
503
+ assertValidAuthAccounts(this.options.auth?.accounts);
504
+
475
505
  // Initialize Rust security bridge (singleton)
476
506
  this.rustBridge = RustSecurityBridge.getInstance();
477
507
 
@@ -488,10 +518,13 @@ export class UnifiedEmailServer extends EventEmitter {
488
518
  // Initialize domain registry
489
519
  this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
490
520
 
491
- // Initialize email router with routes and storage manager
521
+ // Initialize email router with routes and storage manager. The action
522
+ // policy applies to configured, updated, and storage-hydrated routes so
523
+ // an unservable route set fails loudly at configuration time.
492
524
  this.emailRouter = new EmailRouter(options.routes || [], {
493
525
  storageManager: dcRouter.storageManager,
494
- persistChanges: options.persistRoutes ?? true
526
+ persistChanges: options.persistRoutes ?? true,
527
+ validateAction: (route) => this.assertRouteActionServable(route),
495
528
  });
496
529
 
497
530
  // Initialize rate limiter
@@ -918,6 +951,7 @@ export class UnifiedEmailServer extends EventEmitter {
918
951
  */
919
952
  public async start(): Promise<void> {
920
953
  logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
954
+ this.smtpListenerShutdown = false;
921
955
 
922
956
  try {
923
957
  await this.startDeliveryPipeline();
@@ -1160,7 +1194,7 @@ export class UnifiedEmailServer extends EventEmitter {
1160
1194
  maxRecipients: 100,
1161
1195
  connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
1162
1196
  dataTimeoutSecs: 60,
1163
- authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
1197
+ authEnabled: this.isAuthConfigured(this.options),
1164
1198
  maxAuthFailures: 3,
1165
1199
  socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
1166
1200
  processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
@@ -1184,9 +1218,57 @@ export class UnifiedEmailServer extends EventEmitter {
1184
1218
  throw new Error('Failed to start Rust SMTP server');
1185
1219
  }
1186
1220
 
1221
+ this.smtpListenerActive = true;
1187
1222
  logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
1188
1223
  }
1189
1224
 
1225
+ /** Whether the current options require SMTP AUTH support on the listener. */
1226
+ private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
1227
+ return !!options.auth?.required
1228
+ || !!options.auth?.users?.length
1229
+ || !!options.auth?.accounts?.length;
1230
+ }
1231
+
1232
+ /**
1233
+ * Whether the Rust SMTP listener is currently accepting connections.
1234
+ * Embedders should surface this next to getBridgeState() in health
1235
+ * endpoints: a failed listener reconfiguration leaves it false.
1236
+ */
1237
+ public isSmtpListenerActive(): boolean {
1238
+ return this.smtpListenerActive;
1239
+ }
1240
+
1241
+ /**
1242
+ * Restart only the Rust SMTP listener so listener-level configuration such
1243
+ * as AUTH availability takes effect without a full process restart.
1244
+ * Restarts are serialized and become no-ops once shutdown has begun; a
1245
+ * failed restart leaves the listener inactive, logs loudly, and emits
1246
+ * 'smtpListenerRestartFailed' so embedders can react (e.g. by calling
1247
+ * stop()/start()).
1248
+ */
1249
+ private scheduleSmtpListenerRestart(reason: string): void {
1250
+ const previous = this.smtpListenerRestartPromise || Promise.resolve();
1251
+ this.smtpListenerRestartPromise = previous
1252
+ .then(async () => {
1253
+ if (this.smtpListenerShutdown) return;
1254
+ logger.log('info', `Restarting Rust SMTP listener: ${reason}`);
1255
+ this.smtpListenerActive = false;
1256
+ await this.rustBridge.stopSmtpServer();
1257
+ if (this.smtpListenerShutdown) return;
1258
+ await this.startSmtpServer();
1259
+ })
1260
+ .catch(async (error) => {
1261
+ logger.log(
1262
+ 'error',
1263
+ `Failed to restart Rust SMTP listener (${reason}): ${getErrorMessage(error)}`,
1264
+ );
1265
+ await emitSafely(this, 'smtpListenerRestartFailed', {
1266
+ reason,
1267
+ message: getErrorMessage(error),
1268
+ });
1269
+ });
1270
+ }
1271
+
1190
1272
  /**
1191
1273
  * Stop the unified email server
1192
1274
  */
@@ -1199,6 +1281,18 @@ export class UnifiedEmailServer extends EventEmitter {
1199
1281
  this.acceptedEnvelopeDispatchCleanupTimer = undefined;
1200
1282
  }
1201
1283
  await this.acceptedEnvelopeDispatchCleanupPromise;
1284
+ // Prevent new listener restarts from being scheduled or executed
1285
+ // during shutdown, then drain any in-flight reconfiguration before
1286
+ // tearing down (the chain may be extended while awaited).
1287
+ this.smtpListenerShutdown = true;
1288
+ this.smtpListenerActive = false;
1289
+ while (this.smtpListenerRestartPromise) {
1290
+ const pendingRestart = this.smtpListenerRestartPromise;
1291
+ await pendingRestart;
1292
+ if (this.smtpListenerRestartPromise === pendingRestart) {
1293
+ this.smtpListenerRestartPromise = undefined;
1294
+ }
1295
+ }
1202
1296
  await this.stopDnsValidation();
1203
1297
 
1204
1298
  // Stop the Rust SMTP server first
@@ -1208,6 +1302,7 @@ export class UnifiedEmailServer extends EventEmitter {
1208
1302
  } catch (err) {
1209
1303
  logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
1210
1304
  }
1305
+ this.smtpListenerActive = false;
1211
1306
 
1212
1307
  // Clear the servers array - servers will be garbage collected
1213
1308
  this.servers = [];
@@ -1325,6 +1420,30 @@ export class UnifiedEmailServer extends EventEmitter {
1325
1420
  emailArg?: Email,
1326
1421
  sessionArg?: IExtendedSmtpSession,
1327
1422
  ): Promise<IRecipientPolicyResolution> {
1423
+ // Account scopes are envelope policy and precede route evaluation. This
1424
+ // runs both at RCPT TO and again per recipient at DATA (where the
1425
+ // context is rebuilt from session.envelope truth), so an out-of-scope
1426
+ // MAIL FROM is rejected before any recipient is accepted and cannot be
1427
+ // smuggled past the first phase.
1428
+ const scopeViolation = this.resolveAuthAccountScopeDecision(
1429
+ context.authenticatedUser,
1430
+ context.mailFrom,
1431
+ context.rcptTo,
1432
+ );
1433
+ if (scopeViolation) {
1434
+ const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
1435
+ return {
1436
+ recipient: context.rcptTo,
1437
+ localDomain: scopeRecipientDomain && this.domainRegistry.isDomainRegistered(scopeRecipientDomain)
1438
+ ? scopeRecipientDomain
1439
+ : null,
1440
+ route: null,
1441
+ source: 'no-route',
1442
+ decision: scopeViolation.decision,
1443
+ rejectionReason: scopeViolation.rejectionReason,
1444
+ };
1445
+ }
1446
+
1328
1447
  const currentRecipientContext = { ...context, acceptedRcptTo: [] };
1329
1448
  const email = emailArg || new Email({
1330
1449
  from: context.mailFrom || '<>',
@@ -1354,6 +1473,37 @@ export class UnifiedEmailServer extends EventEmitter {
1354
1473
  };
1355
1474
  }
1356
1475
 
1476
+ if (route.action.type === 'deliver') {
1477
+ if (!isLocalRecipient) {
1478
+ // Deliver actions are terminal local deliveries and never relay;
1479
+ // `allowRelay` is ignored for them.
1480
+ return {
1481
+ recipient: context.rcptTo,
1482
+ localDomain: null,
1483
+ route,
1484
+ source: 'explicit-route',
1485
+ decision: { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' },
1486
+ rejectionReason: 'relay-denied',
1487
+ };
1488
+ }
1489
+ if (!this.options.hooks?.onAcceptEnvelope) {
1490
+ // Defense in depth: route validation rejects this configuration,
1491
+ // so refuse loudly at accept time rather than accept-then-fail.
1492
+ return {
1493
+ recipient: context.rcptTo,
1494
+ localDomain,
1495
+ route,
1496
+ source: 'explicit-route',
1497
+ decision: {
1498
+ accepted: false,
1499
+ smtpCode: 451,
1500
+ smtpMessage: 'Local delivery temporarily unavailable',
1501
+ },
1502
+ rejectionReason: 'policy-error',
1503
+ };
1504
+ }
1505
+ }
1506
+
1357
1507
  if (!isLocalRecipient && !route.action.allowRelay) {
1358
1508
  return {
1359
1509
  recipient: context.rcptTo,
@@ -1500,6 +1650,156 @@ export class UnifiedEmailServer extends EventEmitter {
1500
1650
  return match?.[1];
1501
1651
  }
1502
1652
 
1653
+ // -----------------------------------------------------------------------
1654
+ // SMTP auth accounts and scopes
1655
+ // -----------------------------------------------------------------------
1656
+
1657
+ /** Resolve the configured account for a username. Accounts win over legacy users. */
1658
+ private findAuthAccount(username: string): ISmtpAuthAccount | undefined {
1659
+ return this.options.auth?.accounts?.find(
1660
+ (account) => account.username === username,
1661
+ );
1662
+ }
1663
+
1664
+ private normalizeScopeAddress(addressArg: string | null | undefined): string {
1665
+ return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
1666
+ }
1667
+
1668
+ /**
1669
+ * Glob match ('a@b.com' | '*@b.com') against a normalized address, using
1670
+ * the same pattern semantics as route matching. Compiled patterns are
1671
+ * cached; keys are configuration-supplied (never remote-controlled), so
1672
+ * the cache is bounded by account configuration size.
1673
+ */
1674
+ private matchesScopePatterns(
1675
+ addressArg: string | null | undefined,
1676
+ patterns: string[] | undefined,
1677
+ ): boolean {
1678
+ const address = this.normalizeScopeAddress(addressArg);
1679
+ return (patterns || []).some((pattern) => {
1680
+ let regExp = this.scopePatternCache.get(pattern);
1681
+ if (!regExp) {
1682
+ regExp = addressGlobToRegExp(pattern.trim());
1683
+ this.scopePatternCache.set(pattern, regExp);
1684
+ }
1685
+ return regExp.test(address);
1686
+ });
1687
+ }
1688
+
1689
+ /**
1690
+ * Envelope-level scope decision for an authenticated session. Returns null
1691
+ * when the envelope is permitted (unauthenticated sessions, usernames with
1692
+ * no configured account, and accounts without a scope are unrestricted).
1693
+ */
1694
+ private resolveAuthAccountScopeDecision(
1695
+ authenticatedUser: string | null,
1696
+ mailFrom: string,
1697
+ rcptTo: string,
1698
+ ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
1699
+ if (!authenticatedUser) return null;
1700
+ const account = this.findAuthAccount(authenticatedUser);
1701
+ if (!account) return null;
1702
+ if (account.enabled === false) {
1703
+ // The account was disabled after authentication succeeded; the session
1704
+ // must not be able to send anything.
1705
+ return {
1706
+ decision: {
1707
+ accepted: false,
1708
+ smtpCode: 550,
1709
+ smtpMessage: '5.7.1 Account disabled',
1710
+ },
1711
+ rejectionReason: 'account-disabled',
1712
+ };
1713
+ }
1714
+ const scope = account.scope;
1715
+ if (!scope) return null;
1716
+ if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
1717
+ return {
1718
+ decision: {
1719
+ accepted: false,
1720
+ smtpCode: 553,
1721
+ smtpMessage: `5.7.1 Sender address not permitted for authenticated user ${authenticatedUser}`,
1722
+ },
1723
+ rejectionReason: 'sender-scope',
1724
+ };
1725
+ }
1726
+ if (
1727
+ scope.recipients?.mode === 'patterns'
1728
+ && !this.matchesScopePatterns(rcptTo, scope.recipients.patterns)
1729
+ ) {
1730
+ return {
1731
+ decision: {
1732
+ accepted: false,
1733
+ smtpCode: 550,
1734
+ smtpMessage: `5.7.1 Recipient not permitted for authenticated user ${authenticatedUser}`,
1735
+ },
1736
+ rejectionReason: 'recipient-scope',
1737
+ };
1738
+ }
1739
+ return null;
1740
+ }
1741
+
1742
+ /**
1743
+ * Extract every mailbox address from the parsed From header. The Email
1744
+ * abstraction keeps only the first From mailbox, which is not sufficient
1745
+ * for scope enforcement: `From: allowed@a.com, victim@b.com` must not
1746
+ * pass. Parsing stops at the first CRLFCRLF header boundary when present
1747
+ * (the Rust receiver normalizes line endings to CRLF); a bodyless message
1748
+ * without one is parsed whole.
1749
+ */
1750
+ private async parseHeaderFromMailboxes(rawMessage: Buffer): Promise<string[]> {
1751
+ const headerBoundary = rawMessage.indexOf('\r\n\r\n');
1752
+ const headerSection = headerBoundary === -1
1753
+ ? rawMessage
1754
+ : rawMessage.subarray(0, headerBoundary + 4);
1755
+ const parsed = await plugins.mailparser.simpleParser(headerSection);
1756
+ return ((parsed.from as any)?.value || [])
1757
+ .map((mailbox: any) => mailbox?.address)
1758
+ .filter(Boolean) as string[];
1759
+ }
1760
+
1761
+ /**
1762
+ * Header-From alignment for scoped authenticated accounts at DATA. The
1763
+ * envelope MAIL FROM is enforced per recipient via resolveRecipientPolicy
1764
+ * from session.envelope truth; every parsed header From mailbox must
1765
+ * independently stay in scope because route matching and downstream
1766
+ * consumers see the header identity.
1767
+ */
1768
+ private async checkAuthenticatedHeaderFromScope(
1769
+ email: Email,
1770
+ rawMessage: Buffer,
1771
+ session: IExtendedSmtpSession,
1772
+ ): Promise<ISmtpDecision | null> {
1773
+ const username = session.user?.username;
1774
+ if (!username) return null;
1775
+ const account = this.findAuthAccount(username);
1776
+ // Disabled accounts and envelope violations are rejected by
1777
+ // resolveRecipientPolicy; this check only adds header alignment.
1778
+ if (!account || account.enabled === false || !account.scope) return null;
1779
+ let fromMailboxes: string[];
1780
+ try {
1781
+ fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
1782
+ } catch {
1783
+ fromMailboxes = [];
1784
+ }
1785
+ if (fromMailboxes.length === 0) {
1786
+ // No parsable From header: Email.from falls back to the envelope
1787
+ // MAIL FROM, which resolveRecipientPolicy has already scope-checked.
1788
+ fromMailboxes = [email.getFromAddress()];
1789
+ }
1790
+ const outOfScope = fromMailboxes.find(
1791
+ (mailbox) => !this.matchesScopePatterns(mailbox, account.scope!.senders),
1792
+ );
1793
+ if (outOfScope === undefined) {
1794
+ return null;
1795
+ }
1796
+ return {
1797
+ accepted: false,
1798
+ smtpCode: 553,
1799
+ smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
1800
+ };
1801
+ }
1802
+
1503
1803
  private getRecipientValidationTimeoutMs(): number {
1504
1804
  return this.options.smtp?.recipientValidationTimeoutMs || 5000;
1505
1805
  }
@@ -2088,7 +2388,6 @@ export class UnifiedEmailServer extends EventEmitter {
2088
2388
  }
2089
2389
  const hasQueueAction = plan.some((entry) => (
2090
2390
  entry.action.type === 'forward'
2091
- || entry.action.type === 'deliver'
2092
2391
  || entry.action.type === 'process'
2093
2392
  ));
2094
2393
  if (hasQueueAction) {
@@ -2168,7 +2467,16 @@ export class UnifiedEmailServer extends EventEmitter {
2168
2467
 
2169
2468
  try {
2170
2469
  let result: IAcceptedEnvelopeRecipientDispatchResult;
2171
- if (entry.action.type === 'store') {
2470
+ if (entry.action.type === 'store' || entry.action.type === 'deliver') {
2471
+ // Terminal local deliveries: the consumer's durably persisted copy
2472
+ // of the accepted envelope IS the delivery. Nothing is enqueued.
2473
+ if (entry.action.type === 'deliver' && entry.localDomain === null) {
2474
+ // Pre-9.x plans could contain relay-intended deliver entries.
2475
+ // Deliver never relays, so fail loudly instead of dropping.
2476
+ throw new Error(
2477
+ `Deliver action for ${entry.recipient} has no local domain; deliver actions never relay`,
2478
+ );
2479
+ }
2172
2480
  result = { ...commonResult, status: 'stored' };
2173
2481
  } else {
2174
2482
  parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
@@ -2275,6 +2583,29 @@ export class UnifiedEmailServer extends EventEmitter {
2275
2583
 
2276
2584
 
2277
2585
  const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
2586
+
2587
+ // Header-From alignment for scoped authenticated accounts must be
2588
+ // enforced before any durable acceptance or routing: the RCPT phase
2589
+ // only ever saw the envelope MAIL FROM.
2590
+ const headerFromViolation = await this.checkAuthenticatedHeaderFromScope(
2591
+ email,
2592
+ rawMessageBuffer,
2593
+ session,
2594
+ );
2595
+ if (headerFromViolation) {
2596
+ logger.log(
2597
+ 'warn',
2598
+ `Rejected DATA from authenticated user ${session.user?.username}: ${headerFromViolation.smtpMessage}`,
2599
+ );
2600
+ await this.rustBridge.sendEmailProcessingResult({
2601
+ correlationId,
2602
+ accepted: false,
2603
+ smtpCode: headerFromViolation.smtpCode || 553,
2604
+ smtpMessage: headerFromViolation.smtpMessage || 'Header From not permitted',
2605
+ });
2606
+ return;
2607
+ }
2608
+
2278
2609
  const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
2279
2610
  const rejectedRecipients = resolvedRecipientRoutes.filter(
2280
2611
  (resolution) => !resolution.decision.accepted,
@@ -2298,10 +2629,13 @@ export class UnifiedEmailServer extends EventEmitter {
2298
2629
  return;
2299
2630
  }
2300
2631
 
2301
- const hasStoreRoute = resolvedRecipientRoutes.some(
2302
- (resolution) => resolution.route?.action.type === 'store',
2632
+ // `store` and `deliver` are both terminal local deliveries: the
2633
+ // envelope must be durably owned by the consumer before SMTP 250.
2634
+ const hasDurableLocalRoute = resolvedRecipientRoutes.some(
2635
+ (resolution) => resolution.route?.action.type === 'store'
2636
+ || resolution.route?.action.type === 'deliver',
2303
2637
  );
2304
- if (hasStoreRoute) {
2638
+ if (hasDurableLocalRoute) {
2305
2639
  try {
2306
2640
  await this.acceptDurableEnvelope(
2307
2641
  email,
@@ -2498,20 +2832,161 @@ export class UnifiedEmailServer extends EventEmitter {
2498
2832
  }
2499
2833
 
2500
2834
  /**
2501
- * Handle an authRequest event from the Rust SMTP server.
2835
+ * Compute SCRAM-SHA-256 credential fields from a plaintext password with a
2836
+ * fresh random salt (used for legacy plaintext credentials only; verifier
2837
+ * accounts return their stored fields directly).
2838
+ */
2839
+ private computeScramCredentials(password: string): {
2840
+ salt: string;
2841
+ iterations: number;
2842
+ storedKey: string;
2843
+ serverKey: string;
2844
+ } {
2845
+ const salt = plugins.crypto.randomBytes(16);
2846
+ const iterations = 4096;
2847
+
2848
+ // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
2849
+ const saltedPassword = plugins.crypto.pbkdf2Sync(password, salt, iterations, 32, 'sha256');
2850
+
2851
+ // ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
2852
+ const clientKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
2853
+
2854
+ // StoredKey = SHA256(ClientKey)
2855
+ const storedKey = plugins.crypto.createHash('sha256').update(clientKey).digest();
2856
+
2857
+ // ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
2858
+ const serverKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
2859
+
2860
+ return {
2861
+ salt: salt.toString('base64'),
2862
+ iterations,
2863
+ storedKey: storedKey.toString('base64'),
2864
+ serverKey: serverKey.toString('base64'),
2865
+ };
2866
+ }
2867
+
2868
+ /**
2869
+ * Bound concurrent PBKDF2 derivations so credential-stuffing pressure at
2870
+ * high configured iteration counts cannot exhaust the shared libuv
2871
+ * threadpool. Returns false when the wait queue is saturated (fail fast).
2872
+ */
2873
+ private async acquireVerifierDerivationSlot(): Promise<boolean> {
2874
+ if (this.verifierDerivationActive < UnifiedEmailServer.VERIFIER_DERIVATION_CONCURRENCY) {
2875
+ this.verifierDerivationActive++;
2876
+ return true;
2877
+ }
2878
+ if (this.verifierDerivationWaiters.length >= UnifiedEmailServer.VERIFIER_DERIVATION_MAX_QUEUE) {
2879
+ return false;
2880
+ }
2881
+ await new Promise<void>((resolve) => {
2882
+ this.verifierDerivationWaiters.push(resolve);
2883
+ });
2884
+ // Slot ownership was handed over directly by the releaser.
2885
+ return true;
2886
+ }
2887
+
2888
+ private releaseVerifierDerivationSlot(): void {
2889
+ const nextWaiter = this.verifierDerivationWaiters.shift();
2890
+ if (nextWaiter) {
2891
+ // Hand the active slot to the waiter without decrementing so the
2892
+ // concurrency bound is exact even across microtask gaps.
2893
+ nextWaiter();
2894
+ return;
2895
+ }
2896
+ this.verifierDerivationActive--;
2897
+ }
2898
+
2899
+ /**
2900
+ * Verify a presented plaintext password against a stored SCRAM verifier by
2901
+ * deriving StoredKey from the presented password and comparing in constant
2902
+ * time. PBKDF2 runs on the async crypto threadpool so caller-configured
2903
+ * iteration counts never block the event loop inside the Rust auth
2904
+ * callback window; iteration bounds are enforced at configuration time and
2905
+ * in-flight derivations are bounded by a small semaphore.
2906
+ */
2907
+ private async verifyPasswordAgainstScramVerifier(
2908
+ password: string,
2909
+ verifier: ISmtpScramVerifier,
2910
+ ): Promise<boolean> {
2911
+ const acquired = await this.acquireVerifierDerivationSlot();
2912
+ if (!acquired) {
2913
+ logger.log(
2914
+ 'warn',
2915
+ 'SCRAM verifier derivation queue is saturated; failing authentication fast',
2916
+ );
2917
+ return false;
2918
+ }
2919
+ try {
2920
+ const salt = Buffer.from(verifier.salt, 'base64');
2921
+ const storedKey = Buffer.from(verifier.storedKey, 'base64');
2922
+ const saltedPassword = await new Promise<Buffer>((resolve, reject) => {
2923
+ plugins.crypto.pbkdf2(
2924
+ password,
2925
+ salt,
2926
+ verifier.iterations,
2927
+ 32,
2928
+ 'sha256',
2929
+ (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)),
2930
+ );
2931
+ });
2932
+ const clientKey = plugins.crypto.createHmac('sha256', saltedPassword)
2933
+ .update('Client Key')
2934
+ .digest();
2935
+ const candidateStoredKey = plugins.crypto.createHash('sha256')
2936
+ .update(clientKey)
2937
+ .digest();
2938
+ if (candidateStoredKey.length !== storedKey.length) {
2939
+ return false;
2940
+ }
2941
+ return plugins.crypto.timingSafeEqual(candidateStoredKey, storedKey);
2942
+ } catch {
2943
+ return false;
2944
+ } finally {
2945
+ this.releaseVerifierDerivationSlot();
2946
+ }
2947
+ }
2948
+
2949
+ /** Constant-time string comparison for legacy plaintext credentials. */
2950
+ private timingSafeStringEqual(left: string, right: string): boolean {
2951
+ const leftBuffer = Buffer.from(left, 'utf8');
2952
+ const rightBuffer = Buffer.from(right, 'utf8');
2953
+ if (leftBuffer.length !== rightBuffer.length) {
2954
+ // Burn comparable time so length mismatches are not a fast path.
2955
+ plugins.crypto.timingSafeEqual(leftBuffer, leftBuffer);
2956
+ return false;
2957
+ }
2958
+ return plugins.crypto.timingSafeEqual(leftBuffer, rightBuffer);
2959
+ }
2960
+
2961
+ /**
2962
+ * Handle an authRequest event from the Rust SMTP server (PLAIN/LOGIN).
2963
+ * Accounts win over legacy users on username collision; disabled accounts
2964
+ * always fail.
2502
2965
  */
2503
2966
  private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
2504
2967
  const { correlationId, username, password, remoteAddr } = data;
2505
2968
 
2506
2969
  logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
2507
2970
 
2508
- // Check against configured users
2509
- const users = this.options.auth?.users || [];
2510
- const matched = users.find(
2511
- u => u.username === username && u.password === password
2512
- );
2971
+ const account = this.findAuthAccount(username);
2972
+ let success = false;
2973
+ if (account) {
2974
+ if (account.enabled === false) {
2975
+ success = false;
2976
+ } else if ('verifier' in account.credential) {
2977
+ success = await this.verifyPasswordAgainstScramVerifier(password, account.credential.verifier);
2978
+ } else {
2979
+ success = this.timingSafeStringEqual(account.credential.password, password);
2980
+ }
2981
+ } else {
2982
+ // Legacy users array keeps its existing behavior.
2983
+ const users = this.options.auth?.users || [];
2984
+ success = users.some(
2985
+ u => u.username === username && u.password === password
2986
+ );
2987
+ }
2513
2988
 
2514
- if (matched) {
2989
+ if (success) {
2515
2990
  await this.rustBridge.sendAuthResult({
2516
2991
  correlationId,
2517
2992
  success: true,
@@ -2528,14 +3003,43 @@ export class UnifiedEmailServer extends EventEmitter {
2528
3003
 
2529
3004
  /**
2530
3005
  * Handle a SCRAM credential request from the Rust SMTP server.
2531
- * Computes SCRAM-SHA-256 credentials from the stored password for the given user.
3006
+ * Verifier accounts return their stored fields directly; plaintext
3007
+ * credentials (accounts or legacy users) derive fields on the fly.
2532
3008
  */
2533
3009
  private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
2534
3010
  const { correlationId, username, remoteAddr } = data;
2535
- const crypto = await import('crypto');
2536
3011
 
2537
3012
  logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
2538
3013
 
3014
+ const account = this.findAuthAccount(username);
3015
+ if (account) {
3016
+ if (account.enabled === false) {
3017
+ await this.rustBridge.sendScramCredentialResult({
3018
+ correlationId,
3019
+ found: false,
3020
+ });
3021
+ return;
3022
+ }
3023
+ if ('verifier' in account.credential) {
3024
+ const { salt, iterations, storedKey, serverKey } = account.credential.verifier;
3025
+ await this.rustBridge.sendScramCredentialResult({
3026
+ correlationId,
3027
+ found: true,
3028
+ salt,
3029
+ iterations,
3030
+ storedKey,
3031
+ serverKey,
3032
+ });
3033
+ return;
3034
+ }
3035
+ await this.rustBridge.sendScramCredentialResult({
3036
+ correlationId,
3037
+ found: true,
3038
+ ...this.computeScramCredentials(account.credential.password),
3039
+ });
3040
+ return;
3041
+ }
3042
+
2539
3043
  const users = this.options.auth?.users || [];
2540
3044
  const matched = users.find(u => u.username === username);
2541
3045
 
@@ -2547,29 +3051,10 @@ export class UnifiedEmailServer extends EventEmitter {
2547
3051
  return;
2548
3052
  }
2549
3053
 
2550
- // Compute SCRAM-SHA-256 credentials from plaintext password
2551
- const salt = crypto.randomBytes(16);
2552
- const iterations = 4096;
2553
-
2554
- // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
2555
- const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
2556
-
2557
- // ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
2558
- const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
2559
-
2560
- // StoredKey = SHA256(ClientKey)
2561
- const storedKey = crypto.createHash('sha256').update(clientKey).digest();
2562
-
2563
- // ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
2564
- const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
2565
-
2566
3054
  await this.rustBridge.sendScramCredentialResult({
2567
3055
  correlationId,
2568
3056
  found: true,
2569
- salt: salt.toString('base64'),
2570
- iterations,
2571
- storedKey: storedKey.toString('base64'),
2572
- serverKey: serverKey.toString('base64'),
3057
+ ...this.computeScramCredentials(matched.password),
2573
3058
  });
2574
3059
  }
2575
3060
 
@@ -2807,13 +3292,35 @@ export class UnifiedEmailServer extends EventEmitter {
2807
3292
  return routes;
2808
3293
  }
2809
3294
 
3295
+ /**
3296
+ * Server-level action policy for a single route. A `deliver` action is a
3297
+ * terminal local delivery owned by the durable `hooks.onAcceptEnvelope`
3298
+ * acceptance path; without that hook the route could only accept-then-fail,
3299
+ * so it is rejected at configuration time instead.
3300
+ */
3301
+ private assertRouteActionServable(route: IEmailRoute): void {
3302
+ if (
3303
+ route.action.type === 'deliver'
3304
+ && !this.options.hooks?.onAcceptEnvelope
3305
+ ) {
3306
+ throw new Error(
3307
+ `Route ${route.name}: action type 'deliver' requires a configured hooks.onAcceptEnvelope durable local-delivery owner`,
3308
+ );
3309
+ }
3310
+ }
3311
+
2810
3312
  /**
2811
3313
  * Update server configuration
2812
3314
  */
2813
3315
  public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
3316
+ // Fail loudly before any option state is mutated.
3317
+ if (options.auth) {
3318
+ assertValidAuthAccounts(options.auth.accounts);
3319
+ }
2814
3320
  const portsChanged = options.ports &&
2815
3321
  (!this.options.ports ||
2816
3322
  JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
3323
+ const authEnabledBefore = this.isAuthConfigured(this.options);
2817
3324
 
2818
3325
  if (portsChanged) {
2819
3326
  this.stop().then(() => {
@@ -2821,7 +3328,21 @@ export class UnifiedEmailServer extends EventEmitter {
2821
3328
  this.start();
2822
3329
  });
2823
3330
  } else {
2824
- this.options = { ...this.options, ...options };
3331
+ if (options.routes) {
3332
+ // Validate synchronously against the incoming hook configuration
3333
+ // before any option state is mutated, so invalid routes throw here
3334
+ // instead of surfacing as an unhandled rejection.
3335
+ const previousOptions = this.options;
3336
+ this.options = { ...this.options, ...options };
3337
+ try {
3338
+ this.emailRouter.assertRoutesValid(options.routes);
3339
+ } catch (error) {
3340
+ this.options = previousOptions;
3341
+ throw error;
3342
+ }
3343
+ } else {
3344
+ this.options = { ...this.options, ...options };
3345
+ }
2825
3346
 
2826
3347
  if (options.domains) {
2827
3348
  this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
@@ -2830,6 +3351,26 @@ export class UnifiedEmailServer extends EventEmitter {
2830
3351
  if (options.routes) {
2831
3352
  this.emailRouter.updateRoutes(options.routes);
2832
3353
  }
3354
+
3355
+ if (options.auth) {
3356
+ // Drop compiled patterns from replaced account configurations.
3357
+ this.scopePatternCache.clear();
3358
+ }
3359
+
3360
+ // AUTH availability is listener-level Rust configuration. When the
3361
+ // auth presence transitions (zero -> first account/user or reverse)
3362
+ // the running listener must be restarted for AUTH to take effect
3363
+ // without a full process restart.
3364
+ if (
3365
+ options.auth
3366
+ && this.smtpListenerActive
3367
+ && !this.smtpListenerShutdown
3368
+ && this.isAuthConfigured(this.options) !== authEnabledBefore
3369
+ ) {
3370
+ this.scheduleSmtpListenerRestart(
3371
+ `SMTP AUTH ${this.isAuthConfigured(this.options) ? 'enabled' : 'disabled'} by configuration update`,
3372
+ );
3373
+ }
2833
3374
  }
2834
3375
  }
2835
3376
 
@@ -2837,6 +3378,8 @@ export class UnifiedEmailServer extends EventEmitter {
2837
3378
  * Update email routes
2838
3379
  */
2839
3380
  public updateEmailRoutes(routes: IEmailRoute[]): void {
3381
+ // Fail loudly and synchronously before mutating server options.
3382
+ this.emailRouter.assertRoutesValid(routes);
2840
3383
  this.options.routes = routes;
2841
3384
  this.emailRouter.updateRoutes(routes);
2842
3385
  }