@serve.zone/platformclient 4.0.0 → 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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.platformclient.d.ts +8 -0
- package/dist_ts/classes.platformclient.js +42 -1
- 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 +86 -1
- package/dist_ts/email/classes.emailconnector.js +537 -1
- package/package.json +7 -5
- package/readme.md +34 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.platformclient.ts +40 -0
- package/ts/coremail/classes.coremailconnector.ts +215 -3
- package/ts/email/classes.emailconnector.ts +651 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/platformclient",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "a module that makes it really easy to use the serve.zone platform inside your app",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
"author": "Task Venture Capital GmbH",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@git.zone/tsbuild": "^4.4.
|
|
12
|
+
"@git.zone/tsbuild": "^4.4.3",
|
|
13
13
|
"@git.zone/tsbundle": "^2.13.0",
|
|
14
14
|
"@git.zone/tsrun": "^2.0.6",
|
|
15
|
-
"@git.zone/tstest": "
|
|
15
|
+
"@git.zone/tstest": "6.1.0",
|
|
16
|
+
"@push.rocks/smartmongo": "9.0.0",
|
|
16
17
|
"@push.rocks/smartserve": "6.2.1",
|
|
17
|
-
"@
|
|
18
|
+
"@serve.zone/coremail": "1.2.0",
|
|
19
|
+
"@types/node": "26.5.0"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@api.global/typedrequest": "^8.0.4",
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
"@push.rocks/qenv": "^6.1.4",
|
|
24
26
|
"@push.rocks/smartlog": "^3.2.2",
|
|
25
27
|
"@push.rocks/smartntml": "^2.0.9",
|
|
26
|
-
"@serve.zone/interfaces": "^30.
|
|
28
|
+
"@serve.zone/interfaces": "^30.13.0"
|
|
27
29
|
},
|
|
28
30
|
"repository": {
|
|
29
31
|
"type": "git",
|
package/readme.md
CHANGED
|
@@ -65,6 +65,12 @@ Service mail credentials come only from workload environment variables. Standard
|
|
|
65
65
|
|
|
66
66
|
Managed Web Push uses a separate, credential-scoped TypedSocket connection configured with `WEB_PUSH_TYPED_URL`, `WEB_PUSH_API_CREDENTIAL_ID`, and `WEB_PUSH_API_CREDENTIAL_SECRET`. These values form one atomic workload environment bundle: all three values are required and partial bundles fail closed. `webPushConnector` never accepts or sends caller-provided authentication or resource-owner fields; dcrouter derives the service scope from the injected credential.
|
|
67
67
|
|
|
68
|
+
Service mail can instead be routed through a cluster-scoped CoreMail instance. The default sender uses `MAIL_COREMAIL_URL`, `MAIL_COREMAIL_BINDING_ID`, `MAIL_COREMAIL_CREDENTIAL_ID`, `MAIL_COREMAIL_CREDENTIAL_VERSION`, and `MAIL_COREMAIL_CREDENTIAL_SECRET`. Additional sender addresses use scoped `MAIL_<TOKEN>_COREMAIL_*` variants under the same address token as the other mail variables, for example `MAIL_TEST_SERVICE_GATED_ONE_COREMAIL_URL`. Cloudly provisions this group for clusters and Onebox for single hosts.
|
|
69
|
+
|
|
70
|
+
Precedence matches the dcrouter variables: if any scoped `MAIL_<TOKEN>_COREMAIL_*` value is present, that scoped group is used on its own and the unscoped group is not consulted for that address. The group is atomic — a partially configured group fails closed with the exact missing variable names rather than silently falling back to dcrouter. When a CoreMail group resolves for the selected address, `sendMail()`, `sendText()`, `sendHtml()`, `getMailDeliveryStatus()`, and `registerInboundHandler()` use CoreMail; otherwise they use the dcrouter `MAIL_TYPED_URL` path, exactly as before. `MAIL_COREMAIL_URL` must be one canonical `https://.../socket` URL; `http://` and `ws://` are accepted only for loopback hosts, mirroring the rule TypedSocket itself enforces.
|
|
71
|
+
|
|
72
|
+
Each distinct binding gets its own connector instance, so a service may hold an unscoped group and several scoped groups at once and send from any of them.
|
|
73
|
+
|
|
68
74
|
SMTP compatibility credentials are still discoverable with `getServiceMailCredentials()` for applications that explicitly implement SMTP themselves. Those compatibility variables are `SMTP_HOST`, `SMTP_PORT`, `SMTP_TLS_MODE`, `SMTP_USERNAME`, and `SMTP_PASSWORD`, plus scoped `MAIL_<TOKEN>_SMTP_*` variants. They are not used by `sendMail()`, `sendText()`, `sendHtml()`, or `registerInboundHandler()`.
|
|
69
75
|
|
|
70
76
|
```typescript
|
|
@@ -159,7 +165,7 @@ if (status.ready) {
|
|
|
159
165
|
}
|
|
160
166
|
```
|
|
161
167
|
|
|
162
|
-
`getServiceMailStatus().ready` means the typed service-mail path is configured. The status also exposes `transport`, `typedUrl`, `typedCredentialConfigured`, and `smtpReady` so applications can distinguish standard TypedSocket mail
|
|
168
|
+
`getServiceMailStatus().ready` means the typed service-mail path is configured. The status also exposes `transport`, `typedUrl`, `typedCredentialConfigured`, `coreMailConfigured`, `coreMailEndpointUrl`, and `smtpReady` so applications can distinguish CoreMail, standard TypedSocket mail, and explicit SMTP compatibility configuration. `transport` is `'coremail'` when a CoreMail group resolves, `'typedsocket'` for the dcrouter path, `'smtp'` when only SMTP compatibility values are present, and `'none'` otherwise. A partially configured CoreMail group reports `ready: false` with the exact missing `MAIL_COREMAIL_*` names in `missing`. Send helpers return `spoolItemId`, which can be passed to `getMailDeliveryStatus()` to inspect accepted, queued, deferred, delivered, or failed delivery state. Set `replyTo` to one bare mailbox address when replies should go somewhere other than the managed sender. Dcrouter validates that field and renders the authoritative `Reply-To` header; do not supply `Reply-To` through the free-form `headers` object.
|
|
163
169
|
|
|
164
170
|
Specific sender address:
|
|
165
171
|
|
|
@@ -181,7 +187,7 @@ await client.emailConnector.registerInboundHandler(async (message) => {
|
|
|
181
187
|
});
|
|
182
188
|
```
|
|
183
189
|
|
|
184
|
-
`registerInboundHandler()` registers the dedicated mail connection as the typed endpoint for the configured service mail address
|
|
190
|
+
`registerInboundHandler()` behaves according to the resolved transport. On the dcrouter path it registers the dedicated mail connection as the typed endpoint for the configured service mail address, and dcrouter then delivers inbound messages through `deliverInboundMail`. When a CoreMail group resolves for that address, CoreMail is pull-based instead: the connector that owns the address's binding runs a bounded polling loop that lists pending deliveries, fetches each MIME body, invokes the handler with the same message shape the dcrouter path produces, and acknowledges the delivery as `processed` only after the handler resolves. A handler that throws, or returns `{ accepted: false }`, leaves the delivery pending for a later tick. The loop starts on first registration, never overlaps ticks, backs off on transport failure, and is stopped by `stop()`. Tune it with `setCoreMailInboundPollOptions({ intervalMs, pageLimit })` before registering; the default is one tick every 5000 ms over 25 deliveries, with a floor of 250 ms. After reconnect, exact credential-scoped endpoint registration completes before the mail connection becomes ready. A denied or malformed registration prevents readiness.
|
|
185
191
|
|
|
186
192
|
Inbound message normalization for webhook or direct TypedRequest-style delivery:
|
|
187
193
|
|
|
@@ -310,7 +316,28 @@ The exported `TServiceMailSubmissionErrorCode` type contains the supported dcrou
|
|
|
310
316
|
|
|
311
317
|
## CoreMail
|
|
312
318
|
|
|
313
|
-
|
|
319
|
+
There are two ways to reach CoreMail, and most applications want the first.
|
|
320
|
+
|
|
321
|
+
**As the service-mail transport.** Set the `MAIL_COREMAIL_*` group described under [Configuration](#configuration) and keep using `emailConnector`: `sendMail()`, `getServiceMailStatus()`, `getMailDeliveryStatus()`, and `registerInboundHandler()` all route through CoreMail automatically, with the same `IServiceMailSendOptions` input as the dcrouter path. No CoreMail-specific application code is required.
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import { SzPlatformClient } from '@serve.zone/platformclient';
|
|
325
|
+
|
|
326
|
+
const client = new SzPlatformClient();
|
|
327
|
+
await client.init(); // reads MAIL_COREMAIL_* from the workload environment
|
|
328
|
+
|
|
329
|
+
const sent = await client.emailConnector.sendMail({
|
|
330
|
+
to: 'recipient@example.com',
|
|
331
|
+
subject: 'Sent through CoreMail',
|
|
332
|
+
text: 'Bodies and attachments become uploaded parts automatically.',
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const status = await client.emailConnector.getMailDeliveryStatus(sent.spoolItemId!);
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Bodies, HTML, and attachments become CoreMail parts with client-computed SHA-256 digests and lengths, uploaded and finalized for you. Contract limits (1 MiB text, 2 MiB HTML, 16 attachments, 10 MiB each, 17 MiB aggregate, 100 recipients) are enforced before anything is sent. `idempotencyKey` is passed through; CoreMail requires one, so a fresh key is generated when the caller omits it, which reproduces the dcrouter semantics of an unkeyed send. CoreMail error codes that `TMailSubmissionErrorCode` also carries surface unchanged on `ServiceMailSubmissionError.code`; codes with no counterpart become a generic `Error` carrying the code.
|
|
339
|
+
|
|
340
|
+
**As a direct workload API.** `coreMailConnector` is a server-runtime API for standalone CoreMail workload bindings, for applications that manage submissions or inbound deliveries themselves. It is not a browser or cross-origin API. Pass the injected workload authority explicitly; the connector never reads credential material from platform-binding metadata. The endpoint must be one canonical `https://.../socket` URL, or an `http://`/`ws://` loopback URL for local development.
|
|
314
341
|
|
|
315
342
|
```typescript
|
|
316
343
|
import { SzPlatformClient } from '@serve.zone/platformclient';
|
|
@@ -356,6 +383,10 @@ try {
|
|
|
356
383
|
|
|
357
384
|
Outbound delivery is descriptor-first: call `prepareOutboundSubmission()`, upload every declared byte sequence with `uploadOutboundPart()`, then call `finalizeOutboundSubmission()`. Upload and download grants are path-only, same-origin, one-time capabilities. The connector validates the authenticated operation set, grant context, transfer headers, byte length, and SHA-256 digest before completing a transfer. A general platform authorization string is not required when the client is configured only for CoreMail.
|
|
358
385
|
|
|
386
|
+
Manual polling as shown above is only needed when driving `coreMailConnector` directly. Applications using CoreMail as the service-mail transport should call `emailConnector.registerInboundHandler()` instead and let the connector own the loop.
|
|
387
|
+
|
|
388
|
+
`configure()` is one-shot per connector instance. When several bindings are configured through the `MAIL_COREMAIL_*` groups, `emailConnector` keeps one connector per binding — reusing `client.coreMailConnector` for the first or matching binding and creating dedicated instances for the rest — and stops the ones it created in `stop()`.
|
|
389
|
+
|
|
359
390
|
## Platform Bindings
|
|
360
391
|
|
|
361
392
|
Platform bindings allow a workload to discover value-free endpoint and capability metadata from its runtime environment. Secret values are injected separately as workload environment variables or explicit connector options.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -75,6 +75,7 @@ export class SzPlatformClient {
|
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
await this.configureCoreMailFromEnv();
|
|
78
79
|
this.platformBindings = this.mergePlatformBindings(
|
|
79
80
|
this.platformBindings,
|
|
80
81
|
await this.discoverPlatformBindings()
|
|
@@ -298,6 +299,45 @@ export class SzPlatformClient {
|
|
|
298
299
|
return error;
|
|
299
300
|
}
|
|
300
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
|
+
|
|
301
341
|
private async discoverPlatformBindings() {
|
|
302
342
|
const bindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
|
|
303
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;
|