@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.
package/changelog.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-09-15 - 9.6.0
4
+
5
+ ### Fixes
6
+
7
+ - release construction-owned timers when the constructor rejects configuration (lifecycle)
8
+ - `UnifiedEmailServer` constructs `BounceManager` and `UnifiedRateLimiter`, whose constructors arm cleanup intervals, before later steps can still reject the configuration by throwing; a throwing constructor returns no instance, so `stop()` could never release them and the process could not exit. Construction now releases those two owners on failure and rethrows the original error.
9
+ - Clear the self-MX loop test's inject timer on every path and assert that a rejected construction leaves the active timer count unchanged.
10
+
11
+ ### Features
12
+
13
+ - add `hooks.onAuthenticate` for embedder-owned dynamic SMTP credentials (auth-hook)
14
+ - Consult the hook for `PLAIN`/`LOGIN` attempts whose username neither `auth.accounts` nor `auth.users` declares, so an application can own credentials no static list can express (per-tenant bindings, remote authorities, opaque hashes such as argon2id). Configured usernames are never handed to the hook and cannot be overridden or weakened by it.
15
+ - Enforce a hook-returned `scope` identically to a configured account scope, at both the `RCPT TO` envelope check and the `DATA` header-`From` alignment check, for the lifetime of the authenticating session.
16
+ - Bind grants to the SMTP session that authenticated so no session inherits another's scope; bound the grant table by a hard cap with oldest-first eviction plus a TTL, and refuse a session whose grant is gone with a temporary `451` and the new `onRecipientRejected` reason `auth-scope-expired` rather than treating it as unrestricted.
17
+ - Count a configured hook in `isAuthConfigured`, so a server whose credentials are entirely dynamic advertises `AUTH` with an empty account list and keeps the existing listener-restart behaviour when the hook is added or removed.
18
+ - Fail closed on a throw, a `smtp.authenticationTimeoutMs` timeout (new, default 4000 ms) or a non-object return; report SCRAM credential lookups for hook-owned usernames as not-found, since an opaque verifier cannot produce SCRAM fields.
19
+ - Match configuration's ownership of a username case-insensitively while leaving credential verification exact, so a casing variant cannot route around a configured — or deliberately disabled — account by reaching the hook instead.
20
+ - Clamp `smtp.authenticationTimeoutMs` to 4500 ms with a warning, since the SMTP listener abandons the AUTH round trip after a fixed 5000 ms and a larger value could never take effect.
21
+ - Record a rejection `message` in the server log rather than sending it to the client: every failed authentication answers with the listener's standard `535`, so the field cannot be used to probe which usernames exist.
22
+ - Refuse a missing grant independently at both the `RCPT TO` and `DATA` enforcement points, so neither depends on the other having run first.
23
+ - Add the `dynamicSmtpAuthenticationHook` capability probe, `ISmtpAuthenticateContext` and `ISmtpAuthenticateDecision` exports, readme documentation, and 21 regression tests.
24
+ - Known capacity edge: above 4096 concurrent hook-authenticated sessions, oldest-first eviction reaches live grants, and an affected client must reconnect and authenticate again rather than recovering in place. The durable fix is a listener session-close event, which the Rust connection layer does not currently emit.
25
+
26
+ ### Maintenance
27
+
28
+ - Update pnpm to 12.4.0, tstest to 6.0.0, tsrust to 1.13.0, tsbuild to 4.4.3, the gitzone CLI to 6.13.2 and Node typings to 26.5.0 for the release; deny the transitive dees-catalog build script.
29
+
3
30
  ## 2026-07-31 - 9.5.0
4
31
 
5
32
  ### Features
Binary file
@@ -0,0 +1,14 @@
1
+ {
2
+ "format": "tsrust.build-provenance.v2",
3
+ "binarySha256": "74d10e10644850c92fa7061cbabb4dc9583ef40e8c0cc2bb0024dcbdb8d80470",
4
+ "buildInfo": {
5
+ "projectName": "@push.rocks/smartmta",
6
+ "projectVersion": "9.6.0",
7
+ "gitCommit": "eac7a76f2d7e0a49006977a34ff678b638700a1f",
8
+ "gitDirty": false,
9
+ "builtAt": "2026-09-15T17:08:14.540Z",
10
+ "tsrustVersion": "1.13.0",
11
+ "binary": "mailer-bin",
12
+ "target": "linux_amd64"
13
+ }
14
+ }
Binary file
@@ -0,0 +1,14 @@
1
+ {
2
+ "format": "tsrust.build-provenance.v2",
3
+ "binarySha256": "e9372baaab19974f2ab8b353124c88330a99af54c8bc0d17a66359bee6823615",
4
+ "buildInfo": {
5
+ "projectName": "@push.rocks/smartmta",
6
+ "projectVersion": "9.6.0",
7
+ "gitCommit": "eac7a76f2d7e0a49006977a34ff678b638700a1f",
8
+ "gitDirty": false,
9
+ "builtAt": "2026-09-15T17:08:15.103Z",
10
+ "tsrustVersion": "1.13.0",
11
+ "binary": "mailer-bin",
12
+ "target": "linux_arm64"
13
+ }
14
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '9.5.0',
6
+ version: '9.6.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
@@ -16,4 +16,5 @@ export declare const smartMtaCapabilities: Readonly<{
16
16
  readonly terminalLocalDeliverActions: true;
17
17
  readonly smtpAuthAccountsAndScopes: true;
18
18
  readonly durableRawOutboundQueue: true;
19
+ readonly dynamicSmtpAuthenticationHook: true;
19
20
  }>;
package/dist_ts/index.js CHANGED
@@ -16,5 +16,6 @@ export const smartMtaCapabilities = Object.freeze({
16
16
  terminalLocalDeliverActions: true,
17
17
  smtpAuthAccountsAndScopes: true,
18
18
  durableRawOutboundQueue: true,
19
+ dynamicSmtpAuthenticationHook: true,
19
20
  });
20
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHlCQUF5QixDQUFDO0FBQ3hDLGNBQWMsaUJBQWlCLENBQUM7QUFDaEMsY0FBYyxxQkFBcUIsQ0FBQztBQUNwQyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sRUFBRSxNQUFNLGFBQWEsQ0FBQztBQUVqRCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ2hELDBCQUEwQixFQUFFLElBQUk7SUFDaEMscUJBQXFCLEVBQUUsSUFBSTtJQUMzQiwwQkFBMEIsRUFBRSxJQUFJO0lBQ2hDLHFCQUFxQixFQUFFLElBQUk7SUFDM0Isb0JBQW9CLEVBQUUsSUFBSTtJQUMxQixpQkFBaUIsRUFBRSxJQUFJO0lBQ3ZCLG1CQUFtQixFQUFFLElBQUk7SUFDekIsbUJBQW1CLEVBQUUsSUFBSTtJQUN6Qiw0QkFBNEIsRUFBRSxJQUFJO0lBQ2xDLDZCQUE2QixFQUFFLElBQUk7SUFDbkMsMkJBQTJCLEVBQUUsSUFBSTtJQUNqQyx5QkFBeUIsRUFBRSxJQUFJO0lBQy9CLHVCQUF1QixFQUFFLElBQUk7Q0FDckIsQ0FBQyxDQUFDIn0=
21
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHlCQUF5QixDQUFDO0FBQ3hDLGNBQWMsaUJBQWlCLENBQUM7QUFDaEMsY0FBYyxxQkFBcUIsQ0FBQztBQUNwQyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sRUFBRSxNQUFNLGFBQWEsQ0FBQztBQUVqRCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ2hELDBCQUEwQixFQUFFLElBQUk7SUFDaEMscUJBQXFCLEVBQUUsSUFBSTtJQUMzQiwwQkFBMEIsRUFBRSxJQUFJO0lBQ2hDLHFCQUFxQixFQUFFLElBQUk7SUFDM0Isb0JBQW9CLEVBQUUsSUFBSTtJQUMxQixpQkFBaUIsRUFBRSxJQUFJO0lBQ3ZCLG1CQUFtQixFQUFFLElBQUk7SUFDekIsbUJBQW1CLEVBQUUsSUFBSTtJQUN6Qiw0QkFBNEIsRUFBRSxJQUFJO0lBQ2xDLDZCQUE2QixFQUFFLElBQUk7SUFDbkMsMkJBQTJCLEVBQUUsSUFBSTtJQUNqQyx5QkFBeUIsRUFBRSxJQUFJO0lBQy9CLHVCQUF1QixFQUFFLElBQUk7SUFDN0IsNkJBQTZCLEVBQUUsSUFBSTtDQUMzQixDQUFDLENBQUMifQ==
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
2
2
  import { DKIMCreator } from '../security/classes.dkimcreator.js';
3
3
  import type { IStorageManager } from '../interfaces.storage.js';
4
4
  import type { IInboundEmailSecurityResult } from '../../security/classes.rustsecuritybridge.js';
5
- import type { IEmailRoute, IEmailAction, IEmailDomainConfig, ISmtpAuthAccount } from './interfaces.js';
5
+ import type { IEmailRoute, IEmailAction, IEmailDomainConfig, ISmtpAuthAccount, ISmtpAuthenticateContext, ISmtpAuthenticateDecision } from './interfaces.js';
6
6
  import { Email } from '../core/classes.email.js';
7
7
  import { DomainRegistry } from './classes.domain.registry.js';
8
8
  import { BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
@@ -20,7 +20,7 @@ interface DcRouter {
20
20
  options?: any;
21
21
  }
22
22
  export type TRecipientRouteSource = 'explicit-route' | 'domain-policy' | 'no-route';
23
- export type TRecipientRejectionReason = 'explicit-route' | 'domain-policy' | 'no-route' | 'domain-not-hosted' | 'relay-denied' | 'recipient-hook' | 'policy-error' | 'sender-scope' | 'recipient-scope' | 'account-disabled';
23
+ export type TRecipientRejectionReason = 'explicit-route' | 'domain-policy' | 'no-route' | 'domain-not-hosted' | 'relay-denied' | 'recipient-hook' | 'policy-error' | 'sender-scope' | 'recipient-scope' | 'account-disabled' | 'auth-scope-expired';
24
24
  export interface IResolvedRecipientRoute {
25
25
  recipient: string;
26
26
  localDomain: string | null;
@@ -254,6 +254,8 @@ export interface IUnifiedEmailServerOptions {
254
254
  tlsTerminatedPorts?: number[];
255
255
  /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
256
256
  recipientValidationTimeoutMs?: number;
257
+ /** Timeout for `hooks.onAuthenticate` callbacks. Defaults to 5000 ms. */
258
+ authenticationTimeoutMs?: number;
257
259
  /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
258
260
  messageAcceptanceTimeoutMs?: number;
259
261
  /** Maximum complete messages concurrently held across Rust and TypeScript. */
@@ -265,6 +267,24 @@ export interface IUnifiedEmailServerOptions {
265
267
  };
266
268
  };
267
269
  hooks?: {
270
+ /**
271
+ * Async credential verification for PLAIN/LOGIN, letting an embedding
272
+ * application own dynamic SMTP credentials that no static configuration
273
+ * can express.
274
+ *
275
+ * Consulted only for usernames that `auth.accounts` and `auth.users` do
276
+ * not declare: configuration always owns the usernames it lists, so a
277
+ * hook can never override or weaken a configured credential. Configuring
278
+ * the hook enables SMTP AUTH on the listener even with no accounts.
279
+ *
280
+ * The returned `scope` is enforced for that session exactly as a
281
+ * configured account's scope is. A throw or a
282
+ * `smtp.authenticationTimeoutMs` timeout fails the attempt closed.
283
+ *
284
+ * SCRAM cannot be served from this hook — see `onAuthenticate` notes in
285
+ * the readme. Hook-owned usernames authenticate over PLAIN/LOGIN only.
286
+ */
287
+ onAuthenticate?: (context: ISmtpAuthenticateContext) => Promise<ISmtpAuthenticateDecision>;
268
288
  /** Async recipient policy hook that may further restrict an accepted built-in decision. */
269
289
  onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
270
290
  /** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
@@ -385,7 +405,40 @@ export declare class UnifiedEmailServer extends EventEmitter {
385
405
  private verifierDerivationWaiters;
386
406
  /** Compiled scope patterns; keys are config-supplied, bounded by config size. */
387
407
  private scopePatternCache;
408
+ /**
409
+ * Scopes granted by `hooks.onAuthenticate`, keyed by the session the
410
+ * credential authenticated. The SMTP listener reports no session-close
411
+ * event, so entries are bounded by both a TTL and a hard cap rather than
412
+ * by session lifetime.
413
+ *
414
+ * Losing an entry must never widen a session, so the absence of a grant is
415
+ * not read as "unrestricted": a session authenticated as a username that
416
+ * configuration does not declare can only have come from the hook, and is
417
+ * refused with a temporary failure until it authenticates again.
418
+ */
419
+ private hookAuthenticatedSessions;
420
+ private static readonly hookAuthSessionTtlMs;
421
+ private static readonly hookAuthSessionMax;
422
+ private static readonly hookAuthSweepInterval;
423
+ private hookAuthSweepCountdown;
424
+ /** The Rust listener's fixed AUTH round-trip deadline. */
425
+ private static readonly listenerAuthDeadlineMs;
426
+ /** Leave headroom so a hook answering just in time still beats the listener. */
427
+ private static readonly authHookMaxTimeoutMs;
428
+ private static readonly authHookDefaultTimeoutMs;
429
+ private loggedAuthTimeoutClamp;
388
430
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions);
431
+ /**
432
+ * Hand back every event-loop resource a partially completed construction has
433
+ * already taken ownership of, so a rejected configuration leaves the process
434
+ * exactly as it found it.
435
+ *
436
+ * Only construction-time owners belong here: the delivery queue and the
437
+ * delivery system arm their timers in `initialize()` and `start()`, which a
438
+ * failed construction never reaches, and the Rust bridge is an unstarted
439
+ * singleton until `start()` spawns it.
440
+ */
441
+ private releaseConstructionOwnedResources;
389
442
  private assertAcceptingOutboundWork;
390
443
  private trackOutboundWork;
391
444
  onRecipientRejected(listener: (event: IRecipientRejectedEvent) => Promise<void> | void): this;
@@ -470,7 +523,11 @@ export declare class UnifiedEmailServer extends EventEmitter {
470
523
  * rather than degrading silently.
471
524
  */
472
525
  private resolveTlsTerminatedPorts;
473
- /** Whether the current options require SMTP AUTH support on the listener. */
526
+ /**
527
+ * Whether the current options require SMTP AUTH support on the listener.
528
+ * A configured `hooks.onAuthenticate` counts on its own: its whole purpose
529
+ * is to own credentials that no static account list contains.
530
+ */
474
531
  private isAuthConfigured;
475
532
  /**
476
533
  * Whether the Rust SMTP listener is currently accepting connections.
@@ -524,6 +581,47 @@ export declare class UnifiedEmailServer extends EventEmitter {
524
581
  private getAddressDomain;
525
582
  /** Resolve the configured account for a username. Accounts win over legacy users. */
526
583
  private findAuthAccount;
584
+ /** Whether the legacy `auth.users` array declares this username. */
585
+ private hasLegacyAuthUser;
586
+ /**
587
+ * Whether configuration claims this username, for the purpose of deciding
588
+ * that `hooks.onAuthenticate` must not see it.
589
+ *
590
+ * Deliberately case-insensitive, while credential verification itself stays
591
+ * exact. Otherwise `Tenant-A` would slip past an operator's configured
592
+ * `tenant-a` — including a `tenant-a` the operator had set `enabled: false`
593
+ * — and reach a hook that may well resolve names case-insensitively.
594
+ * Configuration owning a name means owning every casing of it: a casing that
595
+ * matches no exact credential simply fails, and never reaches the hook.
596
+ */
597
+ private declaresUsername;
598
+ /**
599
+ * Hook timeout, clamped below the Rust listener's own AUTH deadline.
600
+ *
601
+ * The listener waits a fixed 5 s for the TypeScript answer
602
+ * (`rust/crates/mailer-smtp/src/connection.rs`), so a larger value here
603
+ * could never take effect: the listener would already have failed the
604
+ * attempt and reset the session while the hook was still running. Clamping
605
+ * keeps the configured number meaningful instead of silently unreachable.
606
+ */
607
+ private getAuthenticationTimeoutMs;
608
+ /**
609
+ * Enforce the hard cap oldest-first, and sweep expired grants periodically.
610
+ *
611
+ * The cap is what bounds memory, and it is O(1) amortised because Map
612
+ * preserves insertion order. The TTL sweep is hygiene on top — expiry is
613
+ * always enforced on read — so it runs every `hookAuthSweepInterval`
614
+ * insertions rather than walking the whole table on every authentication.
615
+ */
616
+ private pruneHookAuthSessions;
617
+ /** Record the scope a hook granted, replacing any earlier grant for the session. */
618
+ private rememberHookAuthGrant;
619
+ /**
620
+ * Live hook grant for a session, or undefined when none is held. The
621
+ * username must match the grant: a session may not present one username to
622
+ * the listener and inherit another's scope.
623
+ */
624
+ private findHookAuthGrant;
527
625
  private normalizeScopeAddress;
528
626
  /**
529
627
  * Glob match ('a@b.com' | '*@b.com') against a normalized address, using
@@ -535,9 +633,35 @@ export declare class UnifiedEmailServer extends EventEmitter {
535
633
  /**
536
634
  * Envelope-level scope decision for an authenticated session. Returns null
537
635
  * when the envelope is permitted (unauthenticated sessions, usernames with
538
- * no configured account, and accounts without a scope are unrestricted).
636
+ * no configured account and no `hooks.onAuthenticate`, and accounts without
637
+ * a scope are unrestricted).
539
638
  */
540
639
  private resolveAuthAccountScopeDecision;
640
+ /**
641
+ * Envelope-level scope decision for a session authenticated by
642
+ * `hooks.onAuthenticate`. Reached only for usernames no configured account
643
+ * declares, so when a hook is configured the session must hold a live
644
+ * grant; a missing grant is refused temporarily rather than treated as
645
+ * unrestricted. With no hook configured the pre-existing behaviour stands:
646
+ * legacy users and unknown usernames are unscoped.
647
+ */
648
+ private resolveHookAuthScopeDecision;
649
+ /** Temporary refusal for a session whose hook grant is not available. */
650
+ private static authScopeUnavailableDecision;
651
+ /**
652
+ * Shared sender/recipient scope enforcement. An absent scope is
653
+ * unrestricted, matching `ISmtpAuthAccount.scope`.
654
+ */
655
+ private applyScopeToEnvelope;
656
+ /**
657
+ * Sender scope in force for a session.
658
+ *
659
+ * The three outcomes are kept distinct on purpose. Collapsing `refused` into
660
+ * "no scope" would make a session whose hook grant is gone look unrestricted
661
+ * to any caller that only asks "is there a scope?", which is the opposite of
662
+ * what a missing grant means.
663
+ */
664
+ private resolveSessionSenderScope;
541
665
  /**
542
666
  * Extract every mailbox address from the parsed From header. The Email
543
667
  * abstraction keeps only the first From mailbox, which is not sufficient
@@ -622,10 +746,22 @@ export declare class UnifiedEmailServer extends EventEmitter {
622
746
  * always fail.
623
747
  */
624
748
  private handleRustAuthRequest;
749
+ /**
750
+ * Run `hooks.onAuthenticate` under the configured timeout. Any throw, any
751
+ * timeout and any non-object result fail the attempt closed — an embedder
752
+ * that cannot answer must never widen access.
753
+ */
754
+ private runHookAuthentication;
625
755
  /**
626
756
  * Handle a SCRAM credential request from the Rust SMTP server.
627
757
  * Verifier accounts return their stored fields directly; plaintext
628
758
  * credentials (accounts or legacy users) derive fields on the fly.
759
+ *
760
+ * Usernames owned by `hooks.onAuthenticate` fall through to `found: false`:
761
+ * SCRAM requires the server to hold the salt, iteration count, stored key
762
+ * and server key, and a hook that verifies an opaque credential (argon2id,
763
+ * a remote authority) cannot produce them. Those usernames authenticate
764
+ * over PLAIN/LOGIN only, which is why the hook receives the password.
629
765
  */
630
766
  private handleScramCredentialRequest;
631
767
  /**