@serve.zone/coremail 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/coremail",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Authenticated mail persistence and delivery orchestration for serve.zone workloads.",
5
5
  "main": "dist_ts/index.js",
6
6
  "typings": "dist_ts/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "license": "MIT",
10
10
  "devDependencies": {
11
11
  "@git.zone/tsbuild": "^4.4.2",
12
- "@git.zone/tsdocker": "^3.1.1",
12
+ "@git.zone/tsdocker": "^3.4.1",
13
13
  "@git.zone/tsrun": "^2.0.6",
14
14
  "@git.zone/tstest": "^3.6.7",
15
15
  "@git.zone/tswatch": "^3.3.5",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/coremail',
6
- version: '1.0.2',
6
+ version: '1.1.0',
7
7
  description: 'Authenticated mail persistence and delivery orchestration for serve.zone workloads.'
8
8
  }
@@ -10,6 +10,7 @@ import {
10
10
  requireCoreMailIdentifier,
11
11
  requireCoreMailSafeInteger,
12
12
  } from './coremail.selectors.js';
13
+ import { logCoreMailFailure } from './coremail.log.js';
13
14
 
14
15
  type TCoreMailDesiredState = plugins.serveZoneInterfaces.data.ICoreMailDesiredState;
15
16
  type TCoreMailBinding = plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState;
@@ -26,6 +27,13 @@ export class CoreMailAuthenticationError extends Error {
26
27
  }
27
28
  }
28
29
 
30
+ export class CoreMailAuthLimitError extends CoreMailAuthenticationError {
31
+ public constructor() {
32
+ super();
33
+ this.name = 'CoreMailAuthLimitError';
34
+ }
35
+ }
36
+
29
37
  export class CoreMailAuthorizationError extends Error {
30
38
  public constructor() {
31
39
  super('CoreMail authority is unavailable.');
@@ -40,52 +48,154 @@ export class CoreMailCapabilityDeniedError extends Error {
40
48
  }
41
49
  }
42
50
 
51
+ export interface ICoreMailAuthLimiterOptions {
52
+ maximumInFlight?: number;
53
+ maximumWaiters?: number;
54
+ maximumWaitMs?: number;
55
+ identityCapacity?: number;
56
+ identityRefillPerSecond?: number;
57
+ maximumIdentities?: number;
58
+ now?: () => number;
59
+ }
60
+
61
+ interface IIdentityBucket {
62
+ tokens: number;
63
+ lastRefillAt: number;
64
+ }
65
+
66
+ interface ISlotWaiter {
67
+ grant: () => void;
68
+ expire: () => void;
69
+ timer: ReturnType<typeof setTimeout>;
70
+ }
71
+
43
72
  export class CoreMailAuthLimiter {
73
+ private readonly maximumInFlight: number;
74
+ private readonly maximumWaiters: number;
75
+ private readonly maximumWaitMs: number;
76
+ private readonly identityCapacity: number;
77
+ private readonly identityRefillPerSecond: number;
78
+ private readonly maximumIdentities: number;
79
+ private readonly now: () => number;
80
+
44
81
  private inFlight = 0;
45
- private tokens: number;
46
- private lastRefillAt: number;
82
+ private readonly waiters: ISlotWaiter[] = [];
83
+ private readonly identityBuckets = new Map<string, IIdentityBucket>();
47
84
 
48
- public constructor(
49
- private readonly maximumInFlight = 4,
50
- private readonly capacity = 8,
51
- private readonly refillPerSecond = 2,
52
- private readonly now: () => number = Date.now,
53
- ) {
54
- this.tokens = capacity;
55
- this.lastRefillAt = now();
85
+ public constructor(optionsArg: ICoreMailAuthLimiterOptions = {}) {
86
+ this.maximumInFlight = optionsArg.maximumInFlight ?? 4;
87
+ this.maximumWaiters = optionsArg.maximumWaiters ?? 64;
88
+ this.maximumWaitMs = optionsArg.maximumWaitMs ?? 2_500;
89
+ this.identityCapacity = optionsArg.identityCapacity ?? 4;
90
+ this.identityRefillPerSecond = optionsArg.identityRefillPerSecond ?? 1;
91
+ this.maximumIdentities = optionsArg.maximumIdentities ?? 4_096;
92
+ this.now = optionsArg.now ?? Date.now;
56
93
  }
57
94
 
58
- private refill(): void {
95
+ public snapshot(): ICoreMailAuthLimiterSnapshot {
96
+ return {
97
+ inFlight: this.inFlight,
98
+ waiting: this.waiters.length,
99
+ identities: this.identityBuckets.size,
100
+ };
101
+ }
102
+
103
+ public async run<T>(
104
+ identityArg: string,
105
+ workArg: () => Promise<T>,
106
+ ): Promise<T> {
107
+ this.consumeIdentityToken(identityArg);
108
+ await this.acquireSlot();
109
+ try {
110
+ return await workArg();
111
+ } finally {
112
+ this.releaseSlot();
113
+ }
114
+ }
115
+
116
+ private consumeIdentityToken(identityArg: string): void {
59
117
  const now = this.now();
60
- const elapsedMs = Math.max(0, now - this.lastRefillAt);
61
- if (elapsedMs < 1_000) {
118
+ const bucket = this.identityBuckets.get(identityArg);
119
+ if (!bucket) {
120
+ this.pruneIdentities(now);
121
+ this.identityBuckets.set(identityArg, {
122
+ tokens: this.identityCapacity - 1,
123
+ lastRefillAt: now,
124
+ });
62
125
  return;
63
126
  }
64
- const intervals = Math.floor(elapsedMs / 1_000);
65
- this.tokens = Math.min(
66
- this.capacity,
67
- this.tokens + (intervals * this.refillPerSecond),
68
- );
69
- this.lastRefillAt += intervals * 1_000;
127
+ const intervals = Math.floor(Math.max(0, now - bucket.lastRefillAt) / 1_000);
128
+ if (intervals > 0) {
129
+ bucket.tokens = Math.min(
130
+ this.identityCapacity,
131
+ bucket.tokens + (intervals * this.identityRefillPerSecond),
132
+ );
133
+ bucket.lastRefillAt += intervals * 1_000;
134
+ }
135
+ if (bucket.tokens < 1) {
136
+ throw new CoreMailAuthLimitError();
137
+ }
138
+ bucket.tokens--;
70
139
  }
71
140
 
72
- public snapshot(): ICoreMailAuthLimiterSnapshot {
73
- this.refill();
74
- return { inFlight: this.inFlight, tokens: this.tokens };
141
+ private pruneIdentities(nowArg: number): void {
142
+ if (this.identityBuckets.size < this.maximumIdentities) {
143
+ return;
144
+ }
145
+ for (const [identity, bucket] of this.identityBuckets) {
146
+ const refilled = bucket.tokens + (
147
+ Math.floor(Math.max(0, nowArg - bucket.lastRefillAt) / 1_000)
148
+ * this.identityRefillPerSecond
149
+ );
150
+ if (refilled >= this.identityCapacity) {
151
+ this.identityBuckets.delete(identity);
152
+ if (this.identityBuckets.size < this.maximumIdentities) {
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ for (const identity of this.identityBuckets.keys()) {
158
+ this.identityBuckets.delete(identity);
159
+ if (this.identityBuckets.size < this.maximumIdentities) {
160
+ return;
161
+ }
162
+ }
75
163
  }
76
164
 
77
- public async run<T>(workArg: () => Promise<T>): Promise<T> {
78
- this.refill();
79
- if (this.inFlight >= this.maximumInFlight || this.tokens < 1) {
80
- throw new CoreMailAuthenticationError();
165
+ private async acquireSlot(): Promise<void> {
166
+ if (this.inFlight < this.maximumInFlight) {
167
+ this.inFlight++;
168
+ return;
81
169
  }
82
- this.tokens--;
83
- this.inFlight++;
84
- try {
85
- return await workArg();
86
- } finally {
87
- this.inFlight--;
170
+ if (this.waiters.length >= this.maximumWaiters) {
171
+ throw new CoreMailAuthLimitError();
88
172
  }
173
+ await new Promise<void>((resolveArg, rejectArg) => {
174
+ const waiter: ISlotWaiter = {
175
+ grant: () => {
176
+ clearTimeout(waiter.timer);
177
+ resolveArg();
178
+ },
179
+ expire: () => {
180
+ const index = this.waiters.indexOf(waiter);
181
+ if (index >= 0) {
182
+ this.waiters.splice(index, 1);
183
+ }
184
+ rejectArg(new CoreMailAuthLimitError());
185
+ },
186
+ timer: setTimeout(() => waiter.expire(), this.maximumWaitMs),
187
+ };
188
+ this.waiters.push(waiter);
189
+ });
190
+ }
191
+
192
+ private releaseSlot(): void {
193
+ const waiter = this.waiters.shift();
194
+ if (waiter) {
195
+ waiter.grant();
196
+ return;
197
+ }
198
+ this.inFlight--;
89
199
  }
90
200
  }
91
201
 
@@ -108,7 +218,7 @@ const findAcceptedVerifier = (
108
218
  );
109
219
 
110
220
  export class CoreMailAuth {
111
- public readonly limiter = new CoreMailAuthLimiter();
221
+ public readonly limiter: CoreMailAuthLimiter;
112
222
 
113
223
  public constructor(
114
224
  private readonly controlBootstrap:
@@ -116,7 +226,9 @@ export class CoreMailAuth {
116
226
  private readonly getActiveDesiredState: () =>
117
227
  Promise<TCoreMailDesiredState | null>,
118
228
  private readonly now: () => number = Date.now,
119
- ) {}
229
+ ) {
230
+ this.limiter = new CoreMailAuthLimiter({ now: this.now });
231
+ }
120
232
 
121
233
  private requireFreshPeer(peerArg: ICoreMailPeer): void {
122
234
  const deadline = peerArg.context.state[coreMailPreAuthDeadlineKey];
@@ -130,19 +242,23 @@ export class CoreMailAuth {
130
242
  }
131
243
  }
132
244
 
133
- private async verify(
134
- verifierArg: TCoreMailVerifier | undefined,
135
- secretArg: string,
136
- ): Promise<void> {
245
+ private requireSecretShape(secretArg: unknown): string {
137
246
  if (
138
- !verifierArg
139
- || typeof secretArg !== 'string'
247
+ typeof secretArg !== 'string'
140
248
  || secretArg.length < 32
141
249
  || secretArg.length > 512
142
250
  ) {
143
251
  throw new CoreMailAuthenticationError();
144
252
  }
145
- const verified = await this.limiter.run(async () =>
253
+ return secretArg;
254
+ }
255
+
256
+ private async verify(
257
+ verifierArg: TCoreMailVerifier,
258
+ secretArg: string,
259
+ identityArg: string,
260
+ ): Promise<void> {
261
+ const verified = await this.limiter.run(identityArg, async () =>
146
262
  plugins.argon2.verify(verifierArg.verificationHash, secretArg)
147
263
  );
148
264
  if (!verified) {
@@ -156,8 +272,10 @@ export class CoreMailAuth {
156
272
  plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailAuthenticateControl['request'],
157
273
  ): Promise<ICoreMailControlSession> {
158
274
  this.requireFreshPeer(peerArg);
275
+ let failureCode = 'INVALID_INPUT';
276
+ let credentialId: string | undefined;
159
277
  try {
160
- const credentialId = requireCoreMailIdentifier(
278
+ credentialId = requireCoreMailIdentifier(
161
279
  requestArg.credentialId,
162
280
  'credentialId',
163
281
  );
@@ -166,13 +284,19 @@ export class CoreMailAuth {
166
284
  'credentialVersion',
167
285
  1,
168
286
  );
287
+ const credentialSecret = this.requireSecretShape(requestArg.credentialSecret);
288
+ failureCode = 'UNKNOWN_CREDENTIAL';
169
289
  const verifier = findAcceptedVerifier(
170
290
  this.controlBootstrap.credentials,
171
291
  credentialId,
172
292
  credentialVersion,
173
293
  this.now(),
174
294
  );
175
- await this.verify(verifier, requestArg.credentialSecret);
295
+ if (!verifier) {
296
+ throw new CoreMailAuthenticationError();
297
+ }
298
+ failureCode = 'VERIFIER_REJECTED';
299
+ await this.verify(verifier, credentialSecret, `control:${credentialId}`);
176
300
  const session = Object.freeze<ICoreMailControlSession>({
177
301
  kind: 'control',
178
302
  peerId: peerArg.id,
@@ -182,8 +306,17 @@ export class CoreMailAuth {
182
306
  });
183
307
  peerArg.data.set(coreMailSessionKey, session);
184
308
  return session;
185
- } catch {
309
+ } catch (error) {
186
310
  peerArg.close(4401, 'authentication failed');
311
+ await logCoreMailFailure(
312
+ 'auth',
313
+ error instanceof CoreMailAuthLimitError ? 'LIMITER_EXHAUSTED' : failureCode,
314
+ undefined,
315
+ {
316
+ kind: 'control',
317
+ ...(credentialId === undefined ? {} : { credentialId }),
318
+ },
319
+ ).catch(() => undefined);
187
320
  throw new CoreMailAuthenticationError();
188
321
  }
189
322
  }
@@ -194,13 +327,16 @@ export class CoreMailAuth {
194
327
  plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailAuthenticateWorkload['request'],
195
328
  ): Promise<ICoreMailWorkloadSession> {
196
329
  this.requireFreshPeer(peerArg);
330
+ let failureCode = 'INVALID_INPUT';
331
+ let bindingId: string | undefined;
332
+ let credentialId: string | undefined;
197
333
  try {
198
334
  const desiredState = await this.getActiveDesiredState();
199
- const bindingId = requireCoreMailIdentifier(
335
+ bindingId = requireCoreMailIdentifier(
200
336
  requestArg.bindingId,
201
337
  'bindingId',
202
338
  );
203
- const credentialId = requireCoreMailIdentifier(
339
+ credentialId = requireCoreMailIdentifier(
204
340
  requestArg.credentialId,
205
341
  'credentialId',
206
342
  );
@@ -209,44 +345,64 @@ export class CoreMailAuth {
209
345
  'credentialVersion',
210
346
  1,
211
347
  );
348
+ const credentialSecret = this.requireSecretShape(requestArg.credentialSecret);
349
+ failureCode = 'UNKNOWN_BINDING';
212
350
  const binding = desiredState?.bindings.find((bindingArg) =>
213
351
  bindingArg.bindingId === bindingId
214
352
  && bindingArg.state !== 'disabled'
215
353
  );
216
- const verifier = binding && findAcceptedVerifier(
354
+ if (!desiredState || !binding || binding.state === 'disabled') {
355
+ throw new CoreMailAuthenticationError();
356
+ }
357
+ failureCode = 'UNKNOWN_CREDENTIAL';
358
+ const verifier = findAcceptedVerifier(
217
359
  binding.credentials,
218
360
  credentialId,
219
361
  credentialVersion,
220
362
  this.now(),
221
363
  );
222
- await this.verify(verifier, requestArg.credentialSecret);
364
+ if (!verifier) {
365
+ throw new CoreMailAuthenticationError();
366
+ }
367
+ failureCode = 'VERIFIER_REJECTED';
368
+ await this.verify(verifier, credentialSecret, `workload:${bindingId}`);
223
369
  const allowedOperations =
224
370
  plugins.serveZoneInterfaces.data.resolveCoreMailWorkloadOperations(
225
- binding!.state,
226
- binding!.capabilities,
371
+ binding.state,
372
+ binding.capabilities,
227
373
  );
228
374
  const session = Object.freeze<ICoreMailWorkloadSession>({
229
375
  kind: 'workload',
230
376
  peerId: peerArg.id,
231
- tenantId: binding!.tenantId,
232
- serviceId: binding!.serviceId,
233
- bindingId: binding!.bindingId,
234
- bindingRevision: binding!.revision,
235
- configEpoch: desiredState!.configEpoch,
377
+ tenantId: binding.tenantId,
378
+ serviceId: binding.serviceId,
379
+ bindingId: binding.bindingId,
380
+ bindingRevision: binding.revision,
381
+ configEpoch: desiredState.configEpoch,
236
382
  credentialId,
237
383
  credentialVersion,
238
- bindingState: binding!.state as 'active' | 'draining',
239
- capabilities: Object.freeze([...binding!.capabilities]) as
384
+ bindingState: binding.state,
385
+ capabilities: Object.freeze([...binding.capabilities]) as
240
386
  plugins.serveZoneInterfaces.data.TCoreMailCapability[],
241
387
  allowedOperations: Object.freeze([...allowedOperations]) as
242
388
  plugins.serveZoneInterfaces.data.TCoreMailWorkloadOperation[],
243
- coreMailTransferOrigin: desiredState!.gateway.coreMailTransferOrigin,
389
+ coreMailTransferOrigin: desiredState.gateway.coreMailTransferOrigin,
244
390
  authenticatedAt: this.now(),
245
391
  });
246
392
  peerArg.data.set(coreMailSessionKey, session);
247
393
  return session;
248
- } catch {
394
+ } catch (error) {
249
395
  peerArg.close(4401, 'authentication failed');
396
+ await logCoreMailFailure(
397
+ 'auth',
398
+ error instanceof CoreMailAuthLimitError ? 'LIMITER_EXHAUSTED' : failureCode,
399
+ undefined,
400
+ {
401
+ kind: 'workload',
402
+ ...(bindingId === undefined ? {} : { bindingId }),
403
+ ...(credentialId === undefined ? {} : { credentialId }),
404
+ },
405
+ ).catch(() => undefined);
250
406
  throw new CoreMailAuthenticationError();
251
407
  }
252
408
  }
@@ -3,8 +3,13 @@ import type { ICoreMailConfig } from './interfaces.js';
3
3
 
4
4
  const requireEnv = (keyArg: string): string => {
5
5
  const value = process.env[keyArg];
6
- if (!value || value.trim() !== value) {
7
- throw new Error(`CoreMail requires a non-empty canonical ${keyArg}.`);
6
+ if (!value) {
7
+ throw new Error(`CoreMail requires ${keyArg} to be set and non-empty.`);
8
+ }
9
+ if (value.trim() !== value) {
10
+ throw new Error(
11
+ `CoreMail requires ${keyArg} to be canonical without surrounding whitespace.`,
12
+ );
8
13
  }
9
14
  return value;
10
15
  };
@@ -145,8 +145,8 @@ export class CoreMailDesiredStateService {
145
145
  modelArg.state = 'active';
146
146
  modelArg.activatedAt = stateArg.updatedAt;
147
147
  },
148
- }).catch(async () => {
149
- await logCoreMailFailure('desired-state', 'SNAPSHOT_REPAIR_FAILED')
148
+ }).catch(async (error) => {
149
+ await logCoreMailFailure('desired-state', 'SNAPSHOT_REPAIR_FAILED', error)
150
150
  .catch(() => undefined);
151
151
  });
152
152
  }
@@ -27,6 +27,21 @@ const gatewayAuthority = (serviceIdArg: string): ITransferAuthority => ({
27
27
  bindingId: '-',
28
28
  });
29
29
 
30
+ export const stableStringify = (valueArg: unknown): string => {
31
+ if (Array.isArray(valueArg)) {
32
+ return `[${valueArg.map(stableStringify).join(',')}]`;
33
+ }
34
+ if (valueArg && typeof valueArg === 'object') {
35
+ const record = valueArg as Record<string, unknown>;
36
+ return `{${Object.keys(record)
37
+ .filter((keyArg) => record[keyArg] !== undefined)
38
+ .sort()
39
+ .map((keyArg) => `${JSON.stringify(keyArg)}:${stableStringify(record[keyArg])}`)
40
+ .join(',')}}`;
41
+ }
42
+ return JSON.stringify(valueArg) ?? 'null';
43
+ };
44
+
30
45
  const normalizeTransferOrigin = (valueArg: unknown): string => {
31
46
  if (typeof valueArg !== 'string') {
32
47
  throw new Error('CoreMail gateway returned an invalid transfer origin.');
@@ -138,7 +153,7 @@ export class CoreMailGateway {
138
153
  if (!gateway) {
139
154
  await this.disconnect();
140
155
  } else {
141
- const signature = JSON.stringify(gateway);
156
+ const signature = stableStringify(gateway);
142
157
  if (
143
158
  !this.socket
144
159
  || signature !== this.socketSignature
@@ -152,8 +167,8 @@ export class CoreMailGateway {
152
167
  this.queueAuthentication(gateway, signature);
153
168
  }
154
169
  }
155
- } catch {
156
- await logCoreMailFailure('gateway', 'RECONCILIATION_FAILED')
170
+ } catch (error) {
171
+ await logCoreMailFailure('gateway', 'RECONCILIATION_FAILED', error)
157
172
  .catch(() => undefined);
158
173
  await this.disconnect();
159
174
  }
@@ -264,12 +279,12 @@ export class CoreMailGateway {
264
279
  this.transferOrigin = transferOrigin;
265
280
  this.authenticatedSignature = signatureArg;
266
281
  })
267
- .catch(async () => {
282
+ .catch(async (error) => {
268
283
  if (this.socketSignature === signatureArg) {
269
284
  this.authenticatedSignature = '';
270
285
  this.transferOrigin = null;
271
286
  }
272
- await logCoreMailFailure('gateway', 'AUTHENTICATION_FAILED')
287
+ await logCoreMailFailure('gateway', 'AUTHENTICATION_FAILED', error)
273
288
  .catch(() => undefined);
274
289
  })
275
290
  .finally(() => {
@@ -596,8 +611,8 @@ export class CoreMailOutboundWorker {
596
611
  private async run(): Promise<void> {
597
612
  while (!this.stopController.signal.aborted) {
598
613
  if (this.gateway.isReady()) {
599
- await this.processBatch().catch(async () => {
600
- await logCoreMailFailure('worker', 'BATCH_FAILED')
614
+ await this.processBatch().catch(async (error) => {
615
+ await logCoreMailFailure('worker', 'BATCH_FAILED', error)
601
616
  .catch(() => undefined);
602
617
  });
603
618
  }
@@ -742,9 +757,10 @@ export class CoreMailOutboundWorker {
742
757
  response.status,
743
758
  leaseTokenArg,
744
759
  );
745
- } catch {
746
- await logCoreMailFailure('worker', 'DELIVERY_ATTEMPT_FAILED')
747
- .catch(() => undefined);
760
+ } catch (error) {
761
+ await logCoreMailFailure('worker', 'DELIVERY_ATTEMPT_FAILED', error, {
762
+ submissionId: submissionArg.submissionId,
763
+ }).catch(() => undefined);
748
764
  const delayMs = Math.min(
749
765
  15 * 60 * 1000,
750
766
  1_000 * (2 ** Math.min(submissionArg.attempts, 9)),
@@ -315,9 +315,10 @@ export class CoreMailMaintenanceService {
315
315
  if (this.sweepTask) return await this.sweepTask;
316
316
  const controller = new AbortController();
317
317
  this.sweepController = controller;
318
- const task = this.sweep(controller.signal).catch(async () => {
318
+ const task = this.sweep(controller.signal).catch(async (error) => {
319
319
  if (!controller.signal.aborted) {
320
- await logCoreMailFailure('retention', 'SWEEP_FAILED').catch(() => undefined);
320
+ await logCoreMailFailure('retention', 'SWEEP_FAILED', error)
321
+ .catch(() => undefined);
321
322
  }
322
323
  });
323
324
  this.sweepTask = task;
@@ -20,6 +20,7 @@ import type {
20
20
  ICoreMailWorkloadSession,
21
21
  } from './interfaces.js';
22
22
  import { CoreMailQuotaExceededError } from './coremail.quota.js';
23
+ import { logCoreMailFailure } from './coremail.log.js';
23
24
 
24
25
  type TErrorCode = plugins.serveZoneInterfaces.data.TCoreMailErrorCode;
25
26
 
@@ -440,7 +441,13 @@ export class CoreMailServer {
440
441
  try {
441
442
  return await handlerArg(requestArg, toolsArg);
442
443
  } catch (error) {
443
- throw privacySafeError(error);
444
+ const safeError = privacySafeError(error);
445
+ if (safeError.errorData?.code === 'INVALID_REQUEST') {
446
+ await logCoreMailFailure('server', 'UNEXPECTED_HANDLER_FAILURE', error, {
447
+ method: methodArg,
448
+ }).catch(() => undefined);
449
+ }
450
+ throw safeError;
444
451
  }
445
452
  },
446
453
  ));
@@ -286,8 +286,11 @@ export class CoreMailTransferService {
286
286
  throw new Error('CoreMail transfer completion fence changed.');
287
287
  }
288
288
  return new Response(null, { status: 204 });
289
- } catch {
289
+ } catch (error) {
290
290
  operationController.abort();
291
+ await logCoreMailFailure('transfer', 'UPLOAD_FAILED', error, {
292
+ grantId: grantArg.grantId,
293
+ }).catch(() => undefined);
291
294
  await this.failGrant(grantArg.grantId);
292
295
  return new Response('Transfer failed', { status: 409 });
293
296
  }
@@ -341,8 +344,11 @@ export class CoreMailTransferService {
341
344
  'X-Content-Type-Options': 'nosniff',
342
345
  },
343
346
  });
344
- } catch {
347
+ } catch (error) {
345
348
  operationController.abort();
349
+ await logCoreMailFailure('transfer', 'DOWNLOAD_FAILED', error, {
350
+ grantId: grantArg.grantId,
351
+ }).catch(() => undefined);
346
352
  await this.failGrant(grantArg.grantId);
347
353
  return new Response('Transfer failed', { status: 409 });
348
354
  }
@@ -371,9 +377,10 @@ export class CoreMailTransferService {
371
377
  throw new Error('CoreMail transfer failure fence changed.');
372
378
  }
373
379
  }
374
- } catch {
375
- await logCoreMailFailure('transfer', 'GRANT_FAILURE_PERSISTENCE_FAILED')
376
- .catch(() => undefined);
380
+ } catch (error) {
381
+ await logCoreMailFailure('transfer', 'GRANT_FAILURE_PERSISTENCE_FAILED', error, {
382
+ grantId: grantIdArg,
383
+ }).catch(() => undefined);
377
384
  }
378
385
  }
379
386
  }
@@ -9,13 +9,51 @@ export const coreMailLogger = plugins.smartlog.Smartlog.createForCommitinfo({
9
9
 
10
10
  coreMailLogger.enableConsole();
11
11
 
12
+ const redactUrlCredentials = (valueArg: string): string =>
13
+ valueArg.replace(/:\/\/[^\s/@]+@/g, '://[redacted]@');
14
+
15
+ export interface ICoreMailSanitizedError {
16
+ name: string;
17
+ message: string;
18
+ }
19
+
20
+ export const sanitizeErrorForLog = (
21
+ errorArg: unknown,
22
+ ): ICoreMailSanitizedError => {
23
+ if (errorArg instanceof Error) {
24
+ return {
25
+ name: errorArg.name,
26
+ message: redactUrlCredentials(errorArg.message).slice(0, 500),
27
+ };
28
+ }
29
+ return {
30
+ name: 'UnknownError',
31
+ message: redactUrlCredentials(String(errorArg)).slice(0, 500),
32
+ };
33
+ };
34
+
12
35
  export const logCoreMailFailure = async (
13
- componentArg: 'desired-state' | 'gateway' | 'retention' | 'transfer' | 'worker',
36
+ componentArg:
37
+ | 'auth'
38
+ | 'desired-state'
39
+ | 'gateway'
40
+ | 'lifecycle'
41
+ | 'retention'
42
+ | 'server'
43
+ | 'transfer'
44
+ | 'worker',
14
45
  codeArg: string,
46
+ errorArg?: unknown,
47
+ contextArg?: Record<string, string | number>,
15
48
  ): Promise<void> => {
16
49
  await coreMailLogger.log(
17
50
  'warn',
18
- 'CoreMail background operation failed.',
19
- { component: componentArg, code: codeArg },
51
+ 'CoreMail operation failed.',
52
+ {
53
+ component: componentArg,
54
+ code: codeArg,
55
+ ...(errorArg === undefined ? {} : { error: sanitizeErrorForLog(errorArg) }),
56
+ ...(contextArg === undefined ? {} : { context: contextArg }),
57
+ },
20
58
  );
21
59
  };