@serve.zone/dcrouter 17.4.2 → 17.6.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/deno.json +1 -1
- package/dist_serve/bundle.js +1213 -1068
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +1 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +15 -0
- package/dist_ts/email/classes.accepted-email-spool.js +145 -4
- package/dist_ts/opsserver/handlers/email-ops.handler.js +10 -2
- package/dist_ts_interfaces/requests/email-ops.d.ts +1 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/db/documents/classes.cached.email.ts +1 -1
- package/ts/email/classes.accepted-email-spool.ts +176 -3
- package/ts/opsserver/handlers/email-ops.handler.ts +9 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ICachedEmailSmtpTransaction,
|
|
6
6
|
TCachedEmailDirection,
|
|
7
7
|
} from '../db/documents/classes.cached.email.js';
|
|
8
|
+
import { AcceptEnvelopeRejectionError } from '@push.rocks/smartmta';
|
|
8
9
|
import type {
|
|
9
10
|
Email,
|
|
10
11
|
IAcceptEnvelopeContext,
|
|
@@ -15,6 +16,58 @@ import type {
|
|
|
15
16
|
} from '@push.rocks/smartmta';
|
|
16
17
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
17
18
|
|
|
19
|
+
|
|
20
|
+
/** Inbound authentication verdict evaluation for acceptance semantics. */
|
|
21
|
+
export interface IInboundAcceptanceEvaluation {
|
|
22
|
+
status: 'accepted' | 'flagged';
|
|
23
|
+
doubts: string[];
|
|
24
|
+
dmarcReject: boolean;
|
|
25
|
+
dmarcDomain?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function evaluateInboundAcceptance(securityResults: any): IInboundAcceptanceEvaluation {
|
|
29
|
+
const doubts: string[] = [];
|
|
30
|
+
let dmarcReject = false;
|
|
31
|
+
let dmarcDomain: string | undefined;
|
|
32
|
+
|
|
33
|
+
// Infrastructure failures (temperror, resolver problems) are OUR problem,
|
|
34
|
+
// not evidence against the sender — they never produce doubts.
|
|
35
|
+
const spfResult = String(securityResults?.spf?.result || '').toLowerCase();
|
|
36
|
+
if (spfResult === 'fail' || spfResult === 'softfail') {
|
|
37
|
+
doubts.push(`SPF ${spfResult} for ${securityResults?.spf?.domain || 'sender domain'}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The verdict bridge emits a placeholder entry (status 'none') when a
|
|
41
|
+
// message carries no DKIM signature at all; only real failed signatures
|
|
42
|
+
// count as doubts.
|
|
43
|
+
const dkimSignatures = (Array.isArray(securityResults?.dkim) ? securityResults.dkim : [])
|
|
44
|
+
.filter((signature: any) => String(signature?.status || '').toLowerCase() !== 'none');
|
|
45
|
+
if (dkimSignatures.length > 0 && !dkimSignatures.some((signature: any) => signature?.is_valid)) {
|
|
46
|
+
doubts.push('DKIM signature(s) present but none verified');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// DMARC only matters when the sender domain publishes an enforcing policy;
|
|
50
|
+
// absent records surface as policy 'none' and are not doubts.
|
|
51
|
+
const dmarc = securityResults?.dmarc;
|
|
52
|
+
if (dmarc && dmarc.passed === false) {
|
|
53
|
+
const policy = String(dmarc.policy || '').toLowerCase();
|
|
54
|
+
const action = String(dmarc.action || '').toLowerCase();
|
|
55
|
+
if (policy === 'reject' || policy === 'quarantine' || action === 'reject' || action === 'quarantine') {
|
|
56
|
+
dmarcDomain = dmarc.domain || undefined;
|
|
57
|
+
if (action === 'reject' || policy === 'reject') {
|
|
58
|
+
dmarcReject = true;
|
|
59
|
+
}
|
|
60
|
+
doubts.push(`DMARC failed for ${dmarc.domain || 'sender domain'} (policy ${dmarc.policy || 'unknown'})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
status: doubts.length > 0 ? 'flagged' : 'accepted',
|
|
65
|
+
doubts,
|
|
66
|
+
dmarcReject,
|
|
67
|
+
dmarcDomain,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
18
71
|
export const DCROUTER_CACHE_ID_HEADER = 'X-Dcrouter-Cached-Email-Id';
|
|
19
72
|
const ACCEPTED_EMAIL_SPOOL_INTERVAL_MS = 60_000;
|
|
20
73
|
const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
|
|
@@ -296,17 +349,63 @@ export class AcceptedEmailSpool {
|
|
|
296
349
|
const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
|
|
297
350
|
const nonStoreEntries = plan.filter((entry) => entry.action.type !== 'store');
|
|
298
351
|
|
|
352
|
+
const acceptance = evaluateInboundAcceptance(context.securityResults);
|
|
353
|
+
const startedAtMs = Date.now();
|
|
354
|
+
|
|
299
355
|
const persistedRawMessage = this.setDcRouterCacheIdHeader(
|
|
300
356
|
context.rawMessage.toString('utf8'),
|
|
301
357
|
cachedEmail.id,
|
|
302
358
|
);
|
|
303
359
|
await this.persistRawMessage(cachedEmail, persistedRawMessage);
|
|
304
360
|
cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
|
|
305
|
-
cachedEmail.acceptedAt =
|
|
306
|
-
|
|
361
|
+
cachedEmail.acceptedAt = startedAtMs;
|
|
362
|
+
|
|
363
|
+
if (acceptance.dmarcReject) {
|
|
364
|
+
// Enforce the sender domain's published DMARC reject policy: persist the
|
|
365
|
+
// rejected attempt for the email log, then refuse the envelope with a
|
|
366
|
+
// permanent SMTP failure.
|
|
367
|
+
const rejectionMessage = `5.7.1 Rejected by DMARC policy of ${acceptance.dmarcDomain || 'the sender domain'}`;
|
|
368
|
+
cachedEmail.status = 'rejected';
|
|
369
|
+
cachedEmail.lastError = rejectionMessage;
|
|
370
|
+
cachedEmail.setTTL(INBOUND_STORE_RETENTION_MS);
|
|
371
|
+
cachedEmail.appendSmtpTransaction(this.buildInboundTransaction(context, cachedEmail.id, {
|
|
372
|
+
outcome: 'failed',
|
|
373
|
+
smtpCode: 550,
|
|
374
|
+
finalLine: rejectionMessage,
|
|
375
|
+
doubts: acceptance.doubts,
|
|
376
|
+
startedAtMs,
|
|
377
|
+
}));
|
|
378
|
+
cachedEmail.routeData = JSON.stringify({
|
|
379
|
+
acceptedAt: new Date(startedAtMs).toISOString(),
|
|
380
|
+
acceptance: 'dmarc-policy-reject',
|
|
381
|
+
doubts: acceptance.doubts,
|
|
382
|
+
});
|
|
383
|
+
cachedEmail.updateSenderDomain();
|
|
384
|
+
cachedEmail.updateRecipientDomains();
|
|
385
|
+
await cachedEmail.save().catch(() => undefined);
|
|
386
|
+
await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-rejected-dmarc').catch(() => undefined);
|
|
387
|
+
throw new AcceptEnvelopeRejectionError(550, rejectionMessage);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (acceptance.status === 'flagged') {
|
|
391
|
+
cachedEmail.status = 'flagged';
|
|
392
|
+
cachedEmail.lastError = acceptance.doubts.join('; ');
|
|
393
|
+
} else {
|
|
394
|
+
cachedEmail.status = 'accepted';
|
|
395
|
+
}
|
|
396
|
+
cachedEmail.deliveredAt = new Date();
|
|
397
|
+
cachedEmail.setTTL(INBOUND_STORE_RETENTION_MS);
|
|
398
|
+
cachedEmail.appendSmtpTransaction(this.buildInboundTransaction(context, cachedEmail.id, {
|
|
399
|
+
outcome: 'succeeded',
|
|
400
|
+
smtpCode: 250,
|
|
401
|
+
finalLine: '250 2.0.0 Message accepted for delivery',
|
|
402
|
+
doubts: acceptance.doubts,
|
|
403
|
+
startedAtMs,
|
|
404
|
+
}));
|
|
307
405
|
cachedEmail.routeData = JSON.stringify({
|
|
308
|
-
acceptedAt: new Date().toISOString(),
|
|
406
|
+
acceptedAt: new Date(startedAtMs).toISOString(),
|
|
309
407
|
acceptance: 'durable-envelope',
|
|
408
|
+
verdictDoubts: acceptance.doubts,
|
|
310
409
|
recipientPlans: plan.map((entry) => ({
|
|
311
410
|
recipient: entry.recipient,
|
|
312
411
|
routeName: entry.routeName,
|
|
@@ -363,6 +462,80 @@ export class AcceptedEmailSpool {
|
|
|
363
462
|
}
|
|
364
463
|
}
|
|
365
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Synthesizes the inbound SMTP transaction for the email log from the
|
|
467
|
+
* acceptance context: the receiving session where WE are the server. The
|
|
468
|
+
* Rust frontend does not export a raw line transcript yet, so entries are
|
|
469
|
+
* reconstructed from the envelope, session, and verdict data.
|
|
470
|
+
*/
|
|
471
|
+
private buildInboundTransaction(
|
|
472
|
+
context: IAcceptEnvelopeContext,
|
|
473
|
+
cachedEmailId: string,
|
|
474
|
+
resultArg: {
|
|
475
|
+
outcome: 'succeeded' | 'failed';
|
|
476
|
+
smtpCode: number;
|
|
477
|
+
finalLine: string;
|
|
478
|
+
doubts: string[];
|
|
479
|
+
startedAtMs: number;
|
|
480
|
+
},
|
|
481
|
+
): ICachedEmailSmtpTransaction {
|
|
482
|
+
const session = context.session;
|
|
483
|
+
const nowMs = Date.now();
|
|
484
|
+
const hostname = this.dcRouterRef.options?.emailConfig?.hostname || 'mail-gateway';
|
|
485
|
+
const transcript: Array<{
|
|
486
|
+
timestampMs: number;
|
|
487
|
+
direction: 'client' | 'server' | 'system';
|
|
488
|
+
phase: string;
|
|
489
|
+
text: string;
|
|
490
|
+
responseCode?: number;
|
|
491
|
+
}> = [];
|
|
492
|
+
let timestampMs = resultArg.startedAtMs;
|
|
493
|
+
const push = (direction: 'client' | 'server' | 'system', phase: string, text: string, responseCode?: number) => {
|
|
494
|
+
transcript.push({ timestampMs, direction, phase, text, ...(responseCode !== undefined ? { responseCode } : {}) });
|
|
495
|
+
timestampMs += 1;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
push('system', 'connect', `Connection from ${session.remoteAddress || 'unknown'}${session.clientHostname ? ` (${session.clientHostname})` : ''}${session.secure ? ', TLS' : ', plaintext'}${session.authenticated ? ', authenticated' : ''}`);
|
|
499
|
+
push('client', 'mail_from', `MAIL FROM:<${context.envelope.mailFrom}>`);
|
|
500
|
+
push('server', 'mail_from', '250 OK', 250);
|
|
501
|
+
for (const recipient of context.envelope.rcptTo) {
|
|
502
|
+
push('client', 'rcpt_to', `RCPT TO:<${recipient}>`);
|
|
503
|
+
push('server', 'rcpt_to', '250 OK', 250);
|
|
504
|
+
}
|
|
505
|
+
push('client', 'data_command', 'DATA');
|
|
506
|
+
push('server', 'data_command', '354 Start mail input', 354);
|
|
507
|
+
push('system', 'message_body', `Message received: ${context.rawMessage.length} bytes, ${context.attachmentCount} attachment(s)`);
|
|
508
|
+
const spf = (context.securityResults as any)?.spf;
|
|
509
|
+
const dkimSignatures = Array.isArray((context.securityResults as any)?.dkim) ? (context.securityResults as any).dkim : [];
|
|
510
|
+
const dmarc = (context.securityResults as any)?.dmarc;
|
|
511
|
+
if (spf) push('system', 'message_body', `SPF: ${spf.result || 'unknown'} (${spf.domain || 'unknown domain'})`);
|
|
512
|
+
if (dkimSignatures.length > 0) {
|
|
513
|
+
const valid = dkimSignatures.filter((signature: any) => signature?.is_valid).length;
|
|
514
|
+
push('system', 'message_body', `DKIM: ${valid}/${dkimSignatures.length} signature(s) verified${dkimSignatures[0]?.domain ? ` (${dkimSignatures[0].domain})` : ''}`);
|
|
515
|
+
}
|
|
516
|
+
if (dmarc) push('system', 'message_body', `DMARC: ${dmarc.passed ? 'pass' : 'fail'} (policy ${dmarc.policy || 'unknown'})`);
|
|
517
|
+
for (const doubt of resultArg.doubts) {
|
|
518
|
+
push('system', 'message_body', `Verdict doubt: ${doubt}`);
|
|
519
|
+
}
|
|
520
|
+
push('server', 'final_response', resultArg.finalLine, resultArg.smtpCode);
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
id: `inbound-${cachedEmailId}`,
|
|
524
|
+
queueItemId: cachedEmailId,
|
|
525
|
+
queueAttempt: 1,
|
|
526
|
+
targetHost: hostname,
|
|
527
|
+
targetPort: 25,
|
|
528
|
+
recipients: [...context.envelope.rcptTo],
|
|
529
|
+
startedAt: new Date(resultArg.startedAtMs).toISOString(),
|
|
530
|
+
completedAt: new Date(nowMs).toISOString(),
|
|
531
|
+
durationMs: Math.max(0, nowMs - resultArg.startedAtMs),
|
|
532
|
+
outcome: resultArg.outcome,
|
|
533
|
+
...(resultArg.outcome === 'failed' ? { retryable: false, error: resultArg.finalLine, errorType: 'policy' } : {}),
|
|
534
|
+
smtpCode: resultArg.smtpCode,
|
|
535
|
+
transcript,
|
|
536
|
+
} as unknown as ICachedEmailSmtpTransaction;
|
|
537
|
+
}
|
|
538
|
+
|
|
366
539
|
/** Start the interval-driven spool processor and trigger an immediate run. */
|
|
367
540
|
public start(): void {
|
|
368
541
|
this.clearSpoolTimer();
|
|
@@ -428,8 +428,16 @@ export class EmailOpsHandler {
|
|
|
428
428
|
case 'queued':
|
|
429
429
|
return 'pending';
|
|
430
430
|
case 'delivered':
|
|
431
|
-
case 'stored':
|
|
432
431
|
return 'delivered';
|
|
432
|
+
case 'stored':
|
|
433
|
+
return 'accepted';
|
|
434
|
+
case 'accepted':
|
|
435
|
+
return 'accepted';
|
|
436
|
+
case 'acceptedWithDoubts':
|
|
437
|
+
case 'flagged':
|
|
438
|
+
return 'flagged';
|
|
439
|
+
case 'rejected':
|
|
440
|
+
return 'rejected';
|
|
433
441
|
case 'failed':
|
|
434
442
|
return 'bounced';
|
|
435
443
|
case 'deferred':
|