@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.
- package/changelog.md +26 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +7 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +16 -3
- package/dist_ts/mail/routing/classes.email.router.d.ts +14 -0
- package/dist_ts/mail/routing/classes.email.router.js +35 -7
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +95 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +412 -25
- package/dist_ts/mail/routing/functions.routing.validation.d.ts +14 -0
- package/dist_ts/mail/routing/functions.routing.validation.js +97 -1
- package/dist_ts/mail/routing/index.d.ts +1 -0
- package/dist_ts/mail/routing/index.js +2 -1
- package/dist_ts/mail/routing/interfaces.d.ts +59 -0
- package/package.json +1 -1
- package/readme.md +72 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.system.ts +16 -2
- package/ts/mail/routing/classes.email.router.ts +43 -9
- package/ts/mail/routing/classes.unified.email.server.ts +491 -32
- package/ts/mail/routing/functions.routing.validation.ts +120 -0
- package/ts/mail/routing/index.ts +1 -0
- package/ts/mail/routing/interfaces.ts +58 -0
|
@@ -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,
|
|
114
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZnVuY3Rpb25zLnJvdXRpbmcudmFsaWRhdGlvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvcm91dGluZy9mdW5jdGlvbnMucm91dGluZy52YWxpZGF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxJQUFJLENBQUM7QUFDekMsTUFBTSxDQUFDLE1BQU0sb0JBQW9CLEdBQUcsU0FBUyxDQUFDO0FBRTlDLE1BQU0sZ0JBQWdCLEdBQUcsQ0FBQyxLQUFjLEVBQUUsVUFBbUIsRUFBVyxFQUFFO0lBQ3hFLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ2xFLElBQUksQ0FBQztRQUNILE1BQU0sT0FBTyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsS0FBSyxFQUFFLFFBQVEsQ0FBQyxDQUFDO1FBQzdDLElBQUksT0FBTyxDQUFDLE1BQU0sS0FBSyxDQUFDO1lBQUUsT0FBTyxLQUFLLENBQUM7UUFDdkMsa0VBQWtFO1FBQ2xFLElBQUksT0FBTyxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxFQUFFLENBQUM7WUFDL0UsT0FBTyxLQUFLLENBQUM7UUFDZixDQUFDO1FBQ0QsT0FBTyxVQUFVLEtBQUssU0FBUyxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssVUFBVSxDQUFDO0lBQ25FLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxPQUFPLEtBQUssQ0FBQztJQUNmLENBQUM7QUFDSCxDQUFDLENBQUM7QUFFRjs7OztHQUlHO0FBQ0gsTUFBTSxVQUFVLHVCQUF1QixDQUNyQyxRQUF3QztJQUV4QyxJQUFJLFFB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
|
|
@@ -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
|
-
|
|
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
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`,
|
|
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
|
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|