@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.
Files changed (75) hide show
  1. package/.smartconfig.json +50 -0
  2. package/changelog.md +10 -0
  3. package/cli.js +4 -0
  4. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  5. package/dist_ts/00_commitinfo_data.js +9 -0
  6. package/dist_ts/classes.auth.d.ts +45 -0
  7. package/dist_ts/classes.auth.js +204 -0
  8. package/dist_ts/classes.config.d.ts +2 -0
  9. package/dist_ts/classes.config.js +72 -0
  10. package/dist_ts/classes.coremail.d.ts +41 -0
  11. package/dist_ts/classes.coremail.js +155 -0
  12. package/dist_ts/classes.desiredstate.d.ts +40 -0
  13. package/dist_ts/classes.desiredstate.js +382 -0
  14. package/dist_ts/classes.gateway.d.ts +62 -0
  15. package/dist_ts/classes.gateway.js +669 -0
  16. package/dist_ts/classes.inbound.d.ts +49 -0
  17. package/dist_ts/classes.inbound.js +702 -0
  18. package/dist_ts/classes.maintenance.d.ts +16 -0
  19. package/dist_ts/classes.maintenance.js +339 -0
  20. package/dist_ts/classes.models.d.ts +161 -0
  21. package/dist_ts/classes.models.js +595 -0
  22. package/dist_ts/classes.server.d.ts +40 -0
  23. package/dist_ts/classes.server.js +309 -0
  24. package/dist_ts/classes.storage.d.ts +32 -0
  25. package/dist_ts/classes.storage.js +317 -0
  26. package/dist_ts/classes.submissions.d.ts +33 -0
  27. package/dist_ts/classes.submissions.js +385 -0
  28. package/dist_ts/classes.transfer.d.ts +41 -0
  29. package/dist_ts/classes.transfer.js +289 -0
  30. package/dist_ts/coremail.cursor.d.ts +20 -0
  31. package/dist_ts/coremail.cursor.js +191 -0
  32. package/dist_ts/coremail.log.d.ts +3 -0
  33. package/dist_ts/coremail.log.js +11 -0
  34. package/dist_ts/coremail.mime.d.ts +8 -0
  35. package/dist_ts/coremail.mime.js +63 -0
  36. package/dist_ts/coremail.persistence.d.ts +148 -0
  37. package/dist_ts/coremail.persistence.js +695 -0
  38. package/dist_ts/coremail.quota.d.ts +29 -0
  39. package/dist_ts/coremail.quota.js +149 -0
  40. package/dist_ts/coremail.selectors.d.ts +3 -0
  41. package/dist_ts/coremail.selectors.js +21 -0
  42. package/dist_ts/coremail.validation.d.ts +9 -0
  43. package/dist_ts/coremail.validation.js +325 -0
  44. package/dist_ts/index.d.ts +4 -0
  45. package/dist_ts/index.js +39 -0
  46. package/dist_ts/interfaces.d.ts +92 -0
  47. package/dist_ts/interfaces.js +2 -0
  48. package/dist_ts/plugins.d.ts +11 -0
  49. package/dist_ts/plugins.js +12 -0
  50. package/license.md +19 -0
  51. package/package.json +48 -0
  52. package/readme.md +199 -0
  53. package/ts/00_commitinfo_data.ts +8 -0
  54. package/ts/classes.auth.ts +321 -0
  55. package/ts/classes.config.ts +104 -0
  56. package/ts/classes.coremail.ts +249 -0
  57. package/ts/classes.desiredstate.ts +508 -0
  58. package/ts/classes.gateway.ts +811 -0
  59. package/ts/classes.inbound.ts +898 -0
  60. package/ts/classes.maintenance.ts +335 -0
  61. package/ts/classes.models.ts +530 -0
  62. package/ts/classes.server.ts +444 -0
  63. package/ts/classes.storage.ts +448 -0
  64. package/ts/classes.submissions.ts +554 -0
  65. package/ts/classes.transfer.ts +379 -0
  66. package/ts/coremail.cursor.ts +328 -0
  67. package/ts/coremail.log.ts +21 -0
  68. package/ts/coremail.mime.ts +94 -0
  69. package/ts/coremail.persistence.ts +1156 -0
  70. package/ts/coremail.quota.ts +214 -0
  71. package/ts/coremail.selectors.ts +35 -0
  72. package/ts/coremail.validation.ts +446 -0
  73. package/ts/index.ts +38 -0
  74. package/ts/interfaces.ts +109 -0
  75. 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
+ }