@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/platformclient",
3
- "version": "4.0.0",
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.2",
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": "^4.1.1",
15
+ "@git.zone/tstest": "6.1.0",
16
+ "@push.rocks/smartmongo": "9.0.0",
16
17
  "@push.rocks/smartserve": "6.2.1",
17
- "@types/node": "26.4.1"
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.2.0"
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 from explicit SMTP compatibility configuration. 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.
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. dcrouter then delivers inbound messages through `deliverInboundMail`. After reconnect, exact credential-scoped endpoint registration completes before the mail connection becomes ready. A denied or malformed registration prevents readiness.
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
- `coreMailConnector` is a server-runtime API for standalone CoreMail workload bindings. 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.
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.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/platformclient',
6
- version: '4.0.0',
6
+ version: '4.1.0',
7
7
  description: 'a module that makes it really easy to use the serve.zone platform inside your app'
8
8
  }
@@ -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
- || endpointUrl.protocol !== 'https:'
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
- url.protocol !== 'https:'
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') !== 'no-store'
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;