@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
|
@@ -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;
|
|
@@ -147,8 +153,9 @@ export class AcceptEnvelopeRejectionError extends Error {
|
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
/**
|
|
150
|
-
* Terminal durable acceptance context for an envelope containing at least one
|
|
151
|
-
* The consumer owns persistence and all recipient
|
|
156
|
+
* Terminal durable acceptance context for an envelope containing at least one
|
|
157
|
+
* `store` or `deliver` route. The consumer owns persistence and all recipient
|
|
158
|
+
* fan-out before this hook resolves.
|
|
152
159
|
*/
|
|
153
160
|
export interface IAcceptEnvelopeContext {
|
|
154
161
|
email: Email;
|
|
@@ -294,6 +301,11 @@ export interface IUnifiedEmailServerOptions {
|
|
|
294
301
|
required?: boolean;
|
|
295
302
|
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
|
296
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[];
|
|
297
309
|
};
|
|
298
310
|
|
|
299
311
|
// TLS options
|
|
@@ -338,9 +350,14 @@ export interface IUnifiedEmailServerOptions {
|
|
|
338
350
|
hooks?: {
|
|
339
351
|
/** Async recipient policy hook that may further restrict an accepted built-in decision. */
|
|
340
352
|
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
341
|
-
/** Async message acceptance hook for envelopes with no resolved `store` action. */
|
|
353
|
+
/** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
|
|
342
354
|
onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
|
|
343
|
-
/**
|
|
355
|
+
/**
|
|
356
|
+
* Required terminal durable owner for any envelope containing a resolved
|
|
357
|
+
* `store` or `deliver` action. The hook must durably persist the exact
|
|
358
|
+
* raw message before resolving; its resolution is what authorizes the
|
|
359
|
+
* SMTP 250 for those envelopes.
|
|
360
|
+
*/
|
|
344
361
|
onAcceptEnvelope?: (context: IAcceptEnvelopeContext) => Promise<void>;
|
|
345
362
|
/** Observer-safe callback for SMTP recipient rejections. */
|
|
346
363
|
onRecipientRejected?: (event: IRecipientRejectedEvent) => Promise<void> | void;
|
|
@@ -456,6 +473,15 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
456
473
|
private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
|
|
457
474
|
private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
|
|
458
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();
|
|
459
485
|
|
|
460
486
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
461
487
|
super();
|
|
@@ -472,6 +498,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
472
498
|
socketTimeout: options.socketTimeout || 60000 // 1 minute
|
|
473
499
|
};
|
|
474
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
|
+
|
|
475
505
|
// Initialize Rust security bridge (singleton)
|
|
476
506
|
this.rustBridge = RustSecurityBridge.getInstance();
|
|
477
507
|
|
|
@@ -488,10 +518,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
488
518
|
// Initialize domain registry
|
|
489
519
|
this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
|
|
490
520
|
|
|
491
|
-
// Initialize email router with routes and storage manager
|
|
521
|
+
// Initialize email router with routes and storage manager. The action
|
|
522
|
+
// policy applies to configured, updated, and storage-hydrated routes so
|
|
523
|
+
// an unservable route set fails loudly at configuration time.
|
|
492
524
|
this.emailRouter = new EmailRouter(options.routes || [], {
|
|
493
525
|
storageManager: dcRouter.storageManager,
|
|
494
|
-
persistChanges: options.persistRoutes ?? true
|
|
526
|
+
persistChanges: options.persistRoutes ?? true,
|
|
527
|
+
validateAction: (route) => this.assertRouteActionServable(route),
|
|
495
528
|
});
|
|
496
529
|
|
|
497
530
|
// Initialize rate limiter
|
|
@@ -918,6 +951,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
918
951
|
*/
|
|
919
952
|
public async start(): Promise<void> {
|
|
920
953
|
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
|
954
|
+
this.smtpListenerShutdown = false;
|
|
921
955
|
|
|
922
956
|
try {
|
|
923
957
|
await this.startDeliveryPipeline();
|
|
@@ -1160,7 +1194,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1160
1194
|
maxRecipients: 100,
|
|
1161
1195
|
connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
|
|
1162
1196
|
dataTimeoutSecs: 60,
|
|
1163
|
-
authEnabled:
|
|
1197
|
+
authEnabled: this.isAuthConfigured(this.options),
|
|
1164
1198
|
maxAuthFailures: 3,
|
|
1165
1199
|
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
|
1166
1200
|
processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
|
|
@@ -1184,9 +1218,57 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1184
1218
|
throw new Error('Failed to start Rust SMTP server');
|
|
1185
1219
|
}
|
|
1186
1220
|
|
|
1221
|
+
this.smtpListenerActive = true;
|
|
1187
1222
|
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
|
1188
1223
|
}
|
|
1189
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
|
+
|
|
1190
1272
|
/**
|
|
1191
1273
|
* Stop the unified email server
|
|
1192
1274
|
*/
|
|
@@ -1199,6 +1281,18 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1199
1281
|
this.acceptedEnvelopeDispatchCleanupTimer = undefined;
|
|
1200
1282
|
}
|
|
1201
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
|
+
}
|
|
1202
1296
|
await this.stopDnsValidation();
|
|
1203
1297
|
|
|
1204
1298
|
// Stop the Rust SMTP server first
|
|
@@ -1208,6 +1302,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1208
1302
|
} catch (err) {
|
|
1209
1303
|
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
|
1210
1304
|
}
|
|
1305
|
+
this.smtpListenerActive = false;
|
|
1211
1306
|
|
|
1212
1307
|
// Clear the servers array - servers will be garbage collected
|
|
1213
1308
|
this.servers = [];
|
|
@@ -1325,6 +1420,30 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1325
1420
|
emailArg?: Email,
|
|
1326
1421
|
sessionArg?: IExtendedSmtpSession,
|
|
1327
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
|
+
|
|
1328
1447
|
const currentRecipientContext = { ...context, acceptedRcptTo: [] };
|
|
1329
1448
|
const email = emailArg || new Email({
|
|
1330
1449
|
from: context.mailFrom || '<>',
|
|
@@ -1354,6 +1473,37 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1354
1473
|
};
|
|
1355
1474
|
}
|
|
1356
1475
|
|
|
1476
|
+
if (route.action.type === 'deliver') {
|
|
1477
|
+
if (!isLocalRecipient) {
|
|
1478
|
+
// Deliver actions are terminal local deliveries and never relay;
|
|
1479
|
+
// `allowRelay` is ignored for them.
|
|
1480
|
+
return {
|
|
1481
|
+
recipient: context.rcptTo,
|
|
1482
|
+
localDomain: null,
|
|
1483
|
+
route,
|
|
1484
|
+
source: 'explicit-route',
|
|
1485
|
+
decision: { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' },
|
|
1486
|
+
rejectionReason: 'relay-denied',
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
if (!this.options.hooks?.onAcceptEnvelope) {
|
|
1490
|
+
// Defense in depth: route validation rejects this configuration,
|
|
1491
|
+
// so refuse loudly at accept time rather than accept-then-fail.
|
|
1492
|
+
return {
|
|
1493
|
+
recipient: context.rcptTo,
|
|
1494
|
+
localDomain,
|
|
1495
|
+
route,
|
|
1496
|
+
source: 'explicit-route',
|
|
1497
|
+
decision: {
|
|
1498
|
+
accepted: false,
|
|
1499
|
+
smtpCode: 451,
|
|
1500
|
+
smtpMessage: 'Local delivery temporarily unavailable',
|
|
1501
|
+
},
|
|
1502
|
+
rejectionReason: 'policy-error',
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1357
1507
|
if (!isLocalRecipient && !route.action.allowRelay) {
|
|
1358
1508
|
return {
|
|
1359
1509
|
recipient: context.rcptTo,
|
|
@@ -1500,6 +1650,156 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1500
1650
|
return match?.[1];
|
|
1501
1651
|
}
|
|
1502
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
|
+
|
|
1503
1803
|
private getRecipientValidationTimeoutMs(): number {
|
|
1504
1804
|
return this.options.smtp?.recipientValidationTimeoutMs || 5000;
|
|
1505
1805
|
}
|
|
@@ -2088,7 +2388,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2088
2388
|
}
|
|
2089
2389
|
const hasQueueAction = plan.some((entry) => (
|
|
2090
2390
|
entry.action.type === 'forward'
|
|
2091
|
-
|| entry.action.type === 'deliver'
|
|
2092
2391
|
|| entry.action.type === 'process'
|
|
2093
2392
|
));
|
|
2094
2393
|
if (hasQueueAction) {
|
|
@@ -2168,7 +2467,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2168
2467
|
|
|
2169
2468
|
try {
|
|
2170
2469
|
let result: IAcceptedEnvelopeRecipientDispatchResult;
|
|
2171
|
-
if (entry.action.type === 'store') {
|
|
2470
|
+
if (entry.action.type === 'store' || entry.action.type === 'deliver') {
|
|
2471
|
+
// Terminal local deliveries: the consumer's durably persisted copy
|
|
2472
|
+
// of the accepted envelope IS the delivery. Nothing is enqueued.
|
|
2473
|
+
if (entry.action.type === 'deliver' && entry.localDomain === null) {
|
|
2474
|
+
// Pre-9.x plans could contain relay-intended deliver entries.
|
|
2475
|
+
// Deliver never relays, so fail loudly instead of dropping.
|
|
2476
|
+
throw new Error(
|
|
2477
|
+
`Deliver action for ${entry.recipient} has no local domain; deliver actions never relay`,
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2172
2480
|
result = { ...commonResult, status: 'stored' };
|
|
2173
2481
|
} else {
|
|
2174
2482
|
parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
|
|
@@ -2275,6 +2583,29 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2275
2583
|
|
|
2276
2584
|
|
|
2277
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
|
+
|
|
2278
2609
|
const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
|
|
2279
2610
|
const rejectedRecipients = resolvedRecipientRoutes.filter(
|
|
2280
2611
|
(resolution) => !resolution.decision.accepted,
|
|
@@ -2298,10 +2629,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2298
2629
|
return;
|
|
2299
2630
|
}
|
|
2300
2631
|
|
|
2301
|
-
|
|
2302
|
-
|
|
2632
|
+
// `store` and `deliver` are both terminal local deliveries: the
|
|
2633
|
+
// envelope must be durably owned by the consumer before SMTP 250.
|
|
2634
|
+
const hasDurableLocalRoute = resolvedRecipientRoutes.some(
|
|
2635
|
+
(resolution) => resolution.route?.action.type === 'store'
|
|
2636
|
+
|| resolution.route?.action.type === 'deliver',
|
|
2303
2637
|
);
|
|
2304
|
-
if (
|
|
2638
|
+
if (hasDurableLocalRoute) {
|
|
2305
2639
|
try {
|
|
2306
2640
|
await this.acceptDurableEnvelope(
|
|
2307
2641
|
email,
|
|
@@ -2498,20 +2832,161 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2498
2832
|
}
|
|
2499
2833
|
|
|
2500
2834
|
/**
|
|
2501
|
-
*
|
|
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.
|
|
2502
2965
|
*/
|
|
2503
2966
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
|
2504
2967
|
const { correlationId, username, password, remoteAddr } = data;
|
|
2505
2968
|
|
|
2506
2969
|
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
|
2507
2970
|
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
+
}
|
|
2513
2988
|
|
|
2514
|
-
if (
|
|
2989
|
+
if (success) {
|
|
2515
2990
|
await this.rustBridge.sendAuthResult({
|
|
2516
2991
|
correlationId,
|
|
2517
2992
|
success: true,
|
|
@@ -2528,14 +3003,43 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2528
3003
|
|
|
2529
3004
|
/**
|
|
2530
3005
|
* Handle a SCRAM credential request from the Rust SMTP server.
|
|
2531
|
-
*
|
|
3006
|
+
* Verifier accounts return their stored fields directly; plaintext
|
|
3007
|
+
* credentials (accounts or legacy users) derive fields on the fly.
|
|
2532
3008
|
*/
|
|
2533
3009
|
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
|
2534
3010
|
const { correlationId, username, remoteAddr } = data;
|
|
2535
|
-
const crypto = await import('crypto');
|
|
2536
3011
|
|
|
2537
3012
|
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
|
|
2538
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
|
+
|
|
2539
3043
|
const users = this.options.auth?.users || [];
|
|
2540
3044
|
const matched = users.find(u => u.username === username);
|
|
2541
3045
|
|
|
@@ -2547,29 +3051,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2547
3051
|
return;
|
|
2548
3052
|
}
|
|
2549
3053
|
|
|
2550
|
-
// Compute SCRAM-SHA-256 credentials from plaintext password
|
|
2551
|
-
const salt = crypto.randomBytes(16);
|
|
2552
|
-
const iterations = 4096;
|
|
2553
|
-
|
|
2554
|
-
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
|
|
2555
|
-
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
|
|
2556
|
-
|
|
2557
|
-
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
|
|
2558
|
-
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
|
|
2559
|
-
|
|
2560
|
-
// StoredKey = SHA256(ClientKey)
|
|
2561
|
-
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
|
|
2562
|
-
|
|
2563
|
-
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
|
|
2564
|
-
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
|
|
2565
|
-
|
|
2566
3054
|
await this.rustBridge.sendScramCredentialResult({
|
|
2567
3055
|
correlationId,
|
|
2568
3056
|
found: true,
|
|
2569
|
-
|
|
2570
|
-
iterations,
|
|
2571
|
-
storedKey: storedKey.toString('base64'),
|
|
2572
|
-
serverKey: serverKey.toString('base64'),
|
|
3057
|
+
...this.computeScramCredentials(matched.password),
|
|
2573
3058
|
});
|
|
2574
3059
|
}
|
|
2575
3060
|
|
|
@@ -2807,13 +3292,35 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2807
3292
|
return routes;
|
|
2808
3293
|
}
|
|
2809
3294
|
|
|
3295
|
+
/**
|
|
3296
|
+
* Server-level action policy for a single route. A `deliver` action is a
|
|
3297
|
+
* terminal local delivery owned by the durable `hooks.onAcceptEnvelope`
|
|
3298
|
+
* acceptance path; without that hook the route could only accept-then-fail,
|
|
3299
|
+
* so it is rejected at configuration time instead.
|
|
3300
|
+
*/
|
|
3301
|
+
private assertRouteActionServable(route: IEmailRoute): void {
|
|
3302
|
+
if (
|
|
3303
|
+
route.action.type === 'deliver'
|
|
3304
|
+
&& !this.options.hooks?.onAcceptEnvelope
|
|
3305
|
+
) {
|
|
3306
|
+
throw new Error(
|
|
3307
|
+
`Route ${route.name}: action type 'deliver' requires a configured hooks.onAcceptEnvelope durable local-delivery owner`,
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
|
|
2810
3312
|
/**
|
|
2811
3313
|
* Update server configuration
|
|
2812
3314
|
*/
|
|
2813
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
|
+
}
|
|
2814
3320
|
const portsChanged = options.ports &&
|
|
2815
3321
|
(!this.options.ports ||
|
|
2816
3322
|
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
|
3323
|
+
const authEnabledBefore = this.isAuthConfigured(this.options);
|
|
2817
3324
|
|
|
2818
3325
|
if (portsChanged) {
|
|
2819
3326
|
this.stop().then(() => {
|
|
@@ -2821,7 +3328,21 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2821
3328
|
this.start();
|
|
2822
3329
|
});
|
|
2823
3330
|
} else {
|
|
2824
|
-
|
|
3331
|
+
if (options.routes) {
|
|
3332
|
+
// Validate synchronously against the incoming hook configuration
|
|
3333
|
+
// before any option state is mutated, so invalid routes throw here
|
|
3334
|
+
// instead of surfacing as an unhandled rejection.
|
|
3335
|
+
const previousOptions = this.options;
|
|
3336
|
+
this.options = { ...this.options, ...options };
|
|
3337
|
+
try {
|
|
3338
|
+
this.emailRouter.assertRoutesValid(options.routes);
|
|
3339
|
+
} catch (error) {
|
|
3340
|
+
this.options = previousOptions;
|
|
3341
|
+
throw error;
|
|
3342
|
+
}
|
|
3343
|
+
} else {
|
|
3344
|
+
this.options = { ...this.options, ...options };
|
|
3345
|
+
}
|
|
2825
3346
|
|
|
2826
3347
|
if (options.domains) {
|
|
2827
3348
|
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
|
|
@@ -2830,6 +3351,26 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2830
3351
|
if (options.routes) {
|
|
2831
3352
|
this.emailRouter.updateRoutes(options.routes);
|
|
2832
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
|
+
}
|
|
2833
3374
|
}
|
|
2834
3375
|
}
|
|
2835
3376
|
|
|
@@ -2837,6 +3378,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2837
3378
|
* Update email routes
|
|
2838
3379
|
*/
|
|
2839
3380
|
public updateEmailRoutes(routes: IEmailRoute[]): void {
|
|
3381
|
+
// Fail loudly and synchronously before mutating server options.
|
|
3382
|
+
this.emailRouter.assertRoutesValid(routes);
|
|
2840
3383
|
this.options.routes = routes;
|
|
2841
3384
|
this.emailRouter.updateRoutes(routes);
|
|
2842
3385
|
}
|