@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.
- package/changelog.md +24 -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 +81 -1
- package/dist_ts/mail/routing/classes.unified.email.server.js +515 -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 +697 -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';
|
|
@@ -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:
|
|
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}`,
|