@serve.zone/coremail 1.1.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 +43 -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 -2
- package/dist_ts/classes.server.js +18 -68
- 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 +12 -11
- 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 +23 -80
- 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
package/ts/classes.auth.ts
CHANGED
|
@@ -321,6 +321,83 @@ export class CoreMailAuth {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Verify an SMTP submission credential.
|
|
326
|
+
*
|
|
327
|
+
* SMTP AUTH carries no credential id, so every currently accepted verifier
|
|
328
|
+
* of the binding is tried. Verification, the argon2id code and the
|
|
329
|
+
* per-identity limiter are the socket path's: the identity key is the same
|
|
330
|
+
* `workload:<bindingId>`, so a brute force cannot escape the limit by
|
|
331
|
+
* switching surface.
|
|
332
|
+
*
|
|
333
|
+
* Returns undefined for every refusal — an unknown binding, a binding that
|
|
334
|
+
* is not active or not outbound-capable, and a wrong secret are deliberately
|
|
335
|
+
* indistinguishable to the caller.
|
|
336
|
+
*/
|
|
337
|
+
public async authenticateSmtpSubmission(
|
|
338
|
+
bindingIdArg: string,
|
|
339
|
+
secretArg: string,
|
|
340
|
+
): Promise<{
|
|
341
|
+
binding: plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState;
|
|
342
|
+
configEpoch: number;
|
|
343
|
+
credentialId: string;
|
|
344
|
+
credentialVersion: number;
|
|
345
|
+
} | undefined> {
|
|
346
|
+
let failureCode = 'INVALID_INPUT';
|
|
347
|
+
try {
|
|
348
|
+
const desiredState = await this.getActiveDesiredState();
|
|
349
|
+
const bindingId = requireCoreMailIdentifier(bindingIdArg, 'bindingId');
|
|
350
|
+
const secret = this.requireSecretShape(secretArg);
|
|
351
|
+
failureCode = 'UNKNOWN_BINDING';
|
|
352
|
+
const binding = desiredState?.bindings.find((bindingArg) =>
|
|
353
|
+
bindingArg.bindingId === bindingId
|
|
354
|
+
&& bindingArg.state === 'active'
|
|
355
|
+
&& bindingArg.capabilities.includes('outbound')
|
|
356
|
+
);
|
|
357
|
+
if (!desiredState || !binding) {
|
|
358
|
+
throw new CoreMailAuthenticationError();
|
|
359
|
+
}
|
|
360
|
+
failureCode = 'UNKNOWN_CREDENTIAL';
|
|
361
|
+
const accepted = binding.credentials.filter((credentialArg) =>
|
|
362
|
+
findAcceptedVerifier(
|
|
363
|
+
binding.credentials,
|
|
364
|
+
credentialArg.credentialId,
|
|
365
|
+
credentialArg.version,
|
|
366
|
+
this.now(),
|
|
367
|
+
) !== undefined
|
|
368
|
+
);
|
|
369
|
+
if (accepted.length === 0) {
|
|
370
|
+
throw new CoreMailAuthenticationError();
|
|
371
|
+
}
|
|
372
|
+
failureCode = 'VERIFIER_REJECTED';
|
|
373
|
+
for (const verifier of accepted) {
|
|
374
|
+
try {
|
|
375
|
+
await this.verify(verifier, secret, `workload:${bindingId}`);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error instanceof CoreMailAuthLimitError) throw error;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
binding,
|
|
382
|
+
configEpoch: desiredState.configEpoch,
|
|
383
|
+
credentialId: verifier.credentialId,
|
|
384
|
+
credentialVersion: verifier.version,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
throw new CoreMailAuthenticationError();
|
|
388
|
+
} catch (error) {
|
|
389
|
+
await logCoreMailFailure(
|
|
390
|
+
'auth',
|
|
391
|
+
error instanceof CoreMailAuthLimitError
|
|
392
|
+
? 'LIMITER_EXHAUSTED'
|
|
393
|
+
: failureCode,
|
|
394
|
+
undefined,
|
|
395
|
+
{ surface: 'smtp' },
|
|
396
|
+
).catch(() => undefined);
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
324
401
|
public async authenticateWorkload(
|
|
325
402
|
peerArg: ICoreMailPeer,
|
|
326
403
|
requestArg:
|
package/ts/classes.coremail.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type ICoreMailModels,
|
|
15
15
|
} from './classes.models.js';
|
|
16
16
|
import { CoreMailServer } from './classes.server.js';
|
|
17
|
+
import { CoreMailSmtpSubmissionServer } from './classes.smtpsubmission.js';
|
|
17
18
|
import { CoreMailStorage } from './classes.storage.js';
|
|
18
19
|
import { CoreMailSubmissionService } from './classes.submissions.js';
|
|
19
20
|
import { CoreMailTransferService } from './classes.transfer.js';
|
|
@@ -39,6 +40,7 @@ export class CoreMail {
|
|
|
39
40
|
public readonly inbound: CoreMailInboundService;
|
|
40
41
|
public readonly maintenance: CoreMailMaintenanceService;
|
|
41
42
|
public readonly gateway: CoreMailGateway;
|
|
43
|
+
public readonly smtpSubmission: CoreMailSmtpSubmissionServer;
|
|
42
44
|
public readonly server: CoreMailServer;
|
|
43
45
|
public readonly outboundWorker: CoreMailOutboundWorker;
|
|
44
46
|
|
|
@@ -48,6 +50,7 @@ export class CoreMail {
|
|
|
48
50
|
private databaseStarted = false;
|
|
49
51
|
private storageStarted = false;
|
|
50
52
|
private gatewayStarted = false;
|
|
53
|
+
private smtpSubmissionStarted = false;
|
|
51
54
|
private serverStarted = false;
|
|
52
55
|
private workerStarted = false;
|
|
53
56
|
private maintenanceStarted = false;
|
|
@@ -85,21 +88,30 @@ export class CoreMail {
|
|
|
85
88
|
this.database,
|
|
86
89
|
this.models,
|
|
87
90
|
this.transfers,
|
|
91
|
+
this.storage,
|
|
88
92
|
configArg.replica.serviceId,
|
|
89
93
|
this.desiredState.getActiveDesiredState,
|
|
90
94
|
configArg.resolveRuntimeSecret,
|
|
91
95
|
);
|
|
92
96
|
this.gateway = new CoreMailGateway(
|
|
93
97
|
configArg,
|
|
98
|
+
this.database,
|
|
94
99
|
this.models,
|
|
95
100
|
this.transfers,
|
|
96
101
|
this.inbound,
|
|
97
102
|
this.desiredState.getActiveDesiredState,
|
|
98
103
|
);
|
|
104
|
+
this.smtpSubmission = new CoreMailSmtpSubmissionServer(
|
|
105
|
+
configArg,
|
|
106
|
+
this.auth,
|
|
107
|
+
this.submissions,
|
|
108
|
+
this.desiredState.getActiveDesiredState,
|
|
109
|
+
);
|
|
99
110
|
this.maintenance = new CoreMailMaintenanceService(this.models, this.storage);
|
|
100
111
|
this.server = server = new CoreMailServer(
|
|
101
112
|
configArg,
|
|
102
113
|
this.database,
|
|
114
|
+
this.models,
|
|
103
115
|
this.storage,
|
|
104
116
|
this.transfers,
|
|
105
117
|
this.desiredState,
|
|
@@ -107,6 +119,7 @@ export class CoreMail {
|
|
|
107
119
|
this.submissions,
|
|
108
120
|
this.inbound,
|
|
109
121
|
this.gateway,
|
|
122
|
+
this.smtpSubmission,
|
|
110
123
|
);
|
|
111
124
|
this.outboundWorker = new CoreMailOutboundWorker(
|
|
112
125
|
this.models,
|
|
@@ -163,6 +176,8 @@ export class CoreMail {
|
|
|
163
176
|
await this.desiredState.start();
|
|
164
177
|
this.gateway.start();
|
|
165
178
|
this.gatewayStarted = true;
|
|
179
|
+
this.smtpSubmission.start();
|
|
180
|
+
this.smtpSubmissionStarted = true;
|
|
166
181
|
await this.server.start();
|
|
167
182
|
this.serverStarted = true;
|
|
168
183
|
this.maintenance.start();
|
|
@@ -226,6 +241,11 @@ export class CoreMail {
|
|
|
226
241
|
() => this.maintenance.stop(),
|
|
227
242
|
() => { this.maintenanceStarted = false; },
|
|
228
243
|
);
|
|
244
|
+
await stopOne(
|
|
245
|
+
this.smtpSubmissionStarted,
|
|
246
|
+
() => this.smtpSubmission.stop(),
|
|
247
|
+
() => { this.smtpSubmissionStarted = false; },
|
|
248
|
+
);
|
|
229
249
|
await stopOne(
|
|
230
250
|
this.gatewayStarted,
|
|
231
251
|
() => this.gateway.stop(),
|
package/ts/classes.gateway.ts
CHANGED
|
@@ -6,6 +6,13 @@ import type {
|
|
|
6
6
|
ICoreMailConfig,
|
|
7
7
|
ITransferAuthority,
|
|
8
8
|
} from './interfaces.js';
|
|
9
|
+
import { addPrivacySafeTypedHandler } from './coremail.errors.js';
|
|
10
|
+
import { withCoreMailTransaction } from './coremail.quota.js';
|
|
11
|
+
import {
|
|
12
|
+
createCoreMailStatDescriptor,
|
|
13
|
+
ensureCoreMailStatCounter,
|
|
14
|
+
incrementCoreMailStatCounter,
|
|
15
|
+
} from './coremail.stats.js';
|
|
9
16
|
import { logCoreMailFailure } from './coremail.log.js';
|
|
10
17
|
import type { ICoreMailSubmissionRecord } from './coremail.persistence.js';
|
|
11
18
|
import {
|
|
@@ -111,6 +118,7 @@ export class CoreMailGateway {
|
|
|
111
118
|
|
|
112
119
|
public constructor(
|
|
113
120
|
private readonly config: ICoreMailConfig,
|
|
121
|
+
private readonly database: plugins.smartdata.SmartdataDb,
|
|
114
122
|
private readonly models: ICoreMailModels,
|
|
115
123
|
private readonly transfers: CoreMailTransferService,
|
|
116
124
|
private readonly inbound: CoreMailInboundService,
|
|
@@ -182,31 +190,7 @@ export class CoreMailGateway {
|
|
|
182
190
|
): Promise<void> {
|
|
183
191
|
await this.disconnect();
|
|
184
192
|
if (this.stopController.signal.aborted) return;
|
|
185
|
-
const router =
|
|
186
|
-
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
187
|
-
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
|
|
188
|
-
>('coreMailGatewayResolveRecipients', async (requestArg) => {
|
|
189
|
-
this.requireAuthenticated(signatureArg);
|
|
190
|
-
return await this.inbound.resolveRecipients(requestArg);
|
|
191
|
-
}));
|
|
192
|
-
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
193
|
-
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
|
|
194
|
-
>('coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
|
|
195
|
-
this.requireAuthenticated(signatureArg);
|
|
196
|
-
return await this.inbound.prepareInboundHandoff(requestArg);
|
|
197
|
-
}));
|
|
198
|
-
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
199
|
-
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
|
|
200
|
-
>('coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
|
|
201
|
-
this.requireAuthenticated(signatureArg);
|
|
202
|
-
return await this.inbound.completeInboundHandoff(requestArg);
|
|
203
|
-
}));
|
|
204
|
-
router.addTypedHandler(new plugins.typedrequest.TypedHandler<
|
|
205
|
-
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
|
|
206
|
-
>('coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
|
|
207
|
-
this.requireAuthenticated(signatureArg);
|
|
208
|
-
return await this.completeOutboundHandoff(requestArg);
|
|
209
|
-
}));
|
|
193
|
+
const router = this.createGatewayRouter(signatureArg);
|
|
210
194
|
const socket = await plugins.typedsocket.TypedSocket.createClient(
|
|
211
195
|
router,
|
|
212
196
|
gatewayArg.endpointUrl,
|
|
@@ -235,6 +219,45 @@ export class CoreMailGateway {
|
|
|
235
219
|
this.queueAuthentication(gatewayArg, signatureArg);
|
|
236
220
|
}
|
|
237
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The gateway-facing router.
|
|
224
|
+
*
|
|
225
|
+
* Every handler goes through the same privacy-safe envelope the workload
|
|
226
|
+
* router uses, so dcrouter observes the typed `ICoreMailErrorData` code —
|
|
227
|
+
* `QUOTA_EXCEEDED` versus `STATE_CONFLICT` decides whether it defers a
|
|
228
|
+
* message or rejects it — instead of an opaque error string.
|
|
229
|
+
*/
|
|
230
|
+
public createGatewayRouter(
|
|
231
|
+
signatureArg: string,
|
|
232
|
+
): plugins.typedrequest.TypedRouter {
|
|
233
|
+
const router = new plugins.typedrequest.TypedRouter();
|
|
234
|
+
addPrivacySafeTypedHandler<
|
|
235
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayResolveRecipients
|
|
236
|
+
>(router, 'gateway', 'coreMailGatewayResolveRecipients', async (requestArg) => {
|
|
237
|
+
this.requireAuthenticated(signatureArg);
|
|
238
|
+
return await this.inbound.resolveRecipients(requestArg);
|
|
239
|
+
});
|
|
240
|
+
addPrivacySafeTypedHandler<
|
|
241
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayPrepareInboundHandoff
|
|
242
|
+
>(router, 'gateway', 'coreMailGatewayPrepareInboundHandoff', async (requestArg) => {
|
|
243
|
+
this.requireAuthenticated(signatureArg);
|
|
244
|
+
return await this.inbound.prepareInboundHandoff(requestArg);
|
|
245
|
+
});
|
|
246
|
+
addPrivacySafeTypedHandler<
|
|
247
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteInboundHandoff
|
|
248
|
+
>(router, 'gateway', 'coreMailGatewayCompleteInboundHandoff', async (requestArg) => {
|
|
249
|
+
this.requireAuthenticated(signatureArg);
|
|
250
|
+
return await this.inbound.completeInboundHandoff(requestArg);
|
|
251
|
+
});
|
|
252
|
+
addPrivacySafeTypedHandler<
|
|
253
|
+
plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailGatewayCompleteOutboundHandoff
|
|
254
|
+
>(router, 'gateway', 'coreMailGatewayCompleteOutboundHandoff', async (requestArg) => {
|
|
255
|
+
this.requireAuthenticated(signatureArg);
|
|
256
|
+
return await this.completeOutboundHandoff(requestArg);
|
|
257
|
+
});
|
|
258
|
+
return router;
|
|
259
|
+
}
|
|
260
|
+
|
|
238
261
|
private queueAuthentication(
|
|
239
262
|
gatewayArg: TGatewayDesired,
|
|
240
263
|
signatureArg: string,
|
|
@@ -520,49 +543,81 @@ export class CoreMailGateway {
|
|
|
520
543
|
) {
|
|
521
544
|
throw new Error('CoreMail gateway status chronology conflicts.');
|
|
522
545
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
modelArg
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
546
|
+
// A terminal state is reached at most once (the loop returns early once
|
|
547
|
+
// the record is terminal) and a deferral counts only when the gateway
|
|
548
|
+
// status is strictly newer than the one already recorded, so replaying
|
|
549
|
+
// one status never counts twice.
|
|
550
|
+
const statKind = terminal
|
|
551
|
+
? statusArg.state as 'delivered' | 'failed' | 'deadLettered'
|
|
552
|
+
: statusArg.state === 'deferred'
|
|
553
|
+
&& (
|
|
554
|
+
submission.gatewayStatusUpdatedAt === undefined
|
|
555
|
+
|| statusArg.updatedAt > submission.gatewayStatusUpdatedAt
|
|
556
|
+
)
|
|
557
|
+
? 'deferred' as const
|
|
558
|
+
: undefined;
|
|
559
|
+
const statDescriptor = createCoreMailStatDescriptor(submission, updatedAt);
|
|
560
|
+
if (statKind) {
|
|
561
|
+
await ensureCoreMailStatCounter(this.models, statDescriptor);
|
|
562
|
+
}
|
|
563
|
+
const result = await withCoreMailTransaction(
|
|
564
|
+
this.database,
|
|
565
|
+
async (transactionArg) => {
|
|
566
|
+
const transitioned = await this.models.Submission.exact.transition({
|
|
567
|
+
current: submission,
|
|
568
|
+
change: (modelArg) => {
|
|
569
|
+
modelArg.transportMessageId = statusArg.transportMessageId;
|
|
570
|
+
modelArg.acceptedAt = acceptedAt;
|
|
571
|
+
modelArg.state = statusArg.state;
|
|
572
|
+
modelArg.attempts = Math.max(
|
|
573
|
+
submission.attempts,
|
|
574
|
+
statusArg.attempts,
|
|
575
|
+
);
|
|
576
|
+
modelArg.gatewayStatusUpdatedAt = statusArg.updatedAt;
|
|
577
|
+
if (nextAttemptAt === undefined) {
|
|
578
|
+
delete modelArg.nextAttemptAt;
|
|
579
|
+
} else {
|
|
580
|
+
modelArg.nextAttemptAt = nextAttemptAt;
|
|
581
|
+
}
|
|
582
|
+
if (workerRetryAt === undefined) {
|
|
583
|
+
delete modelArg.workerRetryAt;
|
|
584
|
+
} else {
|
|
585
|
+
modelArg.workerRetryAt = workerRetryAt;
|
|
586
|
+
}
|
|
587
|
+
if (statusArg.state === 'delivered') {
|
|
588
|
+
modelArg.deliveredAt = statusArg.deliveredAt;
|
|
589
|
+
} else {
|
|
590
|
+
delete modelArg.deliveredAt;
|
|
591
|
+
}
|
|
592
|
+
if (statusArg.terminalAt === undefined) {
|
|
593
|
+
delete modelArg.terminalAt;
|
|
594
|
+
} else {
|
|
595
|
+
modelArg.terminalAt = statusArg.terminalAt;
|
|
596
|
+
}
|
|
597
|
+
if (statusArg.error === undefined) {
|
|
598
|
+
delete modelArg.error;
|
|
599
|
+
} else {
|
|
600
|
+
modelArg.error = statusArg.error;
|
|
601
|
+
}
|
|
602
|
+
modelArg.updatedAt = updatedAt;
|
|
603
|
+
modelArg.revision += 1;
|
|
604
|
+
delete modelArg.leaseOwnerTaskId;
|
|
605
|
+
delete modelArg.leaseToken;
|
|
606
|
+
delete modelArg.leaseExpiresAt;
|
|
607
|
+
},
|
|
608
|
+
}, { session: transactionArg });
|
|
609
|
+
if (transitioned.status === 'transitioned' && statKind) {
|
|
610
|
+
await incrementCoreMailStatCounter(
|
|
611
|
+
this.models,
|
|
612
|
+
statDescriptor,
|
|
613
|
+
statKind,
|
|
614
|
+
updatedAt,
|
|
615
|
+
transactionArg,
|
|
616
|
+
);
|
|
558
617
|
}
|
|
559
|
-
|
|
560
|
-
modelArg.revision += 1;
|
|
561
|
-
delete modelArg.leaseOwnerTaskId;
|
|
562
|
-
delete modelArg.leaseToken;
|
|
563
|
-
delete modelArg.leaseExpiresAt;
|
|
618
|
+
return transitioned;
|
|
564
619
|
},
|
|
565
|
-
|
|
620
|
+
);
|
|
566
621
|
if (result.status === 'transitioned') {
|
|
567
622
|
return result.document;
|
|
568
623
|
}
|
package/ts/classes.inbound.ts
CHANGED
|
@@ -3,15 +3,24 @@ import { type ICoreMailModels } from './classes.models.js';
|
|
|
3
3
|
import {
|
|
4
4
|
createOpaqueToken,
|
|
5
5
|
createSha256,
|
|
6
|
+
type CoreMailStorage,
|
|
6
7
|
} from './classes.storage.js';
|
|
7
8
|
import { CoreMailTransferService } from './classes.transfer.js';
|
|
8
9
|
import {
|
|
9
|
-
normalizeCoreMailConnectionInfo,
|
|
10
10
|
normalizeCoreMailEnvelope,
|
|
11
|
-
normalizeCoreMailGatewayMessage,
|
|
12
11
|
normalizeCoreMailMailboxAddress,
|
|
13
12
|
} from './coremail.validation.js';
|
|
14
13
|
import { CoreMailInboundCursorCodec } from './coremail.cursor.js';
|
|
14
|
+
import { logCoreMailFailure } from './coremail.log.js';
|
|
15
|
+
import {
|
|
16
|
+
coreMailHeaderScanBytes,
|
|
17
|
+
parseCoreMailMessageHeaders,
|
|
18
|
+
} from './coremail.mime.js';
|
|
19
|
+
import {
|
|
20
|
+
createCoreMailStatDescriptor,
|
|
21
|
+
ensureCoreMailStatCounter,
|
|
22
|
+
incrementCoreMailStatCounter,
|
|
23
|
+
} from './coremail.stats.js';
|
|
15
24
|
import {
|
|
16
25
|
createCoreMailPendingQuotaDescriptor,
|
|
17
26
|
decrementCoreMailQuotaCounter,
|
|
@@ -100,7 +109,13 @@ const toPublicDelivery = (
|
|
|
100
109
|
: {}),
|
|
101
110
|
});
|
|
102
111
|
|
|
103
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Routing-handle binding digest over the exact normalized envelope and source.
|
|
114
|
+
* Exported so a regression test can pin the byte layout: the normalizers were
|
|
115
|
+
* moved to the upstream contract package, and a silently different shape here
|
|
116
|
+
* would invalidate every issued handle.
|
|
117
|
+
*/
|
|
118
|
+
export const createEnvelopeDigest = (
|
|
104
119
|
envelopeArg: plugins.serveZoneInterfaces.data.ICoreMailEnvelope,
|
|
105
120
|
sourceArg: plugins.serveZoneInterfaces.data.IMailConnectionInfo,
|
|
106
121
|
) => createSha256(JSON.stringify({
|
|
@@ -137,6 +152,7 @@ export class CoreMailInboundService {
|
|
|
137
152
|
private readonly database: plugins.smartdata.SmartdataDb,
|
|
138
153
|
private readonly models: ICoreMailModels,
|
|
139
154
|
private readonly transfers: CoreMailTransferService,
|
|
155
|
+
private readonly storage: CoreMailStorage,
|
|
140
156
|
private readonly serviceId: string,
|
|
141
157
|
private readonly getDesiredState: () => Promise<TDesiredState | null>,
|
|
142
158
|
resolveRuntimeSecretArg: (secretKeyArg: string) => string,
|
|
@@ -163,7 +179,7 @@ export class CoreMailInboundService {
|
|
|
163
179
|
throw new Error('CoreMail gateway recipients are invalid.');
|
|
164
180
|
}
|
|
165
181
|
const envelope = normalizeCoreMailEnvelope(requestArg.envelope);
|
|
166
|
-
const source = normalizeCoreMailConnectionInfo(requestArg.source);
|
|
182
|
+
const source = plugins.serveZoneInterfaces.data.normalizeCoreMailConnectionInfo(requestArg.source);
|
|
167
183
|
const envelopeDigest = createEnvelopeDigest(envelope, source);
|
|
168
184
|
const recipients = requestArg.recipients.map(normalizeCoreMailMailboxAddress);
|
|
169
185
|
if (new Set(recipients).size !== recipients.length) {
|
|
@@ -270,8 +286,8 @@ export class CoreMailInboundService {
|
|
|
270
286
|
if (new Set(routingHandles).size !== routingHandles.length) {
|
|
271
287
|
throw new Error('CoreMail inbound handoff contains duplicate routing handles.');
|
|
272
288
|
}
|
|
273
|
-
const message = normalizeCoreMailGatewayMessage(requestArg.message);
|
|
274
|
-
const source = normalizeCoreMailConnectionInfo(requestArg.source);
|
|
289
|
+
const message = plugins.serveZoneInterfaces.data.normalizeCoreMailGatewayMessage(requestArg.message);
|
|
290
|
+
const source = plugins.serveZoneInterfaces.data.normalizeCoreMailConnectionInfo(requestArg.source);
|
|
275
291
|
const envelopeDigest = createEnvelopeDigest(message.envelope, source);
|
|
276
292
|
const existing = await this.models.InboundHandoff.exact.findStoredOne({
|
|
277
293
|
transportDeliveryId,
|
|
@@ -472,6 +488,25 @@ export class CoreMailInboundService {
|
|
|
472
488
|
grouped.set(key, group);
|
|
473
489
|
}
|
|
474
490
|
const groups = [...grouped.values()];
|
|
491
|
+
// The gateway message descriptor cannot carry Message-ID or Subject, so
|
|
492
|
+
// both are read from the stored bytes. A scan failure must never lose a
|
|
493
|
+
// delivery: the fields stay absent.
|
|
494
|
+
let headers: { messageId?: string; subject?: string } = {};
|
|
495
|
+
try {
|
|
496
|
+
headers = parseCoreMailMessageHeaders(
|
|
497
|
+
await this.storage.readObjectHeadBytes(
|
|
498
|
+
handoff.objectKey,
|
|
499
|
+
handoff.message.rawMime.lengthBytes,
|
|
500
|
+
handoff.message.rawMime.sha256,
|
|
501
|
+
plugins.serveZoneInterfaces.data.coreMailLimits.serializedMimeBytes,
|
|
502
|
+
coreMailHeaderScanBytes,
|
|
503
|
+
),
|
|
504
|
+
);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
await logCoreMailFailure('gateway', 'HEADER_SCAN_FAILED', error, {
|
|
507
|
+
handoffId: handoff.handoffId,
|
|
508
|
+
}).catch(() => undefined);
|
|
509
|
+
}
|
|
475
510
|
const candidates: ICoreMailInboundDeliveryRecord[] = [];
|
|
476
511
|
for (const group of groups) {
|
|
477
512
|
const authority = group[0];
|
|
@@ -483,6 +518,10 @@ export class CoreMailInboundService {
|
|
|
483
518
|
const quota = createCoreMailPendingQuotaDescriptor(authority);
|
|
484
519
|
await ensureCoreMailQuotaCounter(this.models, quota);
|
|
485
520
|
const receivedAt = this.now();
|
|
521
|
+
await ensureCoreMailStatCounter(
|
|
522
|
+
this.models,
|
|
523
|
+
createCoreMailStatDescriptor(authority, receivedAt),
|
|
524
|
+
);
|
|
486
525
|
candidates.push({
|
|
487
526
|
deliveryId: plugins.nodeCrypto.randomUUID(),
|
|
488
527
|
tenantId: authority.tenantId,
|
|
@@ -499,6 +538,10 @@ export class CoreMailInboundService {
|
|
|
499
538
|
rawMimeSha256: handoff.message.rawMime.sha256,
|
|
500
539
|
rawMimeLengthBytes: handoff.message.rawMime.lengthBytes,
|
|
501
540
|
rawMimeObjectKey: handoff.objectKey,
|
|
541
|
+
...(headers.messageId === undefined
|
|
542
|
+
? {}
|
|
543
|
+
: { messageId: headers.messageId }),
|
|
544
|
+
...(headers.subject === undefined ? {} : { subject: headers.subject }),
|
|
502
545
|
receivedAt,
|
|
503
546
|
updatedAt: receivedAt,
|
|
504
547
|
});
|
|
@@ -541,6 +584,15 @@ export class CoreMailInboundService {
|
|
|
541
584
|
if (inserted.status === 'conflict') {
|
|
542
585
|
throw new Error('CoreMail inbound delivery identity conflict.');
|
|
543
586
|
}
|
|
587
|
+
// Counted with the insert, so a replayed completion that finds
|
|
588
|
+
// the delivery already present counts nothing.
|
|
589
|
+
await incrementCoreMailStatCounter(
|
|
590
|
+
this.models,
|
|
591
|
+
createCoreMailStatDescriptor(authority, candidate.receivedAt),
|
|
592
|
+
'received',
|
|
593
|
+
candidate.receivedAt,
|
|
594
|
+
transactionArg,
|
|
595
|
+
);
|
|
544
596
|
}
|
|
545
597
|
for (const originalHandle of group) {
|
|
546
598
|
const handle = await this.models.RecipientHandle.exact.findStoredOne(
|
|
@@ -833,6 +885,8 @@ export class CoreMailInboundService {
|
|
|
833
885
|
bindingRevision: sessionArg.bindingRevision,
|
|
834
886
|
});
|
|
835
887
|
await ensureCoreMailQuotaCounter(this.models, quota);
|
|
888
|
+
const statDescriptor = createCoreMailStatDescriptor(sessionArg, updatedAt);
|
|
889
|
+
await ensureCoreMailStatCounter(this.models, statDescriptor);
|
|
836
890
|
const transactionResult = await withCoreMailTransaction(
|
|
837
891
|
this.database,
|
|
838
892
|
async (transactionArg) => {
|
|
@@ -872,6 +926,17 @@ export class CoreMailInboundService {
|
|
|
872
926
|
if (update.status !== 'transitioned') {
|
|
873
927
|
throw new Error('CoreMail inbound acknowledgement fence changed.');
|
|
874
928
|
}
|
|
929
|
+
// Counted with the acknowledgement transition; the replay branch above
|
|
930
|
+
// returns before reaching this point.
|
|
931
|
+
await incrementCoreMailStatCounter(
|
|
932
|
+
this.models,
|
|
933
|
+
statDescriptor,
|
|
934
|
+
requestArg.outcome === 'processed'
|
|
935
|
+
? 'acknowledgedProcessed'
|
|
936
|
+
: 'acknowledgedDiscarded',
|
|
937
|
+
updatedAt,
|
|
938
|
+
transactionArg,
|
|
939
|
+
);
|
|
875
940
|
return { replayed: false };
|
|
876
941
|
},
|
|
877
942
|
);
|
|
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import type { ICoreMailModels } from './classes.models.js';
|
|
3
3
|
import type { CoreMailStorage } from './classes.storage.js';
|
|
4
4
|
import { logCoreMailFailure } from './coremail.log.js';
|
|
5
|
+
import { coreMailStatRetentionMs } from './coremail.stats.js';
|
|
5
6
|
|
|
6
7
|
const sweepIntervalMs = 60_000;
|
|
7
8
|
const batchSize = 100;
|
|
@@ -274,6 +275,19 @@ export class CoreMailMaintenanceService {
|
|
|
274
275
|
}
|
|
275
276
|
if (handles.length < batchSize) break;
|
|
276
277
|
}
|
|
278
|
+
const statCutoff = now - coreMailStatRetentionMs;
|
|
279
|
+
while (!signalArg?.aborted) {
|
|
280
|
+
const statCounters = await this.models.StatCounter.exact.findStored({
|
|
281
|
+
filter: { dayUtc: { $lte: statCutoff } },
|
|
282
|
+
sort: { dayUtc: 1, statId: 1 },
|
|
283
|
+
limit: batchSize,
|
|
284
|
+
});
|
|
285
|
+
for (const statCounter of statCounters) {
|
|
286
|
+
signalArg?.throwIfAborted();
|
|
287
|
+
await this.models.StatCounter.exact.delete({ current: statCounter });
|
|
288
|
+
}
|
|
289
|
+
if (statCounters.length < batchSize) break;
|
|
290
|
+
}
|
|
277
291
|
while (!signalArg?.aborted) {
|
|
278
292
|
const counters = await this.models.QuotaCounter.exact.findStored({
|
|
279
293
|
filter: {
|