@push.rocks/smartmta 9.5.0 → 9.6.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 +27 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_amd64.tsrust-build.json +14 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64.tsrust-build.json +14 -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/routing/classes.unified.email.server.d.ts +140 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +371 -97
- package/dist_ts/mail/routing/interfaces.d.ts +41 -0
- package/package.json +6 -6
- package/readme.md +44 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/routing/classes.unified.email.server.ts +452 -108
- package/ts/mail/routing/interfaces.ts +43 -0
- package/readme.hints.md +0 -45
- package/readme.plan.md +0 -30
|
@@ -28,6 +28,9 @@ import type {
|
|
|
28
28
|
IEmailContext,
|
|
29
29
|
IEmailDomainConfig,
|
|
30
30
|
ISmtpAuthAccount,
|
|
31
|
+
ISmtpAuthAccountScope,
|
|
32
|
+
ISmtpAuthenticateContext,
|
|
33
|
+
ISmtpAuthenticateDecision,
|
|
31
34
|
ISmtpScramVerifier,
|
|
32
35
|
} from './interfaces.js';
|
|
33
36
|
import { Email } from '../core/classes.email.js';
|
|
@@ -84,7 +87,8 @@ export type TRecipientRejectionReason =
|
|
|
84
87
|
| 'policy-error'
|
|
85
88
|
| 'sender-scope'
|
|
86
89
|
| 'recipient-scope'
|
|
87
|
-
| 'account-disabled'
|
|
90
|
+
| 'account-disabled'
|
|
91
|
+
| 'auth-scope-expired';
|
|
88
92
|
|
|
89
93
|
export interface IResolvedRecipientRoute {
|
|
90
94
|
recipient: string;
|
|
@@ -398,6 +402,8 @@ export interface IUnifiedEmailServerOptions {
|
|
|
398
402
|
tlsTerminatedPorts?: number[];
|
|
399
403
|
/** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
|
|
400
404
|
recipientValidationTimeoutMs?: number;
|
|
405
|
+
/** Timeout for `hooks.onAuthenticate` callbacks. Defaults to 5000 ms. */
|
|
406
|
+
authenticationTimeoutMs?: number;
|
|
401
407
|
/** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
|
|
402
408
|
messageAcceptanceTimeoutMs?: number;
|
|
403
409
|
/** Maximum complete messages concurrently held across Rust and TypeScript. */
|
|
@@ -410,6 +416,24 @@ export interface IUnifiedEmailServerOptions {
|
|
|
410
416
|
};
|
|
411
417
|
|
|
412
418
|
hooks?: {
|
|
419
|
+
/**
|
|
420
|
+
* Async credential verification for PLAIN/LOGIN, letting an embedding
|
|
421
|
+
* application own dynamic SMTP credentials that no static configuration
|
|
422
|
+
* can express.
|
|
423
|
+
*
|
|
424
|
+
* Consulted only for usernames that `auth.accounts` and `auth.users` do
|
|
425
|
+
* not declare: configuration always owns the usernames it lists, so a
|
|
426
|
+
* hook can never override or weaken a configured credential. Configuring
|
|
427
|
+
* the hook enables SMTP AUTH on the listener even with no accounts.
|
|
428
|
+
*
|
|
429
|
+
* The returned `scope` is enforced for that session exactly as a
|
|
430
|
+
* configured account's scope is. A throw or a
|
|
431
|
+
* `smtp.authenticationTimeoutMs` timeout fails the attempt closed.
|
|
432
|
+
*
|
|
433
|
+
* SCRAM cannot be served from this hook — see `onAuthenticate` notes in
|
|
434
|
+
* the readme. Hook-owned usernames authenticate over PLAIN/LOGIN only.
|
|
435
|
+
*/
|
|
436
|
+
onAuthenticate?: (context: ISmtpAuthenticateContext) => Promise<ISmtpAuthenticateDecision>;
|
|
413
437
|
/** Async recipient policy hook that may further restrict an accepted built-in decision. */
|
|
414
438
|
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
415
439
|
/** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
|
|
@@ -548,6 +572,31 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
548
572
|
private verifierDerivationWaiters: Array<() => void> = [];
|
|
549
573
|
/** Compiled scope patterns; keys are config-supplied, bounded by config size. */
|
|
550
574
|
private scopePatternCache: Map<string, RegExp> = new Map();
|
|
575
|
+
/**
|
|
576
|
+
* Scopes granted by `hooks.onAuthenticate`, keyed by the session the
|
|
577
|
+
* credential authenticated. The SMTP listener reports no session-close
|
|
578
|
+
* event, so entries are bounded by both a TTL and a hard cap rather than
|
|
579
|
+
* by session lifetime.
|
|
580
|
+
*
|
|
581
|
+
* Losing an entry must never widen a session, so the absence of a grant is
|
|
582
|
+
* not read as "unrestricted": a session authenticated as a username that
|
|
583
|
+
* configuration does not declare can only have come from the hook, and is
|
|
584
|
+
* refused with a temporary failure until it authenticates again.
|
|
585
|
+
*/
|
|
586
|
+
private hookAuthenticatedSessions: Map<
|
|
587
|
+
string,
|
|
588
|
+
{ username: string; scope?: ISmtpAuthAccountScope; expiresAt: number }
|
|
589
|
+
> = new Map();
|
|
590
|
+
private static readonly hookAuthSessionTtlMs = 60 * 60 * 1000;
|
|
591
|
+
private static readonly hookAuthSessionMax = 4096;
|
|
592
|
+
private static readonly hookAuthSweepInterval = 256;
|
|
593
|
+
private hookAuthSweepCountdown = UnifiedEmailServer.hookAuthSweepInterval;
|
|
594
|
+
/** The Rust listener's fixed AUTH round-trip deadline. */
|
|
595
|
+
private static readonly listenerAuthDeadlineMs = 5000;
|
|
596
|
+
/** Leave headroom so a hook answering just in time still beats the listener. */
|
|
597
|
+
private static readonly authHookMaxTimeoutMs = 4500;
|
|
598
|
+
private static readonly authHookDefaultTimeoutMs = 4000;
|
|
599
|
+
private loggedAuthTimeoutClamp = false;
|
|
551
600
|
|
|
552
601
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
553
602
|
super();
|
|
@@ -568,113 +617,146 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
568
617
|
// never degrade into silent deny-all scopes or silent auth failures.
|
|
569
618
|
assertValidAuthAccounts(this.options.auth?.accounts);
|
|
570
619
|
|
|
571
|
-
//
|
|
572
|
-
|
|
620
|
+
// Subsystem construction below takes ownership of event-loop resources
|
|
621
|
+
// (the bounce manager and the rate limiter arm cleanup intervals in their
|
|
622
|
+
// constructors) while later steps still reject invalid configuration by
|
|
623
|
+
// throwing. A throwing constructor never returns an instance, so nothing
|
|
624
|
+
// can ever call stop() on it: construction is the only owner that can hand
|
|
625
|
+
// those resources back.
|
|
626
|
+
try {
|
|
627
|
+
// Initialize Rust security bridge (singleton)
|
|
628
|
+
this.rustBridge = RustSecurityBridge.getInstance();
|
|
573
629
|
|
|
574
|
-
|
|
575
|
-
|
|
630
|
+
// Initialize DKIM creator with storage manager
|
|
631
|
+
this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
|
|
576
632
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
633
|
+
// Initialize bounce manager with storage manager
|
|
634
|
+
this.bounceManager = new BounceManager({
|
|
635
|
+
maxCacheSize: 10000,
|
|
636
|
+
cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
637
|
+
storageManager: dcRouter.storageManager
|
|
638
|
+
});
|
|
583
639
|
|
|
584
|
-
|
|
585
|
-
|
|
640
|
+
// Initialize domain registry
|
|
641
|
+
this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
|
|
586
642
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
643
|
+
// Initialize email router with routes and storage manager. The action
|
|
644
|
+
// policy applies to configured, updated, and storage-hydrated routes so
|
|
645
|
+
// an unservable route set fails loudly at configuration time.
|
|
646
|
+
this.emailRouter = new EmailRouter(options.routes || [], {
|
|
647
|
+
storageManager: dcRouter.storageManager,
|
|
648
|
+
persistChanges: options.persistRoutes ?? true,
|
|
649
|
+
validateAction: (route) => this.assertRouteActionServable(route),
|
|
650
|
+
});
|
|
595
651
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
652
|
+
// Initialize rate limiter
|
|
653
|
+
this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || {
|
|
654
|
+
global: {
|
|
655
|
+
maxConnectionsPerIP: 10,
|
|
656
|
+
maxMessagesPerMinute: 100,
|
|
657
|
+
maxRecipientsPerMessage: 50,
|
|
658
|
+
maxErrorsPerIP: 10,
|
|
659
|
+
maxAuthFailuresPerIP: 5,
|
|
660
|
+
blockDuration: 300000 // 5 minutes
|
|
661
|
+
}
|
|
662
|
+
});
|
|
607
663
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
664
|
+
// Initialize delivery components
|
|
665
|
+
const queueOptions: IQueueOptions = {
|
|
666
|
+
maxRetries: 3,
|
|
667
|
+
baseRetryDelay: 300000, // 5 minutes
|
|
668
|
+
maxRetryDelay: 3600000, // 1 hour
|
|
669
|
+
...options.queue,
|
|
670
|
+
};
|
|
615
671
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
672
|
+
this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
|
|
673
|
+
|
|
674
|
+
const deliveryOptions: IMultiModeDeliveryOptions = {
|
|
675
|
+
globalRateLimit: 100, // Default to 100 emails per minute
|
|
676
|
+
concurrentDeliveries: 10,
|
|
677
|
+
processBounces: true,
|
|
678
|
+
bounceHandler: {
|
|
679
|
+
processSmtpFailure: async (recipient, smtpResponse, failureOptions) => {
|
|
680
|
+
const processed = await this.processSmtpFailure(
|
|
681
|
+
recipient,
|
|
682
|
+
smtpResponse,
|
|
683
|
+
failureOptions,
|
|
684
|
+
);
|
|
685
|
+
if (!processed) {
|
|
686
|
+
throw new Error(`SMTP bounce operation failed for ${recipient}`);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
632
689
|
},
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
}
|
|
637
|
-
};
|
|
690
|
+
onDeliverySuccess: async (_item, _result) => {
|
|
691
|
+
// Delivery success recorded via delivery system
|
|
692
|
+
}
|
|
693
|
+
};
|
|
638
694
|
|
|
639
|
-
|
|
695
|
+
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
|
640
696
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
697
|
+
// Initialize action executor
|
|
698
|
+
this.actionExecutor = new EmailActionExecutor({
|
|
699
|
+
sendOutboundEmail: this.sendOutboundEmail.bind(this),
|
|
700
|
+
bounceManager: this.bounceManager,
|
|
701
|
+
deliveryQueue: this.deliveryQueue,
|
|
702
|
+
});
|
|
647
703
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
704
|
+
// Initialize DKIM manager
|
|
705
|
+
this.dkimManager = new DkimManager(
|
|
706
|
+
this.dkimCreator,
|
|
707
|
+
this.domainRegistry,
|
|
708
|
+
dcRouter,
|
|
709
|
+
this.rustBridge,
|
|
710
|
+
options.dkimKeyProvisioning || 'automatic',
|
|
711
|
+
);
|
|
712
|
+
this.dnsManager = new DnsManager(this.dcRouter, undefined, options.dkimKeyProvisioning || 'automatic');
|
|
713
|
+
|
|
714
|
+
// Initialize statistics
|
|
715
|
+
this.stats = {
|
|
716
|
+
startTime: new Date(),
|
|
717
|
+
connections: {
|
|
718
|
+
current: 0,
|
|
719
|
+
total: 0
|
|
720
|
+
},
|
|
721
|
+
messages: {
|
|
722
|
+
processed: 0,
|
|
723
|
+
delivered: 0,
|
|
724
|
+
failed: 0
|
|
725
|
+
},
|
|
726
|
+
processingTime: {
|
|
727
|
+
avg: 0,
|
|
728
|
+
max: 0,
|
|
729
|
+
min: 0
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// We'll create the SMTP servers during the start() method
|
|
734
|
+
} catch (error) {
|
|
735
|
+
this.releaseConstructionOwnedResources();
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Hand back every event-loop resource a partially completed construction has
|
|
742
|
+
* already taken ownership of, so a rejected configuration leaves the process
|
|
743
|
+
* exactly as it found it.
|
|
744
|
+
*
|
|
745
|
+
* Only construction-time owners belong here: the delivery queue and the
|
|
746
|
+
* delivery system arm their timers in `initialize()` and `start()`, which a
|
|
747
|
+
* failed construction never reaches, and the Rust bridge is an unstarted
|
|
748
|
+
* singleton until `start()` spawns it.
|
|
749
|
+
*/
|
|
750
|
+
private releaseConstructionOwnedResources(): void {
|
|
751
|
+
const release = (nameArg: string, stepArg: () => void): void => {
|
|
752
|
+
try {
|
|
753
|
+
stepArg();
|
|
754
|
+
} catch (error) {
|
|
755
|
+
logger.log('warn', `${nameArg}: ${getErrorMessage(error)}`);
|
|
674
756
|
}
|
|
675
757
|
};
|
|
676
|
-
|
|
677
|
-
|
|
758
|
+
release('bounce-manager release after failed construction', () => this.bounceManager?.stop());
|
|
759
|
+
release('rate-limiter release after failed construction', () => this.rateLimiter?.stop());
|
|
678
760
|
}
|
|
679
761
|
|
|
680
762
|
private assertAcceptingOutboundWork(): void {
|
|
@@ -1604,11 +1686,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1604
1686
|
return resolved;
|
|
1605
1687
|
}
|
|
1606
1688
|
|
|
1607
|
-
/**
|
|
1689
|
+
/**
|
|
1690
|
+
* Whether the current options require SMTP AUTH support on the listener.
|
|
1691
|
+
* A configured `hooks.onAuthenticate` counts on its own: its whole purpose
|
|
1692
|
+
* is to own credentials that no static account list contains.
|
|
1693
|
+
*/
|
|
1608
1694
|
private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
|
|
1609
1695
|
return !!options.auth?.required
|
|
1610
1696
|
|| !!options.auth?.users?.length
|
|
1611
|
-
|| !!options.auth?.accounts?.length
|
|
1697
|
+
|| !!options.auth?.accounts?.length
|
|
1698
|
+
|| !!options.hooks?.onAuthenticate;
|
|
1612
1699
|
}
|
|
1613
1700
|
|
|
1614
1701
|
/**
|
|
@@ -1833,6 +1920,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1833
1920
|
context.authenticatedUser,
|
|
1834
1921
|
context.mailFrom,
|
|
1835
1922
|
context.rcptTo,
|
|
1923
|
+
context.sessionId,
|
|
1836
1924
|
);
|
|
1837
1925
|
if (scopeViolation) {
|
|
1838
1926
|
const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
|
|
@@ -2116,6 +2204,115 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2116
2204
|
);
|
|
2117
2205
|
}
|
|
2118
2206
|
|
|
2207
|
+
/** Whether the legacy `auth.users` array declares this username. */
|
|
2208
|
+
private hasLegacyAuthUser(username: string): boolean {
|
|
2209
|
+
return (this.options.auth?.users || []).some((user) => user.username === username);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* Whether configuration claims this username, for the purpose of deciding
|
|
2214
|
+
* that `hooks.onAuthenticate` must not see it.
|
|
2215
|
+
*
|
|
2216
|
+
* Deliberately case-insensitive, while credential verification itself stays
|
|
2217
|
+
* exact. Otherwise `Tenant-A` would slip past an operator's configured
|
|
2218
|
+
* `tenant-a` — including a `tenant-a` the operator had set `enabled: false`
|
|
2219
|
+
* — and reach a hook that may well resolve names case-insensitively.
|
|
2220
|
+
* Configuration owning a name means owning every casing of it: a casing that
|
|
2221
|
+
* matches no exact credential simply fails, and never reaches the hook.
|
|
2222
|
+
*/
|
|
2223
|
+
private declaresUsername(username: string): boolean {
|
|
2224
|
+
const normalized = username.toLowerCase();
|
|
2225
|
+
const accounts = this.options.auth?.accounts || [];
|
|
2226
|
+
if (accounts.some((account) => account.username.toLowerCase() === normalized)) return true;
|
|
2227
|
+
const users = this.options.auth?.users || [];
|
|
2228
|
+
return users.some((user) => user.username.toLowerCase() === normalized);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* Hook timeout, clamped below the Rust listener's own AUTH deadline.
|
|
2233
|
+
*
|
|
2234
|
+
* The listener waits a fixed 5 s for the TypeScript answer
|
|
2235
|
+
* (`rust/crates/mailer-smtp/src/connection.rs`), so a larger value here
|
|
2236
|
+
* could never take effect: the listener would already have failed the
|
|
2237
|
+
* attempt and reset the session while the hook was still running. Clamping
|
|
2238
|
+
* keeps the configured number meaningful instead of silently unreachable.
|
|
2239
|
+
*/
|
|
2240
|
+
private getAuthenticationTimeoutMs(): number {
|
|
2241
|
+
const configured = this.options.smtp?.authenticationTimeoutMs;
|
|
2242
|
+
if (!configured) return UnifiedEmailServer.authHookDefaultTimeoutMs;
|
|
2243
|
+
if (configured <= UnifiedEmailServer.authHookMaxTimeoutMs) return configured;
|
|
2244
|
+
if (!this.loggedAuthTimeoutClamp) {
|
|
2245
|
+
this.loggedAuthTimeoutClamp = true;
|
|
2246
|
+
logger.log(
|
|
2247
|
+
'warn',
|
|
2248
|
+
`smtp.authenticationTimeoutMs ${configured} exceeds the SMTP listener's `
|
|
2249
|
+
+ `${UnifiedEmailServer.listenerAuthDeadlineMs}ms AUTH deadline; `
|
|
2250
|
+
+ `clamping to ${UnifiedEmailServer.authHookMaxTimeoutMs}ms`,
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
return UnifiedEmailServer.authHookMaxTimeoutMs;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Enforce the hard cap oldest-first, and sweep expired grants periodically.
|
|
2258
|
+
*
|
|
2259
|
+
* The cap is what bounds memory, and it is O(1) amortised because Map
|
|
2260
|
+
* preserves insertion order. The TTL sweep is hygiene on top — expiry is
|
|
2261
|
+
* always enforced on read — so it runs every `hookAuthSweepInterval`
|
|
2262
|
+
* insertions rather than walking the whole table on every authentication.
|
|
2263
|
+
*/
|
|
2264
|
+
private pruneHookAuthSessions(): void {
|
|
2265
|
+
while (this.hookAuthenticatedSessions.size > UnifiedEmailServer.hookAuthSessionMax) {
|
|
2266
|
+
const oldest = this.hookAuthenticatedSessions.keys().next();
|
|
2267
|
+
if (oldest.done) break;
|
|
2268
|
+
this.hookAuthenticatedSessions.delete(oldest.value);
|
|
2269
|
+
}
|
|
2270
|
+
this.hookAuthSweepCountdown -= 1;
|
|
2271
|
+
if (this.hookAuthSweepCountdown > 0) return;
|
|
2272
|
+
this.hookAuthSweepCountdown = UnifiedEmailServer.hookAuthSweepInterval;
|
|
2273
|
+
const now = Date.now();
|
|
2274
|
+
for (const [sessionId, grant] of this.hookAuthenticatedSessions) {
|
|
2275
|
+
if (grant.expiresAt <= now) {
|
|
2276
|
+
this.hookAuthenticatedSessions.delete(sessionId);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** Record the scope a hook granted, replacing any earlier grant for the session. */
|
|
2282
|
+
private rememberHookAuthGrant(
|
|
2283
|
+
sessionId: string,
|
|
2284
|
+
username: string,
|
|
2285
|
+
scope: ISmtpAuthAccountScope | undefined,
|
|
2286
|
+
): void {
|
|
2287
|
+
this.hookAuthenticatedSessions.delete(sessionId);
|
|
2288
|
+
this.hookAuthenticatedSessions.set(sessionId, {
|
|
2289
|
+
username,
|
|
2290
|
+
scope,
|
|
2291
|
+
expiresAt: Date.now() + UnifiedEmailServer.hookAuthSessionTtlMs,
|
|
2292
|
+
});
|
|
2293
|
+
this.pruneHookAuthSessions();
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* Live hook grant for a session, or undefined when none is held. The
|
|
2298
|
+
* username must match the grant: a session may not present one username to
|
|
2299
|
+
* the listener and inherit another's scope.
|
|
2300
|
+
*/
|
|
2301
|
+
private findHookAuthGrant(
|
|
2302
|
+
sessionId: string | null | undefined,
|
|
2303
|
+
username: string,
|
|
2304
|
+
): { username: string; scope?: ISmtpAuthAccountScope } | undefined {
|
|
2305
|
+
if (!sessionId) return undefined;
|
|
2306
|
+
const grant = this.hookAuthenticatedSessions.get(sessionId);
|
|
2307
|
+
if (!grant) return undefined;
|
|
2308
|
+
if (grant.expiresAt <= Date.now()) {
|
|
2309
|
+
this.hookAuthenticatedSessions.delete(sessionId);
|
|
2310
|
+
return undefined;
|
|
2311
|
+
}
|
|
2312
|
+
if (grant.username !== username) return undefined;
|
|
2313
|
+
return grant;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2119
2316
|
private normalizeScopeAddress(addressArg: string | null | undefined): string {
|
|
2120
2317
|
return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
|
|
2121
2318
|
}
|
|
@@ -2144,16 +2341,25 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2144
2341
|
/**
|
|
2145
2342
|
* Envelope-level scope decision for an authenticated session. Returns null
|
|
2146
2343
|
* when the envelope is permitted (unauthenticated sessions, usernames with
|
|
2147
|
-
* no configured account
|
|
2344
|
+
* no configured account and no `hooks.onAuthenticate`, and accounts without
|
|
2345
|
+
* a scope are unrestricted).
|
|
2148
2346
|
*/
|
|
2149
2347
|
private resolveAuthAccountScopeDecision(
|
|
2150
2348
|
authenticatedUser: string | null,
|
|
2151
2349
|
mailFrom: string,
|
|
2152
2350
|
rcptTo: string,
|
|
2351
|
+
sessionId?: string | null,
|
|
2153
2352
|
): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
|
|
2154
2353
|
if (!authenticatedUser) return null;
|
|
2155
2354
|
const account = this.findAuthAccount(authenticatedUser);
|
|
2156
|
-
if (!account)
|
|
2355
|
+
if (!account) {
|
|
2356
|
+
return this.resolveHookAuthScopeDecision(
|
|
2357
|
+
authenticatedUser,
|
|
2358
|
+
mailFrom,
|
|
2359
|
+
rcptTo,
|
|
2360
|
+
sessionId,
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2157
2363
|
if (account.enabled === false) {
|
|
2158
2364
|
// The account was disabled after authentication succeeded; the session
|
|
2159
2365
|
// must not be able to send anything.
|
|
@@ -2166,7 +2372,53 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2166
2372
|
rejectionReason: 'account-disabled',
|
|
2167
2373
|
};
|
|
2168
2374
|
}
|
|
2169
|
-
|
|
2375
|
+
return this.applyScopeToEnvelope(account.scope, authenticatedUser, mailFrom, rcptTo);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/**
|
|
2379
|
+
* Envelope-level scope decision for a session authenticated by
|
|
2380
|
+
* `hooks.onAuthenticate`. Reached only for usernames no configured account
|
|
2381
|
+
* declares, so when a hook is configured the session must hold a live
|
|
2382
|
+
* grant; a missing grant is refused temporarily rather than treated as
|
|
2383
|
+
* unrestricted. With no hook configured the pre-existing behaviour stands:
|
|
2384
|
+
* legacy users and unknown usernames are unscoped.
|
|
2385
|
+
*/
|
|
2386
|
+
private resolveHookAuthScopeDecision(
|
|
2387
|
+
authenticatedUser: string,
|
|
2388
|
+
mailFrom: string,
|
|
2389
|
+
rcptTo: string,
|
|
2390
|
+
sessionId?: string | null,
|
|
2391
|
+
): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
|
|
2392
|
+
const resolved = this.resolveSessionSenderScope(authenticatedUser, sessionId);
|
|
2393
|
+
if (resolved.kind === 'unrestricted') return null;
|
|
2394
|
+
if (resolved.kind === 'refused') {
|
|
2395
|
+
return {
|
|
2396
|
+
decision: UnifiedEmailServer.authScopeUnavailableDecision(),
|
|
2397
|
+
rejectionReason: 'auth-scope-expired',
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
return this.applyScopeToEnvelope(resolved.scope, authenticatedUser, mailFrom, rcptTo);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/** Temporary refusal for a session whose hook grant is not available. */
|
|
2404
|
+
private static authScopeUnavailableDecision(): ISmtpDecision {
|
|
2405
|
+
return {
|
|
2406
|
+
accepted: false,
|
|
2407
|
+
smtpCode: 451,
|
|
2408
|
+
smtpMessage: '4.7.1 Authentication scope unavailable; authenticate again',
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Shared sender/recipient scope enforcement. An absent scope is
|
|
2414
|
+
* unrestricted, matching `ISmtpAuthAccount.scope`.
|
|
2415
|
+
*/
|
|
2416
|
+
private applyScopeToEnvelope(
|
|
2417
|
+
scope: ISmtpAuthAccountScope | undefined,
|
|
2418
|
+
authenticatedUser: string,
|
|
2419
|
+
mailFrom: string,
|
|
2420
|
+
rcptTo: string,
|
|
2421
|
+
): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
|
|
2170
2422
|
if (!scope) return null;
|
|
2171
2423
|
if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
|
|
2172
2424
|
return {
|
|
@@ -2194,6 +2446,34 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2194
2446
|
return null;
|
|
2195
2447
|
}
|
|
2196
2448
|
|
|
2449
|
+
/**
|
|
2450
|
+
* Sender scope in force for a session.
|
|
2451
|
+
*
|
|
2452
|
+
* The three outcomes are kept distinct on purpose. Collapsing `refused` into
|
|
2453
|
+
* "no scope" would make a session whose hook grant is gone look unrestricted
|
|
2454
|
+
* to any caller that only asks "is there a scope?", which is the opposite of
|
|
2455
|
+
* what a missing grant means.
|
|
2456
|
+
*/
|
|
2457
|
+
private resolveSessionSenderScope(
|
|
2458
|
+
username: string,
|
|
2459
|
+
sessionId?: string | null,
|
|
2460
|
+
):
|
|
2461
|
+
| { kind: 'unrestricted' }
|
|
2462
|
+
| { kind: 'scope'; scope: ISmtpAuthAccountScope }
|
|
2463
|
+
| { kind: 'refused' } {
|
|
2464
|
+
const account = this.findAuthAccount(username);
|
|
2465
|
+
if (account) {
|
|
2466
|
+
if (account.enabled === false || !account.scope) return { kind: 'unrestricted' };
|
|
2467
|
+
return { kind: 'scope', scope: account.scope };
|
|
2468
|
+
}
|
|
2469
|
+
if (!this.options.hooks?.onAuthenticate) return { kind: 'unrestricted' };
|
|
2470
|
+
// Legacy users and other configuration-owned names are unscoped by design.
|
|
2471
|
+
if (this.declaresUsername(username)) return { kind: 'unrestricted' };
|
|
2472
|
+
const grant = this.findHookAuthGrant(sessionId, username);
|
|
2473
|
+
if (!grant) return { kind: 'refused' };
|
|
2474
|
+
return grant.scope ? { kind: 'scope', scope: grant.scope } : { kind: 'unrestricted' };
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2197
2477
|
/**
|
|
2198
2478
|
* Extract every mailbox address from the parsed From header. The Email
|
|
2199
2479
|
* abstraction keeps only the first From mailbox, which is not sufficient
|
|
@@ -2227,10 +2507,15 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2227
2507
|
): Promise<ISmtpDecision | null> {
|
|
2228
2508
|
const username = session.user?.username;
|
|
2229
2509
|
if (!username) return null;
|
|
2230
|
-
const account = this.findAuthAccount(username);
|
|
2231
2510
|
// Disabled accounts and envelope violations are rejected by
|
|
2232
|
-
// resolveRecipientPolicy; this check
|
|
2233
|
-
|
|
2511
|
+
// resolveRecipientPolicy; this check adds header alignment, for configured
|
|
2512
|
+
// accounts and hook grants alike. A session whose hook grant is gone is
|
|
2513
|
+
// refused here in its own right rather than relying on the envelope check
|
|
2514
|
+
// having run first.
|
|
2515
|
+
const resolved = this.resolveSessionSenderScope(username, session.id);
|
|
2516
|
+
if (resolved.kind === 'unrestricted') return null;
|
|
2517
|
+
if (resolved.kind === 'refused') return UnifiedEmailServer.authScopeUnavailableDecision();
|
|
2518
|
+
const scope = resolved.scope;
|
|
2234
2519
|
let fromMailboxes: string[];
|
|
2235
2520
|
try {
|
|
2236
2521
|
fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
|
|
@@ -2243,7 +2528,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2243
2528
|
fromMailboxes = [email.getFromAddress()];
|
|
2244
2529
|
}
|
|
2245
2530
|
const outOfScope = fromMailboxes.find(
|
|
2246
|
-
(mailbox) => !this.matchesScopePatterns(mailbox,
|
|
2531
|
+
(mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
|
|
2247
2532
|
);
|
|
2248
2533
|
if (outOfScope === undefined) {
|
|
2249
2534
|
return null;
|
|
@@ -3419,12 +3704,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3419
3704
|
* always fail.
|
|
3420
3705
|
*/
|
|
3421
3706
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
|
3422
|
-
const { correlationId, username, password, remoteAddr } = data;
|
|
3707
|
+
const { correlationId, sessionId, username, password, remoteAddr } = data;
|
|
3423
3708
|
|
|
3424
3709
|
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
|
3425
3710
|
|
|
3426
3711
|
const account = this.findAuthAccount(username);
|
|
3427
3712
|
let success = false;
|
|
3713
|
+
let failureMessage: string | undefined;
|
|
3428
3714
|
if (account) {
|
|
3429
3715
|
if (account.enabled === false) {
|
|
3430
3716
|
success = false;
|
|
@@ -3433,12 +3719,27 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3433
3719
|
} else {
|
|
3434
3720
|
success = this.timingSafeStringEqual(account.credential.password, password);
|
|
3435
3721
|
}
|
|
3436
|
-
} else {
|
|
3722
|
+
} else if (this.hasLegacyAuthUser(username)) {
|
|
3437
3723
|
// Legacy users array keeps its existing behavior.
|
|
3438
3724
|
const users = this.options.auth?.users || [];
|
|
3439
3725
|
success = users.some(
|
|
3440
3726
|
u => u.username === username && u.password === password
|
|
3441
3727
|
);
|
|
3728
|
+
} else if (this.options.hooks?.onAuthenticate && !this.declaresUsername(username)) {
|
|
3729
|
+
// Only usernames configuration does not declare reach the hook. A casing
|
|
3730
|
+
// variant of a configured name is owned by configuration too, so it
|
|
3731
|
+
// falls through here and fails rather than reaching the hook.
|
|
3732
|
+
const outcome = await this.runHookAuthentication({
|
|
3733
|
+
username,
|
|
3734
|
+
password,
|
|
3735
|
+
sessionId,
|
|
3736
|
+
remoteAddress: remoteAddr,
|
|
3737
|
+
});
|
|
3738
|
+
success = outcome.accepted;
|
|
3739
|
+
failureMessage = outcome.message;
|
|
3740
|
+
if (outcome.accepted) {
|
|
3741
|
+
this.rememberHookAuthGrant(sessionId, username, outcome.scope);
|
|
3742
|
+
}
|
|
3442
3743
|
}
|
|
3443
3744
|
|
|
3444
3745
|
if (success) {
|
|
@@ -3447,19 +3748,62 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3447
3748
|
success: true,
|
|
3448
3749
|
});
|
|
3449
3750
|
} else {
|
|
3450
|
-
|
|
3751
|
+
// The listener answers every failed AUTH with the same 535, so a hook's
|
|
3752
|
+
// reason is recorded here rather than disclosed to the client.
|
|
3753
|
+
logger.log(
|
|
3754
|
+
'warn',
|
|
3755
|
+
`Auth failed for user=${username} from=${remoteAddr}`
|
|
3756
|
+
+ (failureMessage ? `: ${failureMessage}` : ''),
|
|
3757
|
+
);
|
|
3451
3758
|
await this.rustBridge.sendAuthResult({
|
|
3452
3759
|
correlationId,
|
|
3453
3760
|
success: false,
|
|
3454
|
-
message: 'Invalid credentials',
|
|
3761
|
+
message: failureMessage || 'Invalid credentials',
|
|
3455
3762
|
});
|
|
3456
3763
|
}
|
|
3457
3764
|
}
|
|
3458
3765
|
|
|
3766
|
+
/**
|
|
3767
|
+
* Run `hooks.onAuthenticate` under the configured timeout. Any throw, any
|
|
3768
|
+
* timeout and any non-object result fail the attempt closed — an embedder
|
|
3769
|
+
* that cannot answer must never widen access.
|
|
3770
|
+
*/
|
|
3771
|
+
private async runHookAuthentication(
|
|
3772
|
+
context: Omit<ISmtpAuthenticateContext, 'abortSignal'>,
|
|
3773
|
+
): Promise<ISmtpAuthenticateDecision> {
|
|
3774
|
+
try {
|
|
3775
|
+
const decision = await this.runHookWithTimeout(
|
|
3776
|
+
this.getAuthenticationTimeoutMs(),
|
|
3777
|
+
'Authentication hook timeout',
|
|
3778
|
+
(abortSignal) => this.options.hooks!.onAuthenticate!({ ...context, abortSignal }),
|
|
3779
|
+
);
|
|
3780
|
+
if (!decision || typeof decision !== 'object') {
|
|
3781
|
+
logger.log(
|
|
3782
|
+
'error',
|
|
3783
|
+
`Authentication hook returned no decision for user=${context.username}`,
|
|
3784
|
+
);
|
|
3785
|
+
return { accepted: false };
|
|
3786
|
+
}
|
|
3787
|
+
return decision;
|
|
3788
|
+
} catch (error) {
|
|
3789
|
+
logger.log(
|
|
3790
|
+
'error',
|
|
3791
|
+
`Authentication hook failed for user=${context.username}: ${getErrorMessage(error)}`,
|
|
3792
|
+
);
|
|
3793
|
+
return { accepted: false };
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3459
3797
|
/**
|
|
3460
3798
|
* Handle a SCRAM credential request from the Rust SMTP server.
|
|
3461
3799
|
* Verifier accounts return their stored fields directly; plaintext
|
|
3462
3800
|
* credentials (accounts or legacy users) derive fields on the fly.
|
|
3801
|
+
*
|
|
3802
|
+
* Usernames owned by `hooks.onAuthenticate` fall through to `found: false`:
|
|
3803
|
+
* SCRAM requires the server to hold the salt, iteration count, stored key
|
|
3804
|
+
* and server key, and a hook that verifies an opaque credential (argon2id,
|
|
3805
|
+
* a remote authority) cannot produce them. Those usernames authenticate
|
|
3806
|
+
* over PLAIN/LOGIN only, which is why the hook receives the password.
|
|
3463
3807
|
*/
|
|
3464
3808
|
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
|
3465
3809
|
const { correlationId, username, remoteAddr } = data;
|