@push.rocks/smartmta 8.1.0 → 8.2.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.
@@ -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';
@@ -120,6 +131,21 @@ export interface IMessageAcceptanceContext {
120
131
  abortSignal?: AbortSignal;
121
132
  }
122
133
 
134
+ /**
135
+ * Throw from `hooks.onAcceptEnvelope` to REJECT the envelope with a permanent
136
+ * SMTP status (e.g. DMARC policy enforcement) instead of the generic 451
137
+ * temporary failure used for infrastructure errors.
138
+ */
139
+ export class AcceptEnvelopeRejectionError extends Error {
140
+ constructor(
141
+ public readonly smtpCode: number,
142
+ public readonly smtpMessage: string,
143
+ ) {
144
+ super(smtpMessage);
145
+ this.name = 'AcceptEnvelopeRejectionError';
146
+ }
147
+ }
148
+
123
149
  /**
124
150
  * Terminal durable acceptance context for an envelope containing at least one `store` route.
125
151
  * The consumer owns persistence and all recipient fan-out before this hook resolves.
@@ -142,6 +168,74 @@ export interface IAcceptEnvelopeContext {
142
168
  securityResults: IInboundEmailSecurityResult | null;
143
169
  }
144
170
 
171
+ /**
172
+ * JSON-safe, immutable snapshot of one accepted envelope recipient's exact
173
+ * routing decision. The snapshot is safe to persist and replay without
174
+ * consulting the live route table.
175
+ */
176
+ export interface IAcceptedEnvelopeRecipientPlan {
177
+ readonly recipient: string;
178
+ readonly localDomain: string | null;
179
+ readonly routeName: string;
180
+ readonly source: TRecipientRouteSource;
181
+ readonly action: IEmailAction;
182
+ readonly idempotencyKey: string;
183
+ }
184
+
185
+ /** Serializable SMTP metadata needed to recreate an isolated action session. */
186
+ export interface IAcceptedEnvelopeDispatchMetadata {
187
+ readonly mailFrom: string;
188
+ readonly session?: {
189
+ readonly id?: string;
190
+ readonly remoteAddress?: string;
191
+ readonly clientHostname?: string;
192
+ readonly secure?: boolean;
193
+ readonly authenticated?: boolean;
194
+ readonly authenticatedUser?: string | null;
195
+ readonly user?: Readonly<{
196
+ username: string;
197
+ [key: string]: unknown;
198
+ }>;
199
+ };
200
+ }
201
+
202
+ export type TAcceptedEnvelopeRecipientDispatchStatus =
203
+ | 'stored'
204
+ | 'queued'
205
+ | 'rejected'
206
+ | 'failed';
207
+
208
+ export interface IAcceptedEnvelopeRecipientDispatchResult {
209
+ recipient: string;
210
+ localDomain: string | null;
211
+ routeName: string;
212
+ source: TRecipientRouteSource;
213
+ actionType: IEmailAction['type'];
214
+ idempotencyKey: string;
215
+ status: TAcceptedEnvelopeRecipientDispatchStatus;
216
+ replayed: boolean;
217
+ queueId?: string;
218
+ smtpCode?: number;
219
+ message?: string;
220
+ }
221
+
222
+ export interface IAcceptedEnvelopeDispatchResult {
223
+ complete: boolean;
224
+ results: IAcceptedEnvelopeRecipientDispatchResult[];
225
+ }
226
+
227
+ interface IStoredAcceptedEnvelopeDispatchResult {
228
+ schemaVersion: 1;
229
+ fingerprint: string;
230
+ expiresAt: number;
231
+ result: Omit<IAcceptedEnvelopeRecipientDispatchResult, 'replayed'>;
232
+ }
233
+
234
+ const ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX =
235
+ '/email/accepted-envelope-dispatch/v1/';
236
+ const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
237
+ const ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT = 256;
238
+
145
239
  export interface IMessageAcceptanceDecision extends ISmtpDecision {
146
240
  /** Continue SmartMTA's built-in route processing after the consumer accepted responsibility. */
147
241
  continueProcessing?: boolean;
@@ -359,6 +453,9 @@ export class UnifiedEmailServer extends EventEmitter {
359
453
  private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
360
454
  private bridgeAuthRequestHandler?: (data: IAuthRequestEvent) => void;
361
455
  private bridgeScramCredentialRequestHandler?: (data: IScramCredentialRequestEvent) => void;
456
+ private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
457
+ private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
458
+ private acceptedEnvelopeDispatchCleanupCursor?: string;
362
459
 
363
460
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
364
461
  super();
@@ -824,6 +921,7 @@ export class UnifiedEmailServer extends EventEmitter {
824
921
 
825
922
  try {
826
923
  await this.startDeliveryPipeline();
924
+ this.startAcceptedEnvelopeDispatchCleanup();
827
925
  await this.startRustBridge();
828
926
  await this.initializeDkimAndDns();
829
927
  await this.startDeliveryProcessing();
@@ -1096,6 +1194,11 @@ export class UnifiedEmailServer extends EventEmitter {
1096
1194
  logger.log('info', 'Stopping UnifiedEmailServer');
1097
1195
 
1098
1196
  try {
1197
+ if (this.acceptedEnvelopeDispatchCleanupTimer) {
1198
+ clearInterval(this.acceptedEnvelopeDispatchCleanupTimer);
1199
+ this.acceptedEnvelopeDispatchCleanupTimer = undefined;
1200
+ }
1201
+ await this.acceptedEnvelopeDispatchCleanupPromise;
1099
1202
  await this.stopDnsValidation();
1100
1203
 
1101
1204
  // Stop the Rust SMTP server first
@@ -1448,12 +1551,17 @@ export class UnifiedEmailServer extends EventEmitter {
1448
1551
  subject: email.subject,
1449
1552
  text: email.text,
1450
1553
  html: email.html,
1451
- attachments: email.attachments,
1554
+ attachments: email.attachments.map((attachment) => ({
1555
+ ...attachment,
1556
+ content: Buffer.from(attachment.content),
1557
+ })),
1452
1558
  headers: { ...email.headers },
1453
1559
  mightBeSpam: email.mightBeSpam,
1454
1560
  priority: email.priority,
1455
- variables: { ...email.variables },
1456
- });
1561
+ variables: structuredClone(email.variables),
1562
+ })
1563
+ .setMessageId(email.getMessageId())
1564
+ .setEnvelopeFrom(email.getEnvelopeFrom());
1457
1565
  }
1458
1566
 
1459
1567
  private scopeSessionForRoutingRecipient(
@@ -1462,6 +1570,10 @@ export class UnifiedEmailServer extends EventEmitter {
1462
1570
  ): IExtendedSmtpSession {
1463
1571
  return {
1464
1572
  ...session,
1573
+ ...(session.user ? { user: structuredClone(session.user) } : {}),
1574
+ ...(session.securityResults
1575
+ ? { securityResults: structuredClone(session.securityResults) }
1576
+ : {}),
1465
1577
  rcptTo: [recipient],
1466
1578
  envelope: {
1467
1579
  mailFrom: { ...session.envelope.mailFrom },
@@ -1562,6 +1674,571 @@ export class UnifiedEmailServer extends EventEmitter {
1562
1674
  );
1563
1675
  }
1564
1676
 
1677
+ private cloneJsonSafe<T>(
1678
+ value: T,
1679
+ path = 'value',
1680
+ seen = new WeakSet<object>(),
1681
+ ): T {
1682
+ if (
1683
+ value === null
1684
+ || typeof value === 'string'
1685
+ || typeof value === 'boolean'
1686
+ ) {
1687
+ return value;
1688
+ }
1689
+ if (typeof value === 'number') {
1690
+ if (!Number.isFinite(value)) {
1691
+ throw new Error(`${path} must contain only finite JSON numbers`);
1692
+ }
1693
+ return value;
1694
+ }
1695
+ if (Array.isArray(value)) {
1696
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
1697
+ throw new Error(`${path} must use the standard Array prototype`);
1698
+ }
1699
+ if (seen.has(value)) {
1700
+ throw new Error(`${path} must not contain circular references`);
1701
+ }
1702
+ seen.add(value);
1703
+ const ownKeys = Reflect.ownKeys(value);
1704
+ if (ownKeys.some((key) => typeof key === 'symbol')) {
1705
+ throw new Error(`${path} must not contain symbol keys`);
1706
+ }
1707
+ const elementKeys = ownKeys.filter((key) => key !== 'length') as string[];
1708
+ if (
1709
+ elementKeys.length !== value.length
1710
+ || elementKeys.some((key, index) => key !== String(index))
1711
+ ) {
1712
+ throw new Error(`${path} must be a dense JSON array without custom keys`);
1713
+ }
1714
+ const clone: unknown[] = [];
1715
+ for (let index = 0; index < value.length; index++) {
1716
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
1717
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
1718
+ throw new Error(`${path}[${index}] must be an enumerable data property`);
1719
+ }
1720
+ clone.push(this.cloneJsonSafe(
1721
+ descriptor.value,
1722
+ `${path}[${index}]`,
1723
+ seen,
1724
+ ));
1725
+ }
1726
+ seen.delete(value);
1727
+ return Object.freeze(clone) as T;
1728
+ }
1729
+ if (typeof value === 'object') {
1730
+ const prototype = Object.getPrototypeOf(value);
1731
+ if (prototype !== Object.prototype && prototype !== null) {
1732
+ throw new Error(`${path} must contain only JSON-safe plain objects`);
1733
+ }
1734
+ if (seen.has(value)) {
1735
+ throw new Error(`${path} must not contain circular references`);
1736
+ }
1737
+ seen.add(value);
1738
+ const ownKeys = Reflect.ownKeys(value);
1739
+ if (ownKeys.some((key) => typeof key === 'symbol')) {
1740
+ throw new Error(`${path} must not contain symbol keys`);
1741
+ }
1742
+ const clone = Object.create(null) as Record<string, unknown>;
1743
+ for (const key of ownKeys as string[]) {
1744
+ if (key === '__proto__') {
1745
+ throw new Error(`${path} must not contain __proto__ keys`);
1746
+ }
1747
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1748
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
1749
+ throw new Error(`${path}.${key} must be an enumerable data property`);
1750
+ }
1751
+ if (descriptor.value === undefined) {
1752
+ throw new Error(`${path}.${key} must not be undefined`);
1753
+ }
1754
+ clone[key] = this.cloneJsonSafe(
1755
+ descriptor.value,
1756
+ `${path}.${key}`,
1757
+ seen,
1758
+ );
1759
+ }
1760
+ seen.delete(value);
1761
+ return Object.freeze(clone) as T;
1762
+ }
1763
+ throw new Error(`${path} contains a non-JSON-safe value`);
1764
+ }
1765
+
1766
+ private stableJson(value: unknown): string {
1767
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
1768
+ if (Array.isArray(value)) {
1769
+ return `[${value.map((entry) => this.stableJson(entry)).join(',')}]`;
1770
+ }
1771
+ const record = value as Record<string, unknown>;
1772
+ return `{${Object.keys(record)
1773
+ .sort()
1774
+ .map((key) => `${JSON.stringify(key)}:${this.stableJson(record[key])}`)
1775
+ .join(',')}}`;
1776
+ }
1777
+
1778
+ private validateAcceptedEnvelopeAction(
1779
+ action: IEmailAction,
1780
+ path: string,
1781
+ ): void {
1782
+ if (!['forward', 'deliver', 'reject', 'process', 'store'].includes(action.type)) {
1783
+ throw new Error(`${path}.type is unsupported`);
1784
+ }
1785
+ if (action.type === 'forward') {
1786
+ if (!action.forward?.host?.trim()) {
1787
+ throw new Error(`${path}.forward.host is required`);
1788
+ }
1789
+ if (
1790
+ action.forward.port !== undefined
1791
+ && (
1792
+ !Number.isInteger(action.forward.port)
1793
+ || action.forward.port < 1
1794
+ || action.forward.port > 65_535
1795
+ )
1796
+ ) {
1797
+ throw new Error(`${path}.forward.port is invalid`);
1798
+ }
1799
+ }
1800
+ if (action.type === 'store') {
1801
+ if (
1802
+ !Number.isInteger(action.store?.retentionDays)
1803
+ || (action.store?.retentionDays || 0) < 1
1804
+ ) {
1805
+ throw new Error(`${path}.store.retentionDays must be a positive integer`);
1806
+ }
1807
+ }
1808
+ if (action.type === 'reject') {
1809
+ if (
1810
+ !Number.isInteger(action.reject?.code)
1811
+ || (action.reject?.code || 0) < 400
1812
+ || (action.reject?.code || 0) > 599
1813
+ || !action.reject?.message?.trim()
1814
+ ) {
1815
+ throw new Error(`${path}.reject must contain a 4xx/5xx code and message`);
1816
+ }
1817
+ }
1818
+ }
1819
+
1820
+ private normalizeAcceptedEnvelopePlan(
1821
+ plan: readonly IAcceptedEnvelopeRecipientPlan[],
1822
+ ): readonly IAcceptedEnvelopeRecipientPlan[] {
1823
+ if (!Array.isArray(plan) || plan.length === 0) {
1824
+ throw new Error('Accepted envelope dispatch plan must contain at least one recipient');
1825
+ }
1826
+ const keys = new Set<string>();
1827
+ const normalized = plan.map((entry, index) => {
1828
+ const path = `plan[${index}]`;
1829
+ if (!entry || typeof entry !== 'object') {
1830
+ throw new Error(`${path} must be an object`);
1831
+ }
1832
+ const atIndex = entry.recipient?.lastIndexOf('@') ?? -1;
1833
+ if (
1834
+ !entry.recipient?.trim()
1835
+ || atIndex < 1
1836
+ || atIndex === entry.recipient.length - 1
1837
+ ) {
1838
+ throw new Error(`${path}.recipient is invalid`);
1839
+ }
1840
+ if (entry.localDomain !== null && !entry.localDomain?.trim()) {
1841
+ throw new Error(`${path}.localDomain is invalid`);
1842
+ }
1843
+ if (!entry.routeName?.trim()) {
1844
+ throw new Error(`${path}.routeName is required`);
1845
+ }
1846
+ if (!['explicit-route', 'domain-policy'].includes(entry.source)) {
1847
+ throw new Error(`${path}.source cannot represent an accepted route`);
1848
+ }
1849
+ if (!entry.idempotencyKey || entry.idempotencyKey.length > 512) {
1850
+ throw new Error(`${path}.idempotencyKey must contain 1 to 512 characters`);
1851
+ }
1852
+ if (keys.has(entry.idempotencyKey)) {
1853
+ throw new Error(`${path}.idempotencyKey is duplicated`);
1854
+ }
1855
+ keys.add(entry.idempotencyKey);
1856
+ const action = this.cloneJsonSafe(entry.action, `${path}.action`);
1857
+ this.validateAcceptedEnvelopeAction(action, `${path}.action`);
1858
+ return Object.freeze({
1859
+ recipient: entry.recipient,
1860
+ localDomain: entry.localDomain,
1861
+ routeName: entry.routeName,
1862
+ source: entry.source,
1863
+ action,
1864
+ idempotencyKey: entry.idempotencyKey,
1865
+ });
1866
+ });
1867
+ return Object.freeze(normalized);
1868
+ }
1869
+
1870
+ /**
1871
+ * Snapshot the already-resolved acceptance plan for durable consumer storage.
1872
+ * No live route lookup is performed when the returned plan is dispatched.
1873
+ */
1874
+ public createAcceptedEnvelopeDispatchPlan(
1875
+ context: IAcceptEnvelopeContext,
1876
+ idempotencyKeys: readonly string[],
1877
+ ): readonly IAcceptedEnvelopeRecipientPlan[] {
1878
+ if (context.resolvedRecipientRoutes.length !== idempotencyKeys.length) {
1879
+ throw new Error('One idempotency key is required for every resolved recipient route');
1880
+ }
1881
+ return this.normalizeAcceptedEnvelopePlan(
1882
+ context.resolvedRecipientRoutes.map((resolution, index) => {
1883
+ if (!resolution.route) {
1884
+ throw new Error(
1885
+ `Accepted recipient ${resolution.recipient} has no resolved route action`,
1886
+ );
1887
+ }
1888
+ return {
1889
+ recipient: resolution.recipient,
1890
+ localDomain: resolution.localDomain,
1891
+ routeName: resolution.route.name,
1892
+ source: resolution.source,
1893
+ action: resolution.route.action,
1894
+ idempotencyKey: idempotencyKeys[index],
1895
+ };
1896
+ }),
1897
+ );
1898
+ }
1899
+
1900
+ private acceptedEnvelopeDispatchStorageKey(idempotencyKey: string): string {
1901
+ const digest = plugins.crypto
1902
+ .createHash('sha256')
1903
+ .update('smartmta-accepted-envelope-dispatch-v1\0')
1904
+ .update(idempotencyKey)
1905
+ .digest('hex');
1906
+ return `${ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX}${digest}.json`;
1907
+ }
1908
+
1909
+ private startAcceptedEnvelopeDispatchCleanup(): void {
1910
+ if (this.acceptedEnvelopeDispatchCleanupTimer) return;
1911
+ this.acceptedEnvelopeDispatchCleanupTimer = setInterval(() => {
1912
+ void this.cleanupExpiredAcceptedEnvelopeDispatchResults();
1913
+ }, ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_INTERVAL_MS);
1914
+ this.acceptedEnvelopeDispatchCleanupTimer.unref?.();
1915
+ }
1916
+
1917
+ private async cleanupExpiredAcceptedEnvelopeDispatchResults(
1918
+ limit = ACCEPTED_ENVELOPE_DISPATCH_CLEANUP_LIMIT,
1919
+ ): Promise<void> {
1920
+ if (!this.dcRouter.storageManager.listPage) return;
1921
+ if (this.acceptedEnvelopeDispatchCleanupPromise) {
1922
+ return this.acceptedEnvelopeDispatchCleanupPromise;
1923
+ }
1924
+ const cleanup = (async () => {
1925
+ if (!Number.isInteger(limit) || limit < 1) {
1926
+ throw new Error('Accepted-envelope cleanup limit must be a positive integer');
1927
+ }
1928
+ const now = Date.now();
1929
+ const page = await this.dcRouter.storageManager.listPage!(
1930
+ ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX,
1931
+ {
1932
+ limit,
1933
+ ...(this.acceptedEnvelopeDispatchCleanupCursor
1934
+ ? { continuationToken: this.acceptedEnvelopeDispatchCleanupCursor }
1935
+ : {}),
1936
+ },
1937
+ );
1938
+ if (
1939
+ !page
1940
+ || !Array.isArray(page.keys)
1941
+ || page.keys.length > limit
1942
+ || page.keys.some((key) => (
1943
+ typeof key !== 'string'
1944
+ || !key.startsWith(ACCEPTED_ENVELOPE_DISPATCH_STORAGE_PREFIX)
1945
+ ))
1946
+ || new Set(page.keys).size !== page.keys.length
1947
+ || (
1948
+ page.continuationToken !== undefined
1949
+ && (
1950
+ typeof page.continuationToken !== 'string'
1951
+ || page.continuationToken.length === 0
1952
+ || page.continuationToken === this.acceptedEnvelopeDispatchCleanupCursor
1953
+ )
1954
+ )
1955
+ ) {
1956
+ throw new Error('Invalid paginated accepted-envelope checkpoint listing');
1957
+ }
1958
+ this.acceptedEnvelopeDispatchCleanupCursor = page.continuationToken;
1959
+ for (const key of page.keys) {
1960
+ const value = await this.dcRouter.storageManager.get(key);
1961
+ if (!value) continue;
1962
+ try {
1963
+ if (!key.endsWith('.json')) {
1964
+ throw new Error(`Invalid accepted-envelope checkpoint key: ${key}`);
1965
+ }
1966
+ const stored = JSON.parse(value) as Partial<IStoredAcceptedEnvelopeDispatchResult>;
1967
+ if (
1968
+ stored.schemaVersion !== 1
1969
+ || typeof stored.fingerprint !== 'string'
1970
+ || !/^[a-f0-9]{64}$/.test(stored.fingerprint)
1971
+ || !Number.isFinite(stored.expiresAt)
1972
+ || !stored.result
1973
+ || typeof stored.result !== 'object'
1974
+ || stored.expiresAt! <= now
1975
+ ) {
1976
+ await this.dcRouter.storageManager.delete(key);
1977
+ }
1978
+ } catch {
1979
+ await this.dcRouter.storageManager.delete(key);
1980
+ }
1981
+ }
1982
+ })();
1983
+ this.acceptedEnvelopeDispatchCleanupPromise = cleanup
1984
+ .catch((error) => {
1985
+ logger.log(
1986
+ 'warn',
1987
+ `Accepted-envelope dispatch checkpoint cleanup failed: ${getErrorMessage(error)}`,
1988
+ );
1989
+ })
1990
+ .finally(() => {
1991
+ this.acceptedEnvelopeDispatchCleanupPromise = undefined;
1992
+ });
1993
+ return this.acceptedEnvelopeDispatchCleanupPromise;
1994
+ }
1995
+
1996
+ private async readAcceptedEnvelopeDispatchResult(
1997
+ entry: IAcceptedEnvelopeRecipientPlan,
1998
+ fingerprint: string,
1999
+ ): Promise<IAcceptedEnvelopeRecipientDispatchResult | null> {
2000
+ const key = this.acceptedEnvelopeDispatchStorageKey(entry.idempotencyKey);
2001
+ const storedText = await this.dcRouter.storageManager.get(key);
2002
+ if (!storedText) return null;
2003
+ let stored: IStoredAcceptedEnvelopeDispatchResult;
2004
+ try {
2005
+ stored = JSON.parse(storedText) as IStoredAcceptedEnvelopeDispatchResult;
2006
+ } catch {
2007
+ throw new Error(`Accepted-envelope dispatch checkpoint is malformed for ${entry.recipient}`);
2008
+ }
2009
+ if (Number.isFinite(stored.expiresAt) && stored.expiresAt <= Date.now()) {
2010
+ await this.dcRouter.storageManager.delete(key);
2011
+ return null;
2012
+ }
2013
+ if (
2014
+ stored?.schemaVersion !== 1
2015
+ || stored.fingerprint !== fingerprint
2016
+ || !Number.isFinite(stored.expiresAt)
2017
+ || stored.result?.idempotencyKey !== entry.idempotencyKey
2018
+ || stored.result?.recipient !== entry.recipient
2019
+ || stored.result?.localDomain !== entry.localDomain
2020
+ || stored.result?.routeName !== entry.routeName
2021
+ || stored.result?.source !== entry.source
2022
+ || stored.result?.actionType !== entry.action.type
2023
+ || (stored.result as Record<string, unknown> | undefined)?.replayed !== undefined
2024
+ || !['stored', 'queued', 'rejected'].includes(stored.result?.status)
2025
+ || (
2026
+ stored.result?.status === 'queued'
2027
+ && (
2028
+ typeof stored.result.queueId !== 'string'
2029
+ || !stored.result.queueId
2030
+ )
2031
+ )
2032
+ || (
2033
+ stored.result?.status !== 'queued'
2034
+ && stored.result?.queueId !== undefined
2035
+ )
2036
+ || (
2037
+ stored.result?.status === 'rejected'
2038
+ && !Number.isInteger(stored.result.smtpCode)
2039
+ )
2040
+ ) {
2041
+ throw new Error(
2042
+ `Accepted-envelope idempotency key was reused or has invalid state for ${entry.recipient}`,
2043
+ );
2044
+ }
2045
+ return { ...stored.result, replayed: true };
2046
+ }
2047
+
2048
+ private async writeAcceptedEnvelopeDispatchResult(
2049
+ result: IAcceptedEnvelopeRecipientDispatchResult,
2050
+ fingerprint: string,
2051
+ ): Promise<void> {
2052
+ const { replayed: _replayed, ...storedResult } = result;
2053
+ const stored: IStoredAcceptedEnvelopeDispatchResult = {
2054
+ schemaVersion: 1,
2055
+ fingerprint,
2056
+ expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
2057
+ result: storedResult,
2058
+ };
2059
+ await this.dcRouter.storageManager.set(
2060
+ this.acceptedEnvelopeDispatchStorageKey(result.idempotencyKey),
2061
+ JSON.stringify(stored),
2062
+ );
2063
+ }
2064
+
2065
+ /**
2066
+ * Dispatch a durably accepted raw envelope using only its persisted exact
2067
+ * action snapshots. Each recipient receives an isolated Email and session.
2068
+ * Queue-backed actions require managed queue storage and are replay-safe.
2069
+ */
2070
+ public async dispatchAcceptedEnvelope(
2071
+ rawMessage: Uint8Array,
2072
+ planArg: readonly IAcceptedEnvelopeRecipientPlan[],
2073
+ metadataArg: IAcceptedEnvelopeDispatchMetadata,
2074
+ ): Promise<IAcceptedEnvelopeDispatchResult> {
2075
+ if (!(rawMessage instanceof Uint8Array) || rawMessage.byteLength === 0) {
2076
+ throw new Error('Accepted envelope raw message must contain exact RFC822 bytes');
2077
+ }
2078
+ const rawCopy = Buffer.from(rawMessage);
2079
+ const plan = this.normalizeAcceptedEnvelopePlan(planArg);
2080
+ const metadata = this.cloneJsonSafe(metadataArg, 'metadata');
2081
+ if (!metadata?.mailFrom && metadata?.mailFrom !== '') {
2082
+ throw new Error('Accepted envelope metadata.mailFrom is required');
2083
+ }
2084
+ if (!this.dcRouter.storageManager.listPage) {
2085
+ throw new Error(
2086
+ 'Accepted-envelope dispatch requires paginated small-state storage',
2087
+ );
2088
+ }
2089
+ const hasQueueAction = plan.some((entry) => (
2090
+ entry.action.type === 'forward'
2091
+ || entry.action.type === 'deliver'
2092
+ || entry.action.type === 'process'
2093
+ ));
2094
+ if (hasQueueAction) {
2095
+ if (!this.deliveryQueue.hasManagedStorage()) {
2096
+ throw new Error(
2097
+ 'Accepted-envelope queue actions require managed queue storage',
2098
+ );
2099
+ }
2100
+ if (!this.deliveryQueue.hasPaginatedManagedStorage()) {
2101
+ throw new Error(
2102
+ 'Accepted-envelope queue actions require paginated managed queue storage',
2103
+ );
2104
+ }
2105
+ }
2106
+
2107
+ const rawDigest = plugins.crypto.createHash('sha256').update(rawCopy).digest('hex');
2108
+ const fingerprints = plan.map((entry) => plugins.crypto
2109
+ .createHash('sha256')
2110
+ .update(this.stableJson({
2111
+ rawDigest,
2112
+ metadata,
2113
+ entry,
2114
+ }))
2115
+ .digest('hex'));
2116
+
2117
+ const results: Array<IAcceptedEnvelopeRecipientDispatchResult | null> = [];
2118
+ for (let index = 0; index < plan.length; index++) {
2119
+ results.push(await this.readAcceptedEnvelopeDispatchResult(
2120
+ plan[index],
2121
+ fingerprints[index],
2122
+ ));
2123
+ }
2124
+ if (results.every(Boolean)) {
2125
+ return {
2126
+ complete: true,
2127
+ results: results as IAcceptedEnvelopeRecipientDispatchResult[],
2128
+ };
2129
+ }
2130
+
2131
+ const baseSession: IExtendedSmtpSession = {
2132
+ id: metadata.session?.id || `accepted-envelope-${rawDigest.slice(0, 16)}`,
2133
+ state: SmtpState.FINISHED,
2134
+ mailFrom: metadata.mailFrom,
2135
+ rcptTo: plan.map((entry) => entry.recipient),
2136
+ emailData: '',
2137
+ useTLS: !!metadata.session?.secure,
2138
+ connectionEnded: true,
2139
+ remoteAddress: metadata.session?.remoteAddress || '',
2140
+ clientHostname: metadata.session?.clientHostname || '',
2141
+ secure: !!metadata.session?.secure,
2142
+ authenticated: !!metadata.session?.authenticated,
2143
+ envelope: {
2144
+ mailFrom: { address: metadata.mailFrom, args: {} },
2145
+ rcptTo: plan.map((entry) => ({ address: entry.recipient, args: {} })),
2146
+ },
2147
+ };
2148
+ if (metadata.session?.user) {
2149
+ baseSession.user = structuredClone(metadata.session.user) as IExtendedSmtpSession['user'];
2150
+ } else if (metadata.session?.authenticatedUser) {
2151
+ baseSession.user = { username: metadata.session.authenticatedUser };
2152
+ }
2153
+ let parsedEmail: Email | undefined;
2154
+
2155
+ for (let index = 0; index < plan.length; index++) {
2156
+ if (results[index]) continue;
2157
+ const entry = plan[index];
2158
+ const fingerprint = fingerprints[index];
2159
+ const commonResult = {
2160
+ recipient: entry.recipient,
2161
+ localDomain: entry.localDomain,
2162
+ routeName: entry.routeName,
2163
+ source: entry.source,
2164
+ actionType: entry.action.type,
2165
+ idempotencyKey: entry.idempotencyKey,
2166
+ replayed: false,
2167
+ } as const;
2168
+
2169
+ try {
2170
+ let result: IAcceptedEnvelopeRecipientDispatchResult;
2171
+ if (entry.action.type === 'store') {
2172
+ result = { ...commonResult, status: 'stored' };
2173
+ } else {
2174
+ parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
2175
+ const route: IEmailRoute = Object.freeze({
2176
+ name: entry.routeName,
2177
+ match: Object.freeze({ recipients: entry.recipient }),
2178
+ action: entry.action,
2179
+ });
2180
+ const email = this.cloneEmailForRoutingRecipient(parsedEmail, entry.recipient);
2181
+ const session = this.scopeSessionForRoutingRecipient(baseSession, entry.recipient);
2182
+ session.id = `${baseSession.id}:${index}`;
2183
+ session.matchedRoute = route;
2184
+ session.resolvedRecipientRoutes = [{
2185
+ recipient: entry.recipient,
2186
+ localDomain: entry.localDomain,
2187
+ route,
2188
+ source: entry.source,
2189
+ }];
2190
+ try {
2191
+ const execution = await this.actionExecutor.executeActionWithResult(
2192
+ entry.action,
2193
+ email,
2194
+ { email, session },
2195
+ {
2196
+ durableQueue: {
2197
+ idempotencyKey: entry.idempotencyKey,
2198
+ fingerprint,
2199
+ },
2200
+ },
2201
+ );
2202
+ if (execution.outcome !== 'queued' || !execution.queueId) {
2203
+ throw new Error(
2204
+ `Accepted-envelope ${entry.action.type} action did not durably enqueue`,
2205
+ );
2206
+ }
2207
+ result = {
2208
+ ...commonResult,
2209
+ status: 'queued',
2210
+ queueId: execution.queueId,
2211
+ };
2212
+ } catch (error) {
2213
+ if (entry.action.type !== 'reject') throw error;
2214
+ result = {
2215
+ ...commonResult,
2216
+ status: 'rejected',
2217
+ smtpCode: Number((error as any)?.responseCode)
2218
+ || entry.action.reject?.code
2219
+ || 550,
2220
+ message: getErrorMessage(error),
2221
+ };
2222
+ }
2223
+ }
2224
+ await this.writeAcceptedEnvelopeDispatchResult(result, fingerprint);
2225
+ results[index] = result;
2226
+ } catch (error) {
2227
+ results[index] = {
2228
+ ...commonResult,
2229
+ status: 'failed',
2230
+ message: getErrorMessage(error),
2231
+ };
2232
+ }
2233
+ }
2234
+
2235
+ const finalResults = results as IAcceptedEnvelopeRecipientDispatchResult[];
2236
+ return {
2237
+ complete: finalResults.every((result) => result.status !== 'failed'),
2238
+ results: finalResults,
2239
+ };
2240
+ }
2241
+
1565
2242
  /**
1566
2243
  * Handle an emailReceived event from the Rust SMTP server.
1567
2244
  */
@@ -1632,6 +2309,21 @@ export class UnifiedEmailServer extends EventEmitter {
1632
2309
  session,
1633
2310
  );
1634
2311
  } catch (acceptanceError) {
2312
+ if (acceptanceError instanceof AcceptEnvelopeRejectionError
2313
+ && Number.isInteger(acceptanceError.smtpCode)
2314
+ && acceptanceError.smtpCode >= 500 && acceptanceError.smtpCode <= 599) {
2315
+ logger.log(
2316
+ 'warn',
2317
+ `Durable envelope rejected by consumer policy (${acceptanceError.smtpCode}): ${acceptanceError.smtpMessage}`,
2318
+ );
2319
+ await this.rustBridge.sendEmailProcessingResult({
2320
+ correlationId,
2321
+ accepted: false,
2322
+ smtpCode: acceptanceError.smtpCode,
2323
+ smtpMessage: acceptanceError.smtpMessage,
2324
+ });
2325
+ return;
2326
+ }
1635
2327
  logger.log(
1636
2328
  'error',
1637
2329
  `Durable envelope acceptance failed: ${(acceptanceError as Error).message}`,