@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
@@ -1,12 +1,12 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import {
3
- normalizeCoreMailConnectionInfo,
3
+ createCoreMailRawOutboundMessage,
4
4
  normalizeCoreMailEnvelope,
5
- normalizeCoreMailGatewayMessage,
6
5
  normalizeCoreMailOutboundMessage,
7
6
  } from './coremail.validation.js';
8
7
  import type {
9
8
  ISubmissionPartRecord,
9
+ TCoreMailSubmissionSource,
10
10
  TTransferGrantPurpose,
11
11
  } from './interfaces.js';
12
12
 
@@ -49,6 +49,47 @@ export interface ICoreMailQuotaCounterRecord {
49
49
  updatedAt: number;
50
50
  }
51
51
 
52
+ /**
53
+ * Per-binding, per-UTC-day outbound and inbound counters.
54
+ *
55
+ * Counted events are durable transitions, not requests: a replayed transition
56
+ * contributes nothing. Rows are retained for 90 days by the maintenance sweep.
57
+ */
58
+ export interface ICoreMailStatCounterRecord {
59
+ statId: TCoreMailSha256;
60
+ tenantId: string;
61
+ serviceId: string;
62
+ bindingId: string;
63
+ /** UTC midnight of the counted day, in milliseconds. */
64
+ dayUtc: number;
65
+ submittedApi: number;
66
+ submittedSmtp: number;
67
+ delivered: number;
68
+ deferred: number;
69
+ failed: number;
70
+ deadLettered: number;
71
+ received: number;
72
+ acknowledgedProcessed: number;
73
+ acknowledgedDiscarded: number;
74
+ createdAt: number;
75
+ updatedAt: number;
76
+ }
77
+
78
+ /** Counter names an increment may target. */
79
+ export const coreMailStatKinds = [
80
+ 'submittedApi',
81
+ 'submittedSmtp',
82
+ 'delivered',
83
+ 'deferred',
84
+ 'failed',
85
+ 'deadLettered',
86
+ 'received',
87
+ 'acknowledgedProcessed',
88
+ 'acknowledgedDiscarded',
89
+ ] as const;
90
+
91
+ export type TCoreMailStatKind = (typeof coreMailStatKinds)[number];
92
+
52
93
  export interface ICoreMailTransferGrantRecord {
53
94
  grantId: string;
54
95
  authorityKind: 'control' | 'gateway' | 'workload';
@@ -79,6 +120,14 @@ export interface ICoreMailSubmissionRecord {
79
120
  idempotencyKey: string;
80
121
  submissionDigest: TCoreMailSha256;
81
122
  state: plugins.serveZoneInterfaces.data.TCoreMailSubmissionState;
123
+ /** Absent on records persisted before the discriminator existed; read as 'api'. */
124
+ source?: TCoreMailSubmissionSource;
125
+ /**
126
+ * Set once, in the same transition that first publishes the immutable MIME.
127
+ * Its presence is what makes the submitted-counter increment exactly-once
128
+ * across an interrupted publication that finalization later repeats.
129
+ */
130
+ statsSubmittedCountedAt?: number;
82
131
  message: plugins.serveZoneInterfaces.data.ICoreMailOutboundMessageDescriptor;
83
132
  parts: ISubmissionPartRecord[];
84
133
  mimeObjectKey?: string;
@@ -608,6 +657,39 @@ export const assertCoreMailTransferGrantRecord = (
608
657
  }
609
658
  };
610
659
 
660
+ export const assertCoreMailStatCounterRecord = (
661
+ valueArg: unknown,
662
+ ): asserts valueArg is ICoreMailStatCounterRecord => {
663
+ const value = requireRecord(
664
+ valueArg,
665
+ [
666
+ 'statId',
667
+ 'tenantId',
668
+ 'serviceId',
669
+ 'bindingId',
670
+ 'dayUtc',
671
+ ...coreMailStatKinds,
672
+ 'createdAt',
673
+ 'updatedAt',
674
+ ],
675
+ [],
676
+ 'CoreMailStatCounter',
677
+ );
678
+ requireSha256(value.statId, 'CoreMailStatCounter.statId');
679
+ requireIdentifier(value.tenantId, 'CoreMailStatCounter.tenantId');
680
+ requireIdentifier(value.serviceId, 'CoreMailStatCounter.serviceId');
681
+ requireIdentifier(value.bindingId, 'CoreMailStatCounter.bindingId');
682
+ const dayUtc = requireInteger(value.dayUtc, 'CoreMailStatCounter.dayUtc');
683
+ if (dayUtc % 86_400_000 !== 0) {
684
+ throw new Error('CoreMailStatCounter.dayUtc must be a UTC day boundary.');
685
+ }
686
+ for (const kind of coreMailStatKinds) {
687
+ requireInteger(value[kind], `CoreMailStatCounter.${kind}`);
688
+ }
689
+ requireInteger(value.createdAt, 'CoreMailStatCounter.createdAt');
690
+ requireInteger(value.updatedAt, 'CoreMailStatCounter.updatedAt');
691
+ };
692
+
611
693
  export const assertCoreMailSubmissionRecord = (
612
694
  valueArg: unknown,
613
695
  ): asserts valueArg is ICoreMailSubmissionRecord => {
@@ -630,6 +712,8 @@ export const assertCoreMailSubmissionRecord = (
630
712
  'updatedAt',
631
713
  ],
632
714
  [
715
+ 'source',
716
+ 'statsSubmittedCountedAt',
633
717
  'mimeObjectKey',
634
718
  'mimeSha256',
635
719
  'mimeLengthBytes',
@@ -677,6 +761,17 @@ export const assertCoreMailSubmissionRecord = (
677
761
  if (!submissionStates.has(String(value.state))) {
678
762
  throw new Error('CoreMailSubmission.state is invalid.');
679
763
  }
764
+ if (
765
+ value.source !== undefined
766
+ && value.source !== 'api'
767
+ && value.source !== 'smtp'
768
+ ) {
769
+ throw new Error('CoreMailSubmission.source is invalid.');
770
+ }
771
+ // Records persisted before the discriminator existed are API submissions.
772
+ const source: TCoreMailSubmissionSource = value.source === undefined
773
+ ? 'api'
774
+ : value.source;
680
775
  const messageRecord = requireRecord(
681
776
  value.message,
682
777
  ['sender', 'recipients', 'subject', 'parts'],
@@ -689,50 +784,98 @@ export const assertCoreMailSubmissionRecord = (
689
784
  ['displayName'],
690
785
  'CoreMailSubmission.message.sender',
691
786
  );
692
- const normalizedMessage = normalizeCoreMailOutboundMessage(value.message, {
693
- schemaVersion: 2,
694
- bindingId: String(value.bindingId),
695
- serviceId: String(value.serviceId),
696
- tenantId: String(value.tenantId),
697
- revision: Number(value.bindingRevision),
698
- state: 'active',
699
- capabilities: ['outbound'],
700
- credentials: [],
701
- allowedSenders: [String(sender.address)],
702
- inboundRecipients: [],
703
- limits: {
704
- messagesPerMinute: 1,
705
- messagesPerDay: 1,
706
- maxPendingInbound: 1,
707
- },
708
- });
709
- requireNormalized(
710
- value.message,
711
- normalizedMessage,
712
- 'CoreMailSubmission.message',
713
- );
714
- if (!Array.isArray(value.parts) || value.parts.length === 0) {
715
- throw new Error('CoreMailSubmission.parts is invalid.');
716
- }
717
- value.parts.forEach((partArg, indexArg) =>
718
- assertPart(partArg, `CoreMailSubmission.parts[${indexArg}]`)
719
- );
720
- const parts = value.parts as ISubmissionPartRecord[];
721
- if (
722
- new Set(parts.map((partArg) => partArg.partId)).size !== parts.length
723
- || canonicalJson(parts.map((partArg) => ({
724
- partId: partArg.partId,
725
- kind: partArg.kind,
726
- contentType: partArg.contentType,
727
- ...(partArg.filename === undefined ? {} : { filename: partArg.filename }),
728
- ...(partArg.contentId === undefined
729
- ? {}
730
- : { contentId: partArg.contentId }),
731
- sha256: partArg.sha256,
732
- lengthBytes: partArg.lengthBytes,
733
- }))) !== canonicalJson(normalizedMessage.parts)
734
- ) {
735
- throw new Error('CoreMailSubmission parts do not match message intent.');
787
+ if (source === 'smtp') {
788
+ // An SMTP submission carries opaque exact bytes owned by the immutable
789
+ // MIME tuple. It has no CoreMail-composed body, so it has no part
790
+ // descriptors and no part objects to upload — the non-empty and
791
+ // parts-match-intent rules below do not apply to it.
792
+ const recipients = requireRecord(
793
+ messageRecord.recipients,
794
+ ['to'],
795
+ [],
796
+ 'CoreMailSubmission.message.recipients',
797
+ );
798
+ if (
799
+ !Array.isArray(recipients.to)
800
+ || messageRecord.subject !== ''
801
+ || !Array.isArray(messageRecord.parts)
802
+ || messageRecord.parts.length !== 0
803
+ || Object.hasOwn(messageRecord, 'replyTo')
804
+ || Object.hasOwn(messageRecord, 'headers')
805
+ || Object.hasOwn(sender, 'displayName')
806
+ ) {
807
+ throw new Error('CoreMailSubmission raw message is invalid.');
808
+ }
809
+ const envelope = normalizeCoreMailEnvelope({
810
+ mailFrom: sender.address,
811
+ rcptTo: recipients.to.map((recipientArg) =>
812
+ requireRecord(
813
+ recipientArg,
814
+ ['address'],
815
+ [],
816
+ 'CoreMailSubmission.message.recipients.to[]',
817
+ ).address
818
+ ),
819
+ });
820
+ if (envelope.mailFrom === '') {
821
+ throw new Error('CoreMailSubmission raw message has no envelope sender.');
822
+ }
823
+ requireNormalized(
824
+ value.message,
825
+ createCoreMailRawOutboundMessage(envelope),
826
+ 'CoreMailSubmission.message',
827
+ );
828
+ if (!Array.isArray(value.parts) || value.parts.length !== 0) {
829
+ throw new Error('CoreMailSubmission raw parts must be empty.');
830
+ }
831
+ } else {
832
+ const normalizedMessage = normalizeCoreMailOutboundMessage(value.message, {
833
+ schemaVersion: 2,
834
+ bindingId: String(value.bindingId),
835
+ serviceId: String(value.serviceId),
836
+ tenantId: String(value.tenantId),
837
+ revision: Number(value.bindingRevision),
838
+ state: 'active',
839
+ capabilities: ['outbound'],
840
+ credentials: [],
841
+ allowedSenders: [String(sender.address)],
842
+ inboundRecipients: [],
843
+ limits: {
844
+ messagesPerMinute: 1,
845
+ messagesPerDay: 1,
846
+ maxPendingInbound: 1,
847
+ },
848
+ });
849
+ requireNormalized(
850
+ value.message,
851
+ normalizedMessage,
852
+ 'CoreMailSubmission.message',
853
+ );
854
+ if (!Array.isArray(value.parts) || value.parts.length === 0) {
855
+ throw new Error('CoreMailSubmission.parts is invalid.');
856
+ }
857
+ value.parts.forEach((partArg, indexArg) =>
858
+ assertPart(partArg, `CoreMailSubmission.parts[${indexArg}]`)
859
+ );
860
+ const parts = value.parts as ISubmissionPartRecord[];
861
+ if (
862
+ new Set(parts.map((partArg) => partArg.partId)).size !== parts.length
863
+ || canonicalJson(parts.map((partArg) => ({
864
+ partId: partArg.partId,
865
+ kind: partArg.kind,
866
+ contentType: partArg.contentType,
867
+ ...(partArg.filename === undefined
868
+ ? {}
869
+ : { filename: partArg.filename }),
870
+ ...(partArg.contentId === undefined
871
+ ? {}
872
+ : { contentId: partArg.contentId }),
873
+ sha256: partArg.sha256,
874
+ lengthBytes: partArg.lengthBytes,
875
+ }))) !== canonicalJson(normalizedMessage.parts)
876
+ ) {
877
+ throw new Error('CoreMailSubmission parts do not match message intent.');
878
+ }
736
879
  }
737
880
  requireInteger(value.attempts, 'CoreMailSubmission.attempts');
738
881
  requireInteger(value.createdAt, 'CoreMailSubmission.createdAt');
@@ -758,6 +901,10 @@ export const assertCoreMailSubmissionRecord = (
758
901
  if (mimeFieldCount !== 0 && mimeFieldCount !== 3) {
759
902
  throw new Error('CoreMailSubmission immutable MIME tuple is incomplete.');
760
903
  }
904
+ if (source === 'smtp' && mimeFieldCount !== 3) {
905
+ // The exact bytes are an SMTP submission's only content.
906
+ throw new Error('CoreMailSubmission raw submission has no immutable MIME.');
907
+ }
761
908
  if (value.transportMessageId !== undefined) {
762
909
  requireIdentifier(
763
910
  value.transportMessageId,
@@ -768,6 +915,7 @@ export const assertCoreMailSubmissionRecord = (
768
915
  requireUuid(value.transportGrantId, 'CoreMailSubmission.transportGrantId');
769
916
  }
770
917
  for (const [key, entry] of [
918
+ ['statsSubmittedCountedAt', value.statsSubmittedCountedAt],
771
919
  ['gatewayStatusUpdatedAt', value.gatewayStatusUpdatedAt],
772
920
  ['mimePublishedAt', value.mimePublishedAt],
773
921
  ['nextAttemptAt', value.nextAttemptAt],
@@ -1097,12 +1245,12 @@ export const assertCoreMailInboundHandoffRecord = (
1097
1245
  }
1098
1246
  requireNormalized(
1099
1247
  value.message,
1100
- normalizeCoreMailGatewayMessage(value.message),
1248
+ plugins.serveZoneInterfaces.data.normalizeCoreMailGatewayMessage(value.message),
1101
1249
  'CoreMailInboundHandoff.message',
1102
1250
  );
1103
1251
  requireNormalized(
1104
1252
  value.source,
1105
- normalizeCoreMailConnectionInfo(value.source),
1253
+ plugins.serveZoneInterfaces.data.normalizeCoreMailConnectionInfo(value.source),
1106
1254
  'CoreMailInboundHandoff.source',
1107
1255
  );
1108
1256
  if (
@@ -0,0 +1,342 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { ICoreMailModels } from './classes.models.js';
3
+ import { createSha256 } from './classes.storage.js';
4
+ import {
5
+ coreMailStatKinds,
6
+ type ICoreMailStatCounterRecord,
7
+ type TCoreMailStatKind,
8
+ } from './coremail.persistence.js';
9
+
10
+ const dayMs = 86_400_000;
11
+
12
+ /** How long a counted day is retained before the maintenance sweep drops it. */
13
+ export const coreMailStatRetentionMs = 90 * dayMs;
14
+
15
+ export interface ICoreMailStatAuthority {
16
+ tenantId: string;
17
+ serviceId: string;
18
+ bindingId: string;
19
+ }
20
+
21
+ export interface ICoreMailStatDescriptor extends ICoreMailStatAuthority {
22
+ statId: plugins.serveZoneInterfaces.data.TCoreMailSha256;
23
+ dayUtc: number;
24
+ }
25
+
26
+ /**
27
+ * The counter row an event at `nowArg` belongs to.
28
+ *
29
+ * Counters are keyed by binding rather than binding revision: a revision bump
30
+ * is a configuration change, not a new tenant, and a day's totals must survive
31
+ * one.
32
+ */
33
+ export const createCoreMailStatDescriptor = (
34
+ authorityArg: ICoreMailStatAuthority,
35
+ nowArg: number,
36
+ ): ICoreMailStatDescriptor => {
37
+ const dayUtc = Math.floor(nowArg / dayMs) * dayMs;
38
+ return {
39
+ tenantId: authorityArg.tenantId,
40
+ serviceId: authorityArg.serviceId,
41
+ bindingId: authorityArg.bindingId,
42
+ dayUtc,
43
+ statId: createSha256(JSON.stringify({
44
+ tenantId: authorityArg.tenantId,
45
+ serviceId: authorityArg.serviceId,
46
+ bindingId: authorityArg.bindingId,
47
+ dayUtc,
48
+ })),
49
+ };
50
+ };
51
+
52
+ const matchesDescriptor = (
53
+ counterArg: plugins.smartdata.TStoredDocument<ICoreMailStatCounterRecord>,
54
+ descriptorArg: ICoreMailStatDescriptor,
55
+ ): boolean =>
56
+ counterArg.tenantId === descriptorArg.tenantId
57
+ && counterArg.serviceId === descriptorArg.serviceId
58
+ && counterArg.bindingId === descriptorArg.bindingId
59
+ && counterArg.dayUtc === descriptorArg.dayUtc;
60
+
61
+ /** Create the day's row if it does not exist yet. Safe to call concurrently. */
62
+ export const ensureCoreMailStatCounter = async (
63
+ modelsArg: ICoreMailModels,
64
+ descriptorArg: ICoreMailStatDescriptor,
65
+ ): Promise<void> => {
66
+ const existing = await modelsArg.StatCounter.exact.findStoredOne({
67
+ statId: descriptorArg.statId,
68
+ });
69
+ if (existing) {
70
+ if (!matchesDescriptor(existing, descriptorArg)) {
71
+ throw new Error('CoreMail stat counter identity conflicts with persisted state.');
72
+ }
73
+ return;
74
+ }
75
+ try {
76
+ const result = await modelsArg.StatCounter.exact.insert({
77
+ ...descriptorArg,
78
+ ...Object.fromEntries(coreMailStatKinds.map((kindArg) => [kindArg, 0])) as
79
+ Record<TCoreMailStatKind, number>,
80
+ createdAt: descriptorArg.dayUtc,
81
+ updatedAt: descriptorArg.dayUtc,
82
+ });
83
+ if (result.status !== 'conflict') {
84
+ return;
85
+ }
86
+ } catch (error) {
87
+ if (
88
+ !(error instanceof plugins.smartdata.SmartdataExactPersistenceError)
89
+ || error.code !== 'unique_conflict'
90
+ ) {
91
+ throw error;
92
+ }
93
+ }
94
+ const concurrent = await modelsArg.StatCounter.exact.findStoredOne({
95
+ statId: descriptorArg.statId,
96
+ });
97
+ if (!concurrent || !matchesDescriptor(concurrent, descriptorArg)) {
98
+ throw new Error('CoreMail stat counter identity conflicts with persisted state.');
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Add one to a counter through the revision-fenced transition primitive, so
104
+ * the read and the write are one atomic compare-and-set. Call this inside the
105
+ * transaction that carries the counted state transition; then the event and
106
+ * its count commit together or not at all.
107
+ */
108
+ export const incrementCoreMailStatCounter = async (
109
+ modelsArg: ICoreMailModels,
110
+ descriptorArg: ICoreMailStatDescriptor,
111
+ kindArg: TCoreMailStatKind,
112
+ updatedAtArg: number,
113
+ sessionArg?: plugins.smartdata.SmartdataSession,
114
+ ): Promise<void> => {
115
+ const counter = await modelsArg.StatCounter.exact.findStoredOne(
116
+ { statId: descriptorArg.statId },
117
+ sessionArg ? { session: sessionArg } : undefined,
118
+ );
119
+ if (!counter) {
120
+ throw new Error('CoreMail stat counter is unavailable.');
121
+ }
122
+ if (!matchesDescriptor(counter, descriptorArg)) {
123
+ throw new Error('CoreMail stat counter identity conflicts with persisted state.');
124
+ }
125
+ const update = await modelsArg.StatCounter.exact.transition({
126
+ current: counter,
127
+ change: (modelArg) => {
128
+ modelArg[kindArg] += 1;
129
+ modelArg.updatedAt = updatedAtArg;
130
+ },
131
+ }, sessionArg ? { session: sessionArg } : undefined);
132
+ if (update.status !== 'transitioned') {
133
+ throw new Error('CoreMail stat counter transition was contended.');
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Ensure the day's row exists, then count one event on it.
139
+ *
140
+ * For a counted transition that runs inside a transaction, call
141
+ * `ensureCoreMailStatCounter` before opening it and
142
+ * `incrementCoreMailStatCounter` with the session inside it — the same order
143
+ * the quota counters use, so the row insert never contends with the
144
+ * transaction that increments it.
145
+ */
146
+ export const countCoreMailStat = async (
147
+ modelsArg: ICoreMailModels,
148
+ authorityArg: ICoreMailStatAuthority,
149
+ kindArg: TCoreMailStatKind,
150
+ nowArg: number,
151
+ sessionArg?: plugins.smartdata.SmartdataSession,
152
+ ): Promise<ICoreMailStatDescriptor> => {
153
+ const descriptor = createCoreMailStatDescriptor(authorityArg, nowArg);
154
+ await ensureCoreMailStatCounter(modelsArg, descriptor);
155
+ await incrementCoreMailStatCounter(
156
+ modelsArg,
157
+ descriptor,
158
+ kindArg,
159
+ nowArg,
160
+ sessionArg,
161
+ );
162
+ return descriptor;
163
+ };
164
+
165
+ /** Maximum rows one statistics page may return. */
166
+ export const coreMailStatPageMaximum = 200;
167
+
168
+ const dayPattern = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
169
+
170
+ /** `YYYY-MM-DD` to the UTC midnight milliseconds the counter rows are keyed by. */
171
+ export const parseCoreMailStatDay = (valueArg: unknown, labelArg: string): number => {
172
+ if (typeof valueArg !== 'string' || !dayPattern.test(valueArg)) {
173
+ throw new Error(`CoreMail statistics ${labelArg} must be a YYYY-MM-DD day.`);
174
+ }
175
+ const parsed = Date.parse(`${valueArg}T00:00:00.000Z`);
176
+ if (!Number.isSafeInteger(parsed) || parsed % dayMs !== 0) {
177
+ throw new Error(`CoreMail statistics ${labelArg} must be a real UTC day.`);
178
+ }
179
+ return parsed;
180
+ };
181
+
182
+ export const formatCoreMailStatDay = (dayUtcArg: number): string =>
183
+ new Date(dayUtcArg).toISOString().slice(0, 10);
184
+
185
+ export interface ICoreMailStatCursorPosition {
186
+ dayUtc: number;
187
+ statId: plugins.serveZoneInterfaces.data.TCoreMailSha256;
188
+ }
189
+
190
+ /**
191
+ * Opaque composite cursor over `(dayUtc, statId)`.
192
+ *
193
+ * It carries no authority: the control session is what authorizes the read,
194
+ * and every filter is re-applied on the next page, so a replayed cursor can
195
+ * only ever resume the same ordered scan.
196
+ */
197
+ export const encodeCoreMailStatCursor = (
198
+ positionArg: ICoreMailStatCursorPosition,
199
+ ): string =>
200
+ Buffer.from(
201
+ JSON.stringify({ v: 1, d: positionArg.dayUtc, s: positionArg.statId }),
202
+ 'utf8',
203
+ ).toString('base64url');
204
+
205
+ export const decodeCoreMailStatCursor = (
206
+ valueArg: unknown,
207
+ ): ICoreMailStatCursorPosition => {
208
+ if (
209
+ typeof valueArg !== 'string'
210
+ || valueArg.length === 0
211
+ || valueArg.length
212
+ > plugins.serveZoneInterfaces.data.coreMailLimits.cursorBytes
213
+ ) {
214
+ throw new Error('CoreMail statistics cursor is invalid.');
215
+ }
216
+ let decoded: unknown;
217
+ try {
218
+ decoded = JSON.parse(Buffer.from(valueArg, 'base64url').toString('utf8'));
219
+ } catch {
220
+ throw new Error('CoreMail statistics cursor is invalid.');
221
+ }
222
+ const record = decoded as { v?: unknown; d?: unknown; s?: unknown };
223
+ if (
224
+ !record
225
+ || typeof record !== 'object'
226
+ || record.v !== 1
227
+ || !Number.isSafeInteger(record.d)
228
+ || Number(record.d) % dayMs !== 0
229
+ || typeof record.s !== 'string'
230
+ ) {
231
+ throw new Error('CoreMail statistics cursor is invalid.');
232
+ }
233
+ try {
234
+ return {
235
+ dayUtc: Number(record.d),
236
+ statId: plugins.serveZoneInterfaces.data.normalizeCoreMailSha256(record.s),
237
+ };
238
+ } catch {
239
+ throw new Error('CoreMail statistics cursor is invalid.');
240
+ }
241
+ };
242
+
243
+ /** Project one counter row onto the wire contract. */
244
+ export const toCoreMailServiceMailStatistics = (
245
+ counterArg: ICoreMailStatCounterRecord,
246
+ ): plugins.serveZoneInterfaces.data.ICoreMailServiceMailStatistics =>
247
+ plugins.serveZoneInterfaces.data.normalizeCoreMailServiceMailStatistics({
248
+ tenantId: counterArg.tenantId,
249
+ serviceId: counterArg.serviceId,
250
+ bindingId: counterArg.bindingId,
251
+ dayUtc: formatCoreMailStatDay(counterArg.dayUtc),
252
+ outbound: {
253
+ submittedApi: counterArg.submittedApi,
254
+ submittedSmtp: counterArg.submittedSmtp,
255
+ delivered: counterArg.delivered,
256
+ deferred: counterArg.deferred,
257
+ failed: counterArg.failed,
258
+ deadLettered: counterArg.deadLettered,
259
+ },
260
+ inbound: {
261
+ received: counterArg.received,
262
+ acknowledgedProcessed: counterArg.acknowledgedProcessed,
263
+ acknowledgedDiscarded: counterArg.acknowledgedDiscarded,
264
+ },
265
+ updatedAt: counterArg.updatedAt,
266
+ });
267
+
268
+ /** One ordered, bounded page of per-service daily counters. */
269
+ export const listCoreMailServiceMailStatistics = async (
270
+ modelsArg: ICoreMailModels,
271
+ requestArg: {
272
+ fromDayUtc: string;
273
+ toDayUtc: string;
274
+ serviceIds?: string[];
275
+ cursor?: string;
276
+ limit: number;
277
+ },
278
+ ): Promise<{
279
+ statistics: plugins.serveZoneInterfaces.data.ICoreMailServiceMailStatistics[];
280
+ nextCursor?: string;
281
+ }> => {
282
+ const fromDayUtc = parseCoreMailStatDay(requestArg.fromDayUtc, 'fromDayUtc');
283
+ const toDayUtc = parseCoreMailStatDay(requestArg.toDayUtc, 'toDayUtc');
284
+ if (toDayUtc < fromDayUtc) {
285
+ throw new Error('CoreMail statistics range is inverted.');
286
+ }
287
+ if (
288
+ !Number.isSafeInteger(requestArg.limit)
289
+ || requestArg.limit < 1
290
+ || requestArg.limit > coreMailStatPageMaximum
291
+ ) {
292
+ throw new Error(
293
+ `CoreMail statistics limit must be between 1 and ${coreMailStatPageMaximum}.`,
294
+ );
295
+ }
296
+ let serviceIds: string[] | undefined;
297
+ if (requestArg.serviceIds !== undefined) {
298
+ if (
299
+ !Array.isArray(requestArg.serviceIds)
300
+ || requestArg.serviceIds.length === 0
301
+ || requestArg.serviceIds.length > 1_000
302
+ || requestArg.serviceIds.some((entryArg) => typeof entryArg !== 'string')
303
+ ) {
304
+ throw new Error('CoreMail statistics serviceIds filter is invalid.');
305
+ }
306
+ serviceIds = [...new Set(requestArg.serviceIds)];
307
+ }
308
+ const cursor = requestArg.cursor === undefined
309
+ ? undefined
310
+ : decodeCoreMailStatCursor(requestArg.cursor);
311
+ const rows = await modelsArg.StatCounter.exact.findStored({
312
+ filter: {
313
+ $and: [
314
+ { dayUtc: { $gte: fromDayUtc, $lte: toDayUtc } },
315
+ ...(serviceIds ? [{ serviceId: { $in: serviceIds } }] : []),
316
+ ...(cursor
317
+ ? [{
318
+ $or: [
319
+ { dayUtc: { $gt: cursor.dayUtc } },
320
+ { dayUtc: cursor.dayUtc, statId: { $gt: cursor.statId } },
321
+ ],
322
+ }]
323
+ : []),
324
+ ],
325
+ },
326
+ sort: { dayUtc: 1, statId: 1 },
327
+ limit: requestArg.limit + 1,
328
+ });
329
+ const page = rows.slice(0, requestArg.limit);
330
+ const last = page.at(-1);
331
+ return {
332
+ statistics: page.map(toCoreMailServiceMailStatistics),
333
+ ...(rows.length > page.length && last
334
+ ? {
335
+ nextCursor: encodeCoreMailStatCursor({
336
+ dayUtc: last.dayUtc,
337
+ statId: last.statId,
338
+ }),
339
+ }
340
+ : {}),
341
+ };
342
+ };