@serve.zone/platformclient 3.0.1 → 4.1.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 +4 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.platformclient.d.ts +24 -5
- package/dist_ts/classes.platformclient.js +159 -44
- package/dist_ts/coremail/classes.coremailconnector.d.ts +66 -0
- package/dist_ts/coremail/classes.coremailconnector.js +184 -4
- package/dist_ts/email/classes.emailconnector.d.ts +89 -10
- package/dist_ts/email/classes.emailconnector.js +568 -110
- package/dist_ts/email/classes.letterconnector.js +2 -2
- package/dist_ts/email/classes.pushnotificationconnector.js +2 -2
- package/dist_ts/email/classes.smsconnector.js +3 -3
- package/dist_ts/webpush/classes.webpushconnector.js +6 -9
- package/package.json +13 -11
- package/readme.md +46 -9
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.platformclient.ts +158 -49
- package/ts/coremail/classes.coremailconnector.ts +215 -3
- package/ts/email/classes.emailconnector.ts +683 -127
- package/ts/email/classes.letterconnector.ts +1 -1
- package/ts/email/classes.pushnotificationconnector.ts +1 -1
- package/ts/email/classes.smsconnector.ts +2 -2
- package/ts/webpush/classes.webpushconnector.ts +5 -7
- /package/{license → license.md} +0 -0
|
@@ -15,6 +15,8 @@ export interface ISzPlatformClientOptions {
|
|
|
15
15
|
token?: string;
|
|
16
16
|
url?: string;
|
|
17
17
|
platformUrl?: string;
|
|
18
|
+
/** Explicit HTTPS Cloudly origin. Bindings never select this authority. */
|
|
19
|
+
cloudlyUrl?: string;
|
|
18
20
|
binding?: plugins.servezoneInterfaces.platform.IPlatformBinding;
|
|
19
21
|
bindings?: plugins.servezoneInterfaces.platform.IPlatformBinding[];
|
|
20
22
|
coreMail?: ISzCoreMailConnectorOptions;
|
|
@@ -26,7 +28,13 @@ export class SzPlatformClient {
|
|
|
26
28
|
private connectionAddress?: string;
|
|
27
29
|
public platformBindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
|
|
28
30
|
public typedrouter: plugins.typedrequest.TypedRouter = new plugins.typedrequest.TypedRouter();
|
|
29
|
-
public typedsocket
|
|
31
|
+
public typedsocket?: plugins.typedsocket.TypedSocket;
|
|
32
|
+
private identity?: plugins.servezoneInterfaces.data.IIdentity;
|
|
33
|
+
private authenticationGeneration = 0;
|
|
34
|
+
private authenticationAbort?: AbortController;
|
|
35
|
+
private authenticationOperations = new Set<Promise<unknown>>();
|
|
36
|
+
private renewalTimer?: ReturnType<typeof setTimeout>;
|
|
37
|
+
private statusSubscription?: { unsubscribe(): void };
|
|
30
38
|
private qenvInstance = new plugins.qenv.Qenv();
|
|
31
39
|
private stopped = false;
|
|
32
40
|
private initPromise?: Promise<void>;
|
|
@@ -67,6 +75,7 @@ export class SzPlatformClient {
|
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
await this.configureCoreMailFromEnv();
|
|
70
79
|
this.platformBindings = this.mergePlatformBindings(
|
|
71
80
|
this.platformBindings,
|
|
72
81
|
await this.discoverPlatformBindings()
|
|
@@ -77,25 +86,41 @@ export class SzPlatformClient {
|
|
|
77
86
|
return;
|
|
78
87
|
}
|
|
79
88
|
if (!this.authorizationString) throw new Error('authorizationString is required');
|
|
89
|
+
if (this.authorizationString.length > 8192 || /[\x00-\x20\x7f]/.test(this.authorizationString)) {
|
|
90
|
+
throw new Error('Platform authorization is invalid.');
|
|
91
|
+
}
|
|
80
92
|
const socket = await plugins.typedsocket.TypedSocket.createClient(
|
|
81
|
-
this.typedrouter
|
|
93
|
+
this.typedrouter,
|
|
82
94
|
await this.getConnectionAddress(),
|
|
83
|
-
{
|
|
95
|
+
{
|
|
96
|
+
autoReconnect: true, maxRetries: 100, initialBackoffMs: 1000, maxBackoffMs: 30_000,
|
|
97
|
+
abortSignal: this.platformSocketAbortController.signal,
|
|
98
|
+
restoreConnection: (context) => {
|
|
99
|
+
this.invalidateAuthentication();
|
|
100
|
+
const generation = this.authenticationGeneration;
|
|
101
|
+
return this.trackAuthentication(this.authenticate(context.createTypedRequest, context.abortSignal, generation));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
84
104
|
);
|
|
85
105
|
if (this.stopped) {
|
|
86
|
-
await socket.stop()
|
|
106
|
+
await socket.stop();
|
|
87
107
|
throw this.createAbortError();
|
|
88
108
|
}
|
|
89
109
|
this.typedsocket = socket;
|
|
90
110
|
try {
|
|
91
|
-
|
|
111
|
+
this.statusSubscription = socket.statusSubject.subscribe((status) => {
|
|
112
|
+
if (status === 'disconnected') this.invalidateAuthentication();
|
|
113
|
+
if (status === 'connected') this.scheduleRenewal(socket);
|
|
114
|
+
});
|
|
115
|
+
this.scheduleRenewal(socket);
|
|
92
116
|
this.assertRunning();
|
|
93
117
|
if (this.coreMailConnector.isConfigured) {
|
|
94
118
|
await this.coreMailConnector.init();
|
|
95
119
|
this.assertRunning();
|
|
96
120
|
}
|
|
97
121
|
} catch (error) {
|
|
98
|
-
|
|
122
|
+
this.invalidateAuthentication();
|
|
123
|
+
await socket.stop();
|
|
99
124
|
throw error;
|
|
100
125
|
}
|
|
101
126
|
}
|
|
@@ -131,7 +156,7 @@ export class SzPlatformClient {
|
|
|
131
156
|
optionsArg.authorization ||
|
|
132
157
|
optionsArg.token ||
|
|
133
158
|
this.authorizationString;
|
|
134
|
-
this.connectionAddress = optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
|
|
159
|
+
this.connectionAddress = optionsArg.cloudlyUrl || optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
|
|
135
160
|
this.platformBindings = this.mergePlatformBindings(
|
|
136
161
|
this.platformBindings,
|
|
137
162
|
[optionsArg.binding, ...(optionsArg.bindings || [])].filter(
|
|
@@ -146,34 +171,12 @@ export class SzPlatformClient {
|
|
|
146
171
|
private async getConnectionAddress() {
|
|
147
172
|
const connectionAddress =
|
|
148
173
|
this.connectionAddress ||
|
|
149
|
-
(await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL'))
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
private getConnectionAddressFromBindings() {
|
|
156
|
-
for (const binding of this.platformBindings) {
|
|
157
|
-
if (binding.desiredState === 'disabled' || binding.status === 'failed') {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (!this.isSupportedConnectorCapability(binding.capability)) {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const endpoint = binding.endpoints?.find((endpointArg) => {
|
|
164
|
-
return endpointArg.protocol === 'typedrequest' || endpointArg.protocol === 'http';
|
|
165
|
-
});
|
|
166
|
-
const endpointUrl = endpoint?.internalUrl || endpoint?.externalUrl;
|
|
167
|
-
if (endpointUrl) {
|
|
168
|
-
return endpointUrl;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private isSupportedConnectorCapability(
|
|
174
|
-
capabilityArg: plugins.servezoneInterfaces.platform.TPlatformCapability
|
|
175
|
-
) {
|
|
176
|
-
return ['email', 'sms', 'pushnotification', 'letter'].includes(capabilityArg);
|
|
174
|
+
(await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL'));
|
|
175
|
+
if (!connectionAddress) throw new Error('An explicit Cloudly URL is required.');
|
|
176
|
+
const parsed = new URL(connectionAddress);
|
|
177
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
|
|
178
|
+
|| parsed.pathname !== '/') throw new Error('Cloudly URL must be an HTTPS origin.');
|
|
179
|
+
return parsed.origin;
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
private async getConfiguredAuthorizationString() {
|
|
@@ -183,19 +186,81 @@ export class SzPlatformClient {
|
|
|
183
186
|
);
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
public requireTypedSocket(): plugins.typedsocket.TypedSocket {
|
|
190
|
+
this.assertRunning();
|
|
191
|
+
if (!this.typedsocket || this.typedsocket.getStatus() !== 'connected' || !this.identity
|
|
192
|
+
|| this.identity.expiresAt <= Date.now()) throw new Error('Cloudly platform session is unavailable.');
|
|
193
|
+
return this.typedsocket;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private invalidateAuthentication(): void {
|
|
197
|
+
this.authenticationGeneration++;
|
|
198
|
+
this.authenticationAbort?.abort();
|
|
199
|
+
this.authenticationAbort = undefined;
|
|
200
|
+
this.identity = undefined;
|
|
201
|
+
if (this.renewalTimer) clearTimeout(this.renewalTimer);
|
|
202
|
+
this.renewalTimer = undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private trackAuthentication<T>(operation: Promise<T>): Promise<T> {
|
|
206
|
+
this.authenticationOperations.add(operation);
|
|
207
|
+
void operation.then(() => this.authenticationOperations.delete(operation), () => this.authenticationOperations.delete(operation));
|
|
208
|
+
return operation;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async authenticate(createRequest: plugins.typedsocket.TTypedSocketRestoreRequestFactory,
|
|
212
|
+
signal: AbortSignal, generation: number): Promise<void> {
|
|
213
|
+
const requireCurrent = () => {
|
|
214
|
+
this.assertRunning(); signal.throwIfAborted();
|
|
215
|
+
if (generation !== this.authenticationGeneration) throw this.createAbortError();
|
|
216
|
+
};
|
|
190
217
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
218
|
+
requireCurrent();
|
|
219
|
+
const exchange = createRequest<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
|
|
220
|
+
'getIdentityByToken');
|
|
221
|
+
exchange.skipHooks = true;
|
|
222
|
+
const response = await exchange.fire({ token: this.authorizationString! }, { maxRetries: 0, abortSignal: signal });
|
|
223
|
+
requireCurrent();
|
|
224
|
+
const identity = response.identity;
|
|
225
|
+
if (!identity || identity.type !== 'machine' || identity.role !== 'api'
|
|
226
|
+
|| typeof identity.userId !== 'string' || !/^service-machine:[a-f0-9]{64}$/.test(identity.userId)
|
|
227
|
+
|| !Number.isSafeInteger(identity.expiresAt) || identity.expiresAt <= Date.now() + 1000) {
|
|
228
|
+
throw new Error('Invalid platform identity.');
|
|
195
229
|
}
|
|
230
|
+
const requestData = plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionRequestV1({
|
|
231
|
+
schemaVersion: 1, identity: { jwt: identity.jwt },
|
|
232
|
+
});
|
|
233
|
+
const registration = createRequest<plugins.servezoneInterfaces.requests.runtimesession.IReq_Any_Cloudly_RegisterCloudlyClientSessionV1>(
|
|
234
|
+
'registerCloudlyClientSessionV1');
|
|
235
|
+
registration.skipHooks = true;
|
|
236
|
+
const registered = await registration.fire(requestData, { maxRetries: 0, abortSignal: signal });
|
|
237
|
+
plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionResponseV1(registered);
|
|
238
|
+
requireCurrent();
|
|
239
|
+
this.identity = { type: identity.type, role: identity.role, userId: identity.userId,
|
|
240
|
+
name: identity.userId, expiresAt: identity.expiresAt, jwt: requestData.identity.jwt };
|
|
241
|
+
} catch {
|
|
242
|
+
if (this.stopped || signal.aborted || generation !== this.authenticationGeneration) throw this.createAbortError();
|
|
243
|
+
throw new Error('Cloudly platform authentication failed.');
|
|
196
244
|
}
|
|
197
245
|
}
|
|
198
246
|
|
|
247
|
+
private scheduleRenewal(socket: plugins.typedsocket.TypedSocket): void {
|
|
248
|
+
if (this.renewalTimer) clearTimeout(this.renewalTimer);
|
|
249
|
+
if (this.stopped || this.typedsocket !== socket || !this.identity) return;
|
|
250
|
+
const generation = this.authenticationGeneration;
|
|
251
|
+
const remaining = this.identity.expiresAt - Date.now();
|
|
252
|
+
this.renewalTimer = setTimeout(() => {
|
|
253
|
+
this.renewalTimer = undefined;
|
|
254
|
+
if (this.stopped || this.typedsocket !== socket || generation !== this.authenticationGeneration) return;
|
|
255
|
+
const abort = new AbortController(); this.authenticationAbort = abort;
|
|
256
|
+
const work = this.trackAuthentication(this.authenticate(
|
|
257
|
+
(method) => socket.createTypedRequest(method), abort.signal, generation));
|
|
258
|
+
void work.then(() => this.scheduleRenewal(socket), () => {
|
|
259
|
+
if (!this.stopped && generation === this.authenticationGeneration) void this.stop().catch(() => undefined);
|
|
260
|
+
});
|
|
261
|
+
}, Math.max(1, Math.min(30 * 60 * 1000, Math.floor(remaining / 2))));
|
|
262
|
+
}
|
|
263
|
+
|
|
199
264
|
public stop(): Promise<void> {
|
|
200
265
|
this.stopPromise ??= this.stopInner();
|
|
201
266
|
return this.stopPromise;
|
|
@@ -203,21 +268,26 @@ export class SzPlatformClient {
|
|
|
203
268
|
|
|
204
269
|
private async stopInner(): Promise<void> {
|
|
205
270
|
this.stopped = true;
|
|
271
|
+
this.invalidateAuthentication();
|
|
272
|
+
this.statusSubscription?.unsubscribe();
|
|
273
|
+
this.statusSubscription = undefined;
|
|
206
274
|
this.platformSocketAbortController.abort(this.createAbortError());
|
|
207
275
|
const socket = this.typedsocket;
|
|
208
|
-
|
|
276
|
+
this.typedsocket = undefined;
|
|
277
|
+
const results = await Promise.allSettled([
|
|
209
278
|
this.emailConnector.stop(),
|
|
210
279
|
this.webPushConnector.stop(),
|
|
211
280
|
this.coreMailConnector.stop(),
|
|
212
281
|
socket?.stop(),
|
|
213
|
-
this.initPromise,
|
|
214
282
|
]);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
283
|
+
await this.initPromise?.catch(() => undefined);
|
|
284
|
+
while (this.authenticationOperations.size) await Promise.allSettled([...this.authenticationOperations]);
|
|
285
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
286
|
+
if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Platform client shutdown failed.');
|
|
218
287
|
}
|
|
219
288
|
|
|
220
|
-
|
|
289
|
+
/** Connector admission guard; shutdown is terminal for this client lifetime. */
|
|
290
|
+
public assertRunning(): void {
|
|
221
291
|
if (this.stopped) {
|
|
222
292
|
throw this.createAbortError();
|
|
223
293
|
}
|
|
@@ -229,6 +299,45 @@ export class SzPlatformClient {
|
|
|
229
299
|
return error;
|
|
230
300
|
}
|
|
231
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Configures the CoreMail connector from the unscoped MAIL_COREMAIL_* group
|
|
304
|
+
* when no programmatic `coreMail` option was supplied. Per-address groups
|
|
305
|
+
* (MAIL_<TOKEN>_COREMAIL_*) are resolved by the email connector at send time;
|
|
306
|
+
* this only establishes the default binding so init() can authenticate early.
|
|
307
|
+
* A partially configured group throws rather than starting unconfigured.
|
|
308
|
+
*/
|
|
309
|
+
private async configureCoreMailFromEnv(): Promise<void> {
|
|
310
|
+
if (this.coreMailConnector.isConfigured) return;
|
|
311
|
+
const endpointUrl = await this.getConfiguredEnvVar('MAIL_COREMAIL_URL');
|
|
312
|
+
const bindingId = await this.getConfiguredEnvVar('MAIL_COREMAIL_BINDING_ID');
|
|
313
|
+
const credentialId = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_ID');
|
|
314
|
+
const credentialVersion = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_VERSION');
|
|
315
|
+
const credentialSecret = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_SECRET');
|
|
316
|
+
const present = [endpointUrl, bindingId, credentialId, credentialVersion, credentialSecret];
|
|
317
|
+
if (!present.some(Boolean)) return;
|
|
318
|
+
const missing = [
|
|
319
|
+
...(endpointUrl ? [] : ['MAIL_COREMAIL_URL']),
|
|
320
|
+
...(bindingId ? [] : ['MAIL_COREMAIL_BINDING_ID']),
|
|
321
|
+
...(credentialId ? [] : ['MAIL_COREMAIL_CREDENTIAL_ID']),
|
|
322
|
+
...(credentialVersion ? [] : ['MAIL_COREMAIL_CREDENTIAL_VERSION']),
|
|
323
|
+
...(credentialSecret ? [] : ['MAIL_COREMAIL_CREDENTIAL_SECRET']),
|
|
324
|
+
];
|
|
325
|
+
if (missing.length > 0) {
|
|
326
|
+
throw new Error(`CoreMail is partially configured. Missing: ${missing.join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
const parsedVersion = Number(credentialVersion);
|
|
329
|
+
if (!Number.isSafeInteger(parsedVersion) || parsedVersion < 1) {
|
|
330
|
+
throw new Error('MAIL_COREMAIL_CREDENTIAL_VERSION must be a positive integer');
|
|
331
|
+
}
|
|
332
|
+
this.coreMailConnector.configure({
|
|
333
|
+
endpointUrl: endpointUrl!,
|
|
334
|
+
bindingId: bindingId!,
|
|
335
|
+
credentialId: credentialId!,
|
|
336
|
+
credentialVersion: parsedVersion,
|
|
337
|
+
credentialSecret: credentialSecret!,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
232
341
|
private async discoverPlatformBindings() {
|
|
233
342
|
const bindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
|
|
234
343
|
const envBindings = await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_BINDINGS');
|
|
@@ -19,6 +19,29 @@ export interface ISzCoreMailConnectorOptions {
|
|
|
19
19
|
credentialSecret: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Default cadence for the pull-based inbound loop. */
|
|
23
|
+
export const szCoreMailInboundPollDefaults = Object.freeze({
|
|
24
|
+
intervalMs: 5_000,
|
|
25
|
+
minimumIntervalMs: 250,
|
|
26
|
+
maximumBackoffMs: 120_000,
|
|
27
|
+
pageLimit: 25,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface ISzCoreMailInboundPollOptions {
|
|
31
|
+
/** Tick cadence in milliseconds; defaults to 5000, floored at 250. */
|
|
32
|
+
intervalMs?: number;
|
|
33
|
+
/** Deliveries requested per tick; defaults to 25. */
|
|
34
|
+
pageLimit?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Invoked once per pending delivery. Resolving acknowledges the delivery as
|
|
39
|
+
* `processed`; throwing leaves it pending for a later tick.
|
|
40
|
+
*/
|
|
41
|
+
export type TSzCoreMailInboundHandler = (
|
|
42
|
+
resultArg: ISzCoreMailInboundFetchResult,
|
|
43
|
+
) => Promise<void> | void;
|
|
44
|
+
|
|
22
45
|
export interface ISzCoreMailUploadPartOptions {
|
|
23
46
|
submissionId: string;
|
|
24
47
|
part: plugins.servezoneInterfaces.data.ICoreMailContentDescriptor;
|
|
@@ -70,11 +93,37 @@ export class SzCoreMailConnector {
|
|
|
70
93
|
private readonly stopController = new AbortController();
|
|
71
94
|
private readonly activeOperations = new Set<Promise<unknown>>();
|
|
72
95
|
private stopPromise?: Promise<void>;
|
|
96
|
+
private inboundHandler?: TSzCoreMailInboundHandler;
|
|
97
|
+
private inboundPollOptions = { intervalMs: 0, pageLimit: 0 };
|
|
98
|
+
private inboundTimer?: ReturnType<typeof setTimeout>;
|
|
99
|
+
private inboundTickPromise?: Promise<void>;
|
|
100
|
+
private inboundConsecutiveFailures = 0;
|
|
73
101
|
|
|
74
102
|
public get isConfigured(): boolean {
|
|
75
103
|
return Boolean(this.options);
|
|
76
104
|
}
|
|
77
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Value-free identity of the binding this connector is configured for, or
|
|
108
|
+
* undefined while unconfigured. Deliberately excludes credentialSecret: it
|
|
109
|
+
* exists so a caller holding several bindings can tell whether this instance
|
|
110
|
+
* already serves one of them, which never requires the secret.
|
|
111
|
+
*/
|
|
112
|
+
public get configuredBinding(): Readonly<{
|
|
113
|
+
endpointUrl: string;
|
|
114
|
+
bindingId: string;
|
|
115
|
+
credentialId: string;
|
|
116
|
+
credentialVersion: number;
|
|
117
|
+
}> | undefined {
|
|
118
|
+
if (!this.options) return undefined;
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
endpointUrl: this.options.endpointUrl,
|
|
121
|
+
bindingId: this.options.bindingId,
|
|
122
|
+
credentialId: this.options.credentialId,
|
|
123
|
+
credentialVersion: this.options.credentialVersion,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
78
127
|
public configure(optionsArg: ISzCoreMailConnectorOptions): void {
|
|
79
128
|
if (this.stopped) {
|
|
80
129
|
throw new Error('CoreMail connector has stopped.');
|
|
@@ -181,18 +230,144 @@ export class SzCoreMailConnector {
|
|
|
181
230
|
};
|
|
182
231
|
}
|
|
183
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Starts the bounded pull loop (list -> fetch -> acknowledge). CoreMail never
|
|
235
|
+
* pushes inbound mail, so the workload polls. One registration owns the loop;
|
|
236
|
+
* re-registering replaces the handler without starting a second timer.
|
|
237
|
+
*/
|
|
238
|
+
public startInboundPolling(
|
|
239
|
+
handlerArg: TSzCoreMailInboundHandler,
|
|
240
|
+
optionsArg: ISzCoreMailInboundPollOptions = {},
|
|
241
|
+
): void {
|
|
242
|
+
this.requireOptions();
|
|
243
|
+
if (typeof handlerArg !== 'function') {
|
|
244
|
+
throw new Error('CoreMail inbound handler must be a function.');
|
|
245
|
+
}
|
|
246
|
+
const requestedInterval = optionsArg.intervalMs ?? szCoreMailInboundPollDefaults.intervalMs;
|
|
247
|
+
if (!Number.isSafeInteger(requestedInterval) || requestedInterval < szCoreMailInboundPollDefaults.minimumIntervalMs) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`CoreMail inbound intervalMs must be an integer of at least ${szCoreMailInboundPollDefaults.minimumIntervalMs}.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const requestedPageLimit = optionsArg.pageLimit ?? szCoreMailInboundPollDefaults.pageLimit;
|
|
253
|
+
if (
|
|
254
|
+
!Number.isSafeInteger(requestedPageLimit)
|
|
255
|
+
|| requestedPageLimit < 1
|
|
256
|
+
|| requestedPageLimit > plugins.servezoneInterfaces.data.coreMailLimits.inboundPageSize
|
|
257
|
+
) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`CoreMail inbound pageLimit must be an integer between 1 and ${plugins.servezoneInterfaces.data.coreMailLimits.inboundPageSize}.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
this.inboundHandler = handlerArg;
|
|
263
|
+
this.inboundPollOptions = { intervalMs: requestedInterval, pageLimit: requestedPageLimit };
|
|
264
|
+
this.inboundConsecutiveFailures = 0;
|
|
265
|
+
if (!this.inboundTimer && !this.inboundTickPromise) {
|
|
266
|
+
this.scheduleInboundTick(0);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Stops the pull loop and awaits the tick in flight. */
|
|
271
|
+
public async stopInboundPolling(): Promise<void> {
|
|
272
|
+
this.inboundHandler = undefined;
|
|
273
|
+
if (this.inboundTimer) {
|
|
274
|
+
clearTimeout(this.inboundTimer);
|
|
275
|
+
this.inboundTimer = undefined;
|
|
276
|
+
}
|
|
277
|
+
await this.inboundTickPromise?.catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
|
|
184
280
|
public stop(): Promise<void> {
|
|
185
281
|
this.stopPromise ??= this.stopInner();
|
|
186
282
|
return this.stopPromise;
|
|
187
283
|
}
|
|
188
284
|
|
|
285
|
+
private scheduleInboundTick(delayMsArg: number): void {
|
|
286
|
+
if (this.stopped || !this.inboundHandler) return;
|
|
287
|
+
this.inboundTimer = setTimeout(() => {
|
|
288
|
+
this.inboundTimer = undefined;
|
|
289
|
+
// One tick at a time: the next tick is only scheduled once this one settles.
|
|
290
|
+
const tick = this.runInboundTick().catch(() => undefined).finally(() => {
|
|
291
|
+
if (this.inboundTickPromise === tick) this.inboundTickPromise = undefined;
|
|
292
|
+
this.scheduleInboundTick(this.nextInboundDelayMs());
|
|
293
|
+
});
|
|
294
|
+
this.inboundTickPromise = tick;
|
|
295
|
+
}, delayMsArg);
|
|
296
|
+
this.inboundTimer.unref?.();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Exponential backoff on transport failure, capped; reset by a clean tick. */
|
|
300
|
+
private nextInboundDelayMs(): number {
|
|
301
|
+
if (this.inboundConsecutiveFailures === 0) return this.inboundPollOptions.intervalMs;
|
|
302
|
+
const backoff = this.inboundPollOptions.intervalMs * 2 ** Math.min(this.inboundConsecutiveFailures, 16);
|
|
303
|
+
return Math.min(backoff, szCoreMailInboundPollDefaults.maximumBackoffMs);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async runInboundTick(): Promise<void> {
|
|
307
|
+
const handler = this.inboundHandler;
|
|
308
|
+
if (this.stopped || !handler) return;
|
|
309
|
+
try {
|
|
310
|
+
const page = await this.listInboundDeliveries({ limit: this.inboundPollOptions.pageLimit });
|
|
311
|
+
this.inboundConsecutiveFailures = 0;
|
|
312
|
+
for (const delivery of page.deliveries) {
|
|
313
|
+
if (this.stopped || this.inboundHandler !== handler) return;
|
|
314
|
+
if (delivery.state === 'acknowledged') continue;
|
|
315
|
+
let fetched: ISzCoreMailInboundFetchResult;
|
|
316
|
+
try {
|
|
317
|
+
fetched = await this.fetchInboundDelivery(delivery);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// Leave the delivery pending; a later tick retries the fetch.
|
|
320
|
+
this.inboundConsecutiveFailures++;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
await handler(fetched);
|
|
325
|
+
} catch {
|
|
326
|
+
// A throwing handler must not acknowledge. The delivery stays pending.
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
await this.acknowledgeInboundDelivery({
|
|
330
|
+
deliveryId: fetched.delivery.deliveryId,
|
|
331
|
+
outcome: 'processed',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
this.inboundConsecutiveFailures++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Mirrors the loopback rule TypedSocket enforces before it downgrades a
|
|
341
|
+
* connection to an insecure WebSocket. See `isLoopbackHostname` in
|
|
342
|
+
* node_modules/@api.global/typedsocket/dist_ts/classes.typedsocket.js:257-267,
|
|
343
|
+
* applied at classes.typedsocket.js:986-988
|
|
344
|
+
* ('TypedSocket insecure WebSocket transport is restricted to loopback hosts.').
|
|
345
|
+
* Keeping the predicates identical means this connector never accepts an
|
|
346
|
+
* endpoint that TypedSocket would refuse one layer down.
|
|
347
|
+
*/
|
|
348
|
+
private isLoopbackHostname(hostnameArg: string): boolean {
|
|
349
|
+
const hostname = hostnameArg.toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
350
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
const octets = hostname.split('.');
|
|
354
|
+
return octets.length === 4
|
|
355
|
+
&& octets.every((octetArg) => /^\d{1,3}$/.test(octetArg) && Number(octetArg) <= 255)
|
|
356
|
+
&& Number(octets[0]) === 127;
|
|
357
|
+
}
|
|
358
|
+
|
|
189
359
|
private normalizeOptions(
|
|
190
360
|
optionsArg: ISzCoreMailConnectorOptions,
|
|
191
361
|
): ISzCoreMailConnectorOptions {
|
|
192
362
|
const endpointUrl = new URL(optionsArg.endpointUrl);
|
|
363
|
+
// TypedSocket maps http:/ws: onto ws: and https:/wss: onto wss:
|
|
364
|
+
// (classes.typedsocket.js:974-985), so the same four schemes are accepted
|
|
365
|
+
// here; the insecure pair stays restricted to loopback authorities.
|
|
366
|
+
const isSecureProtocol = endpointUrl.protocol === 'https:' || endpointUrl.protocol === 'wss:';
|
|
367
|
+
const isInsecureProtocol = endpointUrl.protocol === 'http:' || endpointUrl.protocol === 'ws:';
|
|
193
368
|
if (
|
|
194
369
|
optionsArg.endpointUrl.trim() !== optionsArg.endpointUrl
|
|
195
|
-
||
|
|
370
|
+
|| (!isSecureProtocol && !isInsecureProtocol)
|
|
196
371
|
|| endpointUrl.username
|
|
197
372
|
|| endpointUrl.password
|
|
198
373
|
|| endpointUrl.search
|
|
@@ -202,6 +377,11 @@ export class SzCoreMailConnector {
|
|
|
202
377
|
) {
|
|
203
378
|
throw new Error('CoreMail endpointUrl must be one canonical HTTPS /socket URL.');
|
|
204
379
|
}
|
|
380
|
+
if (isInsecureProtocol && !this.isLoopbackHostname(endpointUrl.hostname)) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
'CoreMail insecure endpointUrl is restricted to loopback hosts.',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
205
385
|
for (const [name, value] of [
|
|
206
386
|
['bindingId', optionsArg.bindingId],
|
|
207
387
|
['credentialId', optionsArg.credentialId],
|
|
@@ -397,8 +577,13 @@ export class SzCoreMailConnector {
|
|
|
397
577
|
throw new Error('CoreMail returned an invalid transfer origin.');
|
|
398
578
|
}
|
|
399
579
|
const url = new URL(valueArg);
|
|
580
|
+
// The transfer origin must follow the same scheme rule as endpointUrl: a
|
|
581
|
+
// loopback CoreMail reached over http: also issues http: transfer grants,
|
|
582
|
+
// so restricting this to https: would make loopback deployments unusable.
|
|
583
|
+
const isSecureProtocol = url.protocol === 'https:';
|
|
584
|
+
const isInsecureProtocol = url.protocol === 'http:';
|
|
400
585
|
if (
|
|
401
|
-
|
|
586
|
+
(!isSecureProtocol && !isInsecureProtocol)
|
|
402
587
|
|| url.username
|
|
403
588
|
|| url.password
|
|
404
589
|
|| url.search
|
|
@@ -408,6 +593,9 @@ export class SzCoreMailConnector {
|
|
|
408
593
|
) {
|
|
409
594
|
throw new Error('CoreMail returned a noncanonical transfer origin.');
|
|
410
595
|
}
|
|
596
|
+
if (isInsecureProtocol && !this.isLoopbackHostname(url.hostname)) {
|
|
597
|
+
throw new Error('CoreMail returned an insecure non-loopback transfer origin.');
|
|
598
|
+
}
|
|
411
599
|
return valueArg;
|
|
412
600
|
}
|
|
413
601
|
|
|
@@ -689,7 +877,7 @@ export class SzCoreMailConnector {
|
|
|
689
877
|
response.status !== 200
|
|
690
878
|
|| response.headers.get('content-type') !== grantArg.contentType
|
|
691
879
|
|| response.headers.get('content-length') !== String(grantArg.lengthBytes)
|
|
692
|
-
|| response.headers.get('cache-control')
|
|
880
|
+
|| !this.forbidsStoringResponse(response.headers.get('cache-control'))
|
|
693
881
|
|| response.headers.get('x-content-type-options') !== 'nosniff'
|
|
694
882
|
|| !responseBody
|
|
695
883
|
) {
|
|
@@ -794,6 +982,22 @@ export class SzCoreMailConnector {
|
|
|
794
982
|
return url.toString();
|
|
795
983
|
}
|
|
796
984
|
|
|
985
|
+
/**
|
|
986
|
+
* A one-time transfer payload must never be cached, but the exact spelling of
|
|
987
|
+
* Cache-Control is not ours to dictate: CoreMail's transfer service sets
|
|
988
|
+
* 'no-store' and typedserver's `noCache: true` then expands it to the full
|
|
989
|
+
* directive set ('no-store, no-cache, must-revalidate, proxy-revalidate,
|
|
990
|
+
* max-age=0'). Requiring byte equality with 'no-store' rejected every real
|
|
991
|
+
* CoreMail response, so assert the directive is present instead.
|
|
992
|
+
*/
|
|
993
|
+
private forbidsStoringResponse(cacheControlArg: string | null): boolean {
|
|
994
|
+
if (!cacheControlArg) return false;
|
|
995
|
+
return cacheControlArg
|
|
996
|
+
.split(',')
|
|
997
|
+
.map((directiveArg) => directiveArg.trim().toLowerCase())
|
|
998
|
+
.includes('no-store');
|
|
999
|
+
}
|
|
1000
|
+
|
|
797
1001
|
private async createSha256(
|
|
798
1002
|
bytesArg: Uint8Array,
|
|
799
1003
|
): Promise<plugins.servezoneInterfaces.data.TCoreMailSha256> {
|
|
@@ -906,6 +1110,14 @@ export class SzCoreMailConnector {
|
|
|
906
1110
|
private async stopInner(): Promise<void> {
|
|
907
1111
|
this.stopped = true;
|
|
908
1112
|
this.generation++;
|
|
1113
|
+
this.inboundHandler = undefined;
|
|
1114
|
+
if (this.inboundTimer) {
|
|
1115
|
+
clearTimeout(this.inboundTimer);
|
|
1116
|
+
this.inboundTimer = undefined;
|
|
1117
|
+
}
|
|
1118
|
+
const inboundTick = this.inboundTickPromise;
|
|
1119
|
+
this.inboundTickPromise = undefined;
|
|
1120
|
+
await inboundTick?.catch(() => undefined);
|
|
909
1121
|
this.stopController.abort(this.createAbortError());
|
|
910
1122
|
const connection = this.connection;
|
|
911
1123
|
const pendingConnection = this.pendingConnection;
|