@serve.zone/platformclient 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.platformclient.d.ts +8 -0
- package/dist_ts/classes.platformclient.js +42 -1
- package/dist_ts/coremail/classes.coremailconnector.d.ts +66 -0
- package/dist_ts/coremail/classes.coremailconnector.js +184 -4
- package/dist_ts/email/classes.emailconnector.d.ts +86 -1
- package/dist_ts/email/classes.emailconnector.js +537 -1
- package/package.json +7 -5
- package/readme.md +34 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.platformclient.ts +40 -0
- package/ts/coremail/classes.coremailconnector.ts +215 -3
- package/ts/email/classes.emailconnector.ts +651 -1
|
@@ -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;
|
|
@@ -111,6 +206,13 @@ export class SzEmailConnector {
|
|
|
111
206
|
private inboundHandler?: TServiceMailInboundHandler;
|
|
112
207
|
private inboundTypedHandlerRegistered = false;
|
|
113
208
|
private inboundEndpointRegistrations = new Set<string>();
|
|
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>();
|
|
114
216
|
|
|
115
217
|
constructor(platformClientRefArg: SzPlatformClient) {
|
|
116
218
|
this.platformClientRef = platformClientRefArg;
|
|
@@ -168,6 +270,37 @@ export class SzEmailConnector {
|
|
|
168
270
|
const fromMissing = env.from ? [] : [missingPrefix ? `MAIL_${missingPrefix}_FROM` : 'MAIL_FROM'];
|
|
169
271
|
const typedReady = fromMissing.length === 0 && typedMissing.length === 0;
|
|
170
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
|
+
}
|
|
171
304
|
return {
|
|
172
305
|
ready: typedReady,
|
|
173
306
|
from: env.from,
|
|
@@ -230,6 +363,11 @@ export class SzEmailConnector {
|
|
|
230
363
|
return { messageId: 'debug' };
|
|
231
364
|
}
|
|
232
365
|
|
|
366
|
+
const coreMailCredentials = await this.getCoreMailCredentials(optionsArg.from);
|
|
367
|
+
if (coreMailCredentials) {
|
|
368
|
+
return await this.sendMailViaCoreMail(optionsArg, coreMailCredentials);
|
|
369
|
+
}
|
|
370
|
+
|
|
233
371
|
const credentials = await this.getServiceMailTypedCredentials(optionsArg.from);
|
|
234
372
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
235
373
|
const request = typedSocket.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail>('enqueueMail');
|
|
@@ -273,6 +411,212 @@ export class SzEmailConnector {
|
|
|
273
411
|
};
|
|
274
412
|
}
|
|
275
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
|
+
|
|
276
620
|
public async getMailDeliveryStatus(
|
|
277
621
|
spoolItemIdArg: string,
|
|
278
622
|
addressArg?: string,
|
|
@@ -281,6 +625,17 @@ export class SzEmailConnector {
|
|
|
281
625
|
if (!spoolItemId) {
|
|
282
626
|
throw new Error('spoolItemId is required');
|
|
283
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
|
+
}
|
|
284
639
|
const credentials = await this.getServiceMailTypedCredentials(addressArg);
|
|
285
640
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
286
641
|
const request = typedSocket.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus>('getMailDeliveryStatus');
|
|
@@ -293,11 +648,76 @@ export class SzEmailConnector {
|
|
|
293
648
|
});
|
|
294
649
|
}
|
|
295
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
|
+
|
|
296
700
|
public async registerInboundHandler(
|
|
297
701
|
handlerArg: TServiceMailInboundHandler,
|
|
298
702
|
addressArg?: string,
|
|
299
703
|
): Promise<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response']> {
|
|
300
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
|
+
}
|
|
301
721
|
this.ensureInboundTypedHandler();
|
|
302
722
|
const credentials = await this.getServiceMailTypedCredentials(addressArg);
|
|
303
723
|
const typedSocket = await this.getMailTypedSocket(credentials.typedUrl);
|
|
@@ -307,10 +727,84 @@ export class SzEmailConnector {
|
|
|
307
727
|
}
|
|
308
728
|
|
|
309
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.
|
|
310
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()));
|
|
311
739
|
while (this.restorationOperations.size) await Promise.allSettled([...this.restorationOperations]);
|
|
312
740
|
}
|
|
313
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;
|
|
806
|
+
}
|
|
807
|
+
|
|
314
808
|
public normalizeInboundMessage(payloadArg: unknown): IServiceMailInboundMessage {
|
|
315
809
|
const payload = this.asRecord(payloadArg);
|
|
316
810
|
const deliveryCandidate = this.asRecord(payload.delivery || payload.message || payload);
|
|
@@ -375,6 +869,162 @@ export class SzEmailConnector {
|
|
|
375
869
|
|
|
376
870
|
public async sendActionLink(optionsArg: { toArg: string; subject: string; text: string }) {}
|
|
377
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
|
+
|
|
378
1028
|
private async readMailEnv(tokenArg?: string): Promise<{
|
|
379
1029
|
from?: string;
|
|
380
1030
|
smtpHost?: string;
|