import * as plugins from './plugins.js'; type TCoreMailDesiredState = plugins.servezoneInterfaces.data.ICoreMailDesiredState; type TCoreMailReconciliationStatus = plugins.servezoneInterfaces.data.ICoreMailReconciliationStatus; type TCoreMailReplicaIdentity = plugins.servezoneInterfaces.data.ICoreMailReplicaIdentity; type TCoreMailUploadGrant = plugins.servezoneInterfaces.data.ICoreMailUploadGrant; type TCoreMailSha256 = plugins.servezoneInterfaces.data.TCoreMailSha256; type TCoreMailErrorCode = plugins.servezoneInterfaces.data.TCoreMailErrorCode; type TCoreMailServiceMailStatistics = plugins.servezoneInterfaces.data.ICoreMailServiceMailStatistics; type TCoreMailGetServiceMailStatistics = plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailGetServiceMailStatistics; export interface ICoreMailControlClientOptions { /** Canonical CoreMail control socket, `https://host/socket`. */ endpointUrl: string; credentialId: string; credentialVersion: number; credentialSecret: string; } export interface ICoreMailApplyDesiredStateOptions { /** * The `configEpoch` this client believes CoreMail currently has applied. * CoreMail refuses the preparation unless it still matches, which is what * makes a concurrent controller lose instead of both overwriting each other. */ expectedAppliedConfigEpoch: number; } interface ICoreMailControlSession { generation: number; socket: plugins.typedsocket.TypedSocket; statusSubscription: { unsubscribe(): void }; replica: TCoreMailReplicaIdentity; } interface ICoreMailPendingSession { generation: number; promise: Promise; } /** * Thrown when a transfer could not be completed. `ambiguous` marks the case * where the request may or may not have reached CoreMail, so the caller must * restart the whole prepare/upload/apply sequence rather than assume either * outcome. */ export class CoreMailControlTransferError extends Error { public constructor( messageArg: string, public readonly ambiguous: boolean, ) { super(messageArg); this.name = 'CoreMailControlTransferError'; } } const identifierRegex = /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,126}[A-Za-z0-9])?$/; const dayUtcRegex = /^\d{4}-\d{2}-\d{2}$/; const loopbackHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); const maximumAttempts = 4; /** * Control-plane client for one CoreMail replica. * * Cloudly (clusters) and Onebox (single hosts) own the CoreMail control * session: they compose desired state, push it, and read reconciliation and * per-service mail statistics back. Workload-side mail submission is a * different session kind and lives in `@serve.zone/platformclient`. * * This package deliberately carries no argon2 or other native dependency: * `ICoreMailBindingCredentialVerifier.verificationHash` values are produced by * whoever mints the credential and arrive here already hashed. */ export class CoreMailControlClient { private readonly options: Readonly; /** * Origin that path-only transfer grants resolve against. The control * authenticate response carries no `coreMailTransferOrigin` (unlike the * workload one), because a control caller already holds the authenticated * endpoint it dialled — so the origin is derived from it and never from * anything CoreMail sends back. */ private readonly transferOrigin: string; private readonly stopController = new AbortController(); private readonly activeOperations = new Set>(); private stopped = false; private generation = 0; private session?: ICoreMailControlSession; private pendingSession?: ICoreMailPendingSession; private stopPromise?: Promise; public constructor(optionsArg: ICoreMailControlClientOptions) { this.options = Object.freeze(this.normalizeOptions(optionsArg)); this.transferOrigin = this.deriveTransferOrigin(this.options.endpointUrl); } /** Opens and authenticates the control session. */ public async start(): Promise { await this.getAuthenticatedSession(); } public stop(): Promise { this.stopPromise ??= this.stopInner(); return this.stopPromise; } /** * Replica identity from the authenticate response. Available only while a * session is open, because it identifies the replica this client is actually * talking to — a stale copy across a reconnect would misattribute state. */ public getReplicaIdentity(): TCoreMailReplicaIdentity { if (!this.session || this.session.socket.getStatus() !== 'connected') { throw new Error('CoreMail control client has no authenticated session.'); } return this.session.replica; } /** * Pushes one desired state through prepare, upload and apply. * * The returned status is CoreMail's own reconciliation status for the epoch * that was applied. */ public async applyDesiredState( desiredStateArg: TCoreMailDesiredState, optionsArg: ICoreMailApplyDesiredStateOptions, ): Promise { const desiredState = plugins.servezoneInterfaces.data .normalizeCoreMailDesiredState(desiredStateArg); const bytes = new TextEncoder().encode( plugins.servezoneInterfaces.data .canonicalizeCoreMailDesiredState(desiredState), ); const desiredStateDigest = await plugins.servezoneInterfaces.data .createCoreMailDesiredStateDigest(desiredState); const expectedAppliedConfigEpoch = optionsArg?.expectedAppliedConfigEpoch; if ( !Number.isSafeInteger(expectedAppliedConfigEpoch) || expectedAppliedConfigEpoch < 0 ) { throw new Error( 'CoreMail expectedAppliedConfigEpoch must be a non-negative integer.', ); } if (desiredState.configEpoch <= expectedAppliedConfigEpoch) { throw new Error('CoreMail configEpoch must strictly increase.'); } if ( bytes.byteLength < 2 || bytes.byteLength > plugins.servezoneInterfaces.data.coreMailLimits.desiredStateBytes ) { throw new Error('CoreMail desired state exceeds its byte budget.'); } let lastError: unknown; for (let attempt = 0; attempt < maximumAttempts; attempt++) { try { return await this.reconcileOnce( desiredState.configEpoch, desiredStateDigest, bytes, expectedAppliedConfigEpoch, ); } catch (error) { lastError = error; // A failure anywhere after `apply` reached CoreMail is indistinguishable // from one before it, so ask CoreMail what it actually has before // deciding to retry. This is what makes the whole sequence idempotent. const settled = await this.probeAppliedStatus( desiredState.configEpoch, desiredStateDigest, ); if (settled) { return settled; } if (attempt === maximumAttempts - 1 || !this.isRetryable(error)) { throw error; } await this.waitForDelay(25 * (2 ** attempt)); } } throw lastError; } public async getReconciliationStatus( requestArg: { configEpoch: number; desiredStateDigest: TCoreMailSha256 | string; }, ): Promise { const response = await this.fire< plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailGetReconciliationStatus >('coreMailGetReconciliationStatus', { configEpoch: this.requireSafeInteger( requestArg?.configEpoch, 'configEpoch', 1, ), desiredStateDigest: plugins.servezoneInterfaces.data .normalizeCoreMailSha256(requestArg?.desiredStateDigest), }); return plugins.servezoneInterfaces.data .normalizeCoreMailReconciliationStatus(response.status); } /** * Reads one page of per-service mail statistics. Pagination is CoreMail's: * follow `nextCursor` until it is absent. */ public async getServiceMailStatistics( requestArg: TCoreMailGetServiceMailStatistics['request'], ): Promise { const request = this.normalizeStatisticsRequest(requestArg); const response = await this.fire( 'coreMailGetServiceMailStatistics', request, ); return this.normalizeStatisticsResponse(response, request); } // -- desired state ------------------------------------------------------ private async reconcileOnce( configEpochArg: number, desiredStateDigestArg: TCoreMailSha256, bytesArg: Uint8Array, expectedAppliedConfigEpochArg: number, ): Promise { const prepared = await this.fire< plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailPrepareDesiredStateUpload >('coreMailPrepareDesiredStateUpload', { expectedAppliedConfigEpoch: expectedAppliedConfigEpochArg, configEpoch: configEpochArg, desiredStateDigest: desiredStateDigestArg, lengthBytes: bytesArg.byteLength, }); if (typeof prepared.replayed !== 'boolean') { throw new Error('CoreMail returned an invalid replay state.'); } // `replayed` reports that CoreMail already had this exact // (configEpoch, digest) staged from an earlier interrupted attempt. It is // NOT "already applied": a staged snapshot still needs its bytes, and // CoreMail issues a *fresh* single-use grant on every preparation. Skipping // the upload here would strand the reconciliation forever, so the flow is // deliberately identical for both values. const grant = plugins.servezoneInterfaces.data .normalizeCoreMailUploadGrant(prepared.grant); this.assertUploadGrant(grant, desiredStateDigestArg, bytesArg.byteLength); await this.uploadDesiredState(grant, bytesArg); const applied = await this.fire< plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailApplyDesiredState >('coreMailApplyDesiredState', { reconciliationId: prepared.reconciliationId, grantId: grant.grantId, desiredStateDigest: desiredStateDigestArg, lengthBytes: bytesArg.byteLength, }); const status = plugins.servezoneInterfaces.data .normalizeCoreMailReconciliationStatus(applied.status); if ( status.appliedConfigEpoch !== configEpochArg || status.appliedDesiredStateDigest !== desiredStateDigestArg ) { throw new Error('CoreMail applied another desired state.'); } return status; } private assertUploadGrant( grantArg: TCoreMailUploadGrant, desiredStateDigestArg: TCoreMailSha256, lengthBytesArg: number, ): void { if ( grantArg.method !== 'PUT' || grantArg.sha256 !== desiredStateDigestArg || grantArg.lengthBytes !== lengthBytesArg || grantArg.contentType !== 'application/json' ) { throw new Error( 'CoreMail upload grant does not match the prepared desired state.', ); } this.resolveTransferUrl(grantArg); } /** * The one HTTP step in an otherwise TypedSocket-native protocol. * * serve.zone/AGENTS.md records that a TypedSocket-native byte transfer is * planned to replace HTTP transfer grants. This method is therefore kept * deliberately small and is the single seam to swap when that lands: nothing * else in this class touches `fetch`, and its contract is just "make the * grant's bytes reach CoreMail, or throw". */ protected async uploadDesiredState( grantArg: TCoreMailUploadGrant, bytesArg: Uint8Array, ): Promise { const deadline = this.createTransferDeadline( plugins.servezoneInterfaces.data.coreMailLimits.transferOverallTimeoutMs, ); try { let response: Response; try { response = await this.fetchTransfer(this.resolveTransferUrl(grantArg), { method: 'PUT', headers: { Authorization: `Bearer ${grantArg.bearerToken}`, 'Content-Type': grantArg.contentType, // CoreMail rejects the upload unless Content-Length is exactly the // granted length, so the body is a sized buffer, never a stream. 'Content-Length': String(grantArg.lengthBytes), }, body: this.copyBytesToArrayBuffer(bytesArg), cache: 'no-store', credentials: 'omit', redirect: 'error', signal: deadline.controller.signal, }); } catch (error) { if (deadline.controller.signal.aborted) { throw deadline.controller.signal.reason || error; } throw new CoreMailControlTransferError( 'CoreMail desired-state upload response is ambiguous.', true, ); } if (response.status !== 204) { await response.body?.cancel().catch(() => undefined); throw new CoreMailControlTransferError( `CoreMail desired-state upload failed with HTTP ${response.status}.`, false, ); } } finally { deadline.cleanup(); } } private async probeAppliedStatus( configEpochArg: number, desiredStateDigestArg: TCoreMailSha256, ): Promise { if (this.stopped) { return undefined; } try { const status = await this.getReconciliationStatus({ configEpoch: configEpochArg, desiredStateDigest: desiredStateDigestArg, }); if ( status.appliedConfigEpoch === configEpochArg && status.appliedDesiredStateDigest === desiredStateDigestArg ) { return status; } } catch { // CoreMail reports "no applied desired state" and a fence mismatch the // same way it reports an unreachable replica, so a failed probe simply // means "not proven applied" and the caller falls back to its ladder. } return undefined; } // -- statistics --------------------------------------------------------- private normalizeStatisticsRequest( requestArg: TCoreMailGetServiceMailStatistics['request'], ): TCoreMailGetServiceMailStatistics['request'] { const fromDayUtc = this.requireDayUtc(requestArg?.fromDayUtc, 'fromDayUtc'); const toDayUtc = this.requireDayUtc(requestArg?.toDayUtc, 'toDayUtc'); if (fromDayUtc > toDayUtc) { throw new Error('CoreMail statistics range is inverted.'); } const limit = this.requireSafeInteger(requestArg?.limit, 'limit', 1); const serviceIds = requestArg?.serviceIds; if (serviceIds !== undefined) { // Omitting the filter means every service, so an empty array would // silently widen the query rather than narrow it to nothing. if (!Array.isArray(serviceIds) || serviceIds.length === 0) { throw new Error('CoreMail statistics serviceIds must be a non-empty array.'); } for (const serviceId of serviceIds) { if (typeof serviceId !== 'string' || !identifierRegex.test(serviceId)) { throw new Error('CoreMail statistics serviceIds must be canonical.'); } } } const cursor = requestArg?.cursor; if (cursor !== undefined) { this.requireCursor(cursor); } return { fromDayUtc, toDayUtc, limit, ...(serviceIds === undefined ? {} : { serviceIds: [...serviceIds] }), ...(cursor === undefined ? {} : { cursor }), }; } private normalizeStatisticsResponse( responseArg: TCoreMailGetServiceMailStatistics['response'], requestArg: TCoreMailGetServiceMailStatistics['request'], ): TCoreMailGetServiceMailStatistics['response'] { if (!responseArg || !Array.isArray(responseArg.statistics)) { throw new Error('CoreMail returned an invalid statistics page.'); } if (responseArg.statistics.length > requestArg.limit) { throw new Error('CoreMail statistics page exceeds its requested limit.'); } // Entry shape, identifier canonicality and the UTC calendar day are the // published normalizer's job. What it cannot know is what this client // asked for, so only those two cross-checks stay here. const statistics = responseArg.statistics.map( (entryArg): TCoreMailServiceMailStatistics => { const entry = plugins.servezoneInterfaces.data .normalizeCoreMailServiceMailStatistics(entryArg); if (entry.dayUtc < requestArg.fromDayUtc || entry.dayUtc > requestArg.toDayUtc) { throw new Error('CoreMail returned statistics outside the requested range.'); } if ( requestArg.serviceIds && !requestArg.serviceIds.includes(entry.serviceId) ) { throw new Error('CoreMail returned statistics for an unrequested service.'); } return entry; }, ); const nextCursor = responseArg.nextCursor; if (nextCursor !== undefined) { this.requireCursor(nextCursor); } return { statistics, ...(nextCursor === undefined ? {} : { nextCursor }), }; } // -- session ------------------------------------------------------------ private async getAuthenticatedSession(): Promise { if (this.stopped) { throw this.createAbortError(); } if (this.session && this.session.socket.getStatus() === 'connected') { return this.session; } if (this.pendingSession) { return await this.pendingSession.promise; } const generation = ++this.generation; const promise = this.connectAndAuthenticate(generation); this.pendingSession = { generation, promise }; try { return await promise; } finally { if (this.pendingSession?.promise === promise) { this.pendingSession = undefined; } } } private async connectAndAuthenticate( generationArg: number, ): Promise { let socket: plugins.typedsocket.TypedSocket | undefined; let statusSubscription: { unsubscribe(): void } | undefined; let socketInvalidated = false; try { socket = await plugins.typedsocket.TypedSocket.createClient( new plugins.typedrequest.TypedRouter(), this.options.endpointUrl, { autoReconnect: false, maxRetries: 0, abortSignal: this.stopController.signal, }, ); this.assertGeneration(generationArg); statusSubscription = socket.statusSubject.subscribe((statusArg) => { if (statusArg !== 'connected') { socketInvalidated = true; this.invalidateSession(socket!, generationArg, statusSubscription!); } }); // CoreMail closes the peer with 4401 *before* the authentication error // propagates, so a wrong credential surfaces as a disconnect, never as a // typed AUTHENTICATION_FAILED envelope. // // The 4,500 ms deadline below is NOT a way to stay inside CoreMail's // 5,000 ms pre-auth budget: the two clocks do not share a start. CoreMail // stamps its deadline in websocketAdmission, at connection open, while // this one starts only once createClient has resolved — after the socket // opened and the typedsocket version handshake settled. A slow handshake // can therefore still be terminated by the server's pre-auth sweep before // this timeout ever fires, and the caller sees that as a disconnect. The // deadline is only here so a request that never settles cannot hang the // session open indefinitely. const response = await this.trackOperation(socket.createTypedRequest< plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailAuthenticateControl >('coreMailAuthenticateControl', undefined, { timeoutMs: 4_500, abortSignal: this.stopController.signal, }).fire({ credentialId: this.options.credentialId, credentialVersion: this.options.credentialVersion, credentialSecret: this.options.credentialSecret, })); this.assertGeneration(generationArg); if (socket.getStatus() !== 'connected') { throw new Error('CoreMail disconnected during authentication.'); } const session: ICoreMailControlSession = { generation: generationArg, socket, statusSubscription, replica: this.normalizeAuthenticationResponse(response), }; this.session = session; return session; } catch (error) { statusSubscription?.unsubscribe(); if (!socketInvalidated) { await socket?.stop().catch(() => undefined); } throw error; } } private normalizeAuthenticationResponse( responseArg: plugins.servezoneInterfaces.requests.coremail .IReq_CoreMailAuthenticateControl['response'], ): TCoreMailReplicaIdentity { if ( responseArg?.authenticated !== true || responseArg.credentialId !== this.options.credentialId || responseArg.credentialVersion !== this.options.credentialVersion ) { throw new Error('CoreMail returned an invalid control authentication.'); } return plugins.servezoneInterfaces.data .normalizeCoreMailReplicaIdentity(responseArg.replica); } private async fire< TRequest extends plugins.typedRequestInterfaces.ITypedRequest, >( methodArg: TRequest['method'], requestArg: TRequest['request'], ): Promise { const session = await this.getAuthenticatedSession(); const response = await this.trackOperation( session.socket.createTypedRequest(methodArg, undefined, { timeoutMs: 30_000, abortSignal: this.stopController.signal, }).fire(requestArg, { maxRetries: 0 }), ); this.assertGeneration(session.generation); return response; } private invalidateSession( socketArg: plugins.typedsocket.TypedSocket, generationArg: number, subscriptionArg: { unsubscribe(): void }, ): void { const ownsSession = this.session?.socket === socketArg && this.session.generation === generationArg; const ownsPending = this.pendingSession?.generation === generationArg; if (!ownsSession && !ownsPending) { return; } this.generation++; if (ownsSession) this.session = undefined; if (ownsPending) this.pendingSession = undefined; subscriptionArg.unsubscribe(); void socketArg.stop().catch(() => undefined); } private assertGeneration(generationArg: number): void { if (this.stopped || generationArg !== this.generation) { throw this.createAbortError(); } } private trackOperation(promiseArg: Promise): Promise { this.activeOperations.add(promiseArg); return promiseArg.finally(() => { this.activeOperations.delete(promiseArg); }); } private async stopInner(): Promise { this.stopped = true; this.generation++; this.stopController.abort(this.createAbortError()); const session = this.session; const pendingSession = this.pendingSession; this.session = undefined; this.pendingSession = undefined; session?.statusSubscription.unsubscribe(); await Promise.allSettled([ session?.socket.stop(), pendingSession?.promise.then(async (resultArg) => { resultArg.statusSubscription.unsubscribe(); await resultArg.socket.stop(); }), ...this.activeOperations, ].filter(Boolean) as Promise[]); } // -- option and value normalization ------------------------------------- private normalizeOptions( optionsArg: ICoreMailControlClientOptions, ): ICoreMailControlClientOptions { if ( typeof optionsArg?.endpointUrl !== 'string' || optionsArg.endpointUrl.trim() !== optionsArg.endpointUrl ) { throw new Error('CoreMail endpointUrl must be one canonical /socket URL.'); } const endpointUrl = new URL(optionsArg.endpointUrl); const loopback = loopbackHostnames.has(endpointUrl.hostname); // Plaintext transports are a loopback-only affordance for in-process tests // and a co-located Onebox; a routable endpoint is always TLS. const allowedProtocols = loopback ? ['https:', 'http:', 'wss:', 'ws:'] : ['https:', 'wss:']; if ( !allowedProtocols.includes(endpointUrl.protocol) || endpointUrl.username || endpointUrl.password || endpointUrl.search || endpointUrl.hash || endpointUrl.pathname !== '/socket' || endpointUrl.toString() !== optionsArg.endpointUrl ) { throw new Error('CoreMail endpointUrl must be one canonical /socket URL.'); } if ( typeof optionsArg.credentialId !== 'string' || !identifierRegex.test(optionsArg.credentialId) ) { throw new Error('CoreMail credentialId must be canonical.'); } if ( !Number.isSafeInteger(optionsArg.credentialVersion) || optionsArg.credentialVersion < 1 ) { throw new Error('CoreMail credentialVersion must be a positive integer.'); } if ( typeof optionsArg.credentialSecret !== 'string' || optionsArg.credentialSecret.length < 32 || optionsArg.credentialSecret.length > 512 ) { throw new Error('CoreMail credentialSecret must contain 32 to 512 characters.'); } return { endpointUrl: optionsArg.endpointUrl, credentialId: optionsArg.credentialId, credentialVersion: optionsArg.credentialVersion, credentialSecret: optionsArg.credentialSecret, }; } private deriveTransferOrigin(endpointUrlArg: string): string { const url = new URL(endpointUrlArg); const httpProtocol = url.protocol === 'wss:' ? 'https:' : url.protocol === 'ws:' ? 'http:' : url.protocol; return `${httpProtocol}//${url.host}`; } private resolveTransferUrl(grantArg: TCoreMailUploadGrant): string { const url = new URL(grantArg.path, this.transferOrigin); if ( url.origin !== this.transferOrigin || url.pathname !== grantArg.path || url.search || url.hash ) { throw new Error('CoreMail transfer grant escapes its authenticated origin.'); } return url.toString(); } private requireSafeInteger( valueArg: unknown, nameArg: string, minimumArg: number, ): number { if (!Number.isSafeInteger(valueArg) || (valueArg as number) < minimumArg) { throw new Error(`CoreMail ${nameArg} must be an integer of at least ${minimumArg}.`); } return valueArg as number; } /** * Mirrors the interfaces normalizer's `requireUtcCalendarDay`: the shape has * to parse AND be a day that exists, so '2026-02-30' is refused here rather * than by CoreMail. That helper is module-private in interfaces, so the * request side keeps its own copy of the rule. */ private requireDayUtc(valueArg: unknown, nameArg: string): string { if (typeof valueArg !== 'string' || !dayUtcRegex.test(valueArg)) { throw new Error(`CoreMail ${nameArg} must be a YYYY-MM-DD UTC day.`); } const [year, month, day] = valueArg.split('-').map(Number); const parsed = new Date(Date.UTC(year, month - 1, day)); if ( parsed.getUTCFullYear() !== year || parsed.getUTCMonth() + 1 !== month || parsed.getUTCDate() !== day ) { throw new Error(`CoreMail ${nameArg} must be a real UTC calendar day.`); } return valueArg; } private requireCursor(valueArg: unknown): string { if ( typeof valueArg !== 'string' || valueArg.length === 0 || new TextEncoder().encode(valueArg).byteLength > plugins.servezoneInterfaces.data.coreMailLimits.cursorBytes ) { throw new Error('CoreMail cursor is invalid.'); } return valueArg; } // -- failure classification and transfer plumbing ------------------------ private isRetryable(errorArg: unknown): boolean { if (errorArg instanceof CoreMailControlTransferError) { return true; } const errorData = (errorArg as { errorData?: { code?: TCoreMailErrorCode; retryable?: boolean }; })?.errorData; return errorData?.retryable === true; } private createTransferDeadline(timeoutMsArg: number): { controller: AbortController; cleanup: () => void; } { const controller = new AbortController(); const onStop = () => controller.abort(this.stopController.signal.reason); if (this.stopController.signal.aborted) { onStop(); } else { this.stopController.signal.addEventListener('abort', onStop, { once: true }); } const timeout = setTimeout(() => { controller.abort(new Error('CoreMail transfer timeout.')); }, timeoutMsArg); return { controller, cleanup: () => { clearTimeout(timeout); this.stopController.signal.removeEventListener('abort', onStop); }, }; } private async fetchTransfer( inputArg: string, initArg: RequestInit, ): Promise { return await globalThis.fetch(inputArg, initArg); } private copyBytesToArrayBuffer(bytesArg: Uint8Array): ArrayBuffer { const bytes = new Uint8Array(bytesArg.byteLength); bytes.set(bytesArg); return bytes.buffer; } private async waitForDelay(delayMsArg: number): Promise { await new Promise((resolveArg) => { const timer = setTimeout(resolveArg, delayMsArg); (timer as { unref?: () => void }).unref?.(); }); } private createAbortError(): Error { const error = new Error('CoreMail control client operation aborted.'); error.name = 'AbortError'; return error; } }