@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.
@@ -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 = Date.now();
306
- cachedEmail.markStored(INBOUND_STORE_RETENTION_MS);
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':
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.4.2',
6
+ version: '17.6.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }