@serve.zone/platformclient 3.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +4 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.platformclient.d.ts +24 -5
- package/dist_ts/classes.platformclient.js +159 -44
- package/dist_ts/coremail/classes.coremailconnector.d.ts +66 -0
- package/dist_ts/coremail/classes.coremailconnector.js +184 -4
- package/dist_ts/email/classes.emailconnector.d.ts +89 -10
- package/dist_ts/email/classes.emailconnector.js +568 -110
- package/dist_ts/email/classes.letterconnector.js +2 -2
- package/dist_ts/email/classes.pushnotificationconnector.js +2 -2
- package/dist_ts/email/classes.smsconnector.js +3 -3
- package/dist_ts/webpush/classes.webpushconnector.js +6 -9
- package/package.json +13 -11
- package/readme.md +46 -9
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.platformclient.ts +158 -49
- package/ts/coremail/classes.coremailconnector.ts +215 -3
- package/ts/email/classes.emailconnector.ts +683 -127
- package/ts/email/classes.letterconnector.ts +1 -1
- package/ts/email/classes.pushnotificationconnector.ts +1 -1
- package/ts/email/classes.smsconnector.ts +2 -2
- package/ts/webpush/classes.webpushconnector.ts +5 -7
- /package/{license → license.md} +0 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { SzPlatformClient } from '../classes.platformclient.js';
|
|
2
|
+
import {
|
|
3
|
+
SzCoreMailConnector,
|
|
4
|
+
type ISzCoreMailInboundFetchResult,
|
|
5
|
+
type ISzCoreMailInboundPollOptions,
|
|
6
|
+
} from '../coremail/classes.coremailconnector.js';
|
|
2
7
|
import * as plugins from '../plugins.js';
|
|
3
8
|
|
|
4
9
|
import { logger } from '../logger.js';
|
|
@@ -23,6 +28,16 @@ export interface IServiceMailTypedCredentials {
|
|
|
23
28
|
addressToken?: string;
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
export interface IServiceMailCoreMailCredentials {
|
|
32
|
+
from: string;
|
|
33
|
+
endpointUrl: string;
|
|
34
|
+
bindingId: string;
|
|
35
|
+
credentialId: string;
|
|
36
|
+
credentialVersion: number;
|
|
37
|
+
credentialSecret: string;
|
|
38
|
+
addressToken?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
export interface IServiceMailStatus {
|
|
27
42
|
ready: boolean;
|
|
28
43
|
from?: string;
|
|
@@ -30,10 +45,12 @@ export interface IServiceMailStatus {
|
|
|
30
45
|
port?: number;
|
|
31
46
|
tlsMode?: TServiceMailTlsMode;
|
|
32
47
|
typedUrl?: string;
|
|
33
|
-
transport?: 'typedsocket' | 'smtp' | 'none';
|
|
48
|
+
transport?: 'coremail' | 'typedsocket' | 'smtp' | 'none';
|
|
34
49
|
usernameConfigured: boolean;
|
|
35
50
|
passwordConfigured: boolean;
|
|
36
51
|
typedCredentialConfigured?: boolean;
|
|
52
|
+
coreMailConfigured?: boolean;
|
|
53
|
+
coreMailEndpointUrl?: string;
|
|
37
54
|
smtpReady?: boolean;
|
|
38
55
|
missing: string[];
|
|
39
56
|
}
|
|
@@ -73,6 +90,84 @@ export class ServiceMailSubmissionError extends Error {
|
|
|
73
90
|
}
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
/**
|
|
94
|
+
* CoreMail error codes that @serve.zone/interfaces 30.13.0 also carries in
|
|
95
|
+
* TMailSubmissionErrorCode. These pass through by identity, so an application
|
|
96
|
+
* branching on ServiceMailSubmissionError.code sees the code CoreMail actually
|
|
97
|
+
* produced. Both entries of each pair are spelled out and the array is typed on
|
|
98
|
+
* both unions, so dropping a member from either side is a compile error here
|
|
99
|
+
* rather than a silent downgrade to the generic failure at runtime.
|
|
100
|
+
*/
|
|
101
|
+
const coreMailPassThroughErrorCodes: ReadonlyArray<
|
|
102
|
+
plugins.servezoneInterfaces.data.TCoreMailErrorCode
|
|
103
|
+
& plugins.servezoneInterfaces.data.TMailSubmissionErrorCode
|
|
104
|
+
> = [
|
|
105
|
+
'AUTHENTICATION_FAILED',
|
|
106
|
+
'CAPABILITY_DENIED',
|
|
107
|
+
'INVALID_REQUEST',
|
|
108
|
+
'INVALID_MAILBOX',
|
|
109
|
+
'INVALID_SENDER',
|
|
110
|
+
'INVALID_RECIPIENT',
|
|
111
|
+
'INVALID_HEADER',
|
|
112
|
+
'PAYLOAD_LIMIT_EXCEEDED',
|
|
113
|
+
'QUOTA_EXCEEDED',
|
|
114
|
+
'IDEMPOTENCY_CONFLICT',
|
|
115
|
+
'STATE_CONFLICT',
|
|
116
|
+
'NOT_FOUND',
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Deliberate departures from identity. Empty since interfaces 30.13.0: the
|
|
121
|
+
* only former entry was INVALID_HEADER -> REPLY_TO_FIELD_HEADER_CONFLICT, which
|
|
122
|
+
* existed solely because INVALID_HEADER had no member of its own. It now has
|
|
123
|
+
* one, and remapping would describe a generic invalid-header rejection as a
|
|
124
|
+
* reply-to conflict. Kept as the declared seam so that if the two unions ever
|
|
125
|
+
* diverge again the exception has a documented home instead of being open-coded.
|
|
126
|
+
*/
|
|
127
|
+
const coreMailRemappedErrorCodes: Partial<
|
|
128
|
+
Record<
|
|
129
|
+
plugins.servezoneInterfaces.data.TCoreMailErrorCode,
|
|
130
|
+
plugins.servezoneInterfaces.data.TMailSubmissionErrorCode
|
|
131
|
+
>
|
|
132
|
+
> = {};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolves one CoreMail code onto the app-facing union, or undefined when the
|
|
136
|
+
* code has no representation there (the transfer, delivery, gateway and
|
|
137
|
+
* reconciliation families). Those become a generic Error carrying the code.
|
|
138
|
+
*/
|
|
139
|
+
const resolveSubmissionErrorCode = (
|
|
140
|
+
codeArg: plugins.servezoneInterfaces.data.TCoreMailErrorCode,
|
|
141
|
+
): plugins.servezoneInterfaces.data.TMailSubmissionErrorCode | undefined => {
|
|
142
|
+
const remapped = coreMailRemappedErrorCodes[codeArg];
|
|
143
|
+
if (remapped) return remapped;
|
|
144
|
+
return coreMailPassThroughErrorCodes.find((candidateArg) => candidateArg === codeArg);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* TCoreMailSubmissionState -> TMailSpoolStatus.
|
|
149
|
+
* 'accepted' | 'queued' | 'delivering' | 'delivered' | 'deferred' | 'failed' |
|
|
150
|
+
* 'deadLettered' exist verbatim in both unions and pass through. CoreMail's
|
|
151
|
+
* three pre-acceptance states ('preparing' | 'uploading' | 'ready') have no
|
|
152
|
+
* spool counterpart and collapse onto 'queued'; the spool-only 'rejected' is
|
|
153
|
+
* never produced by CoreMail.
|
|
154
|
+
*/
|
|
155
|
+
const coreMailStateToSpoolStatus: Record<
|
|
156
|
+
plugins.servezoneInterfaces.data.TCoreMailSubmissionState,
|
|
157
|
+
plugins.servezoneInterfaces.data.TMailSpoolStatus
|
|
158
|
+
> = {
|
|
159
|
+
preparing: 'queued',
|
|
160
|
+
uploading: 'queued',
|
|
161
|
+
ready: 'queued',
|
|
162
|
+
accepted: 'accepted',
|
|
163
|
+
queued: 'queued',
|
|
164
|
+
delivering: 'delivering',
|
|
165
|
+
delivered: 'delivered',
|
|
166
|
+
deferred: 'deferred',
|
|
167
|
+
failed: 'failed',
|
|
168
|
+
deadLettered: 'deadLettered',
|
|
169
|
+
};
|
|
170
|
+
|
|
76
171
|
export interface IServiceMailSendResult {
|
|
77
172
|
messageId?: string;
|
|
78
173
|
spoolItemId?: string;
|
|
@@ -106,12 +201,18 @@ export class SzEmailConnector {
|
|
|
106
201
|
private mailTypedSocketUrl?: string;
|
|
107
202
|
private mailTypedSocketPromise?: Promise<plugins.typedsocket.TypedSocket>;
|
|
108
203
|
private mailTypedSocketAbortController?: AbortController;
|
|
109
|
-
private
|
|
204
|
+
private readonly mailRouter = new plugins.typedrequest.TypedRouter();
|
|
205
|
+
private readonly restorationOperations = new Set<Promise<void>>();
|
|
110
206
|
private inboundHandler?: TServiceMailInboundHandler;
|
|
111
207
|
private inboundTypedHandlerRegistered = false;
|
|
112
208
|
private inboundEndpointRegistrations = new Set<string>();
|
|
113
|
-
private
|
|
114
|
-
|
|
209
|
+
private coreMailInboundPollOptions: ISzCoreMailInboundPollOptions = {};
|
|
210
|
+
/**
|
|
211
|
+
* Connectors this email connector created and therefore owns, keyed by the
|
|
212
|
+
* value-free binding identity. The client's shared connector is never in here
|
|
213
|
+
* — it is owned by SzPlatformClient and stopped there.
|
|
214
|
+
*/
|
|
215
|
+
private ownedCoreMailConnectors = new Map<string, SzCoreMailConnector>();
|
|
115
216
|
|
|
116
217
|
constructor(platformClientRefArg: SzPlatformClient) {
|
|
117
218
|
this.platformClientRef = platformClientRefArg;
|
|
@@ -136,7 +237,7 @@ export class SzEmailConnector {
|
|
|
136
237
|
}
|
|
137
238
|
|
|
138
239
|
const typedRequest =
|
|
139
|
-
this.platformClientRef.
|
|
240
|
+
this.platformClientRef.requireTypedSocket().createTypedRequest<plugins.servezoneInterfaces.platform.email.IReq_SendEmail>(
|
|
140
241
|
'sendEmail'
|
|
141
242
|
);
|
|
142
243
|
const response = await typedRequest.fire(optionsArg);
|
|
@@ -169,6 +270,37 @@ export class SzEmailConnector {
|
|
|
169
270
|
const fromMissing = env.from ? [] : [missingPrefix ? `MAIL_${missingPrefix}_FROM` : 'MAIL_FROM'];
|
|
170
271
|
const typedReady = fromMissing.length === 0 && typedMissing.length === 0;
|
|
171
272
|
const smtpReady = fromMissing.length === 0 && smtpMissing.length === 0;
|
|
273
|
+
|
|
274
|
+
// CoreMail outranks the dcrouter-direct transports when its group resolves.
|
|
275
|
+
const coreMailToken = useDefaultEnv ? undefined : token;
|
|
276
|
+
const scopedCoreMail = coreMailToken ? await this.readCoreMailEnv(coreMailToken) : undefined;
|
|
277
|
+
const hasScopedCoreMail = Boolean(scopedCoreMail && Object.values(scopedCoreMail).some(Boolean));
|
|
278
|
+
const coreMailEnv = hasScopedCoreMail ? scopedCoreMail! : await this.readCoreMailEnv();
|
|
279
|
+
const coreMailPresent = Object.values(coreMailEnv).some(Boolean);
|
|
280
|
+
const coreMailMissing = coreMailPresent
|
|
281
|
+
? this.listMissingCoreMailEnvNames(coreMailEnv, hasScopedCoreMail ? coreMailToken : undefined)
|
|
282
|
+
: [];
|
|
283
|
+
const coreMailReady = coreMailPresent
|
|
284
|
+
&& coreMailMissing.length === 0
|
|
285
|
+
&& fromMissing.length === 0;
|
|
286
|
+
if (coreMailPresent) {
|
|
287
|
+
return {
|
|
288
|
+
ready: coreMailReady,
|
|
289
|
+
from: env.from,
|
|
290
|
+
host: env.smtpHost,
|
|
291
|
+
port,
|
|
292
|
+
tlsMode,
|
|
293
|
+
typedUrl: env.typedUrl,
|
|
294
|
+
transport: coreMailReady ? 'coremail' : smtpReady ? 'smtp' : 'none',
|
|
295
|
+
usernameConfigured: Boolean(env.smtpUsername),
|
|
296
|
+
passwordConfigured: Boolean(env.smtpPassword),
|
|
297
|
+
typedCredentialConfigured: Boolean(env.apiCredentialId && env.apiCredentialSecret),
|
|
298
|
+
coreMailConfigured: coreMailReady,
|
|
299
|
+
coreMailEndpointUrl: coreMailEnv.url,
|
|
300
|
+
smtpReady,
|
|
301
|
+
missing: coreMailReady ? [] : [...fromMissing, ...coreMailMissing],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
172
304
|
return {
|
|
173
305
|
ready: typedReady,
|
|
174
306
|
from: env.from,
|
|
@@ -231,6 +363,11 @@ export class SzEmailConnector {
|
|
|
231
363
|
return { messageId: 'debug' };
|
|
232
364
|
}
|
|
233
365
|
|
|
366
|
+
const coreMailCredentials = await this.getCoreMailCredentials(optionsArg.from);
|
|
367
|
+
if (coreMailCredentials) {
|
|
368
|
+
return await this.sendMailViaCoreMail(optionsArg, coreMailCredentials);
|
|
369
|
+
}
|
|
370
|
+
|
|
234
371
|
const credentials = await this.getServiceMailTypedCredentials(optionsArg.from);
|
|
235
372
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
236
373
|
const request = typedSocket.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail>('enqueueMail');
|
|
@@ -274,6 +411,212 @@ export class SzEmailConnector {
|
|
|
274
411
|
};
|
|
275
412
|
}
|
|
276
413
|
|
|
414
|
+
private async createCoreMailSha256(
|
|
415
|
+
bytesArg: Uint8Array,
|
|
416
|
+
): Promise<plugins.servezoneInterfaces.data.TCoreMailSha256> {
|
|
417
|
+
const source = new Uint8Array(bytesArg.byteLength);
|
|
418
|
+
source.set(bytesArg);
|
|
419
|
+
const digest = new Uint8Array(
|
|
420
|
+
await globalThis.crypto.subtle.digest('SHA-256', source.buffer),
|
|
421
|
+
);
|
|
422
|
+
return `sha256:${Array.from(digest)
|
|
423
|
+
.map((byteArg) => byteArg.toString(16).padStart(2, '0'))
|
|
424
|
+
.join('')}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private toCoreMailMailbox(
|
|
428
|
+
addressArg: string,
|
|
429
|
+
): plugins.servezoneInterfaces.data.ICoreMailMailbox {
|
|
430
|
+
const address = String(addressArg || '').trim();
|
|
431
|
+
if (!address) throw new Error('CoreMail recipients must be non-empty addresses');
|
|
432
|
+
return { address };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Bodies and attachments become uploadable parts. CoreMail owns the MIME
|
|
437
|
+
* framing, so text/html/attachments are separate descriptors rather than one
|
|
438
|
+
* serialized message, and every descriptor carries a client-computed digest.
|
|
439
|
+
*/
|
|
440
|
+
private async buildCoreMailParts(optionsArg: IServiceMailSendOptions): Promise<Array<{
|
|
441
|
+
descriptor: plugins.servezoneInterfaces.data.ICoreMailContentDescriptor;
|
|
442
|
+
bytes: Uint8Array;
|
|
443
|
+
}>> {
|
|
444
|
+
const limits = plugins.servezoneInterfaces.data.coreMailLimits;
|
|
445
|
+
const encoder = new TextEncoder();
|
|
446
|
+
const parts: Array<{
|
|
447
|
+
descriptor: plugins.servezoneInterfaces.data.ICoreMailContentDescriptor;
|
|
448
|
+
bytes: Uint8Array;
|
|
449
|
+
}> = [];
|
|
450
|
+
|
|
451
|
+
const pushPart = async (
|
|
452
|
+
partIdArg: string,
|
|
453
|
+
kindArg: plugins.servezoneInterfaces.data.TCoreMailPartKind,
|
|
454
|
+
contentTypeArg: string,
|
|
455
|
+
bytesArg: Uint8Array,
|
|
456
|
+
filenameArg?: string,
|
|
457
|
+
) => {
|
|
458
|
+
parts.push({
|
|
459
|
+
descriptor: {
|
|
460
|
+
partId: partIdArg,
|
|
461
|
+
kind: kindArg,
|
|
462
|
+
contentType: contentTypeArg,
|
|
463
|
+
...(filenameArg ? { filename: filenameArg } : {}),
|
|
464
|
+
sha256: await this.createCoreMailSha256(bytesArg),
|
|
465
|
+
lengthBytes: bytesArg.byteLength,
|
|
466
|
+
},
|
|
467
|
+
bytes: bytesArg,
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
if (optionsArg.text) {
|
|
472
|
+
const bytes = encoder.encode(optionsArg.text);
|
|
473
|
+
if (bytes.byteLength > limits.textPartBytes) {
|
|
474
|
+
throw new Error(`CoreMail text part exceeds ${limits.textPartBytes} bytes`);
|
|
475
|
+
}
|
|
476
|
+
await pushPart('text', 'text', 'text/plain; charset=utf-8', bytes);
|
|
477
|
+
}
|
|
478
|
+
if (optionsArg.html) {
|
|
479
|
+
const bytes = encoder.encode(optionsArg.html);
|
|
480
|
+
if (bytes.byteLength > limits.htmlPartBytes) {
|
|
481
|
+
throw new Error(`CoreMail html part exceeds ${limits.htmlPartBytes} bytes`);
|
|
482
|
+
}
|
|
483
|
+
await pushPart('html', 'html', 'text/html; charset=utf-8', bytes);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const attachments = optionsArg.attachments || [];
|
|
487
|
+
if (attachments.length > limits.attachmentCount) {
|
|
488
|
+
throw new Error(`CoreMail accepts at most ${limits.attachmentCount} attachments`);
|
|
489
|
+
}
|
|
490
|
+
let aggregateBytes = 0;
|
|
491
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
492
|
+
const bytes = typeof attachment.content === 'string'
|
|
493
|
+
? encoder.encode(attachment.content)
|
|
494
|
+
: attachment.content;
|
|
495
|
+
if (bytes.byteLength > limits.attachmentBytes) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`CoreMail attachment ${attachment.filename} exceeds ${limits.attachmentBytes} bytes`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
aggregateBytes += bytes.byteLength;
|
|
501
|
+
if (aggregateBytes > limits.aggregateAttachmentBytes) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`CoreMail attachments exceed ${limits.aggregateAttachmentBytes} aggregate bytes`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
await pushPart(
|
|
507
|
+
`attachment-${index}`,
|
|
508
|
+
'attachment',
|
|
509
|
+
attachment.contentType || 'application/octet-stream',
|
|
510
|
+
bytes,
|
|
511
|
+
attachment.filename,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (parts.length === 0) {
|
|
515
|
+
throw new Error('Either text or html is required for service mail');
|
|
516
|
+
}
|
|
517
|
+
return parts;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private buildCoreMailDescriptor(
|
|
521
|
+
optionsArg: IServiceMailSendOptions,
|
|
522
|
+
credentialsArg: IServiceMailCoreMailCredentials,
|
|
523
|
+
partsArg: plugins.servezoneInterfaces.data.ICoreMailContentDescriptor[],
|
|
524
|
+
): plugins.servezoneInterfaces.data.ICoreMailOutboundMessageDescriptor {
|
|
525
|
+
const limits = plugins.servezoneInterfaces.data.coreMailLimits;
|
|
526
|
+
const to = this.listToArray(optionsArg.to).map((addressArg) => this.toCoreMailMailbox(addressArg));
|
|
527
|
+
const cc = optionsArg.cc
|
|
528
|
+
? this.listToArray(optionsArg.cc).map((addressArg) => this.toCoreMailMailbox(addressArg))
|
|
529
|
+
: undefined;
|
|
530
|
+
const bcc = optionsArg.bcc
|
|
531
|
+
? this.listToArray(optionsArg.bcc).map((addressArg) => this.toCoreMailMailbox(addressArg))
|
|
532
|
+
: undefined;
|
|
533
|
+
const recipientCount = to.length + (cc?.length || 0) + (bcc?.length || 0);
|
|
534
|
+
if (to.length === 0) throw new Error('CoreMail requires at least one To recipient');
|
|
535
|
+
if (recipientCount > limits.recipientCount) {
|
|
536
|
+
throw new Error(`CoreMail accepts at most ${limits.recipientCount} recipients`);
|
|
537
|
+
}
|
|
538
|
+
// Ordered header list; CoreMail rejects transport-owned MIME headers itself.
|
|
539
|
+
const headers = optionsArg.headers
|
|
540
|
+
? Object.entries(optionsArg.headers).map(([nameArg, valueArg]) => ({
|
|
541
|
+
name: nameArg,
|
|
542
|
+
value: String(valueArg),
|
|
543
|
+
}))
|
|
544
|
+
: undefined;
|
|
545
|
+
return {
|
|
546
|
+
sender: this.toCoreMailMailbox(credentialsArg.from),
|
|
547
|
+
recipients: { to, ...(cc ? { cc } : {}), ...(bcc ? { bcc } : {}) },
|
|
548
|
+
...(optionsArg.replyTo !== undefined
|
|
549
|
+
? { replyTo: this.toCoreMailMailbox(optionsArg.replyTo) }
|
|
550
|
+
: {}),
|
|
551
|
+
subject: optionsArg.subject,
|
|
552
|
+
...(headers ? { headers } : {}),
|
|
553
|
+
parts: partsArg,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Re-throws a CoreMail failure on the app-facing error surface. */
|
|
558
|
+
private translateCoreMailError(errorArg: unknown): never {
|
|
559
|
+
const data = (errorArg as { data?: unknown })?.data as
|
|
560
|
+
| plugins.servezoneInterfaces.data.ICoreMailErrorData
|
|
561
|
+
| undefined;
|
|
562
|
+
const code = data?.code;
|
|
563
|
+
const message = (errorArg as Error)?.message || 'CoreMail rejected the submission';
|
|
564
|
+
if (code) {
|
|
565
|
+
const mapped = resolveSubmissionErrorCode(code);
|
|
566
|
+
if (mapped) throw new ServiceMailSubmissionError(mapped, message);
|
|
567
|
+
throw new Error(`CoreMail ${code}: ${message}`);
|
|
568
|
+
}
|
|
569
|
+
throw errorArg instanceof Error ? errorArg : new Error(message);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async sendMailViaCoreMail(
|
|
573
|
+
optionsArg: IServiceMailSendOptions,
|
|
574
|
+
credentialsArg: IServiceMailCoreMailCredentials,
|
|
575
|
+
): Promise<IServiceMailSendResult> {
|
|
576
|
+
const connector = this.getConfiguredCoreMailConnector(credentialsArg);
|
|
577
|
+
const parts = await this.buildCoreMailParts(optionsArg);
|
|
578
|
+
const descriptor = this.buildCoreMailDescriptor(
|
|
579
|
+
optionsArg,
|
|
580
|
+
credentialsArg,
|
|
581
|
+
parts.map((partArg) => partArg.descriptor),
|
|
582
|
+
);
|
|
583
|
+
try {
|
|
584
|
+
const prepared = await connector.prepareOutboundSubmission({
|
|
585
|
+
// CoreMail requires a replay key; IServiceMailSendOptions leaves it
|
|
586
|
+
// optional because the dcrouter path treats an omitted key as "no
|
|
587
|
+
// replay protection". A fresh random key per call reproduces exactly
|
|
588
|
+
// that semantic without weakening a key the caller did supply.
|
|
589
|
+
idempotencyKey: optionsArg.idempotencyKey || globalThis.crypto.randomUUID(),
|
|
590
|
+
message: descriptor,
|
|
591
|
+
});
|
|
592
|
+
let submission = prepared.submission;
|
|
593
|
+
if (!prepared.replayed) {
|
|
594
|
+
for (const part of parts) {
|
|
595
|
+
submission = await connector.uploadOutboundPart({
|
|
596
|
+
submissionId: submission.submissionId,
|
|
597
|
+
part: part.descriptor,
|
|
598
|
+
bytes: part.bytes,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
submission = await connector.finalizeOutboundSubmission(submission.submissionId);
|
|
602
|
+
}
|
|
603
|
+
const recipients = [
|
|
604
|
+
...this.listToArray(optionsArg.to),
|
|
605
|
+
...(optionsArg.cc ? this.listToArray(optionsArg.cc) : []),
|
|
606
|
+
...(optionsArg.bcc ? this.listToArray(optionsArg.bcc) : []),
|
|
607
|
+
];
|
|
608
|
+
return {
|
|
609
|
+
messageId: submission.submissionId,
|
|
610
|
+
spoolItemId: submission.submissionId,
|
|
611
|
+
accepted: recipients,
|
|
612
|
+
rejected: [],
|
|
613
|
+
response: submission.state,
|
|
614
|
+
};
|
|
615
|
+
} catch (error) {
|
|
616
|
+
this.translateCoreMailError(error);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
277
620
|
public async getMailDeliveryStatus(
|
|
278
621
|
spoolItemIdArg: string,
|
|
279
622
|
addressArg?: string,
|
|
@@ -282,6 +625,17 @@ export class SzEmailConnector {
|
|
|
282
625
|
if (!spoolItemId) {
|
|
283
626
|
throw new Error('spoolItemId is required');
|
|
284
627
|
}
|
|
628
|
+
const coreMailCredentials = await this.getCoreMailCredentials(addressArg);
|
|
629
|
+
if (coreMailCredentials) {
|
|
630
|
+
const connector = this.getConfiguredCoreMailConnector(coreMailCredentials);
|
|
631
|
+
let submission: plugins.servezoneInterfaces.data.ICoreMailSubmission;
|
|
632
|
+
try {
|
|
633
|
+
submission = await connector.getOutboundSubmission(spoolItemId);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
this.translateCoreMailError(error);
|
|
636
|
+
}
|
|
637
|
+
return this.toMailDeliveryStatus(submission, coreMailCredentials);
|
|
638
|
+
}
|
|
285
639
|
const credentials = await this.getServiceMailTypedCredentials(addressArg);
|
|
286
640
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
287
641
|
const request = typedSocket.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus>('getMailDeliveryStatus');
|
|
@@ -294,24 +648,161 @@ export class SzEmailConnector {
|
|
|
294
648
|
});
|
|
295
649
|
}
|
|
296
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Projects one CoreMail submission onto the dcrouter delivery-status shape.
|
|
653
|
+
* CoreMail keeps no per-attempt journal, so `attempts` carries at most one
|
|
654
|
+
* synthesized entry describing the current state, and `bounceEvents` stays
|
|
655
|
+
* empty; `spoolItem.attempts` is CoreMail's own attempt counter.
|
|
656
|
+
*/
|
|
657
|
+
private toMailDeliveryStatus(
|
|
658
|
+
submissionArg: plugins.servezoneInterfaces.data.ICoreMailSubmission,
|
|
659
|
+
credentialsArg: IServiceMailCoreMailCredentials,
|
|
660
|
+
): plugins.servezoneInterfaces.data.IMailDeliveryStatus {
|
|
661
|
+
const status = coreMailStateToSpoolStatus[submissionArg.state];
|
|
662
|
+
// CoreMail fronts dcrouter, which performs the actual MTA relay; there is
|
|
663
|
+
// no 'coremail' member in TMailDeliveryTargetType.
|
|
664
|
+
const target: plugins.servezoneInterfaces.data.IMailDeliveryTargetSummary = {
|
|
665
|
+
type: 'mtaRelay',
|
|
666
|
+
label: 'coremail',
|
|
667
|
+
url: credentialsArg.endpointUrl,
|
|
668
|
+
};
|
|
669
|
+
return {
|
|
670
|
+
spoolItem: {
|
|
671
|
+
id: submissionArg.submissionId,
|
|
672
|
+
direction: 'outbound',
|
|
673
|
+
status,
|
|
674
|
+
envelope: { mailFrom: credentialsArg.from, rcptTo: [] },
|
|
675
|
+
messageId: submissionArg.transportMessageId,
|
|
676
|
+
target,
|
|
677
|
+
attempts: submissionArg.attempts,
|
|
678
|
+
nextAttemptAt: submissionArg.nextAttemptAt,
|
|
679
|
+
lastError: submissionArg.error
|
|
680
|
+
? `CoreMail ${submissionArg.error.code}`
|
|
681
|
+
: undefined,
|
|
682
|
+
createdAt: submissionArg.createdAt,
|
|
683
|
+
updatedAt: submissionArg.updatedAt,
|
|
684
|
+
},
|
|
685
|
+
attempts: submissionArg.attempts > 0
|
|
686
|
+
? [{
|
|
687
|
+
id: `${submissionArg.submissionId}:${submissionArg.attempts}`,
|
|
688
|
+
spoolItemId: submissionArg.submissionId,
|
|
689
|
+
target,
|
|
690
|
+
status,
|
|
691
|
+
startedAt: submissionArg.acceptedAt || submissionArg.createdAt,
|
|
692
|
+
completedAt: submissionArg.deliveredAt || submissionArg.terminalAt,
|
|
693
|
+
response: submissionArg.error ? submissionArg.error.code : undefined,
|
|
694
|
+
}]
|
|
695
|
+
: [],
|
|
696
|
+
bounceEvents: [],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
297
700
|
public async registerInboundHandler(
|
|
298
701
|
handlerArg: TServiceMailInboundHandler,
|
|
299
702
|
addressArg?: string,
|
|
300
703
|
): Promise<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response']> {
|
|
301
704
|
this.inboundHandler = handlerArg;
|
|
705
|
+
const coreMailCredentials = await this.getCoreMailCredentials(addressArg);
|
|
706
|
+
if (coreMailCredentials) {
|
|
707
|
+
const connector = this.getConfiguredCoreMailConnector(coreMailCredentials);
|
|
708
|
+
connector.startInboundPolling(async (fetchedArg) => {
|
|
709
|
+
const message = this.normalizeInboundMessage(
|
|
710
|
+
this.toInboundDeliveryPayload(fetchedArg),
|
|
711
|
+
);
|
|
712
|
+
const result = await handlerArg(message);
|
|
713
|
+
// A handler that declines must not acknowledge the delivery as processed.
|
|
714
|
+
if (result && result.accepted === false) {
|
|
715
|
+
throw new Error(result.message || 'Service mail inbound handler declined the delivery');
|
|
716
|
+
}
|
|
717
|
+
}, this.coreMailInboundPollOptions);
|
|
718
|
+
this.inboundEndpointRegistrations.add(coreMailCredentials.from);
|
|
719
|
+
return { success: true, addresses: [coreMailCredentials.from] };
|
|
720
|
+
}
|
|
302
721
|
this.ensureInboundTypedHandler();
|
|
303
722
|
const credentials = await this.getServiceMailTypedCredentials(addressArg);
|
|
304
723
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
305
724
|
const result = await this.registerServiceMailEndpoint(typedSocket, credentials);
|
|
306
|
-
|
|
307
|
-
for (const address of registeredAddresses) {
|
|
308
|
-
this.inboundEndpointRegistrations.add(address.trim().toLowerCase());
|
|
309
|
-
}
|
|
725
|
+
this.inboundEndpointRegistrations.add(credentials.from);
|
|
310
726
|
return result;
|
|
311
727
|
}
|
|
312
728
|
|
|
313
729
|
public async stop(): Promise<void> {
|
|
730
|
+
// The mail socket this connector owns is released first; the CoreMail pull
|
|
731
|
+
// loop is released afterwards so stopping order stays observable to callers.
|
|
314
732
|
await this.stopMailTypedSocket();
|
|
733
|
+
// The shared connector belongs to SzPlatformClient, so only its pull loop is
|
|
734
|
+
// released here; connectors this instance created are stopped outright.
|
|
735
|
+
await this.platformClientRef.coreMailConnector.stopInboundPolling();
|
|
736
|
+
const ownedConnectors = [...this.ownedCoreMailConnectors.values()];
|
|
737
|
+
this.ownedCoreMailConnectors.clear();
|
|
738
|
+
await Promise.allSettled(ownedConnectors.map((connectorArg) => connectorArg.stop()));
|
|
739
|
+
while (this.restorationOperations.size) await Promise.allSettled([...this.restorationOperations]);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Tunes the CoreMail pull loop before registerInboundHandler starts it.
|
|
744
|
+
* CoreMail never pushes, so cadence is a client-side choice.
|
|
745
|
+
*/
|
|
746
|
+
public setCoreMailInboundPollOptions(
|
|
747
|
+
optionsArg: ISzCoreMailInboundPollOptions,
|
|
748
|
+
): void {
|
|
749
|
+
this.coreMailInboundPollOptions = { ...optionsArg };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Projects one fetched CoreMail delivery onto the same payload shape dcrouter
|
|
754
|
+
* sends to deliverInboundMail, so both transports reach the app handler
|
|
755
|
+
* through normalizeInboundMessage unchanged.
|
|
756
|
+
*/
|
|
757
|
+
private toInboundDeliveryPayload(
|
|
758
|
+
fetchedArg: ISzCoreMailInboundFetchResult,
|
|
759
|
+
): plugins.servezoneInterfaces.data.IMailInboundMessagePayload {
|
|
760
|
+
const rawMessage = new TextDecoder().decode(fetchedArg.bytes);
|
|
761
|
+
return {
|
|
762
|
+
spoolItemId: fetchedArg.delivery.deliveryId,
|
|
763
|
+
envelope: {
|
|
764
|
+
mailFrom: fetchedArg.delivery.envelope.mailFrom,
|
|
765
|
+
rcptTo: fetchedArg.delivery.envelope.rcptTo,
|
|
766
|
+
},
|
|
767
|
+
rawMessage,
|
|
768
|
+
sizeBytes: fetchedArg.delivery.rawMime.lengthBytes,
|
|
769
|
+
messageId: fetchedArg.delivery.messageId,
|
|
770
|
+
subject: fetchedArg.delivery.subject,
|
|
771
|
+
headers: this.extractInboundHeaders(rawMessage),
|
|
772
|
+
receivedAt: fetchedArg.delivery.receivedAt,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* RFC 5322 header-block extraction over the fetched MIME. Mirrors the
|
|
778
|
+
* projection dcrouter performs before deliverInboundMail so both transports
|
|
779
|
+
* hand the app the same `headers` record: unfold continuation lines, repeat
|
|
780
|
+
* names collapse into an array. Bodies are not parsed — the inbound contract
|
|
781
|
+
* carries `rawMessage`, not decoded text/html/attachments.
|
|
782
|
+
*/
|
|
783
|
+
private extractInboundHeaders(rawMessageArg: string): Record<string, string | string[]> {
|
|
784
|
+
const headers: Record<string, string | string[]> = {};
|
|
785
|
+
const headerText = rawMessageArg.split(/\r?\n\r?\n/, 1)[0] || '';
|
|
786
|
+
let currentHeader = '';
|
|
787
|
+
for (const line of headerText.split(/\r?\n/)) {
|
|
788
|
+
if (/^[\t ]/.test(line) && currentHeader) {
|
|
789
|
+
const existing = headers[currentHeader];
|
|
790
|
+
const base = Array.isArray(existing) ? existing[existing.length - 1] : existing;
|
|
791
|
+
const folded = `${base || ''} ${line.trim()}`;
|
|
792
|
+
if (Array.isArray(existing)) existing[existing.length - 1] = folded;
|
|
793
|
+
else headers[currentHeader] = folded;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const separatorIndex = line.indexOf(':');
|
|
797
|
+
if (separatorIndex < 1) continue;
|
|
798
|
+
currentHeader = line.slice(0, separatorIndex).trim();
|
|
799
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
800
|
+
const existing = headers[currentHeader];
|
|
801
|
+
if (Array.isArray(existing)) existing.push(value);
|
|
802
|
+
else if (existing !== undefined) headers[currentHeader] = [String(existing), value];
|
|
803
|
+
else headers[currentHeader] = value;
|
|
804
|
+
}
|
|
805
|
+
return headers;
|
|
315
806
|
}
|
|
316
807
|
|
|
317
808
|
public normalizeInboundMessage(payloadArg: unknown): IServiceMailInboundMessage {
|
|
@@ -361,7 +852,7 @@ export class SzEmailConnector {
|
|
|
361
852
|
private ensureInboundTypedHandler(): void {
|
|
362
853
|
if (this.inboundTypedHandlerRegistered) return;
|
|
363
854
|
this.inboundTypedHandlerRegistered = true;
|
|
364
|
-
this.
|
|
855
|
+
this.mailRouter.addTypedHandler(
|
|
365
856
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeliverInboundMail>(
|
|
366
857
|
'deliverInboundMail',
|
|
367
858
|
async (dataArg) => {
|
|
@@ -378,6 +869,162 @@ export class SzEmailConnector {
|
|
|
378
869
|
|
|
379
870
|
public async sendActionLink(optionsArg: { toArg: string; subject: string; text: string }) {}
|
|
380
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Env names for one CoreMail binding. Unscoped names use the MAIL_COREMAIL_
|
|
874
|
+
* prefix; per-address names insert the same token readMailEnv uses.
|
|
875
|
+
*/
|
|
876
|
+
private getCoreMailEnvNames(tokenArg?: string): Record<
|
|
877
|
+
'url' | 'bindingId' | 'credentialId' | 'credentialVersion' | 'credentialSecret',
|
|
878
|
+
string
|
|
879
|
+
> {
|
|
880
|
+
const prefix = tokenArg ? `MAIL_${tokenArg}_COREMAIL_` : 'MAIL_COREMAIL_';
|
|
881
|
+
return {
|
|
882
|
+
url: `${prefix}URL`,
|
|
883
|
+
bindingId: `${prefix}BINDING_ID`,
|
|
884
|
+
credentialId: `${prefix}CREDENTIAL_ID`,
|
|
885
|
+
credentialVersion: `${prefix}CREDENTIAL_VERSION`,
|
|
886
|
+
credentialSecret: `${prefix}CREDENTIAL_SECRET`,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private async readCoreMailEnv(tokenArg?: string): Promise<{
|
|
891
|
+
url?: string;
|
|
892
|
+
bindingId?: string;
|
|
893
|
+
credentialId?: string;
|
|
894
|
+
credentialVersion?: string;
|
|
895
|
+
credentialSecret?: string;
|
|
896
|
+
}> {
|
|
897
|
+
const names = this.getCoreMailEnvNames(tokenArg);
|
|
898
|
+
return {
|
|
899
|
+
url: await this.readMailEnvVar(names.url),
|
|
900
|
+
bindingId: await this.readMailEnvVar(names.bindingId),
|
|
901
|
+
credentialId: await this.readMailEnvVar(names.credentialId),
|
|
902
|
+
credentialVersion: await this.readMailEnvVar(names.credentialVersion),
|
|
903
|
+
credentialSecret: await this.readMailEnvVar(names.credentialSecret),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private listMissingCoreMailEnvNames(
|
|
908
|
+
envArg: { url?: string; bindingId?: string; credentialId?: string; credentialVersion?: string; credentialSecret?: string },
|
|
909
|
+
tokenArg?: string,
|
|
910
|
+
): string[] {
|
|
911
|
+
const names = this.getCoreMailEnvNames(tokenArg);
|
|
912
|
+
const missing: string[] = [];
|
|
913
|
+
if (!envArg.url) missing.push(names.url);
|
|
914
|
+
if (!envArg.bindingId) missing.push(names.bindingId);
|
|
915
|
+
if (!envArg.credentialId) missing.push(names.credentialId);
|
|
916
|
+
if (!envArg.credentialVersion) missing.push(names.credentialVersion);
|
|
917
|
+
if (!envArg.credentialSecret) missing.push(names.credentialSecret);
|
|
918
|
+
return missing;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Resolves the CoreMail group for one address. Scoped values win wholesale,
|
|
923
|
+
* exactly like readMailEnv: if any MAIL_<TOKEN>_COREMAIL_* value is present
|
|
924
|
+
* the scoped group is used on its own and the unscoped group is not consulted.
|
|
925
|
+
* Returns undefined when no CoreMail value is configured at all; a partially
|
|
926
|
+
* configured group throws instead of silently falling back to dcrouter.
|
|
927
|
+
*/
|
|
928
|
+
private async getCoreMailCredentials(
|
|
929
|
+
addressArg?: string,
|
|
930
|
+
): Promise<IServiceMailCoreMailCredentials | undefined> {
|
|
931
|
+
const requestedAddress = addressArg?.trim().toLowerCase();
|
|
932
|
+
const requestedToken = requestedAddress ? this.getMailEnvToken(requestedAddress) : undefined;
|
|
933
|
+
const scopedCoreMail = requestedToken ? await this.readCoreMailEnv(requestedToken) : undefined;
|
|
934
|
+
const hasScopedCoreMail = Boolean(scopedCoreMail && Object.values(scopedCoreMail).some(Boolean));
|
|
935
|
+
const coreMailEnv = hasScopedCoreMail ? scopedCoreMail! : await this.readCoreMailEnv();
|
|
936
|
+
if (!Object.values(coreMailEnv).some(Boolean)) return undefined;
|
|
937
|
+
|
|
938
|
+
const token = hasScopedCoreMail ? requestedToken : undefined;
|
|
939
|
+
const missing = this.listMissingCoreMailEnvNames(coreMailEnv, token);
|
|
940
|
+
if (missing.length > 0) {
|
|
941
|
+
throw new Error(`CoreMail service mail is partially configured. Missing: ${missing.join(', ')}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The From address follows the same scoped-wins rule as the typed path.
|
|
945
|
+
const addressEnv = hasScopedCoreMail ? await this.readMailEnv(requestedToken) : undefined;
|
|
946
|
+
const env = addressEnv && Object.values(addressEnv).some(Boolean)
|
|
947
|
+
? addressEnv
|
|
948
|
+
: await this.readMailEnv();
|
|
949
|
+
const from = env.from?.trim().toLowerCase();
|
|
950
|
+
if (!from) {
|
|
951
|
+
throw new Error(
|
|
952
|
+
`${token ? `MAIL_${token}_FROM` : 'MAIL_FROM'} is required for CoreMail service mail`,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
if (requestedAddress && from !== requestedAddress) {
|
|
956
|
+
throw new Error(`No CoreMail service mail credentials are configured for ${requestedAddress}`);
|
|
957
|
+
}
|
|
958
|
+
const credentialVersion = Number(coreMailEnv.credentialVersion);
|
|
959
|
+
if (!Number.isSafeInteger(credentialVersion) || credentialVersion < 1) {
|
|
960
|
+
throw new Error(
|
|
961
|
+
`${this.getCoreMailEnvNames(token).credentialVersion} must be a positive integer`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
from,
|
|
966
|
+
endpointUrl: coreMailEnv.url!,
|
|
967
|
+
bindingId: coreMailEnv.bindingId!,
|
|
968
|
+
credentialId: coreMailEnv.credentialId!,
|
|
969
|
+
credentialVersion,
|
|
970
|
+
credentialSecret: coreMailEnv.credentialSecret!,
|
|
971
|
+
addressToken: token,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Value-free key identifying one CoreMail binding; never includes the secret. */
|
|
976
|
+
private getCoreMailConnectorKey(
|
|
977
|
+
bindingArg: {
|
|
978
|
+
endpointUrl: string;
|
|
979
|
+
bindingId: string;
|
|
980
|
+
credentialId: string;
|
|
981
|
+
credentialVersion: number;
|
|
982
|
+
},
|
|
983
|
+
): string {
|
|
984
|
+
return JSON.stringify([
|
|
985
|
+
bindingArg.endpointUrl,
|
|
986
|
+
bindingArg.bindingId,
|
|
987
|
+
bindingArg.credentialId,
|
|
988
|
+
bindingArg.credentialVersion,
|
|
989
|
+
]);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Returns the connector that serves this address's binding.
|
|
994
|
+
*
|
|
995
|
+
* SzCoreMailConnector.configure() is one-shot, and a service may hold several
|
|
996
|
+
* bindings at once (an unscoped MAIL_COREMAIL_* group plus per-address
|
|
997
|
+
* MAIL_<TOKEN>_COREMAIL_* groups). One shared instance therefore cannot serve
|
|
998
|
+
* them all: the client's shared connector is reused only while it is still
|
|
999
|
+
* unconfigured or already configured for this exact binding, and every other
|
|
1000
|
+
* binding gets a dedicated instance owned and stopped by this email connector.
|
|
1001
|
+
*/
|
|
1002
|
+
private getConfiguredCoreMailConnector(
|
|
1003
|
+
credentialsArg: IServiceMailCoreMailCredentials,
|
|
1004
|
+
): SzCoreMailConnector {
|
|
1005
|
+
const options = {
|
|
1006
|
+
endpointUrl: credentialsArg.endpointUrl,
|
|
1007
|
+
bindingId: credentialsArg.bindingId,
|
|
1008
|
+
credentialId: credentialsArg.credentialId,
|
|
1009
|
+
credentialVersion: credentialsArg.credentialVersion,
|
|
1010
|
+
credentialSecret: credentialsArg.credentialSecret,
|
|
1011
|
+
};
|
|
1012
|
+
const requestedKey = this.getCoreMailConnectorKey(options);
|
|
1013
|
+
const shared = this.platformClientRef.coreMailConnector;
|
|
1014
|
+
const sharedBinding = shared.configuredBinding;
|
|
1015
|
+
if (!sharedBinding || this.getCoreMailConnectorKey(sharedBinding) === requestedKey) {
|
|
1016
|
+
// Unconfigured: adopt it. Already this binding: configure() is a no-op.
|
|
1017
|
+
shared.configure(options);
|
|
1018
|
+
return shared;
|
|
1019
|
+
}
|
|
1020
|
+
const owned = this.ownedCoreMailConnectors.get(requestedKey);
|
|
1021
|
+
if (owned) return owned;
|
|
1022
|
+
const connector = new SzCoreMailConnector();
|
|
1023
|
+
connector.configure(options);
|
|
1024
|
+
this.ownedCoreMailConnectors.set(requestedKey, connector);
|
|
1025
|
+
return connector;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
381
1028
|
private async readMailEnv(tokenArg?: string): Promise<{
|
|
382
1029
|
from?: string;
|
|
383
1030
|
smtpHost?: string;
|
|
@@ -445,6 +1092,7 @@ export class SzEmailConnector {
|
|
|
445
1092
|
}
|
|
446
1093
|
|
|
447
1094
|
private async getMailTypedSocket(urlArg: string): Promise<plugins.typedsocket.TypedSocket> {
|
|
1095
|
+
this.platformClientRef.assertRunning();
|
|
448
1096
|
const normalizedUrl = this.normalizeTypedSocketUrl(urlArg);
|
|
449
1097
|
if (this.mailTypedSocket && this.mailTypedSocketUrl === normalizedUrl) {
|
|
450
1098
|
return this.mailTypedSocket;
|
|
@@ -455,20 +1103,27 @@ export class SzEmailConnector {
|
|
|
455
1103
|
if (this.mailTypedSocket || this.mailTypedSocketPromise) {
|
|
456
1104
|
await this.stopMailTypedSocket();
|
|
457
1105
|
}
|
|
1106
|
+
this.platformClientRef.assertRunning();
|
|
458
1107
|
this.mailTypedSocketUrl = normalizedUrl;
|
|
459
1108
|
const abortController = new AbortController();
|
|
460
1109
|
this.mailTypedSocketAbortController = abortController;
|
|
461
1110
|
const socketPromise = plugins.typedsocket.TypedSocket.createClient(
|
|
462
|
-
this.
|
|
1111
|
+
this.mailRouter,
|
|
463
1112
|
normalizedUrl,
|
|
464
|
-
{ autoReconnect: true, abortSignal: abortController.signal
|
|
1113
|
+
{ autoReconnect: true, abortSignal: abortController.signal,
|
|
1114
|
+
restoreConnection: (context) => {
|
|
1115
|
+
const operation = this.restoreMailConnection(context, normalizedUrl, abortController.signal);
|
|
1116
|
+
this.restorationOperations.add(operation);
|
|
1117
|
+
void operation.then(() => this.restorationOperations.delete(operation), () => this.restorationOperations.delete(operation));
|
|
1118
|
+
return operation;
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
465
1121
|
);
|
|
466
1122
|
this.mailTypedSocketPromise = socketPromise;
|
|
467
1123
|
try {
|
|
468
1124
|
const socket = await socketPromise;
|
|
469
1125
|
if (this.mailTypedSocketPromise === socketPromise && this.mailTypedSocketUrl === normalizedUrl) {
|
|
470
1126
|
this.mailTypedSocket = socket;
|
|
471
|
-
this.watchMailTypedSocketStatus(socket);
|
|
472
1127
|
} else {
|
|
473
1128
|
await socket.stop();
|
|
474
1129
|
}
|
|
@@ -487,148 +1142,49 @@ export class SzEmailConnector {
|
|
|
487
1142
|
const socket = this.mailTypedSocket;
|
|
488
1143
|
const socketPromise = this.mailTypedSocketPromise;
|
|
489
1144
|
const abortController = this.mailTypedSocketAbortController;
|
|
490
|
-
const statusSubscription = this.mailTypedSocketStatusSubscription;
|
|
491
1145
|
this.mailTypedSocket = undefined;
|
|
492
1146
|
this.mailTypedSocketPromise = undefined;
|
|
493
1147
|
this.mailTypedSocketUrl = undefined;
|
|
494
1148
|
this.mailTypedSocketAbortController = undefined;
|
|
495
|
-
this.mailTypedSocketStatusSubscription = undefined;
|
|
496
|
-
statusSubscription?.unsubscribe();
|
|
497
1149
|
abortController?.abort();
|
|
498
1150
|
if (socket) {
|
|
499
1151
|
await socket.stop();
|
|
500
1152
|
return;
|
|
501
1153
|
}
|
|
502
1154
|
if (socketPromise) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
await pendingSocket.stop();
|
|
506
|
-
} catch {
|
|
507
|
-
// The pending connection already failed; there is no socket to stop.
|
|
508
|
-
}
|
|
1155
|
+
const pendingSocket = await socketPromise.catch(() => undefined);
|
|
1156
|
+
await pendingSocket?.stop();
|
|
509
1157
|
}
|
|
510
1158
|
}
|
|
511
1159
|
|
|
512
|
-
private
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
void this.handleMailTypedSocketStatus(socketArg, statusArg);
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private async handleMailTypedSocketStatus(
|
|
520
|
-
socketArg: plugins.typedsocket.TypedSocket,
|
|
521
|
-
statusArg: plugins.typedsocket.TConnectionStatus,
|
|
1160
|
+
private async restoreMailConnection(
|
|
1161
|
+
context: plugins.typedsocket.ITypedSocketConnectionRestoreContext,
|
|
1162
|
+
url: string, signal: AbortSignal,
|
|
522
1163
|
): Promise<void> {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
try {
|
|
527
|
-
await this.replayInboundEndpointRegistrationsWithRetry(socketArg);
|
|
528
|
-
} catch (error) {
|
|
529
|
-
console.warn('Could not re-register service mail endpoint after reconnect:', (error as Error).message);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
private async replayInboundEndpointRegistrationsWithRetry(
|
|
534
|
-
socketArg: plugins.typedsocket.TypedSocket,
|
|
535
|
-
): Promise<void> {
|
|
536
|
-
if (!this.inboundEndpointRegistrations.size) {
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
if (this.inboundEndpointReplayPromise) {
|
|
540
|
-
return await this.inboundEndpointReplayPromise;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const replayPromise = this.replayInboundEndpointRegistrationsWithRetryInner(socketArg);
|
|
544
|
-
this.inboundEndpointReplayPromise = replayPromise;
|
|
545
|
-
try {
|
|
546
|
-
await replayPromise;
|
|
547
|
-
} finally {
|
|
548
|
-
if (this.inboundEndpointReplayPromise === replayPromise) {
|
|
549
|
-
this.inboundEndpointReplayPromise = undefined;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
private async replayInboundEndpointRegistrationsWithRetryInner(
|
|
555
|
-
socketArg: plugins.typedsocket.TypedSocket,
|
|
556
|
-
): Promise<void> {
|
|
557
|
-
let lastError: unknown;
|
|
558
|
-
const delays = [0, ...this.inboundEndpointReplayRetryDelaysMs];
|
|
559
|
-
for (let attemptIndex = 0; attemptIndex < delays.length; attemptIndex++) {
|
|
560
|
-
if (this.mailTypedSocket !== socketArg) {
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
if (attemptIndex > 0) {
|
|
564
|
-
const shouldContinue = await this.waitForInboundEndpointReplayRetry(socketArg, delays[attemptIndex]);
|
|
565
|
-
if (!shouldContinue) {
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
try {
|
|
570
|
-
await this.replayInboundEndpointRegistrationsOnce(socketArg);
|
|
571
|
-
return;
|
|
572
|
-
} catch (error) {
|
|
573
|
-
lastError = error;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
throw lastError instanceof Error
|
|
577
|
-
? lastError
|
|
578
|
-
: new Error('Could not re-register service mail endpoint after reconnect');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
private async replayInboundEndpointRegistrationsOnce(
|
|
582
|
-
socketArg: plugins.typedsocket.TypedSocket,
|
|
583
|
-
): Promise<void> {
|
|
584
|
-
for (const address of this.inboundEndpointRegistrations.values()) {
|
|
585
|
-
if (this.mailTypedSocket !== socketArg) {
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
1164
|
+
for (const address of this.inboundEndpointRegistrations) {
|
|
1165
|
+
signal.throwIfAborted(); context.abortSignal.throwIfAborted();
|
|
588
1166
|
const credentials = await this.getServiceMailTypedCredentials(address);
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
await this.registerServiceMailEndpoint(socketArg, credentials);
|
|
1167
|
+
if (credentials.typedUrl !== url) continue;
|
|
1168
|
+
await this.registerServiceMailEndpoint({ createTypedRequest: context.createTypedRequest }, credentials, context.abortSignal);
|
|
1169
|
+
signal.throwIfAborted(); context.abortSignal.throwIfAborted();
|
|
593
1170
|
}
|
|
594
1171
|
}
|
|
595
1172
|
|
|
596
|
-
private async waitForInboundEndpointReplayRetry(
|
|
597
|
-
socketArg: plugins.typedsocket.TypedSocket,
|
|
598
|
-
delayMsArg: number,
|
|
599
|
-
): Promise<boolean> {
|
|
600
|
-
if (this.mailTypedSocket !== socketArg) {
|
|
601
|
-
return false;
|
|
602
|
-
}
|
|
603
|
-
const abortSignal = this.mailTypedSocketAbortController?.signal;
|
|
604
|
-
if (abortSignal?.aborted) {
|
|
605
|
-
return false;
|
|
606
|
-
}
|
|
607
|
-
return await new Promise<boolean>((resolve) => {
|
|
608
|
-
const resolveOnce = (resultArg: boolean) => {
|
|
609
|
-
clearTimeout(timeout);
|
|
610
|
-
abortSignal?.removeEventListener('abort', abortHandler);
|
|
611
|
-
resolve(resultArg && this.mailTypedSocket === socketArg);
|
|
612
|
-
};
|
|
613
|
-
const abortHandler = () => resolveOnce(false);
|
|
614
|
-
const timeout = setTimeout(() => resolveOnce(true), delayMsArg);
|
|
615
|
-
abortSignal?.addEventListener('abort', abortHandler, { once: true });
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
|
|
619
1173
|
private async registerServiceMailEndpoint(
|
|
620
|
-
typedSocketArg: plugins.typedsocket.TypedSocket,
|
|
1174
|
+
typedSocketArg: Pick<plugins.typedsocket.TypedSocket, 'createTypedRequest'>,
|
|
621
1175
|
credentialsArg: IServiceMailTypedCredentials,
|
|
1176
|
+
abortSignal?: AbortSignal,
|
|
622
1177
|
): Promise<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response']> {
|
|
623
1178
|
const request = typedSocketArg.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint>('registerServiceMailEndpoint');
|
|
1179
|
+
request.skipHooks = true;
|
|
624
1180
|
const result = await request.fire({
|
|
625
1181
|
auth: {
|
|
626
1182
|
credentialId: credentialsArg.credentialId,
|
|
627
1183
|
credentialSecret: credentialsArg.credentialSecret,
|
|
628
1184
|
},
|
|
629
1185
|
addresses: [credentialsArg.from],
|
|
630
|
-
});
|
|
631
|
-
if (!result.success) {
|
|
1186
|
+
}, { maxRetries: 0, abortSignal });
|
|
1187
|
+
if (!result.success || result.addresses?.length !== 1 || result.addresses[0] !== credentialsArg.from) {
|
|
632
1188
|
throw new Error(result.message || 'Could not register service mail endpoint');
|
|
633
1189
|
}
|
|
634
1190
|
return result;
|