@serve.zone/coremail 1.2.0 → 1.3.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/changelog.md +22 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.auth.d.ts +19 -0
- package/dist_ts/classes.auth.js +58 -1
- package/dist_ts/classes.coremail.d.ts +3 -0
- package/dist_ts/classes.coremail.js +11 -4
- package/dist_ts/classes.gateway.d.ts +11 -1
- package/dist_ts/classes.gateway.js +101 -63
- package/dist_ts/classes.inbound.d.ts +10 -1
- package/dist_ts/classes.inbound.js +45 -7
- package/dist_ts/classes.maintenance.js +16 -1
- package/dist_ts/classes.models.d.ts +24 -2
- package/dist_ts/classes.models.js +72 -2
- package/dist_ts/classes.server.d.ts +6 -12
- package/dist_ts/classes.server.js +18 -78
- package/dist_ts/classes.smtpsubmission.d.ts +85 -0
- package/dist_ts/classes.smtpsubmission.js +379 -0
- package/dist_ts/classes.storage.d.ts +9 -0
- package/dist_ts/classes.storage.js +44 -1
- package/dist_ts/classes.submissions.d.ts +27 -1
- package/dist_ts/classes.submissions.js +164 -14
- package/dist_ts/coremail.errors.d.ts +18 -0
- package/dist_ts/coremail.errors.js +84 -0
- package/dist_ts/coremail.log.d.ts +1 -1
- package/dist_ts/coremail.log.js +1 -1
- package/dist_ts/coremail.mime.d.ts +15 -0
- package/dist_ts/coremail.mime.js +76 -1
- package/dist_ts/coremail.persistence.d.ts +38 -1
- package/dist_ts/coremail.persistence.js +124 -40
- package/dist_ts/coremail.stats.d.ts +72 -0
- package/dist_ts/coremail.stats.js +242 -0
- package/dist_ts/coremail.validation.d.ts +15 -2
- package/dist_ts/coremail.validation.js +26 -78
- package/dist_ts/interfaces.d.ts +18 -0
- package/dist_ts/plugins.d.ts +1 -0
- package/dist_ts/plugins.js +2 -1
- package/package.json +4 -3
- package/readme.md +51 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.auth.ts +77 -0
- package/ts/classes.coremail.ts +20 -0
- package/ts/classes.gateway.ts +121 -66
- package/ts/classes.inbound.ts +71 -6
- package/ts/classes.maintenance.ts +14 -0
- package/ts/classes.models.ts +67 -1
- package/ts/classes.server.ts +22 -86
- package/ts/classes.smtpsubmission.ts +466 -0
- package/ts/classes.storage.ts +54 -0
- package/ts/classes.submissions.ts +247 -12
- package/ts/coremail.errors.ts +111 -0
- package/ts/coremail.log.ts +1 -0
- package/ts/coremail.mime.ts +89 -0
- package/ts/coremail.persistence.ts +196 -48
- package/ts/coremail.stats.ts +342 -0
- package/ts/coremail.validation.ts +31 -103
- package/ts/interfaces.ts +20 -0
- 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.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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:
|
|
@@ -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
|
+
};
|
package/ts/coremail.log.ts
CHANGED
package/ts/coremail.mime.ts
CHANGED
|
@@ -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
|
+
};
|