@serve.zone/platformclient 3.0.0 → 4.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/readme.md CHANGED
@@ -19,7 +19,7 @@ import { SzPlatformClient } from '@serve.zone/platformclient';
19
19
 
20
20
  const platformClient = new SzPlatformClient({
21
21
  token: process.env.SERVEZONE_PLATFORM_TOKEN,
22
- platformUrl: process.env.SERVEZONE_PLATFORM_URL,
22
+ cloudlyUrl: process.env.SERVEZONE_PLATFORM_URL,
23
23
  });
24
24
 
25
25
  await platformClient.init();
@@ -49,14 +49,16 @@ The request and response payloads come from `@serve.zone/interfaces`, so TypeScr
49
49
 
50
50
  ## Configuration
51
51
 
52
- The shared platform connection needs an authorization string and a platform endpoint. Authorization comes from explicit options or environment values. Value-free platform bindings can supply endpoint discovery metadata, but never credentials.
52
+ The shared platform connection requires a Cloudly machine token and an explicit HTTPS Cloudly origin. On each connection it exchanges the token for an API machine identity and registers that JWT on the physical socket before initialization succeeds. Reconnect repeats both steps, and a live connection renews its identity before expiry. Rejected credentials, invalid registration replies, or failed renewal close the connection. Value-free platform bindings describe capabilities; they never select the Cloudly authority or provide credentials.
53
53
 
54
54
  | Value | Sources |
55
55
  | --- | --- |
56
56
  | Authorization | Constructor string, `authorizationString`, `authorization`, `token`, `init()` argument, `SERVEZONE_PLATFORM_AUTHORIZATION`, or `SERVEZONE_PLATFORM_TOKEN`. |
57
- | Platform URL | `url`, `platformUrl`, `SERVEZONE_PLATFORM_URL`, or the first active binding endpoint using `typedrequest` or `http`. |
57
+ | Cloudly URL | `cloudlyUrl`, `url`, `platformUrl`, or `SERVEZONE_PLATFORM_URL`; an explicit HTTPS origin is required. |
58
58
  | Platform bindings | Constructor `binding` or `bindings`, `SERVEZONE_PLATFORM_BINDING`, or `SERVEZONE_PLATFORM_BINDINGS`. |
59
59
 
60
+ Workloads should receive their service-scoped `platform:session` token through `SERVEZONE_PLATFORM_AUTHORIZATION`. A deployment token does not grant a platform session. The SDK never sends the token to a binding or mail endpoint. Token exchange and registration bypass request payload hooks and disable automatic RPC retries.
61
+
60
62
  Binding environment variables must contain JSON encoded, value-free `IPlatformBinding` objects from `@serve.zone/interfaces`.
61
63
 
62
64
  Service mail credentials come only from workload environment variables. Standard platformclient mail uses dcrouter TypedSocket/TypedRequest service credentials. The default sender uses `MAIL_FROM`, `MAIL_TYPED_URL`, `MAIL_API_CREDENTIAL_ID`, and `MAIL_API_CREDENTIAL_SECRET`. Additional configured sender addresses use scoped variables such as `MAIL_TEST_SERVICE_GATED_ONE_FROM`, `MAIL_TEST_SERVICE_GATED_ONE_TYPED_URL`, `MAIL_TEST_SERVICE_GATED_ONE_API_CREDENTIAL_ID`, and `MAIL_TEST_SERVICE_GATED_ONE_API_CREDENTIAL_SECRET`.
@@ -83,6 +85,10 @@ const client = new SzPlatformClient({
83
85
  await client.init();
84
86
  ```
85
87
 
88
+ ## Socket 8 migration
89
+
90
+ Version 4 requires Socket 8 peers: upgrade Cloudly and the dedicated dcrouter mail/Web Push endpoints together with their clients. Older Socket 4/5 servers are incompatible. Set `SERVEZONE_PLATFORM_URL` to Cloudly's HTTPS origin and retain `MAIL_TYPED_URL` for the dedicated mail endpoint. Replace legacy platform tokens with Cloudly service credentials; initialization no longer accepts a successful transport handshake as authentication. CoreMail, SMTP credential discovery, mail delivery status, attachments, Reply-To, and debug connector behavior remain available.
91
+
86
92
  ## Debug Mode
87
93
 
88
94
  Pass `test` as the authorization string to activate debug mode for the shared platform connection and the legacy service connectors. Those connectors log or return deterministic test values instead of sending real platform requests. CoreMail has no debug transport: omit CoreMail configuration in debug clients, because calling `coreMailConnector` methods still requires normal workload authentication.
@@ -110,12 +116,12 @@ The current SMS verification method is spelled `sendSmsVerifcation()` in code. U
110
116
 
111
117
  ## Lifecycle
112
118
 
113
- `SzPlatformClient` keeps TypedSocket connections open for platform requests, CoreMail, typed service-mail delivery, and managed Web Push delivery. Call `stop()` when a short-lived process, worker, or test is done with the client.
119
+ `SzPlatformClient` keeps separate TypedSocket connections for Cloudly, CoreMail, typed service mail, and managed Web Push. Call `stop()` to cancel initialization, renewal, and restoration and join their cleanup. `requireTypedSocket()` returns the authenticated Cloudly connection while its identity is valid; `typedsocket` is optional. The mail router is private to its own connection, so Cloudly and Web Push peers cannot invoke inbound mail handlers.
114
120
 
115
121
  ```typescript
116
122
  const client = new SzPlatformClient({
117
123
  token: process.env.SERVEZONE_PLATFORM_TOKEN,
118
- platformUrl: process.env.SERVEZONE_PLATFORM_URL,
124
+ cloudlyUrl: process.env.SERVEZONE_PLATFORM_URL,
119
125
  });
120
126
 
121
127
  await client.init();
@@ -141,6 +147,7 @@ const status = await client.emailConnector.getServiceMailStatus();
141
147
  if (status.ready) {
142
148
  const sendResult = await client.emailConnector.sendText({
143
149
  to: 'user@example.com',
150
+ replyTo: 'customer@example.com',
144
151
  subject: 'Service mail ready',
145
152
  text: 'This message was sent through the managed service mail identity.',
146
153
  });
@@ -152,7 +159,7 @@ if (status.ready) {
152
159
  }
153
160
  ```
154
161
 
155
- `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.
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.
156
163
 
157
164
  Specific sender address:
158
165
 
@@ -174,7 +181,7 @@ await client.emailConnector.registerInboundHandler(async (message) => {
174
181
  });
175
182
  ```
176
183
 
177
- `registerInboundHandler()` registers this client connection as the typed endpoint for the configured service mail address. dcrouter then delivers inbound messages through the shared `deliverInboundMail` TypedRequest contract.
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.
178
185
 
179
186
  Inbound message normalization for webhook or direct TypedRequest-style delivery:
180
187
 
@@ -279,6 +286,28 @@ await client.letterConnector.sendLetter({
279
286
 
280
287
  Service-mail and managed Web Push helper types are exported from `@serve.zone/platformclient`. `IServiceMailSendResult.spoolItemId` identifies the dcrouter delivery spool item for follow-up status queries through `getMailDeliveryStatus(spoolItemId, address?)`. `IServiceWebPushEnqueueOptions` and `TServiceWebPushCancellationTarget` mirror the credential-scoped Web Push delivery contract without exposing auth or owner fields. The legacy platform-service request fields remain under `platform.email`, `platform.sms`, `platform.pushnotification`, and `platform.letter`.
281
288
 
289
+ Dcrouter rejections with a stable submission code are exposed as `ServiceMailSubmissionError`. Uncoded rejections remain plain `Error` instances:
290
+
291
+ ```typescript
292
+ import { ServiceMailSubmissionError } from '@serve.zone/platformclient';
293
+
294
+ try {
295
+ await client.emailConnector.sendText({
296
+ to: 'user@example.com',
297
+ replyTo: 'customer@example.com',
298
+ subject: 'Service mail',
299
+ text: 'Hello',
300
+ });
301
+ } catch (error) {
302
+ if (error instanceof ServiceMailSubmissionError) {
303
+ console.error(error.code);
304
+ }
305
+ throw error;
306
+ }
307
+ ```
308
+
309
+ The exported `TServiceMailSubmissionErrorCode` type contains the supported dcrouter rejection codes.
310
+
282
311
  ## CoreMail
283
312
 
284
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.
@@ -366,7 +395,7 @@ The package is authored as ESM TypeScript and builds source folders with `tsbuil
366
395
 
367
396
  ## License and Legal Information
368
397
 
369
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [repository license file](./license).
398
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [repository license file](./license.md).
370
399
 
371
400
  **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
372
401
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/platformclient',
6
- version: '3.0.0',
6
+ version: '4.0.0',
7
7
  description: 'a module that makes it really easy to use the serve.zone platform inside your app'
8
8
  }
@@ -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!: plugins.typedsocket.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>;
@@ -77,25 +85,41 @@ export class SzPlatformClient {
77
85
  return;
78
86
  }
79
87
  if (!this.authorizationString) throw new Error('authorizationString is required');
88
+ if (this.authorizationString.length > 8192 || /[\x00-\x20\x7f]/.test(this.authorizationString)) {
89
+ throw new Error('Platform authorization is invalid.');
90
+ }
80
91
  const socket = await plugins.typedsocket.TypedSocket.createClient(
81
- this.typedrouter as any,
92
+ this.typedrouter,
82
93
  await this.getConnectionAddress(),
83
- { abortSignal: this.platformSocketAbortController.signal },
94
+ {
95
+ autoReconnect: true, maxRetries: 100, initialBackoffMs: 1000, maxBackoffMs: 30_000,
96
+ abortSignal: this.platformSocketAbortController.signal,
97
+ restoreConnection: (context) => {
98
+ this.invalidateAuthentication();
99
+ const generation = this.authenticationGeneration;
100
+ return this.trackAuthentication(this.authenticate(context.createTypedRequest, context.abortSignal, generation));
101
+ },
102
+ },
84
103
  );
85
104
  if (this.stopped) {
86
- await socket.stop().catch(() => undefined);
105
+ await socket.stop();
87
106
  throw this.createAbortError();
88
107
  }
89
108
  this.typedsocket = socket;
90
109
  try {
91
- await this.setAuthorizationTag();
110
+ this.statusSubscription = socket.statusSubject.subscribe((status) => {
111
+ if (status === 'disconnected') this.invalidateAuthentication();
112
+ if (status === 'connected') this.scheduleRenewal(socket);
113
+ });
114
+ this.scheduleRenewal(socket);
92
115
  this.assertRunning();
93
116
  if (this.coreMailConnector.isConfigured) {
94
117
  await this.coreMailConnector.init();
95
118
  this.assertRunning();
96
119
  }
97
120
  } catch (error) {
98
- await socket.stop().catch(() => undefined);
121
+ this.invalidateAuthentication();
122
+ await socket.stop();
99
123
  throw error;
100
124
  }
101
125
  }
@@ -131,7 +155,7 @@ export class SzPlatformClient {
131
155
  optionsArg.authorization ||
132
156
  optionsArg.token ||
133
157
  this.authorizationString;
134
- this.connectionAddress = optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
158
+ this.connectionAddress = optionsArg.cloudlyUrl || optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
135
159
  this.platformBindings = this.mergePlatformBindings(
136
160
  this.platformBindings,
137
161
  [optionsArg.binding, ...(optionsArg.bindings || [])].filter(
@@ -146,34 +170,12 @@ export class SzPlatformClient {
146
170
  private async getConnectionAddress() {
147
171
  const connectionAddress =
148
172
  this.connectionAddress ||
149
- (await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL')) ||
150
- this.getConnectionAddressFromBindings();
151
- if (!connectionAddress) throw new Error('SERVEZONE_PLATFORM_URL or platform binding endpoint is required');
152
- return connectionAddress;
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);
173
+ (await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL'));
174
+ if (!connectionAddress) throw new Error('An explicit Cloudly URL is required.');
175
+ const parsed = new URL(connectionAddress);
176
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
177
+ || parsed.pathname !== '/') throw new Error('Cloudly URL must be an HTTPS origin.');
178
+ return parsed.origin;
177
179
  }
178
180
 
179
181
  private async getConfiguredAuthorizationString() {
@@ -183,19 +185,81 @@ export class SzPlatformClient {
183
185
  );
184
186
  }
185
187
 
186
- private async setAuthorizationTag() {
187
- if (!this.authorizationString) {
188
- return;
189
- }
188
+ public requireTypedSocket(): plugins.typedsocket.TypedSocket {
189
+ this.assertRunning();
190
+ if (!this.typedsocket || this.typedsocket.getStatus() !== 'connected' || !this.identity
191
+ || this.identity.expiresAt <= Date.now()) throw new Error('Cloudly platform session is unavailable.');
192
+ return this.typedsocket;
193
+ }
194
+
195
+ private invalidateAuthentication(): void {
196
+ this.authenticationGeneration++;
197
+ this.authenticationAbort?.abort();
198
+ this.authenticationAbort = undefined;
199
+ this.identity = undefined;
200
+ if (this.renewalTimer) clearTimeout(this.renewalTimer);
201
+ this.renewalTimer = undefined;
202
+ }
203
+
204
+ private trackAuthentication<T>(operation: Promise<T>): Promise<T> {
205
+ this.authenticationOperations.add(operation);
206
+ void operation.then(() => this.authenticationOperations.delete(operation), () => this.authenticationOperations.delete(operation));
207
+ return operation;
208
+ }
209
+
210
+ private async authenticate(createRequest: plugins.typedsocket.TTypedSocketRestoreRequestFactory,
211
+ signal: AbortSignal, generation: number): Promise<void> {
212
+ const requireCurrent = () => {
213
+ this.assertRunning(); signal.throwIfAborted();
214
+ if (generation !== this.authenticationGeneration) throw this.createAbortError();
215
+ };
190
216
  try {
191
- await this.typedsocket.setTag('authorization' as any, this.authorizationString);
192
- } catch (error) {
193
- if (await this.getConfiguredEnvVar('SERVEZONE_PLATFORMCLIENT_DEBUG')) {
194
- console.warn('Could not set platform authorization tag:', (error as Error).message);
217
+ requireCurrent();
218
+ const exchange = createRequest<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
219
+ 'getIdentityByToken');
220
+ exchange.skipHooks = true;
221
+ const response = await exchange.fire({ token: this.authorizationString! }, { maxRetries: 0, abortSignal: signal });
222
+ requireCurrent();
223
+ const identity = response.identity;
224
+ if (!identity || identity.type !== 'machine' || identity.role !== 'api'
225
+ || typeof identity.userId !== 'string' || !/^service-machine:[a-f0-9]{64}$/.test(identity.userId)
226
+ || !Number.isSafeInteger(identity.expiresAt) || identity.expiresAt <= Date.now() + 1000) {
227
+ throw new Error('Invalid platform identity.');
195
228
  }
229
+ const requestData = plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionRequestV1({
230
+ schemaVersion: 1, identity: { jwt: identity.jwt },
231
+ });
232
+ const registration = createRequest<plugins.servezoneInterfaces.requests.runtimesession.IReq_Any_Cloudly_RegisterCloudlyClientSessionV1>(
233
+ 'registerCloudlyClientSessionV1');
234
+ registration.skipHooks = true;
235
+ const registered = await registration.fire(requestData, { maxRetries: 0, abortSignal: signal });
236
+ plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionResponseV1(registered);
237
+ requireCurrent();
238
+ this.identity = { type: identity.type, role: identity.role, userId: identity.userId,
239
+ name: identity.userId, expiresAt: identity.expiresAt, jwt: requestData.identity.jwt };
240
+ } catch {
241
+ if (this.stopped || signal.aborted || generation !== this.authenticationGeneration) throw this.createAbortError();
242
+ throw new Error('Cloudly platform authentication failed.');
196
243
  }
197
244
  }
198
245
 
246
+ private scheduleRenewal(socket: plugins.typedsocket.TypedSocket): void {
247
+ if (this.renewalTimer) clearTimeout(this.renewalTimer);
248
+ if (this.stopped || this.typedsocket !== socket || !this.identity) return;
249
+ const generation = this.authenticationGeneration;
250
+ const remaining = this.identity.expiresAt - Date.now();
251
+ this.renewalTimer = setTimeout(() => {
252
+ this.renewalTimer = undefined;
253
+ if (this.stopped || this.typedsocket !== socket || generation !== this.authenticationGeneration) return;
254
+ const abort = new AbortController(); this.authenticationAbort = abort;
255
+ const work = this.trackAuthentication(this.authenticate(
256
+ (method) => socket.createTypedRequest(method), abort.signal, generation));
257
+ void work.then(() => this.scheduleRenewal(socket), () => {
258
+ if (!this.stopped && generation === this.authenticationGeneration) void this.stop().catch(() => undefined);
259
+ });
260
+ }, Math.max(1, Math.min(30 * 60 * 1000, Math.floor(remaining / 2))));
261
+ }
262
+
199
263
  public stop(): Promise<void> {
200
264
  this.stopPromise ??= this.stopInner();
201
265
  return this.stopPromise;
@@ -203,21 +267,26 @@ export class SzPlatformClient {
203
267
 
204
268
  private async stopInner(): Promise<void> {
205
269
  this.stopped = true;
270
+ this.invalidateAuthentication();
271
+ this.statusSubscription?.unsubscribe();
272
+ this.statusSubscription = undefined;
206
273
  this.platformSocketAbortController.abort(this.createAbortError());
207
274
  const socket = this.typedsocket;
208
- await Promise.allSettled([
275
+ this.typedsocket = undefined;
276
+ const results = await Promise.allSettled([
209
277
  this.emailConnector.stop(),
210
278
  this.webPushConnector.stop(),
211
279
  this.coreMailConnector.stop(),
212
280
  socket?.stop(),
213
- this.initPromise,
214
281
  ]);
215
- if (this.typedsocket && this.typedsocket !== socket) {
216
- await this.typedsocket.stop().catch(() => undefined);
217
- }
282
+ await this.initPromise?.catch(() => undefined);
283
+ while (this.authenticationOperations.size) await Promise.allSettled([...this.authenticationOperations]);
284
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
285
+ if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Platform client shutdown failed.');
218
286
  }
219
287
 
220
- private assertRunning(): void {
288
+ /** Connector admission guard; shutdown is terminal for this client lifetime. */
289
+ public assertRunning(): void {
221
290
  if (this.stopped) {
222
291
  throw this.createAbortError();
223
292
  }
@@ -51,6 +51,8 @@ export interface IServiceMailSendOptions {
51
51
  to: string | string[];
52
52
  cc?: string | string[];
53
53
  bcc?: string | string[];
54
+ /** One bare mailbox address rendered as the authoritative Reply-To header by dcrouter. */
55
+ replyTo?: string;
54
56
  subject: string;
55
57
  text?: string;
56
58
  html?: string;
@@ -58,6 +60,19 @@ export interface IServiceMailSendOptions {
58
60
  attachments?: IServiceMailAttachment[];
59
61
  }
60
62
 
63
+ export type TServiceMailSubmissionErrorCode =
64
+ plugins.servezoneInterfaces.data.TMailSubmissionErrorCode;
65
+
66
+ export class ServiceMailSubmissionError extends Error {
67
+ constructor(
68
+ public readonly code: TServiceMailSubmissionErrorCode,
69
+ messageArg: string,
70
+ ) {
71
+ super(messageArg);
72
+ this.name = 'ServiceMailSubmissionError';
73
+ }
74
+ }
75
+
61
76
  export interface IServiceMailSendResult {
62
77
  messageId?: string;
63
78
  spoolItemId?: string;
@@ -91,12 +106,11 @@ export class SzEmailConnector {
91
106
  private mailTypedSocketUrl?: string;
92
107
  private mailTypedSocketPromise?: Promise<plugins.typedsocket.TypedSocket>;
93
108
  private mailTypedSocketAbortController?: AbortController;
94
- private mailTypedSocketStatusSubscription?: { unsubscribe: () => void };
109
+ private readonly mailRouter = new plugins.typedrequest.TypedRouter();
110
+ private readonly restorationOperations = new Set<Promise<void>>();
95
111
  private inboundHandler?: TServiceMailInboundHandler;
96
112
  private inboundTypedHandlerRegistered = false;
97
113
  private inboundEndpointRegistrations = new Set<string>();
98
- private inboundEndpointReplayPromise?: Promise<void>;
99
- private readonly inboundEndpointReplayRetryDelaysMs = [500, 1000, 2000, 5000];
100
114
 
101
115
  constructor(platformClientRefArg: SzPlatformClient) {
102
116
  this.platformClientRef = platformClientRefArg;
@@ -121,7 +135,7 @@ export class SzEmailConnector {
121
135
  }
122
136
 
123
137
  const typedRequest =
124
- this.platformClientRef.typedsocket.createTypedRequest<plugins.servezoneInterfaces.platform.email.IReq_SendEmail>(
138
+ this.platformClientRef.requireTypedSocket().createTypedRequest<plugins.servezoneInterfaces.platform.email.IReq_SendEmail>(
125
139
  'sendEmail'
126
140
  );
127
141
  const response = await typedRequest.fire(optionsArg);
@@ -231,6 +245,7 @@ export class SzEmailConnector {
231
245
  to: this.listToArray(optionsArg.to),
232
246
  cc: optionsArg.cc ? this.listToArray(optionsArg.cc) : undefined,
233
247
  bcc: optionsArg.bcc ? this.listToArray(optionsArg.bcc) : undefined,
248
+ ...(optionsArg.replyTo !== undefined ? { replyTo: optionsArg.replyTo } : {}),
234
249
  subject: optionsArg.subject,
235
250
  text: optionsArg.text,
236
251
  html: optionsArg.html,
@@ -243,7 +258,11 @@ export class SzEmailConnector {
243
258
  },
244
259
  });
245
260
  if (!result.accepted) {
246
- throw new Error(result.message || 'service mail was not accepted by dcrouter');
261
+ const message = result.message || 'service mail was not accepted by dcrouter';
262
+ if (result.errorCode) {
263
+ throw new ServiceMailSubmissionError(result.errorCode, message);
264
+ }
265
+ throw new Error(message);
247
266
  }
248
267
  return {
249
268
  messageId: result.spoolItemId,
@@ -283,15 +302,13 @@ export class SzEmailConnector {
283
302
  const credentials = await this.getServiceMailTypedCredentials(addressArg);
284
303
  const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
285
304
  const result = await this.registerServiceMailEndpoint(typedSocket, credentials);
286
- const registeredAddresses = result.addresses?.length ? result.addresses : [credentials.from];
287
- for (const address of registeredAddresses) {
288
- this.inboundEndpointRegistrations.add(address.trim().toLowerCase());
289
- }
305
+ this.inboundEndpointRegistrations.add(credentials.from);
290
306
  return result;
291
307
  }
292
308
 
293
309
  public async stop(): Promise<void> {
294
310
  await this.stopMailTypedSocket();
311
+ while (this.restorationOperations.size) await Promise.allSettled([...this.restorationOperations]);
295
312
  }
296
313
 
297
314
  public normalizeInboundMessage(payloadArg: unknown): IServiceMailInboundMessage {
@@ -341,7 +358,7 @@ export class SzEmailConnector {
341
358
  private ensureInboundTypedHandler(): void {
342
359
  if (this.inboundTypedHandlerRegistered) return;
343
360
  this.inboundTypedHandlerRegistered = true;
344
- this.platformClientRef.typedrouter.addTypedHandler(
361
+ this.mailRouter.addTypedHandler(
345
362
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeliverInboundMail>(
346
363
  'deliverInboundMail',
347
364
  async (dataArg) => {
@@ -425,6 +442,7 @@ export class SzEmailConnector {
425
442
  }
426
443
 
427
444
  private async getMailTypedSocket(urlArg: string): Promise<plugins.typedsocket.TypedSocket> {
445
+ this.platformClientRef.assertRunning();
428
446
  const normalizedUrl = this.normalizeTypedSocketUrl(urlArg);
429
447
  if (this.mailTypedSocket && this.mailTypedSocketUrl === normalizedUrl) {
430
448
  return this.mailTypedSocket;
@@ -435,20 +453,27 @@ export class SzEmailConnector {
435
453
  if (this.mailTypedSocket || this.mailTypedSocketPromise) {
436
454
  await this.stopMailTypedSocket();
437
455
  }
456
+ this.platformClientRef.assertRunning();
438
457
  this.mailTypedSocketUrl = normalizedUrl;
439
458
  const abortController = new AbortController();
440
459
  this.mailTypedSocketAbortController = abortController;
441
460
  const socketPromise = plugins.typedsocket.TypedSocket.createClient(
442
- this.platformClientRef.typedrouter,
461
+ this.mailRouter,
443
462
  normalizedUrl,
444
- { autoReconnect: true, abortSignal: abortController.signal },
463
+ { autoReconnect: true, abortSignal: abortController.signal,
464
+ restoreConnection: (context) => {
465
+ const operation = this.restoreMailConnection(context, normalizedUrl, abortController.signal);
466
+ this.restorationOperations.add(operation);
467
+ void operation.then(() => this.restorationOperations.delete(operation), () => this.restorationOperations.delete(operation));
468
+ return operation;
469
+ },
470
+ },
445
471
  );
446
472
  this.mailTypedSocketPromise = socketPromise;
447
473
  try {
448
474
  const socket = await socketPromise;
449
475
  if (this.mailTypedSocketPromise === socketPromise && this.mailTypedSocketUrl === normalizedUrl) {
450
476
  this.mailTypedSocket = socket;
451
- this.watchMailTypedSocketStatus(socket);
452
477
  } else {
453
478
  await socket.stop();
454
479
  }
@@ -467,148 +492,49 @@ export class SzEmailConnector {
467
492
  const socket = this.mailTypedSocket;
468
493
  const socketPromise = this.mailTypedSocketPromise;
469
494
  const abortController = this.mailTypedSocketAbortController;
470
- const statusSubscription = this.mailTypedSocketStatusSubscription;
471
495
  this.mailTypedSocket = undefined;
472
496
  this.mailTypedSocketPromise = undefined;
473
497
  this.mailTypedSocketUrl = undefined;
474
498
  this.mailTypedSocketAbortController = undefined;
475
- this.mailTypedSocketStatusSubscription = undefined;
476
- statusSubscription?.unsubscribe();
477
499
  abortController?.abort();
478
500
  if (socket) {
479
501
  await socket.stop();
480
502
  return;
481
503
  }
482
504
  if (socketPromise) {
483
- try {
484
- const pendingSocket = await socketPromise;
485
- await pendingSocket.stop();
486
- } catch {
487
- // The pending connection already failed; there is no socket to stop.
488
- }
489
- }
490
- }
491
-
492
- private watchMailTypedSocketStatus(socketArg: plugins.typedsocket.TypedSocket): void {
493
- this.mailTypedSocketStatusSubscription?.unsubscribe();
494
- this.mailTypedSocketStatusSubscription = socketArg.statusSubject.subscribe((statusArg) => {
495
- void this.handleMailTypedSocketStatus(socketArg, statusArg);
496
- });
497
- }
498
-
499
- private async handleMailTypedSocketStatus(
500
- socketArg: plugins.typedsocket.TypedSocket,
501
- statusArg: plugins.typedsocket.TConnectionStatus,
502
- ): Promise<void> {
503
- if (statusArg !== 'connected' || this.mailTypedSocket !== socketArg) {
504
- return;
505
- }
506
- try {
507
- await this.replayInboundEndpointRegistrationsWithRetry(socketArg);
508
- } catch (error) {
509
- console.warn('Could not re-register service mail endpoint after reconnect:', (error as Error).message);
510
- }
511
- }
512
-
513
- private async replayInboundEndpointRegistrationsWithRetry(
514
- socketArg: plugins.typedsocket.TypedSocket,
515
- ): Promise<void> {
516
- if (!this.inboundEndpointRegistrations.size) {
517
- return;
518
- }
519
- if (this.inboundEndpointReplayPromise) {
520
- return await this.inboundEndpointReplayPromise;
521
- }
522
-
523
- const replayPromise = this.replayInboundEndpointRegistrationsWithRetryInner(socketArg);
524
- this.inboundEndpointReplayPromise = replayPromise;
525
- try {
526
- await replayPromise;
527
- } finally {
528
- if (this.inboundEndpointReplayPromise === replayPromise) {
529
- this.inboundEndpointReplayPromise = undefined;
530
- }
505
+ const pendingSocket = await socketPromise.catch(() => undefined);
506
+ await pendingSocket?.stop();
531
507
  }
532
508
  }
533
509
 
534
- private async replayInboundEndpointRegistrationsWithRetryInner(
535
- socketArg: plugins.typedsocket.TypedSocket,
510
+ private async restoreMailConnection(
511
+ context: plugins.typedsocket.ITypedSocketConnectionRestoreContext,
512
+ url: string, signal: AbortSignal,
536
513
  ): Promise<void> {
537
- let lastError: unknown;
538
- const delays = [0, ...this.inboundEndpointReplayRetryDelaysMs];
539
- for (let attemptIndex = 0; attemptIndex < delays.length; attemptIndex++) {
540
- if (this.mailTypedSocket !== socketArg) {
541
- return;
542
- }
543
- if (attemptIndex > 0) {
544
- const shouldContinue = await this.waitForInboundEndpointReplayRetry(socketArg, delays[attemptIndex]);
545
- if (!shouldContinue) {
546
- return;
547
- }
548
- }
549
- try {
550
- await this.replayInboundEndpointRegistrationsOnce(socketArg);
551
- return;
552
- } catch (error) {
553
- lastError = error;
554
- }
555
- }
556
- throw lastError instanceof Error
557
- ? lastError
558
- : new Error('Could not re-register service mail endpoint after reconnect');
559
- }
560
-
561
- private async replayInboundEndpointRegistrationsOnce(
562
- socketArg: plugins.typedsocket.TypedSocket,
563
- ): Promise<void> {
564
- for (const address of this.inboundEndpointRegistrations.values()) {
565
- if (this.mailTypedSocket !== socketArg) {
566
- return;
567
- }
514
+ for (const address of this.inboundEndpointRegistrations) {
515
+ signal.throwIfAborted(); context.abortSignal.throwIfAborted();
568
516
  const credentials = await this.getServiceMailTypedCredentials(address);
569
- if (this.normalizeTypedSocketUrl(credentials.typedUrl) !== this.mailTypedSocketUrl) {
570
- continue;
571
- }
572
- await this.registerServiceMailEndpoint(socketArg, credentials);
517
+ if (credentials.typedUrl !== url) continue;
518
+ await this.registerServiceMailEndpoint({ createTypedRequest: context.createTypedRequest }, credentials, context.abortSignal);
519
+ signal.throwIfAborted(); context.abortSignal.throwIfAborted();
573
520
  }
574
521
  }
575
522
 
576
- private async waitForInboundEndpointReplayRetry(
577
- socketArg: plugins.typedsocket.TypedSocket,
578
- delayMsArg: number,
579
- ): Promise<boolean> {
580
- if (this.mailTypedSocket !== socketArg) {
581
- return false;
582
- }
583
- const abortSignal = this.mailTypedSocketAbortController?.signal;
584
- if (abortSignal?.aborted) {
585
- return false;
586
- }
587
- return await new Promise<boolean>((resolve) => {
588
- const resolveOnce = (resultArg: boolean) => {
589
- clearTimeout(timeout);
590
- abortSignal?.removeEventListener('abort', abortHandler);
591
- resolve(resultArg && this.mailTypedSocket === socketArg);
592
- };
593
- const abortHandler = () => resolveOnce(false);
594
- const timeout = setTimeout(() => resolveOnce(true), delayMsArg);
595
- abortSignal?.addEventListener('abort', abortHandler, { once: true });
596
- });
597
- }
598
-
599
523
  private async registerServiceMailEndpoint(
600
- typedSocketArg: plugins.typedsocket.TypedSocket,
524
+ typedSocketArg: Pick<plugins.typedsocket.TypedSocket, 'createTypedRequest'>,
601
525
  credentialsArg: IServiceMailTypedCredentials,
526
+ abortSignal?: AbortSignal,
602
527
  ): Promise<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response']> {
603
528
  const request = typedSocketArg.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint>('registerServiceMailEndpoint');
529
+ request.skipHooks = true;
604
530
  const result = await request.fire({
605
531
  auth: {
606
532
  credentialId: credentialsArg.credentialId,
607
533
  credentialSecret: credentialsArg.credentialSecret,
608
534
  },
609
535
  addresses: [credentialsArg.from],
610
- });
611
- if (!result.success) {
536
+ }, { maxRetries: 0, abortSignal });
537
+ if (!result.success || result.addresses?.length !== 1 || result.addresses[0] !== credentialsArg.from) {
612
538
  throw new Error(result.message || 'Could not register service mail endpoint');
613
539
  }
614
540
  return result;