@push.rocks/smartmta 9.0.0 → 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 +20 -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/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/interfaces.ts +58 -0
|
@@ -18,12 +18,15 @@ import type {
|
|
|
18
18
|
IRcptToRequestEvent,
|
|
19
19
|
IScramCredentialRequestEvent,
|
|
20
20
|
} from '../../security/classes.rustsecuritybridge.js';
|
|
21
|
-
import { EmailRouter } from './classes.email.router.js';
|
|
21
|
+
import { EmailRouter, addressGlobToRegExp } from './classes.email.router.js';
|
|
22
|
+
import { assertValidAuthAccounts } from './functions.routing.validation.js';
|
|
22
23
|
import type {
|
|
23
24
|
IEmailRoute,
|
|
24
25
|
IEmailAction,
|
|
25
26
|
IEmailContext,
|
|
26
27
|
IEmailDomainConfig,
|
|
28
|
+
ISmtpAuthAccount,
|
|
29
|
+
ISmtpScramVerifier,
|
|
27
30
|
} from './interfaces.js';
|
|
28
31
|
import { Email } from '../core/classes.email.js';
|
|
29
32
|
import { DomainRegistry } from './classes.domain.registry.js';
|
|
@@ -61,7 +64,10 @@ export type TRecipientRejectionReason =
|
|
|
61
64
|
| 'domain-not-hosted'
|
|
62
65
|
| 'relay-denied'
|
|
63
66
|
| 'recipient-hook'
|
|
64
|
-
| 'policy-error'
|
|
67
|
+
| 'policy-error'
|
|
68
|
+
| 'sender-scope'
|
|
69
|
+
| 'recipient-scope'
|
|
70
|
+
| 'account-disabled';
|
|
65
71
|
|
|
66
72
|
export interface IResolvedRecipientRoute {
|
|
67
73
|
recipient: string;
|
|
@@ -295,6 +301,11 @@ export interface IUnifiedEmailServerOptions {
|
|
|
295
301
|
required?: boolean;
|
|
296
302
|
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
|
297
303
|
users?: Array<{username: string, password: string}>;
|
|
304
|
+
/**
|
|
305
|
+
* Authenticated SMTP accounts with hashed credentials and send scopes.
|
|
306
|
+
* Coexists with legacy `users`; accounts win on username collision.
|
|
307
|
+
*/
|
|
308
|
+
accounts?: ISmtpAuthAccount[];
|
|
298
309
|
};
|
|
299
310
|
|
|
300
311
|
// TLS options
|
|
@@ -462,6 +473,15 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
462
473
|
private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
|
|
463
474
|
private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
|
|
464
475
|
private acceptedEnvelopeDispatchCleanupCursor?: string;
|
|
476
|
+
private smtpListenerActive = false;
|
|
477
|
+
private smtpListenerShutdown = false;
|
|
478
|
+
private smtpListenerRestartPromise?: Promise<void>;
|
|
479
|
+
private static readonly VERIFIER_DERIVATION_CONCURRENCY = 4;
|
|
480
|
+
private static readonly VERIFIER_DERIVATION_MAX_QUEUE = 32;
|
|
481
|
+
private verifierDerivationActive = 0;
|
|
482
|
+
private verifierDerivationWaiters: Array<() => void> = [];
|
|
483
|
+
/** Compiled scope patterns; keys are config-supplied, bounded by config size. */
|
|
484
|
+
private scopePatternCache: Map<string, RegExp> = new Map();
|
|
465
485
|
|
|
466
486
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
467
487
|
super();
|
|
@@ -478,6 +498,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
478
498
|
socketTimeout: options.socketTimeout || 60000 // 1 minute
|
|
479
499
|
};
|
|
480
500
|
|
|
501
|
+
// Auth account misconfiguration must fail loudly at configuration time,
|
|
502
|
+
// never degrade into silent deny-all scopes or silent auth failures.
|
|
503
|
+
assertValidAuthAccounts(this.options.auth?.accounts);
|
|
504
|
+
|
|
481
505
|
// Initialize Rust security bridge (singleton)
|
|
482
506
|
this.rustBridge = RustSecurityBridge.getInstance();
|
|
483
507
|
|
|
@@ -927,6 +951,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
927
951
|
*/
|
|
928
952
|
public async start(): Promise<void> {
|
|
929
953
|
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
|
954
|
+
this.smtpListenerShutdown = false;
|
|
930
955
|
|
|
931
956
|
try {
|
|
932
957
|
await this.startDeliveryPipeline();
|
|
@@ -1169,7 +1194,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1169
1194
|
maxRecipients: 100,
|
|
1170
1195
|
connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
|
|
1171
1196
|
dataTimeoutSecs: 60,
|
|
1172
|
-
authEnabled:
|
|
1197
|
+
authEnabled: this.isAuthConfigured(this.options),
|
|
1173
1198
|
maxAuthFailures: 3,
|
|
1174
1199
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
1175
1200
|
processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
|
|
@@ -1193,9 +1218,57 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1193
1218
|
throw new Error('Failed to start Rust SMTP server');
|
|
1194
1219
|
}
|
|
1195
1220
|
|
|
1221
|
+
this.smtpListenerActive = true;
|
|
1196
1222
|
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
|
1197
1223
|
}
|
|
1198
1224
|
|
|
1225
|
+
/** Whether the current options require SMTP AUTH support on the listener. */
|
|
1226
|
+
private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
|
|
1227
|
+
return !!options.auth?.required
|
|
1228
|
+
|| !!options.auth?.users?.length
|
|
1229
|
+
|| !!options.auth?.accounts?.length;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Whether the Rust SMTP listener is currently accepting connections.
|
|
1234
|
+
* Embedders should surface this next to getBridgeState() in health
|
|
1235
|
+
* endpoints: a failed listener reconfiguration leaves it false.
|
|
1236
|
+
*/
|
|
1237
|
+
public isSmtpListenerActive(): boolean {
|
|
1238
|
+
return this.smtpListenerActive;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Restart only the Rust SMTP listener so listener-level configuration such
|
|
1243
|
+
* as AUTH availability takes effect without a full process restart.
|
|
1244
|
+
* Restarts are serialized and become no-ops once shutdown has begun; a
|
|
1245
|
+
* failed restart leaves the listener inactive, logs loudly, and emits
|
|
1246
|
+
* 'smtpListenerRestartFailed' so embedders can react (e.g. by calling
|
|
1247
|
+
* stop()/start()).
|
|
1248
|
+
*/
|
|
1249
|
+
private scheduleSmtpListenerRestart(reason: string): void {
|
|
1250
|
+
const previous = this.smtpListenerRestartPromise || Promise.resolve();
|
|
1251
|
+
this.smtpListenerRestartPromise = previous
|
|
1252
|
+
.then(async () => {
|
|
1253
|
+
if (this.smtpListenerShutdown) return;
|
|
1254
|
+
logger.log('info', `Restarting Rust SMTP listener: ${reason}`);
|
|
1255
|
+
this.smtpListenerActive = false;
|
|
1256
|
+
await this.rustBridge.stopSmtpServer();
|
|
1257
|
+
if (this.smtpListenerShutdown) return;
|
|
1258
|
+
await this.startSmtpServer();
|
|
1259
|
+
})
|
|
1260
|
+
.catch(async (error) => {
|
|
1261
|
+
logger.log(
|
|
1262
|
+
'error',
|
|
1263
|
+
`Failed to restart Rust SMTP listener (${reason}): ${getErrorMessage(error)}`,
|
|
1264
|
+
);
|
|
1265
|
+
await emitSafely(this, 'smtpListenerRestartFailed', {
|
|
1266
|
+
reason,
|
|
1267
|
+
message: getErrorMessage(error),
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1199
1272
|
/**
|
|
1200
1273
|
* Stop the unified email server
|
|
1201
1274
|
*/
|
|
@@ -1208,6 +1281,18 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1208
1281
|
this.acceptedEnvelopeDispatchCleanupTimer = undefined;
|
|
1209
1282
|
}
|
|
1210
1283
|
await this.acceptedEnvelopeDispatchCleanupPromise;
|
|
1284
|
+
// Prevent new listener restarts from being scheduled or executed
|
|
1285
|
+
// during shutdown, then drain any in-flight reconfiguration before
|
|
1286
|
+
// tearing down (the chain may be extended while awaited).
|
|
1287
|
+
this.smtpListenerShutdown = true;
|
|
1288
|
+
this.smtpListenerActive = false;
|
|
1289
|
+
while (this.smtpListenerRestartPromise) {
|
|
1290
|
+
const pendingRestart = this.smtpListenerRestartPromise;
|
|
1291
|
+
await pendingRestart;
|
|
1292
|
+
if (this.smtpListenerRestartPromise === pendingRestart) {
|
|
1293
|
+
this.smtpListenerRestartPromise = undefined;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1211
1296
|
await this.stopDnsValidation();
|
|
1212
1297
|
|
|
1213
1298
|
// Stop the Rust SMTP server first
|
|
@@ -1217,6 +1302,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1217
1302
|
} catch (err) {
|
|
1218
1303
|
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
|
1219
1304
|
}
|
|
1305
|
+
this.smtpListenerActive = false;
|
|
1220
1306
|
|
|
1221
1307
|
// Clear the servers array - servers will be garbage collected
|
|
1222
1308
|
this.servers = [];
|
|
@@ -1334,6 +1420,30 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1334
1420
|
emailArg?: Email,
|
|
1335
1421
|
sessionArg?: IExtendedSmtpSession,
|
|
1336
1422
|
): Promise<IRecipientPolicyResolution> {
|
|
1423
|
+
// Account scopes are envelope policy and precede route evaluation. This
|
|
1424
|
+
// runs both at RCPT TO and again per recipient at DATA (where the
|
|
1425
|
+
// context is rebuilt from session.envelope truth), so an out-of-scope
|
|
1426
|
+
// MAIL FROM is rejected before any recipient is accepted and cannot be
|
|
1427
|
+
// smuggled past the first phase.
|
|
1428
|
+
const scopeViolation = this.resolveAuthAccountScopeDecision(
|
|
1429
|
+
context.authenticatedUser,
|
|
1430
|
+
context.mailFrom,
|
|
1431
|
+
context.rcptTo,
|
|
1432
|
+
);
|
|
1433
|
+
if (scopeViolation) {
|
|
1434
|
+
const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
|
|
1435
|
+
return {
|
|
1436
|
+
recipient: context.rcptTo,
|
|
1437
|
+
localDomain: scopeRecipientDomain && this.domainRegistry.isDomainRegistered(scopeRecipientDomain)
|
|
1438
|
+
? scopeRecipientDomain
|
|
1439
|
+
: null,
|
|
1440
|
+
route: null,
|
|
1441
|
+
source: 'no-route',
|
|
1442
|
+
decision: scopeViolation.decision,
|
|
1443
|
+
rejectionReason: scopeViolation.rejectionReason,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1337
1447
|
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
1338
1448
|
const email = emailArg || new Email({
|
|
1339
1449
|
from: context.mailFrom || '<>',
|
|
@@ -1540,6 +1650,156 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1540
1650
|
return match?.[1];
|
|
1541
1651
|
}
|
|
1542
1652
|
|
|
1653
|
+
// -----------------------------------------------------------------------
|
|
1654
|
+
// SMTP auth accounts and scopes
|
|
1655
|
+
// -----------------------------------------------------------------------
|
|
1656
|
+
|
|
1657
|
+
/** Resolve the configured account for a username. Accounts win over legacy users. */
|
|
1658
|
+
private findAuthAccount(username: string): ISmtpAuthAccount | undefined {
|
|
1659
|
+
return this.options.auth?.accounts?.find(
|
|
1660
|
+
(account) => account.username === username,
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private normalizeScopeAddress(addressArg: string | null | undefined): string {
|
|
1665
|
+
return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Glob match ('a@b.com' | '*@b.com') against a normalized address, using
|
|
1670
|
+
* the same pattern semantics as route matching. Compiled patterns are
|
|
1671
|
+
* cached; keys are configuration-supplied (never remote-controlled), so
|
|
1672
|
+
* the cache is bounded by account configuration size.
|
|
1673
|
+
*/
|
|
1674
|
+
private matchesScopePatterns(
|
|
1675
|
+
addressArg: string | null | undefined,
|
|
1676
|
+
patterns: string[] | undefined,
|
|
1677
|
+
): boolean {
|
|
1678
|
+
const address = this.normalizeScopeAddress(addressArg);
|
|
1679
|
+
return (patterns || []).some((pattern) => {
|
|
1680
|
+
let regExp = this.scopePatternCache.get(pattern);
|
|
1681
|
+
if (!regExp) {
|
|
1682
|
+
regExp = addressGlobToRegExp(pattern.trim());
|
|
1683
|
+
this.scopePatternCache.set(pattern, regExp);
|
|
1684
|
+
}
|
|
1685
|
+
return regExp.test(address);
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Envelope-level scope decision for an authenticated session. Returns null
|
|
1691
|
+
* when the envelope is permitted (unauthenticated sessions, usernames with
|
|
1692
|
+
* no configured account, and accounts without a scope are unrestricted).
|
|
1693
|
+
*/
|
|
1694
|
+
private resolveAuthAccountScopeDecision(
|
|
1695
|
+
authenticatedUser: string | null,
|
|
1696
|
+
mailFrom: string,
|
|
1697
|
+
rcptTo: string,
|
|
1698
|
+
): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
|
|
1699
|
+
if (!authenticatedUser) return null;
|
|
1700
|
+
const account = this.findAuthAccount(authenticatedUser);
|
|
1701
|
+
if (!account) return null;
|
|
1702
|
+
if (account.enabled === false) {
|
|
1703
|
+
// The account was disabled after authentication succeeded; the session
|
|
1704
|
+
// must not be able to send anything.
|
|
1705
|
+
return {
|
|
1706
|
+
decision: {
|
|
1707
|
+
accepted: false,
|
|
1708
|
+
smtpCode: 550,
|
|
1709
|
+
smtpMessage: '5.7.1 Account disabled',
|
|
1710
|
+
},
|
|
1711
|
+
rejectionReason: 'account-disabled',
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
const scope = account.scope;
|
|
1715
|
+
if (!scope) return null;
|
|
1716
|
+
if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
|
|
1717
|
+
return {
|
|
1718
|
+
decision: {
|
|
1719
|
+
accepted: false,
|
|
1720
|
+
smtpCode: 553,
|
|
1721
|
+
smtpMessage: `5.7.1 Sender address not permitted for authenticated user ${authenticatedUser}`,
|
|
1722
|
+
},
|
|
1723
|
+
rejectionReason: 'sender-scope',
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
if (
|
|
1727
|
+
scope.recipients?.mode === 'patterns'
|
|
1728
|
+
&& !this.matchesScopePatterns(rcptTo, scope.recipients.patterns)
|
|
1729
|
+
) {
|
|
1730
|
+
return {
|
|
1731
|
+
decision: {
|
|
1732
|
+
accepted: false,
|
|
1733
|
+
smtpCode: 550,
|
|
1734
|
+
smtpMessage: `5.7.1 Recipient not permitted for authenticated user ${authenticatedUser}`,
|
|
1735
|
+
},
|
|
1736
|
+
rejectionReason: 'recipient-scope',
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Extract every mailbox address from the parsed From header. The Email
|
|
1744
|
+
* abstraction keeps only the first From mailbox, which is not sufficient
|
|
1745
|
+
* for scope enforcement: `From: allowed@a.com, victim@b.com` must not
|
|
1746
|
+
* pass. Parsing stops at the first CRLFCRLF header boundary when present
|
|
1747
|
+
* (the Rust receiver normalizes line endings to CRLF); a bodyless message
|
|
1748
|
+
* without one is parsed whole.
|
|
1749
|
+
*/
|
|
1750
|
+
private async parseHeaderFromMailboxes(rawMessage: Buffer): Promise<string[]> {
|
|
1751
|
+
const headerBoundary = rawMessage.indexOf('\r\n\r\n');
|
|
1752
|
+
const headerSection = headerBoundary === -1
|
|
1753
|
+
? rawMessage
|
|
1754
|
+
: rawMessage.subarray(0, headerBoundary + 4);
|
|
1755
|
+
const parsed = await plugins.mailparser.simpleParser(headerSection);
|
|
1756
|
+
return ((parsed.from as any)?.value || [])
|
|
1757
|
+
.map((mailbox: any) => mailbox?.address)
|
|
1758
|
+
.filter(Boolean) as string[];
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Header-From alignment for scoped authenticated accounts at DATA. The
|
|
1763
|
+
* envelope MAIL FROM is enforced per recipient via resolveRecipientPolicy
|
|
1764
|
+
* from session.envelope truth; every parsed header From mailbox must
|
|
1765
|
+
* independently stay in scope because route matching and downstream
|
|
1766
|
+
* consumers see the header identity.
|
|
1767
|
+
*/
|
|
1768
|
+
private async checkAuthenticatedHeaderFromScope(
|
|
1769
|
+
email: Email,
|
|
1770
|
+
rawMessage: Buffer,
|
|
1771
|
+
session: IExtendedSmtpSession,
|
|
1772
|
+
): Promise<ISmtpDecision | null> {
|
|
1773
|
+
const username = session.user?.username;
|
|
1774
|
+
if (!username) return null;
|
|
1775
|
+
const account = this.findAuthAccount(username);
|
|
1776
|
+
// Disabled accounts and envelope violations are rejected by
|
|
1777
|
+
// resolveRecipientPolicy; this check only adds header alignment.
|
|
1778
|
+
if (!account || account.enabled === false || !account.scope) return null;
|
|
1779
|
+
let fromMailboxes: string[];
|
|
1780
|
+
try {
|
|
1781
|
+
fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
|
|
1782
|
+
} catch {
|
|
1783
|
+
fromMailboxes = [];
|
|
1784
|
+
}
|
|
1785
|
+
if (fromMailboxes.length === 0) {
|
|
1786
|
+
// No parsable From header: Email.from falls back to the envelope
|
|
1787
|
+
// MAIL FROM, which resolveRecipientPolicy has already scope-checked.
|
|
1788
|
+
fromMailboxes = [email.getFromAddress()];
|
|
1789
|
+
}
|
|
1790
|
+
const outOfScope = fromMailboxes.find(
|
|
1791
|
+
(mailbox) => !this.matchesScopePatterns(mailbox, account.scope!.senders),
|
|
1792
|
+
);
|
|
1793
|
+
if (outOfScope === undefined) {
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
return {
|
|
1797
|
+
accepted: false,
|
|
1798
|
+
smtpCode: 553,
|
|
1799
|
+
smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1543
1803
|
private getRecipientValidationTimeoutMs(): number {
|
|
1544
1804
|
return this.options.smtp?.recipientValidationTimeoutMs || 5000;
|
|
1545
1805
|
}
|
|
@@ -2323,6 +2583,29 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2323
2583
|
|
|
2324
2584
|
|
|
2325
2585
|
const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
|
|
2586
|
+
|
|
2587
|
+
// Header-From alignment for scoped authenticated accounts must be
|
|
2588
|
+
// enforced before any durable acceptance or routing: the RCPT phase
|
|
2589
|
+
// only ever saw the envelope MAIL FROM.
|
|
2590
|
+
const headerFromViolation = await this.checkAuthenticatedHeaderFromScope(
|
|
2591
|
+
email,
|
|
2592
|
+
rawMessageBuffer,
|
|
2593
|
+
session,
|
|
2594
|
+
);
|
|
2595
|
+
if (headerFromViolation) {
|
|
2596
|
+
logger.log(
|
|
2597
|
+
'warn',
|
|
2598
|
+
`Rejected DATA from authenticated user ${session.user?.username}: ${headerFromViolation.smtpMessage}`,
|
|
2599
|
+
);
|
|
2600
|
+
await this.rustBridge.sendEmailProcessingResult({
|
|
2601
|
+
correlationId,
|
|
2602
|
+
accepted: false,
|
|
2603
|
+
smtpCode: headerFromViolation.smtpCode || 553,
|
|
2604
|
+
smtpMessage: headerFromViolation.smtpMessage || 'Header From not permitted',
|
|
2605
|
+
});
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2326
2609
|
const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
|
|
2327
2610
|
const rejectedRecipients = resolvedRecipientRoutes.filter(
|
|
2328
2611
|
(resolution) => !resolution.decision.accepted,
|
|
@@ -2549,20 +2832,161 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2549
2832
|
}
|
|
2550
2833
|
|
|
2551
2834
|
/**
|
|
2552
|
-
*
|
|
2835
|
+
* Compute SCRAM-SHA-256 credential fields from a plaintext password with a
|
|
2836
|
+
* fresh random salt (used for legacy plaintext credentials only; verifier
|
|
2837
|
+
* accounts return their stored fields directly).
|
|
2838
|
+
*/
|
|
2839
|
+
private computeScramCredentials(password: string): {
|
|
2840
|
+
salt: string;
|
|
2841
|
+
iterations: number;
|
|
2842
|
+
storedKey: string;
|
|
2843
|
+
serverKey: string;
|
|
2844
|
+
} {
|
|
2845
|
+
const salt = plugins.crypto.randomBytes(16);
|
|
2846
|
+
const iterations = 4096;
|
|
2847
|
+
|
|
2848
|
+
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
|
|
2849
|
+
const saltedPassword = plugins.crypto.pbkdf2Sync(password, salt, iterations, 32, 'sha256');
|
|
2850
|
+
|
|
2851
|
+
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
|
|
2852
|
+
const clientKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
|
|
2853
|
+
|
|
2854
|
+
// StoredKey = SHA256(ClientKey)
|
|
2855
|
+
const storedKey = plugins.crypto.createHash('sha256').update(clientKey).digest();
|
|
2856
|
+
|
|
2857
|
+
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
|
|
2858
|
+
const serverKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
|
|
2859
|
+
|
|
2860
|
+
return {
|
|
2861
|
+
salt: salt.toString('base64'),
|
|
2862
|
+
iterations,
|
|
2863
|
+
storedKey: storedKey.toString('base64'),
|
|
2864
|
+
serverKey: serverKey.toString('base64'),
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
/**
|
|
2869
|
+
* Bound concurrent PBKDF2 derivations so credential-stuffing pressure at
|
|
2870
|
+
* high configured iteration counts cannot exhaust the shared libuv
|
|
2871
|
+
* threadpool. Returns false when the wait queue is saturated (fail fast).
|
|
2872
|
+
*/
|
|
2873
|
+
private async acquireVerifierDerivationSlot(): Promise<boolean> {
|
|
2874
|
+
if (this.verifierDerivationActive < UnifiedEmailServer.VERIFIER_DERIVATION_CONCURRENCY) {
|
|
2875
|
+
this.verifierDerivationActive++;
|
|
2876
|
+
return true;
|
|
2877
|
+
}
|
|
2878
|
+
if (this.verifierDerivationWaiters.length >= UnifiedEmailServer.VERIFIER_DERIVATION_MAX_QUEUE) {
|
|
2879
|
+
return false;
|
|
2880
|
+
}
|
|
2881
|
+
await new Promise<void>((resolve) => {
|
|
2882
|
+
this.verifierDerivationWaiters.push(resolve);
|
|
2883
|
+
});
|
|
2884
|
+
// Slot ownership was handed over directly by the releaser.
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
private releaseVerifierDerivationSlot(): void {
|
|
2889
|
+
const nextWaiter = this.verifierDerivationWaiters.shift();
|
|
2890
|
+
if (nextWaiter) {
|
|
2891
|
+
// Hand the active slot to the waiter without decrementing so the
|
|
2892
|
+
// concurrency bound is exact even across microtask gaps.
|
|
2893
|
+
nextWaiter();
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
this.verifierDerivationActive--;
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/**
|
|
2900
|
+
* Verify a presented plaintext password against a stored SCRAM verifier by
|
|
2901
|
+
* deriving StoredKey from the presented password and comparing in constant
|
|
2902
|
+
* time. PBKDF2 runs on the async crypto threadpool so caller-configured
|
|
2903
|
+
* iteration counts never block the event loop inside the Rust auth
|
|
2904
|
+
* callback window; iteration bounds are enforced at configuration time and
|
|
2905
|
+
* in-flight derivations are bounded by a small semaphore.
|
|
2906
|
+
*/
|
|
2907
|
+
private async verifyPasswordAgainstScramVerifier(
|
|
2908
|
+
password: string,
|
|
2909
|
+
verifier: ISmtpScramVerifier,
|
|
2910
|
+
): Promise<boolean> {
|
|
2911
|
+
const acquired = await this.acquireVerifierDerivationSlot();
|
|
2912
|
+
if (!acquired) {
|
|
2913
|
+
logger.log(
|
|
2914
|
+
'warn',
|
|
2915
|
+
'SCRAM verifier derivation queue is saturated; failing authentication fast',
|
|
2916
|
+
);
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
try {
|
|
2920
|
+
const salt = Buffer.from(verifier.salt, 'base64');
|
|
2921
|
+
const storedKey = Buffer.from(verifier.storedKey, 'base64');
|
|
2922
|
+
const saltedPassword = await new Promise<Buffer>((resolve, reject) => {
|
|
2923
|
+
plugins.crypto.pbkdf2(
|
|
2924
|
+
password,
|
|
2925
|
+
salt,
|
|
2926
|
+
verifier.iterations,
|
|
2927
|
+
32,
|
|
2928
|
+
'sha256',
|
|
2929
|
+
(error, derivedKey) => (error ? reject(error) : resolve(derivedKey)),
|
|
2930
|
+
);
|
|
2931
|
+
});
|
|
2932
|
+
const clientKey = plugins.crypto.createHmac('sha256', saltedPassword)
|
|
2933
|
+
.update('Client Key')
|
|
2934
|
+
.digest();
|
|
2935
|
+
const candidateStoredKey = plugins.crypto.createHash('sha256')
|
|
2936
|
+
.update(clientKey)
|
|
2937
|
+
.digest();
|
|
2938
|
+
if (candidateStoredKey.length !== storedKey.length) {
|
|
2939
|
+
return false;
|
|
2940
|
+
}
|
|
2941
|
+
return plugins.crypto.timingSafeEqual(candidateStoredKey, storedKey);
|
|
2942
|
+
} catch {
|
|
2943
|
+
return false;
|
|
2944
|
+
} finally {
|
|
2945
|
+
this.releaseVerifierDerivationSlot();
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
/** Constant-time string comparison for legacy plaintext credentials. */
|
|
2950
|
+
private timingSafeStringEqual(left: string, right: string): boolean {
|
|
2951
|
+
const leftBuffer = Buffer.from(left, 'utf8');
|
|
2952
|
+
const rightBuffer = Buffer.from(right, 'utf8');
|
|
2953
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
2954
|
+
// Burn comparable time so length mismatches are not a fast path.
|
|
2955
|
+
plugins.crypto.timingSafeEqual(leftBuffer, leftBuffer);
|
|
2956
|
+
return false;
|
|
2957
|
+
}
|
|
2958
|
+
return plugins.crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
/**
|
|
2962
|
+
* Handle an authRequest event from the Rust SMTP server (PLAIN/LOGIN).
|
|
2963
|
+
* Accounts win over legacy users on username collision; disabled accounts
|
|
2964
|
+
* always fail.
|
|
2553
2965
|
*/
|
|
2554
2966
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
|
2555
2967
|
const { correlationId, username, password, remoteAddr } = data;
|
|
2556
2968
|
|
|
2557
2969
|
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
|
2558
2970
|
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2971
|
+
const account = this.findAuthAccount(username);
|
|
2972
|
+
let success = false;
|
|
2973
|
+
if (account) {
|
|
2974
|
+
if (account.enabled === false) {
|
|
2975
|
+
success = false;
|
|
2976
|
+
} else if ('verifier' in account.credential) {
|
|
2977
|
+
success = await this.verifyPasswordAgainstScramVerifier(password, account.credential.verifier);
|
|
2978
|
+
} else {
|
|
2979
|
+
success = this.timingSafeStringEqual(account.credential.password, password);
|
|
2980
|
+
}
|
|
2981
|
+
} else {
|
|
2982
|
+
// Legacy users array keeps its existing behavior.
|
|
2983
|
+
const users = this.options.auth?.users || [];
|
|
2984
|
+
success = users.some(
|
|
2985
|
+
u => u.username === username && u.password === password
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2564
2988
|
|
|
2565
|
-
if (
|
|
2989
|
+
if (success) {
|
|
2566
2990
|
await this.rustBridge.sendAuthResult({
|
|
2567
2991
|
correlationId,
|
|
2568
2992
|
success: true,
|
|
@@ -2579,14 +3003,43 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2579
3003
|
|
|
2580
3004
|
/**
|
|
2581
3005
|
* Handle a SCRAM credential request from the Rust SMTP server.
|
|
2582
|
-
*
|
|
3006
|
+
* Verifier accounts return their stored fields directly; plaintext
|
|
3007
|
+
* credentials (accounts or legacy users) derive fields on the fly.
|
|
2583
3008
|
*/
|
|
2584
3009
|
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
|
2585
3010
|
const { correlationId, username, remoteAddr } = data;
|
|
2586
|
-
const crypto = await import('crypto');
|
|
2587
3011
|
|
|
2588
3012
|
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
|
|
2589
3013
|
|
|
3014
|
+
const account = this.findAuthAccount(username);
|
|
3015
|
+
if (account) {
|
|
3016
|
+
if (account.enabled === false) {
|
|
3017
|
+
await this.rustBridge.sendScramCredentialResult({
|
|
3018
|
+
correlationId,
|
|
3019
|
+
found: false,
|
|
3020
|
+
});
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if ('verifier' in account.credential) {
|
|
3024
|
+
const { salt, iterations, storedKey, serverKey } = account.credential.verifier;
|
|
3025
|
+
await this.rustBridge.sendScramCredentialResult({
|
|
3026
|
+
correlationId,
|
|
3027
|
+
found: true,
|
|
3028
|
+
salt,
|
|
3029
|
+
iterations,
|
|
3030
|
+
storedKey,
|
|
3031
|
+
serverKey,
|
|
3032
|
+
});
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
await this.rustBridge.sendScramCredentialResult({
|
|
3036
|
+
correlationId,
|
|
3037
|
+
found: true,
|
|
3038
|
+
...this.computeScramCredentials(account.credential.password),
|
|
3039
|
+
});
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
2590
3043
|
const users = this.options.auth?.users || [];
|
|
2591
3044
|
const matched = users.find(u => u.username === username);
|
|
2592
3045
|
|
|
@@ -2598,29 +3051,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2598
3051
|
return;
|
|
2599
3052
|
}
|
|
2600
3053
|
|
|
2601
|
-
// Compute SCRAM-SHA-256 credentials from plaintext password
|
|
2602
|
-
const salt = crypto.randomBytes(16);
|
|
2603
|
-
const iterations = 4096;
|
|
2604
|
-
|
|
2605
|
-
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
|
|
2606
|
-
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
|
|
2607
|
-
|
|
2608
|
-
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
|
|
2609
|
-
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
|
|
2610
|
-
|
|
2611
|
-
// StoredKey = SHA256(ClientKey)
|
|
2612
|
-
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
|
|
2613
|
-
|
|
2614
|
-
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
|
|
2615
|
-
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
|
|
2616
|
-
|
|
2617
3054
|
await this.rustBridge.sendScramCredentialResult({
|
|
2618
3055
|
correlationId,
|
|
2619
3056
|
found: true,
|
|
2620
|
-
|
|
2621
|
-
iterations,
|
|
2622
|
-
storedKey: storedKey.toString('base64'),
|
|
2623
|
-
serverKey: serverKey.toString('base64'),
|
|
3057
|
+
...this.computeScramCredentials(matched.password),
|
|
2624
3058
|
});
|
|
2625
3059
|
}
|
|
2626
3060
|
|
|
@@ -2879,9 +3313,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2879
3313
|
* Update server configuration
|
|
2880
3314
|
*/
|
|
2881
3315
|
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
|
3316
|
+
// Fail loudly before any option state is mutated.
|
|
3317
|
+
if (options.auth) {
|
|
3318
|
+
assertValidAuthAccounts(options.auth.accounts);
|
|
3319
|
+
}
|
|
2882
3320
|
const portsChanged = options.ports &&
|
|
2883
3321
|
(!this.options.ports ||
|
|
2884
3322
|
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
|
3323
|
+
const authEnabledBefore = this.isAuthConfigured(this.options);
|
|
2885
3324
|
|
|
2886
3325
|
if (portsChanged) {
|
|
2887
3326
|
this.stop().then(() => {
|
|
@@ -2912,6 +3351,26 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2912
3351
|
if (options.routes) {
|
|
2913
3352
|
this.emailRouter.updateRoutes(options.routes);
|
|
2914
3353
|
}
|
|
3354
|
+
|
|
3355
|
+
if (options.auth) {
|
|
3356
|
+
// Drop compiled patterns from replaced account configurations.
|
|
3357
|
+
this.scopePatternCache.clear();
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// AUTH availability is listener-level Rust configuration. When the
|
|
3361
|
+
// auth presence transitions (zero -> first account/user or reverse)
|
|
3362
|
+
// the running listener must be restarted for AUTH to take effect
|
|
3363
|
+
// without a full process restart.
|
|
3364
|
+
if (
|
|
3365
|
+
options.auth
|
|
3366
|
+
&& this.smtpListenerActive
|
|
3367
|
+
&& !this.smtpListenerShutdown
|
|
3368
|
+
&& this.isAuthConfigured(this.options) !== authEnabledBefore
|
|
3369
|
+
) {
|
|
3370
|
+
this.scheduleSmtpListenerRestart(
|
|
3371
|
+
`SMTP AUTH ${this.isAuthConfigured(this.options) ? 'enabled' : 'disabled'} by configuration update`,
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
2915
3374
|
}
|
|
2916
3375
|
}
|
|
2917
3376
|
|