@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.
- package/changelog.md +43 -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 +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +7 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +33 -4
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +4 -2
- package/dist_ts/mail/routing/classes.email.action.executor.js +6 -16
- package/dist_ts/mail/routing/classes.email.router.d.ts +28 -0
- package/dist_ts/mail/routing/classes.email.router.js +47 -7
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +112 -8
- package/dist_ts/mail/routing/classes.unified.email.server.js +491 -32
- 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/interfaces.d.ts +74 -3
- package/package.json +3 -3
- package/readme.md +109 -12
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/mail/delivery/classes.delivery.system.ts +36 -3
- package/ts/mail/routing/classes.email.action.executor.ts +7 -28
- package/ts/mail/routing/classes.email.router.ts +64 -10
- package/ts/mail/routing/classes.unified.email.server.ts +587 -44
- package/ts/mail/routing/functions.routing.validation.ts +120 -0
- package/ts/mail/routing/interfaces.ts +73 -3
|
@@ -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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZnVuY3Rpb25zLnJvdXRpbmcudmFsaWRhdGlvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvcm91dGluZy9mdW5jdGlvbnMucm91dGluZy52YWxpZGF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxJQUFJLENBQUM7QUFDekMsTUFBTSxDQUFDLE1BQU0sb0JBQW9CLEdBQUcsU0FBUyxDQUFDO0FBRTlDLE1BQU0sZ0JBQWdCLEdBQUcsQ0FBQyxLQUFjLEVBQUUsVUFBbUIsRUFBVyxFQUFFO0lBQ3hFLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ2xFLElBQUksQ0FBQztRQUNILE1BQU0sT0FBTyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsS0FBSyxFQUFFLFFBQVEsQ0FBQyxDQUFDO1FBQzdDLElBQUksT0FBTyxDQUFDLE1BQU0sS0FBSyxDQUFDO1lBQUUsT0FBTyxLQUFLLENBQUM7UUFDdkMsa0VBQWtFO1FBQ2xFLElBQUksT0FBTyxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxFQUFFLENBQUM7WUFDL0UsT0FBTyxLQUFLLENBQUM7UUFDZixDQUFDO1FBQ0QsT0FBTyxVQUFVLEtBQUssU0FBUyxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssVUFBVSxDQUFDO0lBQ25FLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxPQUFPLEtBQUssQ0FBQztJQUNmLENBQUM7QUFDSCxDQUFDLENBQUM7QUFFRjs7OztHQUlHO0FBQ0gsTUFBTSxVQUFVLHVCQUF1QixDQUNyQyxRQUF3QztJQUV4QyxJQUFJLFFBQVEsS0FBSyxTQUFTO1FBQUUsT0FBTztJQUNuQyxJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxRQUFRLENBQUMsRUFBRSxDQUFDO1FBQzdCLE1BQU0sSUFBSSxLQUFLLENBQUMsZ0NBQWdDLENBQUMsQ0FBQztJQUNwRCxDQUFDO0lBQ0QsTUFBTSxTQUFTLEdBQUcsSUFBSSxHQUFHLEVBQVUsQ0FBQztJQUNwQyxLQUFLLE1BQU0sT0FBTyxJQUFJLFFBQVEsRUFBRSxDQUFDO1FBQy9CLE1BQU0sUUFBUSxHQUFHLE9BQU8sRUFBRSxRQUFRLENBQUM7UUFDbkMsSUFBSSxPQUFPLFFBQVEsS0FBSyxRQUFRLElBQUksQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQztZQUNyRCxNQUFNLElBQUksS0FBSyxDQUFDLG9EQUFvRCxDQUFDLENBQUM7UUFDeEUsQ0FBQztRQUNELE1BQU0sS0FBSyxHQUFHLGdCQUFnQixRQUFRLEVBQUUsQ0FBQztRQUN6QyxJQUFJLFNBQVMsQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLEVBQUUsQ0FBQztZQUM1QixNQUFNLElBQUksS0FBSyxDQUFDLEdBQUcsS0FBSywrQkFBK0IsQ0FBQyxDQUFDO1FBQzNELENBQUM7UUFDRCxTQUFTLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxDQUFDO1FBRXhCLE1BQU0sVUFBVSxHQUFHLE9BQU8sQ0FBQyxVQUVkLENBQUM7UUFDZCxNQUFNLFdBQVcsR0FBRyxVQUFVLEVBQUUsUUFBUSxLQUFLLFNBQVMsQ0FBQztRQUN2RCxNQUFNLFdBQVcsR0FBRyxVQUFVLEVBQUUsUUFBUSxLQUFLLFNBQVMsQ0FBQztRQUN2RCxJQUFJLFdBQVcsS0FBSyxXQUFXLEVBQUUsQ0FBQztZQUNoQyxNQUFNLElBQUksS0FBSyxDQUNiLEdBQUcsS0FBSyw4REFBOEQsQ0FDdkUsQ0FBQztRQUNKLENBQUM7UUFDRCxJQUFJLFdBQVcsSUFBSSxPQUFPLFVBQVcsQ0FBQyxRQUFRLEtBQUssUUFBUSxFQUFFLENBQUM7WUFDNUQsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUssdUNBQXVDLENBQUMsQ0FBQztRQUNuRSxDQUFDO1FBQ0QsSUFBSSxXQUFXLEVBQUUsQ0FBQztZQUNoQixNQUFNLFFBQVEsR0FBRyxVQUFXLENBQUMsUUFBdUMsQ0FBQztZQUNyRSxJQUFJLFFBQVEsRUFBRSxTQUFTLEtBQUssZUFBZSxFQUFFLENBQUM7Z0JBQzVDLE1BQU0sSUFBSSxLQUFLLENBQUMsR0FBRyxLQUFLLDJDQUEyQyxDQUFDLENBQUM7WUFDdkUsQ0FBQztZQUNELElBQ0UsQ0FBQyxNQUFNLENBQUMsU0FBUyxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUM7bUJBQ25DLFFBQVEsQ0FBQyxVQUFXLEdBQUcsb0JBQW9CO21CQUMzQyxRQUFRLENBQUMsVUFBVyxHQUFHLG9CQUFvQixFQUM5QyxDQUFDO2dCQUNELE1BQU0sSUFBSSxLQUFLLENBQ2IsR0FBRyxLQUFLLG1EQUFtRCxvQkFBb0IsUUFBUSxvQkFBb0IsRUFBRSxDQUM5RyxDQUFDO1lBQ0osQ0FBQztZQUNELElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLEVBQUUsQ0FBQztnQkFDckMsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUsseUNBQXlDLENBQUMsQ0FBQztZQUNyRSxDQUFDO1lBQ0QsSUFBSSxDQUFDLGdCQUFnQixDQUFDLFFBQVEsQ0FBQyxTQUFTLEVBQUUsRUFBRSxDQUFDLEVBQUUsQ0FBQztnQkFDOUMsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUsscURBQXFELENBQUMsQ0FBQztZQUNqRixDQUFDO1lBQ0QsSUFBSSxDQUFDLGdCQUFnQixDQUFDLFFBQVEsQ0FBQyxTQUFTLEVBQUUsRUFBRSxDQUFDLEVBQUUsQ0FBQztnQkFDOUMsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUsscURBQXFELENBQUMsQ0FBQztZQUNqRixDQUFDO1FBQ0gsQ0FBQztRQUVELE1BQU0sS0FBSyxHQUFHLE9BQU8sQ0FBQyxLQUFLLENBQUM7UUFDNUIsSUFBSSxLQUFLLEtBQUssU0FBUztZQUFFLFNBQVM7UUFDbEMsSUFDRSxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQztlQUMxQixLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sS0FBSyxDQUFDO2VBQzFCLEtBQUssQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDLENBQUMsT0FBTyxFQUFFLEVBQUUsQ0FBQyxPQUFPLE9BQU8sS0FBSyxRQUFRLElBQUksQ0FBQyxPQUFPLENBQUMsSUFBSSxFQUFFLENBQUMsRUFDbEYsQ0FBQztZQUNELE1BQU0sSUFBSSxLQUFLLENBQ2IsR0FBRyxLQUFLLDhEQUE4RCxDQUN2RSxDQUFDO1FBQ0osQ0FBQztRQUNELElBQUksS0FBSyxDQUFDLFVBQVUsS0FBSyxTQUFTLEVBQUUsQ0FBQztZQUNuQyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsSUFBSSxLQUFLLEtBQUssSUFBSSxLQUFLLENBQUMsVUFBVSxDQUFDLElBQUksS0FBSyxVQUFVLEVBQUUsQ0FBQztnQkFDNUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUssZ0RBQWdELENBQUMsQ0FBQztZQUM1RSxDQUFDO1lBQ0QsSUFDRSxLQUFLLENBQUMsVUFBVSxDQUFDLElBQUksS0FBSyxVQUFVO21CQUNqQyxDQUNELENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsVUFBVSxDQUFDLFFBQVEsQ0FBQzt1QkFDdEMsS0FBSyxDQUFDLFVBQVUsQ0FBQyxRQUFRLENBQUMsTUFBTSxLQUFLLENBQUM7dUJBQ3RDLEtBQUssQ0FBQyxVQUFVLENBQUMsUUFBUSxDQUFDLElBQUksQ0FDL0IsQ0FBQyxPQUFPLEVBQUUsRUFBRSxDQUFDLE9BQU8sT0FBTyxLQUFLLFFBQVEsSUFBSSxDQUFDLE9BQU8sQ0FBQyxJQUFJLEVBQUUsQ0FDNUQsQ0FDRixFQUNELENBQUM7Z0JBQ0QsTUFBTSxJQUFJLEtBQUssQ0FDYixHQUFHLEtBQUssNEVBQTRFLENBQ3JGLENBQUM7WUFDSixDQUFDO1FBQ0gsQ0FBQztJQUNILENBQUM7QUFDSCxDQUFDO0FBRUQ7O0dBRUc7QUFDSCxNQUFNLFVBQVUsd0JBQXdCLENBQ3RDLGFBQXNCLEVBQ3RCLEtBQWE7SUFFYixJQUNFLE9BQU8sYUFBYSxLQUFLLFFBQVE7V0FDOUIsQ0FBQyxNQUFNLENBQUMsYUFBYSxDQUFDLGFBQWEsQ0FBQztXQUNwQyxhQUFhLElBQUksQ0FBQyxFQUNyQixDQUFDO1FBQ0QsTUFBTSxJQUFJLEtBQUssQ0FBQyxHQUFHLEtBQUssa0RBQWtELENBQUMsQ0FBQztJQUM5RSxDQUFDO0FBQ0gsQ0FBQztBQUVELE1BQU0sVUFBVSx3QkFBd0IsQ0FDdEMsYUFHQyxFQUNELE1BQWM7SUFFZCxJQUNFLGFBQWEsQ0FBQyxhQUFhLEtBQUssT0FBTztXQUNwQyxhQUFhLENBQUMsYUFBYSxLQUFLLFFBQVEsRUFDM0MsQ0FBQztRQUNELE1BQU0sSUFBSSxLQUFLLENBQ2IsNkJBQTZCLE1BQU0sd0NBQXdDLENBQzVFLENBQUM7SUFDSixDQUFDO0lBQ0Qsd0JBQXdCLENBQ3RCLGFBQWEsQ0FBQyxhQUFhLEVBQzNCLDZCQUE2QixNQUFNLEVBQUUsQ0FDdEMsQ0FBQztBQUNKLENBQUMifQ==
|
|
@@ -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 */
|
|
@@ -41,9 +46,21 @@ export interface IEmailMatch {
|
|
|
41
46
|
* Action to take when route matches
|
|
42
47
|
*/
|
|
43
48
|
export interface IEmailAction {
|
|
44
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Type of action to perform.
|
|
51
|
+
* - `forward`: relay to a configured SMTP host.
|
|
52
|
+
* - `process`: queue for scanning and outbound MX delivery.
|
|
53
|
+
* - `deliver`: terminal local delivery through the durable
|
|
54
|
+
* `hooks.onAcceptEnvelope` acceptance path. Never relays.
|
|
55
|
+
* - `store`: terminal local delivery like `deliver`, with explicit
|
|
56
|
+
* retention configuration.
|
|
57
|
+
* - `reject`: refuse with a configurable SMTP error.
|
|
58
|
+
*/
|
|
45
59
|
type: 'forward' | 'deliver' | 'reject' | 'process' | 'store';
|
|
46
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Explicitly permits this route to relay to non-local recipients.
|
|
62
|
+
* Ignored for `deliver` actions, which are local-only and never relay.
|
|
63
|
+
*/
|
|
47
64
|
allowRelay?: boolean;
|
|
48
65
|
/** Forward action configuration */
|
|
49
66
|
forward?: {
|
|
@@ -116,7 +133,7 @@ export interface IEmailAction {
|
|
|
116
133
|
[key: string]: any;
|
|
117
134
|
}>;
|
|
118
135
|
};
|
|
119
|
-
/** Delivery options (applies to forward/process
|
|
136
|
+
/** Delivery options (applies to the queue-backed forward/process actions) */
|
|
120
137
|
delivery?: {
|
|
121
138
|
/** Rate limit (messages per minute) */
|
|
122
139
|
rateLimit?: number;
|
|
@@ -124,6 +141,60 @@ export interface IEmailAction {
|
|
|
124
141
|
retries?: number;
|
|
125
142
|
};
|
|
126
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
|
+
}
|
|
127
198
|
/**
|
|
128
199
|
* Context for route evaluation
|
|
129
200
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@push.rocks/smartmta",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.1.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,9 +31,9 @@
|
|
|
31
31
|
".": "./dist_ts/index.js"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@git.zone/cli": "^
|
|
34
|
+
"@git.zone/cli": "^3.0.0",
|
|
35
35
|
"@git.zone/tsbuild": "4.4.2",
|
|
36
|
-
"@git.zone/tsrust": "^1.
|
|
36
|
+
"@git.zone/tsrust": "^1.7.0",
|
|
37
37
|
"@git.zone/tstest": "^3.6.6",
|
|
38
38
|
"@types/mailparser": "^3.4.6",
|
|
39
39
|
"@types/node": "^26.1.1",
|
package/readme.md
CHANGED
|
@@ -29,7 +29,7 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
|
|
29
29
|
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
|
30
30
|
| **SPF** | Full SPF record validation via Rust |
|
|
31
31
|
| **DMARC** | Policy enforcement and verification |
|
|
32
|
-
| **Email Router** | Pattern-based routing with priority, forward/
|
|
32
|
+
| **Email Router** | Pattern-based routing with priority, forward/process/reject actions plus terminal local `deliver`/`store` actions |
|
|
33
33
|
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
|
|
34
34
|
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
|
|
35
35
|
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
|
|
@@ -171,7 +171,8 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
|
|
171
171
|
securePort: 465,
|
|
172
172
|
recipientValidationTimeoutMs: 5000,
|
|
173
173
|
|
|
174
|
-
// Timeout for hooks.onMessageData
|
|
174
|
+
// Timeout for hooks.onMessageData and hooks.onAcceptEnvelope before
|
|
175
|
+
// returning a temporary SMTP failure.
|
|
175
176
|
messageAcceptanceTimeoutMs: 30000,
|
|
176
177
|
|
|
177
178
|
// Set required to true only on backend listeners reached through trusted proxies.
|
|
@@ -197,17 +198,36 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
|
|
197
198
|
return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
|
|
198
199
|
},
|
|
199
200
|
async onMessageData(context) {
|
|
201
|
+
// Runs for envelopes WITHOUT a resolved store/deliver action.
|
|
200
202
|
// Return accepted only after the consumer has taken responsibility for the message.
|
|
201
203
|
// await durableStore.save(context.email, context.rawMessage, { signal: context.abortSignal });
|
|
202
204
|
return { accepted: true, continueProcessing: false };
|
|
203
205
|
},
|
|
206
|
+
async onAcceptEnvelope(context) {
|
|
207
|
+
// Required terminal durable owner for envelopes with a resolved
|
|
208
|
+
// `store` or `deliver` action. Durably persist the EXACT raw bytes and
|
|
209
|
+
// the per-recipient plan before resolving — the SMTP 250 is only sent
|
|
210
|
+
// after this hook resolves.
|
|
211
|
+
// const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
|
|
212
|
+
// await durableStore.saveEnvelope(context.rawMessage, plan, { signal: context.abortSignal });
|
|
213
|
+
},
|
|
204
214
|
},
|
|
205
215
|
|
|
206
|
-
// 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.
|
|
207
219
|
auth: {
|
|
208
220
|
required: false,
|
|
209
221
|
methods: ['PLAIN', 'LOGIN'],
|
|
210
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
|
+
}],
|
|
211
231
|
},
|
|
212
232
|
|
|
213
233
|
// TLS material is provided in memory; SmartMTA does not read certificate files.
|
|
@@ -241,7 +261,7 @@ await emailServer.start();
|
|
|
241
261
|
|
|
242
262
|
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
|
243
263
|
>
|
|
244
|
-
> SmartMTA
|
|
264
|
+
> SmartMTA requires Node.js 22 or newer.
|
|
245
265
|
>
|
|
246
266
|
> `hostname` is the public SMTP identity for greetings and outbound delivery by default. It is not a bind address.
|
|
247
267
|
>
|
|
@@ -268,16 +288,75 @@ Bounce delivery is at-least-once. `BounceManager` uses the operation ID as a dur
|
|
|
268
288
|
|
|
269
289
|
SmartMTA validates each `RCPT TO` before the client can enter `DATA` by default. The default policy evaluates routes and accepts configured local domains. External recipients are rejected unless the matched route action explicitly sets `allowRelay: true`. Set `smtp.recipientValidation: false` only on fully trusted private listeners where another layer performs recipient and relay policy.
|
|
270
290
|
|
|
271
|
-
Use `allowRelay` only for intentional relay routes, for example authenticated submission or tightly scoped internal networks. Broad `process`
|
|
291
|
+
Use `allowRelay` only for intentional relay routes, for example authenticated submission or tightly scoped internal networks. Broad `process` routes do not grant relay permission by themselves, and `deliver` routes can never relay — `allowRelay` is ignored for them.
|
|
272
292
|
|
|
273
|
-
`hooks.onRcptTo` and `hooks.
|
|
293
|
+
`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`.
|
|
274
294
|
|
|
275
|
-
`hooks.onMessageData` runs after `DATA` and before the final SMTP response. 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.
|
|
295
|
+
`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.
|
|
296
|
+
|
|
297
|
+
`hooks.onAcceptEnvelope` is the terminal durable owner for any envelope containing a resolved `store` or `deliver` action. It receives the exact raw RFC822 bytes (`rawMessage`), the complete per-recipient route resolution (`resolvedRecipientRoutes`), and the Rust security verdicts (`securityResults`). It must durably persist the message before resolving — the SMTP `250` is only sent after the hook resolves. Throwing an `AcceptEnvelopeRejectionError` with a `5xx` code rejects the envelope permanently with that status; any other error or a `smtp.messageAcceptanceTimeoutMs` timeout refuses the envelope with a temporary `451`. Use `createAcceptedEnvelopeDispatchPlan()` inside the hook to snapshot the resolved plan for later replay-safe `dispatchAcceptedEnvelope()` calls. Both `AcceptEnvelopeRejectionError` and `IAcceptEnvelopeContext` are exported.
|
|
276
298
|
|
|
277
299
|
Use `smtp.securePort` when the implicit TLS listener uses a remapped backend port instead of public `465`; the configured secure port must also be present in `ports`, otherwise `start()` throws.
|
|
278
300
|
|
|
279
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.
|
|
280
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
|
+
|
|
281
360
|
### 📧 Sending Emails (Automatic MX Discovery)
|
|
282
361
|
|
|
283
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:
|
|
@@ -344,6 +423,8 @@ public async sendEmail(
|
|
|
344
423
|
|
|
345
424
|
**Self-MX loop guard:** MTA delivery refuses any MX host that equals the server's own hostname (`hostname` or `outbound.hostname`). A domain whose MX record points back at this server would otherwise re-enter the inbound pipeline on every delivery attempt — an infinite mail loop. Such deliveries fail with `MX for <domain> points back at this server — refusing self-delivery to avoid a mail loop`. The comparison is hostname-based; if a target only reveals the loop at the IP level (e.g. an A-record fallback), embedders should add an IP-level guard in their `connectionProxyProvider`.
|
|
346
425
|
|
|
426
|
+
Routes with action `deliver` never enter MTA mode: they are terminal local deliveries through the durable acceptance path (see Route Action Types below), so the self-MX guard is structurally unreachable for them. Pre-9.x persisted queue items that still carry a `deliver` action fail loudly at delivery time with error type `deliver_action_relay_unsupported` (failure class `localTopology`) instead of being relayed or bounced as a misleading self-MX loop.
|
|
427
|
+
|
|
347
428
|
### 📬 Direct SMTP Delivery (Low-Level)
|
|
348
429
|
|
|
349
430
|
For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level `sendOutboundEmail()`:
|
|
@@ -426,8 +507,9 @@ When `UnifiedEmailServer.start()` is called:
|
|
|
426
507
|
- With `dkimKeyProvisioning: 'caller-managed'`, SmartMTA only loads the requested selector from managed storage; it never generates or rotates keys.
|
|
427
508
|
- A requested missing or invalid key fails closed before SMTP delivery. SmartMTA never falls back to unsigned delivery.
|
|
428
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.
|
|
429
511
|
|
|
430
|
-
Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerManagedDkimKeys`, `structuredSmtpTransactions`, and `
|
|
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.
|
|
431
513
|
|
|
432
514
|
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
|
433
515
|
|
|
@@ -460,6 +542,8 @@ const router = new EmailRouter([
|
|
|
460
542
|
authenticated: true,
|
|
461
543
|
},
|
|
462
544
|
action: {
|
|
545
|
+
// Terminal local delivery. In UnifiedEmailServer this requires a
|
|
546
|
+
// configured hooks.onAcceptEnvelope durable local-delivery owner.
|
|
463
547
|
type: 'deliver',
|
|
464
548
|
},
|
|
465
549
|
},
|
|
@@ -505,11 +589,23 @@ const matchedRoute = await router.evaluateRoutes(emailContext);
|
|
|
505
589
|
| Action | Description |
|
|
506
590
|
|---|---|
|
|
507
591
|
| `forward` | Forward the email to another SMTP server via the Rust SMTP client |
|
|
508
|
-
| `deliver` |
|
|
509
|
-
| `
|
|
592
|
+
| `deliver` | **Terminal local delivery.** The envelope is durably accepted through `hooks.onAcceptEnvelope` before the SMTP 250; nothing is enqueued for outbound MX relay. |
|
|
593
|
+
| `store` | Terminal local delivery like `deliver`, with explicit retention configuration (`store.retentionDays`) |
|
|
594
|
+
| `process` | Queue for processing (with optional content scanning and DKIM signing), then deliver via automatic MX resolution |
|
|
510
595
|
| `reject` | Reject with a configurable SMTP error code and message |
|
|
511
596
|
|
|
512
|
-
|
|
597
|
+
##### Local delivery: `deliver` and `store`
|
|
598
|
+
|
|
599
|
+
`deliver` and `store` are the two terminal local-delivery actions. An envelope that resolves to either goes through the durable acceptance path: SmartMTA calls `hooks.onAcceptEnvelope` with the exact raw message and the complete per-recipient plan, and only sends the SMTP `250` after the hook resolves — the consumer's durably persisted copy IS the delivery. In `dispatchAcceptedEnvelope`, both report `status: 'stored'` with no `queueId`; the `actionType` field distinguishes them.
|
|
600
|
+
|
|
601
|
+
- A `deliver` route **requires** a configured `hooks.onAcceptEnvelope`. Without it the configuration is unservable and is rejected loudly whenever routes are supplied or loaded: the constructor, `updateEmailRoutes`, `updateOptions` calls that include `routes`, and persisted-route hydration all throw. If hook state is nevertheless broken at runtime (for example an `updateOptions` call that replaces `hooks` without passing `routes`), deliver recipients are refused with a temporary `451 Local delivery temporarily unavailable` at `RCPT TO`/`DATA`. Mail is never accepted and then destroyed.
|
|
602
|
+
- `deliver` never relays. Non-local recipients matched by a deliver route are refused with `550` at `RCPT TO`, and `allowRelay` is ignored for deliver actions.
|
|
603
|
+
- `deliver` envelopes skip `hooks.onMessageData`, bounce-notification detection, and the inbound `processEmailByMode` pipeline — exactly like `store` envelopes. Rust-computed security verdicts are still delivered in the acceptance context.
|
|
604
|
+
- `deliver` carries no retention metadata; the consumer owns retention for delivered messages. Use `store` with `store.retentionDays` when retention must be explicit.
|
|
605
|
+
|
|
606
|
+
> ⚠️ **Changed in v9:** before v9, `deliver` silently meant "relay via MX discovery". On a domain whose MX points back at this server, that accepted mail with `250` and then terminally bounced it on the self-MX loop guard — destroying mail. `deliver` now delivers locally, and configurations that cannot do so fail at config/accept time.
|
|
607
|
+
|
|
608
|
+
Every action can include `allowRelay?: boolean`. It defaults to `false`. Non-local recipients are accepted only when the matched action explicitly sets `allowRelay: true`; configured local domains remain eligible for inbound delivery without relay permission. `allowRelay` has no effect on `deliver` actions.
|
|
513
609
|
|
|
514
610
|
#### Match Criteria
|
|
515
611
|
|
|
@@ -519,6 +615,7 @@ Every action can include `allowRelay?: boolean`. It defaults to `false`. Non-loc
|
|
|
519
615
|
| `senders` | Glob patterns for sender addresses |
|
|
520
616
|
| `clientIp` | IP addresses or CIDR ranges |
|
|
521
617
|
| `authenticated` | Require authentication status |
|
|
618
|
+
| `authenticatedUser` | Require the session to be authenticated as this exact username (string or array of usernames) |
|
|
522
619
|
| `headers` | Match specific headers (string or RegExp) |
|
|
523
620
|
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
|
|
524
621
|
| `subject` | Subject line pattern (string or RegExp) |
|
|
@@ -669,7 +766,7 @@ The Rust `mailer-bin` process is supervised by `RustSecurityBridge`:
|
|
|
669
766
|
- **Health checks** — a `ping` every 30 s; two consecutive failures kill and restart the process.
|
|
670
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.
|
|
671
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.
|
|
672
|
-
- **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`.
|
|
673
770
|
|
|
674
771
|
Tune with `RustSecurityBridge.configure({ maxRestartAttempts, healthCheckIntervalMs, healthCheckTimeoutMs, restartBackoffBaseMs, restartBackoffMaxMs })`.
|
|
675
772
|
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/index.ts
CHANGED
|
@@ -675,7 +675,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
675
675
|
const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
|
|
676
676
|
retryable: deliveryError.retryable,
|
|
677
677
|
terminalFailure: {
|
|
678
|
-
failureClass:
|
|
678
|
+
failureClass: (
|
|
679
|
+
deliveryError.errorType.startsWith('self_mx_')
|
|
680
|
+
|| deliveryError.errorType === 'deliver_action_relay_unsupported'
|
|
681
|
+
)
|
|
679
682
|
? 'localTopology'
|
|
680
683
|
: 'transport',
|
|
681
684
|
...(deliveryError.smtpCode !== undefined ? { smtpCode: deliveryError.smtpCode } : {}),
|
|
@@ -775,7 +778,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
775
778
|
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
|
776
779
|
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.getFromDomain() || undefined)
|
|
777
780
|
: undefined;
|
|
778
|
-
const dkimSelector = item
|
|
781
|
+
const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
|
|
779
782
|
|
|
780
783
|
// Build auth options from route forward config
|
|
781
784
|
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
|
@@ -847,6 +850,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
847
850
|
}
|
|
848
851
|
}
|
|
849
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
|
+
|
|
850
867
|
/**
|
|
851
868
|
* Resolve MX records for a domain, sorted by priority (lowest first).
|
|
852
869
|
* Falls back to the domain itself as an A record per RFC 5321.
|
|
@@ -926,6 +943,22 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
926
943
|
|
|
927
944
|
const email = item.processingResult as Email;
|
|
928
945
|
|
|
946
|
+
// Structural invariant: `deliver` actions are terminal local deliveries
|
|
947
|
+
// and must never reach outbound MX relay (and thus never the self-MX
|
|
948
|
+
// guard below). Items carrying a deliver action can only be pre-9.x
|
|
949
|
+
// persisted queue state or a direct-enqueue misuse; both fail loudly
|
|
950
|
+
// here instead of relaying or bouncing as a misleading self-MX loop.
|
|
951
|
+
if (item.route?.action.type === 'deliver') {
|
|
952
|
+
throw new SmtpDeliveryError({
|
|
953
|
+
message: `Queue item ${item.id} carries a 'deliver' action; deliver is a terminal local delivery and cannot be relayed via MX`,
|
|
954
|
+
errorType: 'deliver_action_relay_unsupported',
|
|
955
|
+
retryable: false,
|
|
956
|
+
phase: 'connect',
|
|
957
|
+
transactionLog: [],
|
|
958
|
+
recipients: email.getAllRecipients(),
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
929
962
|
if (!this.emailServer) {
|
|
930
963
|
throw new Error('No email server available for MTA delivery');
|
|
931
964
|
}
|
|
@@ -937,7 +970,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
937
970
|
const dkimDomain = effectiveDkimSigning
|
|
938
971
|
? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
|
|
939
972
|
: undefined;
|
|
940
|
-
const dkimSelector = item
|
|
973
|
+
const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
|
|
941
974
|
|
|
942
975
|
const allRecipients = email.getAllRecipients();
|
|
943
976
|
if (allRecipients.length === 0) {
|
|
@@ -47,7 +47,10 @@ export interface IActionExecutionResult {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Executes email routing actions (forward,
|
|
50
|
+
* Executes queue-backed and rejecting email routing actions (forward,
|
|
51
|
+
* process, reject). Terminal local-delivery actions (`deliver`, `store`) are
|
|
52
|
+
* owned by the durable `hooks.onAcceptEnvelope` acceptance path and are
|
|
53
|
+
* refused here.
|
|
51
54
|
*/
|
|
52
55
|
export class EmailActionExecutor {
|
|
53
56
|
constructor(private deps: IActionExecutorDeps) {}
|
|
@@ -97,7 +100,9 @@ export class EmailActionExecutor {
|
|
|
97
100
|
case 'process':
|
|
98
101
|
return this.handleProcessAction(action, email, context, options);
|
|
99
102
|
case 'deliver':
|
|
100
|
-
|
|
103
|
+
throw new Error(
|
|
104
|
+
'Deliver actions are terminal local deliveries and require the durable hooks.onAcceptEnvelope acceptance path; they never enqueue outbound MX relay',
|
|
105
|
+
);
|
|
101
106
|
case 'store':
|
|
102
107
|
throw new Error(
|
|
103
108
|
'Store actions require raw SMTP context and an awaited hooks.onAcceptEnvelope handler',
|
|
@@ -250,32 +255,6 @@ export class EmailActionExecutor {
|
|
|
250
255
|
return { outcome: 'queued', queueId };
|
|
251
256
|
}
|
|
252
257
|
|
|
253
|
-
private async handleDeliverAction(
|
|
254
|
-
_action: IEmailAction,
|
|
255
|
-
email: Email,
|
|
256
|
-
context: IEmailContext,
|
|
257
|
-
options: IActionExecutionOptions,
|
|
258
|
-
): Promise<IActionExecutionResult> {
|
|
259
|
-
logger.log('info', `Delivering email locally`);
|
|
260
|
-
|
|
261
|
-
// Queue for local delivery
|
|
262
|
-
const queueId = await this.deps.deliveryQueue.enqueue(
|
|
263
|
-
email,
|
|
264
|
-
'mta',
|
|
265
|
-
context.session.matchedRoute!,
|
|
266
|
-
options.durableQueue
|
|
267
|
-
? {
|
|
268
|
-
idempotencyKey: options.durableQueue.idempotencyKey,
|
|
269
|
-
idempotencyFingerprint: options.durableQueue.fingerprint,
|
|
270
|
-
requireDurable: true,
|
|
271
|
-
}
|
|
272
|
-
: {},
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
logger.log('info', 'Email queued for local delivery');
|
|
276
|
-
return { outcome: 'queued', queueId };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
258
|
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
|
|
280
259
|
const code = action.reject?.code || 550;
|
|
281
260
|
const message = action.reject?.message || 'Message rejected';
|