@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/.smartconfig.json +4 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.platformclient.d.ts +16 -5
- package/dist_ts/classes.platformclient.js +118 -44
- package/dist_ts/email/classes.emailconnector.d.ts +10 -9
- package/dist_ts/email/classes.emailconnector.js +45 -111
- package/dist_ts/email/classes.letterconnector.js +2 -2
- package/dist_ts/email/classes.pushnotificationconnector.js +2 -2
- package/dist_ts/email/classes.smsconnector.js +3 -3
- package/dist_ts/webpush/classes.webpushconnector.js +6 -9
- package/package.json +10 -10
- package/readme.md +37 -8
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.platformclient.ts +118 -49
- package/ts/email/classes.emailconnector.ts +53 -127
- package/ts/email/classes.letterconnector.ts +1 -1
- package/ts/email/classes.pushnotificationconnector.ts +1 -1
- package/ts/email/classes.smsconnector.ts +2 -2
- package/ts/webpush/classes.webpushconnector.ts +5 -7
- /package/{license → license.md} +0 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ISzPlatformClientOptions {
|
|
|
15
15
|
token?: string;
|
|
16
16
|
url?: string;
|
|
17
17
|
platformUrl?: string;
|
|
18
|
+
/** Explicit HTTPS Cloudly origin. Bindings never select this authority. */
|
|
19
|
+
cloudlyUrl?: string;
|
|
18
20
|
binding?: plugins.servezoneInterfaces.platform.IPlatformBinding;
|
|
19
21
|
bindings?: plugins.servezoneInterfaces.platform.IPlatformBinding[];
|
|
20
22
|
coreMail?: ISzCoreMailConnectorOptions;
|
|
@@ -26,7 +28,13 @@ export class SzPlatformClient {
|
|
|
26
28
|
private connectionAddress?: string;
|
|
27
29
|
public platformBindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
|
|
28
30
|
public typedrouter: plugins.typedrequest.TypedRouter = new plugins.typedrequest.TypedRouter();
|
|
29
|
-
public typedsocket
|
|
31
|
+
public typedsocket?: plugins.typedsocket.TypedSocket;
|
|
32
|
+
private identity?: plugins.servezoneInterfaces.data.IIdentity;
|
|
33
|
+
private authenticationGeneration = 0;
|
|
34
|
+
private authenticationAbort?: AbortController;
|
|
35
|
+
private authenticationOperations = new Set<Promise<unknown>>();
|
|
36
|
+
private renewalTimer?: ReturnType<typeof setTimeout>;
|
|
37
|
+
private statusSubscription?: { unsubscribe(): void };
|
|
30
38
|
private qenvInstance = new plugins.qenv.Qenv();
|
|
31
39
|
private stopped = false;
|
|
32
40
|
private initPromise?: Promise<void>;
|
|
@@ -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
|
|
92
|
+
this.typedrouter,
|
|
82
93
|
await this.getConnectionAddress(),
|
|
83
|
-
{
|
|
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()
|
|
105
|
+
await socket.stop();
|
|
87
106
|
throw this.createAbortError();
|
|
88
107
|
}
|
|
89
108
|
this.typedsocket = socket;
|
|
90
109
|
try {
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
private getConnectionAddressFromBindings() {
|
|
156
|
-
for (const binding of this.platformBindings) {
|
|
157
|
-
if (binding.desiredState === 'disabled' || binding.status === 'failed') {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (!this.isSupportedConnectorCapability(binding.capability)) {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const endpoint = binding.endpoints?.find((endpointArg) => {
|
|
164
|
-
return endpointArg.protocol === 'typedrequest' || endpointArg.protocol === 'http';
|
|
165
|
-
});
|
|
166
|
-
const endpointUrl = endpoint?.internalUrl || endpoint?.externalUrl;
|
|
167
|
-
if (endpointUrl) {
|
|
168
|
-
return endpointUrl;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private isSupportedConnectorCapability(
|
|
174
|
-
capabilityArg: plugins.servezoneInterfaces.platform.TPlatformCapability
|
|
175
|
-
) {
|
|
176
|
-
return ['email', 'sms', 'pushnotification', 'letter'].includes(capabilityArg);
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
484
|
-
|
|
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
|
|
535
|
-
|
|
510
|
+
private async restoreMailConnection(
|
|
511
|
+
context: plugins.typedsocket.ITypedSocketConnectionRestoreContext,
|
|
512
|
+
url: string, signal: AbortSignal,
|
|
536
513
|
): Promise<void> {
|
|
537
|
-
|
|
538
|
-
|
|
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 (
|
|
570
|
-
|
|
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;
|