@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.
- package/.smartconfig.json +50 -0
- package/changelog.md +10 -0
- package/cli.js +4 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.auth.d.ts +45 -0
- package/dist_ts/classes.auth.js +204 -0
- package/dist_ts/classes.config.d.ts +2 -0
- package/dist_ts/classes.config.js +72 -0
- package/dist_ts/classes.coremail.d.ts +41 -0
- package/dist_ts/classes.coremail.js +155 -0
- package/dist_ts/classes.desiredstate.d.ts +40 -0
- package/dist_ts/classes.desiredstate.js +382 -0
- package/dist_ts/classes.gateway.d.ts +62 -0
- package/dist_ts/classes.gateway.js +669 -0
- package/dist_ts/classes.inbound.d.ts +49 -0
- package/dist_ts/classes.inbound.js +702 -0
- package/dist_ts/classes.maintenance.d.ts +16 -0
- package/dist_ts/classes.maintenance.js +339 -0
- package/dist_ts/classes.models.d.ts +161 -0
- package/dist_ts/classes.models.js +595 -0
- package/dist_ts/classes.server.d.ts +40 -0
- package/dist_ts/classes.server.js +309 -0
- package/dist_ts/classes.storage.d.ts +32 -0
- package/dist_ts/classes.storage.js +317 -0
- package/dist_ts/classes.submissions.d.ts +33 -0
- package/dist_ts/classes.submissions.js +385 -0
- package/dist_ts/classes.transfer.d.ts +41 -0
- package/dist_ts/classes.transfer.js +289 -0
- package/dist_ts/coremail.cursor.d.ts +20 -0
- package/dist_ts/coremail.cursor.js +191 -0
- package/dist_ts/coremail.log.d.ts +3 -0
- package/dist_ts/coremail.log.js +11 -0
- package/dist_ts/coremail.mime.d.ts +8 -0
- package/dist_ts/coremail.mime.js +63 -0
- package/dist_ts/coremail.persistence.d.ts +148 -0
- package/dist_ts/coremail.persistence.js +695 -0
- package/dist_ts/coremail.quota.d.ts +29 -0
- package/dist_ts/coremail.quota.js +149 -0
- package/dist_ts/coremail.selectors.d.ts +3 -0
- package/dist_ts/coremail.selectors.js +21 -0
- package/dist_ts/coremail.validation.d.ts +9 -0
- package/dist_ts/coremail.validation.js +325 -0
- package/dist_ts/index.d.ts +4 -0
- package/dist_ts/index.js +39 -0
- package/dist_ts/interfaces.d.ts +92 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/plugins.d.ts +11 -0
- package/dist_ts/plugins.js +12 -0
- package/license.md +19 -0
- package/package.json +48 -0
- package/readme.md +199 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.auth.ts +321 -0
- package/ts/classes.config.ts +104 -0
- package/ts/classes.coremail.ts +249 -0
- package/ts/classes.desiredstate.ts +508 -0
- package/ts/classes.gateway.ts +811 -0
- package/ts/classes.inbound.ts +898 -0
- package/ts/classes.maintenance.ts +335 -0
- package/ts/classes.models.ts +530 -0
- package/ts/classes.server.ts +444 -0
- package/ts/classes.storage.ts +448 -0
- package/ts/classes.submissions.ts +554 -0
- package/ts/classes.transfer.ts +379 -0
- package/ts/coremail.cursor.ts +328 -0
- package/ts/coremail.log.ts +21 -0
- package/ts/coremail.mime.ts +94 -0
- package/ts/coremail.persistence.ts +1156 -0
- package/ts/coremail.quota.ts +214 -0
- package/ts/coremail.selectors.ts +35 -0
- package/ts/coremail.validation.ts +446 -0
- package/ts/index.ts +38 -0
- package/ts/interfaces.ts +109 -0
- 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
|
+
};
|