@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.
@@ -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;