@serve.zone/coremail 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.smartconfig.json +50 -0
  2. package/changelog.md +10 -0
  3. package/cli.js +4 -0
  4. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  5. package/dist_ts/00_commitinfo_data.js +9 -0
  6. package/dist_ts/classes.auth.d.ts +45 -0
  7. package/dist_ts/classes.auth.js +204 -0
  8. package/dist_ts/classes.config.d.ts +2 -0
  9. package/dist_ts/classes.config.js +72 -0
  10. package/dist_ts/classes.coremail.d.ts +41 -0
  11. package/dist_ts/classes.coremail.js +155 -0
  12. package/dist_ts/classes.desiredstate.d.ts +40 -0
  13. package/dist_ts/classes.desiredstate.js +382 -0
  14. package/dist_ts/classes.gateway.d.ts +62 -0
  15. package/dist_ts/classes.gateway.js +669 -0
  16. package/dist_ts/classes.inbound.d.ts +49 -0
  17. package/dist_ts/classes.inbound.js +702 -0
  18. package/dist_ts/classes.maintenance.d.ts +16 -0
  19. package/dist_ts/classes.maintenance.js +339 -0
  20. package/dist_ts/classes.models.d.ts +161 -0
  21. package/dist_ts/classes.models.js +595 -0
  22. package/dist_ts/classes.server.d.ts +40 -0
  23. package/dist_ts/classes.server.js +309 -0
  24. package/dist_ts/classes.storage.d.ts +32 -0
  25. package/dist_ts/classes.storage.js +317 -0
  26. package/dist_ts/classes.submissions.d.ts +33 -0
  27. package/dist_ts/classes.submissions.js +385 -0
  28. package/dist_ts/classes.transfer.d.ts +41 -0
  29. package/dist_ts/classes.transfer.js +289 -0
  30. package/dist_ts/coremail.cursor.d.ts +20 -0
  31. package/dist_ts/coremail.cursor.js +191 -0
  32. package/dist_ts/coremail.log.d.ts +3 -0
  33. package/dist_ts/coremail.log.js +11 -0
  34. package/dist_ts/coremail.mime.d.ts +8 -0
  35. package/dist_ts/coremail.mime.js +63 -0
  36. package/dist_ts/coremail.persistence.d.ts +148 -0
  37. package/dist_ts/coremail.persistence.js +695 -0
  38. package/dist_ts/coremail.quota.d.ts +29 -0
  39. package/dist_ts/coremail.quota.js +149 -0
  40. package/dist_ts/coremail.selectors.d.ts +3 -0
  41. package/dist_ts/coremail.selectors.js +21 -0
  42. package/dist_ts/coremail.validation.d.ts +9 -0
  43. package/dist_ts/coremail.validation.js +325 -0
  44. package/dist_ts/index.d.ts +4 -0
  45. package/dist_ts/index.js +39 -0
  46. package/dist_ts/interfaces.d.ts +92 -0
  47. package/dist_ts/interfaces.js +2 -0
  48. package/dist_ts/plugins.d.ts +11 -0
  49. package/dist_ts/plugins.js +12 -0
  50. package/license.md +19 -0
  51. package/package.json +48 -0
  52. package/readme.md +199 -0
  53. package/ts/00_commitinfo_data.ts +8 -0
  54. package/ts/classes.auth.ts +321 -0
  55. package/ts/classes.config.ts +104 -0
  56. package/ts/classes.coremail.ts +249 -0
  57. package/ts/classes.desiredstate.ts +508 -0
  58. package/ts/classes.gateway.ts +811 -0
  59. package/ts/classes.inbound.ts +898 -0
  60. package/ts/classes.maintenance.ts +335 -0
  61. package/ts/classes.models.ts +530 -0
  62. package/ts/classes.server.ts +444 -0
  63. package/ts/classes.storage.ts +448 -0
  64. package/ts/classes.submissions.ts +554 -0
  65. package/ts/classes.transfer.ts +379 -0
  66. package/ts/coremail.cursor.ts +328 -0
  67. package/ts/coremail.log.ts +21 -0
  68. package/ts/coremail.mime.ts +94 -0
  69. package/ts/coremail.persistence.ts +1156 -0
  70. package/ts/coremail.quota.ts +214 -0
  71. package/ts/coremail.selectors.ts +35 -0
  72. package/ts/coremail.validation.ts +446 -0
  73. package/ts/index.ts +38 -0
  74. package/ts/interfaces.ts +109 -0
  75. package/ts/plugins.ts +11 -0
@@ -0,0 +1,214 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { ICoreMailModels } from './classes.models.js';
3
+ import { createSha256 } from './classes.storage.js';
4
+ import type { ICoreMailQuotaCounterRecord } from './coremail.persistence.js';
5
+
6
+ export interface ICoreMailQuotaAuthority {
7
+ tenantId: string;
8
+ serviceId: string;
9
+ bindingId: string;
10
+ bindingRevision: number;
11
+ }
12
+
13
+ export interface ICoreMailQuotaDescriptor extends ICoreMailQuotaAuthority {
14
+ counterId: plugins.serveZoneInterfaces.data.TCoreMailSha256;
15
+ kind: ICoreMailQuotaCounterRecord['kind'];
16
+ windowStartedAt?: number;
17
+ }
18
+
19
+ export class CoreMailQuotaExceededError extends Error {
20
+ public constructor(public readonly retryAfterMs?: number) {
21
+ super('CoreMail quota is exhausted.');
22
+ this.name = 'CoreMailQuotaExceededError';
23
+ }
24
+ }
25
+
26
+ const createDescriptor = (
27
+ authorityArg: ICoreMailQuotaAuthority,
28
+ kindArg: ICoreMailQuotaCounterRecord['kind'],
29
+ windowStartedAtArg?: number,
30
+ ): ICoreMailQuotaDescriptor => {
31
+ const identity = JSON.stringify({
32
+ tenantId: authorityArg.tenantId,
33
+ serviceId: authorityArg.serviceId,
34
+ bindingId: authorityArg.bindingId,
35
+ bindingRevision: authorityArg.bindingRevision,
36
+ kind: kindArg,
37
+ });
38
+ return {
39
+ tenantId: authorityArg.tenantId,
40
+ serviceId: authorityArg.serviceId,
41
+ bindingId: authorityArg.bindingId,
42
+ bindingRevision: authorityArg.bindingRevision,
43
+ counterId: createSha256(identity),
44
+ kind: kindArg,
45
+ ...(windowStartedAtArg === undefined
46
+ ? {}
47
+ : { windowStartedAt: windowStartedAtArg }),
48
+ };
49
+ };
50
+
51
+ export const createCoreMailOutboundQuotaDescriptors = (
52
+ authorityArg: ICoreMailQuotaAuthority,
53
+ nowArg: number,
54
+ ): {
55
+ minute: ICoreMailQuotaDescriptor;
56
+ day: ICoreMailQuotaDescriptor;
57
+ minuteRetryAfterMs: number;
58
+ dayRetryAfterMs: number;
59
+ } => {
60
+ const minuteStartedAt = Math.floor(nowArg / 60_000) * 60_000;
61
+ const dayStartedAt = Math.floor(nowArg / 86_400_000) * 86_400_000;
62
+ return {
63
+ minute: createDescriptor(authorityArg, 'outbound-minute', minuteStartedAt),
64
+ day: createDescriptor(authorityArg, 'outbound-day', dayStartedAt),
65
+ minuteRetryAfterMs: minuteStartedAt + 60_000 - nowArg,
66
+ dayRetryAfterMs: dayStartedAt + 86_400_000 - nowArg,
67
+ };
68
+ };
69
+
70
+ export const createCoreMailPendingQuotaDescriptor = (
71
+ authorityArg: ICoreMailQuotaAuthority,
72
+ ): ICoreMailQuotaDescriptor => createDescriptor(authorityArg, 'pending-inbound');
73
+
74
+ export const ensureCoreMailQuotaCounter = async (
75
+ modelsArg: ICoreMailModels,
76
+ descriptorArg: ICoreMailQuotaDescriptor,
77
+ ): Promise<void> => {
78
+ const matchesDescriptor = (
79
+ counterArg: plugins.smartdata.TStoredDocument<ICoreMailQuotaCounterRecord>,
80
+ ): boolean =>
81
+ counterArg.tenantId === descriptorArg.tenantId
82
+ && counterArg.serviceId === descriptorArg.serviceId
83
+ && counterArg.bindingId === descriptorArg.bindingId
84
+ && counterArg.bindingRevision === descriptorArg.bindingRevision
85
+ && counterArg.kind === descriptorArg.kind;
86
+ const existing = await modelsArg.QuotaCounter.exact.findStoredOne({
87
+ counterId: descriptorArg.counterId,
88
+ });
89
+ if (existing) {
90
+ if (!matchesDescriptor(existing)) {
91
+ throw new Error('CoreMail quota counter identity conflicts with persisted state.');
92
+ }
93
+ return;
94
+ }
95
+ const deterministicTimestamp = 0;
96
+ try {
97
+ const result = await modelsArg.QuotaCounter.exact.insert({
98
+ ...descriptorArg,
99
+ count: 0,
100
+ createdAt: deterministicTimestamp,
101
+ updatedAt: deterministicTimestamp,
102
+ });
103
+ if (result.status !== 'conflict') {
104
+ return;
105
+ }
106
+ } catch (error) {
107
+ if (
108
+ !(error instanceof plugins.smartdata.SmartdataExactPersistenceError)
109
+ || error.code !== 'unique_conflict'
110
+ ) {
111
+ throw error;
112
+ }
113
+ }
114
+ const concurrent = await modelsArg.QuotaCounter.exact.findStoredOne({
115
+ counterId: descriptorArg.counterId,
116
+ });
117
+ if (!concurrent || !matchesDescriptor(concurrent)) {
118
+ throw new Error('CoreMail quota counter identity conflicts with persisted state.');
119
+ }
120
+ };
121
+
122
+ export const incrementCoreMailQuotaCounter = async (
123
+ modelsArg: ICoreMailModels,
124
+ descriptorArg: ICoreMailQuotaDescriptor,
125
+ limitArg: number,
126
+ updatedAtArg: number,
127
+ sessionArg: plugins.smartdata.SmartdataSession,
128
+ retryAfterMsArg?: number,
129
+ ): Promise<void> => {
130
+ const counter = await modelsArg.QuotaCounter.exact.findStoredOne(
131
+ { counterId: descriptorArg.counterId },
132
+ { session: sessionArg },
133
+ );
134
+ if (!counter) {
135
+ throw new Error('CoreMail quota counter is unavailable.');
136
+ }
137
+ if (
138
+ descriptorArg.windowStartedAt !== undefined
139
+ && counter.windowStartedAt !== descriptorArg.windowStartedAt
140
+ ) {
141
+ if (
142
+ counter.windowStartedAt !== undefined
143
+ && descriptorArg.windowStartedAt < counter.windowStartedAt
144
+ ) {
145
+ throw new Error('CoreMail quota window fence advanced.');
146
+ }
147
+ const reset = await modelsArg.QuotaCounter.exact.transition({
148
+ current: counter,
149
+ change: (modelArg) => {
150
+ modelArg.windowStartedAt = descriptorArg.windowStartedAt;
151
+ modelArg.count = 1;
152
+ modelArg.updatedAt = updatedAtArg;
153
+ },
154
+ }, { session: sessionArg });
155
+ if (reset.status !== 'transitioned') {
156
+ throw new Error('CoreMail quota counter transition was contended.');
157
+ }
158
+ return;
159
+ }
160
+ if (counter.count >= limitArg) {
161
+ throw new CoreMailQuotaExceededError(retryAfterMsArg);
162
+ }
163
+ const update = await modelsArg.QuotaCounter.exact.transition({
164
+ current: counter,
165
+ change: (modelArg) => {
166
+ modelArg.count += 1;
167
+ modelArg.updatedAt = updatedAtArg;
168
+ },
169
+ }, { session: sessionArg });
170
+ if (update.status !== 'transitioned') {
171
+ throw new Error('CoreMail quota counter transition was contended.');
172
+ }
173
+ };
174
+
175
+ export const decrementCoreMailQuotaCounter = async (
176
+ modelsArg: ICoreMailModels,
177
+ descriptorArg: ICoreMailQuotaDescriptor,
178
+ updatedAtArg: number,
179
+ sessionArg: plugins.smartdata.SmartdataSession,
180
+ ): Promise<void> => {
181
+ const counter = await modelsArg.QuotaCounter.exact.findStoredOne(
182
+ { counterId: descriptorArg.counterId },
183
+ { session: sessionArg },
184
+ );
185
+ if (!counter || counter.count <= 0) {
186
+ throw new Error('CoreMail pending-inbound quota counter would underflow.');
187
+ }
188
+ const update = await modelsArg.QuotaCounter.exact.transition({
189
+ current: counter,
190
+ change: (modelArg) => {
191
+ modelArg.count -= 1;
192
+ modelArg.updatedAt = updatedAtArg;
193
+ },
194
+ }, { session: sessionArg });
195
+ if (update.status !== 'transitioned') {
196
+ throw new Error('CoreMail quota counter transition was contended.');
197
+ }
198
+ };
199
+
200
+ export const withCoreMailTransaction = async <T>(
201
+ databaseArg: plugins.smartdata.SmartdataDb,
202
+ callbackArg: (sessionArg: plugins.smartdata.SmartdataSession) => Promise<T>,
203
+ ): Promise<T> => {
204
+ const session = databaseArg.createSession();
205
+ try {
206
+ const result = await session.withTransaction(callbackArg);
207
+ if (result === undefined) {
208
+ throw new Error('CoreMail transaction did not commit a result.');
209
+ }
210
+ return result;
211
+ } finally {
212
+ await session.close();
213
+ }
214
+ };
@@ -0,0 +1,35 @@
1
+ const identifierRegex =
2
+ /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,126}[A-Za-z0-9])?$/;
3
+ const uuidRegex =
4
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
5
+
6
+ export const requireCoreMailIdentifier = (
7
+ valueArg: unknown,
8
+ labelArg: string,
9
+ ): string => {
10
+ if (typeof valueArg !== 'string' || !identifierRegex.test(valueArg)) {
11
+ throw new Error(`${labelArg} must be a canonical CoreMail identifier.`);
12
+ }
13
+ return valueArg;
14
+ };
15
+
16
+ export const requireCoreMailUuid = (
17
+ valueArg: unknown,
18
+ labelArg: string,
19
+ ): string => {
20
+ if (typeof valueArg !== 'string' || !uuidRegex.test(valueArg)) {
21
+ throw new Error(`${labelArg} must be a canonical UUID.`);
22
+ }
23
+ return valueArg;
24
+ };
25
+
26
+ export const requireCoreMailSafeInteger = (
27
+ valueArg: unknown,
28
+ labelArg: string,
29
+ minimumArg = 0,
30
+ ): number => {
31
+ if (!Number.isSafeInteger(valueArg) || Number(valueArg) < minimumArg) {
32
+ throw new Error(`${labelArg} must be a bounded safe integer.`);
33
+ }
34
+ return Number(valueArg);
35
+ };
@@ -0,0 +1,446 @@
1
+ import * as plugins from './plugins.js';
2
+ import { createSha256 } from './classes.storage.js';
3
+
4
+ type TMessage = plugins.serveZoneInterfaces.data.ICoreMailOutboundMessageDescriptor;
5
+ type TMailbox = plugins.serveZoneInterfaces.data.ICoreMailMailbox;
6
+ type TPart = plugins.serveZoneInterfaces.data.ICoreMailContentDescriptor;
7
+
8
+ const mailboxLocalPartRegex = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
9
+ const mailboxDomainLabelRegex = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
+ const idRegex = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
11
+ const contentTypeRegex =
12
+ /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:; ?[a-z0-9!#$&^_.+-]+=(?:"[^"\r\n]{1,100}"|[a-z0-9!#$&^_.+-]+))*$/i;
13
+
14
+ const requirePlainRecord = (
15
+ valueArg: unknown,
16
+ labelArg: string,
17
+ ): Record<string, unknown> => {
18
+ if (
19
+ !valueArg
20
+ || typeof valueArg !== 'object'
21
+ || Array.isArray(valueArg)
22
+ || (
23
+ Object.getPrototypeOf(valueArg) !== Object.prototype
24
+ && Object.getPrototypeOf(valueArg) !== null
25
+ )
26
+ ) {
27
+ throw new Error(`${labelArg} must be a plain object.`);
28
+ }
29
+ return valueArg as Record<string, unknown>;
30
+ };
31
+
32
+ const requireExactKeys = (
33
+ recordArg: Record<string, unknown>,
34
+ requiredArg: string[],
35
+ optionalArg: string[],
36
+ labelArg: string,
37
+ ): void => {
38
+ const allowed = new Set([...requiredArg, ...optionalArg]);
39
+ if (
40
+ requiredArg.some((keyArg) => !Object.hasOwn(recordArg, keyArg))
41
+ || Object.keys(recordArg).some((keyArg) => !allowed.has(keyArg))
42
+ ) {
43
+ throw new Error(`${labelArg} contains missing or unknown fields.`);
44
+ }
45
+ };
46
+
47
+ export const normalizeCoreMailMailboxAddress = (valueArg: unknown): string => {
48
+ if (
49
+ typeof valueArg !== 'string'
50
+ || valueArg.length < 3
51
+ || Buffer.byteLength(valueArg, 'utf8') > 254
52
+ || valueArg !== valueArg.toLowerCase()
53
+ ) {
54
+ throw new Error('CoreMail mailbox must be canonical lowercase ASCII.');
55
+ }
56
+ const atIndex = valueArg.lastIndexOf('@');
57
+ const localPart = atIndex > 0 ? valueArg.slice(0, atIndex) : '';
58
+ const domain = atIndex > 0 ? valueArg.slice(atIndex + 1) : '';
59
+ const labels = domain.split('.');
60
+ if (
61
+ localPart.length === 0
62
+ || localPart.length > 64
63
+ || !mailboxLocalPartRegex.test(localPart)
64
+ || localPart.startsWith('.')
65
+ || localPart.endsWith('.')
66
+ || localPart.includes('..')
67
+ || domain.length === 0
68
+ || domain.length > 253
69
+ || labels.length < 2
70
+ || labels.some((labelArg) => !mailboxDomainLabelRegex.test(labelArg))
71
+ ) {
72
+ throw new Error('CoreMail mailbox is invalid.');
73
+ }
74
+ return valueArg;
75
+ };
76
+
77
+ export const normalizeCoreMailEnvelope = (
78
+ valueArg: unknown,
79
+ ): plugins.serveZoneInterfaces.data.ICoreMailEnvelope => {
80
+ const value = requirePlainRecord(valueArg, 'envelope');
81
+ requireExactKeys(value, ['mailFrom', 'rcptTo'], [], 'envelope');
82
+ if (
83
+ typeof value.mailFrom !== 'string'
84
+ || !Array.isArray(value.rcptTo)
85
+ || value.rcptTo.length === 0
86
+ || value.rcptTo.length > 100
87
+ ) {
88
+ throw new Error('CoreMail gateway envelope is invalid.');
89
+ }
90
+ const mailFrom = value.mailFrom === ''
91
+ ? ''
92
+ : normalizeCoreMailMailboxAddress(value.mailFrom);
93
+ const rcptTo = value.rcptTo.map(normalizeCoreMailMailboxAddress);
94
+ if (new Set(rcptTo).size !== rcptTo.length) {
95
+ throw new Error('CoreMail gateway envelope contains duplicate recipients.');
96
+ }
97
+ return { mailFrom, rcptTo };
98
+ };
99
+
100
+ export const normalizeCoreMailConnectionInfo = (
101
+ valueArg: unknown,
102
+ ): plugins.serveZoneInterfaces.data.IMailConnectionInfo => {
103
+ const value = requirePlainRecord(valueArg, 'connectionInfo');
104
+ requireExactKeys(
105
+ value,
106
+ ['protocol'],
107
+ [
108
+ 'remoteAddress',
109
+ 'proxyAddress',
110
+ 'heloName',
111
+ 'tls',
112
+ 'authUsername',
113
+ 'authenticatedCredentialId',
114
+ ],
115
+ 'connectionInfo',
116
+ );
117
+ if (
118
+ value.protocol !== 'smtp'
119
+ && value.protocol !== 'submission'
120
+ && value.protocol !== 'api'
121
+ ) {
122
+ throw new Error('CoreMail gateway connection protocol is invalid.');
123
+ }
124
+ for (const key of [
125
+ 'remoteAddress',
126
+ 'proxyAddress',
127
+ 'heloName',
128
+ 'authUsername',
129
+ 'authenticatedCredentialId',
130
+ ] as const) {
131
+ if (
132
+ value[key] !== undefined
133
+ && (
134
+ typeof value[key] !== 'string'
135
+ || value[key].length === 0
136
+ || Buffer.byteLength(value[key], 'utf8') > 512
137
+ || value[key].trim() !== value[key]
138
+ )
139
+ ) {
140
+ throw new Error(`CoreMail gateway connection ${key} is invalid.`);
141
+ }
142
+ }
143
+ if (value.tls !== undefined && typeof value.tls !== 'boolean') {
144
+ throw new Error('CoreMail gateway connection TLS state is invalid.');
145
+ }
146
+ return {
147
+ protocol: value.protocol,
148
+ ...(value.remoteAddress === undefined
149
+ ? {}
150
+ : { remoteAddress: value.remoteAddress as string }),
151
+ ...(value.proxyAddress === undefined
152
+ ? {}
153
+ : { proxyAddress: value.proxyAddress as string }),
154
+ ...(value.heloName === undefined
155
+ ? {}
156
+ : { heloName: value.heloName as string }),
157
+ ...(value.tls === undefined ? {} : { tls: value.tls }),
158
+ ...(value.authUsername === undefined
159
+ ? {}
160
+ : { authUsername: value.authUsername as string }),
161
+ ...(value.authenticatedCredentialId === undefined
162
+ ? {}
163
+ : {
164
+ authenticatedCredentialId:
165
+ value.authenticatedCredentialId as string,
166
+ }),
167
+ };
168
+ };
169
+
170
+ export const normalizeCoreMailGatewayMessage = (
171
+ valueArg: unknown,
172
+ ): plugins.serveZoneInterfaces.data.ICoreMailGatewayMessageDescriptor => {
173
+ const value = requirePlainRecord(valueArg, 'gatewayMessage');
174
+ requireExactKeys(value, ['envelope', 'rawMime'], [], 'gatewayMessage');
175
+ const rawMime = requirePlainRecord(value.rawMime, 'gatewayMessage.rawMime');
176
+ requireExactKeys(
177
+ rawMime,
178
+ ['sha256', 'lengthBytes', 'contentType'],
179
+ [],
180
+ 'gatewayMessage.rawMime',
181
+ );
182
+ if (
183
+ rawMime.contentType !== 'message/rfc822'
184
+ || !Number.isSafeInteger(rawMime.lengthBytes)
185
+ || Number(rawMime.lengthBytes) < 1
186
+ || Number(rawMime.lengthBytes)
187
+ > plugins.serveZoneInterfaces.data.coreMailLimits.serializedMimeBytes
188
+ ) {
189
+ throw new Error('CoreMail gateway MIME descriptor is invalid.');
190
+ }
191
+ return {
192
+ envelope: normalizeCoreMailEnvelope(value.envelope),
193
+ rawMime: {
194
+ sha256: plugins.serveZoneInterfaces.data.normalizeCoreMailSha256(
195
+ rawMime.sha256,
196
+ ),
197
+ lengthBytes: Number(rawMime.lengthBytes),
198
+ contentType: 'message/rfc822',
199
+ },
200
+ };
201
+ };
202
+
203
+ const normalizeDisplayText = (
204
+ valueArg: unknown,
205
+ labelArg: string,
206
+ maximumBytesArg: number,
207
+ allowEmptyArg = false,
208
+ ): string => {
209
+ if (
210
+ typeof valueArg !== 'string'
211
+ || (!allowEmptyArg && valueArg.length === 0)
212
+ || valueArg.trim() !== valueArg
213
+ || /[\u0000-\u001f\u007f]/.test(valueArg)
214
+ || Buffer.byteLength(valueArg, 'utf8') > maximumBytesArg
215
+ ) {
216
+ throw new Error(`${labelArg} is invalid.`);
217
+ }
218
+ return valueArg;
219
+ };
220
+
221
+ const normalizeMailbox = (valueArg: unknown, labelArg: string): TMailbox => {
222
+ const value = requirePlainRecord(valueArg, labelArg);
223
+ requireExactKeys(value, ['address'], ['displayName'], labelArg);
224
+ return {
225
+ address: normalizeCoreMailMailboxAddress(value.address),
226
+ ...(value.displayName === undefined
227
+ ? {}
228
+ : {
229
+ displayName: normalizeDisplayText(
230
+ value.displayName,
231
+ `${labelArg}.displayName`,
232
+ 256,
233
+ ),
234
+ }),
235
+ };
236
+ };
237
+
238
+ const normalizeMailboxArray = (
239
+ valueArg: unknown,
240
+ labelArg: string,
241
+ allowEmptyArg = false,
242
+ ): TMailbox[] => {
243
+ if (
244
+ !Array.isArray(valueArg)
245
+ || (!allowEmptyArg && valueArg.length === 0)
246
+ || valueArg.length > 100
247
+ ) {
248
+ throw new Error(`${labelArg} must be a bounded mailbox array.`);
249
+ }
250
+ const normalized = valueArg.map((entryArg, indexArg) =>
251
+ normalizeMailbox(entryArg, `${labelArg}[${indexArg}]`)
252
+ );
253
+ const addresses = normalized.map((entryArg) => entryArg.address);
254
+ if (new Set(addresses).size !== addresses.length) {
255
+ throw new Error(`${labelArg} contains duplicate addresses.`);
256
+ }
257
+ return normalized;
258
+ };
259
+
260
+ const forbiddenHeaderNames = new Set([
261
+ 'bcc',
262
+ 'cc',
263
+ 'content-transfer-encoding',
264
+ 'content-type',
265
+ 'date',
266
+ 'dkim-signature',
267
+ 'from',
268
+ 'message-id',
269
+ 'mime-version',
270
+ 'received',
271
+ 'reply-to',
272
+ 'return-path',
273
+ 'subject',
274
+ 'to',
275
+ ]);
276
+
277
+ const normalizePart = (valueArg: unknown, labelArg: string): TPart => {
278
+ const value = requirePlainRecord(valueArg, labelArg);
279
+ requireExactKeys(
280
+ value,
281
+ ['partId', 'kind', 'contentType', 'sha256', 'lengthBytes'],
282
+ ['filename', 'contentId'],
283
+ labelArg,
284
+ );
285
+ if (typeof value.partId !== 'string' || !idRegex.test(value.partId)) {
286
+ throw new Error(`${labelArg}.partId is invalid.`);
287
+ }
288
+ if (value.kind !== 'text' && value.kind !== 'html' && value.kind !== 'attachment') {
289
+ throw new Error(`${labelArg}.kind is invalid.`);
290
+ }
291
+ if (typeof value.contentType !== 'string' || !contentTypeRegex.test(value.contentType)) {
292
+ throw new Error(`${labelArg}.contentType is invalid.`);
293
+ }
294
+ const sha256 = plugins.serveZoneInterfaces.data.normalizeCoreMailSha256(value.sha256);
295
+ if (!Number.isSafeInteger(value.lengthBytes) || Number(value.lengthBytes) < 0) {
296
+ throw new Error(`${labelArg}.lengthBytes is invalid.`);
297
+ }
298
+ const lengthBytes = Number(value.lengthBytes);
299
+ const maximum = value.kind === 'text'
300
+ ? plugins.serveZoneInterfaces.data.coreMailLimits.textPartBytes
301
+ : value.kind === 'html'
302
+ ? plugins.serveZoneInterfaces.data.coreMailLimits.htmlPartBytes
303
+ : plugins.serveZoneInterfaces.data.coreMailLimits.attachmentBytes;
304
+ if (lengthBytes > maximum) {
305
+ throw new Error(`${labelArg} exceeds its content budget.`);
306
+ }
307
+ return {
308
+ partId: value.partId,
309
+ kind: value.kind,
310
+ contentType: value.contentType,
311
+ sha256,
312
+ lengthBytes,
313
+ ...(value.filename === undefined
314
+ ? {}
315
+ : {
316
+ filename: normalizeDisplayText(
317
+ value.filename,
318
+ `${labelArg}.filename`,
319
+ 512,
320
+ ),
321
+ }),
322
+ ...(value.contentId === undefined
323
+ ? {}
324
+ : {
325
+ contentId: normalizeDisplayText(
326
+ value.contentId,
327
+ `${labelArg}.contentId`,
328
+ 256,
329
+ ),
330
+ }),
331
+ };
332
+ };
333
+
334
+ export const normalizeCoreMailOutboundMessage = (
335
+ valueArg: unknown,
336
+ bindingArg: plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState,
337
+ ): TMessage => {
338
+ const value = requirePlainRecord(valueArg, 'message');
339
+ requireExactKeys(
340
+ value,
341
+ ['sender', 'recipients', 'subject', 'parts'],
342
+ ['replyTo', 'headers'],
343
+ 'message',
344
+ );
345
+ const sender = normalizeMailbox(value.sender, 'message.sender');
346
+ if (!bindingArg.allowedSenders.includes(sender.address)) {
347
+ throw new Error('CoreMail sender is not authorized by the binding.');
348
+ }
349
+ const recipientsRecord = requirePlainRecord(value.recipients, 'message.recipients');
350
+ requireExactKeys(recipientsRecord, ['to'], ['cc', 'bcc'], 'message.recipients');
351
+ const to = normalizeMailboxArray(recipientsRecord.to, 'message.recipients.to');
352
+ const cc = recipientsRecord.cc === undefined
353
+ ? undefined
354
+ : normalizeMailboxArray(recipientsRecord.cc, 'message.recipients.cc', true);
355
+ const bcc = recipientsRecord.bcc === undefined
356
+ ? undefined
357
+ : normalizeMailboxArray(recipientsRecord.bcc, 'message.recipients.bcc', true);
358
+ const allRecipientAddresses = [...to, ...(cc ?? []), ...(bcc ?? [])]
359
+ .map((entryArg) => entryArg.address);
360
+ if (
361
+ allRecipientAddresses.length === 0
362
+ || new Set(allRecipientAddresses).size !== allRecipientAddresses.length
363
+ ) {
364
+ throw new Error('CoreMail recipients must be globally unique and non-empty.');
365
+ }
366
+ const partsValue = value.parts;
367
+ if (!Array.isArray(partsValue) || partsValue.length === 0) {
368
+ throw new Error('CoreMail message requires at least one part.');
369
+ }
370
+ const parts = partsValue.map((entryArg, indexArg) =>
371
+ normalizePart(entryArg, `message.parts[${indexArg}]`)
372
+ );
373
+ if (
374
+ new Set(parts.map((partArg) => partArg.partId)).size !== parts.length
375
+ || parts.filter((partArg) => partArg.kind === 'text').length > 1
376
+ || parts.filter((partArg) => partArg.kind === 'html').length > 1
377
+ || parts.filter((partArg) => partArg.kind === 'attachment').length >
378
+ plugins.serveZoneInterfaces.data.coreMailLimits.attachmentCount
379
+ || parts
380
+ .filter((partArg) => partArg.kind === 'attachment')
381
+ .reduce((totalArg, partArg) => totalArg + partArg.lengthBytes, 0) >
382
+ plugins.serveZoneInterfaces.data.coreMailLimits.aggregateAttachmentBytes
383
+ ) {
384
+ throw new Error('CoreMail message part set is invalid.');
385
+ }
386
+ const headers = value.headers === undefined
387
+ ? undefined
388
+ : (() => {
389
+ if (!Array.isArray(value.headers) || value.headers.length > 32) {
390
+ throw new Error('CoreMail custom headers are invalid.');
391
+ }
392
+ const seen = new Set<string>();
393
+ return value.headers.map((entryArg, indexArg) => {
394
+ const record = requirePlainRecord(entryArg, `message.headers[${indexArg}]`);
395
+ requireExactKeys(record, ['name', 'value'], [], `message.headers[${indexArg}]`);
396
+ if (
397
+ typeof record.name !== 'string'
398
+ || !/^[A-Za-z][A-Za-z0-9-]{0,62}$/.test(record.name)
399
+ ) {
400
+ throw new Error('CoreMail header name is invalid.');
401
+ }
402
+ const lowerName = record.name.toLowerCase();
403
+ if (forbiddenHeaderNames.has(lowerName) || seen.has(lowerName)) {
404
+ throw new Error('CoreMail header is transport-owned or duplicated.');
405
+ }
406
+ seen.add(lowerName);
407
+ return {
408
+ name: record.name,
409
+ value: normalizeDisplayText(
410
+ record.value,
411
+ `message.headers[${indexArg}].value`,
412
+ 768,
413
+ true,
414
+ ),
415
+ };
416
+ });
417
+ })();
418
+ return {
419
+ sender,
420
+ recipients: {
421
+ to,
422
+ ...(cc ? { cc } : {}),
423
+ ...(bcc ? { bcc } : {}),
424
+ },
425
+ ...(value.replyTo === undefined
426
+ ? {}
427
+ : { replyTo: normalizeMailbox(value.replyTo, 'message.replyTo') }),
428
+ subject: normalizeDisplayText(value.subject, 'message.subject', 768, true),
429
+ ...(headers ? { headers } : {}),
430
+ parts,
431
+ };
432
+ };
433
+
434
+ export const createCoreMailSubmissionDigest = (
435
+ idempotencyKeyArg: string,
436
+ messageArg: TMessage,
437
+ ): plugins.serveZoneInterfaces.data.TCoreMailSha256 => {
438
+ if (!idRegex.test(idempotencyKeyArg)) {
439
+ throw new Error('CoreMail idempotency key is invalid.');
440
+ }
441
+ return createSha256(JSON.stringify({
442
+ schemaVersion: 1,
443
+ idempotencyKey: idempotencyKeyArg,
444
+ message: messageArg,
445
+ }));
446
+ };