@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.
- package/changelog.md +43 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.auth.d.ts +19 -0
- package/dist_ts/classes.auth.js +58 -1
- package/dist_ts/classes.coremail.d.ts +3 -0
- package/dist_ts/classes.coremail.js +11 -4
- package/dist_ts/classes.gateway.d.ts +11 -1
- package/dist_ts/classes.gateway.js +101 -63
- package/dist_ts/classes.inbound.d.ts +10 -1
- package/dist_ts/classes.inbound.js +45 -7
- package/dist_ts/classes.maintenance.js +16 -1
- package/dist_ts/classes.models.d.ts +24 -2
- package/dist_ts/classes.models.js +72 -2
- package/dist_ts/classes.server.d.ts +6 -2
- package/dist_ts/classes.server.js +18 -68
- package/dist_ts/classes.smtpsubmission.d.ts +85 -0
- package/dist_ts/classes.smtpsubmission.js +379 -0
- package/dist_ts/classes.storage.d.ts +9 -0
- package/dist_ts/classes.storage.js +44 -1
- package/dist_ts/classes.submissions.d.ts +27 -1
- package/dist_ts/classes.submissions.js +164 -14
- package/dist_ts/coremail.errors.d.ts +18 -0
- package/dist_ts/coremail.errors.js +84 -0
- package/dist_ts/coremail.log.d.ts +1 -1
- package/dist_ts/coremail.log.js +1 -1
- package/dist_ts/coremail.mime.d.ts +15 -0
- package/dist_ts/coremail.mime.js +76 -1
- package/dist_ts/coremail.persistence.d.ts +38 -1
- package/dist_ts/coremail.persistence.js +124 -40
- package/dist_ts/coremail.stats.d.ts +72 -0
- package/dist_ts/coremail.stats.js +242 -0
- package/dist_ts/coremail.validation.d.ts +15 -2
- package/dist_ts/coremail.validation.js +26 -78
- package/dist_ts/interfaces.d.ts +18 -0
- package/dist_ts/plugins.d.ts +1 -0
- package/dist_ts/plugins.js +2 -1
- package/package.json +12 -11
- package/readme.md +51 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.auth.ts +77 -0
- package/ts/classes.coremail.ts +20 -0
- package/ts/classes.gateway.ts +121 -66
- package/ts/classes.inbound.ts +71 -6
- package/ts/classes.maintenance.ts +14 -0
- package/ts/classes.models.ts +67 -1
- package/ts/classes.server.ts +23 -80
- package/ts/classes.smtpsubmission.ts +466 -0
- package/ts/classes.storage.ts +54 -0
- package/ts/classes.submissions.ts +247 -12
- package/ts/coremail.errors.ts +111 -0
- package/ts/coremail.log.ts +1 -0
- package/ts/coremail.mime.ts +89 -0
- package/ts/coremail.persistence.ts +196 -48
- package/ts/coremail.stats.ts +342 -0
- package/ts/coremail.validation.ts +31 -103
- package/ts/interfaces.ts +20 -0
- package/ts/plugins.ts +1 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import {
|
|
3
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
};
|