@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.
@@ -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 mailTypedSocketStatusSubscription?: { unsubscribe: () => void };
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 inboundEndpointReplayPromise?: Promise<void>;
114
- private readonly inboundEndpointReplayRetryDelaysMs = [500, 1000, 2000, 5000];
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.typedsocket.createTypedRequest<plugins.servezoneInterfaces.platform.email.IReq_SendEmail>(
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
- const registeredAddresses = result.addresses?.length ? result.addresses : [credentials.from];
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.platformClientRef.typedrouter.addTypedHandler(
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.platformClientRef.typedrouter,
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
- try {
504
- const pendingSocket = await socketPromise;
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 watchMailTypedSocketStatus(socketArg: plugins.typedsocket.TypedSocket): void {
513
- this.mailTypedSocketStatusSubscription?.unsubscribe();
514
- this.mailTypedSocketStatusSubscription = socketArg.statusSubject.subscribe((statusArg) => {
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
- if (statusArg !== 'connected' || this.mailTypedSocket !== socketArg) {
524
- return;
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 (this.normalizeTypedSocketUrl(credentials.typedUrl) !== this.mailTypedSocketUrl) {
590
- continue;
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;