@push.rocks/smartmta 9.4.1 → 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 +41 -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 +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +63 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
- package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
- package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +1 -1
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +180 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +592 -172
- package/dist_ts/mail/routing/interfaces.d.ts +41 -0
- package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
- package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
- package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
- package/package.json +6 -6
- package/readme.md +78 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
- package/ts/mail/delivery/classes.delivery.system.ts +192 -80
- package/ts/mail/delivery/functions.raw-email.ts +111 -0
- package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
- package/ts/mail/index.ts +1 -0
- package/ts/mail/routing/classes.unified.email.server.ts +780 -192
- package/ts/mail/routing/interfaces.ts +43 -0
- package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
- package/ts/security/classes.rustsecuritybridge.ts +6 -0
- package/readme.hints.md +0 -37
- 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';
|
|
@@ -38,6 +41,7 @@ import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rus
|
|
|
38
41
|
import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
|
39
42
|
import {
|
|
40
43
|
ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
|
|
44
|
+
DEFAULT_MAX_RAW_MESSAGE_BYTES,
|
|
41
45
|
UnifiedDeliveryQueue,
|
|
42
46
|
type IQueueItem,
|
|
43
47
|
type IQueueOptions,
|
|
@@ -49,6 +53,20 @@ import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../d
|
|
|
49
53
|
import { EmailActionExecutor } from './classes.email.action.executor.js';
|
|
50
54
|
import { DkimManager } from './classes.dkim.manager.js';
|
|
51
55
|
import { callObserverSafely, emitSafely } from '../delivery/functions.safe-observers.js';
|
|
56
|
+
import {
|
|
57
|
+
assertCanonicalRawRfc822Message,
|
|
58
|
+
normalizeRawSmtpEnvelope,
|
|
59
|
+
type IRawSmtpEnvelope,
|
|
60
|
+
} from '../delivery/functions.raw-email.js';
|
|
61
|
+
import {
|
|
62
|
+
kQueueCloseAdmission,
|
|
63
|
+
kQueueEnqueueRaw,
|
|
64
|
+
kQueueReserveRawAccess,
|
|
65
|
+
} from '../delivery/symbols.delivery.queue.js';
|
|
66
|
+
import {
|
|
67
|
+
kSendOutboundEmailInternal,
|
|
68
|
+
kSendRawOutboundEmailInternal,
|
|
69
|
+
} from './symbols.unified.email.server.js';
|
|
52
70
|
|
|
53
71
|
|
|
54
72
|
/** External DcRouter interface shape used by UnifiedEmailServer */
|
|
@@ -69,7 +87,8 @@ export type TRecipientRejectionReason =
|
|
|
69
87
|
| 'policy-error'
|
|
70
88
|
| 'sender-scope'
|
|
71
89
|
| 'recipient-scope'
|
|
72
|
-
| 'account-disabled'
|
|
90
|
+
| 'account-disabled'
|
|
91
|
+
| 'auth-scope-expired';
|
|
73
92
|
|
|
74
93
|
export interface IResolvedRecipientRoute {
|
|
75
94
|
recipient: string;
|
|
@@ -282,6 +301,30 @@ export interface IOutboundTopologyIdentity {
|
|
|
282
301
|
ipAddresses?: string[];
|
|
283
302
|
}
|
|
284
303
|
|
|
304
|
+
interface IOutboundTransportParameters {
|
|
305
|
+
host: string;
|
|
306
|
+
port: number;
|
|
307
|
+
connectHost?: string;
|
|
308
|
+
connectPort?: number;
|
|
309
|
+
secure: boolean;
|
|
310
|
+
domain: string;
|
|
311
|
+
auth?: { user: string; pass: string };
|
|
312
|
+
connectionTimeoutSecs: number;
|
|
313
|
+
socketTimeoutSecs: number;
|
|
314
|
+
poolKey: string;
|
|
315
|
+
maxPoolConnections: number;
|
|
316
|
+
tlsOpportunistic: boolean;
|
|
317
|
+
disablePooling?: boolean;
|
|
318
|
+
outboundAuthenticationCaptureId?: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface IOutboundDkimMaterial {
|
|
322
|
+
domain: string;
|
|
323
|
+
selector: string;
|
|
324
|
+
privateKey: string;
|
|
325
|
+
keyType: 'rsa' | 'ed25519';
|
|
326
|
+
}
|
|
327
|
+
|
|
285
328
|
export interface ISelfMxIdentityMatch {
|
|
286
329
|
isSelf: boolean;
|
|
287
330
|
source: 'explicit' | 'dns-derived' | 'dynamic' | null;
|
|
@@ -359,6 +402,8 @@ export interface IUnifiedEmailServerOptions {
|
|
|
359
402
|
tlsTerminatedPorts?: number[];
|
|
360
403
|
/** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
|
|
361
404
|
recipientValidationTimeoutMs?: number;
|
|
405
|
+
/** Timeout for `hooks.onAuthenticate` callbacks. Defaults to 5000 ms. */
|
|
406
|
+
authenticationTimeoutMs?: number;
|
|
362
407
|
/** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
|
|
363
408
|
messageAcceptanceTimeoutMs?: number;
|
|
364
409
|
/** Maximum complete messages concurrently held across Rust and TypeScript. */
|
|
@@ -371,6 +416,24 @@ export interface IUnifiedEmailServerOptions {
|
|
|
371
416
|
};
|
|
372
417
|
|
|
373
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>;
|
|
374
437
|
/** Async recipient policy hook that may further restrict an accepted built-in decision. */
|
|
375
438
|
onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
|
|
376
439
|
/** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
|
|
@@ -499,12 +562,41 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
499
562
|
private smtpListenerActive = false;
|
|
500
563
|
private smtpListenerShutdown = false;
|
|
501
564
|
private smtpListenerRestartPromise?: Promise<void>;
|
|
565
|
+
private acceptingOutboundWork = true;
|
|
566
|
+
private readonly outboundWorkFlights = new Set<Promise<unknown>>();
|
|
567
|
+
private readonly outboundAuthenticationFlights = new Set<Promise<IOutboundAuthenticationCheck>>();
|
|
568
|
+
private stopFlight?: Promise<void>;
|
|
502
569
|
private static readonly VERIFIER_DERIVATION_CONCURRENCY = 4;
|
|
503
570
|
private static readonly VERIFIER_DERIVATION_MAX_QUEUE = 32;
|
|
504
571
|
private verifierDerivationActive = 0;
|
|
505
572
|
private verifierDerivationWaiters: Array<() => void> = [];
|
|
506
573
|
/** Compiled scope patterns; keys are config-supplied, bounded by config size. */
|
|
507
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;
|
|
508
600
|
|
|
509
601
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
|
510
602
|
super();
|
|
@@ -525,113 +617,163 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
525
617
|
// never degrade into silent deny-all scopes or silent auth failures.
|
|
526
618
|
assertValidAuthAccounts(this.options.auth?.accounts);
|
|
527
619
|
|
|
528
|
-
//
|
|
529
|
-
|
|
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();
|
|
530
629
|
|
|
531
|
-
|
|
532
|
-
|
|
630
|
+
// Initialize DKIM creator with storage manager
|
|
631
|
+
this.dkimCreator = new DKIMCreator(dcRouter.storageManager);
|
|
533
632
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
});
|
|
540
639
|
|
|
541
|
-
|
|
542
|
-
|
|
640
|
+
// Initialize domain registry
|
|
641
|
+
this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
|
|
543
642
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
});
|
|
552
651
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
});
|
|
564
663
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
};
|
|
572
671
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
},
|
|
589
689
|
},
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
};
|
|
690
|
+
onDeliverySuccess: async (_item, _result) => {
|
|
691
|
+
// Delivery success recorded via delivery system
|
|
692
|
+
}
|
|
693
|
+
};
|
|
595
694
|
|
|
596
|
-
|
|
695
|
+
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
|
|
597
696
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
697
|
+
// Initialize action executor
|
|
698
|
+
this.actionExecutor = new EmailActionExecutor({
|
|
699
|
+
sendOutboundEmail: this.sendOutboundEmail.bind(this),
|
|
700
|
+
bounceManager: this.bounceManager,
|
|
701
|
+
deliveryQueue: this.deliveryQueue,
|
|
702
|
+
});
|
|
604
703
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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)}`);
|
|
631
756
|
}
|
|
632
757
|
};
|
|
758
|
+
release('bounce-manager release after failed construction', () => this.bounceManager?.stop());
|
|
759
|
+
release('rate-limiter release after failed construction', () => this.rateLimiter?.stop());
|
|
760
|
+
}
|
|
633
761
|
|
|
634
|
-
|
|
762
|
+
private assertAcceptingOutboundWork(): void {
|
|
763
|
+
if (!this.acceptingOutboundWork) {
|
|
764
|
+
throw new Error('UnifiedEmailServer is not accepting outbound work');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private trackOutboundWork<T>(operationArg: () => Promise<T>): Promise<T> {
|
|
769
|
+
let flight: Promise<T>;
|
|
770
|
+
flight = Promise.resolve()
|
|
771
|
+
.then(operationArg)
|
|
772
|
+
.finally(() => {
|
|
773
|
+
this.outboundWorkFlights.delete(flight);
|
|
774
|
+
});
|
|
775
|
+
this.outboundWorkFlights.add(flight);
|
|
776
|
+
return flight;
|
|
635
777
|
}
|
|
636
778
|
|
|
637
779
|
public onRecipientRejected(
|
|
@@ -890,32 +1032,30 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
890
1032
|
* Send an outbound email via the Rust SMTP client.
|
|
891
1033
|
* Uses connection pooling in the Rust binary for efficiency.
|
|
892
1034
|
*/
|
|
893
|
-
public
|
|
1035
|
+
public sendOutboundEmail(host: string, port: number, email: Email, options?: {
|
|
894
1036
|
auth?: { user: string; pass: string };
|
|
895
1037
|
dkimDomain?: string;
|
|
896
1038
|
dkimSelector?: string;
|
|
897
1039
|
tlsOpportunistic?: boolean;
|
|
898
1040
|
}): Promise<ISmtpSendResult> {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
};
|
|
918
|
-
}
|
|
1041
|
+
this.assertAcceptingOutboundWork();
|
|
1042
|
+
return this.trackOutboundWork(() => this[kSendOutboundEmailInternal](
|
|
1043
|
+
host,
|
|
1044
|
+
port,
|
|
1045
|
+
email,
|
|
1046
|
+
options,
|
|
1047
|
+
));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
public async [kSendOutboundEmailInternal](host: string, port: number, email: Email, options?: {
|
|
1051
|
+
auth?: { user: string; pass: string };
|
|
1052
|
+
dkimDomain?: string;
|
|
1053
|
+
dkimSelector?: string;
|
|
1054
|
+
tlsOpportunistic?: boolean;
|
|
1055
|
+
}): Promise<ISmtpSendResult> {
|
|
1056
|
+
const dkim = options?.dkimDomain
|
|
1057
|
+
? await this.resolveOutboundDkim(options.dkimDomain, options.dkimSelector || 'default')
|
|
1058
|
+
: undefined;
|
|
919
1059
|
|
|
920
1060
|
// Serialize the Email to the outbound format
|
|
921
1061
|
const outboundEmail: IOutboundEmail = {
|
|
@@ -935,18 +1075,170 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
935
1075
|
})),
|
|
936
1076
|
};
|
|
937
1077
|
|
|
938
|
-
const secure = port === 465;
|
|
939
|
-
const tlsOpportunistic = options?.tlsOpportunistic ?? (port === 25);
|
|
940
1078
|
const senderDomain = email.getFromDomain()?.toLowerCase() || undefined;
|
|
941
|
-
|
|
1079
|
+
return this.sendWithOutboundTransport({
|
|
942
1080
|
host,
|
|
943
1081
|
port,
|
|
1082
|
+
envelopeFrom: email.from,
|
|
1083
|
+
senderDomain,
|
|
1084
|
+
auth: options?.auth,
|
|
1085
|
+
tlsOpportunistic: options?.tlsOpportunistic,
|
|
1086
|
+
dkim,
|
|
1087
|
+
send: (transport) => this.rustBridge.sendOutboundEmail({
|
|
1088
|
+
...transport,
|
|
1089
|
+
email: outboundEmail,
|
|
1090
|
+
dkim,
|
|
1091
|
+
}),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Send exact RFC822 bytes through the same outbound transport path as structured mail. */
|
|
1096
|
+
public sendRawOutboundEmail(
|
|
1097
|
+
host: string,
|
|
1098
|
+
port: number,
|
|
1099
|
+
rawMessage: Buffer,
|
|
1100
|
+
envelopeArg: IRawSmtpEnvelope,
|
|
1101
|
+
options: {
|
|
1102
|
+
auth?: { user: string; pass: string };
|
|
1103
|
+
dkimDomain: string;
|
|
1104
|
+
dkimSelector: string;
|
|
1105
|
+
tlsOpportunistic?: boolean;
|
|
1106
|
+
},
|
|
1107
|
+
): Promise<ISmtpSendResult> {
|
|
1108
|
+
this.assertAcceptingOutboundWork();
|
|
1109
|
+
assertCanonicalRawRfc822Message(rawMessage);
|
|
1110
|
+
const maxRawMessageBytes = this.options.queue.maxRawMessageBytes
|
|
1111
|
+
?? DEFAULT_MAX_RAW_MESSAGE_BYTES;
|
|
1112
|
+
if (rawMessage.length > maxRawMessageBytes) {
|
|
1113
|
+
throw new Error(`Raw outbound message exceeds ${maxRawMessageBytes} bytes`);
|
|
1114
|
+
}
|
|
1115
|
+
const releaseRawReservation = this.deliveryQueue[kQueueReserveRawAccess](rawMessage.length);
|
|
1116
|
+
let exactRawMessage: Buffer;
|
|
1117
|
+
let envelope: IRawSmtpEnvelope;
|
|
1118
|
+
try {
|
|
1119
|
+
exactRawMessage = Buffer.from(rawMessage);
|
|
1120
|
+
assertCanonicalRawRfc822Message(exactRawMessage);
|
|
1121
|
+
envelope = normalizeRawSmtpEnvelope(envelopeArg);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
releaseRawReservation();
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
const exactOptions = {
|
|
1127
|
+
...options,
|
|
1128
|
+
...(options.auth ? { auth: { ...options.auth } } : {}),
|
|
1129
|
+
};
|
|
1130
|
+
return this.trackOutboundWork(async () => {
|
|
1131
|
+
try {
|
|
1132
|
+
return await this[kSendRawOutboundEmailInternal](
|
|
1133
|
+
host,
|
|
1134
|
+
port,
|
|
1135
|
+
exactRawMessage,
|
|
1136
|
+
envelope,
|
|
1137
|
+
exactOptions,
|
|
1138
|
+
);
|
|
1139
|
+
} finally {
|
|
1140
|
+
releaseRawReservation();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
public async [kSendRawOutboundEmailInternal](
|
|
1146
|
+
host: string,
|
|
1147
|
+
port: number,
|
|
1148
|
+
rawMessage: Buffer,
|
|
1149
|
+
envelopeArg: IRawSmtpEnvelope,
|
|
1150
|
+
options: {
|
|
1151
|
+
auth?: { user: string; pass: string };
|
|
1152
|
+
dkimDomain: string;
|
|
1153
|
+
dkimSelector: string;
|
|
1154
|
+
tlsOpportunistic?: boolean;
|
|
1155
|
+
},
|
|
1156
|
+
): Promise<ISmtpSendResult> {
|
|
1157
|
+
assertCanonicalRawRfc822Message(rawMessage);
|
|
1158
|
+
const maxRawMessageBytes = this.options.queue.maxRawMessageBytes
|
|
1159
|
+
?? DEFAULT_MAX_RAW_MESSAGE_BYTES;
|
|
1160
|
+
if (rawMessage.length > maxRawMessageBytes) {
|
|
1161
|
+
throw new Error(`Raw outbound message exceeds ${maxRawMessageBytes} bytes`);
|
|
1162
|
+
}
|
|
1163
|
+
const envelope = normalizeRawSmtpEnvelope(envelopeArg);
|
|
1164
|
+
const senderDomain = envelope.from.split('@')[1];
|
|
1165
|
+
const dkimDomain = options.dkimDomain.toLowerCase();
|
|
1166
|
+
if (dkimDomain !== senderDomain) {
|
|
1167
|
+
throw new SmtpDeliveryError({
|
|
1168
|
+
message: `Raw outbound DKIM domain ${dkimDomain} does not match envelope sender domain ${senderDomain}`,
|
|
1169
|
+
errorType: 'dkim',
|
|
1170
|
+
retryable: false,
|
|
1171
|
+
phase: 'message_body',
|
|
1172
|
+
transactionLog: [],
|
|
1173
|
+
recipients: envelope.to,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const dkim = await this.resolveOutboundDkim(dkimDomain, options.dkimSelector);
|
|
1177
|
+
return this.sendWithOutboundTransport({
|
|
1178
|
+
host,
|
|
1179
|
+
port,
|
|
1180
|
+
envelopeFrom: envelope.from,
|
|
1181
|
+
senderDomain,
|
|
1182
|
+
auth: options.auth,
|
|
1183
|
+
tlsOpportunistic: options.tlsOpportunistic,
|
|
1184
|
+
dkim,
|
|
1185
|
+
send: (transport) => this.rustBridge.sendRawEmail({
|
|
1186
|
+
...transport,
|
|
1187
|
+
envelopeFrom: envelope.from,
|
|
1188
|
+
envelopeTo: [...envelope.to],
|
|
1189
|
+
rawMessageBase64: rawMessage.toString('base64'),
|
|
1190
|
+
dkim,
|
|
1191
|
+
}),
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private async resolveOutboundDkim(
|
|
1196
|
+
domainArg: string,
|
|
1197
|
+
selectorArg: string,
|
|
1198
|
+
): Promise<IOutboundDkimMaterial> {
|
|
1199
|
+
try {
|
|
1200
|
+
const material = await this.dkimCreator.readValidatedDKIMKeysForSelector(
|
|
1201
|
+
domainArg,
|
|
1202
|
+
selectorArg,
|
|
1203
|
+
);
|
|
1204
|
+
return {
|
|
1205
|
+
domain: domainArg,
|
|
1206
|
+
selector: selectorArg,
|
|
1207
|
+
privateKey: material.privateKey,
|
|
1208
|
+
keyType: material.keyType,
|
|
1209
|
+
};
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
throw new SmtpDeliveryError({
|
|
1212
|
+
message: getErrorMessage(error),
|
|
1213
|
+
errorType: 'dkim',
|
|
1214
|
+
retryable: false,
|
|
1215
|
+
phase: 'message_body',
|
|
1216
|
+
transactionLog: [],
|
|
1217
|
+
recipients: [],
|
|
1218
|
+
}, error);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private async sendWithOutboundTransport(optionsArg: {
|
|
1223
|
+
host: string;
|
|
1224
|
+
port: number;
|
|
1225
|
+
envelopeFrom: string;
|
|
1226
|
+
senderDomain?: string;
|
|
1227
|
+
auth?: { user: string; pass: string };
|
|
1228
|
+
tlsOpportunistic?: boolean;
|
|
1229
|
+
dkim?: IOutboundDkimMaterial;
|
|
1230
|
+
send: (transport: IOutboundTransportParameters) => Promise<ISmtpSendResult>;
|
|
1231
|
+
}): Promise<ISmtpSendResult> {
|
|
1232
|
+
const secure = optionsArg.port === 465;
|
|
1233
|
+
const tlsOpportunistic = optionsArg.tlsOpportunistic ?? (optionsArg.port === 25);
|
|
1234
|
+
const proxy = await this.options.outbound?.connectionProxyProvider?.({
|
|
1235
|
+
host: optionsArg.host,
|
|
1236
|
+
port: optionsArg.port,
|
|
944
1237
|
secure,
|
|
945
1238
|
ehloDomain: this.getOutboundHostname(),
|
|
946
1239
|
tlsOpportunistic,
|
|
947
|
-
senderDomain,
|
|
1240
|
+
senderDomain: optionsArg.senderDomain,
|
|
948
1241
|
});
|
|
949
|
-
|
|
950
1242
|
const captureId = proxy?.sourceEvidence ? plugins.uuid.v4() : undefined;
|
|
951
1243
|
const attachAuthenticationHandle = <T extends ISmtpSendResult | SmtpDeliveryError>(valueArg: T): T => {
|
|
952
1244
|
if (!captureId || !proxy?.sourceEvidence) return valueArg;
|
|
@@ -954,10 +1246,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
954
1246
|
captureId,
|
|
955
1247
|
sourceEvidence: proxy.sourceEvidence,
|
|
956
1248
|
heloDomain: proxy.ehloHostname || this.getOutboundHostname(),
|
|
957
|
-
receiverHostname: host,
|
|
958
|
-
envelopeFrom:
|
|
959
|
-
dkimDomain: dkim?.domain,
|
|
960
|
-
dkimSelector: dkim?.selector,
|
|
1249
|
+
receiverHostname: optionsArg.host,
|
|
1250
|
+
envelopeFrom: optionsArg.envelopeFrom,
|
|
1251
|
+
dkimDomain: optionsArg.dkim?.domain,
|
|
1252
|
+
dkimSelector: optionsArg.dkim?.selector,
|
|
961
1253
|
});
|
|
962
1254
|
Object.defineProperty(valueArg, 'outboundAuthenticationHandle', {
|
|
963
1255
|
value: handle,
|
|
@@ -968,18 +1260,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
968
1260
|
};
|
|
969
1261
|
|
|
970
1262
|
try {
|
|
971
|
-
const result = await
|
|
972
|
-
host,
|
|
973
|
-
port,
|
|
1263
|
+
const result = await optionsArg.send({
|
|
1264
|
+
host: optionsArg.host,
|
|
1265
|
+
port: optionsArg.port,
|
|
974
1266
|
...(proxy ? { connectHost: proxy.connectHost, connectPort: proxy.connectPort } : {}),
|
|
975
1267
|
secure,
|
|
976
1268
|
domain: proxy?.ehloHostname || this.getOutboundHostname(),
|
|
977
|
-
auth:
|
|
978
|
-
email: outboundEmail,
|
|
979
|
-
dkim,
|
|
1269
|
+
auth: optionsArg.auth,
|
|
980
1270
|
connectionTimeoutSecs: Math.floor((this.options.outbound?.connectionTimeout || 30000) / 1000),
|
|
981
1271
|
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
|
|
982
|
-
poolKey: proxy?.poolKey || `${host}:${port}`,
|
|
1272
|
+
poolKey: proxy?.poolKey || `${optionsArg.host}:${optionsArg.port}`,
|
|
983
1273
|
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
|
984
1274
|
tlsOpportunistic,
|
|
985
1275
|
...(proxy ? { disablePooling: true } : {}),
|
|
@@ -1015,7 +1305,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1015
1305
|
checkedAt: new Date().toISOString(),
|
|
1016
1306
|
error: errorArg,
|
|
1017
1307
|
});
|
|
1018
|
-
|
|
1308
|
+
let result: Promise<IOutboundAuthenticationCheck>;
|
|
1309
|
+
result = (async (): Promise<IOutboundAuthenticationCheck> => {
|
|
1019
1310
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1020
1311
|
try {
|
|
1021
1312
|
const sourceEvidence = await new Promise<IOutboundConnectionSourceEvidence | null>((resolve, reject) => {
|
|
@@ -1071,7 +1362,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1071
1362
|
} finally {
|
|
1072
1363
|
if (timeout) clearTimeout(timeout);
|
|
1073
1364
|
}
|
|
1074
|
-
})()
|
|
1365
|
+
})().finally(() => {
|
|
1366
|
+
this.outboundAuthenticationFlights.delete(result);
|
|
1367
|
+
});
|
|
1368
|
+
this.outboundAuthenticationFlights.add(result);
|
|
1075
1369
|
return { pending, result };
|
|
1076
1370
|
}
|
|
1077
1371
|
|
|
@@ -1392,11 +1686,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1392
1686
|
return resolved;
|
|
1393
1687
|
}
|
|
1394
1688
|
|
|
1395
|
-
/**
|
|
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
|
+
*/
|
|
1396
1694
|
private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
|
|
1397
1695
|
return !!options.auth?.required
|
|
1398
1696
|
|| !!options.auth?.users?.length
|
|
1399
|
-
|| !!options.auth?.accounts?.length
|
|
1697
|
+
|| !!options.auth?.accounts?.length
|
|
1698
|
+
|| !!options.hooks?.onAuthenticate;
|
|
1400
1699
|
}
|
|
1401
1700
|
|
|
1402
1701
|
/**
|
|
@@ -1442,18 +1741,46 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1442
1741
|
/**
|
|
1443
1742
|
* Stop the unified email server
|
|
1444
1743
|
*/
|
|
1445
|
-
public
|
|
1744
|
+
public stop(): Promise<void> {
|
|
1745
|
+
if (this.stopFlight) return this.stopFlight;
|
|
1446
1746
|
logger.log('info', 'Stopping UnifiedEmailServer');
|
|
1747
|
+
this.acceptingOutboundWork = false;
|
|
1748
|
+
this.deliveryQueue[kQueueCloseAdmission]();
|
|
1749
|
+
this.stopFlight = this.stopOnce();
|
|
1750
|
+
return this.stopFlight;
|
|
1751
|
+
}
|
|
1447
1752
|
|
|
1448
|
-
|
|
1753
|
+
private async stopOnce(): Promise<void> {
|
|
1754
|
+
const errors: Error[] = [];
|
|
1755
|
+
const runStep = async (nameArg: string, stepArg: () => Promise<void> | void): Promise<void> => {
|
|
1756
|
+
try {
|
|
1757
|
+
await stepArg();
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
const wrapped = new Error(`${nameArg}: ${getErrorMessage(error)}`, { cause: error });
|
|
1760
|
+
errors.push(wrapped);
|
|
1761
|
+
logger.log('error', wrapped.message);
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
const runBestEffortStep = async (
|
|
1765
|
+
nameArg: string,
|
|
1766
|
+
stepArg: () => Promise<void> | void,
|
|
1767
|
+
): Promise<void> => {
|
|
1768
|
+
try {
|
|
1769
|
+
await stepArg();
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
logger.log('warn', `${nameArg}: ${getErrorMessage(error)}`);
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
await runStep('accepted-envelope cleanup drain failed', async () => {
|
|
1449
1776
|
if (this.acceptedEnvelopeDispatchCleanupTimer) {
|
|
1450
1777
|
clearInterval(this.acceptedEnvelopeDispatchCleanupTimer);
|
|
1451
1778
|
this.acceptedEnvelopeDispatchCleanupTimer = undefined;
|
|
1452
1779
|
}
|
|
1453
1780
|
await this.acceptedEnvelopeDispatchCleanupPromise;
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
await runStep('SMTP listener reconfiguration drain failed', async () => {
|
|
1457
1784
|
this.smtpListenerShutdown = true;
|
|
1458
1785
|
this.smtpListenerActive = false;
|
|
1459
1786
|
while (this.smtpListenerRestartPromise) {
|
|
@@ -1463,55 +1790,49 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1463
1790
|
this.smtpListenerRestartPromise = undefined;
|
|
1464
1791
|
}
|
|
1465
1792
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
// Stop the Rust SMTP server first
|
|
1469
|
-
try {
|
|
1470
|
-
await this.rustBridge.stopSmtpServer();
|
|
1471
|
-
logger.log('info', 'Rust SMTP server stopped');
|
|
1472
|
-
} catch (err) {
|
|
1473
|
-
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
|
1474
|
-
}
|
|
1475
|
-
this.smtpListenerActive = false;
|
|
1476
|
-
|
|
1477
|
-
// Clear the servers array - servers will be garbage collected
|
|
1478
|
-
this.servers = [];
|
|
1793
|
+
});
|
|
1794
|
+
await runStep('DNS validation shutdown failed', () => this.stopDnsValidation());
|
|
1479
1795
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1796
|
+
await runBestEffortStep(
|
|
1797
|
+
'Rust SMTP listener shutdown skipped or failed',
|
|
1798
|
+
() => this.rustBridge.stopSmtpServer(),
|
|
1799
|
+
);
|
|
1800
|
+
this.smtpListenerActive = false;
|
|
1801
|
+
this.servers = [];
|
|
1483
1802
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
logger.log('info', 'Email delivery system stopped');
|
|
1488
|
-
}
|
|
1803
|
+
await runStep('delivery-system drain failed', async () => {
|
|
1804
|
+
await this.deliverySystem?.stop();
|
|
1805
|
+
});
|
|
1489
1806
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1807
|
+
while (this.outboundWorkFlights.size > 0) {
|
|
1808
|
+
await Promise.allSettled([...this.outboundWorkFlights]);
|
|
1809
|
+
}
|
|
1810
|
+
while (this.outboundAuthenticationFlights.size > 0) {
|
|
1811
|
+
await Promise.allSettled([...this.outboundAuthenticationFlights]);
|
|
1812
|
+
}
|
|
1495
1813
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1814
|
+
await runStep('delivery-queue shutdown failed', async () => {
|
|
1815
|
+
await this.deliveryQueue?.shutdown();
|
|
1816
|
+
});
|
|
1498
1817
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1818
|
+
await runStep('bounce-manager shutdown failed', () => this.bounceManager.stop());
|
|
1819
|
+
await runStep('rate-limiter shutdown failed', () => this.rateLimiter.stop());
|
|
1820
|
+
await runBestEffortStep(
|
|
1821
|
+
'SMTP pool shutdown skipped or failed',
|
|
1822
|
+
() => this.rustBridge.closeSmtpPool(),
|
|
1823
|
+
);
|
|
1501
1824
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
}
|
|
1825
|
+
await runStep('Rust bridge shutdown failed', async () => {
|
|
1826
|
+
this.unregisterBridgeEventHandlers();
|
|
1827
|
+
this.unregisterBridgeStateHandler();
|
|
1828
|
+
await this.rustBridge.stop();
|
|
1829
|
+
});
|
|
1508
1830
|
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
logger.log('error', `Error stopping UnifiedEmailServer: ${getErrorMessage(error)}`);
|
|
1513
|
-
throw error;
|
|
1831
|
+
await emitSafely(this, 'stopped');
|
|
1832
|
+
if (errors.length > 0) {
|
|
1833
|
+
throw new AggregateError(errors, 'UnifiedEmailServer shutdown completed with errors');
|
|
1514
1834
|
}
|
|
1835
|
+
logger.log('info', 'UnifiedEmailServer stopped successfully');
|
|
1515
1836
|
}
|
|
1516
1837
|
|
|
1517
1838
|
// -----------------------------------------------------------------------
|
|
@@ -1599,6 +1920,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1599
1920
|
context.authenticatedUser,
|
|
1600
1921
|
context.mailFrom,
|
|
1601
1922
|
context.rcptTo,
|
|
1923
|
+
context.sessionId,
|
|
1602
1924
|
);
|
|
1603
1925
|
if (scopeViolation) {
|
|
1604
1926
|
const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
|
|
@@ -1882,6 +2204,115 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1882
2204
|
);
|
|
1883
2205
|
}
|
|
1884
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
|
+
|
|
1885
2316
|
private normalizeScopeAddress(addressArg: string | null | undefined): string {
|
|
1886
2317
|
return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
|
|
1887
2318
|
}
|
|
@@ -1910,16 +2341,25 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1910
2341
|
/**
|
|
1911
2342
|
* Envelope-level scope decision for an authenticated session. Returns null
|
|
1912
2343
|
* when the envelope is permitted (unauthenticated sessions, usernames with
|
|
1913
|
-
* no configured account
|
|
2344
|
+
* no configured account and no `hooks.onAuthenticate`, and accounts without
|
|
2345
|
+
* a scope are unrestricted).
|
|
1914
2346
|
*/
|
|
1915
2347
|
private resolveAuthAccountScopeDecision(
|
|
1916
2348
|
authenticatedUser: string | null,
|
|
1917
2349
|
mailFrom: string,
|
|
1918
2350
|
rcptTo: string,
|
|
2351
|
+
sessionId?: string | null,
|
|
1919
2352
|
): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
|
|
1920
2353
|
if (!authenticatedUser) return null;
|
|
1921
2354
|
const account = this.findAuthAccount(authenticatedUser);
|
|
1922
|
-
if (!account)
|
|
2355
|
+
if (!account) {
|
|
2356
|
+
return this.resolveHookAuthScopeDecision(
|
|
2357
|
+
authenticatedUser,
|
|
2358
|
+
mailFrom,
|
|
2359
|
+
rcptTo,
|
|
2360
|
+
sessionId,
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
1923
2363
|
if (account.enabled === false) {
|
|
1924
2364
|
// The account was disabled after authentication succeeded; the session
|
|
1925
2365
|
// must not be able to send anything.
|
|
@@ -1932,7 +2372,53 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1932
2372
|
rejectionReason: 'account-disabled',
|
|
1933
2373
|
};
|
|
1934
2374
|
}
|
|
1935
|
-
|
|
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 {
|
|
1936
2422
|
if (!scope) return null;
|
|
1937
2423
|
if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
|
|
1938
2424
|
return {
|
|
@@ -1960,6 +2446,34 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1960
2446
|
return null;
|
|
1961
2447
|
}
|
|
1962
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
|
+
|
|
1963
2477
|
/**
|
|
1964
2478
|
* Extract every mailbox address from the parsed From header. The Email
|
|
1965
2479
|
* abstraction keeps only the first From mailbox, which is not sufficient
|
|
@@ -1993,10 +2507,15 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
1993
2507
|
): Promise<ISmtpDecision | null> {
|
|
1994
2508
|
const username = session.user?.username;
|
|
1995
2509
|
if (!username) return null;
|
|
1996
|
-
const account = this.findAuthAccount(username);
|
|
1997
2510
|
// Disabled accounts and envelope violations are rejected by
|
|
1998
|
-
// resolveRecipientPolicy; this check
|
|
1999
|
-
|
|
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;
|
|
2000
2519
|
let fromMailboxes: string[];
|
|
2001
2520
|
try {
|
|
2002
2521
|
fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
|
|
@@ -2009,7 +2528,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2009
2528
|
fromMailboxes = [email.getFromAddress()];
|
|
2010
2529
|
}
|
|
2011
2530
|
const outOfScope = fromMailboxes.find(
|
|
2012
|
-
(mailbox) => !this.matchesScopePatterns(mailbox,
|
|
2531
|
+
(mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
|
|
2013
2532
|
);
|
|
2014
2533
|
if (outOfScope === undefined) {
|
|
2015
2534
|
return null;
|
|
@@ -3185,12 +3704,13 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3185
3704
|
* always fail.
|
|
3186
3705
|
*/
|
|
3187
3706
|
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
|
3188
|
-
const { correlationId, username, password, remoteAddr } = data;
|
|
3707
|
+
const { correlationId, sessionId, username, password, remoteAddr } = data;
|
|
3189
3708
|
|
|
3190
3709
|
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
|
3191
3710
|
|
|
3192
3711
|
const account = this.findAuthAccount(username);
|
|
3193
3712
|
let success = false;
|
|
3713
|
+
let failureMessage: string | undefined;
|
|
3194
3714
|
if (account) {
|
|
3195
3715
|
if (account.enabled === false) {
|
|
3196
3716
|
success = false;
|
|
@@ -3199,12 +3719,27 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3199
3719
|
} else {
|
|
3200
3720
|
success = this.timingSafeStringEqual(account.credential.password, password);
|
|
3201
3721
|
}
|
|
3202
|
-
} else {
|
|
3722
|
+
} else if (this.hasLegacyAuthUser(username)) {
|
|
3203
3723
|
// Legacy users array keeps its existing behavior.
|
|
3204
3724
|
const users = this.options.auth?.users || [];
|
|
3205
3725
|
success = users.some(
|
|
3206
3726
|
u => u.username === username && u.password === password
|
|
3207
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
|
+
}
|
|
3208
3743
|
}
|
|
3209
3744
|
|
|
3210
3745
|
if (success) {
|
|
@@ -3213,19 +3748,62 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3213
3748
|
success: true,
|
|
3214
3749
|
});
|
|
3215
3750
|
} else {
|
|
3216
|
-
|
|
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
|
+
);
|
|
3217
3758
|
await this.rustBridge.sendAuthResult({
|
|
3218
3759
|
correlationId,
|
|
3219
3760
|
success: false,
|
|
3220
|
-
message: 'Invalid credentials',
|
|
3761
|
+
message: failureMessage || 'Invalid credentials',
|
|
3221
3762
|
});
|
|
3222
3763
|
}
|
|
3223
3764
|
}
|
|
3224
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
|
+
|
|
3225
3797
|
/**
|
|
3226
3798
|
* Handle a SCRAM credential request from the Rust SMTP server.
|
|
3227
3799
|
* Verifier accounts return their stored fields directly; plaintext
|
|
3228
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.
|
|
3229
3807
|
*/
|
|
3230
3808
|
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
|
3231
3809
|
const { correlationId, username, remoteAddr } = data;
|
|
@@ -3663,6 +4241,15 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3663
4241
|
/**
|
|
3664
4242
|
* Send an email through the delivery system
|
|
3665
4243
|
*/
|
|
4244
|
+
public async enqueueRawEmail(
|
|
4245
|
+
rawMessage: Buffer,
|
|
4246
|
+
envelope: IRawSmtpEnvelope,
|
|
4247
|
+
idempotencyKey: string,
|
|
4248
|
+
): Promise<string> {
|
|
4249
|
+
this.assertAcceptingOutboundWork();
|
|
4250
|
+
return this.deliveryQueue[kQueueEnqueueRaw]({ rawMessage, envelope, idempotencyKey });
|
|
4251
|
+
}
|
|
4252
|
+
|
|
3666
4253
|
public async sendEmail(
|
|
3667
4254
|
email: Email,
|
|
3668
4255
|
mode: EmailProcessingMode = 'mta',
|
|
@@ -3673,6 +4260,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3673
4260
|
isTransactional?: boolean;
|
|
3674
4261
|
}
|
|
3675
4262
|
): Promise<string> {
|
|
4263
|
+
this.assertAcceptingOutboundWork();
|
|
3676
4264
|
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
|
|
3677
4265
|
|
|
3678
4266
|
try {
|