@serve.zone/coremail 1.2.0 → 32.0.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.
Files changed (60) hide show
  1. package/.smartconfig.json +1 -0
  2. package/changelog.md +50 -0
  3. package/dist_ts/00_commitinfo_data.js +4 -4
  4. package/dist_ts/classes.auth.d.ts +19 -0
  5. package/dist_ts/classes.auth.js +58 -1
  6. package/dist_ts/classes.coremail.d.ts +3 -0
  7. package/dist_ts/classes.coremail.js +11 -4
  8. package/dist_ts/classes.gateway.d.ts +21 -1
  9. package/dist_ts/classes.gateway.js +115 -68
  10. package/dist_ts/classes.inbound.d.ts +10 -1
  11. package/dist_ts/classes.inbound.js +45 -7
  12. package/dist_ts/classes.maintenance.js +16 -1
  13. package/dist_ts/classes.models.d.ts +24 -2
  14. package/dist_ts/classes.models.js +72 -2
  15. package/dist_ts/classes.server.d.ts +6 -12
  16. package/dist_ts/classes.server.js +18 -78
  17. package/dist_ts/classes.smtpsubmission.d.ts +85 -0
  18. package/dist_ts/classes.smtpsubmission.js +379 -0
  19. package/dist_ts/classes.storage.d.ts +9 -0
  20. package/dist_ts/classes.storage.js +44 -1
  21. package/dist_ts/classes.submissions.d.ts +27 -1
  22. package/dist_ts/classes.submissions.js +164 -14
  23. package/dist_ts/coremail.cursor.js +1 -15
  24. package/dist_ts/coremail.errors.d.ts +18 -0
  25. package/dist_ts/coremail.errors.js +84 -0
  26. package/dist_ts/coremail.log.d.ts +1 -1
  27. package/dist_ts/coremail.log.js +3 -6
  28. package/dist_ts/coremail.mime.d.ts +15 -0
  29. package/dist_ts/coremail.mime.js +76 -1
  30. package/dist_ts/coremail.persistence.d.ts +38 -1
  31. package/dist_ts/coremail.persistence.js +123 -40
  32. package/dist_ts/coremail.stats.d.ts +72 -0
  33. package/dist_ts/coremail.stats.js +242 -0
  34. package/dist_ts/coremail.validation.d.ts +15 -2
  35. package/dist_ts/coremail.validation.js +26 -79
  36. package/dist_ts/interfaces.d.ts +18 -0
  37. package/dist_ts/plugins.d.ts +1 -0
  38. package/dist_ts/plugins.js +2 -1
  39. package/package.json +6 -5
  40. package/readme.md +66 -8
  41. package/ts/00_commitinfo_data.ts +3 -3
  42. package/ts/classes.auth.ts +77 -0
  43. package/ts/classes.coremail.ts +20 -0
  44. package/ts/classes.gateway.ts +142 -71
  45. package/ts/classes.inbound.ts +71 -6
  46. package/ts/classes.maintenance.ts +14 -0
  47. package/ts/classes.models.ts +67 -1
  48. package/ts/classes.server.ts +22 -86
  49. package/ts/classes.smtpsubmission.ts +466 -0
  50. package/ts/classes.storage.ts +54 -0
  51. package/ts/classes.submissions.ts +247 -12
  52. package/ts/coremail.cursor.ts +2 -18
  53. package/ts/coremail.errors.ts +111 -0
  54. package/ts/coremail.log.ts +3 -6
  55. package/ts/coremail.mime.ts +89 -0
  56. package/ts/coremail.persistence.ts +195 -48
  57. package/ts/coremail.stats.ts +342 -0
  58. package/ts/coremail.validation.ts +31 -104
  59. package/ts/interfaces.ts +20 -0
  60. package/ts/plugins.ts +1 -0
@@ -1,9 +1,16 @@
1
1
  import * as plugins from './plugins.js';
2
+ import {
3
+ CoreMailAuthorizationError,
4
+ CoreMailCapabilityDeniedError,
5
+ } from './classes.auth.js';
2
6
  import { type ICoreMailModels } from './classes.models.js';
3
7
  import { CoreMailStorage, createSha256 } from './classes.storage.js';
4
8
  import { CoreMailTransferService } from './classes.transfer.js';
5
9
  import {
10
+ assertCoreMailSenderAllowed,
11
+ createCoreMailRawOutboundMessage,
6
12
  createCoreMailSubmissionDigest,
13
+ normalizeCoreMailEnvelope,
7
14
  normalizeCoreMailOutboundMessage,
8
15
  } from './coremail.validation.js';
9
16
  import { createCoreMailMime } from './coremail.mime.js';
@@ -13,12 +20,18 @@ import {
13
20
  incrementCoreMailQuotaCounter,
14
21
  withCoreMailTransaction,
15
22
  } from './coremail.quota.js';
23
+ import {
24
+ ensureCoreMailStatCounter,
25
+ createCoreMailStatDescriptor,
26
+ incrementCoreMailStatCounter,
27
+ } from './coremail.stats.js';
16
28
  import {
17
29
  requireCoreMailIdentifier,
18
30
  requireCoreMailUuid,
19
31
  } from './coremail.selectors.js';
20
32
  import type { ICoreMailSubmissionRecord } from './coremail.persistence.js';
21
33
  import type {
34
+ ICoreMailRawSubmissionInput,
22
35
  ICoreMailWorkloadSession,
23
36
  ISubmissionPartRecord,
24
37
  ITransferAuthority,
@@ -30,6 +43,42 @@ type TStoredSubmission =
30
43
  plugins.smartdata.TStoredDocument<ICoreMailSubmissionRecord>;
31
44
 
32
45
  const maximumPartBytes = plugins.serveZoneInterfaces.data.coreMailLimits.attachmentBytes;
46
+ const maximumRawMimeBytes =
47
+ plugins.serveZoneInterfaces.data.coreMailLimits.serializedMimeBytes;
48
+
49
+ /**
50
+ * The operation that authorizes creating a new outbound submission. An SMTP
51
+ * submission creates one without a typed request, so it is gated by exactly
52
+ * the rule `CoreMailAuthService.requireWorkloadSession` applies to its API
53
+ * counterpart.
54
+ */
55
+ const rawSubmissionOperation = 'coreMailPrepareOutboundSubmission' as const;
56
+
57
+ const assertRawSubmissionAuthority = (
58
+ sessionArg: ICoreMailWorkloadSession,
59
+ bindingArg: TBinding,
60
+ ): void => {
61
+ if (
62
+ bindingArg.tenantId !== sessionArg.tenantId
63
+ || bindingArg.serviceId !== sessionArg.serviceId
64
+ || bindingArg.bindingId !== sessionArg.bindingId
65
+ || bindingArg.revision !== sessionArg.bindingRevision
66
+ || bindingArg.state !== sessionArg.bindingState
67
+ ) {
68
+ throw new CoreMailAuthorizationError();
69
+ }
70
+ const allowedOperations =
71
+ plugins.serveZoneInterfaces.data.resolveCoreMailWorkloadOperations(
72
+ bindingArg.state,
73
+ bindingArg.capabilities,
74
+ );
75
+ if (
76
+ !allowedOperations.includes(rawSubmissionOperation)
77
+ || !sessionArg.allowedOperations.includes(rawSubmissionOperation)
78
+ ) {
79
+ throw new CoreMailCapabilityDeniedError();
80
+ }
81
+ };
33
82
 
34
83
  const authorityFilter = (
35
84
  sessionArg: ICoreMailWorkloadSession,
@@ -60,6 +109,11 @@ const toPublicSubmission = (
60
109
  idempotencyKey: submissionArg.idempotencyKey,
61
110
  submissionDigest: submissionArg.submissionDigest,
62
111
  state: submissionArg.state,
112
+ // Records written before the discriminator existed are API submissions,
113
+ // and the wire type omits the field for exactly that case.
114
+ ...(submissionArg.source === undefined || submissionArg.source === 'api'
115
+ ? {}
116
+ : { source: submissionArg.source }),
63
117
  parts: submissionArg.parts.map((partArg) => ({
64
118
  partId: partArg.partId,
65
119
  state: partArg.state,
@@ -140,6 +194,7 @@ export class CoreMailSubmissionService {
140
194
  idempotencyKey: requestArg.idempotencyKey,
141
195
  submissionDigest,
142
196
  state: 'preparing',
197
+ source: 'api',
143
198
  message,
144
199
  parts,
145
200
  attempts: 0,
@@ -223,6 +278,146 @@ export class CoreMailSubmissionService {
223
278
  }
224
279
  }
225
280
 
281
+ /**
282
+ * Accept an exact-byte SMTP submission from an authenticated MSA session.
283
+ *
284
+ * CoreMail never parses, rewrites, or re-encodes `rawMime`. The bytes become
285
+ * the submission's immutable MIME directly, so the existing outbound worker
286
+ * and gateway hand an SMTP submission off exactly as they hand off an API
287
+ * submission that `finalize()` has published.
288
+ *
289
+ * The write order mirrors `finalize()`: the record that owns the object key
290
+ * is committed first and only becomes worker-visible once the bytes are
291
+ * durably stored, so a failure between the two steps leaves a retirable
292
+ * record rather than an object no record refers to.
293
+ */
294
+ public async submitRaw(
295
+ sessionArg: ICoreMailWorkloadSession,
296
+ bindingArg: TBinding,
297
+ inputArg: ICoreMailRawSubmissionInput,
298
+ ): Promise<{
299
+ submissionId: string;
300
+ submissionDigest: plugins.serveZoneInterfaces.data.TCoreMailSha256;
301
+ }> {
302
+ assertRawSubmissionAuthority(sessionArg, bindingArg);
303
+ const envelope = normalizeCoreMailEnvelope({
304
+ mailFrom: inputArg.envelope.from,
305
+ rcptTo: inputArg.envelope.to,
306
+ });
307
+ if (envelope.mailFrom === '') {
308
+ throw new Error('CoreMail raw submission requires an envelope sender.');
309
+ }
310
+ assertCoreMailSenderAllowed(envelope.mailFrom, bindingArg);
311
+ // Buffer is a Uint8Array subclass, so a listener may hand either over.
312
+ if (
313
+ !(inputArg.rawMime instanceof Uint8Array)
314
+ || inputArg.rawMime.byteLength === 0
315
+ ) {
316
+ throw new Error('CoreMail raw submission requires nonempty RFC822 bytes.');
317
+ }
318
+ if (inputArg.rawMime.byteLength > maximumRawMimeBytes) {
319
+ throw new Error('CoreMail raw submission exceeds its byte budget.');
320
+ }
321
+ const rawMime = new Uint8Array(inputArg.rawMime);
322
+ const message = createCoreMailRawOutboundMessage(envelope);
323
+ const idempotencyKey = `smtp:${plugins.nodeCrypto.randomUUID()}`;
324
+ const submissionDigest = createCoreMailSubmissionDigest(
325
+ idempotencyKey,
326
+ message,
327
+ );
328
+ const submissionId = plugins.nodeCrypto.randomUUID();
329
+ const mimeObjectKey =
330
+ `outbound-mime/${submissionId}/${submissionDigest.slice(7)}`;
331
+ const mimeSha256 = createSha256(rawMime);
332
+ const createdAt = this.now();
333
+ const submission: ICoreMailSubmissionRecord = {
334
+ submissionId,
335
+ tenantId: sessionArg.tenantId,
336
+ serviceId: sessionArg.serviceId,
337
+ bindingId: sessionArg.bindingId,
338
+ bindingRevision: sessionArg.bindingRevision,
339
+ revision: 0,
340
+ idempotencyKey,
341
+ submissionDigest,
342
+ state: 'ready',
343
+ source: 'smtp',
344
+ message,
345
+ parts: [],
346
+ mimeObjectKey,
347
+ mimeSha256,
348
+ mimeLengthBytes: rawMime.byteLength,
349
+ attempts: 0,
350
+ createdAt,
351
+ updatedAt: createdAt,
352
+ };
353
+ const quotaNow = this.now();
354
+ const quota = createCoreMailOutboundQuotaDescriptors({
355
+ tenantId: sessionArg.tenantId,
356
+ serviceId: sessionArg.serviceId,
357
+ bindingId: sessionArg.bindingId,
358
+ bindingRevision: sessionArg.bindingRevision,
359
+ }, quotaNow);
360
+ await ensureCoreMailQuotaCounter(this.models, quota.minute);
361
+ await ensureCoreMailQuotaCounter(this.models, quota.day);
362
+ await withCoreMailTransaction(this.database, async (transactionArg) => {
363
+ await incrementCoreMailQuotaCounter(
364
+ this.models,
365
+ quota.day,
366
+ bindingArg.limits.messagesPerDay,
367
+ quotaNow,
368
+ transactionArg,
369
+ quota.dayRetryAfterMs,
370
+ );
371
+ await incrementCoreMailQuotaCounter(
372
+ this.models,
373
+ quota.minute,
374
+ bindingArg.limits.messagesPerMinute,
375
+ quotaNow,
376
+ transactionArg,
377
+ quota.minuteRetryAfterMs,
378
+ );
379
+ const inserted = await this.models.Submission.exact.insert(
380
+ submission,
381
+ { session: transactionArg },
382
+ );
383
+ if (inserted.status === 'conflict') {
384
+ throw new Error('CoreMail submission identity conflicts with persisted intent.');
385
+ }
386
+ return { submissionId };
387
+ });
388
+ const committed = await this.models.Submission.exact.findStoredOne({
389
+ ...authorityFilter(sessionArg, submissionId),
390
+ });
391
+ if (!committed || committed.submissionDigest !== submissionDigest) {
392
+ throw new Error('CoreMail committed submission is unavailable.');
393
+ }
394
+ const published = await this.storage.putBytesExact(
395
+ mimeObjectKey,
396
+ rawMime,
397
+ 'message/rfc822',
398
+ maximumRawMimeBytes,
399
+ AbortSignal.timeout(
400
+ plugins.serveZoneInterfaces.data.coreMailLimits.transferOverallTimeoutMs,
401
+ ),
402
+ );
403
+ if (
404
+ published.sha256 !== mimeSha256
405
+ || published.lengthBytes !== rawMime.byteLength
406
+ ) {
407
+ throw new Error('CoreMail published MIME integrity conflicts.');
408
+ }
409
+ const queuedAt = this.now();
410
+ const handoff = await this.publishSubmission(
411
+ committed,
412
+ 'submittedSmtp',
413
+ queuedAt,
414
+ );
415
+ if (handoff.status !== 'transitioned') {
416
+ throw new Error('CoreMail raw submission publication fence changed.');
417
+ }
418
+ return { submissionId, submissionDigest };
419
+ }
420
+
226
421
  public async preparePartUpload(
227
422
  sessionArg: ICoreMailWorkloadSession,
228
423
  requestArg:
@@ -491,18 +686,11 @@ export class CoreMailSubmissionService {
491
686
  throw new Error('CoreMail published MIME integrity conflicts.');
492
687
  }
493
688
  const queuedAt = this.now();
494
- const update = await this.models.Submission.exact.transition({
495
- current: claim.document,
496
- change: (modelArg) => {
497
- modelArg.mimePublishedAt = queuedAt;
498
- modelArg.workerRetryAt = queuedAt;
499
- modelArg.updatedAt = queuedAt;
500
- modelArg.revision += 1;
501
- delete modelArg.leaseOwnerTaskId;
502
- delete modelArg.leaseToken;
503
- delete modelArg.leaseExpiresAt;
504
- },
505
- });
689
+ const update = await this.publishSubmission(
690
+ claim.document,
691
+ 'submittedApi',
692
+ queuedAt,
693
+ );
506
694
  if (update.status !== 'transitioned') {
507
695
  const replay = await this.requireSubmission(
508
696
  sessionArg,
@@ -527,6 +715,53 @@ export class CoreMailSubmissionService {
527
715
  };
528
716
  }
529
717
 
718
+ /**
719
+ * Publish an owned immutable MIME and hand the submission to the worker.
720
+ *
721
+ * The submitted counter is incremented in the same transaction as the
722
+ * transition that publishes, and only when `statsSubmittedCountedAt` is
723
+ * still absent. An interrupted publication that finalization later repeats
724
+ * therefore republishes without counting a second submission.
725
+ */
726
+ private async publishSubmission(
727
+ currentArg: TStoredSubmission,
728
+ kindArg: 'submittedApi' | 'submittedSmtp',
729
+ queuedAtArg: number,
730
+ ): Promise<plugins.smartdata.TExactTransitionResult<ICoreMailSubmissionRecord>> {
731
+ const alreadyCounted = currentArg.statsSubmittedCountedAt !== undefined;
732
+ const descriptor = createCoreMailStatDescriptor(currentArg, queuedAtArg);
733
+ if (!alreadyCounted) {
734
+ await ensureCoreMailStatCounter(this.models, descriptor);
735
+ }
736
+ return await withCoreMailTransaction(this.database, async (transactionArg) => {
737
+ const transitioned = await this.models.Submission.exact.transition({
738
+ current: currentArg,
739
+ change: (modelArg) => {
740
+ modelArg.mimePublishedAt = queuedAtArg;
741
+ modelArg.workerRetryAt = queuedAtArg;
742
+ modelArg.updatedAt = queuedAtArg;
743
+ modelArg.revision += 1;
744
+ if (!alreadyCounted) {
745
+ modelArg.statsSubmittedCountedAt = queuedAtArg;
746
+ }
747
+ delete modelArg.leaseOwnerTaskId;
748
+ delete modelArg.leaseToken;
749
+ delete modelArg.leaseExpiresAt;
750
+ },
751
+ }, { session: transactionArg });
752
+ if (transitioned.status === 'transitioned' && !alreadyCounted) {
753
+ await incrementCoreMailStatCounter(
754
+ this.models,
755
+ descriptor,
756
+ kindArg,
757
+ queuedAtArg,
758
+ transactionArg,
759
+ );
760
+ }
761
+ return transitioned;
762
+ });
763
+ }
764
+
530
765
  public async get(
531
766
  sessionArg: ICoreMailWorkloadSession,
532
767
  requestArg:
@@ -18,13 +18,11 @@ export interface ICoreMailInboundCursorPosition {
18
18
  deliveryId: string;
19
19
  }
20
20
 
21
+ /** Exactly the authority a cursor is bound to plus the position it marks. */
21
22
  interface ICoreMailInboundCursorPayload
22
- extends ICoreMailInboundCursorAuthority, ICoreMailInboundCursorPosition {
23
- version: 1;
24
- }
23
+ extends ICoreMailInboundCursorAuthority, ICoreMailInboundCursorPosition {}
25
24
 
26
25
  interface ICoreMailInboundCursorEnvelope {
27
- version: 1;
28
26
  keyId: string;
29
27
  keyVersion: number;
30
28
  payload: string;
@@ -36,7 +34,6 @@ type TCursorKey = plugins.serveZoneInterfaces.data.ICoreMailRuntimeKeyReference;
36
34
  const maximumCursorAgeMs = 24 * 60 * 60 * 1_000;
37
35
  const maximumFutureSkewMs = 1_000;
38
36
  const payloadKeys = new Set([
39
- 'version',
40
37
  'tenantId',
41
38
  'serviceId',
42
39
  'bindingId',
@@ -46,7 +43,6 @@ const payloadKeys = new Set([
46
43
  'deliveryId',
47
44
  ]);
48
45
  const envelopeKeys = new Set([
49
- 'version',
50
46
  'keyId',
51
47
  'keyVersion',
52
48
  'payload',
@@ -56,7 +52,6 @@ const envelopeKeys = new Set([
56
52
  const canonicalPayload = (
57
53
  payloadArg: ICoreMailInboundCursorPayload,
58
54
  ): string => JSON.stringify({
59
- version: payloadArg.version,
60
55
  tenantId: payloadArg.tenantId,
61
56
  serviceId: payloadArg.serviceId,
62
57
  bindingId: payloadArg.bindingId,
@@ -69,7 +64,6 @@ const canonicalPayload = (
69
64
  const canonicalEnvelope = (
70
65
  envelopeArg: ICoreMailInboundCursorEnvelope,
71
66
  ): string => JSON.stringify({
72
- version: envelopeArg.version,
73
67
  keyId: envelopeArg.keyId,
74
68
  keyVersion: envelopeArg.keyVersion,
75
69
  payload: envelopeArg.payload,
@@ -128,11 +122,7 @@ const parseCanonicalRecord = (
128
122
 
129
123
  const parsePayload = (encodedArg: string): ICoreMailInboundCursorPayload => {
130
124
  const value = parseCanonicalRecord(encodedArg, payloadKeys);
131
- if (value.version !== 1) {
132
- throw new Error('CoreMail inbound cursor version is unsupported.');
133
- }
134
125
  const payload: ICoreMailInboundCursorPayload = {
135
- version: 1,
136
126
  tenantId: requireCoreMailIdentifier(value.tenantId, 'cursor.tenantId'),
137
127
  serviceId: requireCoreMailIdentifier(value.serviceId, 'cursor.serviceId'),
138
128
  bindingId: requireCoreMailIdentifier(value.bindingId, 'cursor.bindingId'),
@@ -201,7 +191,6 @@ export class CoreMailInboundCursorCodec {
201
191
  }
202
192
  const authority = requireAuthority(authorityArg);
203
193
  const payload: ICoreMailInboundCursorPayload = {
204
- version: 1,
205
194
  ...authority,
206
195
  snapshotAt: requireCoreMailSafeInteger(
207
196
  positionArg.snapshotAt,
@@ -224,7 +213,6 @@ export class CoreMailInboundCursorCodec {
224
213
  'utf8',
225
214
  ).toString('base64url');
226
215
  const envelope: ICoreMailInboundCursorEnvelope = {
227
- version: 1,
228
216
  keyId: currentKey.keyId,
229
217
  keyVersion: currentKey.version,
230
218
  payload: encodedPayload,
@@ -250,9 +238,6 @@ export class CoreMailInboundCursorCodec {
250
238
  cursorArg,
251
239
  );
252
240
  const value = parseCanonicalRecord(cursor, envelopeKeys);
253
- if (value.version !== 1) {
254
- throw new Error('CoreMail inbound cursor version is unsupported.');
255
- }
256
241
  const now = requireCoreMailSafeInteger(nowArg, 'cursor.now');
257
242
  const keyId = requireCoreMailIdentifier(value.keyId, 'cursor.keyId');
258
243
  const keyVersion = requireCoreMailSafeInteger(
@@ -264,7 +249,6 @@ export class CoreMailInboundCursorCodec {
264
249
  throw new Error('CoreMail inbound cursor is invalid.');
265
250
  }
266
251
  const envelope: ICoreMailInboundCursorEnvelope = {
267
- version: 1,
268
252
  keyId,
269
253
  keyVersion,
270
254
  payload: value.payload,
@@ -0,0 +1,111 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ CoreMailAuthenticationError,
4
+ CoreMailAuthorizationError,
5
+ CoreMailCapabilityDeniedError,
6
+ } from './classes.auth.js';
7
+ import { CoreMailQuotaExceededError } from './coremail.quota.js';
8
+ import { logCoreMailFailure } from './coremail.log.js';
9
+
10
+ type TErrorCode = plugins.serveZoneInterfaces.data.TCoreMailErrorCode;
11
+
12
+ export const privacySafeError = (
13
+ errorArg: unknown,
14
+ ): plugins.typedrequest.TypedResponseError => {
15
+ let code: TErrorCode = 'INVALID_REQUEST';
16
+ let retryable = false;
17
+ if (errorArg instanceof CoreMailAuthenticationError) {
18
+ code = 'AUTHENTICATION_FAILED';
19
+ } else if (errorArg instanceof CoreMailCapabilityDeniedError) {
20
+ code = 'CAPABILITY_DENIED';
21
+ } else if (errorArg instanceof CoreMailAuthorizationError) {
22
+ code = 'AUTHORITY_REVOKED';
23
+ } else if (errorArg instanceof CoreMailQuotaExceededError) {
24
+ return new plugins.typedrequest.TypedResponseError('CoreMail request rejected.', {
25
+ code: 'QUOTA_EXCEEDED',
26
+ retryable: true,
27
+ ...(errorArg.retryAfterMs === undefined
28
+ ? {}
29
+ : { retryAfterMs: errorArg.retryAfterMs }),
30
+ } satisfies plugins.serveZoneInterfaces.data.ICoreMailErrorData);
31
+ } else if (
32
+ errorArg instanceof plugins.serveZoneInterfaces.data.CoreMailContractError
33
+ ) {
34
+ code = errorArg.code;
35
+ } else if (
36
+ errorArg instanceof Error
37
+ && errorArg.message.includes('gateway')
38
+ && errorArg.message.includes('unavailable')
39
+ ) {
40
+ code = 'GATEWAY_UNAVAILABLE';
41
+ retryable = true;
42
+ } else if (
43
+ errorArg instanceof Error
44
+ && errorArg.message.includes('idempotency')
45
+ ) {
46
+ code = 'IDEMPOTENCY_CONFLICT';
47
+ } else if (
48
+ errorArg instanceof Error
49
+ && errorArg.message.includes('fence')
50
+ ) {
51
+ code = 'STATE_CONFLICT';
52
+ retryable = true;
53
+ } else if (
54
+ errorArg instanceof Error
55
+ && (
56
+ errorArg.message.includes('byte budget')
57
+ || errorArg.message.includes('content budget')
58
+ || errorArg.message.includes('exceeds')
59
+ )
60
+ ) {
61
+ code = 'PAYLOAD_LIMIT_EXCEEDED';
62
+ }
63
+ return new plugins.typedrequest.TypedResponseError('CoreMail request rejected.', {
64
+ code,
65
+ retryable,
66
+ } satisfies plugins.serveZoneInterfaces.data.ICoreMailErrorData);
67
+ };
68
+
69
+ /**
70
+ * Register one typed handler with uniform failure redaction.
71
+ *
72
+ * Both peer-facing routers use this. The workload socket and the gateway
73
+ * socket are different peers, but a caller on either side has to be able to
74
+ * tell `QUOTA_EXCEEDED` from `STATE_CONFLICT`, so both must answer with the
75
+ * typed `ICoreMailErrorData` envelope rather than an opaque message.
76
+ *
77
+ * `handlerArg` is typed as typedrequest's own `THandlerFunction` rather than
78
+ * a hand-written signature: since typedrequest 8 both sides are wrapped in
79
+ * the `TReverseVirtualStreamDirections` mapped type, which does not reduce
80
+ * inside a generic wrapper, so any restatement of the shape stops being
81
+ * assignable. Concrete `IReq_*` types reduce normally, which is why the call
82
+ * sites need no change.
83
+ */
84
+ export const addPrivacySafeTypedHandler = <
85
+ TRequest extends plugins.typedrequestInterfaces.ITypedRequest,
86
+ >(
87
+ routerArg: plugins.typedrequest.TypedRouter,
88
+ scopeArg: 'server' | 'gateway',
89
+ methodArg: TRequest['method'],
90
+ handlerArg: plugins.typedrequest.THandlerFunction<TRequest>,
91
+ ): void => {
92
+ routerArg.addTypedHandler(new plugins.typedrequest.TypedHandler<TRequest>(
93
+ methodArg,
94
+ async (requestArg, toolsArg) => {
95
+ try {
96
+ return await handlerArg(requestArg, toolsArg);
97
+ } catch (error) {
98
+ const safeError = privacySafeError(error);
99
+ if (safeError.errorData?.code === 'INVALID_REQUEST') {
100
+ await logCoreMailFailure(
101
+ scopeArg,
102
+ 'UNEXPECTED_HANDLER_FAILURE',
103
+ error,
104
+ { method: methodArg },
105
+ ).catch(() => undefined);
106
+ }
107
+ throw safeError;
108
+ }
109
+ },
110
+ ));
111
+ };
@@ -1,11 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { commitinfo } from './00_commitinfo_data.js';
2
3
 
3
- export const coreMailLogger = plugins.smartlog.Smartlog.createForCommitinfo({
4
- name: '@serve.zone/coremail',
5
- version: '1.0.0',
6
- description:
7
- 'Authenticated mail persistence and delivery orchestration for serve.zone workloads.',
8
- });
4
+ export const coreMailLogger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
9
5
 
10
6
  coreMailLogger.enableConsole();
11
7
 
@@ -40,6 +36,7 @@ export const logCoreMailFailure = async (
40
36
  | 'lifecycle'
41
37
  | 'retention'
42
38
  | 'server'
39
+ | 'smtp'
43
40
  | 'transfer'
44
41
  | 'worker',
45
42
  codeArg: string,
@@ -92,3 +92,92 @@ export const createCoreMailMime = (
92
92
  }
93
93
  return encoded;
94
94
  };
95
+
96
+ /** Bytes of a stored message that are read to find its headers. */
97
+ export const coreMailHeaderScanBytes = 64 * 1024;
98
+
99
+ const decodeEncodedWords = (valueArg: string): string =>
100
+ valueArg.replace(
101
+ /=\?([A-Za-z0-9._-]+)\?([BbQq])\?([^?]*)\?=/g,
102
+ (matchArg, charsetArg: string, encodingArg: string, payloadArg: string) => {
103
+ const charset = charsetArg.toLowerCase();
104
+ // Only the charsets Node decodes natively; anything else keeps its raw
105
+ // encoded-word so nothing is silently corrupted.
106
+ if (charset !== 'utf-8' && charset !== 'us-ascii' && charset !== 'iso-8859-1') {
107
+ return matchArg;
108
+ }
109
+ const nodeCharset = charset === 'iso-8859-1' ? 'latin1' : 'utf8';
110
+ try {
111
+ if (encodingArg.toLowerCase() === 'b') {
112
+ return Buffer.from(payloadArg, 'base64').toString(nodeCharset);
113
+ }
114
+ const quoted = payloadArg
115
+ .replaceAll('_', ' ')
116
+ .replace(/=([0-9A-Fa-f]{2})/g, (_matchArg, hexArg: string) =>
117
+ String.fromCharCode(Number.parseInt(hexArg, 16))
118
+ );
119
+ return Buffer.from(quoted, 'binary').toString(nodeCharset);
120
+ } catch {
121
+ return matchArg;
122
+ }
123
+ },
124
+ );
125
+
126
+ const sanitizeHeaderValue = (
127
+ valueArg: string,
128
+ maximumBytesArg: number,
129
+ ): string | undefined => {
130
+ let value = valueArg.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
131
+ if (!value) return undefined;
132
+ while (Buffer.byteLength(value, 'utf8') > maximumBytesArg) {
133
+ value = value.slice(0, -1).trim();
134
+ if (!value) return undefined;
135
+ }
136
+ return value;
137
+ };
138
+
139
+ /**
140
+ * Read `Message-ID` and `Subject` out of an RFC 5322 header block.
141
+ *
142
+ * `bytesArg` is a bounded prefix of the stored message, so a header block that
143
+ * does not end inside it is treated as absent rather than guessed at. Folded
144
+ * continuation lines are unfolded, `Subject` encoded-words are decoded when
145
+ * that is possible without a dependency, and both values are trimmed to the
146
+ * byte budgets the delivery record enforces.
147
+ */
148
+ export const parseCoreMailMessageHeaders = (
149
+ bytesArg: Uint8Array,
150
+ ): { messageId?: string; subject?: string } => {
151
+ const text = Buffer.from(bytesArg).toString('binary');
152
+ const boundary = text.indexOf('\r\n\r\n');
153
+ const headerSection = boundary < 0 ? text : text.slice(0, boundary);
154
+ const unfolded: string[] = [];
155
+ for (const rawLine of headerSection.split('\r\n')) {
156
+ if (/^[ \t]/.test(rawLine) && unfolded.length > 0) {
157
+ unfolded[unfolded.length - 1] += ` ${rawLine.trim()}`;
158
+ continue;
159
+ }
160
+ unfolded.push(rawLine);
161
+ }
162
+ const result: { messageId?: string; subject?: string } = {};
163
+ for (const line of unfolded) {
164
+ const separator = line.indexOf(':');
165
+ if (separator < 1) continue;
166
+ const name = line.slice(0, separator).trim().toLowerCase();
167
+ const rawValue = line.slice(separator + 1);
168
+ if (name === 'message-id' && result.messageId === undefined) {
169
+ const value = sanitizeHeaderValue(
170
+ Buffer.from(rawValue, 'binary').toString('utf8'),
171
+ 998,
172
+ );
173
+ if (value) result.messageId = value;
174
+ } else if (name === 'subject' && result.subject === undefined) {
175
+ const value = sanitizeHeaderValue(
176
+ decodeEncodedWords(Buffer.from(rawValue, 'binary').toString('utf8')),
177
+ 768,
178
+ );
179
+ if (value) result.subject = value;
180
+ }
181
+ }
182
+ return result;
183
+ };