@push.rocks/smartmta 8.1.0 → 8.1.1

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.
@@ -19,14 +19,25 @@ import type {
19
19
  IScramCredentialRequestEvent,
20
20
  } from '../../security/classes.rustsecuritybridge.js';
21
21
  import { EmailRouter } from './classes.email.router.js';
22
- import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
22
+ import type {
23
+ IEmailRoute,
24
+ IEmailAction,
25
+ IEmailContext,
26
+ IEmailDomainConfig,
27
+ } from './interfaces.js';
23
28
  import { Email } from '../core/classes.email.js';
24
29
  import { DomainRegistry } from './classes.domain.registry.js';
25
30
  import { DnsManager } from './classes.dns.manager.js';
26
31
  import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
27
32
  import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
28
33
  import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
29
- import { UnifiedDeliveryQueue, type IQueueItem, type IQueueOptions, type IQueueStats } from '../delivery/classes.delivery.queue.js';
34
+ import {
35
+ ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
36
+ UnifiedDeliveryQueue,
37
+ type IQueueItem,
38
+ type IQueueOptions,
39
+ type IQueueStats,
40
+ } from '../delivery/classes.delivery.queue.js';
30
41
  import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
31
42
  import { SmtpState } from '../delivery/interfaces.js';
32
43
  import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
@@ -142,6 +153,74 @@ export interface IAcceptEnvelopeContext {
142
153
  securityResults: IInboundEmailSecurityResult | null;
143
154
  }
144
155
 
156
+ /**
157
+ * JSON-safe, immutable snapshot of one accepted envelope recipient's exact
158
+ * routing decision. The snapshot is safe to persist and replay without
159
+ * consulting the live route table.
160
+ */
161
+ export interface IAcceptedEnvelopeRecipientPlan {
162
+ readonly recipient: string;
163
+ readonly localDomain: string | null;
164
+ readonly routeName: string;
165
+ readonly source: TRecipientRouteSource;
166
+ readonly action: IEmailAction;
167
+ readonly idempotencyKey: string;
168
+ }
169
+
170
+ /** Serializable SMTP metadata needed to recreate an isolated action session. */
171
+ export interface IAcceptedEnvelopeDispatchMetadata {
172
+ readonly mailFrom: string;
173
+ readonly session?: {
174
+ readonly id?: string;
175
+ readonly remoteAddress?: string;
176
+ readonly clientHostname?: string;
177
+ readonly secure?: boolean;
178
+ readonly authenticated?: boolean;
179
+ readonly authenticatedUser?: string | null;
180
+ readonly user?: Readonly<{
181
+ username: string;
182
+ [key: string]: unknown;
183
+ }>;
184
+ };
185
+ }
186
+
187
+ export type TAcceptedEnvelopeRecipientDispatchStatus =
188
+ | 'stored'
189
+ | 'queued'
190
+ | 'rejected'
191
+ | 'failed';
192
+
193
+ export interface IAcceptedEnvelopeRecipientDispatchResult {
194
+ recipient: string;
195
+ localDomain: string | null;
196
+ routeName: string;
197
+ source: TRecipientRouteSource;
198
+ actionType: IEmailAction['type'];
199
+ idempotencyKey: string;
200
+ status: TAcceptedEnvelopeRecipientDispatchStatus;
201
+ replayed: boolean;
202
+ queueId?: string;
203
+ smtpCode?: number;
204
+ message?: string;
205
+ }
206
+
207
+ export interface IAcceptedEnvelopeDispatchResult {
208
+ complete: boolean;
209
+ results: IAcceptedEnvelopeRecipientDispatchResult[];
210
+ }
211
+
212
+ interface IStoredAcceptedEnvelopeDispatchResult {
213
+ schemaVersion: 1;
214
+ fingerprint: string;
215
+ expiresAt: number;
216
+ result: Omit<IAcceptedEnvelopeRecipientDispatchResult, 'replayed'>;
217
+ }
218
+
219
+ const ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX =
220
+ '/email/accepted-envelope-dispatch/v1/';
221
+ const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
222
+ const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT = 256;
223
+
145
224
  export interface IMessageAcceptanceDecision extends ISmtpDecision {
146
225
  /** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
147
226
  continueProcessing?: boolean;
@@ -359,6 +438,9 @@ export class UnifiedEmailServer extends EventEmitter {
359
438
  private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
360
439
  private bridgeAuthRequestHandler?: (data: IAuthRequestEvent) => void;
361
440
  private bridgeScramCredentialRequestHandler?: (data: IScramCredentialRequestEvent) => void;
441
+ private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
442
+ private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
443
+ private acceptedEnvelopeDispatchCleanupCursor?: string;
362
444
 
363
445
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
364
446
  super();
@@ -824,6 +906,7 @@ export class UnifiedEmailServer extends EventEmitter {
824
906
 
825
907
  try {
826
908
  await this.startDeliveryPipeline();
909
+ this.startAcceptedEnvelopeDispatchCleanup();
827
910
  await this.startRustBridge();
828
911
  await this.initializeDkimAndDns();
829
912
  await this.startDeliveryProcessing();
@@ -1096,6 +1179,11 @@ export class UnifiedEmailServer extends EventEmitter {
1096
1179
  logger.log('info', 'Stopping UnifiedEmailServer');
1097
1180
 
1098
1181
  try {
1182
+ if (this.acceptedEnvelopeDispatchCleanupTimer) {
1183
+ clearInterval(this.acceptedEnvelopeDispatchCleanupTimer);
1184
+ this.acceptedEnvelopeDispatchCleanupTimer = undefined;
1185
+ }
1186
+ await this.acceptedEnvelopeDispatchCleanupPromise;
1099
1187
  await this.stopDnsValidation();
1100
1188
 
1101
1189
  // Stop the Rust SMTP server first
@@ -1448,12 +1536,17 @@ export class UnifiedEmailServer extends EventEmitter {
1448
1536
  subject: email.subject,
1449
1537
  text: email.text,
1450
1538
  html: email.html,
1451
- attachments: email.attachments,
1539
+ attachments: email.attachments.map((attachment) => ({
1540
+ ...attachment,
1541
+ content: Buffer.from(attachment.content),
1542
+ })),
1452
1543
  headers: { ...email.headers },
1453
1544
  mightBeSpam: email.mightBeSpam,
1454
1545
  priority: email.priority,
1455
- variables: { ...email.variables },
1456
- });
1546
+ variables: structuredClone(email.variables),
1547
+ })
1548
+ .setMessageId(email.getMessageId())
1549
+ .setEnvelopeFrom(email.getEnvelopeFrom());
1457
1550
  }
1458
1551
 
1459
1552
  private scopeSessionForRoutingRecipient(
@@ -1462,6 +1555,10 @@ export class UnifiedEmailServer extends EventEmitter {
1462
1555
  ): IExtendedSmtpSession {
1463
1556
  return {
1464
1557
  ...session,
1558
+ ...(session.user ? { user: structuredClone(session.user) } : {}),
1559
+ ...(session.securityResults
1560
+ ? { securityResults: structuredClone(session.securityResults) }
1561
+ : {}),
1465
1562
  rcptTo: [recipient],
1466
1563
  envelope: {
1467
1564
  mailFrom: { ...session.envelope.mailFrom },
@@ -1562,6 +1659,571 @@ export class UnifiedEmailServer extends EventEmitter {
1562
1659
  );
1563
1660
  }
1564
1661
 
1662
+ private cloneJsonSafe<T>(
1663
+ value: T,
1664
+ path = 'value',
1665
+ seen = new WeakSet<object>(),
1666
+ ): T {
1667
+ if (
1668
+ value === null
1669
+ || typeof value === 'string'
1670
+ || typeof value === 'boolean'
1671
+ ) {
1672
+ return value;
1673
+ }
1674
+ if (typeof value === 'number') {
1675
+ if (!Number.isFinite(value)) {
1676
+ throw new Error(`${path} must contain only finite JSON numbers`);
1677
+ }
1678
+ return value;
1679
+ }
1680
+ if (Array.isArray(value)) {
1681
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
1682
+ throw new Error(`${path} must use the standard Array prototype`);
1683
+ }
1684
+ if (seen.has(value)) {
1685
+ throw new Error(`${path} must not contain circular references`);
1686
+ }
1687
+ seen.add(value);
1688
+ const ownKeys = Reflect.ownKeys(value);
1689
+ if (ownKeys.some((key) => typeof key === 'symbol')) {
1690
+ throw new Error(`${path} must not contain symbol keys`);
1691
+ }
1692
+ const elementKeys = ownKeys.filter((key) => key !== 'length') as string[];
1693
+ if (
1694
+ elementKeys.length !== value.length
1695
+ || elementKeys.some((key, index) => key !== String(index))
1696
+ ) {
1697
+ throw new Error(`${path} must be a dense JSON array without custom keys`);
1698
+ }
1699
+ const clone: unknown[] = [];
1700
+ for (let index = 0; index < value.length; index++) {
1701
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
1702
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
1703
+ throw new Error(`${path}[${index}] must be an enumerable data property`);
1704
+ }
1705
+ clone.push(this.cloneJsonSafe(
1706
+ descriptor.value,
1707
+ `${path}[${index}]`,
1708
+ seen,
1709
+ ));
1710
+ }
1711
+ seen.delete(value);
1712
+ return Object.freeze(clone) as T;
1713
+ }
1714
+ if (typeof value === 'object') {
1715
+ const prototype = Object.getPrototypeOf(value);
1716
+ if (prototype !== Object.prototype && prototype !== null) {
1717
+ throw new Error(`${path} must contain only JSON-safe plain objects`);
1718
+ }
1719
+ if (seen.has(value)) {
1720
+ throw new Error(`${path} must not contain circular references`);
1721
+ }
1722
+ seen.add(value);
1723
+ const ownKeys = Reflect.ownKeys(value);
1724
+ if (ownKeys.some((key) => typeof key === 'symbol')) {
1725
+ throw new Error(`${path} must not contain symbol keys`);
1726
+ }
1727
+ const clone = Object.create(null) as Record<string, unknown>;
1728
+ for (const key of ownKeys as string[]) {
1729
+ if (key === '__proto__') {
1730
+ throw new Error(`${path} must not contain __proto__ keys`);
1731
+ }
1732
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1733
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
1734
+ throw new Error(`${path}.${key} must be an enumerable data property`);
1735
+ }
1736
+ if (descriptor.value === undefined) {
1737
+ throw new Error(`${path}.${key} must not be undefined`);
1738
+ }
1739
+ clone[key] = this.cloneJsonSafe(
1740
+ descriptor.value,
1741
+ `${path}.${key}`,
1742
+ seen,
1743
+ );
1744
+ }
1745
+ seen.delete(value);
1746
+ return Object.freeze(clone) as T;
1747
+ }
1748
+ throw new Error(`${path} contains a non-JSON-safe value`);
1749
+ }
1750
+
1751
+ private stableJson(value: unknown): string {
1752
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
1753
+ if (Array.isArray(value)) {
1754
+ return `[${value.map((entry) => this.stableJson(entry)).join(',')}]`;
1755
+ }
1756
+ const record = value as Record<string, unknown>;
1757
+ return `{${Object.keys(record)
1758
+ .sort()
1759
+ .map((key) => `${JSON.stringify(key)}:${this.stableJson(record[key])}`)
1760
+ .join(',')}}`;
1761
+ }
1762
+
1763
+ private validateAcceptedEnvelopeAction(
1764
+ action: IEmailAction,
1765
+ path: string,
1766
+ ): void {
1767
+ if (!['forward', 'deliver', 'reject', 'process', 'store'].includes(action.type)) {
1768
+ throw new Error(`${path}.type is unsupported`);
1769
+ }
1770
+ if (action.type === 'forward') {
1771
+ if (!action.forward?.host?.trim()) {
1772
+ throw new Error(`${path}.forward.host is required`);
1773
+ }
1774
+ if (
1775
+ action.forward.port !== undefined
1776
+ && (
1777
+ !Number.isInteger(action.forward.port)
1778
+ || action.forward.port < 1
1779
+ || action.forward.port > 65_535
1780
+ )
1781
+ ) {
1782
+ throw new Error(`${path}.forward.port is invalid`);
1783
+ }
1784
+ }
1785
+ if (action.type === 'store') {
1786
+ if (
1787
+ !Number.isInteger(action.store?.retentionDays)
1788
+ || (action.store?.retentionDays || 0) < 1
1789
+ ) {
1790
+ throw new Error(`${path}.store.retentionDays must be a positive integer`);
1791
+ }
1792
+ }
1793
+ if (action.type === 'reject') {
1794
+ if (
1795
+ !Number.isInteger(action.reject?.code)
1796
+ || (action.reject?.code || 0) < 400
1797
+ || (action.reject?.code || 0) > 599
1798
+ || !action.reject?.message?.trim()
1799
+ ) {
1800
+ throw new Error(`${path}.reject must contain a 4xx/5xx code and message`);
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ private normalizeAcceptedEnvelopePlan(
1806
+ plan: readonly IAcceptedEnvelopeRecipientPlan[],
1807
+ ): readonly IAcceptedEnvelopeRecipientPlan[] {
1808
+ if (!Array.isArray(plan) || plan.length === 0) {
1809
+ throw new Error('Accepted envelope dispatch plan must contain at least one recipient');
1810
+ }
1811
+ const keys = new Set<string>();
1812
+ const normalized = plan.map((entry, index) => {
1813
+ const path = `plan[${index}]`;
1814
+ if (!entry || typeof entry !== 'object') {
1815
+ throw new Error(`${path} must be an object`);
1816
+ }
1817
+ const atIndex = entry.recipient?.lastIndexOf('@') ?? -1;
1818
+ if (
1819
+ !entry.recipient?.trim()
1820
+ || atIndex < 1
1821
+ || atIndex === entry.recipient.length - 1
1822
+ ) {
1823
+ throw new Error(`${path}.recipient is invalid`);
1824
+ }
1825
+ if (entry.localDomain !== null && !entry.localDomain?.trim()) {
1826
+ throw new Error(`${path}.localDomain is invalid`);
1827
+ }
1828
+ if (!entry.routeName?.trim()) {
1829
+ throw new Error(`${path}.routeName is required`);
1830
+ }
1831
+ if (!['explicit-route', 'domain-policy'].includes(entry.source)) {
1832
+ throw new Error(`${path}.source cannot represent an accepted route`);
1833
+ }
1834
+ if (!entry.idempotencyKey || entry.idempotencyKey.length > 512) {
1835
+ throw new Error(`${path}.idempotencyKey must contain 1 to 512 characters`);
1836
+ }
1837
+ if (keys.has(entry.idempotencyKey)) {
1838
+ throw new Error(`${path}.idempotencyKey is duplicated`);
1839
+ }
1840
+ keys.add(entry.idempotencyKey);
1841
+ const action = this.cloneJsonSafe(entry.action, `${path}.action`);
1842
+ this.validateAcceptedEnvelopeAction(action, `${path}.action`);
1843
+ return Object.freeze({
1844
+ recipient: entry.recipient,
1845
+ localDomain: entry.localDomain,
1846
+ routeName: entry.routeName,
1847
+ source: entry.source,
1848
+ action,
1849
+ idempotencyKey: entry.idempotencyKey,
1850
+ });
1851
+ });
1852
+ return Object.freeze(normalized);
1853
+ }
1854
+
1855
+ /**
1856
+ * Snapshot the already-resolved acceptance plan for durable consumer storage.
1857
+ * No live route lookup is performed when the returned plan is dispatched.
1858
+ */
1859
+ public createAcceptedEnvelopeDispatchPlan(
1860
+ context: IAcceptEnvelopeContext,
1861
+ idempotencyKeys: readonly string[],
1862
+ ): readonly IAcceptedEnvelopeRecipientPlan[] {
1863
+ if (context.resolvedRecipientRoutes.length !== idempotencyKeys.length) {
1864
+ throw new Error('One idempotency key is required for every resolved recipient route');
1865
+ }
1866
+ return this.normalizeAcceptedEnvelopePlan(
1867
+ context.resolvedRecipientRoutes.map((resolution, index) => {
1868
+ if (!resolution.route) {
1869
+ throw new Error(
1870
+ `Accepted recipient ${resolution.recipient} has no resolved route action`,
1871
+ );
1872
+ }
1873
+ return {
1874
+ recipient: resolution.recipient,
1875
+ localDomain: resolution.localDomain,
1876
+ routeName: resolution.route.name,
1877
+ source: resolution.source,
1878
+ action: resolution.route.action,
1879
+ idempotencyKey: idempotencyKeys[index],
1880
+ };
1881
+ }),
1882
+ );
1883
+ }
1884
+
1885
+ private acceptedEnvelopeDispatchStorageKey(idempotencyKey: string): string {
1886
+ const digest = plugins.crypto
1887
+ .createHash('sha256')
1888
+ .update('smartmta-accepted-envelope-dispatch-v1\0')
1889
+ .update(idempotencyKey)
1890
+ .digest('hex');
1891
+ return `${ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX}${digest}.json`;
1892
+ }
1893
+
1894
+ private startAcceptedEnvelopeDispatchCleanup(): void {
1895
+ if (this.acceptedEnvelopeDispatchCleanupTimer) return;
1896
+ this.acceptedEnvelopeDispatchCleanupTimer = setInterval(() => {
1897
+ void this.cleanupExpiredAcceptedEnvelopeDispatchResults();
1898
+ }, ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS);
1899
+ this.acceptedEnvelopeDispatchCleanupTimer.unref?.();
1900
+ }
1901
+
1902
+ private async cleanupExpiredAcceptedEnvelopeDispatchResults(
1903
+ limit = ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT,
1904
+ ): Promise<void> {
1905
+ if (!this.dcRouter.storageManager.listPage) return;
1906
+ if (this.acceptedEnvelopeDispatchCleanupPromise) {
1907
+ return this.acceptedEnvelopeDispatchCleanupPromise;
1908
+ }
1909
+ const cleanup = (async () => {
1910
+ if (!Number.isInteger(limit) || limit < 1) {
1911
+ throw new Error('Accepted-envelope cleanup limit must be a positive integer');
1912
+ }
1913
+ const now = Date.now();
1914
+ const page = await this.dcRouter.storageManager.listPage!(
1915
+ ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX,
1916
+ {
1917
+ limit,
1918
+ ...(this.acceptedEnvelopeDispatchCleanupCursor
1919
+ ? { continuationToken: this.acceptedEnvelopeDispatchCleanupCursor }
1920
+ : {}),
1921
+ },
1922
+ );
1923
+ if (
1924
+ !page
1925
+ || !Array.isArray(page.keys)
1926
+ || page.keys.length > limit
1927
+ || page.keys.some((key) => (
1928
+ typeof key !== 'string'
1929
+ || !key.startsWith(ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX)
1930
+ ))
1931
+ || new Set(page.keys).size !== page.keys.length
1932
+ || (
1933
+ page.continuationToken !== undefined
1934
+ && (
1935
+ typeof page.continuationToken !== 'string'
1936
+ || page.continuationToken.length === 0
1937
+ || page.continuationToken === this.acceptedEnvelopeDispatchCleanupCursor
1938
+ )
1939
+ )
1940
+ ) {
1941
+ throw new Error('Invalid paginated accepted-envelope checkpoint listing');
1942
+ }
1943
+ this.acceptedEnvelopeDispatchCleanupCursor = page.continuationToken;
1944
+ for (const key of page.keys) {
1945
+ const value = await this.dcRouter.storageManager.get(key);
1946
+ if (!value) continue;
1947
+ try {
1948
+ if (!key.endsWith('.json')) {
1949
+ throw new Error(`Invalid accepted-envelope checkpoint key: ${key}`);
1950
+ }
1951
+ const stored = JSON.parse(value) as Partial<IStoredAcceptedEnvelopeDispatchResult>;
1952
+ if (
1953
+ stored.schemaVersion !== 1
1954
+ || typeof stored.fingerprint !== 'string'
1955
+ || !/^[a-f0-9]{64}$/.test(stored.fingerprint)
1956
+ || !Number.isFinite(stored.expiresAt)
1957
+ || !stored.result
1958
+ || typeof stored.result !== 'object'
1959
+ || stored.expiresAt! <= now
1960
+ ) {
1961
+ await this.dcRouter.storageManager.delete(key);
1962
+ }
1963
+ } catch {
1964
+ await this.dcRouter.storageManager.delete(key);
1965
+ }
1966
+ }
1967
+ })();
1968
+ this.acceptedEnvelopeDispatchCleanupPromise = cleanup
1969
+ .catch((error) => {
1970
+ logger.log(
1971
+ 'warn',
1972
+ `Accepted-envelope dispatch checkpoint cleanup failed: ${getErrorMessage(error)}`,
1973
+ );
1974
+ })
1975
+ .finally(() => {
1976
+ this.acceptedEnvelopeDispatchCleanupPromise = undefined;
1977
+ });
1978
+ return this.acceptedEnvelopeDispatchCleanupPromise;
1979
+ }
1980
+
1981
+ private async readAcceptedEnvelopeDispatchResult(
1982
+ entry: IAcceptedEnvelopeRecipientPlan,
1983
+ fingerprint: string,
1984
+ ): Promise<IAcceptedEnvelopeRecipientDispatchResult | null> {
1985
+ const key = this.acceptedEnvelopeDispatchStorageKey(entry.idempotencyKey);
1986
+ const storedText = await this.dcRouter.storageManager.get(key);
1987
+ if (!storedText) return null;
1988
+ let stored: IStoredAcceptedEnvelopeDispatchResult;
1989
+ try {
1990
+ stored = JSON.parse(storedText) as IStoredAcceptedEnvelopeDispatchResult;
1991
+ } catch {
1992
+ throw new Error(`Accepted-envelope dispatch checkpoint is malformed for ${entry.recipient}`);
1993
+ }
1994
+ if (Number.isFinite(stored.expiresAt) && stored.expiresAt <= Date.now()) {
1995
+ await this.dcRouter.storageManager.delete(key);
1996
+ return null;
1997
+ }
1998
+ if (
1999
+ stored?.schemaVersion !== 1
2000
+ || stored.fingerprint !== fingerprint
2001
+ || !Number.isFinite(stored.expiresAt)
2002
+ || stored.result?.idempotencyKey !== entry.idempotencyKey
2003
+ || stored.result?.recipient !== entry.recipient
2004
+ || stored.result?.localDomain !== entry.localDomain
2005
+ || stored.result?.routeName !== entry.routeName
2006
+ || stored.result?.source !== entry.source
2007
+ || stored.result?.actionType !== entry.action.type
2008
+ || (stored.result as Record<string, unknown> | undefined)?.replayed !== undefined
2009
+ || !['stored', 'queued', 'rejected'].includes(stored.result?.status)
2010
+ || (
2011
+ stored.result?.status === 'queued'
2012
+ && (
2013
+ typeof stored.result.queueId !== 'string'
2014
+ || !stored.result.queueId
2015
+ )
2016
+ )
2017
+ || (
2018
+ stored.result?.status !== 'queued'
2019
+ && stored.result?.queueId !== undefined
2020
+ )
2021
+ || (
2022
+ stored.result?.status === 'rejected'
2023
+ && !Number.isInteger(stored.result.smtpCode)
2024
+ )
2025
+ ) {
2026
+ throw new Error(
2027
+ `Accepted-envelope idempotency key was reused or has invalid state for ${entry.recipient}`,
2028
+ );
2029
+ }
2030
+ return { ...stored.result, replayed: true };
2031
+ }
2032
+
2033
+ private async writeAcceptedEnvelopeDispatchResult(
2034
+ result: IAcceptedEnvelopeRecipientDispatchResult,
2035
+ fingerprint: string,
2036
+ ): Promise<void> {
2037
+ const { replayed: _replayed, ...storedResult } = result;
2038
+ const stored: IStoredAcceptedEnvelopeDispatchResult = {
2039
+ schemaVersion: 1,
2040
+ fingerprint,
2041
+ expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
2042
+ result: storedResult,
2043
+ };
2044
+ await this.dcRouter.storageManager.set(
2045
+ this.acceptedEnvelopeDispatchStorageKey(result.idempotencyKey),
2046
+ JSON.stringify(stored),
2047
+ );
2048
+ }
2049
+
2050
+ /**
2051
+ * Dispatch a durably accepted raw envelope using only its persisted exact
2052
+ * action snapshots. Each recipient receives an isolated Email and session.
2053
+ * Queue-backed actions require managed queue storage and are replay-safe.
2054
+ */
2055
+ public async dispatchAcceptedEnvelope(
2056
+ rawMessage: Uint8Array,
2057
+ planArg: readonly IAcceptedEnvelopeRecipientPlan[],
2058
+ metadataArg: IAcceptedEnvelopeDispatchMetadata,
2059
+ ): Promise<IAcceptedEnvelopeDispatchResult> {
2060
+ if (!(rawMessage instanceof Uint8Array) || rawMessage.byteLength === 0) {
2061
+ throw new Error('Accepted envelope raw message must contain exact RFC822 bytes');
2062
+ }
2063
+ const rawCopy = Buffer.from(rawMessage);
2064
+ const plan = this.normalizeAcceptedEnvelopePlan(planArg);
2065
+ const metadata = this.cloneJsonSafe(metadataArg, 'metadata');
2066
+ if (!metadata?.mailFrom && metadata?.mailFrom !== '') {
2067
+ throw new Error('Accepted envelope metadata.mailFrom is required');
2068
+ }
2069
+ if (!this.dcRouter.storageManager.listPage) {
2070
+ throw new Error(
2071
+ 'Accepted-envelope dispatch requires paginated small-state storage',
2072
+ );
2073
+ }
2074
+ const hasQueueAction = plan.some((entry) => (
2075
+ entry.action.type === 'forward'
2076
+ || entry.action.type === 'deliver'
2077
+ || entry.action.type === 'process'
2078
+ ));
2079
+ if (hasQueueAction) {
2080
+ if (!this.deliveryQueue.hasManagedStorage()) {
2081
+ throw new Error(
2082
+ 'Accepted-envelope queue actions require managed queue storage',
2083
+ );
2084
+ }
2085
+ if (!this.deliveryQueue.hasPaginatedManagedStorage()) {
2086
+ throw new Error(
2087
+ 'Accepted-envelope queue actions require paginated managed queue storage',
2088
+ );
2089
+ }
2090
+ }
2091
+
2092
+ const rawDigest = plugins.crypto.createHash('sha256').update(rawCopy).digest('hex');
2093
+ const fingerprints = plan.map((entry) => plugins.crypto
2094
+ .createHash('sha256')
2095
+ .update(this.stableJson({
2096
+ rawDigest,
2097
+ metadata,
2098
+ entry,
2099
+ }))
2100
+ .digest('hex'));
2101
+
2102
+ const results: Array<IAcceptedEnvelopeRecipientDispatchResult | null> = [];
2103
+ for (let index = 0; index < plan.length; index++) {
2104
+ results.push(await this.readAcceptedEnvelopeDispatchResult(
2105
+ plan[index],
2106
+ fingerprints[index],
2107
+ ));
2108
+ }
2109
+ if (results.every(Boolean)) {
2110
+ return {
2111
+ complete: true,
2112
+ results: results as IAcceptedEnvelopeRecipientDispatchResult[],
2113
+ };
2114
+ }
2115
+
2116
+ const baseSession: IExtendedSmtpSession = {
2117
+ id: metadata.session?.id || `accepted-envelope-${rawDigest.slice(0, 16)}`,
2118
+ state: SmtpState.FINISHED,
2119
+ mailFrom: metadata.mailFrom,
2120
+ rcptTo: plan.map((entry) => entry.recipient),
2121
+ emailData: '',
2122
+ useTLS: !!metadata.session?.secure,
2123
+ connectionEnded: true,
2124
+ remoteAddress: metadata.session?.remoteAddress || '',
2125
+ clientHostname: metadata.session?.clientHostname || '',
2126
+ secure: !!metadata.session?.secure,
2127
+ authenticated: !!metadata.session?.authenticated,
2128
+ envelope: {
2129
+ mailFrom: { address: metadata.mailFrom, args: {} },
2130
+ rcptTo: plan.map((entry) => ({ address: entry.recipient, args: {} })),
2131
+ },
2132
+ };
2133
+ if (metadata.session?.user) {
2134
+ baseSession.user = structuredClone(metadata.session.user) as IExtendedSmtpSession['user'];
2135
+ } else if (metadata.session?.authenticatedUser) {
2136
+ baseSession.user = { username: metadata.session.authenticatedUser };
2137
+ }
2138
+ let parsedEmail: Email | undefined;
2139
+
2140
+ for (let index = 0; index < plan.length; index++) {
2141
+ if (results[index]) continue;
2142
+ const entry = plan[index];
2143
+ const fingerprint = fingerprints[index];
2144
+ const commonResult = {
2145
+ recipient: entry.recipient,
2146
+ localDomain: entry.localDomain,
2147
+ routeName: entry.routeName,
2148
+ source: entry.source,
2149
+ actionType: entry.action.type,
2150
+ idempotencyKey: entry.idempotencyKey,
2151
+ replayed: false,
2152
+ } as const;
2153
+
2154
+ try {
2155
+ let result: IAcceptedEnvelopeRecipientDispatchResult;
2156
+ if (entry.action.type === 'store') {
2157
+ result = { ...commonResult, status: 'stored' };
2158
+ } else {
2159
+ parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
2160
+ const route: IEmailRoute = Object.freeze({
2161
+ name: entry.routeName,
2162
+ match: Object.freeze({ recipients: entry.recipient }),
2163
+ action: entry.action,
2164
+ });
2165
+ const email = this.cloneEmailForRoutingRecipient(parsedEmail, entry.recipient);
2166
+ const session = this.scopeSessionForRoutingRecipient(baseSession, entry.recipient);
2167
+ session.id = `${baseSession.id}:${index}`;
2168
+ session.matchedRoute = route;
2169
+ session.resolvedRecipientRoutes = [{
2170
+ recipient: entry.recipient,
2171
+ localDomain: entry.localDomain,
2172
+ route,
2173
+ source: entry.source,
2174
+ }];
2175
+ try {
2176
+ const execution = await this.actionExecutor.executeActionWithResult(
2177
+ entry.action,
2178
+ email,
2179
+ { email, session },
2180
+ {
2181
+ durableQueue: {
2182
+ idempotencyKey: entry.idempotencyKey,
2183
+ fingerprint,
2184
+ },
2185
+ },
2186
+ );
2187
+ if (execution.outcome !== 'queued' || !execution.queueId) {
2188
+ throw new Error(
2189
+ `Accepted-envelope ${entry.action.type} action did not durably enqueue`,
2190
+ );
2191
+ }
2192
+ result = {
2193
+ ...commonResult,
2194
+ status: 'queued',
2195
+ queueId: execution.queueId,
2196
+ };
2197
+ } catch (error) {
2198
+ if (entry.action.type !== 'reject') throw error;
2199
+ result = {
2200
+ ...commonResult,
2201
+ status: 'rejected',
2202
+ smtpCode: Number((error as any)?.responseCode)
2203
+ || entry.action.reject?.code
2204
+ || 550,
2205
+ message: getErrorMessage(error),
2206
+ };
2207
+ }
2208
+ }
2209
+ await this.writeAcceptedEnvelopeDispatchResult(result, fingerprint);
2210
+ results[index] = result;
2211
+ } catch (error) {
2212
+ results[index] = {
2213
+ ...commonResult,
2214
+ status: 'failed',
2215
+ message: getErrorMessage(error),
2216
+ };
2217
+ }
2218
+ }
2219
+
2220
+ const finalResults = results as IAcceptedEnvelopeRecipientDispatchResult[];
2221
+ return {
2222
+ complete: finalResults.every((result) => result.status !== 'failed'),
2223
+ results: finalResults,
2224
+ };
2225
+ }
2226
+
1565
2227
  /**
1566
2228
  * Handle an emailReceived event from the Rust SMTP server.
1567
2229
  */