@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.
@@ -195,6 +195,47 @@ export interface ISmtpAuthAccount {
195
195
  /** Absent scope = unrestricted (backwards compatible). */
196
196
  scope?: ISmtpAuthAccountScope;
197
197
  }
198
+ /**
199
+ * Context handed to `hooks.onAuthenticate` for a PLAIN/LOGIN attempt whose
200
+ * username is not declared in `auth.accounts` or `auth.users`.
201
+ */
202
+ export interface ISmtpAuthenticateContext {
203
+ /** Username exactly as supplied by the client. */
204
+ username: string;
205
+ /** Password exactly as supplied by the client. */
206
+ password: string;
207
+ /** SMTP session the attempt belongs to. */
208
+ sessionId: string;
209
+ /** Remote peer address of the SMTP connection. */
210
+ remoteAddress: string;
211
+ /** Aborted when the hook exceeds `smtp.authenticationTimeoutMs`. */
212
+ abortSignal?: AbortSignal;
213
+ }
214
+ /**
215
+ * Result of an `hooks.onAuthenticate` credential verification.
216
+ */
217
+ export interface ISmtpAuthenticateDecision {
218
+ /** Whether the supplied credential is valid. */
219
+ accepted: boolean;
220
+ /**
221
+ * Reason for a rejection, recorded in the server log.
222
+ *
223
+ * It is deliberately NOT sent to the client: the SMTP listener answers every
224
+ * failed authentication with the same `535 5.7.8 Authentication credentials
225
+ * invalid`, so a caller cannot probe which usernames exist. Use this to make
226
+ * an operator's log say why a binding was refused.
227
+ */
228
+ message?: string;
229
+ /**
230
+ * Send-authorization scope for the resulting session, enforced identically
231
+ * to a configured account's scope for the lifetime of that session.
232
+ *
233
+ * Absent scope = unrestricted, matching `ISmtpAuthAccount.scope`. A hook
234
+ * that authenticates tenants MUST return a scope; without one the session
235
+ * may send as any sender to any recipient.
236
+ */
237
+ scope?: ISmtpAuthAccountScope;
238
+ }
198
239
  /**
199
240
  * Context for route evaluation
200
241
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
5
5
  "keywords": [
6
6
  "mta",
@@ -31,12 +31,12 @@
31
31
  ".": "./dist_ts/index.js"
32
32
  },
33
33
  "devDependencies": {
34
- "@git.zone/cli": "^3.0.1",
35
- "@git.zone/tsbuild": "4.4.2",
36
- "@git.zone/tsrust": "^1.7.0",
37
- "@git.zone/tstest": "^3.6.7",
34
+ "@git.zone/cli": "^6.13.2",
35
+ "@git.zone/tsbuild": "4.4.3",
36
+ "@git.zone/tsrust": "^1.13.0",
37
+ "@git.zone/tstest": "^6.0.0",
38
38
  "@types/mailparser": "^3.4.6",
39
- "@types/node": "^26.1.1",
39
+ "@types/node": "26.5.0",
40
40
  "tsx": "^4.23.1"
41
41
  },
42
42
  "dependencies": {
package/readme.md CHANGED
@@ -171,6 +171,11 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
171
171
  securePort: 465,
172
172
  recipientValidationTimeoutMs: 5000,
173
173
 
174
+ // Timeout for hooks.onAuthenticate before failing the attempt closed.
175
+ // Defaults to 4000. The SMTP listener abandons the AUTH round trip after a
176
+ // fixed 5000 ms, so larger values are clamped to 4500 with a warning.
177
+ authenticationTimeoutMs: 4000,
178
+
174
179
  // Timeout for hooks.onMessageData and hooks.onAcceptEnvelope before
175
180
  // returning a temporary SMTP failure.
176
181
  messageAcceptanceTimeoutMs: 30000,
@@ -183,6 +188,20 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
183
188
  },
184
189
 
185
190
  hooks: {
191
+ async onAuthenticate(context) {
192
+ // Only reached for usernames auth.accounts and auth.users do not declare.
193
+ // Verify the credential however the application stores it, and return the
194
+ // send scope the resulting session must be held to.
195
+ const binding = await credentialStore.verify(context.username, context.password);
196
+ if (!binding) {
197
+ // message is logged server-side; the client always sees a plain 535.
198
+ return { accepted: false, message: 'Unknown binding' };
199
+ }
200
+ return {
201
+ accepted: true,
202
+ scope: { senders: [`*@${binding.domain}`] },
203
+ };
204
+ },
186
205
  async onRcptTo(context) {
187
206
  if (context.abortSignal?.aborted) {
188
207
  return { accepted: false, smtpCode: 451, smtpMessage: 'Recipient policy timeout' };
@@ -330,6 +349,8 @@ A submission route grants an authenticated client permission to relay. It does n
330
349
 
331
350
  Without this, an authenticated submission addressed to a domain this server hosts is accepted with `250`, relayed to that domain's MX, and destroyed by the self-MX loop guard. Everything else keeps today's behaviour: recipient-scoped `process` routes still win (consumers use them for local addresses handled by their own acceptance pipeline), `store`/`deliver`/`forward`/`reject` actions always win, and a domain registered for outbound identity only — no `inboundPolicy`, or the send-only `defaultAction: 'reject'` — keeps relaying because its real MX lives elsewhere. Encoding recipient intent in `match.headers` is not recognised for this purpose.
332
351
 
352
+ `hooks.onAuthenticate` lets the embedding application own SMTP credentials that no static configuration can express — a per-tenant binding store, a remote authority, an opaque hash such as argon2id. See [Dynamic credentials](#dynamic-credentials-hooksonauthenticate).
353
+
333
354
  `hooks.onRcptTo`, `hooks.onMessageData`, and `hooks.onAcceptEnvelope` are awaited asynchronously. If a hook throws or times out, SmartMTA returns a temporary SMTP failure. The hook context includes `abortSignal`; long-running consumers should observe it and stop work promptly. A custom `onRcptTo` hook may accept local recipients using application-specific lookup, but external relay is still blocked unless a matched route explicitly sets `allowRelay: true`.
334
355
 
335
356
  `hooks.onMessageData` runs after `DATA` and before the final SMTP response — but only for envelopes with **no** resolved `store` or `deliver` action; terminal local deliveries go through `hooks.onAcceptEnvelope` instead and never invoke `onMessageData`. Return `{ accepted: true }` only after your application has accepted responsibility for the message, such as after durable persistence. Set `continueProcessing: false` when the consumer owns all downstream processing.
@@ -413,7 +434,7 @@ Enforcement semantics:
413
434
  - **Recipient scope ("send TO who")** — with `recipients.mode: 'patterns'`, every `RCPT TO` matching no pattern is refused with `550`. `mode: 'any'` or an absent `recipients` block leaves recipients unrestricted.
414
435
  - **Patterns** are exact addresses (`a@b.com`) or glob patterns (`*@b.com`), matched case-insensitively with identical semantics to route matching. A scoped account sending with an empty `MAIL FROM <>` (null sender) is refused unless a pattern explicitly matches — `senders: ['*']` is the documented escape hatch.
415
436
  - **Route binding** — a route with `match.authenticatedUser: 'newsletter-service'` (or an array of usernames) matches only sessions authenticated as that account (exact, case-sensitive username comparison), so dedicated processing or relay routes can be attached per account. Unauthenticated sessions never match such routes.
416
- - **Rejections** surface through `onRecipientRejected` with the reasons `sender-scope`, `recipient-scope`, and `account-disabled`.
437
+ - **Rejections** surface through `onRecipientRejected` with the reasons `sender-scope`, `recipient-scope`, `account-disabled`, and `auth-scope-expired`.
417
438
  - **Live enablement** — when a configuration update transitions between zero and one-or-more accounts/users, `updateOptions` restarts only the Rust SMTP listener so the `AUTH` capability appears or disappears without a process restart. A failed listener restart logs loudly, emits `smtpListenerRestartFailed`, and leaves `isSmtpListenerActive()` false — surface that next to `getBridgeState()` in health endpoints.
418
439
  - An account without a `scope` is authenticated but unrestricted — identical to legacy `auth.users` behavior, which itself is unchanged.
419
440
 
@@ -423,6 +444,28 @@ Deriving verifiers: `deriveScramVerifier(password, iterations?)` is the canonica
423
444
 
424
445
  Compatibility probe: `smartMtaCapabilities.smtpAuthAccountsAndScopes`.
425
446
 
447
+ ### Dynamic credentials (`hooks.onAuthenticate`)
448
+
449
+ `auth.accounts` is a static list. An embedding application that mints SMTP credentials at runtime — one per tenant, per service, per binding — cannot express them that way, and cannot store them as SCRAM verifiers if its credential store is an opaque hash such as argon2id. `hooks.onAuthenticate` hands those attempts to the application instead:
450
+
451
+ ```ts
452
+ hooks: {
453
+ async onAuthenticate({ username, password, sessionId, remoteAddress, abortSignal }) {
454
+ const binding = await credentialStore.verify(username, password, { signal: abortSignal });
455
+ if (!binding) return { accepted: false, message: 'Unknown binding' };
456
+ return { accepted: true, scope: { senders: [`*@${binding.domain}`] } };
457
+ },
458
+ }
459
+ ```
460
+
461
+ - **Configuration always wins.** The hook is consulted only for usernames that neither `auth.accounts` nor `auth.users` declares. A hook can never override, weaken or second-guess a credential an operator configured — a configured username that fails its password fails, full stop. Ownership is matched **case-insensitively** while credential verification itself stays exact, so `Tenant-A` cannot slip past a configured (or deliberately `enabled: false`) `tenant-a` by changing case: it matches no exact credential, and it never reaches the hook either.
462
+ - **A configured hook enables `AUTH` on its own.** `isAuthConfigured` counts it alongside `auth.required`, `auth.users` and `auth.accounts`, so a server whose credentials are entirely dynamic still advertises `AUTH` with an empty account list. The same listener-restart behaviour applies when the hook is added or removed through `updateOptions`.
463
+ - **The returned `scope` is enforced identically to an account scope** — both the `RCPT TO` envelope check and the `DATA` header-`From` alignment check — for the lifetime of the session that authenticated. An absent `scope` is unrestricted, matching `ISmtpAuthAccount.scope`; **a hook that authenticates tenants must return one.**
464
+ - **Grants are session-bound.** They are keyed by the SMTP session that authenticated, so one session cannot inherit another's scope, and they are bounded by a hard cap of 4096 (oldest-first eviction) and a one-hour TTL. Because the listener reports no session-close event, a session may outlive its grant: that case is refused with a temporary `451` and `onRecipientRejected` reason `auth-scope-expired`, never silently treated as unrestricted. Both enforcement points refuse independently, so neither relies on the other having run first. Note the capacity edge: above 4096 concurrent hook-authenticated sessions, eviction reaches live grants, and an affected client must reconnect and authenticate again rather than being able to recover in place.
465
+ - **Failure is closed.** A throw, a `smtp.authenticationTimeoutMs` timeout, or a non-object return rejects the attempt. The client always receives the listener's standard `535 5.7.8 Authentication credentials invalid`; a `message` on the decision is recorded in the server log, never sent to the client, so a caller cannot use it to probe which usernames exist.
466
+ - **`PLAIN`/`LOGIN` only.** SCRAM requires the server to hold the salt, iteration count, stored key and server key; a hook that verifies an opaque credential cannot produce them. SCRAM credential lookups for hook-owned usernames report not-found, which is why the hook receives the password directly. Offer SCRAM only for usernames configured as `auth.accounts` verifiers.
467
+ - The hook sits on the SMTP auth path: keep it fast, and keep expensive derivation bounded. `abortSignal` fires on timeout — observe it.
468
+
426
469
  ### 📧 Sending Emails (Automatic MX Discovery)
427
470
 
428
471
  The recommended way to send email is `sendEmail()`. It defaults to **MTA mode**, which automatically resolves MX records for each recipient domain via DNS — you don't need to know the destination mail server:
@@ -3,6 +3,6 @@
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
  }
package/ts/index.ts CHANGED
@@ -17,4 +17,5 @@ export const smartMtaCapabilities = Object.freeze({
17
17
  terminalLocalDeliverActions: true,
18
18
  smtpAuthAccountsAndScopes: true,
19
19
  durableRawOutboundQueue: true,
20
+ dynamicSmtpAuthenticationHook: true,
20
21
  } as const);