@serve.zone/coremail 1.1.0 → 1.3.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 (57) hide show
  1. package/changelog.md +43 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.auth.d.ts +19 -0
  4. package/dist_ts/classes.auth.js +58 -1
  5. package/dist_ts/classes.coremail.d.ts +3 -0
  6. package/dist_ts/classes.coremail.js +11 -4
  7. package/dist_ts/classes.gateway.d.ts +11 -1
  8. package/dist_ts/classes.gateway.js +101 -63
  9. package/dist_ts/classes.inbound.d.ts +10 -1
  10. package/dist_ts/classes.inbound.js +45 -7
  11. package/dist_ts/classes.maintenance.js +16 -1
  12. package/dist_ts/classes.models.d.ts +24 -2
  13. package/dist_ts/classes.models.js +72 -2
  14. package/dist_ts/classes.server.d.ts +6 -2
  15. package/dist_ts/classes.server.js +18 -68
  16. package/dist_ts/classes.smtpsubmission.d.ts +85 -0
  17. package/dist_ts/classes.smtpsubmission.js +379 -0
  18. package/dist_ts/classes.storage.d.ts +9 -0
  19. package/dist_ts/classes.storage.js +44 -1
  20. package/dist_ts/classes.submissions.d.ts +27 -1
  21. package/dist_ts/classes.submissions.js +164 -14
  22. package/dist_ts/coremail.errors.d.ts +18 -0
  23. package/dist_ts/coremail.errors.js +84 -0
  24. package/dist_ts/coremail.log.d.ts +1 -1
  25. package/dist_ts/coremail.log.js +1 -1
  26. package/dist_ts/coremail.mime.d.ts +15 -0
  27. package/dist_ts/coremail.mime.js +76 -1
  28. package/dist_ts/coremail.persistence.d.ts +38 -1
  29. package/dist_ts/coremail.persistence.js +124 -40
  30. package/dist_ts/coremail.stats.d.ts +72 -0
  31. package/dist_ts/coremail.stats.js +242 -0
  32. package/dist_ts/coremail.validation.d.ts +15 -2
  33. package/dist_ts/coremail.validation.js +26 -78
  34. package/dist_ts/interfaces.d.ts +18 -0
  35. package/dist_ts/plugins.d.ts +1 -0
  36. package/dist_ts/plugins.js +2 -1
  37. package/package.json +12 -11
  38. package/readme.md +51 -2
  39. package/ts/00_commitinfo_data.ts +1 -1
  40. package/ts/classes.auth.ts +77 -0
  41. package/ts/classes.coremail.ts +20 -0
  42. package/ts/classes.gateway.ts +121 -66
  43. package/ts/classes.inbound.ts +71 -6
  44. package/ts/classes.maintenance.ts +14 -0
  45. package/ts/classes.models.ts +67 -1
  46. package/ts/classes.server.ts +23 -80
  47. package/ts/classes.smtpsubmission.ts +466 -0
  48. package/ts/classes.storage.ts +54 -0
  49. package/ts/classes.submissions.ts +247 -12
  50. package/ts/coremail.errors.ts +111 -0
  51. package/ts/coremail.log.ts +1 -0
  52. package/ts/coremail.mime.ts +89 -0
  53. package/ts/coremail.persistence.ts +196 -48
  54. package/ts/coremail.stats.ts +342 -0
  55. package/ts/coremail.validation.ts +31 -103
  56. package/ts/interfaces.ts +20 -0
  57. package/ts/plugins.ts +1 -0
@@ -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 {
@@ -111,6 +118,7 @@ export class CoreMailGateway {
111
118
 
112
119
  public constructor(
113
120
  private readonly config: ICoreMailConfig,
121
+ private readonly database: plugins.smartdata.SmartdataDb,
114
122
  private readonly models: ICoreMailModels,
115
123
  private readonly transfers: CoreMailTransferService,
116
124
  private readonly inbound: CoreMailInboundService,
@@ -182,31 +190,7 @@ export class CoreMailGateway {
182
190
  ): Promise<void> {
183
191
  await this.disconnect();
184
192
  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
- }));
193
+ const router = this.createGatewayRouter(signatureArg);
210
194
  const socket = await plugins.typedsocket.TypedSocket.createClient(
211
195
  router,
212
196
  gatewayArg.endpointUrl,
@@ -235,6 +219,45 @@ export class CoreMailGateway {
235
219
  this.queueAuthentication(gatewayArg, signatureArg);
236
220
  }
237
221
 
222
+ /**
223
+ * The gateway-facing router.
224
+ *
225
+ * Every handler goes through the same privacy-safe envelope the workload
226
+ * router uses, so dcrouter observes the typed `ICoreMailErrorData` code —
227
+ * `QUOTA_EXCEEDED` versus `STATE_CONFLICT` decides whether it defers a
228
+ * message or rejects it — instead of an opaque error string.
229
+ */
230
+ public createGatewayRouter(
231
+ signatureArg: string,
232
+ ): plugins.typedrequest.TypedRouter {
233
+ const router = new plugins.typedrequest.TypedRouter();
234
+ addPrivacySafeTypedHandler<
235
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
236
+ >(router, 'gateway', 'coreMailGatewayResolveRecipients', async (requestArg) => {
237
+ this.requireAuthenticated(signatureArg);
238
+ return await this.inbound.resolveRecipients(requestArg);
239
+ });
240
+ addPrivacySafeTypedHandler<
241
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
242
+ >(router, 'gateway', 'coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
243
+ this.requireAuthenticated(signatureArg);
244
+ return await this.inbound.prepareInboundHandoff(requestArg);
245
+ });
246
+ addPrivacySafeTypedHandler<
247
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
248
+ >(router, 'gateway', 'coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
249
+ this.requireAuthenticated(signatureArg);
250
+ return await this.inbound.completeInboundHandoff(requestArg);
251
+ });
252
+ addPrivacySafeTypedHandler<
253
+ plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
254
+ >(router, 'gateway', 'coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
255
+ this.requireAuthenticated(signatureArg);
256
+ return await this.completeOutboundHandoff(requestArg);
257
+ });
258
+ return router;
259
+ }
260
+
238
261
  private queueAuthentication(
239
262
  gatewayArg: TGatewayDesired,
240
263
  signatureArg: string,
@@ -520,49 +543,81 @@ export class CoreMailGateway {
520
543
  ) {
521
544
  throw new Error('CoreMail gateway status chronology conflicts.');
522
545
  }
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;
546
+ // A terminal state is reached at most once (the loop returns early once
547
+ // the record is terminal) and a deferral counts only when the gateway
548
+ // status is strictly newer than the one already recorded, so replaying
549
+ // one status never counts twice.
550
+ const statKind = terminal
551
+ ? statusArg.state as 'delivered' | 'failed' | 'deadLettered'
552
+ : statusArg.state === 'deferred'
553
+ && (
554
+ submission.gatewayStatusUpdatedAt === undefined
555
+ || statusArg.updatedAt > submission.gatewayStatusUpdatedAt
556
+ )
557
+ ? 'deferred' as const
558
+ : undefined;
559
+ const statDescriptor = createCoreMailStatDescriptor(submission, updatedAt);
560
+ if (statKind) {
561
+ await ensureCoreMailStatCounter(this.models, statDescriptor);
562
+ }
563
+ const result = await withCoreMailTransaction(
564
+ this.database,
565
+ async (transactionArg) => {
566
+ const transitioned = await this.models.Submission.exact.transition({
567
+ current: submission,
568
+ change: (modelArg) => {
569
+ modelArg.transportMessageId = statusArg.transportMessageId;
570
+ modelArg.acceptedAt = acceptedAt;
571
+ modelArg.state = statusArg.state;
572
+ modelArg.attempts = Math.max(
573
+ submission.attempts,
574
+ statusArg.attempts,
575
+ );
576
+ modelArg.gatewayStatusUpdatedAt = statusArg.updatedAt;
577
+ if (nextAttemptAt === undefined) {
578
+ delete modelArg.nextAttemptAt;
579
+ } else {
580
+ modelArg.nextAttemptAt = nextAttemptAt;
581
+ }
582
+ if (workerRetryAt === undefined) {
583
+ delete modelArg.workerRetryAt;
584
+ } else {
585
+ modelArg.workerRetryAt = workerRetryAt;
586
+ }
587
+ if (statusArg.state === 'delivered') {
588
+ modelArg.deliveredAt = statusArg.deliveredAt;
589
+ } else {
590
+ delete modelArg.deliveredAt;
591
+ }
592
+ if (statusArg.terminalAt === undefined) {
593
+ delete modelArg.terminalAt;
594
+ } else {
595
+ modelArg.terminalAt = statusArg.terminalAt;
596
+ }
597
+ if (statusArg.error === undefined) {
598
+ delete modelArg.error;
599
+ } else {
600
+ modelArg.error = statusArg.error;
601
+ }
602
+ modelArg.updatedAt = updatedAt;
603
+ modelArg.revision += 1;
604
+ delete modelArg.leaseOwnerTaskId;
605
+ delete modelArg.leaseToken;
606
+ delete modelArg.leaseExpiresAt;
607
+ },
608
+ }, { session: transactionArg });
609
+ if (transitioned.status === 'transitioned' && statKind) {
610
+ await incrementCoreMailStatCounter(
611
+ this.models,
612
+ statDescriptor,
613
+ statKind,
614
+ updatedAt,
615
+ transactionArg,
616
+ );
558
617
  }
559
- modelArg.updatedAt = updatedAt;
560
- modelArg.revision += 1;
561
- delete modelArg.leaseOwnerTaskId;
562
- delete modelArg.leaseToken;
563
- delete modelArg.leaseExpiresAt;
618
+ return transitioned;
564
619
  },
565
- });
620
+ );
566
621
  if (result.status === 'transitioned') {
567
622
  return result.document;
568
623
  }
@@ -3,15 +3,24 @@ import { type ICoreMailModels } from './classes.models.js';
3
3
  import {
4
4
  createOpaqueToken,
5
5
  createSha256,
6
+ type CoreMailStorage,
6
7
  } from './classes.storage.js';
7
8
  import { CoreMailTransferService } from './classes.transfer.js';
8
9
  import {
9
- normalizeCoreMailConnectionInfo,
10
10
  normalizeCoreMailEnvelope,
11
- normalizeCoreMailGatewayMessage,
12
11
  normalizeCoreMailMailboxAddress,
13
12
  } from './coremail.validation.js';
14
13
  import { CoreMailInboundCursorCodec } from './coremail.cursor.js';
14
+ import { logCoreMailFailure } from './coremail.log.js';
15
+ import {
16
+ coreMailHeaderScanBytes,
17
+ parseCoreMailMessageHeaders,
18
+ } from './coremail.mime.js';
19
+ import {
20
+ createCoreMailStatDescriptor,
21
+ ensureCoreMailStatCounter,
22
+ incrementCoreMailStatCounter,
23
+ } from './coremail.stats.js';
15
24
  import {
16
25
  createCoreMailPendingQuotaDescriptor,
17
26
  decrementCoreMailQuotaCounter,
@@ -100,7 +109,13 @@ const toPublicDelivery = (
100
109
  : {}),
101
110
  });
102
111
 
103
- const createEnvelopeDigest = (
112
+ /**
113
+ * Routing-handle binding digest over the exact normalized envelope and source.
114
+ * Exported so a regression test can pin the byte layout: the normalizers were
115
+ * moved to the upstream contract package, and a silently different shape here
116
+ * would invalidate every issued handle.
117
+ */
118
+ export const createEnvelopeDigest = (
104
119
  envelopeArg: plugins.serveZoneInterfaces.data.ICoreMailEnvelope,
105
120
  sourceArg: plugins.serveZoneInterfaces.data.IMailConnectionInfo,
106
121
  ) => createSha256(JSON.stringify({
@@ -137,6 +152,7 @@ export class CoreMailInboundService {
137
152
  private readonly database: plugins.smartdata.SmartdataDb,
138
153
  private readonly models: ICoreMailModels,
139
154
  private readonly transfers: CoreMailTransferService,
155
+ private readonly storage: CoreMailStorage,
140
156
  private readonly serviceId: string,
141
157
  private readonly getDesiredState: () => Promise<TDesiredState | null>,
142
158
  resolveRuntimeSecretArg: (secretKeyArg: string) => string,
@@ -163,7 +179,7 @@ export class CoreMailInboundService {
163
179
  throw new Error('CoreMail gateway recipients are invalid.');
164
180
  }
165
181
  const envelope = normalizeCoreMailEnvelope(requestArg.envelope);
166
- const source = normalizeCoreMailConnectionInfo(requestArg.source);
182
+ const source = plugins.serveZoneInterfaces.data.normalizeCoreMailConnectionInfo(requestArg.source);
167
183
  const envelopeDigest = createEnvelopeDigest(envelope, source);
168
184
  const recipients = requestArg.recipients.map(normalizeCoreMailMailboxAddress);
169
185
  if (new Set(recipients).size !== recipients.length) {
@@ -270,8 +286,8 @@ export class CoreMailInboundService {
270
286
  if (new Set(routingHandles).size !== routingHandles.length) {
271
287
  throw new Error('CoreMail inbound handoff contains duplicate routing handles.');
272
288
  }
273
- const message = normalizeCoreMailGatewayMessage(requestArg.message);
274
- const source = normalizeCoreMailConnectionInfo(requestArg.source);
289
+ const message = plugins.serveZoneInterfaces.data.normalizeCoreMailGatewayMessage(requestArg.message);
290
+ const source = plugins.serveZoneInterfaces.data.normalizeCoreMailConnectionInfo(requestArg.source);
275
291
  const envelopeDigest = createEnvelopeDigest(message.envelope, source);
276
292
  const existing = await this.models.InboundHandoff.exact.findStoredOne({
277
293
  transportDeliveryId,
@@ -472,6 +488,25 @@ export class CoreMailInboundService {
472
488
  grouped.set(key, group);
473
489
  }
474
490
  const groups = [...grouped.values()];
491
+ // The gateway message descriptor cannot carry Message-ID or Subject, so
492
+ // both are read from the stored bytes. A scan failure must never lose a
493
+ // delivery: the fields stay absent.
494
+ let headers: { messageId?: string; subject?: string } = {};
495
+ try {
496
+ headers = parseCoreMailMessageHeaders(
497
+ await this.storage.readObjectHeadBytes(
498
+ handoff.objectKey,
499
+ handoff.message.rawMime.lengthBytes,
500
+ handoff.message.rawMime.sha256,
501
+ plugins.serveZoneInterfaces.data.coreMailLimits.serializedMimeBytes,
502
+ coreMailHeaderScanBytes,
503
+ ),
504
+ );
505
+ } catch (error) {
506
+ await logCoreMailFailure('gateway', 'HEADER_SCAN_FAILED', error, {
507
+ handoffId: handoff.handoffId,
508
+ }).catch(() => undefined);
509
+ }
475
510
  const candidates: ICoreMailInboundDeliveryRecord[] = [];
476
511
  for (const group of groups) {
477
512
  const authority = group[0];
@@ -483,6 +518,10 @@ export class CoreMailInboundService {
483
518
  const quota = createCoreMailPendingQuotaDescriptor(authority);
484
519
  await ensureCoreMailQuotaCounter(this.models, quota);
485
520
  const receivedAt = this.now();
521
+ await ensureCoreMailStatCounter(
522
+ this.models,
523
+ createCoreMailStatDescriptor(authority, receivedAt),
524
+ );
486
525
  candidates.push({
487
526
  deliveryId: plugins.nodeCrypto.randomUUID(),
488
527
  tenantId: authority.tenantId,
@@ -499,6 +538,10 @@ export class CoreMailInboundService {
499
538
  rawMimeSha256: handoff.message.rawMime.sha256,
500
539
  rawMimeLengthBytes: handoff.message.rawMime.lengthBytes,
501
540
  rawMimeObjectKey: handoff.objectKey,
541
+ ...(headers.messageId === undefined
542
+ ? {}
543
+ : { messageId: headers.messageId }),
544
+ ...(headers.subject === undefined ? {} : { subject: headers.subject }),
502
545
  receivedAt,
503
546
  updatedAt: receivedAt,
504
547
  });
@@ -541,6 +584,15 @@ export class CoreMailInboundService {
541
584
  if (inserted.status === 'conflict') {
542
585
  throw new Error('CoreMail inbound delivery identity conflict.');
543
586
  }
587
+ // Counted with the insert, so a replayed completion that finds
588
+ // the delivery already present counts nothing.
589
+ await incrementCoreMailStatCounter(
590
+ this.models,
591
+ createCoreMailStatDescriptor(authority, candidate.receivedAt),
592
+ 'received',
593
+ candidate.receivedAt,
594
+ transactionArg,
595
+ );
544
596
  }
545
597
  for (const originalHandle of group) {
546
598
  const handle = await this.models.RecipientHandle.exact.findStoredOne(
@@ -833,6 +885,8 @@ export class CoreMailInboundService {
833
885
  bindingRevision: sessionArg.bindingRevision,
834
886
  });
835
887
  await ensureCoreMailQuotaCounter(this.models, quota);
888
+ const statDescriptor = createCoreMailStatDescriptor(sessionArg, updatedAt);
889
+ await ensureCoreMailStatCounter(this.models, statDescriptor);
836
890
  const transactionResult = await withCoreMailTransaction(
837
891
  this.database,
838
892
  async (transactionArg) => {
@@ -872,6 +926,17 @@ export class CoreMailInboundService {
872
926
  if (update.status !== 'transitioned') {
873
927
  throw new Error('CoreMail inbound acknowledgement fence changed.');
874
928
  }
929
+ // Counted with the acknowledgement transition; the replay branch above
930
+ // returns before reaching this point.
931
+ await incrementCoreMailStatCounter(
932
+ this.models,
933
+ statDescriptor,
934
+ requestArg.outcome === 'processed'
935
+ ? 'acknowledgedProcessed'
936
+ : 'acknowledgedDiscarded',
937
+ updatedAt,
938
+ transactionArg,
939
+ );
875
940
  return { replayed: false };
876
941
  },
877
942
  );
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import type { ICoreMailModels } from './classes.models.js';
3
3
  import type { CoreMailStorage } from './classes.storage.js';
4
4
  import { logCoreMailFailure } from './coremail.log.js';
5
+ import { coreMailStatRetentionMs } from './coremail.stats.js';
5
6
 
6
7
  const sweepIntervalMs = 60_000;
7
8
  const batchSize = 100;
@@ -274,6 +275,19 @@ export class CoreMailMaintenanceService {
274
275
  }
275
276
  if (handles.length < batchSize) break;
276
277
  }
278
+ const statCutoff = now - coreMailStatRetentionMs;
279
+ while (!signalArg?.aborted) {
280
+ const statCounters = await this.models.StatCounter.exact.findStored({
281
+ filter: { dayUtc: { $lte: statCutoff } },
282
+ sort: { dayUtc: 1, statId: 1 },
283
+ limit: batchSize,
284
+ });
285
+ for (const statCounter of statCounters) {
286
+ signalArg?.throwIfAborted();
287
+ await this.models.StatCounter.exact.delete({ current: statCounter });
288
+ }
289
+ if (statCounters.length < batchSize) break;
290
+ }
277
291
  while (!signalArg?.aborted) {
278
292
  const counters = await this.models.QuotaCounter.exact.findStored({
279
293
  filter: {