@push.rocks/smartmta 9.0.0 → 9.1.1

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.
@@ -1,3 +1,17 @@
1
+ import type { ISmtpAuthAccount } from './interfaces.js';
2
+ /**
3
+ * PBKDF2 iteration bounds for SCRAM verifiers. The lower bound is the RFC
4
+ * 5802 recommended minimum; the upper bound caps per-attempt CPU so a single
5
+ * AUTH cannot exhaust the Rust bridge's 5s callback window.
6
+ */
7
+ export declare const SCRAM_MIN_ITERATIONS = 4096;
8
+ export declare const SCRAM_MAX_ITERATIONS = 1000000;
9
+ /**
10
+ * Loudly reject unusable SMTP auth account configurations. Every failure
11
+ * mode here would otherwise degrade into a silent deny-all (empty scope
12
+ * patterns) or a silent authentication failure (malformed verifier keys).
13
+ */
14
+ export declare function assertValidAuthAccounts(accounts: ISmtpAuthAccount[] | undefined): void;
1
15
  /**
2
16
  * Reject retention values that cannot be represented and scheduled reliably.
3
17
  */
@@ -1,3 +1,99 @@
1
+ /**
2
+ * PBKDF2 iteration bounds for SCRAM verifiers. The lower bound is the RFC
3
+ * 5802 recommended minimum; the upper bound caps per-attempt CPU so a single
4
+ * AUTH cannot exhaust the Rust bridge's 5s callback window.
5
+ */
6
+ export const SCRAM_MIN_ITERATIONS = 4096;
7
+ export const SCRAM_MAX_ITERATIONS = 1_000_000;
8
+ const isBase64OfLength = (value, byteLength) => {
9
+ if (typeof value !== 'string' || value.length === 0)
10
+ return false;
11
+ try {
12
+ const decoded = Buffer.from(value, 'base64');
13
+ if (decoded.length === 0)
14
+ return false;
15
+ // Reject strings that silently drop invalid characters on decode.
16
+ if (decoded.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) {
17
+ return false;
18
+ }
19
+ return byteLength === undefined || decoded.length === byteLength;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ };
25
+ /**
26
+ * Loudly reject unusable SMTP auth account configurations. Every failure
27
+ * mode here would otherwise degrade into a silent deny-all (empty scope
28
+ * patterns) or a silent authentication failure (malformed verifier keys).
29
+ */
30
+ export function assertValidAuthAccounts(accounts) {
31
+ if (accounts === undefined)
32
+ return;
33
+ if (!Array.isArray(accounts)) {
34
+ throw new Error('auth.accounts must be an array');
35
+ }
36
+ const usernames = new Set();
37
+ for (const account of accounts) {
38
+ const username = account?.username;
39
+ if (typeof username !== 'string' || !username.trim()) {
40
+ throw new Error('auth.accounts entries require a non-empty username');
41
+ }
42
+ const owner = `auth account ${username}`;
43
+ if (usernames.has(username)) {
44
+ throw new Error(`${owner} is configured more than once`);
45
+ }
46
+ usernames.add(username);
47
+ const credential = account.credential;
48
+ const hasVerifier = credential?.verifier !== undefined;
49
+ const hasPassword = credential?.password !== undefined;
50
+ if (hasVerifier === hasPassword) {
51
+ throw new Error(`${owner} credential must contain exactly one of verifier or password`);
52
+ }
53
+ if (hasPassword && typeof credential.password !== 'string') {
54
+ throw new Error(`${owner} credential.password must be a string`);
55
+ }
56
+ if (hasVerifier) {
57
+ const verifier = credential.verifier;
58
+ if (verifier?.algorithm !== 'SCRAM-SHA-256') {
59
+ throw new Error(`${owner} verifier.algorithm must be SCRAM-SHA-256`);
60
+ }
61
+ if (!Number.isInteger(verifier.iterations)
62
+ || verifier.iterations < SCRAM_MIN_ITERATIONS
63
+ || verifier.iterations > SCRAM_MAX_ITERATIONS) {
64
+ throw new Error(`${owner} verifier.iterations must be an integer between ${SCRAM_MIN_ITERATIONS} and ${SCRAM_MAX_ITERATIONS}`);
65
+ }
66
+ if (!isBase64OfLength(verifier.salt)) {
67
+ throw new Error(`${owner} verifier.salt must be non-empty base64`);
68
+ }
69
+ if (!isBase64OfLength(verifier.storedKey, 32)) {
70
+ throw new Error(`${owner} verifier.storedKey must be base64 of a 32-byte key`);
71
+ }
72
+ if (!isBase64OfLength(verifier.serverKey, 32)) {
73
+ throw new Error(`${owner} verifier.serverKey must be base64 of a 32-byte key`);
74
+ }
75
+ }
76
+ const scope = account.scope;
77
+ if (scope === undefined)
78
+ continue;
79
+ if (!Array.isArray(scope.senders)
80
+ || scope.senders.length === 0
81
+ || scope.senders.some((pattern) => typeof pattern !== 'string' || !pattern.trim())) {
82
+ throw new Error(`${owner} scope.senders must be a non-empty array of address patterns`);
83
+ }
84
+ if (scope.recipients !== undefined) {
85
+ if (scope.recipients.mode !== 'any' && scope.recipients.mode !== 'patterns') {
86
+ throw new Error(`${owner} scope.recipients.mode must be any or patterns`);
87
+ }
88
+ if (scope.recipients.mode === 'patterns'
89
+ && (!Array.isArray(scope.recipients.patterns)
90
+ || scope.recipients.patterns.length === 0
91
+ || scope.recipients.patterns.some((pattern) => typeof pattern !== 'string' || !pattern.trim()))) {
92
+ throw new Error(`${owner} scope.recipients.patterns must be a non-empty array when mode is patterns`);
93
+ }
94
+ }
95
+ }
96
+ }
1
97
  /**
2
98
  * Reject retention values that cannot be represented and scheduled reliably.
3
99
  */
@@ -15,4 +111,4 @@ export function assertValidInboundPolicy(inboundPolicy, domain) {
15
111
  }
16
112
  assertValidRetentionDays(inboundPolicy.retentionDays, `Inbound policy for domain ${domain}`);
17
113
  }
18
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZnVuY3Rpb25zLnJvdXRpbmcudmFsaWRhdGlvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvcm91dGluZy9mdW5jdGlvbnMucm91dGluZy52YWxpZGF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxVQUFVLHdCQUF3QixDQUN0QyxhQUFzQixFQUN0QixLQUFhO0lBRWIsSUFDRSxPQUFPLGFBQWEsS0FBSyxRQUFRO1dBQzlCLENBQUMsTUFBTSxDQUFDLGFBQWEsQ0FBQyxhQUFhLENBQUM7V0FDcEMsYUFBYSxJQUFJLENBQUMsRUFDckIsQ0FBQztRQUNELE1BQU0sSUFBSSxLQUFLLENBQUMsR0FBRyxLQUFLLGtEQUFrRCxDQUFDLENBQUM7SUFDOUUsQ0FBQztBQUNILENBQUM7QUFFRCxNQUFNLFVBQVUsd0JBQXdCLENBQ3RDLGFBR0MsRUFDRCxNQUFjO0lBRWQsSUFDRSxhQUFhLENBQUMsYUFBYSxLQUFLLE9BQU87V0FDcEMsYUFBYSxDQUFDLGFBQWEsS0FBSyxRQUFRLEVBQzNDLENBQUM7UUFDRCxNQUFNLElBQUksS0FBSyxDQUNiLDZCQUE2QixNQUFNLHdDQUF3QyxDQUM1RSxDQUFDO0lBQ0osQ0FBQztJQUNELHdCQUF3QixDQUN0QixhQUFhLENBQUMsYUFBYSxFQUMzQiw2QkFBNkIsTUFBTSxFQUFFLENBQ3RDLENBQUM7QUFDSixDQUFDIn0=
114
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZnVuY3Rpb25zLnJvdXRpbmcudmFsaWRhdGlvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvcm91dGluZy9mdW5jdGlvbnMucm91dGluZy52YWxpZGF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxJQUFJLENBQUM7QUFDekMsTUFBTSxDQUFDLE1BQU0sb0JBQW9CLEdBQUcsU0FBUyxDQUFDO0FBRTlDLE1BQU0sZ0JBQWdCLEdBQUcsQ0FBQyxLQUFjLEVBQUUsVUFBbUIsRUFBVyxFQUFFO0lBQ3hFLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ2xFLElBQUksQ0FBQztRQUNILE1BQU0sT0FBTyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsS0FBSyxFQUFFLFFBQVEsQ0FBQyxDQUFDO1FBQzdDLElBQUksT0FBTyxDQUFDLE1BQU0sS0FBSyxDQUFDO1lBQUUsT0FBTyxLQUFLLENBQUM7UUFDdkMsa0VBQWtFO1FBQ2xFLElBQUksT0FBTyxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxFQUFFLENBQUM7WUFDL0UsT0FBTyxLQUFLLENBQUM7UUFDZixDQUFDO1FBQ0QsT0FBTyxVQUFVLEtBQUssU0FBUyxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssVUFBVSxDQUFDO0lBQ25FLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxPQUFPLEtBQUssQ0FBQztJQUNmLENBQUM7QUFDSCxDQUFDLENBQUM7QUFFRjs7OztHQUlHO0FBQ0gsTUFBTSxVQUFVLHVCQUF1QixDQUNyQyxRQUF3QztJQUV4QyxJQUFJLFFB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
@@ -5,3 +5,4 @@ export * from './interfaces.js';
5
5
  export * from './classes.domain.registry.js';
6
6
  export * from './classes.email.action.executor.js';
7
7
  export * from './classes.dkim.manager.js';
8
+ export * from './functions.routing.validation.js';
@@ -6,4 +6,5 @@ export * from './interfaces.js';
6
6
  export * from './classes.domain.registry.js';
7
7
  export * from './classes.email.action.executor.js';
8
8
  export * from './classes.dkim.manager.js';
9
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL3JvdXRpbmcvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsMkJBQTJCO0FBQzNCLGNBQWMsMkJBQTJCLENBQUM7QUFDMUMsY0FBYyxtQ0FBbUMsQ0FBQztBQUNsRCxjQUFjLDBCQUEwQixDQUFDO0FBQ3pDLGNBQWMsaUJBQWlCLENBQUM7QUFDaEMsY0FBYyw4QkFBOEIsQ0FBQztBQUM3QyxjQUFjLG9DQUFvQyxDQUFDO0FBQ25ELGNBQWMsMkJBQTJCLENBQUMifQ==
9
+ export * from './functions.routing.validation.js';
10
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL3JvdXRpbmcvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsMkJBQTJCO0FBQzNCLGNBQWMsMkJBQTJCLENBQUM7QUFDMUMsY0FBYyxtQ0FBbUMsQ0FBQztBQUNsRCxjQUFjLDBCQUEwQixDQUFDO0FBQ3pDLGNBQWMsaUJBQWlCLENBQUM7QUFDaEMsY0FBYyw4QkFBOEIsQ0FBQztBQUM3QyxjQUFjLG9DQUFvQyxDQUFDO0FBQ25ELGNBQWMsMkJBQTJCLENBQUM7QUFDMUMsY0FBYyxtQ0FBbUMsQ0FBQyJ9
@@ -25,6 +25,11 @@ export interface IEmailMatch {
25
25
  clientIp?: string | string[];
26
26
  /** Require authentication status */
27
27
  authenticated?: boolean;
28
+ /**
29
+ * Require the session to be authenticated as this exact username (or one
30
+ * of these usernames). Unauthenticated sessions never match such routes.
31
+ */
32
+ authenticatedUser?: string | string[];
28
33
  /** Headers to match */
29
34
  headers?: Record<string, string | RegExp>;
30
35
  /** Message size range */
@@ -136,6 +141,60 @@ export interface IEmailAction {
136
141
  retries?: number;
137
142
  };
138
143
  }
144
+ /**
145
+ * Precomputed SCRAM-SHA-256 verifier for an SMTP auth account. Only derived
146
+ * key material is stored — the plaintext password never has to be persisted.
147
+ *
148
+ * Derivation (RFC 5802): SaltedPassword = PBKDF2-HMAC-SHA256(password, salt,
149
+ * iterations, 32); StoredKey = SHA256(HMAC-SHA256(SaltedPassword, 'Client Key'));
150
+ * ServerKey = HMAC-SHA256(SaltedPassword, 'Server Key').
151
+ */
152
+ export interface ISmtpScramVerifier {
153
+ algorithm: 'SCRAM-SHA-256';
154
+ /** Base64-encoded PBKDF2 salt. */
155
+ salt: string;
156
+ /** PBKDF2 iteration count (4096 to 1000000, validated at configuration time). */
157
+ iterations: number;
158
+ /** Base64-encoded StoredKey. */
159
+ storedKey: string;
160
+ /** Base64-encoded ServerKey. */
161
+ serverKey: string;
162
+ }
163
+ /**
164
+ * Send-authorization scope for an authenticated SMTP account.
165
+ */
166
+ export interface ISmtpAuthAccountScope {
167
+ /**
168
+ * Address patterns ('a@b.com' | '*@b.com') the account may send AS.
169
+ * Enforced against both the SMTP MAIL FROM envelope sender and the parsed
170
+ * message header From address.
171
+ */
172
+ senders: string[];
173
+ /** Recipient scope ("send TO who"). Absent or mode 'any' = unrestricted. */
174
+ recipients?: {
175
+ mode: 'any' | 'patterns';
176
+ /** Address patterns enforced for every RCPT TO when mode is 'patterns'. */
177
+ patterns?: string[];
178
+ };
179
+ }
180
+ /**
181
+ * Authenticated SMTP account with hashed credentials and send scopes.
182
+ */
183
+ export interface ISmtpAuthAccount {
184
+ username: string;
185
+ /** Disabled accounts always fail authentication (default: enabled). */
186
+ enabled?: boolean;
187
+ /**
188
+ * Hashed SCRAM-SHA-256 verifier (preferred) or legacy plaintext password.
189
+ */
190
+ credential: {
191
+ verifier: ISmtpScramVerifier;
192
+ } | {
193
+ password: string;
194
+ };
195
+ /** Absent scope = unrestricted (backwards compatible). */
196
+ scope?: ISmtpAuthAccountScope;
197
+ }
139
198
  /**
140
199
  * Context for route evaluation
141
200
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "9.0.0",
3
+ "version": "9.1.1",
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",
package/readme.md CHANGED
@@ -213,11 +213,21 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
213
213
  },
214
214
  },
215
215
 
216
- // Authentication settings for the SMTP server
216
+ // Authentication settings for the SMTP server.
217
+ // Legacy plaintext `users` and hashed `accounts` coexist; accounts win on
218
+ // username collision. See "Authenticated Accounts And Send Scopes" below.
217
219
  auth: {
218
220
  required: false,
219
221
  methods: ['PLAIN', 'LOGIN'],
220
222
  users: [{ username: 'outbound', password: 'secret' }],
223
+ accounts: [{
224
+ username: 'newsletter-service',
225
+ credential: { verifier: storedScramVerifier },
226
+ scope: {
227
+ senders: ['newsletter@example.com'],
228
+ recipients: { mode: 'any' },
229
+ },
230
+ }],
221
231
  },
222
232
 
223
233
  // TLS material is provided in memory; SmartMTA does not read certificate files.
@@ -290,6 +300,63 @@ Use `smtp.securePort` when the implicit TLS listener uses a remapped backend por
290
300
 
291
301
  Trusted PROXY protocol v1 support is configured with `smtp.proxyProtocol`. Only enable `required: true` on backend listeners that are reached through a trusted local or private proxy, and always provide `trustedIps`. Required PROXY mode fails closed when no trusted peers are configured.
292
302
 
303
+ ### 🔐 Authenticated Accounts And Send Scopes
304
+
305
+ `auth.accounts` defines authenticated SMTP submission accounts with hashed credentials and per-account send authorization. It coexists with the legacy plaintext `auth.users` array; when both define the same username, the account wins.
306
+
307
+ ```typescript
308
+ import type { ISmtpAuthAccount } from '@push.rocks/smartmta';
309
+
310
+ const accounts: ISmtpAuthAccount[] = [
311
+ {
312
+ username: 'newsletter-service',
313
+ // Hashed SCRAM-SHA-256 verifier — the plaintext password is never stored.
314
+ // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
315
+ // StoredKey = SHA256(HMAC-SHA256(SaltedPassword, 'Client Key'))
316
+ // ServerKey = HMAC-SHA256(SaltedPassword, 'Server Key')
317
+ credential: {
318
+ verifier: {
319
+ algorithm: 'SCRAM-SHA-256',
320
+ salt: '<base64 salt>',
321
+ iterations: 4096, // minimum
322
+ storedKey: '<base64 StoredKey>',
323
+ serverKey: '<base64 ServerKey>',
324
+ },
325
+ },
326
+ scope: {
327
+ // "send AS who": enforced against the SMTP MAIL FROM envelope sender
328
+ // AND the parsed message header From address.
329
+ senders: ['newsletter@example.com', '*@mail.example.com'],
330
+ // "send TO who": optional; absent or mode 'any' = unrestricted.
331
+ recipients: { mode: 'patterns', patterns: ['*@example.com'] },
332
+ },
333
+ },
334
+ {
335
+ username: 'trusted-app',
336
+ // Plaintext credential is supported for migration, but verifiers are preferred.
337
+ credential: { password: 'app-password' },
338
+ // No scope: unrestricted, exactly like a legacy auth.users entry.
339
+ },
340
+ ];
341
+
342
+ server.updateOptions({ auth: { accounts } });
343
+ ```
344
+
345
+ Enforcement semantics:
346
+
347
+ - **Credentials** — `PLAIN`/`LOGIN` passwords are verified against the stored verifier by deriving `StoredKey` from the presented password (PBKDF2-HMAC-SHA256 on the async crypto threadpool) and comparing in constant time. `SCRAM-SHA-256` clients are served the stored verifier fields directly, so the plaintext never needs to exist server-side. Accounts with `enabled: false` always fail authentication on both mechanisms. Verification is fully in-memory — no I/O inside the Rust auth callback window.
348
+ - **Sender scope ("send AS who")** — enforced twice. At the first `RCPT TO`, an out-of-scope `MAIL FROM` is refused with `553` before any recipient is accepted (with `smtp.recipientValidation: false` this check still runs, but only at `DATA`). At `DATA`, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and **every** parsed header `From` mailbox must independently be in scope — header-From spoofing behind an in-scope envelope, including multi-mailbox `From:` headers, is refused with `553`.
349
+ - **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.
350
+ - **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.
351
+ - **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.
352
+ - **Rejections** surface through `onRecipientRejected` with the reasons `sender-scope`, `recipient-scope`, and `account-disabled`.
353
+ - **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.
354
+ - An account without a `scope` is authenticated but unrestricted — identical to legacy `auth.users` behavior, which itself is unchanged.
355
+
356
+ Configuration is validated loudly at construction and `updateOptions` time: unusable accounts (empty `scope.senders`, `mode: 'patterns'` without patterns, both or neither credential kind, malformed base64 keys, `iterations` outside `[4096, 1000000]`, duplicate usernames) throw instead of degrading into silent deny-all or silent auth failure. Note that `updateOptions({ auth })` replaces the whole `auth` block — include `users`, `required`, and `methods` again if they should persist.
357
+
358
+ Compatibility probe: `smartMtaCapabilities.smtpAuthAccountsAndScopes`.
359
+
293
360
  ### 📧 Sending Emails (Automatic MX Discovery)
294
361
 
295
362
  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:
@@ -440,8 +507,9 @@ When `UnifiedEmailServer.start()` is called:
440
507
  - With `dkimKeyProvisioning: 'caller-managed'`, SmartMTA only loads the requested selector from managed storage; it never generates or rotates keys.
441
508
  - A requested missing or invalid key fails closed before SMTP delivery. SmartMTA never falls back to unsigned delivery.
442
509
  - Signing is applied exactly once, by the Rust SMTP client at final outbound transmission.
510
+ - When a route enables signing without explicit `dkimOptions`, the selector is resolved from the sending domain's registry configuration (`domains[].dkim.selector`); the hardcoded `'default'` selector is only the last fallback.
443
511
 
444
- Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerManagedDkimKeys`, `structuredSmtpTransactions`, `typedDeliveryFailures`, and `terminalLocalDeliverActions` (deliver routes are terminal local deliveries through the durable acceptance path) as compatibility probes.
512
+ Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerManagedDkimKeys`, `structuredSmtpTransactions`, `typedDeliveryFailures`, `terminalLocalDeliverActions` (deliver routes are terminal local deliveries through the durable acceptance path), and `smtpAuthAccountsAndScopes` (authenticated accounts with hashed credentials and send scopes) as compatibility probes.
445
513
 
446
514
  ### 🛡️ Email Authentication (SPF, DKIM, DMARC)
447
515
 
@@ -547,6 +615,7 @@ Every action can include `allowRelay?: boolean`. It defaults to `false`. Non-loc
547
615
  | `senders` | Glob patterns for sender addresses |
548
616
  | `clientIp` | IP addresses or CIDR ranges |
549
617
  | `authenticated` | Require authentication status |
618
+ | `authenticatedUser` | Require the session to be authenticated as this exact username (string or array of usernames) |
550
619
  | `headers` | Match specific headers (string or RegExp) |
551
620
  | `sizeRange` | Message size constraints (`{ min?, max? }`) |
552
621
  | `subject` | Subject line pattern (string or RegExp) |
@@ -697,7 +766,7 @@ The Rust `mailer-bin` process is supervised by `RustSecurityBridge`:
697
766
  - **Health checks** — a `ping` every 30 s; two consecutive failures kill and restart the process.
698
767
  - **Auto-restart** — any unexpected exit triggers restart with exponential backoff (1 s → 30 s). After `maxRestartAttempts` fast attempts the bridge enters the `failed` state but keeps retrying at the slow cadence forever — it never silently stays dead.
699
768
  - **SMTP server restore** — after a restart the SMTP listener is restored; if the restore fails, the process is killed and the restart cycle continues rather than reporting a half-alive bridge.
700
- - **State surface** — `UnifiedEmailServer.getBridgeState()` returns `'idle' | 'starting' | 'running' | 'restarting' | 'failed' | 'stopped'`; the server emits `bridgeFailed`, `bridgeRestarting`, and `bridgeRecovered` events for embedders' alerting.
769
+ - **State surface** — `UnifiedEmailServer.getBridgeState()` returns `'idle' | 'starting' | 'running' | 'restarting' | 'failed' | 'stopped'`; the server emits `bridgeFailed`, `bridgeRestarting`, and `bridgeRecovered` events for embedders' alerting. `isSmtpListenerActive()` reports whether the Rust SMTP listener is accepting connections, and a failed listener reconfiguration emits `smtpListenerRestartFailed`.
701
770
 
702
771
  Tune with `RustSecurityBridge.configure({ maxRestartAttempts, healthCheckIntervalMs, healthCheckTimeoutMs, restartBackoffBaseMs, restartBackoffMaxMs })`.
703
772
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '9.0.0',
6
+ version: '9.1.1',
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
@@ -15,4 +15,5 @@ export const smartMtaCapabilities = Object.freeze({
15
15
  durableDeliveryFailureOutbox: true,
16
16
  exactAcceptedEnvelopeDispatch: true,
17
17
  terminalLocalDeliverActions: true,
18
+ smtpAuthAccountsAndScopes: true,
18
19
  } as const);
@@ -778,7 +778,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
778
778
  const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
779
779
  ? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.getFromDomain() || undefined)
780
780
  : undefined;
781
- const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
781
+ const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
782
782
 
783
783
  // Build auth options from route forward config
784
784
  const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
@@ -850,6 +850,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
850
850
  }
851
851
  }
852
852
 
853
+ /**
854
+ * Resolve the DKIM selector for signed delivery. Without explicit route
855
+ * dkimOptions the selector comes from the domain registry's per-domain
856
+ * configuration (which key rotation keeps current), not a hardcoded
857
+ * 'default'; 'default' remains only the last fallback.
858
+ */
859
+ private resolveDkimSelector(item: IQueueItem, dkimDomain: string | undefined): string {
860
+ return item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector
861
+ || (dkimDomain
862
+ ? this.emailServer?.getDomainRegistry?.()?.getDomainConfig(dkimDomain)?.dkim?.selector
863
+ : undefined)
864
+ || 'default';
865
+ }
866
+
853
867
  /**
854
868
  * Resolve MX records for a domain, sorted by priority (lowest first).
855
869
  * Falls back to the domain itself as an A record per RFC 5321.
@@ -956,7 +970,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
956
970
  const dkimDomain = effectiveDkimSigning
957
971
  ? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
958
972
  : undefined;
959
- const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
973
+ const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
960
974
 
961
975
  const allRecipients = email.getAllRecipients();
962
976
  if (allRecipients.length === 0) {
@@ -6,6 +6,21 @@ import type { Email } from '../core/classes.email.js';
6
6
  import { getErrorMessage } from '../../functions.errors.js';
7
7
  import { assertValidRetentionDays } from './functions.routing.validation.js';
8
8
 
9
+ /**
10
+ * Convert an address glob pattern ('a@b.com' | '*@b.com' | 'admin@*') into a
11
+ * case-insensitive full-match RegExp. Shared by route matching and auth
12
+ * account scope enforcement so both use identical pattern semantics.
13
+ */
14
+ export function addressGlobToRegExp(pattern: string): RegExp {
15
+ // Escape special regex characters except * and ?
16
+ const regexString = pattern
17
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
18
+ .replace(/\*/g, '.*')
19
+ .replace(/\?/g, '.');
20
+
21
+ return new RegExp(`^${regexString}$`, 'i');
22
+ }
23
+
9
24
  /**
10
25
  * Email router that evaluates routes and determines actions
11
26
  */
@@ -185,11 +200,17 @@ export class EmailRouter extends EventEmitter {
185
200
  }
186
201
 
187
202
  // Check authentication
188
- if (match.authenticated !== undefined &&
203
+ if (match.authenticated !== undefined &&
189
204
  context.session.authenticated !== match.authenticated) {
190
205
  return false;
191
206
  }
192
-
207
+
208
+ // Check authenticated username binding
209
+ if (match.authenticatedUser !== undefined &&
210
+ !this.matchesAuthenticatedUser(context.session, match.authenticatedUser)) {
211
+ return false;
212
+ }
213
+
193
214
  // Check headers
194
215
  if (match.headers && !this.matchesHeaders(context.email, match.headers)) {
195
216
  return false;
@@ -253,6 +274,25 @@ export class EmailRouter extends EventEmitter {
253
274
  return false;
254
275
  }
255
276
 
277
+ /**
278
+ * Check if the session is authenticated as one of the required usernames.
279
+ * Unauthenticated sessions never match.
280
+ * @param session SMTP session
281
+ * @param usernames Required username or usernames
282
+ * @returns True if the session's authenticated username is in the set
283
+ */
284
+ private matchesAuthenticatedUser(
285
+ session: IEmailContext['session'],
286
+ usernames: string | string[],
287
+ ): boolean {
288
+ const username = session.user?.username;
289
+ if (!username) {
290
+ return false;
291
+ }
292
+ const allowed = Array.isArray(usernames) ? usernames : [usernames];
293
+ return allowed.includes(username);
294
+ }
295
+
256
296
  /**
257
297
  * Check if client IP matches patterns
258
298
  * @param context Email context
@@ -374,13 +414,7 @@ export class EmailRouter extends EventEmitter {
374
414
  * @returns Regular expression
375
415
  */
376
416
  private globToRegExp(pattern: string): RegExp {
377
- // Escape special regex characters except * and ?
378
- let regexString = pattern
379
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
380
- .replace(/\*/g, '.*')
381
- .replace(/\?/g, '.');
382
-
383
- return new RegExp(`^${regexString}$`, 'i');
417
+ return addressGlobToRegExp(pattern);
384
418
  }
385
419
 
386
420
  /**