@push.rocks/smartmta 8.0.1 → 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 +51 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +6 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +43 -3
- package/dist_ts/mail/delivery/classes.delivery.queue.js +310 -10
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -2
- package/dist_ts/mail/delivery/classes.delivery.system.js +106 -36
- 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.domain.registry.js +5 -1
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
- package/dist_ts/mail/routing/classes.email.action.executor.js +38 -13
- package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
- package/dist_ts/mail/routing/classes.email.router.js +28 -21
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +156 -4
- package/dist_ts/mail/routing/classes.unified.email.server.js +1001 -61
- package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
- package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
- package/dist_ts/mail/routing/interfaces.d.ts +11 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +1 -1
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +6 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +429 -7
- package/ts/mail/delivery/classes.delivery.system.ts +137 -41
- package/ts/mail/interfaces.storage.ts +148 -2
- package/ts/mail/routing/classes.domain.registry.ts +4 -0
- package/ts/mail/routing/classes.email.action.executor.ts +97 -13
- package/ts/mail/routing/classes.email.router.ts +33 -20
- package/ts/mail/routing/classes.unified.email.server.ts +1367 -74
- package/ts/mail/routing/functions.routing.validation.ts +36 -0
- package/ts/mail/routing/interfaces.ts +13 -1
- package/ts/plugins.ts +2 -0
- package/ts/security/classes.rustsecuritybridge.ts +46 -1
- package/ts/security/index.ts +2 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
UnifiedDeliveryQueue,
|
|
11
11
|
type IQueueItem,
|
|
12
12
|
type IRecipientPermanentFailure,
|
|
13
|
+
type IBounceOperation,
|
|
13
14
|
} from './classes.delivery.queue.js';
|
|
14
15
|
import { Email } from '../core/classes.email.js';
|
|
15
16
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
@@ -41,6 +42,17 @@ export interface IDeliveryHandler {
|
|
|
41
42
|
deliver(item: IQueueItem): Promise<any>;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
export interface IDeliveryFailureNotice {
|
|
46
|
+
itemId: string;
|
|
47
|
+
operation: Readonly<IBounceOperation>;
|
|
48
|
+
sender: string;
|
|
49
|
+
headers: Readonly<Record<string, string>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IDeliveryFailureHandler {
|
|
53
|
+
processDeliveryFailure(notice: IDeliveryFailureNotice): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
/**
|
|
45
57
|
* Delivery options
|
|
46
58
|
*/
|
|
@@ -71,7 +83,9 @@ export interface IMultiModeDeliveryOptions {
|
|
|
71
83
|
bounceHandler?: {
|
|
72
84
|
processSmtpFailure: (recipient: string, smtpResponse: string, options: any) => Promise<any>;
|
|
73
85
|
};
|
|
74
|
-
|
|
86
|
+
/** Durable, idempotent terminal-delivery notice consumer. */
|
|
87
|
+
deliveryFailureHandler?: IDeliveryFailureHandler;
|
|
88
|
+
|
|
75
89
|
// Event hooks
|
|
76
90
|
onDeliveryStart?: (item: IQueueItem) => Promise<void>;
|
|
77
91
|
onDeliverySuccess?: (item: IQueueItem, result: any) => Promise<void>;
|
|
@@ -112,9 +126,10 @@ export interface IDeliveryStats {
|
|
|
112
126
|
*/
|
|
113
127
|
type TResolvedMultiModeDeliveryOptions = Omit<
|
|
114
128
|
Required<IMultiModeDeliveryOptions>,
|
|
115
|
-
'bounceHandler'
|
|
129
|
+
'bounceHandler' | 'deliveryFailureHandler'
|
|
116
130
|
> & {
|
|
117
131
|
bounceHandler?: NonNullable<IMultiModeDeliveryOptions['bounceHandler']>;
|
|
132
|
+
deliveryFailureHandler: IDeliveryFailureHandler;
|
|
118
133
|
};
|
|
119
134
|
|
|
120
135
|
export class MultiModeDeliverySystem extends EventEmitter {
|
|
@@ -165,6 +180,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
165
180
|
perPatternRateLimit: options.perPatternRateLimit || {},
|
|
166
181
|
processBounces: options.processBounces !== false, // Default to true
|
|
167
182
|
bounceHandler: options.bounceHandler,
|
|
183
|
+
deliveryFailureHandler: options.deliveryFailureHandler || {
|
|
184
|
+
processDeliveryFailure: async (notice) => {
|
|
185
|
+
logger.log('warn', `Terminal delivery failure persisted for ${notice.operation.recipient}`);
|
|
186
|
+
await emitSafely(this, 'deliveryFailureNotice', notice);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
168
189
|
onDeliveryStart: options.onDeliveryStart || (async () => {}),
|
|
169
190
|
onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
|
|
170
191
|
onDeliveryFailed: options.onDeliveryFailed || (async () => {})
|
|
@@ -313,34 +334,68 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
313
334
|
}
|
|
314
335
|
|
|
315
336
|
/**
|
|
316
|
-
* Drain durable
|
|
337
|
+
* Drain durable failure work with one in-process flight. Custom handlers
|
|
317
338
|
* receive operationId and must use it for their own idempotency.
|
|
318
339
|
*/
|
|
319
340
|
public async drainBounceOutbox(): Promise<void> {
|
|
320
341
|
if (this.bounceDrainPromise) return this.bounceDrainPromise;
|
|
321
342
|
const drain = (async () => {
|
|
322
|
-
if (
|
|
323
|
-
|
|
343
|
+
if (this.options.processBounces && this.options.bounceHandler) {
|
|
344
|
+
for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
|
|
345
|
+
const item = this.queue.getItem(itemId);
|
|
346
|
+
if (!item) continue;
|
|
347
|
+
const email = item.processingResult as Email;
|
|
348
|
+
try {
|
|
349
|
+
await this.options.bounceHandler.processSmtpFailure(
|
|
350
|
+
operation.recipient,
|
|
351
|
+
operation.smtpResponse,
|
|
352
|
+
{
|
|
353
|
+
operationId: operation.operationId,
|
|
354
|
+
sender: email.from,
|
|
355
|
+
originalEmailId: item.id,
|
|
356
|
+
headers: email.headers,
|
|
357
|
+
statusCode: String(operation.smtpCode),
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger.log(
|
|
363
|
+
'error',
|
|
364
|
+
`Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
|
|
365
|
+
);
|
|
366
|
+
try {
|
|
367
|
+
await this.queue.markBounceOperationFailed(
|
|
368
|
+
itemId,
|
|
369
|
+
operation.operationId,
|
|
370
|
+
(error as Error).message,
|
|
371
|
+
);
|
|
372
|
+
} catch (checkpointError) {
|
|
373
|
+
logger.log(
|
|
374
|
+
'error',
|
|
375
|
+
`Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const { itemId, operation } of this.queue.listPendingDeliveryFailureOperations()) {
|
|
324
383
|
const item = this.queue.getItem(itemId);
|
|
325
384
|
if (!item) continue;
|
|
326
385
|
const email = item.processingResult as Email;
|
|
386
|
+
const notice: IDeliveryFailureNotice = {
|
|
387
|
+
itemId,
|
|
388
|
+
operation,
|
|
389
|
+
sender: email.from,
|
|
390
|
+
headers: { ...email.headers },
|
|
391
|
+
};
|
|
327
392
|
try {
|
|
328
|
-
await this.options.
|
|
329
|
-
operation.recipient,
|
|
330
|
-
operation.smtpResponse,
|
|
331
|
-
{
|
|
332
|
-
operationId: operation.operationId,
|
|
333
|
-
sender: email.from,
|
|
334
|
-
originalEmailId: item.id,
|
|
335
|
-
headers: email.headers,
|
|
336
|
-
statusCode: String(operation.smtpCode),
|
|
337
|
-
},
|
|
338
|
-
);
|
|
393
|
+
await this.options.deliveryFailureHandler.processDeliveryFailure(notice);
|
|
339
394
|
await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
|
|
340
395
|
} catch (error) {
|
|
341
396
|
logger.log(
|
|
342
397
|
'error',
|
|
343
|
-
`
|
|
398
|
+
`Delivery-failure operation ${operation.operationId} failed: ${(error as Error).message}`,
|
|
344
399
|
);
|
|
345
400
|
try {
|
|
346
401
|
await this.queue.markBounceOperationFailed(
|
|
@@ -351,7 +406,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
351
406
|
} catch (checkpointError) {
|
|
352
407
|
logger.log(
|
|
353
408
|
'error',
|
|
354
|
-
`Could not checkpoint failed
|
|
409
|
+
`Could not checkpoint failed delivery-failure operation ${operation.operationId}: ${(checkpointError as Error).message}`,
|
|
355
410
|
);
|
|
356
411
|
}
|
|
357
412
|
}
|
|
@@ -619,6 +674,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
619
674
|
|
|
620
675
|
const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
|
|
621
676
|
retryable: deliveryError.retryable,
|
|
677
|
+
terminalFailure: {
|
|
678
|
+
failureClass: deliveryError.errorType.startsWith('self_mx_')
|
|
679
|
+
? 'localTopology'
|
|
680
|
+
: 'transport',
|
|
681
|
+
...(deliveryError.smtpCode !== undefined ? { smtpCode: deliveryError.smtpCode } : {}),
|
|
682
|
+
},
|
|
622
683
|
});
|
|
623
684
|
if (this.running) void this.drainBounceOutbox();
|
|
624
685
|
if (!transition.updated) return;
|
|
@@ -832,6 +893,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
832
893
|
&& typeof failure.smtpCode === 'number'
|
|
833
894
|
&& failure.smtpCode >= 500
|
|
834
895
|
));
|
|
896
|
+
const allLocalTopologyFailures = normalized.every((failure) => (
|
|
897
|
+
failure.errorType.startsWith('self_mx_')
|
|
898
|
+
));
|
|
835
899
|
const selected = normalized.find((failure) => failure.retryable)
|
|
836
900
|
|| normalized[normalized.length - 1];
|
|
837
901
|
const smtpCode = allPermanentRecipientRejections
|
|
@@ -839,7 +903,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
839
903
|
: selected.smtpCode;
|
|
840
904
|
return new SmtpDeliveryError({
|
|
841
905
|
message: normalized.map((failure) => failure.message).join('; '),
|
|
842
|
-
errorType: allPermanentRecipientRejections
|
|
906
|
+
errorType: allPermanentRecipientRejections
|
|
907
|
+
? 'protocol'
|
|
908
|
+
: allLocalTopologyFailures
|
|
909
|
+
? selected.errorType
|
|
910
|
+
: 'smtp_aggregate',
|
|
843
911
|
retryable,
|
|
844
912
|
...(smtpCode !== undefined ? { smtpCode } : {}),
|
|
845
913
|
phase: allPermanentRecipientRejections ? 'rcpt_to' : selected.phase,
|
|
@@ -903,37 +971,65 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
903
971
|
|
|
904
972
|
for (const [domain, recipients] of domainGroups) {
|
|
905
973
|
const allMxHosts = await this.resolveMxForDomain(domain);
|
|
974
|
+
let recipientsForMx = [...recipients];
|
|
975
|
+
const domainFailures: SmtpDeliveryError[] = [];
|
|
976
|
+
const acceptedForDomain: string[] = [];
|
|
977
|
+
const mxHosts: typeof allMxHosts = [];
|
|
978
|
+
|
|
979
|
+
// RFC 5321 §5.3.6: skip local MX identities but continue to non-local
|
|
980
|
+
// backup MX hosts. Proxy connection targets are intentionally absent
|
|
981
|
+
// from this identity check.
|
|
982
|
+
for (const mx of allMxHosts) {
|
|
983
|
+
const identity = await this.emailServer.resolveSelfMxIdentity(mx.exchange);
|
|
984
|
+
if (!identity.isSelf && !identity.indeterminate) {
|
|
985
|
+
mxHosts.push(mx);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
906
988
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
989
|
+
const startedAt = new Date();
|
|
990
|
+
const retryable = identity.source !== 'explicit';
|
|
991
|
+
const errorType = identity.indeterminate
|
|
992
|
+
? 'self_mx_dns_indeterminate'
|
|
993
|
+
: identity.source === 'explicit'
|
|
994
|
+
? 'self_mx_config'
|
|
995
|
+
: 'self_mx_loop';
|
|
996
|
+
const reason = identity.indeterminate
|
|
997
|
+
? 'could not be safely classified against current DNS and listener topology'
|
|
998
|
+
: identity.source === 'explicit'
|
|
999
|
+
? 'exactly matches a configured listener identity'
|
|
1000
|
+
: 'resolves to the current listener topology';
|
|
1001
|
+
const evidence = identity.evidence.length > 0
|
|
1002
|
+
? ` (${identity.evidence.join('; ')})`
|
|
1003
|
+
: '';
|
|
913
1004
|
const loopError = new SmtpDeliveryError({
|
|
914
|
-
message:
|
|
915
|
-
|
|
916
|
-
|
|
1005
|
+
message: identity.indeterminate
|
|
1006
|
+
? `MX ${mx.exchange} for ${domain} ${reason}${evidence}`
|
|
1007
|
+
: `MX ${mx.exchange} for ${domain} points back at this server because it ${reason}${evidence}`,
|
|
1008
|
+
errorType,
|
|
1009
|
+
retryable,
|
|
917
1010
|
phase: 'connect',
|
|
918
|
-
transactionLog: [
|
|
1011
|
+
transactionLog: [{
|
|
1012
|
+
timestampMs: Date.now(),
|
|
1013
|
+
phase: 'connect',
|
|
1014
|
+
direction: 'system',
|
|
1015
|
+
text: `Skipped ${mx.exchange}: ${reason}`,
|
|
1016
|
+
}],
|
|
919
1017
|
recipients,
|
|
920
1018
|
recipientDomain: domain,
|
|
921
1019
|
});
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1020
|
+
await this.persistSmtpAttempt({
|
|
1021
|
+
item,
|
|
1022
|
+
targetHost: mx.exchange,
|
|
1023
|
+
targetPort: 25,
|
|
1024
|
+
recipientDomain: domain,
|
|
1025
|
+
recipients,
|
|
1026
|
+
startedAt,
|
|
1027
|
+
error: loopError,
|
|
928
1028
|
});
|
|
929
|
-
|
|
930
|
-
|
|
1029
|
+
domainFailures.push(loopError);
|
|
1030
|
+
logger.log(retryable ? 'warn' : 'error', `MTA: skipped MX ${mx.exchange} for ${domain}: ${loopError.message}`);
|
|
931
1031
|
}
|
|
932
1032
|
|
|
933
|
-
let recipientsForMx = [...recipients];
|
|
934
|
-
const domainFailures: SmtpDeliveryError[] = [];
|
|
935
|
-
const acceptedForDomain: string[] = [];
|
|
936
|
-
|
|
937
1033
|
for (const mx of mxHosts) {
|
|
938
1034
|
if (recipientsForMx.length === 0) break;
|
|
939
1035
|
const startedAt = new Date();
|
|
@@ -9,6 +9,23 @@ export interface IStorageManager {
|
|
|
9
9
|
get(key: string): Promise<string | null>;
|
|
10
10
|
set(key: string, value: string): Promise<void>;
|
|
11
11
|
list(prefix: string): Promise<string[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Return at most `limit` keys from one backend-bounded page.
|
|
14
|
+
*
|
|
15
|
+
* Continuation tokens are opaque, scoped to the supplied prefix, and must
|
|
16
|
+
* only be passed back to the same storage implementation. A returned token
|
|
17
|
+
* must advance a non-overlapping traversal, remain valid after any keys in
|
|
18
|
+
* its page are deleted, and eventually exhaust an otherwise stable prefix.
|
|
19
|
+
* Offset-based tokens do not satisfy this deletion-stability requirement.
|
|
20
|
+
*
|
|
21
|
+
* This optional method preserves compatibility for existing callers;
|
|
22
|
+
* features which need bounded cleanup must require it explicitly and must
|
|
23
|
+
* never fall back to list().
|
|
24
|
+
*/
|
|
25
|
+
listPage?(
|
|
26
|
+
prefix: string,
|
|
27
|
+
options: IStorageListPageOptions,
|
|
28
|
+
): Promise<IStorageListPageResult>;
|
|
12
29
|
delete(key: string): Promise<void>;
|
|
13
30
|
}
|
|
14
31
|
|
|
@@ -17,18 +34,131 @@ export interface IBlobStorageManager {
|
|
|
17
34
|
get(key: string): Promise<Buffer | null>;
|
|
18
35
|
set(key: string, value: Buffer): Promise<void>;
|
|
19
36
|
list(prefix: string): Promise<string[]>;
|
|
37
|
+
/** Backend-bounded counterpart to list(); see IStorageManager.listPage. */
|
|
38
|
+
listPage?(
|
|
39
|
+
prefix: string,
|
|
40
|
+
options: IStorageListPageOptions,
|
|
41
|
+
): Promise<IStorageListPageResult>;
|
|
20
42
|
delete(key: string): Promise<void>;
|
|
21
43
|
}
|
|
22
44
|
|
|
45
|
+
export interface IStorageListPageOptions {
|
|
46
|
+
/** Positive maximum number of keys the backend may return. */
|
|
47
|
+
readonly limit: number;
|
|
48
|
+
/** Opaque token returned by the immediately preceding page. */
|
|
49
|
+
readonly continuationToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IStorageListPageResult {
|
|
53
|
+
/** Unique keys under the requested prefix; length must not exceed limit. */
|
|
54
|
+
readonly keys: string[];
|
|
55
|
+
/** Omitted when the prefix has been exhausted. */
|
|
56
|
+
readonly continuationToken?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Hard upper bound for one SmartMTA storage listing page. */
|
|
60
|
+
export const SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT = 1_000;
|
|
61
|
+
|
|
62
|
+
function assertValidListPageOptions(options: IStorageListPageOptions): void {
|
|
63
|
+
if (
|
|
64
|
+
!Number.isInteger(options.limit)
|
|
65
|
+
|| options.limit < 1
|
|
66
|
+
|| options.limit > SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Storage list page limit must be an integer from 1 to ${SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
options.continuationToken !== undefined
|
|
74
|
+
&& (
|
|
75
|
+
typeof options.continuationToken !== 'string'
|
|
76
|
+
|| options.continuationToken.length === 0
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error('Storage list page continuation token must be a non-empty string');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lowerBound(sortedKeys: readonly string[], target: string): number {
|
|
84
|
+
let low = 0;
|
|
85
|
+
let high = sortedKeys.length;
|
|
86
|
+
while (low < high) {
|
|
87
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
88
|
+
if (sortedKeys[middle] < target) low = middle + 1;
|
|
89
|
+
else high = middle;
|
|
90
|
+
}
|
|
91
|
+
return low;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function upperBound(sortedKeys: readonly string[], target: string): number {
|
|
95
|
+
let low = 0;
|
|
96
|
+
let high = sortedKeys.length;
|
|
97
|
+
while (low < high) {
|
|
98
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
99
|
+
if (sortedKeys[middle] <= target) low = middle + 1;
|
|
100
|
+
else high = middle;
|
|
101
|
+
}
|
|
102
|
+
return low;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function insertSortedKey(sortedKeys: string[], key: string): void {
|
|
106
|
+
sortedKeys.splice(lowerBound(sortedKeys, key), 0, key);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeSortedKey(sortedKeys: string[], key: string): void {
|
|
110
|
+
const index = lowerBound(sortedKeys, key);
|
|
111
|
+
if (sortedKeys[index] === key) sortedKeys.splice(index, 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function listSortedPrefixPage(
|
|
115
|
+
sortedKeys: readonly string[],
|
|
116
|
+
prefix: string,
|
|
117
|
+
options: IStorageListPageOptions,
|
|
118
|
+
): IStorageListPageResult {
|
|
119
|
+
assertValidListPageOptions(options);
|
|
120
|
+
if (
|
|
121
|
+
options.continuationToken !== undefined
|
|
122
|
+
&& !options.continuationToken.startsWith(prefix)
|
|
123
|
+
) {
|
|
124
|
+
throw new Error('Storage list page continuation token does not belong to the prefix');
|
|
125
|
+
}
|
|
126
|
+
let index = options.continuationToken === undefined
|
|
127
|
+
? lowerBound(sortedKeys, prefix)
|
|
128
|
+
: Math.max(
|
|
129
|
+
lowerBound(sortedKeys, prefix),
|
|
130
|
+
upperBound(sortedKeys, options.continuationToken),
|
|
131
|
+
);
|
|
132
|
+
const keys: string[] = [];
|
|
133
|
+
while (index < sortedKeys.length && keys.length < options.limit) {
|
|
134
|
+
const key = sortedKeys[index];
|
|
135
|
+
if (!key.startsWith(prefix)) break;
|
|
136
|
+
keys.push(key);
|
|
137
|
+
index++;
|
|
138
|
+
}
|
|
139
|
+
const hasMore = (
|
|
140
|
+
index < sortedKeys.length
|
|
141
|
+
&& sortedKeys[index].startsWith(prefix)
|
|
142
|
+
);
|
|
143
|
+
return {
|
|
144
|
+
keys,
|
|
145
|
+
...(hasMore && keys.length > 0
|
|
146
|
+
? { continuationToken: keys[keys.length - 1] }
|
|
147
|
+
: {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
23
151
|
/** In-memory small-state store for tests and explicitly non-durable runtimes. */
|
|
24
152
|
export class MemoryStorageManager implements IStorageManager {
|
|
25
153
|
private readonly entries = new Map<string, string>();
|
|
154
|
+
private readonly sortedKeys: string[] = [];
|
|
26
155
|
|
|
27
156
|
public async get(key: string): Promise<string | null> {
|
|
28
157
|
return this.entries.get(key) ?? null;
|
|
29
158
|
}
|
|
30
159
|
|
|
31
160
|
public async set(key: string, value: string): Promise<void> {
|
|
161
|
+
if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
|
|
32
162
|
this.entries.set(key, value);
|
|
33
163
|
}
|
|
34
164
|
|
|
@@ -36,14 +166,22 @@ export class MemoryStorageManager implements IStorageManager {
|
|
|
36
166
|
return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
|
|
37
167
|
}
|
|
38
168
|
|
|
169
|
+
public async listPage(
|
|
170
|
+
prefix: string,
|
|
171
|
+
options: IStorageListPageOptions,
|
|
172
|
+
): Promise<IStorageListPageResult> {
|
|
173
|
+
return listSortedPrefixPage(this.sortedKeys, prefix, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
39
176
|
public async delete(key: string): Promise<void> {
|
|
40
|
-
this.entries.delete(key);
|
|
177
|
+
if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
|
|
41
178
|
}
|
|
42
179
|
}
|
|
43
180
|
|
|
44
181
|
/** In-memory blob store for tests and explicitly non-durable runtimes. */
|
|
45
182
|
export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
46
183
|
private readonly entries = new Map<string, Buffer>();
|
|
184
|
+
private readonly sortedKeys: string[] = [];
|
|
47
185
|
|
|
48
186
|
public async get(key: string): Promise<Buffer | null> {
|
|
49
187
|
const value = this.entries.get(key);
|
|
@@ -51,6 +189,7 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
|
51
189
|
}
|
|
52
190
|
|
|
53
191
|
public async set(key: string, value: Buffer): Promise<void> {
|
|
192
|
+
if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
|
|
54
193
|
this.entries.set(key, Buffer.from(value));
|
|
55
194
|
}
|
|
56
195
|
|
|
@@ -58,8 +197,15 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
|
58
197
|
return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
|
|
59
198
|
}
|
|
60
199
|
|
|
200
|
+
public async listPage(
|
|
201
|
+
prefix: string,
|
|
202
|
+
options: IStorageListPageOptions,
|
|
203
|
+
): Promise<IStorageListPageResult> {
|
|
204
|
+
return listSortedPrefixPage(this.sortedKeys, prefix, options);
|
|
205
|
+
}
|
|
206
|
+
|
|
61
207
|
public async delete(key: string): Promise<void> {
|
|
62
|
-
this.entries.delete(key);
|
|
208
|
+
if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
|
|
63
209
|
}
|
|
64
210
|
}
|
|
65
211
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IEmailDomainConfig } from './interfaces.js';
|
|
2
2
|
import { logger } from '../../logger.js';
|
|
3
|
+
import { assertValidInboundPolicy } from './functions.routing.validation.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Registry for email domain configurations
|
|
@@ -30,6 +31,9 @@ export class DomainRegistry {
|
|
|
30
31
|
|
|
31
32
|
// Process and store domain configurations
|
|
32
33
|
for (const config of domainConfigs) {
|
|
34
|
+
if (config.inboundPolicy) {
|
|
35
|
+
assertValidInboundPolicy(config.inboundPolicy, config.domain);
|
|
36
|
+
}
|
|
33
37
|
const processedConfig = this.applyDefaults(config);
|
|
34
38
|
this.domains.set(config.domain.toLowerCase(), processedConfig);
|
|
35
39
|
logger.log('info', `Registered domain: ${config.domain} with DNS mode: ${processedConfig.dnsMode}`);
|
|
@@ -30,6 +30,22 @@ export interface IActionExecutorDeps {
|
|
|
30
30
|
deliveryQueue: UnifiedDeliveryQueue;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface IActionExecutionOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Queue the action with a deterministic durable id instead of performing
|
|
36
|
+
* direct delivery. Used when replaying an already-accepted envelope.
|
|
37
|
+
*/
|
|
38
|
+
durableQueue?: {
|
|
39
|
+
idempotencyKey: string;
|
|
40
|
+
fingerprint: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IActionExecutionResult {
|
|
45
|
+
outcome: 'completed' | 'queued';
|
|
46
|
+
queueId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
/**
|
|
34
50
|
* Executes email routing actions (forward, process, deliver, reject)
|
|
35
51
|
*/
|
|
@@ -61,26 +77,45 @@ export class EmailActionExecutor {
|
|
|
61
77
|
}
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
async executeAction(
|
|
80
|
+
async executeAction(
|
|
81
|
+
action: IEmailAction,
|
|
82
|
+
email: Email,
|
|
83
|
+
context: IEmailContext,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
await this.executeActionWithResult(action, email, context);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async executeActionWithResult(
|
|
89
|
+
action: IEmailAction,
|
|
90
|
+
email: Email,
|
|
91
|
+
context: IEmailContext,
|
|
92
|
+
options: IActionExecutionOptions = {},
|
|
93
|
+
): Promise<IActionExecutionResult> {
|
|
65
94
|
switch (action.type) {
|
|
66
95
|
case 'forward':
|
|
67
|
-
|
|
68
|
-
break;
|
|
96
|
+
return this.handleForwardAction(action, email, context, options);
|
|
69
97
|
case 'process':
|
|
70
|
-
|
|
71
|
-
break;
|
|
98
|
+
return this.handleProcessAction(action, email, context, options);
|
|
72
99
|
case 'deliver':
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
return this.handleDeliverAction(action, email, context, options);
|
|
101
|
+
case 'store':
|
|
102
|
+
throw new Error(
|
|
103
|
+
'Store actions require raw SMTP context and an awaited hooks.onAcceptEnvelope handler',
|
|
104
|
+
);
|
|
75
105
|
case 'reject':
|
|
76
106
|
await this.handleRejectAction(action, email, context);
|
|
77
|
-
|
|
107
|
+
throw new Error('Reject action unexpectedly returned');
|
|
78
108
|
default:
|
|
79
109
|
throw new Error(`Unknown action type: ${(action as any).type}`);
|
|
80
110
|
}
|
|
81
111
|
}
|
|
82
112
|
|
|
83
|
-
private async handleForwardAction(
|
|
113
|
+
private async handleForwardAction(
|
|
114
|
+
action: IEmailAction,
|
|
115
|
+
email: Email,
|
|
116
|
+
context: IEmailContext,
|
|
117
|
+
options: IActionExecutionOptions,
|
|
118
|
+
): Promise<IActionExecutionResult> {
|
|
84
119
|
if (!action.forward) {
|
|
85
120
|
throw new Error('Forward action requires forward configuration');
|
|
86
121
|
}
|
|
@@ -101,6 +136,20 @@ export class EmailActionExecutor {
|
|
|
101
136
|
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
|
102
137
|
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
|
103
138
|
|
|
139
|
+
if (options.durableQueue) {
|
|
140
|
+
const queueId = await this.deps.deliveryQueue.enqueue(
|
|
141
|
+
email,
|
|
142
|
+
'forward',
|
|
143
|
+
context.session.matchedRoute!,
|
|
144
|
+
{
|
|
145
|
+
idempotencyKey: options.durableQueue.idempotencyKey,
|
|
146
|
+
idempotencyFingerprint: options.durableQueue.fingerprint,
|
|
147
|
+
requireDurable: true,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
return { outcome: 'queued', queueId };
|
|
151
|
+
}
|
|
152
|
+
|
|
104
153
|
try {
|
|
105
154
|
// Send email via Rust SMTP client
|
|
106
155
|
const result = await this.deps.sendOutboundEmail(host, port, email, {
|
|
@@ -124,6 +173,7 @@ export class EmailActionExecutor {
|
|
|
124
173
|
},
|
|
125
174
|
success: true
|
|
126
175
|
}));
|
|
176
|
+
return { outcome: 'completed' };
|
|
127
177
|
} catch (error) {
|
|
128
178
|
const errorMessage = getErrorMessage(error);
|
|
129
179
|
logger.log('error', `Failed to forward email: ${errorMessage}`);
|
|
@@ -168,7 +218,12 @@ export class EmailActionExecutor {
|
|
|
168
218
|
}
|
|
169
219
|
}
|
|
170
220
|
|
|
171
|
-
private async handleProcessAction(
|
|
221
|
+
private async handleProcessAction(
|
|
222
|
+
action: IEmailAction,
|
|
223
|
+
email: Email,
|
|
224
|
+
context: IEmailContext,
|
|
225
|
+
options: IActionExecutionOptions,
|
|
226
|
+
): Promise<IActionExecutionResult> {
|
|
172
227
|
logger.log('info', `Processing email with action options`);
|
|
173
228
|
|
|
174
229
|
// Apply scanning if requested
|
|
@@ -178,18 +233,47 @@ export class EmailActionExecutor {
|
|
|
178
233
|
|
|
179
234
|
// Queue for delivery
|
|
180
235
|
const queue = action.process?.queue || 'normal';
|
|
181
|
-
await this.deps.deliveryQueue.enqueue(
|
|
236
|
+
const queueId = await this.deps.deliveryQueue.enqueue(
|
|
237
|
+
email,
|
|
238
|
+
'process',
|
|
239
|
+
context.session.matchedRoute!,
|
|
240
|
+
options.durableQueue
|
|
241
|
+
? {
|
|
242
|
+
idempotencyKey: options.durableQueue.idempotencyKey,
|
|
243
|
+
idempotencyFingerprint: options.durableQueue.fingerprint,
|
|
244
|
+
requireDurable: true,
|
|
245
|
+
}
|
|
246
|
+
: {},
|
|
247
|
+
);
|
|
182
248
|
|
|
183
249
|
logger.log('info', `Email queued for delivery in ${queue} queue`);
|
|
250
|
+
return { outcome: 'queued', queueId };
|
|
184
251
|
}
|
|
185
252
|
|
|
186
|
-
private async handleDeliverAction(
|
|
253
|
+
private async handleDeliverAction(
|
|
254
|
+
_action: IEmailAction,
|
|
255
|
+
email: Email,
|
|
256
|
+
context: IEmailContext,
|
|
257
|
+
options: IActionExecutionOptions,
|
|
258
|
+
): Promise<IActionExecutionResult> {
|
|
187
259
|
logger.log('info', `Delivering email locally`);
|
|
188
260
|
|
|
189
261
|
// Queue for local delivery
|
|
190
|
-
await this.deps.deliveryQueue.enqueue(
|
|
262
|
+
const queueId = await this.deps.deliveryQueue.enqueue(
|
|
263
|
+
email,
|
|
264
|
+
'mta',
|
|
265
|
+
context.session.matchedRoute!,
|
|
266
|
+
options.durableQueue
|
|
267
|
+
? {
|
|
268
|
+
idempotencyKey: options.durableQueue.idempotencyKey,
|
|
269
|
+
idempotencyFingerprint: options.durableQueue.fingerprint,
|
|
270
|
+
requireDurable: true,
|
|
271
|
+
}
|
|
272
|
+
: {},
|
|
273
|
+
);
|
|
191
274
|
|
|
192
275
|
logger.log('info', 'Email queued for local delivery');
|
|
276
|
+
return { outcome: 'queued', queueId };
|
|
193
277
|
}
|
|
194
278
|
|
|
195
279
|
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
|