@serve.zone/dcrouter 17.3.0 → 17.4.1

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.
@@ -33,6 +33,7 @@ import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceRes
33
33
  import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
34
34
  import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
36
+ import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
36
37
  import { DnsManager } from './dns/manager.dns.js';
37
38
  import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
38
39
  import { GatewayRouteDnsReconciler } from './dns/classes.gateway-route-dns-reconciler.js';
@@ -1770,6 +1771,7 @@ export class DcRouter {
1770
1771
  let emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1771
1772
  ...this.options.emailConfig,
1772
1773
  ports: mappedEmailPorts,
1774
+ domains: applyDefaultInboundPolicy(this.options.emailConfig.domains),
1773
1775
  dkimKeyProvisioning: 'caller-managed',
1774
1776
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
1775
1777
  queue: queueOptions,
@@ -1812,6 +1814,13 @@ export class DcRouter {
1812
1814
  smtpMessage: configuredDecision?.smtpMessage ?? dcrouterDecision.smtpMessage,
1813
1815
  };
1814
1816
  },
1817
+ onAcceptEnvelope: async (context) => {
1818
+ const emailServer = this.emailServer;
1819
+ if (!emailServer) {
1820
+ throw new Error('Email server is not available for durable envelope acceptance');
1821
+ }
1822
+ await this.acceptedEmailSpool.acceptEnvelope(context, emailServer);
1823
+ },
1815
1824
  },
1816
1825
  });
1817
1826
 
@@ -1827,7 +1836,7 @@ export class DcRouter {
1827
1836
  )) {
1828
1837
  emailConfig = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1829
1838
  ...emailConfig,
1830
- domains: [...(this.options.emailConfig?.domains || [])],
1839
+ domains: applyDefaultInboundPolicy(this.options.emailConfig?.domains),
1831
1840
  });
1832
1841
  // DkimManager captures its DomainRegistry at construction. Rebuild the
1833
1842
  // unstarted server so per-domain repair failures are absent from
@@ -6,7 +6,7 @@ const TTL = plugins.smartdata.smartdataTtlValues;
6
6
  /**
7
7
  * Email status in the cache
8
8
  */
9
- export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
9
+ export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred' | 'stored';
10
10
 
11
11
  /**
12
12
  * Direction of an accepted email: 'inbound' = received from a remote peer,
@@ -141,6 +141,14 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
141
141
  @plugins.smartdata.svDb()
142
142
  public status!: TCachedEmailStatus;
143
143
 
144
+ /** Attachment count derived from the parsed message at acceptance. */
145
+ @plugins.smartdata.svDb()
146
+ public attachmentCount?: number;
147
+
148
+ /** JSON-serialized inbound SPF/DKIM/DMARC verdicts from the SMTP session. */
149
+ @plugins.smartdata.svDb()
150
+ public inboundSecurityResults?: string;
151
+
144
152
  /**
145
153
  * Number of delivery attempts
146
154
  */
@@ -344,6 +352,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
344
352
  this.deliveredAt = new Date();
345
353
  }
346
354
 
355
+ /**
356
+ * Mark as terminally stored (catch-all inbound): visible in the email log,
357
+ * pruned after the retention window.
358
+ */
359
+ public markStored(retentionMs: number): void {
360
+ this.status = 'stored';
361
+ this.deliveredAt = new Date();
362
+ // Base-class TTL: the CacheCleaner deletes expired rows and their raw
363
+ // payloads via beforeDeleteCachedEmail.
364
+ this.setTTL(retentionMs);
365
+ }
366
+
347
367
  /**
348
368
  * Mark as failed with error
349
369
  */
@@ -7,6 +7,7 @@ import type {
7
7
  } from '../db/documents/classes.cached.email.js';
8
8
  import type {
9
9
  Email,
10
+ IAcceptEnvelopeContext,
10
11
  IExtendedSmtpSession,
11
12
  IMessageAcceptanceContext,
12
13
  IMessageAcceptanceDecision,
@@ -20,6 +21,8 @@ const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
20
21
  const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
21
22
  const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
22
23
  const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
24
+ /** Retention for catch-all stored inbound mail (30 days). */
25
+ const INBOUND_STORE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
23
26
 
24
27
  export type TSmartMtaQueueItemLike = {
25
28
  id?: string;
@@ -252,6 +255,114 @@ export class AcceptedEmailSpool {
252
255
  };
253
256
  }
254
257
 
258
+ /**
259
+ * Durable acceptance for envelopes containing at least one catch-all store
260
+ * route: anchors the exact raw message + metadata as a CachedEmail before
261
+ * SMTP 250, then dispatches any non-store recipients through SmartMTA's
262
+ * exact-plan executor. Store recipients are fulfilled by the anchor itself.
263
+ */
264
+ public async acceptEnvelope(
265
+ context: IAcceptEnvelopeContext,
266
+ emailServer: UnifiedEmailServer,
267
+ ): Promise<void> {
268
+ if (!this.dcRouterRef.dcRouterDb?.isReady()) {
269
+ throw new Error('DcRouterDb is not available for durable envelope acceptance');
270
+ }
271
+ this.throwIfMessageAcceptanceAborted(context.abortSignal);
272
+
273
+ const email = context.email;
274
+ const session = context.session;
275
+ const cachedEmail = CachedEmail.createNew();
276
+ this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
277
+ email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
278
+ cachedEmail.messageId = email.headers['Message-ID'] || email.headers['message-id'] || cachedEmail.id;
279
+ cachedEmail.from = context.envelope.mailFrom || email.from || '';
280
+ cachedEmail.to = context.envelope.rcptTo.length > 0
281
+ ? [...context.envelope.rcptTo]
282
+ : Array.isArray(email.to) ? email.to : [];
283
+ cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
284
+ cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
285
+ cachedEmail.subject = email.subject || '';
286
+ cachedEmail.attachmentCount = context.attachmentCount;
287
+ if (context.securityResults) {
288
+ cachedEmail.inboundSecurityResults = JSON.stringify(context.securityResults);
289
+ }
290
+
291
+ // Build the exact per-recipient dispatch plan before durably committing,
292
+ // so an unplannable envelope is refused instead of half-anchored.
293
+ const idempotencyKeys = context.resolvedRecipientRoutes.map(
294
+ (resolution) => `${cachedEmail.id}:${resolution.recipient}`,
295
+ );
296
+ const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
297
+ const nonStoreEntries = plan.filter((entry) => entry.action.type !== 'store');
298
+
299
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(
300
+ context.rawMessage.toString('utf8'),
301
+ cachedEmail.id,
302
+ );
303
+ await this.persistRawMessage(cachedEmail, persistedRawMessage);
304
+ cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
305
+ cachedEmail.acceptedAt = Date.now();
306
+ cachedEmail.markStored(INBOUND_STORE_RETENTION_MS);
307
+ cachedEmail.routeData = JSON.stringify({
308
+ acceptedAt: new Date().toISOString(),
309
+ acceptance: 'durable-envelope',
310
+ recipientPlans: plan.map((entry) => ({
311
+ recipient: entry.recipient,
312
+ routeName: entry.routeName,
313
+ source: entry.source,
314
+ actionType: entry.action.type,
315
+ })),
316
+ session: {
317
+ id: session.id,
318
+ remoteAddress: session.remoteAddress,
319
+ clientHostname: session.clientHostname,
320
+ secure: !!session.secure,
321
+ authenticated: !!session.authenticated,
322
+ user: session.user,
323
+ envelope: context.envelope,
324
+ },
325
+ });
326
+ cachedEmail.updateSenderDomain();
327
+ cachedEmail.updateRecipientDomains();
328
+ try {
329
+ await cachedEmail.save();
330
+ await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-stored');
331
+ } catch (error) {
332
+ await this.deleteRawMessage(cachedEmail).catch(() => undefined);
333
+ throw error;
334
+ }
335
+ if (context.abortSignal?.aborted) {
336
+ cachedEmail.markFailed('Envelope acceptance aborted before SMTP success');
337
+ await cachedEmail.save();
338
+ await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-acceptance-aborted');
339
+ throw new Error('Envelope acceptance aborted before SMTP success');
340
+ }
341
+
342
+ if (nonStoreEntries.length > 0) {
343
+ const dispatchResult = await emailServer.dispatchAcceptedEnvelope(
344
+ context.rawMessage,
345
+ plan,
346
+ {
347
+ mailFrom: context.envelope.mailFrom,
348
+ session: {
349
+ id: session.id,
350
+ remoteAddress: session.remoteAddress,
351
+ clientHostname: session.clientHostname,
352
+ secure: !!session.secure,
353
+ authenticated: !!session.authenticated,
354
+ },
355
+ },
356
+ );
357
+ const failed = dispatchResult.results.filter(
358
+ (result) => result.status === 'failed' || result.status === 'rejected',
359
+ );
360
+ if (failed.length > 0) {
361
+ logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
362
+ }
363
+ }
364
+ }
365
+
255
366
  /** Start the interval-driven spool processor and trigger an immediate run. */
256
367
  public start(): void {
257
368
  this.clearSpoolTimer();
@@ -16,6 +16,7 @@ import {
16
16
  MAIL_DNS_MAX_RETRY_INTERVAL_MS,
17
17
  MAIL_DNS_RETRY_INTERVAL_MS,
18
18
  } from './classes.mail-dns-sync.js';
19
+ import { applyDefaultInboundPolicy } from './inbound-policy.js';
19
20
  import { projectMailDnsIntentStatus } from './mail-dns-status.js';
20
21
 
21
22
  export interface IEmailDomainManagerActionResult {
@@ -884,7 +885,7 @@ export class EmailDomainManager {
884
885
  }
885
886
  mergedDomains.set(key, managedConfig);
886
887
  }
887
- const domains = [...mergedDomains.values()];
888
+ const domains = applyDefaultInboundPolicy([...mergedDomains.values()]);
888
889
  this.dcRouter.options.emailConfig.domains = domains;
889
890
  this.dcRouter.emailServer?.updateOptions({ domains });
890
891
  }
@@ -1,5 +1,9 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IStorageManager } from '@push.rocks/smartmta';
2
+ import type {
3
+ IStorageManager,
4
+ IStorageListPageOptions,
5
+ IStorageListPageResult,
6
+ } from '@push.rocks/smartmta';
3
7
  import {
4
8
  getSmartMtaTextNamespace,
5
9
  hashSmartMtaStorageKey,
@@ -98,6 +102,58 @@ export class SmartMtaStorageManager implements IStorageManager {
98
102
  .sort();
99
103
  }
100
104
 
105
+ /**
106
+ * Bounded, deletion-stable page listing: seeks strictly beyond the last
107
+ * returned key, so tokens stay valid while pages are deleted and an
108
+ * otherwise stable prefix is always exhausted.
109
+ */
110
+ public async listPage(
111
+ prefix: string,
112
+ options: IStorageListPageOptions,
113
+ ): Promise<IStorageListPageResult> {
114
+ const normalizedPrefix = normalizeSmartMtaStorageKey(prefix);
115
+ getSmartMtaTextNamespace(normalizedPrefix);
116
+ const requestedLimit = Number.isSafeInteger(options?.limit) && options.limit > 0
117
+ ? options.limit
118
+ : 100;
119
+ const limit = Math.min(requestedLimit, 1000);
120
+ let seekAfter: string | undefined;
121
+ if (options?.continuationToken) {
122
+ const decoded = Buffer.from(options.continuationToken, 'base64url').toString('utf8');
123
+ if (!decoded.startsWith(normalizedPrefix)) {
124
+ throw new Error('SmartMTA storage continuation token does not match the requested prefix');
125
+ }
126
+ seekAfter = decoded;
127
+ }
128
+
129
+ const documents = await this.collection
130
+ .find({
131
+ key: {
132
+ ...(seekAfter ? { $gt: seekAfter } : { $gte: normalizedPrefix }),
133
+ $lt: `${normalizedPrefix}￿`,
134
+ },
135
+ })
136
+ .sort({ key: 1 })
137
+ .limit(limit + 1)
138
+ .toArray();
139
+
140
+ const hasMore = documents.length > limit;
141
+ const pageDocuments = documents.slice(0, limit);
142
+ const keys = pageDocuments
143
+ .map((document: ISmartMtaStorageDocument) => this.validateDocument(document).key)
144
+ .filter((key: string) => key.startsWith(normalizedPrefix));
145
+ const lastKey = pageDocuments.length > 0
146
+ ? (pageDocuments[pageDocuments.length - 1] as ISmartMtaStorageDocument).key
147
+ : undefined;
148
+
149
+ return {
150
+ keys,
151
+ ...(hasMore && lastKey
152
+ ? { continuationToken: Buffer.from(lastKey, 'utf8').toString('base64url') }
153
+ : {}),
154
+ };
155
+ }
156
+
101
157
  public async delete(key: string): Promise<void> {
102
158
  const normalizedKey = normalizeSmartMtaStorageKey(key);
103
159
  getSmartMtaTextNamespace(normalizedKey);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Org default inbound policy: every email domain accepts inbound mail via
3
+ * catch-all store with bounded retention, so replies to platform-sent mail
4
+ * are never bounced by omission. Explicit SmartMTA routes always outrank
5
+ * this no-route fallback; explicit per-domain policies are preserved.
6
+ */
7
+
8
+ export interface IInboundPolicyLike {
9
+ defaultAction: 'store' | 'reject';
10
+ retentionDays: number;
11
+ }
12
+
13
+ export const DEFAULT_INBOUND_POLICY: IInboundPolicyLike = Object.freeze({
14
+ defaultAction: 'store',
15
+ retentionDays: 30,
16
+ });
17
+
18
+ export function applyDefaultInboundPolicy<T extends { inboundPolicy?: IInboundPolicyLike }>(
19
+ domainsArg: T[] | undefined,
20
+ ): T[] {
21
+ return (domainsArg || []).map((domain) => ({
22
+ ...domain,
23
+ inboundPolicy: domain.inboundPolicy ?? { ...DEFAULT_INBOUND_POLICY },
24
+ }));
25
+ }
@@ -11,6 +11,8 @@ export const smartMtaTextNamespaces: readonly ISmartMtaTextNamespace[] = Object.
11
11
  { prefix: '/email/bounces/', maxBytes: 8 * 1024 * 1024 },
12
12
  { prefix: '/email/templates/', maxBytes: 4 * 1024 * 1024 },
13
13
  { prefix: '/email/routes/config.json', maxBytes: 4 * 1024 * 1024 },
14
+ // SmartMTA 8.1+ accepted-envelope dispatch bookkeeping (idempotent replay records).
15
+ { prefix: '/email/accepted-envelope-dispatch/', maxBytes: 64 * 1024 },
14
16
  { prefix: '/security/ip-reputation-cache.json', maxBytes: 8 * 1024 * 1024 },
15
17
  { prefix: '/workhosters/mail-identities.json', maxBytes: 4 * 1024 * 1024 },
16
18
  ]);
@@ -197,14 +197,7 @@ export class EmailOpsHandler {
197
197
  authMethod: '',
198
198
  authUser: this.extractSessionUser(session),
199
199
  },
200
- authenticationResults: {
201
- spf: 'none',
202
- spfDomain: '',
203
- dkim: 'none',
204
- dkimDomain: '',
205
- dmarc: 'none',
206
- dmarcPolicy: '',
207
- },
200
+ authenticationResults: this.mapInboundSecurityResults(doc.inboundSecurityResults),
208
201
  rejectionReason: doc.status === 'failed' ? doc.lastError : undefined,
209
202
  bounceMessage: doc.status === 'failed' ? doc.lastError : undefined,
210
203
  headers,
@@ -388,6 +381,46 @@ export class EmailOpsHandler {
388
381
  /**
389
382
  * Map queue status to catalog TEmailStatus
390
383
  */
384
+ /**
385
+ * Maps SmartMTA inbound SPF/DKIM/DMARC verdicts (persisted as JSON on the
386
+ * cached email) into the catalog authenticationResults shape. Falls back to
387
+ * all-'none' when no verdicts were captured.
388
+ */
389
+ private mapInboundSecurityResults(serializedArg?: string): interfaces.requests.IEmailDetail['authenticationResults'] {
390
+ const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = {
391
+ spf: 'none',
392
+ spfDomain: '',
393
+ dkim: 'none',
394
+ dkimDomain: '',
395
+ dmarc: 'none',
396
+ dmarcPolicy: '',
397
+ };
398
+ if (!serializedArg) return noneResult;
399
+ try {
400
+ const parsed = JSON.parse(serializedArg) as {
401
+ spf?: { result?: string; domain?: string } | null;
402
+ dkim?: Array<{ is_valid?: boolean; domain?: string | null; status?: string }> | null;
403
+ dmarc?: { passed?: boolean; policy?: string; domain?: string } | null;
404
+ };
405
+ const spfResult = (parsed.spf?.result || '').toLowerCase();
406
+ const spf = (['pass', 'fail', 'softfail', 'neutral'] as const).find((value) => value === spfResult) || 'none';
407
+ const dkimSignatures = Array.isArray(parsed.dkim) ? parsed.dkim : [];
408
+ const validSignature = dkimSignatures.find((signature) => signature?.is_valid);
409
+ const dkim = validSignature ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none';
410
+ const dmarc = parsed.dmarc ? (parsed.dmarc.passed ? 'pass' : 'fail') : 'none';
411
+ return {
412
+ spf,
413
+ spfDomain: parsed.spf?.domain || '',
414
+ dkim,
415
+ dkimDomain: validSignature?.domain || dkimSignatures[0]?.domain || '',
416
+ dmarc,
417
+ dmarcPolicy: parsed.dmarc?.policy || '',
418
+ };
419
+ } catch {
420
+ return noneResult;
421
+ }
422
+ }
423
+
391
424
  private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
392
425
  switch (queueStatus) {
393
426
  case 'pending':
@@ -395,6 +428,7 @@ export class EmailOpsHandler {
395
428
  case 'queued':
396
429
  return 'pending';
397
430
  case 'delivered':
431
+ case 'stored':
398
432
  return 'delivered';
399
433
  case 'failed':
400
434
  return 'bounced';
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.3.0',
6
+ version: '17.4.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }