@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.
- package/changelog.md +16 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +28 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.js +220 -3
- package/dist_ts/mail/interfaces.storage.d.ts +34 -0
- package/dist_ts/mail/interfaces.storage.js +89 -3
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
- package/dist_ts/mail/routing/classes.email.action.executor.js +36 -13
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +71 -1
- package/dist_ts/mail/routing/classes.unified.email.server.js +488 -5
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +303 -1
- package/ts/mail/interfaces.storage.ts +148 -2
- package/ts/mail/routing/classes.email.action.executor.ts +93 -13
- package/ts/mail/routing/classes.unified.email.server.ts +667 -5
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
*/
|