@serve.zone/coremail 1.2.0 → 32.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 (60) hide show
  1. package/.smartconfig.json +1 -0
  2. package/changelog.md +50 -0
  3. package/dist_ts/00_commitinfo_data.js +4 -4
  4. package/dist_ts/classes.auth.d.ts +19 -0
  5. package/dist_ts/classes.auth.js +58 -1
  6. package/dist_ts/classes.coremail.d.ts +3 -0
  7. package/dist_ts/classes.coremail.js +11 -4
  8. package/dist_ts/classes.gateway.d.ts +21 -1
  9. package/dist_ts/classes.gateway.js +115 -68
  10. package/dist_ts/classes.inbound.d.ts +10 -1
  11. package/dist_ts/classes.inbound.js +45 -7
  12. package/dist_ts/classes.maintenance.js +16 -1
  13. package/dist_ts/classes.models.d.ts +24 -2
  14. package/dist_ts/classes.models.js +72 -2
  15. package/dist_ts/classes.server.d.ts +6 -12
  16. package/dist_ts/classes.server.js +18 -78
  17. package/dist_ts/classes.smtpsubmission.d.ts +85 -0
  18. package/dist_ts/classes.smtpsubmission.js +379 -0
  19. package/dist_ts/classes.storage.d.ts +9 -0
  20. package/dist_ts/classes.storage.js +44 -1
  21. package/dist_ts/classes.submissions.d.ts +27 -1
  22. package/dist_ts/classes.submissions.js +164 -14
  23. package/dist_ts/coremail.cursor.js +1 -15
  24. package/dist_ts/coremail.errors.d.ts +18 -0
  25. package/dist_ts/coremail.errors.js +84 -0
  26. package/dist_ts/coremail.log.d.ts +1 -1
  27. package/dist_ts/coremail.log.js +3 -6
  28. package/dist_ts/coremail.mime.d.ts +15 -0
  29. package/dist_ts/coremail.mime.js +76 -1
  30. package/dist_ts/coremail.persistence.d.ts +38 -1
  31. package/dist_ts/coremail.persistence.js +123 -40
  32. package/dist_ts/coremail.stats.d.ts +72 -0
  33. package/dist_ts/coremail.stats.js +242 -0
  34. package/dist_ts/coremail.validation.d.ts +15 -2
  35. package/dist_ts/coremail.validation.js +26 -79
  36. package/dist_ts/interfaces.d.ts +18 -0
  37. package/dist_ts/plugins.d.ts +1 -0
  38. package/dist_ts/plugins.js +2 -1
  39. package/package.json +6 -5
  40. package/readme.md +66 -8
  41. package/ts/00_commitinfo_data.ts +3 -3
  42. package/ts/classes.auth.ts +77 -0
  43. package/ts/classes.coremail.ts +20 -0
  44. package/ts/classes.gateway.ts +142 -71
  45. package/ts/classes.inbound.ts +71 -6
  46. package/ts/classes.maintenance.ts +14 -0
  47. package/ts/classes.models.ts +67 -1
  48. package/ts/classes.server.ts +22 -86
  49. package/ts/classes.smtpsubmission.ts +466 -0
  50. package/ts/classes.storage.ts +54 -0
  51. package/ts/classes.submissions.ts +247 -12
  52. package/ts/coremail.cursor.ts +2 -18
  53. package/ts/coremail.errors.ts +111 -0
  54. package/ts/coremail.log.ts +3 -6
  55. package/ts/coremail.mime.ts +89 -0
  56. package/ts/coremail.persistence.ts +195 -48
  57. package/ts/coremail.stats.ts +342 -0
  58. package/ts/coremail.validation.ts +31 -104
  59. package/ts/interfaces.ts +20 -0
  60. package/ts/plugins.ts +1 -0
package/readme.md CHANGED
@@ -11,6 +11,12 @@ needs to provision and reconcile bindings without proxying message traffic.
11
11
  pnpm add @serve.zone/coremail
12
12
  ```
13
13
 
14
+ CoreMail installs on Linux only. Its SMTP submission listener is built on
15
+ `@push.rocks/smartmta`, which declares `os: linux` and ships the prebuilt Rust
16
+ `mailer-bin` for `linux-x64` and `linux-arm64` — CoreMail's own runtime target.
17
+ Installing on macOS or Windows fails at the dependency, by design; develop
18
+ against a Linux container or host.
19
+
14
20
  The package exports the service lifecycle and its strict configuration reader:
15
21
 
16
22
  ```typescript
@@ -58,6 +64,10 @@ CoreMail enforces these boundaries:
58
64
  - Active and draining bindings derive their exact permitted operation set from
59
65
  the shared contract; disabled bindings cannot authenticate.
60
66
  - Every reconnect requires a new Argon2id-authenticated handshake.
67
+ - No CoreMail session negotiates a contract major: interfaces 32.0.0 puts the
68
+ protocol offer on the registration carriers and gives the CoreMail contracts
69
+ none, so the control, workload and gateway sessions are first-contact
70
+ exchanges judged by their exact contract alone.
61
71
  - TypedSocket tags are routing metadata, never authentication.
62
72
  - Message bytes never travel as JSON, base64 RPC payloads, or VirtualStreams.
63
73
  One-time grants authorize bounded HTTP `PUT` or `GET` transfers.
@@ -75,11 +85,19 @@ CoreMail enforces these boundaries:
75
85
  One strict hostname surface is exposed:
76
86
 
77
87
  - `GET /live` reports process liveness.
78
- - `GET /ready` reports database, exact object-storage, desired-state, and
79
- authenticated gateway readiness.
88
+ - `GET /ready` reports database, exact object-storage, desired-state,
89
+ authenticated gateway, and SMTP submission listener readiness.
80
90
  - `GET|PUT /transfers/:grantId` consumes one-time transfer capabilities.
81
91
  - `GET /socket` upgrades to the authenticated TypedSocket RPC transport.
82
92
 
93
+ When the desired state enables it, an SMTP submission listener (MSA) runs on
94
+ its own port. It offers `STARTTLS` and accepts `AUTH` only on an encrypted
95
+ session — the username is a `bindingId` and the password any accepted
96
+ credential secret of that binding. Accepted messages go straight to the durable
97
+ outbound pipeline as exact bytes; the embedded SMTP stack stores, queues and
98
+ forwards nothing. A listener whose certificate or private key material does not
99
+ resolve stays down and is reported unavailable.
100
+
83
101
  HTTP TypedRequest and built-in routes are disabled. WebSocket messages are
84
102
  limited to 64 KiB, unauthenticated connections have five seconds to complete
85
103
  their first authentication request, and HTTP connection/header/request
@@ -88,19 +106,27 @@ deadlines are enforced by SmartServe.
88
106
  ## Configuration
89
107
 
90
108
  CoreMail requires Coreflow and the deployment secret foundation to provide
91
- runtime configuration. For Interfaces v23 secret material, the workload must be
92
- launched through `workloadinit` protocol v1. The platform integration must write
93
- the verified map and secret files; `workloadinit` injects their exact values into
94
- the process environment immediately before executing CoreMail. CoreMail does not
109
+ runtime configuration. Secret material declared by `@serve.zone/interfaces` is
110
+ delivered by launching the workload through `workloadinit`. The platform
111
+ integration must write the verified map and secret files; `workloadinit` injects
112
+ their exact values into the process environment immediately before executing
113
+ CoreMail. CoreMail does not
95
114
  fetch, decrypt, or persist runtime secret material itself. Until the dcrouter
96
115
  and Coreflow integrations are released, a platform CoreMail deployment is not
97
116
  ready. Malformed startup configuration terminates
98
117
  startup. Missing desired-state secret references keep the affected gateway or
99
118
  cursor operation unavailable.
100
119
 
120
+ The SMTP submission listener is configured entirely through desired state
121
+ (`smtp`): `enabled`, `port`, `hostname`, and `tls.certificatePemSecretKey` /
122
+ `tls.privateKeyPemSecretKey`, which name runtime secret keys holding PEM text —
123
+ never the PEM values themselves. An omitted or disabled `smtp` section keeps the
124
+ API-only surface; an enabled one whose secret keys do not resolve leaves the
125
+ listener down. Changing any of these values restarts the listener in place.
126
+
101
127
  ```text
102
- /opt/serve.zone/runtime-assets/workloadinit/v1/workloadinit run \
103
- --map /run/serve.zone/workloadinit-map-v1.json -- node cli.js
128
+ /opt/serve.zone/runtime-assets/workloadinit/workloadinit run \
129
+ --map /run/serve.zone/workloadinit-map.json -- node cli.js
104
130
  ```
105
131
 
106
132
  | Variable | Purpose |
@@ -140,6 +166,15 @@ header, publishes that MIME exactly once, and queues a lease-fenced worker.
140
166
  The downstream transport identity is the CoreMail submission ID and remains
141
167
  stable across ambiguous responses and retries.
142
168
 
169
+ Every outbound submission records how it entered CoreMail. API submissions are
170
+ composed from declared parts as described above. An SMTP submission is accepted
171
+ as exact RFC822 bytes: it has no part descriptors, its bytes become its
172
+ immutable MIME unchanged, and it enters the same lease-fenced worker in the same
173
+ state a finalized API submission does. It is subject to the same binding
174
+ authority, sender authorization, recipient validation, serialized-MIME byte
175
+ budget, and minute and day quota. Records written before this discriminator
176
+ existed are read as API submissions.
177
+
143
178
  Inbound SMTP recipient resolution returns short-lived opaque handles. The
144
179
  gateway uploads one immutable MIME object, after which CoreMail resumes a
145
180
  SmartData saga that consumes handles and creates one delivery per binding.
@@ -154,6 +189,25 @@ one-time grant, and may acknowledge only after the exact fetch completes.
154
189
  Cursor signatures rotate through current and bounded retiring desired-state
155
190
  keys without exposing runtime secret material.
156
191
 
192
+ Every gateway-facing handler answers with the same privacy-safe typed error
193
+ envelope the workload handlers use, so the transport can tell a retryable
194
+ `QUOTA_EXCEEDED` or `STATE_CONFLICT` from a permanent refusal without ever
195
+ receiving a cause string.
196
+
197
+ Inbound deliveries carry the `Message-ID` and `Subject` read from a bounded
198
+ prefix of the stored message. The gateway message descriptor cannot carry
199
+ either, and a header scan that fails never costs a delivery — the fields simply
200
+ stay absent.
201
+
202
+ Per-binding counters are kept for each UTC day: `submittedApi` and
203
+ `submittedSmtp` when a submission is published to the worker, `delivered`,
204
+ `deferred`, `failed` and `deadLettered` on outbound transitions, and `received`,
205
+ `acknowledgedProcessed` and `acknowledgedDiscarded` on inbound ones. Each
206
+ increment commits in the same transaction as the durable transition it counts,
207
+ so a replayed transition — a repeated finalization, a re-applied gateway status,
208
+ a replayed handoff completion or acknowledgement — counts nothing. Counter rows
209
+ are retained for 90 days.
210
+
157
211
  Desired-state activation uses immutable snapshots and a strictly increasing
158
212
  `configEpoch` compare-and-set pointer. Replayed or stale configurations cannot
159
213
  replace newer authority.
@@ -182,6 +236,10 @@ pnpm run build:docker
182
236
  pnpm run release:docker
183
237
  ```
184
238
 
239
+ It is built from `Dockerfile_##version##`, so every build publishes the release
240
+ version tag and nothing else. `coremail:latest` is never moved again — it keeps
241
+ the last 1.x build it held — and consumers pin the version tag.
242
+
185
243
  ## License and Legal Information
186
244
 
187
245
  This repository contains open-source code licensed under the MIT License. A
@@ -2,7 +2,7 @@
2
2
  * autocreated commitinfo by @push.rocks/commitinfo
3
3
  */
4
4
  export const commitinfo = {
5
- name: '@serve.zone/coremail',
6
- version: '1.2.0',
7
- description: 'Authenticated mail persistence and delivery orchestration for serve.zone workloads.'
5
+ name: "@serve.zone/coremail",
6
+ version: "32.0.0",
7
+ description: "Authenticated mail persistence and delivery orchestration for serve.zone workloads."
8
8
  }
@@ -321,6 +321,83 @@ export class CoreMailAuth {
321
321
  }
322
322
  }
323
323
 
324
+ /**
325
+ * Verify an SMTP submission credential.
326
+ *
327
+ * SMTP AUTH carries no credential id, so every currently accepted verifier
328
+ * of the binding is tried. Verification, the argon2id code and the
329
+ * per-identity limiter are the socket path's: the identity key is the same
330
+ * `workload:<bindingId>`, so a brute force cannot escape the limit by
331
+ * switching surface.
332
+ *
333
+ * Returns undefined for every refusal — an unknown binding, a binding that
334
+ * is not active or not outbound-capable, and a wrong secret are deliberately
335
+ * indistinguishable to the caller.
336
+ */
337
+ public async authenticateSmtpSubmission(
338
+ bindingIdArg: string,
339
+ secretArg: string,
340
+ ): Promise<{
341
+ binding: plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState;
342
+ configEpoch: number;
343
+ credentialId: string;
344
+ credentialVersion: number;
345
+ } | undefined> {
346
+ let failureCode = 'INVALID_INPUT';
347
+ try {
348
+ const desiredState = await this.getActiveDesiredState();
349
+ const bindingId = requireCoreMailIdentifier(bindingIdArg, 'bindingId');
350
+ const secret = this.requireSecretShape(secretArg);
351
+ failureCode = 'UNKNOWN_BINDING';
352
+ const binding = desiredState?.bindings.find((bindingArg) =>
353
+ bindingArg.bindingId === bindingId
354
+ && bindingArg.state === 'active'
355
+ && bindingArg.capabilities.includes('outbound')
356
+ );
357
+ if (!desiredState || !binding) {
358
+ throw new CoreMailAuthenticationError();
359
+ }
360
+ failureCode = 'UNKNOWN_CREDENTIAL';
361
+ const accepted = binding.credentials.filter((credentialArg) =>
362
+ findAcceptedVerifier(
363
+ binding.credentials,
364
+ credentialArg.credentialId,
365
+ credentialArg.version,
366
+ this.now(),
367
+ ) !== undefined
368
+ );
369
+ if (accepted.length === 0) {
370
+ throw new CoreMailAuthenticationError();
371
+ }
372
+ failureCode = 'VERIFIER_REJECTED';
373
+ for (const verifier of accepted) {
374
+ try {
375
+ await this.verify(verifier, secret, `workload:${bindingId}`);
376
+ } catch (error) {
377
+ if (error instanceof CoreMailAuthLimitError) throw error;
378
+ continue;
379
+ }
380
+ return {
381
+ binding,
382
+ configEpoch: desiredState.configEpoch,
383
+ credentialId: verifier.credentialId,
384
+ credentialVersion: verifier.version,
385
+ };
386
+ }
387
+ throw new CoreMailAuthenticationError();
388
+ } catch (error) {
389
+ await logCoreMailFailure(
390
+ 'auth',
391
+ error instanceof CoreMailAuthLimitError
392
+ ? 'LIMITER_EXHAUSTED'
393
+ : failureCode,
394
+ undefined,
395
+ { surface: 'smtp' },
396
+ ).catch(() => undefined);
397
+ return undefined;
398
+ }
399
+ }
400
+
324
401
  public async authenticateWorkload(
325
402
  peerArg: ICoreMailPeer,
326
403
  requestArg:
@@ -14,6 +14,7 @@ import {
14
14
  type ICoreMailModels,
15
15
  } from './classes.models.js';
16
16
  import { CoreMailServer } from './classes.server.js';
17
+ import { CoreMailSmtpSubmissionServer } from './classes.smtpsubmission.js';
17
18
  import { CoreMailStorage } from './classes.storage.js';
18
19
  import { CoreMailSubmissionService } from './classes.submissions.js';
19
20
  import { CoreMailTransferService } from './classes.transfer.js';
@@ -39,6 +40,7 @@ export class CoreMail {
39
40
  public readonly inbound: CoreMailInboundService;
40
41
  public readonly maintenance: CoreMailMaintenanceService;
41
42
  public readonly gateway: CoreMailGateway;
43
+ public readonly smtpSubmission: CoreMailSmtpSubmissionServer;
42
44
  public readonly server: CoreMailServer;
43
45
  public readonly outboundWorker: CoreMailOutboundWorker;
44
46
 
@@ -48,6 +50,7 @@ export class CoreMail {
48
50
  private databaseStarted = false;
49
51
  private storageStarted = false;
50
52
  private gatewayStarted = false;
53
+ private smtpSubmissionStarted = false;
51
54
  private serverStarted = false;
52
55
  private workerStarted = false;
53
56
  private maintenanceStarted = false;
@@ -85,21 +88,30 @@ export class CoreMail {
85
88
  this.database,
86
89
  this.models,
87
90
  this.transfers,
91
+ this.storage,
88
92
  configArg.replica.serviceId,
89
93
  this.desiredState.getActiveDesiredState,
90
94
  configArg.resolveRuntimeSecret,
91
95
  );
92
96
  this.gateway = new CoreMailGateway(
93
97
  configArg,
98
+ this.database,
94
99
  this.models,
95
100
  this.transfers,
96
101
  this.inbound,
97
102
  this.desiredState.getActiveDesiredState,
98
103
  );
104
+ this.smtpSubmission = new CoreMailSmtpSubmissionServer(
105
+ configArg,
106
+ this.auth,
107
+ this.submissions,
108
+ this.desiredState.getActiveDesiredState,
109
+ );
99
110
  this.maintenance = new CoreMailMaintenanceService(this.models, this.storage);
100
111
  this.server = server = new CoreMailServer(
101
112
  configArg,
102
113
  this.database,
114
+ this.models,
103
115
  this.storage,
104
116
  this.transfers,
105
117
  this.desiredState,
@@ -107,6 +119,7 @@ export class CoreMail {
107
119
  this.submissions,
108
120
  this.inbound,
109
121
  this.gateway,
122
+ this.smtpSubmission,
110
123
  );
111
124
  this.outboundWorker = new CoreMailOutboundWorker(
112
125
  this.models,
@@ -163,6 +176,8 @@ export class CoreMail {
163
176
  await this.desiredState.start();
164
177
  this.gateway.start();
165
178
  this.gatewayStarted = true;
179
+ this.smtpSubmission.start();
180
+ this.smtpSubmissionStarted = true;
166
181
  await this.server.start();
167
182
  this.serverStarted = true;
168
183
  this.maintenance.start();
@@ -226,6 +241,11 @@ export class CoreMail {
226
241
  () => this.maintenance.stop(),
227
242
  () => { this.maintenanceStarted = false; },
228
243
  );
244
+ await stopOne(
245
+ this.smtpSubmissionStarted,
246
+ () => this.smtpSubmission.stop(),
247
+ () => { this.smtpSubmissionStarted = false; },
248
+ );
229
249
  await stopOne(
230
250
  this.gatewayStarted,
231
251
  () => this.gateway.stop(),
@@ -6,6 +6,13 @@ import type {
6
6
  ICoreMailConfig,
7
7
  ITransferAuthority,
8
8
  } from './interfaces.js';
9
+ import { addPrivacySafeTypedHandler } from './coremail.errors.js';
10
+ import { withCoreMailTransaction } from './coremail.quota.js';
11
+ import {
12
+ createCoreMailStatDescriptor,
13
+ ensureCoreMailStatCounter,
14
+ incrementCoreMailStatCounter,
15
+ } from './coremail.stats.js';
9
16
  import { logCoreMailFailure } from './coremail.log.js';
10
17
  import type { ICoreMailSubmissionRecord } from './coremail.persistence.js';
11
18
  import {
@@ -42,6 +49,24 @@ export const stableStringify = (valueArg: unknown): string => {
42
49
  return JSON.stringify(valueArg) ?? 'null';
43
50
  };
44
51
 
52
+ /**
53
+ * The body CoreMail sends to open its dcrouter gateway session.
54
+ *
55
+ * A first-contact exchange: `@serve.zone/interfaces` 32.0.0 gives the CoreMail
56
+ * carriers no `protocol` member, so CoreMail names no major to the gateway and
57
+ * negotiates nothing. The body lives here, beside the caller, so the session
58
+ * CoreMail opens can be judged by a specification without dialling a socket.
59
+ */
60
+ export const createCoreMailGatewayAuthenticationRequest = (
61
+ gatewayArg: TGatewayDesired,
62
+ credentialSecretArg: string,
63
+ ): plugins.serveZoneInterfaces.requests.coremail
64
+ .IReq_CoreMailGatewayAuthenticate['request'] => ({
65
+ credentialId: gatewayArg.credentialId,
66
+ credentialVersion: gatewayArg.credentialVersion,
67
+ credentialSecret: credentialSecretArg,
68
+ });
69
+
45
70
  const normalizeTransferOrigin = (valueArg: unknown): string => {
46
71
  if (typeof valueArg !== 'string') {
47
72
  throw new Error('CoreMail gateway returned an invalid transfer origin.');
@@ -111,6 +136,7 @@ export class CoreMailGateway {
111
136
 
112
137
  public constructor(
113
138
  private readonly config: ICoreMailConfig,
139
+ private readonly database: plugins.smartdata.SmartdataDb,
114
140
  private readonly models: ICoreMailModels,
115
141
  private readonly transfers: CoreMailTransferService,
116
142
  private readonly inbound: CoreMailInboundService,
@@ -182,31 +208,7 @@ export class CoreMailGateway {
182
208
  ): Promise<void> {
183
209
  await this.disconnect();
184
210
  if (this.stopController.signal.aborted) return;
185
- const router = new plugins.typedrequest.TypedRouter();
186
- router.addTypedHandler(new plugins.typedrequest.TypedHandler<
187
- plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
188
- >('coreMailGatewayResolveRecipients', async (requestArg) => {
189
- this.requireAuthenticated(signatureArg);
190
- return await this.inbound.resolveRecipients(requestArg);
191
- }));
192
- router.addTypedHandler(new plugins.typedrequest.TypedHandler<
193
- plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
194
- >('coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
195
- this.requireAuthenticated(signatureArg);
196
- return await this.inbound.prepareInboundHandoff(requestArg);
197
- }));
198
- router.addTypedHandler(new plugins.typedrequest.TypedHandler<
199
- plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
200
- >('coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
201
- this.requireAuthenticated(signatureArg);
202
- return await this.inbound.completeInboundHandoff(requestArg);
203
- }));
204
- router.addTypedHandler(new plugins.typedrequest.TypedHandler<
205
- plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
206
- >('coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
207
- this.requireAuthenticated(signatureArg);
208
- return await this.completeOutboundHandoff(requestArg);
209
- }));
211
+ const router = this.createGatewayRouter(signatureArg);
210
212
  const socket = await plugins.typedsocket.TypedSocket.createClient(
211
213
  router,
212
214
  gatewayArg.endpointUrl,
@@ -235,6 +237,45 @@ export class CoreMailGateway {
235
237
  this.queueAuthentication(gatewayArg, signatureArg);
236
238
  }
237
239
 
240
+ /**
241
+ * The gateway-facing router.
242
+ *
243
+ * Every handler goes through the same privacy-safe envelope the workload
244
+ * router uses, so dcrouter observes the typed `ICoreMailErrorData` code —
245
+ * `QUOTA_EXCEEDED` versus `STATE_CONFLICT` decides whether it defers a
246
+ * message or rejects it — instead of an opaque error string.
247
+ */
248
+ public createGatewayRouter(
249
+ signatureArg: string,
250
+ ): plugins.typedrequest.TypedRouter {
251
+ const router = new plugins.typedrequest.TypedRouter();
252
+ addPrivacySafeTypedHandler<
253
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
254
+ >(router, 'gateway', 'coreMailGatewayResolveRecipients', async (requestArg) => {
255
+ this.requireAuthenticated(signatureArg);
256
+ return await this.inbound.resolveRecipients(requestArg);
257
+ });
258
+ addPrivacySafeTypedHandler<
259
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
260
+ >(router, 'gateway', 'coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
261
+ this.requireAuthenticated(signatureArg);
262
+ return await this.inbound.prepareInboundHandoff(requestArg);
263
+ });
264
+ addPrivacySafeTypedHandler<
265
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
266
+ >(router, 'gateway', 'coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
267
+ this.requireAuthenticated(signatureArg);
268
+ return await this.inbound.completeInboundHandoff(requestArg);
269
+ });
270
+ addPrivacySafeTypedHandler<
271
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
272
+ >(router, 'gateway', 'coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
273
+ this.requireAuthenticated(signatureArg);
274
+ return await this.completeOutboundHandoff(requestArg);
275
+ });
276
+ return router;
277
+ }
278
+
238
279
  private queueAuthentication(
239
280
  gatewayArg: TGatewayDesired,
240
281
  signatureArg: string,
@@ -259,11 +300,9 @@ export class CoreMailGateway {
259
300
  >('coreMailGatewayAuthenticate', undefined, {
260
301
  timeoutMs: 5_000,
261
302
  abortSignal: this.stopController.signal,
262
- }).fire({
263
- credentialId: gatewayArg.credentialId,
264
- credentialVersion: gatewayArg.credentialVersion,
265
- credentialSecret,
266
- });
303
+ }).fire(
304
+ createCoreMailGatewayAuthenticationRequest(gatewayArg, credentialSecret),
305
+ );
267
306
  if (
268
307
  response.authenticated !== true
269
308
  || response.credentialId !== gatewayArg.credentialId
@@ -520,49 +559,81 @@ export class CoreMailGateway {
520
559
  ) {
521
560
  throw new Error('CoreMail gateway status chronology conflicts.');
522
561
  }
523
- const result = await this.models.Submission.exact.transition({
524
- current: submission,
525
- change: (modelArg) => {
526
- modelArg.transportMessageId = statusArg.transportMessageId;
527
- modelArg.acceptedAt = acceptedAt;
528
- modelArg.state = statusArg.state;
529
- modelArg.attempts = Math.max(
530
- submission.attempts,
531
- statusArg.attempts,
532
- );
533
- modelArg.gatewayStatusUpdatedAt = statusArg.updatedAt;
534
- if (nextAttemptAt === undefined) {
535
- delete modelArg.nextAttemptAt;
536
- } else {
537
- modelArg.nextAttemptAt = nextAttemptAt;
538
- }
539
- if (workerRetryAt === undefined) {
540
- delete modelArg.workerRetryAt;
541
- } else {
542
- modelArg.workerRetryAt = workerRetryAt;
543
- }
544
- if (statusArg.state === 'delivered') {
545
- modelArg.deliveredAt = statusArg.deliveredAt;
546
- } else {
547
- delete modelArg.deliveredAt;
548
- }
549
- if (statusArg.terminalAt === undefined) {
550
- delete modelArg.terminalAt;
551
- } else {
552
- modelArg.terminalAt = statusArg.terminalAt;
553
- }
554
- if (statusArg.error === undefined) {
555
- delete modelArg.error;
556
- } else {
557
- modelArg.error = statusArg.error;
562
+ // A terminal state is reached at most once (the loop returns early once
563
+ // the record is terminal) and a deferral counts only when the gateway
564
+ // status is strictly newer than the one already recorded, so replaying
565
+ // one status never counts twice.
566
+ const statKind = terminal
567
+ ? statusArg.state as 'delivered' | 'failed' | 'deadLettered'
568
+ : statusArg.state === 'deferred'
569
+ && (
570
+ submission.gatewayStatusUpdatedAt === undefined
571
+ || statusArg.updatedAt > submission.gatewayStatusUpdatedAt
572
+ )
573
+ ? 'deferred' as const
574
+ : undefined;
575
+ const statDescriptor = createCoreMailStatDescriptor(submission, updatedAt);
576
+ if (statKind) {
577
+ await ensureCoreMailStatCounter(this.models, statDescriptor);
578
+ }
579
+ const result = await withCoreMailTransaction(
580
+ this.database,
581
+ async (transactionArg) => {
582
+ const transitioned = await this.models.Submission.exact.transition({
583
+ current: submission,
584
+ change: (modelArg) => {
585
+ modelArg.transportMessageId = statusArg.transportMessageId;
586
+ modelArg.acceptedAt = acceptedAt;
587
+ modelArg.state = statusArg.state;
588
+ modelArg.attempts = Math.max(
589
+ submission.attempts,
590
+ statusArg.attempts,
591
+ );
592
+ modelArg.gatewayStatusUpdatedAt = statusArg.updatedAt;
593
+ if (nextAttemptAt === undefined) {
594
+ delete modelArg.nextAttemptAt;
595
+ } else {
596
+ modelArg.nextAttemptAt = nextAttemptAt;
597
+ }
598
+ if (workerRetryAt === undefined) {
599
+ delete modelArg.workerRetryAt;
600
+ } else {
601
+ modelArg.workerRetryAt = workerRetryAt;
602
+ }
603
+ if (statusArg.state === 'delivered') {
604
+ modelArg.deliveredAt = statusArg.deliveredAt;
605
+ } else {
606
+ delete modelArg.deliveredAt;
607
+ }
608
+ if (statusArg.terminalAt === undefined) {
609
+ delete modelArg.terminalAt;
610
+ } else {
611
+ modelArg.terminalAt = statusArg.terminalAt;
612
+ }
613
+ if (statusArg.error === undefined) {
614
+ delete modelArg.error;
615
+ } else {
616
+ modelArg.error = statusArg.error;
617
+ }
618
+ modelArg.updatedAt = updatedAt;
619
+ modelArg.revision += 1;
620
+ delete modelArg.leaseOwnerTaskId;
621
+ delete modelArg.leaseToken;
622
+ delete modelArg.leaseExpiresAt;
623
+ },
624
+ }, { session: transactionArg });
625
+ if (transitioned.status === 'transitioned' && statKind) {
626
+ await incrementCoreMailStatCounter(
627
+ this.models,
628
+ statDescriptor,
629
+ statKind,
630
+ updatedAt,
631
+ transactionArg,
632
+ );
558
633
  }
559
- modelArg.updatedAt = updatedAt;
560
- modelArg.revision += 1;
561
- delete modelArg.leaseOwnerTaskId;
562
- delete modelArg.leaseToken;
563
- delete modelArg.leaseExpiresAt;
634
+ return transitioned;
564
635
  },
565
- });
636
+ );
566
637
  if (result.status === 'transitioned') {
567
638
  return result.document;
568
639
  }