@serve.zone/coremail 1.0.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/.smartconfig.json +50 -0
- package/changelog.md +10 -0
- package/cli.js +4 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.auth.d.ts +45 -0
- package/dist_ts/classes.auth.js +204 -0
- package/dist_ts/classes.config.d.ts +2 -0
- package/dist_ts/classes.config.js +72 -0
- package/dist_ts/classes.coremail.d.ts +41 -0
- package/dist_ts/classes.coremail.js +155 -0
- package/dist_ts/classes.desiredstate.d.ts +40 -0
- package/dist_ts/classes.desiredstate.js +382 -0
- package/dist_ts/classes.gateway.d.ts +62 -0
- package/dist_ts/classes.gateway.js +669 -0
- package/dist_ts/classes.inbound.d.ts +49 -0
- package/dist_ts/classes.inbound.js +702 -0
- package/dist_ts/classes.maintenance.d.ts +16 -0
- package/dist_ts/classes.maintenance.js +339 -0
- package/dist_ts/classes.models.d.ts +161 -0
- package/dist_ts/classes.models.js +595 -0
- package/dist_ts/classes.server.d.ts +40 -0
- package/dist_ts/classes.server.js +309 -0
- package/dist_ts/classes.storage.d.ts +32 -0
- package/dist_ts/classes.storage.js +317 -0
- package/dist_ts/classes.submissions.d.ts +33 -0
- package/dist_ts/classes.submissions.js +385 -0
- package/dist_ts/classes.transfer.d.ts +41 -0
- package/dist_ts/classes.transfer.js +289 -0
- package/dist_ts/coremail.cursor.d.ts +20 -0
- package/dist_ts/coremail.cursor.js +191 -0
- package/dist_ts/coremail.log.d.ts +3 -0
- package/dist_ts/coremail.log.js +11 -0
- package/dist_ts/coremail.mime.d.ts +8 -0
- package/dist_ts/coremail.mime.js +63 -0
- package/dist_ts/coremail.persistence.d.ts +148 -0
- package/dist_ts/coremail.persistence.js +695 -0
- package/dist_ts/coremail.quota.d.ts +29 -0
- package/dist_ts/coremail.quota.js +149 -0
- package/dist_ts/coremail.selectors.d.ts +3 -0
- package/dist_ts/coremail.selectors.js +21 -0
- package/dist_ts/coremail.validation.d.ts +9 -0
- package/dist_ts/coremail.validation.js +325 -0
- package/dist_ts/index.d.ts +4 -0
- package/dist_ts/index.js +39 -0
- package/dist_ts/interfaces.d.ts +92 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/plugins.d.ts +11 -0
- package/dist_ts/plugins.js +12 -0
- package/license.md +19 -0
- package/package.json +48 -0
- package/readme.md +199 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.auth.ts +321 -0
- package/ts/classes.config.ts +104 -0
- package/ts/classes.coremail.ts +249 -0
- package/ts/classes.desiredstate.ts +508 -0
- package/ts/classes.gateway.ts +811 -0
- package/ts/classes.inbound.ts +898 -0
- package/ts/classes.maintenance.ts +335 -0
- package/ts/classes.models.ts +530 -0
- package/ts/classes.server.ts +444 -0
- package/ts/classes.storage.ts +448 -0
- package/ts/classes.submissions.ts +554 -0
- package/ts/classes.transfer.ts +379 -0
- package/ts/coremail.cursor.ts +328 -0
- package/ts/coremail.log.ts +21 -0
- package/ts/coremail.mime.ts +94 -0
- package/ts/coremail.persistence.ts +1156 -0
- package/ts/coremail.quota.ts +214 -0
- package/ts/coremail.selectors.ts +35 -0
- package/ts/coremail.validation.ts +446 -0
- package/ts/index.ts +38 -0
- package/ts/interfaces.ts +109 -0
- package/ts/plugins.ts +11 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { type ICoreMailModels } from './classes.models.js';
|
|
3
|
+
import { CoreMailTransferService } from './classes.transfer.js';
|
|
4
|
+
import type { CoreMailInboundService } from './classes.inbound.js';
|
|
5
|
+
import type {
|
|
6
|
+
ICoreMailConfig,
|
|
7
|
+
ITransferAuthority,
|
|
8
|
+
} from './interfaces.js';
|
|
9
|
+
import { logCoreMailFailure } from './coremail.log.js';
|
|
10
|
+
import type { ICoreMailSubmissionRecord } from './coremail.persistence.js';
|
|
11
|
+
import {
|
|
12
|
+
requireCoreMailIdentifier,
|
|
13
|
+
requireCoreMailSafeInteger,
|
|
14
|
+
requireCoreMailUuid,
|
|
15
|
+
} from './coremail.selectors.js';
|
|
16
|
+
|
|
17
|
+
type TDesiredState = plugins.serveZoneInterfaces.data.ICoreMailDesiredState;
|
|
18
|
+
type TGatewayStatus = plugins.serveZoneInterfaces.data.ICoreMailGatewayOutboundStatus;
|
|
19
|
+
type TGatewayDesired = plugins.serveZoneInterfaces.data.ICoreMailGatewayDesiredState;
|
|
20
|
+
type TStoredSubmission =
|
|
21
|
+
plugins.smartdata.TStoredDocument<ICoreMailSubmissionRecord>;
|
|
22
|
+
|
|
23
|
+
const gatewayAuthority = (serviceIdArg: string): ITransferAuthority => ({
|
|
24
|
+
kind: 'gateway',
|
|
25
|
+
tenantId: '-',
|
|
26
|
+
serviceId: serviceIdArg,
|
|
27
|
+
bindingId: '-',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const normalizeTransferOrigin = (valueArg: unknown): string => {
|
|
31
|
+
if (typeof valueArg !== 'string') {
|
|
32
|
+
throw new Error('CoreMail gateway returned an invalid transfer origin.');
|
|
33
|
+
}
|
|
34
|
+
const url = new URL(valueArg);
|
|
35
|
+
if (
|
|
36
|
+
url.protocol !== 'https:'
|
|
37
|
+
|| url.username
|
|
38
|
+
|| url.password
|
|
39
|
+
|| url.search
|
|
40
|
+
|| url.hash
|
|
41
|
+
|| url.pathname !== '/'
|
|
42
|
+
|| url.origin !== valueArg
|
|
43
|
+
) {
|
|
44
|
+
throw new Error('CoreMail gateway returned a non-canonical transfer origin.');
|
|
45
|
+
}
|
|
46
|
+
return valueArg;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const requireMatchingCoreMailTransferOrigin = (
|
|
50
|
+
valueArg: unknown,
|
|
51
|
+
expectedArg: string,
|
|
52
|
+
): string => {
|
|
53
|
+
const transferOrigin = normalizeTransferOrigin(valueArg);
|
|
54
|
+
if (transferOrigin !== expectedArg) {
|
|
55
|
+
throw new Error('CoreMail gateway transfer origin does not match desired state.');
|
|
56
|
+
}
|
|
57
|
+
return transferOrigin;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const normalizeGatewayStatus = (valueArg: unknown): TGatewayStatus => {
|
|
61
|
+
try {
|
|
62
|
+
return plugins.serveZoneInterfaces.data
|
|
63
|
+
.normalizeCoreMailGatewayOutboundStatus(valueArg);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error('CoreMail gateway returned an invalid outbound status.');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const waitForAbortableDelay = async (
|
|
70
|
+
delayMsArg: number,
|
|
71
|
+
signalArg: AbortSignal,
|
|
72
|
+
): Promise<void> => {
|
|
73
|
+
if (signalArg.aborted) return;
|
|
74
|
+
await new Promise<void>((resolveArg) => {
|
|
75
|
+
const timeout = setTimeout(done, delayMsArg);
|
|
76
|
+
const onAbort = () => done();
|
|
77
|
+
function done() {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
signalArg.removeEventListener('abort', onAbort);
|
|
80
|
+
resolveArg();
|
|
81
|
+
}
|
|
82
|
+
signalArg.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export class CoreMailGateway {
|
|
87
|
+
private socket: plugins.typedsocket.TypedSocket | null = null;
|
|
88
|
+
private socketSignature = '';
|
|
89
|
+
private authenticatedSignature = '';
|
|
90
|
+
private transferOrigin: string | null = null;
|
|
91
|
+
private readonly stopController = new AbortController();
|
|
92
|
+
private reconcileTask: Promise<void> | null = null;
|
|
93
|
+
private statusSubscription: { unsubscribe(): void } | null = null;
|
|
94
|
+
private authenticationTask: Promise<void> = Promise.resolve();
|
|
95
|
+
private authenticatingSignature = '';
|
|
96
|
+
|
|
97
|
+
public constructor(
|
|
98
|
+
private readonly config: ICoreMailConfig,
|
|
99
|
+
private readonly models: ICoreMailModels,
|
|
100
|
+
private readonly transfers: CoreMailTransferService,
|
|
101
|
+
private readonly inbound: CoreMailInboundService,
|
|
102
|
+
private readonly getDesiredState: () => Promise<TDesiredState | null>,
|
|
103
|
+
private readonly now: () => number = Date.now,
|
|
104
|
+
) {}
|
|
105
|
+
|
|
106
|
+
public start(): void {
|
|
107
|
+
if (this.reconcileTask) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.reconcileTask = this.runReconciliationLoop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public async stop(): Promise<void> {
|
|
114
|
+
this.stopController.abort();
|
|
115
|
+
await this.disconnect();
|
|
116
|
+
await this.reconcileTask?.catch(() => undefined);
|
|
117
|
+
this.reconcileTask = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public isReady(): boolean {
|
|
121
|
+
return Boolean(
|
|
122
|
+
this.socket
|
|
123
|
+
&& this.socket.getStatus() === 'connected'
|
|
124
|
+
&& this.authenticatedSignature === this.socketSignature
|
|
125
|
+
&& this.transferOrigin,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public getTransferOrigin(): string | null {
|
|
130
|
+
return this.transferOrigin;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async runReconciliationLoop(): Promise<void> {
|
|
134
|
+
while (!this.stopController.signal.aborted) {
|
|
135
|
+
try {
|
|
136
|
+
const desired = await this.getDesiredState();
|
|
137
|
+
const gateway = desired?.gateway;
|
|
138
|
+
if (!gateway) {
|
|
139
|
+
await this.disconnect();
|
|
140
|
+
} else {
|
|
141
|
+
const signature = JSON.stringify(gateway);
|
|
142
|
+
if (
|
|
143
|
+
!this.socket
|
|
144
|
+
|| signature !== this.socketSignature
|
|
145
|
+
|| this.socket.getStatus() !== 'connected'
|
|
146
|
+
) {
|
|
147
|
+
await this.connect(gateway, signature);
|
|
148
|
+
} else if (
|
|
149
|
+
this.authenticatedSignature !== signature
|
|
150
|
+
&& this.authenticatingSignature !== signature
|
|
151
|
+
) {
|
|
152
|
+
this.queueAuthentication(gateway, signature);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
await logCoreMailFailure('gateway', 'RECONCILIATION_FAILED')
|
|
157
|
+
.catch(() => undefined);
|
|
158
|
+
await this.disconnect();
|
|
159
|
+
}
|
|
160
|
+
await waitForAbortableDelay(1_000, this.stopController.signal);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async connect(
|
|
165
|
+
gatewayArg: TGatewayDesired,
|
|
166
|
+
signatureArg: string,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
await this.disconnect();
|
|
169
|
+
if (this.stopController.signal.aborted) return;
|
|
170
|
+
const router = new plugins.typedrequest.TypedRouter();
|
|
171
|
+
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
172
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
|
|
173
|
+
>('coreMailGatewayResolveRecipients', async (requestArg) => {
|
|
174
|
+
this.requireAuthenticated(signatureArg);
|
|
175
|
+
return await this.inbound.resolveRecipients(requestArg);
|
|
176
|
+
}));
|
|
177
|
+
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
178
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
|
|
179
|
+
>('coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
|
|
180
|
+
this.requireAuthenticated(signatureArg);
|
|
181
|
+
return await this.inbound.prepareInboundHandoff(requestArg);
|
|
182
|
+
}));
|
|
183
|
+
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
184
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
|
|
185
|
+
>('coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
|
|
186
|
+
this.requireAuthenticated(signatureArg);
|
|
187
|
+
return await this.inbound.completeInboundHandoff(requestArg);
|
|
188
|
+
}));
|
|
189
|
+
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
190
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
|
|
191
|
+
>('coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
|
|
192
|
+
this.requireAuthenticated(signatureArg);
|
|
193
|
+
return await this.completeOutboundHandoff(requestArg);
|
|
194
|
+
}));
|
|
195
|
+
const socket = await plugins.typedsocket.TypedSocket.createClient(
|
|
196
|
+
router,
|
|
197
|
+
gatewayArg.endpointUrl,
|
|
198
|
+
{
|
|
199
|
+
autoReconnect: false,
|
|
200
|
+
maxRetries: 0,
|
|
201
|
+
initialBackoffMs: 500,
|
|
202
|
+
maxBackoffMs: 15_000,
|
|
203
|
+
abortSignal: this.stopController.signal,
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
if (this.stopController.signal.aborted) {
|
|
207
|
+
await socket.stop();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.socket = socket;
|
|
211
|
+
this.socketSignature = signatureArg;
|
|
212
|
+
this.statusSubscription = socket.statusSubject.subscribe((statusArg) => {
|
|
213
|
+
if (statusArg === 'connected') {
|
|
214
|
+
this.queueAuthentication(gatewayArg, signatureArg);
|
|
215
|
+
} else {
|
|
216
|
+
this.authenticatedSignature = '';
|
|
217
|
+
this.transferOrigin = null;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
this.queueAuthentication(gatewayArg, signatureArg);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private queueAuthentication(
|
|
224
|
+
gatewayArg: TGatewayDesired,
|
|
225
|
+
signatureArg: string,
|
|
226
|
+
): void {
|
|
227
|
+
this.authenticationTask = this.authenticationTask
|
|
228
|
+
.catch(() => undefined)
|
|
229
|
+
.then(async () => {
|
|
230
|
+
if (
|
|
231
|
+
this.stopController.signal.aborted
|
|
232
|
+
|| !this.socket
|
|
233
|
+
|| this.socketSignature !== signatureArg
|
|
234
|
+
|| this.socket.getStatus() !== 'connected'
|
|
235
|
+
) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.authenticatingSignature = signatureArg;
|
|
239
|
+
const credentialSecret = this.config.resolveRuntimeSecret(
|
|
240
|
+
gatewayArg.credentialSecretKey,
|
|
241
|
+
);
|
|
242
|
+
const response = await this.socket.createTypedRequest<
|
|
243
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayAuthenticate
|
|
244
|
+
>('coreMailGatewayAuthenticate', undefined, {
|
|
245
|
+
timeoutMs: 5_000,
|
|
246
|
+
abortSignal: this.stopController.signal,
|
|
247
|
+
}).fire({
|
|
248
|
+
credentialId: gatewayArg.credentialId,
|
|
249
|
+
credentialVersion: gatewayArg.credentialVersion,
|
|
250
|
+
credentialSecret,
|
|
251
|
+
});
|
|
252
|
+
if (
|
|
253
|
+
response.authenticated !== true
|
|
254
|
+
|| response.credentialId !== gatewayArg.credentialId
|
|
255
|
+
|| response.credentialVersion !== gatewayArg.credentialVersion
|
|
256
|
+
|| this.socketSignature !== signatureArg
|
|
257
|
+
) {
|
|
258
|
+
throw new Error('CoreMail gateway authentication result is invalid.');
|
|
259
|
+
}
|
|
260
|
+
const transferOrigin = requireMatchingCoreMailTransferOrigin(
|
|
261
|
+
response.coreMailTransferOrigin,
|
|
262
|
+
gatewayArg.coreMailTransferOrigin,
|
|
263
|
+
);
|
|
264
|
+
this.transferOrigin = transferOrigin;
|
|
265
|
+
this.authenticatedSignature = signatureArg;
|
|
266
|
+
})
|
|
267
|
+
.catch(async () => {
|
|
268
|
+
if (this.socketSignature === signatureArg) {
|
|
269
|
+
this.authenticatedSignature = '';
|
|
270
|
+
this.transferOrigin = null;
|
|
271
|
+
}
|
|
272
|
+
await logCoreMailFailure('gateway', 'AUTHENTICATION_FAILED')
|
|
273
|
+
.catch(() => undefined);
|
|
274
|
+
})
|
|
275
|
+
.finally(() => {
|
|
276
|
+
if (this.authenticatingSignature === signatureArg) {
|
|
277
|
+
this.authenticatingSignature = '';
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private requireAuthenticated(signatureArg: string): void {
|
|
283
|
+
if (
|
|
284
|
+
this.authenticatedSignature !== signatureArg
|
|
285
|
+
|| !this.transferOrigin
|
|
286
|
+
|| this.socket?.getStatus() !== 'connected'
|
|
287
|
+
) {
|
|
288
|
+
throw new Error('CoreMail gateway session is unauthenticated.');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async disconnect(): Promise<void> {
|
|
293
|
+
this.statusSubscription?.unsubscribe();
|
|
294
|
+
this.statusSubscription = null;
|
|
295
|
+
const socket = this.socket;
|
|
296
|
+
this.socket = null;
|
|
297
|
+
this.socketSignature = '';
|
|
298
|
+
this.authenticatedSignature = '';
|
|
299
|
+
this.authenticatingSignature = '';
|
|
300
|
+
this.transferOrigin = null;
|
|
301
|
+
await socket?.stop().catch(() => undefined);
|
|
302
|
+
await this.authenticationTask.catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
public async prepareOutbound(
|
|
306
|
+
submissionArg: ICoreMailSubmissionRecord,
|
|
307
|
+
grantArg: plugins.serveZoneInterfaces.data.ICoreMailDownloadGrant,
|
|
308
|
+
): Promise<{
|
|
309
|
+
transportMessageId: string;
|
|
310
|
+
status: TGatewayStatus;
|
|
311
|
+
replayed: boolean;
|
|
312
|
+
}> {
|
|
313
|
+
if (!this.socket || !this.isReady()) {
|
|
314
|
+
throw new Error('CoreMail gateway is unavailable.');
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
!submissionArg.mimeSha256
|
|
318
|
+
|| submissionArg.mimeLengthBytes === undefined
|
|
319
|
+
) {
|
|
320
|
+
throw new Error('CoreMail outbound MIME is unavailable.');
|
|
321
|
+
}
|
|
322
|
+
const response = await this.socket.createTypedRequest<
|
|
323
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareOutboundHandoff
|
|
324
|
+
>('coreMailGatewayPrepareOutboundHandoff', undefined, {
|
|
325
|
+
timeoutMs: 30_000,
|
|
326
|
+
abortSignal: this.stopController.signal,
|
|
327
|
+
}).fire({
|
|
328
|
+
transportSubmissionId: submissionArg.submissionId,
|
|
329
|
+
submissionDigest: submissionArg.submissionDigest,
|
|
330
|
+
message: {
|
|
331
|
+
envelope: {
|
|
332
|
+
mailFrom: submissionArg.message.sender.address,
|
|
333
|
+
rcptTo: [
|
|
334
|
+
...submissionArg.message.recipients.to,
|
|
335
|
+
...(submissionArg.message.recipients.cc ?? []),
|
|
336
|
+
...(submissionArg.message.recipients.bcc ?? []),
|
|
337
|
+
].map((recipientArg) => recipientArg.address),
|
|
338
|
+
},
|
|
339
|
+
rawMime: {
|
|
340
|
+
sha256: submissionArg.mimeSha256,
|
|
341
|
+
lengthBytes: submissionArg.mimeLengthBytes,
|
|
342
|
+
contentType: 'message/rfc822',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
grant: grantArg,
|
|
346
|
+
});
|
|
347
|
+
const status = normalizeGatewayStatus(response.status);
|
|
348
|
+
if (
|
|
349
|
+
typeof response.transportMessageId !== 'string'
|
|
350
|
+
|| response.transportMessageId !== status.transportMessageId
|
|
351
|
+
|| response.replayed !== Boolean(response.replayed)
|
|
352
|
+
) {
|
|
353
|
+
throw new Error('CoreMail gateway outbound handoff response is invalid.');
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
transportMessageId: response.transportMessageId,
|
|
357
|
+
status,
|
|
358
|
+
replayed: response.replayed,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
public async getOutboundStatus(
|
|
363
|
+
transportMessageIdArg: string,
|
|
364
|
+
): Promise<TGatewayStatus> {
|
|
365
|
+
if (!this.socket || !this.isReady()) {
|
|
366
|
+
throw new Error('CoreMail gateway is unavailable.');
|
|
367
|
+
}
|
|
368
|
+
const transportMessageId = requireCoreMailIdentifier(
|
|
369
|
+
transportMessageIdArg,
|
|
370
|
+
'transportMessageId',
|
|
371
|
+
);
|
|
372
|
+
const response = await this.socket.createTypedRequest<
|
|
373
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayGetOutboundStatus
|
|
374
|
+
>('coreMailGatewayGetOutboundStatus', undefined, {
|
|
375
|
+
timeoutMs: 10_000,
|
|
376
|
+
abortSignal: this.stopController.signal,
|
|
377
|
+
}).fire({ transportMessageId });
|
|
378
|
+
const status = normalizeGatewayStatus(response.status);
|
|
379
|
+
if (status.transportMessageId !== transportMessageId) {
|
|
380
|
+
throw new Error('CoreMail gateway status authority is invalid.');
|
|
381
|
+
}
|
|
382
|
+
return status;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async completeOutboundHandoff(
|
|
386
|
+
requestArg:
|
|
387
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff['request'],
|
|
388
|
+
): Promise<{
|
|
389
|
+
status: plugins.serveZoneInterfaces.data.ICoreMailGatewayOutboundStatus;
|
|
390
|
+
replayed: boolean;
|
|
391
|
+
}> {
|
|
392
|
+
const sha256 = plugins.serveZoneInterfaces.data.normalizeCoreMailSha256(
|
|
393
|
+
requestArg.sha256,
|
|
394
|
+
);
|
|
395
|
+
const grantId = requireCoreMailUuid(requestArg.grantId, 'grantId');
|
|
396
|
+
const transportMessageId = requireCoreMailIdentifier(
|
|
397
|
+
requestArg.transportMessageId,
|
|
398
|
+
'transportMessageId',
|
|
399
|
+
);
|
|
400
|
+
const lengthBytes = requireCoreMailSafeInteger(
|
|
401
|
+
requestArg.lengthBytes,
|
|
402
|
+
'lengthBytes',
|
|
403
|
+
);
|
|
404
|
+
const grant = await this.transfers.assertConsumed({
|
|
405
|
+
grantId,
|
|
406
|
+
authority: gatewayAuthority(this.config.replica.serviceId),
|
|
407
|
+
purpose: 'outbound-mime-download',
|
|
408
|
+
expectedSha256: sha256,
|
|
409
|
+
lengthBytes,
|
|
410
|
+
});
|
|
411
|
+
const submission = await this.models.Submission.exact.findStoredOne({
|
|
412
|
+
mimeObjectKey: grant.objectKey,
|
|
413
|
+
mimeSha256: sha256,
|
|
414
|
+
mimeLengthBytes: lengthBytes,
|
|
415
|
+
});
|
|
416
|
+
if (!submission || !submission.mimeSha256) {
|
|
417
|
+
throw new Error('CoreMail outbound handoff is unavailable.');
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
submission.transportMessageId
|
|
421
|
+
&& submission.transportMessageId !== transportMessageId
|
|
422
|
+
) {
|
|
423
|
+
throw new Error('CoreMail outbound transport identity conflicts.');
|
|
424
|
+
}
|
|
425
|
+
const replayed = submission.transportMessageId === transportMessageId;
|
|
426
|
+
const terminalState =
|
|
427
|
+
submission.state === 'delivered'
|
|
428
|
+
|| submission.state === 'failed'
|
|
429
|
+
|| submission.state === 'deadLettered'
|
|
430
|
+
? submission.state
|
|
431
|
+
: undefined;
|
|
432
|
+
const status: TGatewayStatus = {
|
|
433
|
+
transportMessageId,
|
|
434
|
+
state: terminalState ?? 'queued',
|
|
435
|
+
attempts: submission.attempts,
|
|
436
|
+
...(submission.error === undefined ? {} : { error: submission.error }),
|
|
437
|
+
...(submission.deliveredAt === undefined
|
|
438
|
+
? {}
|
|
439
|
+
: { deliveredAt: submission.deliveredAt }),
|
|
440
|
+
...(submission.terminalAt === undefined
|
|
441
|
+
? {}
|
|
442
|
+
: { terminalAt: submission.terminalAt }),
|
|
443
|
+
updatedAt: terminalState
|
|
444
|
+
? (submission.gatewayStatusUpdatedAt ?? submission.updatedAt)
|
|
445
|
+
: this.now(),
|
|
446
|
+
};
|
|
447
|
+
if (!terminalState) {
|
|
448
|
+
await this.persistStatus(submission, status);
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
status,
|
|
452
|
+
replayed,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public async persistStatus(
|
|
457
|
+
initialSubmissionArg: TStoredSubmission,
|
|
458
|
+
statusArg: TGatewayStatus,
|
|
459
|
+
leaseTokenArg?: string,
|
|
460
|
+
): Promise<TStoredSubmission> {
|
|
461
|
+
let submission = initialSubmissionArg;
|
|
462
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
463
|
+
if (
|
|
464
|
+
submission.transportMessageId
|
|
465
|
+
&& submission.transportMessageId !== statusArg.transportMessageId
|
|
466
|
+
) {
|
|
467
|
+
throw new Error('CoreMail outbound transport identity conflicts.');
|
|
468
|
+
}
|
|
469
|
+
if (
|
|
470
|
+
submission.state === 'delivered'
|
|
471
|
+
|| submission.state === 'failed'
|
|
472
|
+
|| submission.state === 'deadLettered'
|
|
473
|
+
) {
|
|
474
|
+
return submission;
|
|
475
|
+
}
|
|
476
|
+
if (
|
|
477
|
+
submission.gatewayStatusUpdatedAt !== undefined
|
|
478
|
+
&& statusArg.updatedAt < submission.gatewayStatusUpdatedAt
|
|
479
|
+
) {
|
|
480
|
+
return submission;
|
|
481
|
+
}
|
|
482
|
+
if (leaseTokenArg && submission.leaseToken !== leaseTokenArg) {
|
|
483
|
+
throw new Error('CoreMail outbound worker lease fence changed.');
|
|
484
|
+
}
|
|
485
|
+
const updatedAt = Math.max(this.now(), statusArg.updatedAt);
|
|
486
|
+
const terminal = statusArg.state === 'delivered'
|
|
487
|
+
|| statusArg.state === 'failed'
|
|
488
|
+
|| statusArg.state === 'deadLettered';
|
|
489
|
+
const nextAttemptAt = statusArg.state === 'deferred'
|
|
490
|
+
? statusArg.nextAttemptAt
|
|
491
|
+
: undefined;
|
|
492
|
+
const workerRetryAt = terminal || statusArg.state === 'deferred'
|
|
493
|
+
? undefined
|
|
494
|
+
: this.now() + 5_000;
|
|
495
|
+
const acceptedAt = submission.acceptedAt ?? Math.max(
|
|
496
|
+
submission.createdAt,
|
|
497
|
+
Math.min(
|
|
498
|
+
updatedAt,
|
|
499
|
+
statusArg.terminalAt ?? statusArg.deliveredAt ?? statusArg.updatedAt,
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
if (
|
|
503
|
+
(statusArg.terminalAt !== undefined && statusArg.terminalAt < acceptedAt)
|
|
504
|
+
|| (statusArg.deliveredAt !== undefined && statusArg.deliveredAt < acceptedAt)
|
|
505
|
+
) {
|
|
506
|
+
throw new Error('CoreMail gateway status chronology conflicts.');
|
|
507
|
+
}
|
|
508
|
+
const result = await this.models.Submission.exact.transition({
|
|
509
|
+
current: submission,
|
|
510
|
+
change: (modelArg) => {
|
|
511
|
+
modelArg.transportMessageId = statusArg.transportMessageId;
|
|
512
|
+
modelArg.acceptedAt = acceptedAt;
|
|
513
|
+
modelArg.state = statusArg.state;
|
|
514
|
+
modelArg.attempts = Math.max(
|
|
515
|
+
submission.attempts,
|
|
516
|
+
statusArg.attempts,
|
|
517
|
+
);
|
|
518
|
+
modelArg.gatewayStatusUpdatedAt = statusArg.updatedAt;
|
|
519
|
+
if (nextAttemptAt === undefined) {
|
|
520
|
+
delete modelArg.nextAttemptAt;
|
|
521
|
+
} else {
|
|
522
|
+
modelArg.nextAttemptAt = nextAttemptAt;
|
|
523
|
+
}
|
|
524
|
+
if (workerRetryAt === undefined) {
|
|
525
|
+
delete modelArg.workerRetryAt;
|
|
526
|
+
} else {
|
|
527
|
+
modelArg.workerRetryAt = workerRetryAt;
|
|
528
|
+
}
|
|
529
|
+
if (statusArg.state === 'delivered') {
|
|
530
|
+
modelArg.deliveredAt = statusArg.deliveredAt;
|
|
531
|
+
} else {
|
|
532
|
+
delete modelArg.deliveredAt;
|
|
533
|
+
}
|
|
534
|
+
if (statusArg.terminalAt === undefined) {
|
|
535
|
+
delete modelArg.terminalAt;
|
|
536
|
+
} else {
|
|
537
|
+
modelArg.terminalAt = statusArg.terminalAt;
|
|
538
|
+
}
|
|
539
|
+
if (statusArg.error === undefined) {
|
|
540
|
+
delete modelArg.error;
|
|
541
|
+
} else {
|
|
542
|
+
modelArg.error = statusArg.error;
|
|
543
|
+
}
|
|
544
|
+
modelArg.updatedAt = updatedAt;
|
|
545
|
+
modelArg.revision += 1;
|
|
546
|
+
delete modelArg.leaseOwnerTaskId;
|
|
547
|
+
delete modelArg.leaseToken;
|
|
548
|
+
delete modelArg.leaseExpiresAt;
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
if (result.status === 'transitioned') {
|
|
552
|
+
return result.document;
|
|
553
|
+
}
|
|
554
|
+
if (leaseTokenArg) {
|
|
555
|
+
throw new Error('CoreMail outbound worker lease fence changed.');
|
|
556
|
+
}
|
|
557
|
+
const reloaded = await this.models.Submission.exact.findStoredOne({
|
|
558
|
+
submissionId: submission.submissionId,
|
|
559
|
+
tenantId: submission.tenantId,
|
|
560
|
+
serviceId: submission.serviceId,
|
|
561
|
+
bindingId: submission.bindingId,
|
|
562
|
+
bindingRevision: submission.bindingRevision,
|
|
563
|
+
});
|
|
564
|
+
if (!reloaded) {
|
|
565
|
+
throw new Error('CoreMail outbound submission disappeared.');
|
|
566
|
+
}
|
|
567
|
+
submission = reloaded;
|
|
568
|
+
}
|
|
569
|
+
throw new Error('CoreMail outbound status update remained contended.');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export class CoreMailOutboundWorker {
|
|
574
|
+
private readonly stopController = new AbortController();
|
|
575
|
+
private workerTask: Promise<void> | null = null;
|
|
576
|
+
|
|
577
|
+
public constructor(
|
|
578
|
+
private readonly models: ICoreMailModels,
|
|
579
|
+
private readonly transfers: CoreMailTransferService,
|
|
580
|
+
private readonly gateway: CoreMailGateway,
|
|
581
|
+
private readonly replicaTaskId: string,
|
|
582
|
+
private readonly serviceId: string,
|
|
583
|
+
private readonly now: () => number = Date.now,
|
|
584
|
+
) {}
|
|
585
|
+
|
|
586
|
+
public start(): void {
|
|
587
|
+
this.workerTask ??= this.run();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
public async stop(): Promise<void> {
|
|
591
|
+
this.stopController.abort();
|
|
592
|
+
await this.workerTask?.catch(() => undefined);
|
|
593
|
+
this.workerTask = null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async run(): Promise<void> {
|
|
597
|
+
while (!this.stopController.signal.aborted) {
|
|
598
|
+
if (this.gateway.isReady()) {
|
|
599
|
+
await this.processBatch().catch(async () => {
|
|
600
|
+
await logCoreMailFailure('worker', 'BATCH_FAILED')
|
|
601
|
+
.catch(() => undefined);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
await waitForAbortableDelay(500, this.stopController.signal);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private async processBatch(): Promise<void> {
|
|
609
|
+
const now = this.now();
|
|
610
|
+
const candidates = await this.models.Submission.exact.findStored({
|
|
611
|
+
filter: {
|
|
612
|
+
$or: [
|
|
613
|
+
{
|
|
614
|
+
state: { $in: ['ready', 'accepted', 'queued', 'delivering'] },
|
|
615
|
+
$or: [
|
|
616
|
+
{
|
|
617
|
+
workerRetryAt: { $lte: now },
|
|
618
|
+
mimePublishedAt: { $exists: true },
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
leaseExpiresAt: { $lte: now },
|
|
622
|
+
leaseOwnerTaskId: { $ne: 'submission-finalizer' },
|
|
623
|
+
mimePublishedAt: { $exists: true },
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
state: 'deferred',
|
|
629
|
+
$or: [
|
|
630
|
+
{ nextAttemptAt: { $lte: now } },
|
|
631
|
+
{
|
|
632
|
+
leaseExpiresAt: { $lte: now },
|
|
633
|
+
leaseOwnerTaskId: { $ne: 'submission-finalizer' },
|
|
634
|
+
mimePublishedAt: { $exists: true },
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
sort: { workerRetryAt: 1, nextAttemptAt: 1, submissionId: 1 },
|
|
641
|
+
limit: 8,
|
|
642
|
+
});
|
|
643
|
+
for (const candidate of candidates) {
|
|
644
|
+
if (this.stopController.signal.aborted || !this.gateway.isReady()) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const claimNow = this.now();
|
|
648
|
+
const retryAt = candidate.state === 'deferred'
|
|
649
|
+
? candidate.nextAttemptAt
|
|
650
|
+
: candidate.workerRetryAt;
|
|
651
|
+
const expiredLease = candidate.leaseExpiresAt !== undefined
|
|
652
|
+
&& candidate.leaseExpiresAt <= claimNow;
|
|
653
|
+
if (
|
|
654
|
+
(
|
|
655
|
+
(retryAt === undefined || retryAt > claimNow)
|
|
656
|
+
&& !expiredLease
|
|
657
|
+
)
|
|
658
|
+
|| (
|
|
659
|
+
candidate.leaseExpiresAt !== undefined
|
|
660
|
+
&& candidate.leaseExpiresAt > claimNow
|
|
661
|
+
)
|
|
662
|
+
) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const leaseToken = plugins.nodeCrypto.randomUUID();
|
|
666
|
+
const leaseExpiresAt = claimNow + 60_000;
|
|
667
|
+
const claim = await this.models.Submission.exact.transition({
|
|
668
|
+
current: candidate,
|
|
669
|
+
change: (modelArg) => {
|
|
670
|
+
if (candidate.transportMessageId) {
|
|
671
|
+
modelArg.state = 'delivering';
|
|
672
|
+
delete modelArg.nextAttemptAt;
|
|
673
|
+
delete modelArg.error;
|
|
674
|
+
}
|
|
675
|
+
modelArg.leaseOwnerTaskId = this.replicaTaskId;
|
|
676
|
+
modelArg.leaseToken = leaseToken;
|
|
677
|
+
modelArg.leaseExpiresAt = leaseExpiresAt;
|
|
678
|
+
modelArg.updatedAt = this.now();
|
|
679
|
+
modelArg.revision += 1;
|
|
680
|
+
delete modelArg.workerRetryAt;
|
|
681
|
+
if (!candidate.transportMessageId) {
|
|
682
|
+
modelArg.attempts += 1;
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
if (claim.status !== 'transitioned') {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
await this.processClaimed(claim.document, leaseToken);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async processClaimed(
|
|
694
|
+
submissionArg: TStoredSubmission,
|
|
695
|
+
leaseTokenArg: string,
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
try {
|
|
698
|
+
if (submissionArg.transportMessageId) {
|
|
699
|
+
const status = await this.gateway.getOutboundStatus(
|
|
700
|
+
submissionArg.transportMessageId,
|
|
701
|
+
);
|
|
702
|
+
await this.gateway.persistStatus(submissionArg, status, leaseTokenArg);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
!submissionArg.mimeObjectKey
|
|
707
|
+
|| !submissionArg.mimeSha256
|
|
708
|
+
|| submissionArg.mimeLengthBytes === undefined
|
|
709
|
+
|| submissionArg.mimePublishedAt === undefined
|
|
710
|
+
) {
|
|
711
|
+
throw new Error('CoreMail queued submission has no immutable MIME object.');
|
|
712
|
+
}
|
|
713
|
+
const grant = await this.transfers.issueGrant({
|
|
714
|
+
authority: gatewayAuthority(this.serviceId),
|
|
715
|
+
purpose: 'outbound-mime-download',
|
|
716
|
+
method: 'GET',
|
|
717
|
+
expectedSha256: submissionArg.mimeSha256,
|
|
718
|
+
lengthBytes: submissionArg.mimeLengthBytes,
|
|
719
|
+
contentType: 'message/rfc822',
|
|
720
|
+
objectKey: submissionArg.mimeObjectKey,
|
|
721
|
+
}) as plugins.serveZoneInterfaces.data.ICoreMailDownloadGrant;
|
|
722
|
+
if (
|
|
723
|
+
submissionArg.leaseToken !== leaseTokenArg
|
|
724
|
+
) {
|
|
725
|
+
throw new Error('CoreMail outbound grant fence changed.');
|
|
726
|
+
}
|
|
727
|
+
const grantPersisted = await this.models.Submission.exact.transition({
|
|
728
|
+
current: submissionArg,
|
|
729
|
+
change: (modelArg) => {
|
|
730
|
+
modelArg.transportGrantId = grant.grantId;
|
|
731
|
+
modelArg.updatedAt = this.now();
|
|
732
|
+
modelArg.revision += 1;
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
if (grantPersisted.status !== 'transitioned') {
|
|
736
|
+
throw new Error('CoreMail outbound grant fence changed.');
|
|
737
|
+
}
|
|
738
|
+
const withGrant = grantPersisted.document;
|
|
739
|
+
const response = await this.gateway.prepareOutbound(withGrant, grant);
|
|
740
|
+
await this.gateway.persistStatus(
|
|
741
|
+
withGrant,
|
|
742
|
+
response.status,
|
|
743
|
+
leaseTokenArg,
|
|
744
|
+
);
|
|
745
|
+
} catch {
|
|
746
|
+
await logCoreMailFailure('worker', 'DELIVERY_ATTEMPT_FAILED')
|
|
747
|
+
.catch(() => undefined);
|
|
748
|
+
const delayMs = Math.min(
|
|
749
|
+
15 * 60 * 1000,
|
|
750
|
+
1_000 * (2 ** Math.min(submissionArg.attempts, 9)),
|
|
751
|
+
);
|
|
752
|
+
const current = await this.models.Submission.exact.findStoredOne({
|
|
753
|
+
submissionId: submissionArg.submissionId,
|
|
754
|
+
tenantId: submissionArg.tenantId,
|
|
755
|
+
serviceId: submissionArg.serviceId,
|
|
756
|
+
bindingId: submissionArg.bindingId,
|
|
757
|
+
bindingRevision: submissionArg.bindingRevision,
|
|
758
|
+
leaseToken: leaseTokenArg,
|
|
759
|
+
});
|
|
760
|
+
if (current) {
|
|
761
|
+
await this.models.Submission.exact.transition({
|
|
762
|
+
current,
|
|
763
|
+
change: (modelArg) => {
|
|
764
|
+
if (current.transportMessageId) {
|
|
765
|
+
modelArg.state = 'deferred';
|
|
766
|
+
modelArg.nextAttemptAt = this.now() + delayMs;
|
|
767
|
+
modelArg.error = {
|
|
768
|
+
code: 'GATEWAY_UNAVAILABLE',
|
|
769
|
+
retryable: true,
|
|
770
|
+
retryAfterMs: delayMs,
|
|
771
|
+
};
|
|
772
|
+
delete modelArg.workerRetryAt;
|
|
773
|
+
} else if (
|
|
774
|
+
!current.mimeObjectKey
|
|
775
|
+
|| !current.mimeSha256
|
|
776
|
+
|| current.mimeLengthBytes === undefined
|
|
777
|
+
|| current.mimePublishedAt === undefined
|
|
778
|
+
) {
|
|
779
|
+
const terminalAt = this.now();
|
|
780
|
+
modelArg.state = 'failed';
|
|
781
|
+
modelArg.error = {
|
|
782
|
+
code: 'OBJECT_INTEGRITY_CONFLICT',
|
|
783
|
+
retryable: false,
|
|
784
|
+
};
|
|
785
|
+
modelArg.terminalAt = terminalAt;
|
|
786
|
+
modelArg.updatedAt = terminalAt;
|
|
787
|
+
delete modelArg.nextAttemptAt;
|
|
788
|
+
delete modelArg.workerRetryAt;
|
|
789
|
+
} else {
|
|
790
|
+
modelArg.state = 'ready';
|
|
791
|
+
modelArg.workerRetryAt = this.now() + delayMs;
|
|
792
|
+
delete modelArg.nextAttemptAt;
|
|
793
|
+
delete modelArg.error;
|
|
794
|
+
}
|
|
795
|
+
if (modelArg.state !== 'failed') {
|
|
796
|
+
delete modelArg.terminalAt;
|
|
797
|
+
}
|
|
798
|
+
delete modelArg.deliveredAt;
|
|
799
|
+
if (modelArg.state !== 'failed') {
|
|
800
|
+
modelArg.updatedAt = this.now();
|
|
801
|
+
}
|
|
802
|
+
modelArg.revision += 1;
|
|
803
|
+
delete modelArg.leaseOwnerTaskId;
|
|
804
|
+
delete modelArg.leaseToken;
|
|
805
|
+
delete modelArg.leaseExpiresAt;
|
|
806
|
+
},
|
|
807
|
+
}).catch(() => undefined);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|