@serve.zone/dcrouter 14.0.1 → 14.2.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,17 +5,21 @@ import * as paths from './paths.js';
5
5
 
6
6
  // Import the email server and its configuration from smartmta
7
7
  import {
8
+ type Email,
8
9
  UnifiedEmailServer,
9
10
  type IUnifiedEmailServerOptions,
10
11
  type IEmailRoute,
11
12
  type IEmailDomainConfig,
12
13
  type IStorageManagerLike,
14
+ type IExtendedSmtpSession,
15
+ type IMessageAcceptanceContext,
16
+ type IMessageAcceptanceDecision,
13
17
  } from '@push.rocks/smartmta';
14
18
  import { logger } from './logger.js';
15
19
  import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
16
20
  import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
17
21
  // Import unified database
18
- import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
22
+ import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc, CachedEmail } from './db/index.js';
19
23
  // Import migration runner and app version
20
24
  import { createMigrationRunner } from '../ts_migrations/index.js';
21
25
  import { commitinfo } from './00_commitinfo_data.js';
@@ -38,6 +42,45 @@ import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPer
38
42
  import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
39
43
 
40
44
  type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
45
+ const DCROUTER_CACHE_ID_HEADER = 'X-Dcrouter-Cached-Email-Id';
46
+ const ACCEPTED_EMAIL_SPOOL_INTERVAL_MS = 60_000;
47
+ const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
48
+ const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
49
+ const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
50
+ const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
51
+
52
+ type TSmartMtaQueueItemLike = {
53
+ processingResult?: {
54
+ headers?: Record<string, string>;
55
+ email?: { headers?: Record<string, string> };
56
+ };
57
+ status?: 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
58
+ attempts?: number;
59
+ nextAttempt?: Date;
60
+ lastError?: string;
61
+ };
62
+
63
+ type TStoredCachedEmailEnvelopeAddress = {
64
+ address: string;
65
+ args?: Record<string, string>;
66
+ };
67
+
68
+ type TStoredCachedEmailSession = {
69
+ id?: string;
70
+ clientHostname?: string;
71
+ remoteAddress?: string;
72
+ secure?: boolean;
73
+ authenticated?: boolean;
74
+ user?: IExtendedSmtpSession['user'];
75
+ envelope?: {
76
+ mailFrom?: TStoredCachedEmailEnvelopeAddress;
77
+ rcptTo?: TStoredCachedEmailEnvelopeAddress[];
78
+ };
79
+ };
80
+
81
+ type TStoredCachedEmailRouteData = {
82
+ session?: TStoredCachedEmailSession;
83
+ };
41
84
 
42
85
  export interface IDcRouterOptions {
43
86
  /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -280,6 +323,11 @@ export class DcRouter {
280
323
  private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
281
324
  private smartProxyLifecycleChain: Promise<void> = Promise.resolve();
282
325
  private emailLifecycleChain: Promise<void> = Promise.resolve();
326
+ private acceptedEmailSpoolTimer?: ReturnType<typeof setInterval> & { unref?: () => void };
327
+ private acceptedEmailSpoolRun?: Promise<void>;
328
+ private acceptedEmailSpoolProcessing = false;
329
+ private acceptedEmailSpoolStopping = false;
330
+ private acceptedEmailQueueUpdatePromises = new Set<Promise<void>>();
283
331
  private remoteIngressHubStopping = false;
284
332
  private remoteIngressHubGeneration = 0;
285
333
 
@@ -680,12 +728,10 @@ export class DcRouter {
680
728
  );
681
729
  }
682
730
 
683
- // Email Server: optional, depends on SmartProxy
684
- if (this.options.dbConfig?.enabled !== false || this.options.emailConfig) {
731
+ // Email Server: optional, depends on SmartProxy and durable DB persistence.
732
+ if (this.options.dbConfig?.enabled !== false) {
685
733
  const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
686
- if (this.options.dbConfig?.enabled !== false) {
687
- emailServiceDeps.push('EmailDomainManager');
688
- }
734
+ emailServiceDeps.push('EmailDomainManager');
689
735
  this.serviceManager.addService(
690
736
  new plugins.taskbuffer.Service('EmailServer')
691
737
  .optional()
@@ -706,6 +752,8 @@ export class DcRouter {
706
752
  })
707
753
  .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
708
754
  );
755
+ } else if (this.options.emailConfig) {
756
+ logger.log('warn', 'EmailServer: dbConfig.enabled=false, skipping SMTP startup because accepted email requires durable DB persistence');
709
757
  }
710
758
 
711
759
  // DNS Server: optional, depends on SmartProxy
@@ -713,7 +761,7 @@ export class DcRouter {
713
761
  this.serviceManager.addService(
714
762
  new plugins.taskbuffer.Service('DnsServer')
715
763
  .optional()
716
- .dependsOn('SmartProxy', ...((this.options.dbConfig?.enabled !== false || this.options.emailConfig) ? ['EmailServer'] : []))
764
+ .dependsOn('SmartProxy', ...((this.options.dbConfig?.enabled !== false) ? ['EmailServer'] : []))
717
765
  .withStart(async () => {
718
766
  await this.setupDnsWithSocketHandler();
719
767
  })
@@ -1158,9 +1206,11 @@ export class DcRouter {
1158
1206
  logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
1159
1207
 
1160
1208
  this.seedEmailRoutes = [];
1161
- if (this.options.emailConfig) {
1209
+ if (this.options.emailConfig && this.options.dbConfig?.enabled !== false) {
1162
1210
  this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
1163
1211
  logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
1212
+ } else if (this.options.emailConfig) {
1213
+ logger.log('warn', 'Email routes skipped because dbConfig.enabled=false and SMTP acceptance requires durable DB persistence');
1164
1214
  }
1165
1215
 
1166
1216
  this.seedDnsRoutes = [];
@@ -1395,6 +1445,12 @@ export class DcRouter {
1395
1445
  logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
1396
1446
 
1397
1447
  const smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
1448
+ smartProxy.registerChallengeProvider(
1449
+ 'smartchallenge',
1450
+ new plugins.smartchallenge.SmartChallengeProvider({
1451
+ challengeTypes: [new plugins.smartchallenge.WaitChallengeType()],
1452
+ }),
1453
+ );
1398
1454
  this.smartProxy = smartProxy;
1399
1455
 
1400
1456
  // Set up event listeners
@@ -1723,9 +1779,11 @@ export class DcRouter {
1723
1779
 
1724
1780
  let action: any = {
1725
1781
  type: 'forward',
1782
+ sendProxyProtocol: true,
1726
1783
  targets: [{
1727
1784
  host: 'localhost', // Forward to internal email server
1728
- port: internalPort
1785
+ port: internalPort,
1786
+ sendProxyProtocol: true,
1729
1787
  }],
1730
1788
  tls: {
1731
1789
  mode: tlsMode as any
@@ -1808,6 +1866,9 @@ export class DcRouter {
1808
1866
  }
1809
1867
 
1810
1868
  private getCurrentGeneratedEmailRouteNames(): Set<string> {
1869
+ if (this.options.dbConfig?.enabled === false) {
1870
+ return new Set();
1871
+ }
1811
1872
  const sourceRoutes = this.seedEmailRoutes.length > 0
1812
1873
  ? this.seedEmailRoutes
1813
1874
  : this.options.emailConfig
@@ -1882,7 +1943,7 @@ export class DcRouter {
1882
1943
  targetHost: string,
1883
1944
  targetPort: number,
1884
1945
  ): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
1885
- return (clientSocket) => {
1946
+ return (clientSocket, context) => {
1886
1947
  let backendSocket: plugins.net.Socket | undefined;
1887
1948
  let connectTimeout: ReturnType<typeof setTimeout> & { unref?: () => void };
1888
1949
  let cleanupDone = false;
@@ -1917,8 +1978,20 @@ export class DcRouter {
1917
1978
  backendSocket = plugins.net.connect(targetPort, targetHost, () => {
1918
1979
  clearTimeout(connectTimeout);
1919
1980
  backendSocket?.setTimeout(300_000);
1920
- clientSocket.pipe(backendSocket!);
1921
- backendSocket!.pipe(clientSocket);
1981
+ const proxyHeader = this.createProxyProtocolV1Header(
1982
+ context?.clientIp,
1983
+ targetHost,
1984
+ 0,
1985
+ targetPort,
1986
+ );
1987
+ if (!proxyHeader) {
1988
+ cleanup();
1989
+ return;
1990
+ }
1991
+ backendSocket!.write(proxyHeader, () => {
1992
+ clientSocket.pipe(backendSocket!);
1993
+ backendSocket!.pipe(clientSocket);
1994
+ });
1922
1995
  });
1923
1996
  backendSocket.setTimeout(30_000);
1924
1997
  backendSocket.on('timeout', cleanup);
@@ -1928,6 +2001,32 @@ export class DcRouter {
1928
2001
  };
1929
2002
  }
1930
2003
 
2004
+ private createProxyProtocolV1Header(
2005
+ sourceIp: string | undefined,
2006
+ destinationIp: string,
2007
+ sourcePort: number,
2008
+ destinationPort: number,
2009
+ ): string | undefined {
2010
+ if (!sourceIp || !plugins.net.isIP(sourceIp)) {
2011
+ logger.log('warn', `Cannot create email PROXY protocol header for invalid source IP: ${sourceIp || 'unknown'}`);
2012
+ return undefined;
2013
+ }
2014
+ const sourceFamily = plugins.net.isIP(sourceIp);
2015
+ const destinationAddress = destinationIp === 'localhost' || destinationIp === '127.0.0.1' || destinationIp === '::1'
2016
+ ? sourceFamily === 6 ? '::1' : '127.0.0.1'
2017
+ : destinationIp;
2018
+ const destinationFamily = plugins.net.isIP(destinationAddress);
2019
+ if (!destinationFamily) {
2020
+ logger.log('warn', `Cannot create email PROXY protocol header for invalid destination IP: ${destinationIp}`);
2021
+ return undefined;
2022
+ }
2023
+ if (sourceFamily !== destinationFamily) {
2024
+ return undefined;
2025
+ }
2026
+ const protocol = sourceFamily === 6 ? 'TCP6' : 'TCP4';
2027
+ return `PROXY ${protocol} ${sourceIp} ${destinationAddress} ${sourcePort} ${destinationPort}\r\n`;
2028
+ }
2029
+
1931
2030
  private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
1932
2031
  const routeName = storedRoute.route.name || '';
1933
2032
  const isDohRoute = storedRoute.origin === 'dns'
@@ -2068,6 +2167,9 @@ export class DcRouter {
2068
2167
  if (!this.options.emailConfig) {
2069
2168
  throw new Error('Email configuration is required for unified email handling');
2070
2169
  }
2170
+ if (!this.dcRouterDb?.isReady()) {
2171
+ throw new Error('DcRouterDb is required for email acceptance');
2172
+ }
2071
2173
 
2072
2174
  // Apply port mapping if behind SmartProxy
2073
2175
  const portMapping = this.options.emailPortConfig?.portMapping || {
@@ -2077,14 +2179,44 @@ export class DcRouter {
2077
2179
  };
2078
2180
 
2079
2181
  // Create config with mapped ports
2182
+ const baseEmailConfig = this.options.emailConfig;
2183
+ const configuredMessageDataHook = baseEmailConfig.hooks?.onMessageData;
2080
2184
  const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
2081
2185
  ...this.options.emailConfig,
2082
2186
  ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
2083
2187
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
2084
2188
  queue: {
2085
- storageType: 'disk',
2086
- persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
2087
2189
  ...this.options.emailConfig.queue,
2190
+ storageType: 'memory',
2191
+ },
2192
+ smtp: {
2193
+ ...baseEmailConfig.smtp,
2194
+ recipientValidation: true,
2195
+ proxyProtocol: {
2196
+ ...baseEmailConfig.smtp?.proxyProtocol,
2197
+ required: true,
2198
+ trustedIps: ['127.0.0.1', '::1'],
2199
+ },
2200
+ },
2201
+ hooks: {
2202
+ ...baseEmailConfig.hooks,
2203
+ onMessageData: async (context) => {
2204
+ const configuredDecision = configuredMessageDataHook
2205
+ ? await configuredMessageDataHook(context)
2206
+ : undefined;
2207
+ if (configuredDecision && !configuredDecision.accepted) {
2208
+ return configuredDecision;
2209
+ }
2210
+ const dcrouterDecision = await this.acceptSmartMtaMessage(
2211
+ context,
2212
+ configuredDecision ? configuredDecision.continueProcessing === true : true,
2213
+ );
2214
+ return {
2215
+ ...dcrouterDecision,
2216
+ smtpCode: configuredDecision?.smtpCode ?? dcrouterDecision.smtpCode,
2217
+ smtpMessage: configuredDecision?.smtpMessage ?? dcrouterDecision.smtpMessage,
2218
+ };
2219
+ },
2088
2220
  },
2089
2221
  });
2090
2222
 
@@ -2114,58 +2246,450 @@ export class DcRouter {
2114
2246
  throw error;
2115
2247
  }
2116
2248
 
2117
- // Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
2118
- if (this.metricsManager) {
2119
- const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
2120
- const emailLike = item?.processingResult;
2121
- const from = emailLike?.from || emailLike?.email?.from || '';
2122
- const recipients = Array.isArray(emailLike?.to)
2123
- ? emailLike.to
2124
- : Array.isArray(emailLike?.email?.to)
2125
- ? emailLike.email.to
2126
- : [];
2127
- return {
2128
- from,
2129
- recipients: recipients.filter(Boolean),
2130
- };
2131
- };
2132
- const updateQueueSize = () => {
2133
- this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize);
2134
- };
2135
-
2136
- this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
2137
- const envelope = getEnvelope(item);
2138
- this.metricsManager!.trackEmailReceived(envelope.from);
2139
- updateQueueSize();
2140
- logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2249
+ try {
2250
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: TSmartMtaQueueItemLike) => {
2251
+ this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to update accepted email after queue enqueue');
2141
2252
  });
2142
- this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
2143
- const envelope = getEnvelope(item);
2144
- this.metricsManager!.trackEmailSent(envelope.recipients[0]);
2145
- updateQueueSize();
2146
- logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2253
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: TSmartMtaQueueItemLike) => {
2254
+ this.trackAcceptedEmailQueueUpdate(item, 'delivered', 'Unable to mark accepted email delivered');
2147
2255
  });
2148
- this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => {
2149
- const envelope = getEnvelope(item);
2150
- this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
2151
- updateQueueSize();
2152
- logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
2256
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', (item: TSmartMtaQueueItemLike) => {
2257
+ this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to defer accepted email');
2153
2258
  });
2154
- this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => {
2155
- updateQueueSize();
2259
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: TSmartMtaQueueItemLike) => {
2260
+ this.trackAcceptedEmailQueueUpdate(item, 'failed', 'Unable to mark accepted email failed');
2156
2261
  });
2157
- this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => {
2262
+
2263
+ // Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
2264
+ if (this.metricsManager) {
2265
+ const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
2266
+ const emailLike = item?.processingResult;
2267
+ const from = emailLike?.from || emailLike?.email?.from || '';
2268
+ const recipients = Array.isArray(emailLike?.to)
2269
+ ? emailLike.to
2270
+ : Array.isArray(emailLike?.email?.to)
2271
+ ? emailLike.email.to
2272
+ : [];
2273
+ return {
2274
+ from,
2275
+ recipients: recipients.filter(Boolean),
2276
+ };
2277
+ };
2278
+ const updateQueueSize = () => {
2279
+ this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize);
2280
+ };
2281
+
2282
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
2283
+ const envelope = getEnvelope(item);
2284
+ this.metricsManager!.trackEmailReceived(envelope.from);
2285
+ updateQueueSize();
2286
+ logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2287
+ });
2288
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
2289
+ const envelope = getEnvelope(item);
2290
+ this.metricsManager!.trackEmailSent(envelope.recipients[0]);
2291
+ updateQueueSize();
2292
+ logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2293
+ });
2294
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => {
2295
+ const envelope = getEnvelope(item);
2296
+ this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
2297
+ updateQueueSize();
2298
+ logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
2299
+ });
2300
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => {
2301
+ updateQueueSize();
2302
+ });
2303
+ this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => {
2304
+ updateQueueSize();
2305
+ });
2306
+ this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => {
2307
+ this.metricsManager!.trackEmailBounced();
2308
+ logger.log('warn', 'Email bounce processed', { zone: 'email' });
2309
+ });
2158
2310
  updateQueueSize();
2159
- });
2160
- this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => {
2161
- this.metricsManager!.trackEmailBounced();
2162
- logger.log('warn', 'Email bounce processed', { zone: 'email' });
2163
- });
2164
- updateQueueSize();
2311
+ }
2312
+
2313
+ await this.recoverQueuedAcceptedEmails();
2314
+ this.startAcceptedEmailSpoolProcessor();
2315
+ } catch (error: unknown) {
2316
+ this.acceptedEmailSpoolStopping = true;
2317
+ this.clearAcceptedEmailSpoolTimer();
2318
+ try {
2319
+ await emailServer.stop();
2320
+ } catch (stopError: unknown) {
2321
+ logger.log('warn', `Error cleaning up failed UnifiedEmailServer setup: ${(stopError as Error).message}`);
2322
+ }
2323
+ await this.stopAcceptedEmailSpoolProcessor();
2324
+ await this.drainAcceptedEmailQueueUpdates();
2325
+ this.clearEmailEventSubscriptions();
2326
+ if (this.emailServer === emailServer) {
2327
+ this.emailServer = undefined;
2328
+ }
2329
+ throw error;
2165
2330
  }
2166
2331
 
2167
2332
  logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
2168
2333
  }
2334
+
2335
+ private async acceptSmartMtaMessage(
2336
+ context: IMessageAcceptanceContext,
2337
+ processAfterAccept = true,
2338
+ ): Promise<IMessageAcceptanceDecision> {
2339
+ if (!this.dcRouterDb?.isReady()) {
2340
+ throw new Error('DcRouterDb is not available for email acceptance');
2341
+ }
2342
+ this.throwIfMessageAcceptanceAborted(context.abortSignal);
2343
+
2344
+ const rawMessage = context.rawMessage;
2345
+ const session = context.session;
2346
+ const envelope = session.envelope;
2347
+ const envelopeRecipients = Array.isArray(envelope.rcptTo)
2348
+ ? envelope.rcptTo.map((recipient) => recipient.address).filter(Boolean)
2349
+ : [];
2350
+ const email = context.email;
2351
+ const headers = email.headers;
2352
+
2353
+ const cachedEmail = CachedEmail.createNew();
2354
+ this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
2355
+ email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
2356
+ cachedEmail.messageId = headers['Message-ID'] || headers['message-id'] || cachedEmail.id;
2357
+ cachedEmail.from = envelope.mailFrom?.address || email.from || '';
2358
+ cachedEmail.to = envelopeRecipients.length > 0
2359
+ ? envelopeRecipients
2360
+ : Array.isArray(email.to) ? email.to : [];
2361
+ cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
2362
+ cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
2363
+ cachedEmail.subject = email.subject || '';
2364
+ cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
2365
+ cachedEmail.status = 'pending';
2366
+ cachedEmail.nextAttempt = new Date();
2367
+ cachedEmail.routeData = JSON.stringify({
2368
+ acceptedAt: new Date().toISOString(),
2369
+ session: {
2370
+ id: session.id,
2371
+ remoteAddress: session.remoteAddress,
2372
+ clientHostname: session.clientHostname,
2373
+ secure: !!session.secure,
2374
+ authenticated: !!session.authenticated,
2375
+ user: session.user,
2376
+ envelope,
2377
+ },
2378
+ });
2379
+ cachedEmail.updateSenderDomain();
2380
+ await cachedEmail.save();
2381
+ if (context.abortSignal?.aborted) {
2382
+ cachedEmail.markFailed('Message acceptance aborted before SMTP success');
2383
+ await cachedEmail.save();
2384
+ throw new Error('Message acceptance aborted before SMTP success');
2385
+ }
2386
+
2387
+ if (processAfterAccept) {
2388
+ this.runAcceptedEmailSpoolProcessor();
2389
+ } else {
2390
+ cachedEmail.markDelivered();
2391
+ await cachedEmail.save();
2392
+ }
2393
+
2394
+ return {
2395
+ accepted: true,
2396
+ smtpCode: 250,
2397
+ smtpMessage: '2.0.0 Message accepted for delivery',
2398
+ continueProcessing: false,
2399
+ };
2400
+ }
2401
+
2402
+ private setDcRouterCacheIdHeader(rawContent: string, cachedEmailId: string): string {
2403
+ const headerRegex = new RegExp(`^${DCROUTER_CACHE_ID_HEADER}:.*(?:\r?\n[\t ].*)*\r?\n?`, 'gim');
2404
+ const sanitizedContent = rawContent.replace(headerRegex, '');
2405
+ return `${DCROUTER_CACHE_ID_HEADER}: ${cachedEmailId}\r\n${sanitizedContent}`;
2406
+ }
2407
+
2408
+ private async processAcceptedCachedEmail(
2409
+ cachedEmail: CachedEmail,
2410
+ emailData: Email | plugins.buffer.Buffer,
2411
+ session: IExtendedSmtpSession,
2412
+ emailServer: UnifiedEmailServer,
2413
+ ): Promise<void> {
2414
+ cachedEmail.status = 'processing';
2415
+ cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2416
+ await cachedEmail.save();
2417
+
2418
+ try {
2419
+ await emailServer.processEmailByMode(emailData, session);
2420
+ if (session.matchedRoute?.action.type === 'forward') {
2421
+ cachedEmail.markDelivered();
2422
+ } else {
2423
+ const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
2424
+ if (this.isCachedEmailTerminal(currentCachedEmail)) {
2425
+ return;
2426
+ }
2427
+ currentCachedEmail.status = 'queued';
2428
+ currentCachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2429
+ await currentCachedEmail.save();
2430
+ return;
2431
+ }
2432
+ await cachedEmail.save();
2433
+ } catch (error: unknown) {
2434
+ cachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
2435
+ cachedEmail.lastError = (error as Error).message;
2436
+ await cachedEmail.save();
2437
+ logger.log('warn', `Accepted email ${cachedEmail.id} deferred after SmartMTA handoff failure: ${(error as Error).message}`);
2438
+ }
2439
+ }
2440
+
2441
+ private startAcceptedEmailSpoolProcessor(): void {
2442
+ this.clearAcceptedEmailSpoolTimer();
2443
+ this.acceptedEmailSpoolStopping = false;
2444
+ const runProcessor = () => {
2445
+ this.runAcceptedEmailSpoolProcessor();
2446
+ };
2447
+ this.acceptedEmailSpoolTimer = setInterval(
2448
+ runProcessor,
2449
+ ACCEPTED_EMAIL_SPOOL_INTERVAL_MS,
2450
+ ) as ReturnType<typeof setInterval> & { unref?: () => void };
2451
+ this.acceptedEmailSpoolTimer.unref?.();
2452
+ runProcessor();
2453
+ }
2454
+
2455
+ private clearAcceptedEmailSpoolTimer(): void {
2456
+ if (this.acceptedEmailSpoolTimer) {
2457
+ clearInterval(this.acceptedEmailSpoolTimer);
2458
+ this.acceptedEmailSpoolTimer = undefined;
2459
+ }
2460
+ }
2461
+
2462
+ private async stopAcceptedEmailSpoolProcessor(): Promise<void> {
2463
+ this.acceptedEmailSpoolStopping = true;
2464
+ this.clearAcceptedEmailSpoolTimer();
2465
+ const spoolRun = this.acceptedEmailSpoolRun;
2466
+ if (spoolRun) {
2467
+ const settled = await this.waitForPromiseToSettleWithTimeout(
2468
+ spoolRun,
2469
+ ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
2470
+ );
2471
+ if (!settled) {
2472
+ logger.log('warn', 'Timed out waiting for accepted email spool processing to stop');
2473
+ }
2474
+ }
2475
+ }
2476
+
2477
+ private runAcceptedEmailSpoolProcessor(): void {
2478
+ if (this.acceptedEmailSpoolRun) {
2479
+ return;
2480
+ }
2481
+ const run = this.processAcceptedEmailSpool().catch((error) => {
2482
+ logger.log('warn', `Accepted email spool processing failed: ${(error as Error).message}`);
2483
+ });
2484
+ this.acceptedEmailSpoolRun = run;
2485
+ void run.finally(() => {
2486
+ if (this.acceptedEmailSpoolRun === run) {
2487
+ this.acceptedEmailSpoolRun = undefined;
2488
+ }
2489
+ });
2490
+ }
2491
+
2492
+ private async processAcceptedEmailSpool(): Promise<void> {
2493
+ const emailServer = this.emailServer;
2494
+ if (this.acceptedEmailSpoolProcessing || !emailServer || !this.dcRouterDb?.isReady()) {
2495
+ return;
2496
+ }
2497
+ this.acceptedEmailSpoolProcessing = true;
2498
+ try {
2499
+ const cachedEmails = await CachedEmail.findPendingForDelivery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
2500
+ for (const cachedEmail of cachedEmails) {
2501
+ if (this.acceptedEmailSpoolStopping || this.emailServer !== emailServer) {
2502
+ break;
2503
+ }
2504
+ const session = this.buildCachedEmailSession(cachedEmail);
2505
+ const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
2506
+ await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
2507
+ }
2508
+ } finally {
2509
+ this.acceptedEmailSpoolProcessing = false;
2510
+ }
2511
+ }
2512
+
2513
+ private buildCachedEmailSession(cachedEmail: CachedEmail): IExtendedSmtpSession {
2514
+ const routeData = this.parseCachedEmailRouteData(cachedEmail);
2515
+ const storedSession = routeData.session || {};
2516
+ const storedEnvelope = storedSession.envelope || {};
2517
+ const storedRcptTo = Array.isArray(storedEnvelope.rcptTo) && storedEnvelope.rcptTo.length > 0
2518
+ ? storedEnvelope.rcptTo
2519
+ : cachedEmail.to.map((address) => ({ address, args: {} }));
2520
+ const rcptTo = storedRcptTo
2521
+ .filter((recipient) => !!recipient.address)
2522
+ .map((recipient) => ({ address: recipient.address, args: recipient.args || {} }));
2523
+ const mailFrom = storedEnvelope.mailFrom
2524
+ ? { address: storedEnvelope.mailFrom.address, args: storedEnvelope.mailFrom.args || {} }
2525
+ : { address: cachedEmail.from || '', args: {} };
2526
+ const session = {
2527
+ id: `${storedSession.id || cachedEmail.id}-replay-${Date.now()}`,
2528
+ state: 'DATA' as unknown as IExtendedSmtpSession['state'],
2529
+ clientHostname: storedSession.clientHostname || '',
2530
+ mailFrom: mailFrom.address,
2531
+ rcptTo: rcptTo.map((recipient) => recipient.address),
2532
+ emailData: cachedEmail.rawContent || '',
2533
+ useTLS: !!storedSession.secure,
2534
+ connectionEnded: false,
2535
+ remoteAddress: storedSession.remoteAddress || '127.0.0.1',
2536
+ secure: !!storedSession.secure,
2537
+ authenticated: !!storedSession.authenticated,
2538
+ envelope: {
2539
+ mailFrom,
2540
+ rcptTo,
2541
+ },
2542
+ } as IExtendedSmtpSession;
2543
+ if (storedSession.user) {
2544
+ session.user = storedSession.user;
2545
+ }
2546
+ return session;
2547
+ }
2548
+
2549
+ private parseCachedEmailRouteData(cachedEmail: CachedEmail): TStoredCachedEmailRouteData {
2550
+ try {
2551
+ return cachedEmail.routeData ? JSON.parse(cachedEmail.routeData) : {};
2552
+ } catch {
2553
+ return {};
2554
+ }
2555
+ }
2556
+
2557
+ private async updateAcceptedEmailFromQueueItem(
2558
+ item: TSmartMtaQueueItemLike,
2559
+ status: 'queued' | 'delivered' | 'failed',
2560
+ ): Promise<void> {
2561
+ const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
2562
+ if (!cachedEmailId || !this.dcRouterDb?.isReady()) {
2563
+ return;
2564
+ }
2565
+ const cachedEmail = await CachedEmail.findById(cachedEmailId);
2566
+ if (!cachedEmail) {
2567
+ return;
2568
+ }
2569
+ if (this.isCachedEmailTerminal(cachedEmail) && status !== 'delivered') {
2570
+ return;
2571
+ }
2572
+ cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
2573
+ if (status === 'delivered') {
2574
+ cachedEmail.markDelivered();
2575
+ } else if (status === 'failed') {
2576
+ cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
2577
+ } else {
2578
+ cachedEmail.status = 'queued';
2579
+ cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2580
+ }
2581
+ await cachedEmail.save();
2582
+ }
2583
+
2584
+ private trackAcceptedEmailQueueUpdate(
2585
+ item: TSmartMtaQueueItemLike,
2586
+ status: 'queued' | 'delivered' | 'failed',
2587
+ failureMessage: string,
2588
+ ): void {
2589
+ const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
2590
+ logger.log('warn', `${failureMessage}: ${(error as Error).message}`);
2591
+ });
2592
+ this.acceptedEmailQueueUpdatePromises.add(updatePromise);
2593
+ void updatePromise.finally(() => {
2594
+ this.acceptedEmailQueueUpdatePromises.delete(updatePromise);
2595
+ });
2596
+ }
2597
+
2598
+ private async drainAcceptedEmailQueueUpdates(): Promise<void> {
2599
+ const queueUpdates = [...this.acceptedEmailQueueUpdatePromises];
2600
+ if (queueUpdates.length === 0) {
2601
+ return;
2602
+ }
2603
+ const settled = await this.waitForPromiseToSettleWithTimeout(
2604
+ Promise.allSettled(queueUpdates).then(() => undefined),
2605
+ ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
2606
+ );
2607
+ if (!settled) {
2608
+ for (const queueUpdate of queueUpdates) {
2609
+ this.acceptedEmailQueueUpdatePromises.delete(queueUpdate);
2610
+ }
2611
+ logger.log('warn', `Timed out waiting for ${queueUpdates.length} accepted email queue update(s) to settle`);
2612
+ }
2613
+ }
2614
+
2615
+ private async waitForPromiseToSettleWithTimeout(
2616
+ promise: Promise<unknown>,
2617
+ timeoutMs: number,
2618
+ ): Promise<boolean> {
2619
+ let timeout: (ReturnType<typeof setTimeout> & { unref?: () => void }) | undefined;
2620
+ return await new Promise<boolean>((resolve) => {
2621
+ let settled = false;
2622
+ const settle = (didSettle: boolean) => {
2623
+ if (settled) {
2624
+ return;
2625
+ }
2626
+ settled = true;
2627
+ if (timeout) {
2628
+ clearTimeout(timeout);
2629
+ }
2630
+ resolve(didSettle);
2631
+ };
2632
+ timeout = setTimeout(() => settle(false), timeoutMs) as ReturnType<typeof setTimeout> & { unref?: () => void };
2633
+ timeout.unref?.();
2634
+ promise.then(
2635
+ () => settle(true),
2636
+ () => settle(true),
2637
+ );
2638
+ });
2639
+ }
2640
+
2641
+ private async recoverQueuedAcceptedEmails(): Promise<void> {
2642
+ while (true) {
2643
+ const queuedEmails = await CachedEmail.findQueuedForRecovery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
2644
+ if (queuedEmails.length === 0) {
2645
+ return;
2646
+ }
2647
+ for (const queuedEmail of queuedEmails) {
2648
+ if (this.isCachedEmailTerminal(queuedEmail)) {
2649
+ continue;
2650
+ }
2651
+ queuedEmail.status = 'pending';
2652
+ queuedEmail.nextAttempt = new Date();
2653
+ await queuedEmail.save();
2654
+ }
2655
+ if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
2656
+ return;
2657
+ }
2658
+ }
2659
+ }
2660
+
2661
+ private isCachedEmailTerminal(cachedEmail: CachedEmail): boolean {
2662
+ return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
2663
+ }
2664
+
2665
+ private getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
2666
+ return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
2667
+ || this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
2668
+ }
2669
+
2670
+ private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
2671
+ if (!headers) {
2672
+ return undefined;
2673
+ }
2674
+ const normalizedHeaderName = headerName.toLowerCase();
2675
+ const matchingHeaderName = Object.keys(headers).find((key) => key.toLowerCase() === normalizedHeaderName);
2676
+ return matchingHeaderName ? headers[matchingHeaderName] : undefined;
2677
+ }
2678
+
2679
+ private removeHeader(headers: Record<string, string>, headerName: string): void {
2680
+ const normalizedHeaderName = headerName.toLowerCase();
2681
+ for (const key of Object.keys(headers)) {
2682
+ if (key.toLowerCase() === normalizedHeaderName) {
2683
+ delete headers[key];
2684
+ }
2685
+ }
2686
+ }
2687
+
2688
+ private throwIfMessageAcceptanceAborted(abortSignal: AbortSignal | undefined): void {
2689
+ if (abortSignal?.aborted) {
2690
+ throw new Error('Message acceptance aborted before SMTP success');
2691
+ }
2692
+ }
2169
2693
 
2170
2694
  /**
2171
2695
  * Update the unified email configuration
@@ -2232,10 +2756,18 @@ export class DcRouter {
2232
2756
  try {
2233
2757
  // Stop the unified email server which contains all components
2234
2758
  if (this.emailServer) {
2235
- this.clearEmailEventSubscriptions();
2236
- await this.emailServer.stop();
2237
- logger.log('info', 'Unified email server stopped');
2759
+ const emailServer = this.emailServer;
2238
2760
  this.emailServer = undefined;
2761
+ this.acceptedEmailSpoolStopping = true;
2762
+ this.clearAcceptedEmailSpoolTimer();
2763
+ try {
2764
+ await emailServer.stop();
2765
+ } finally {
2766
+ await this.stopAcceptedEmailSpoolProcessor();
2767
+ await this.drainAcceptedEmailQueueUpdates();
2768
+ this.clearEmailEventSubscriptions();
2769
+ }
2770
+ logger.log('info', 'Unified email server stopped');
2239
2771
  }
2240
2772
 
2241
2773
  logger.log('info', 'All unified email components stopped');