@serve.zone/dcrouter 14.1.0 → 14.2.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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +26 -0
- package/dist_ts/classes.dcrouter.js +505 -60
- package/dist_ts/config/classes.reference-resolver.js +3 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +4 -2
- package/dist_ts/db/documents/classes.cached.email.js +25 -5
- package/dist_ts/http3/http3-route-augmentation.js +2 -1
- package/dist_ts/plugins.d.ts +4 -2
- package/dist_ts/plugins.js +5 -3
- package/dist_ts_migrations/index.js +2 -2
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-sourceprofiles.js +2 -1
- package/package.json +5 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +606 -62
- package/ts/config/classes.reference-resolver.ts +1 -0
- package/ts/db/documents/classes.cached.email.ts +31 -5
- package/ts/http3/http3-route-augmentation.ts +1 -0
- package/ts/plugins.ts +4 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-sourceprofiles.ts +1 -0
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -1686,7 +1742,7 @@ export class DcRouter {
|
|
|
1686
1742
|
|
|
1687
1743
|
case 465: // SMTPS
|
|
1688
1744
|
routeName = 'smtps-route';
|
|
1689
|
-
tlsMode = '
|
|
1745
|
+
tlsMode = 'passthrough'; // Implicit TLS is handled by the email server backend
|
|
1690
1746
|
break;
|
|
1691
1747
|
|
|
1692
1748
|
default:
|
|
@@ -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
|
-
|
|
1921
|
-
|
|
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,56 @@ 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;
|
|
2184
|
+
const mappedEmailPorts = baseEmailConfig.ports.map(port => portMapping[port] || port + 10000);
|
|
2185
|
+
const configuredSecurePort = baseEmailConfig.smtp?.securePort;
|
|
2186
|
+
const defaultSecurePort = baseEmailConfig.ports.includes(465) ? 465 : undefined;
|
|
2187
|
+
const securePortSource = configuredSecurePort ?? defaultSecurePort;
|
|
2188
|
+
const mappedSecurePort = securePortSource === undefined
|
|
2189
|
+
? undefined
|
|
2190
|
+
: mappedEmailPorts.includes(securePortSource) && !baseEmailConfig.ports.includes(securePortSource)
|
|
2191
|
+
? securePortSource
|
|
2192
|
+
: portMapping[securePortSource] || securePortSource + 10000;
|
|
2080
2193
|
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
|
2081
2194
|
...this.options.emailConfig,
|
|
2082
|
-
ports:
|
|
2195
|
+
ports: mappedEmailPorts,
|
|
2083
2196
|
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
|
2084
2197
|
queue: {
|
|
2085
|
-
storageType: 'disk',
|
|
2086
|
-
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
|
2087
2198
|
...this.options.emailConfig.queue,
|
|
2199
|
+
storageType: 'memory',
|
|
2200
|
+
},
|
|
2201
|
+
smtp: {
|
|
2202
|
+
...baseEmailConfig.smtp,
|
|
2203
|
+
...(mappedSecurePort !== undefined
|
|
2204
|
+
? { securePort: mappedSecurePort }
|
|
2205
|
+
: {}),
|
|
2206
|
+
recipientValidation: true,
|
|
2207
|
+
proxyProtocol: {
|
|
2208
|
+
...baseEmailConfig.smtp?.proxyProtocol,
|
|
2209
|
+
required: true,
|
|
2210
|
+
trustedIps: ['127.0.0.1', '::1'],
|
|
2211
|
+
},
|
|
2212
|
+
},
|
|
2213
|
+
hooks: {
|
|
2214
|
+
...baseEmailConfig.hooks,
|
|
2215
|
+
onMessageData: async (context) => {
|
|
2216
|
+
const configuredDecision = configuredMessageDataHook
|
|
2217
|
+
? await configuredMessageDataHook(context)
|
|
2218
|
+
: undefined;
|
|
2219
|
+
if (configuredDecision && !configuredDecision.accepted) {
|
|
2220
|
+
return configuredDecision;
|
|
2221
|
+
}
|
|
2222
|
+
const dcrouterDecision = await this.acceptSmartMtaMessage(
|
|
2223
|
+
context,
|
|
2224
|
+
configuredDecision ? configuredDecision.continueProcessing === true : true,
|
|
2225
|
+
);
|
|
2226
|
+
return {
|
|
2227
|
+
...dcrouterDecision,
|
|
2228
|
+
smtpCode: configuredDecision?.smtpCode ?? dcrouterDecision.smtpCode,
|
|
2229
|
+
smtpMessage: configuredDecision?.smtpMessage ?? dcrouterDecision.smtpMessage,
|
|
2230
|
+
};
|
|
2231
|
+
},
|
|
2088
2232
|
},
|
|
2089
2233
|
});
|
|
2090
2234
|
|
|
@@ -2114,58 +2258,450 @@ export class DcRouter {
|
|
|
2114
2258
|
throw error;
|
|
2115
2259
|
}
|
|
2116
2260
|
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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' });
|
|
2261
|
+
try {
|
|
2262
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: TSmartMtaQueueItemLike) => {
|
|
2263
|
+
this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to update accepted email after queue enqueue');
|
|
2141
2264
|
});
|
|
2142
|
-
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item:
|
|
2143
|
-
|
|
2144
|
-
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
|
2145
|
-
updateQueueSize();
|
|
2146
|
-
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
|
2265
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: TSmartMtaQueueItemLike) => {
|
|
2266
|
+
this.trackAcceptedEmailQueueUpdate(item, 'delivered', 'Unable to mark accepted email delivered');
|
|
2147
2267
|
});
|
|
2148
|
-
this.addEmailEventSubscription(emailServer.deliveryQueue, '
|
|
2149
|
-
|
|
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' });
|
|
2268
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', (item: TSmartMtaQueueItemLike) => {
|
|
2269
|
+
this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to defer accepted email');
|
|
2153
2270
|
});
|
|
2154
|
-
this.addEmailEventSubscription(emailServer.deliveryQueue, '
|
|
2155
|
-
|
|
2271
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: TSmartMtaQueueItemLike) => {
|
|
2272
|
+
this.trackAcceptedEmailQueueUpdate(item, 'failed', 'Unable to mark accepted email failed');
|
|
2156
2273
|
});
|
|
2157
|
-
|
|
2274
|
+
|
|
2275
|
+
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
|
2276
|
+
if (this.metricsManager) {
|
|
2277
|
+
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
|
2278
|
+
const emailLike = item?.processingResult;
|
|
2279
|
+
const from = emailLike?.from || emailLike?.email?.from || '';
|
|
2280
|
+
const recipients = Array.isArray(emailLike?.to)
|
|
2281
|
+
? emailLike.to
|
|
2282
|
+
: Array.isArray(emailLike?.email?.to)
|
|
2283
|
+
? emailLike.email.to
|
|
2284
|
+
: [];
|
|
2285
|
+
return {
|
|
2286
|
+
from,
|
|
2287
|
+
recipients: recipients.filter(Boolean),
|
|
2288
|
+
};
|
|
2289
|
+
};
|
|
2290
|
+
const updateQueueSize = () => {
|
|
2291
|
+
this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize);
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
|
2295
|
+
const envelope = getEnvelope(item);
|
|
2296
|
+
this.metricsManager!.trackEmailReceived(envelope.from);
|
|
2297
|
+
updateQueueSize();
|
|
2298
|
+
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
|
2299
|
+
});
|
|
2300
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
|
2301
|
+
const envelope = getEnvelope(item);
|
|
2302
|
+
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
|
2303
|
+
updateQueueSize();
|
|
2304
|
+
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
|
2305
|
+
});
|
|
2306
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
|
2307
|
+
const envelope = getEnvelope(item);
|
|
2308
|
+
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
|
2309
|
+
updateQueueSize();
|
|
2310
|
+
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
|
2311
|
+
});
|
|
2312
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => {
|
|
2313
|
+
updateQueueSize();
|
|
2314
|
+
});
|
|
2315
|
+
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => {
|
|
2316
|
+
updateQueueSize();
|
|
2317
|
+
});
|
|
2318
|
+
this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => {
|
|
2319
|
+
this.metricsManager!.trackEmailBounced();
|
|
2320
|
+
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
|
2321
|
+
});
|
|
2158
2322
|
updateQueueSize();
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
await this.recoverQueuedAcceptedEmails();
|
|
2326
|
+
this.startAcceptedEmailSpoolProcessor();
|
|
2327
|
+
} catch (error: unknown) {
|
|
2328
|
+
this.acceptedEmailSpoolStopping = true;
|
|
2329
|
+
this.clearAcceptedEmailSpoolTimer();
|
|
2330
|
+
try {
|
|
2331
|
+
await emailServer.stop();
|
|
2332
|
+
} catch (stopError: unknown) {
|
|
2333
|
+
logger.log('warn', `Error cleaning up failed UnifiedEmailServer setup: ${(stopError as Error).message}`);
|
|
2334
|
+
}
|
|
2335
|
+
await this.stopAcceptedEmailSpoolProcessor();
|
|
2336
|
+
await this.drainAcceptedEmailQueueUpdates();
|
|
2337
|
+
this.clearEmailEventSubscriptions();
|
|
2338
|
+
if (this.emailServer === emailServer) {
|
|
2339
|
+
this.emailServer = undefined;
|
|
2340
|
+
}
|
|
2341
|
+
throw error;
|
|
2165
2342
|
}
|
|
2166
2343
|
|
|
2167
2344
|
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
|
2168
2345
|
}
|
|
2346
|
+
|
|
2347
|
+
private async acceptSmartMtaMessage(
|
|
2348
|
+
context: IMessageAcceptanceContext,
|
|
2349
|
+
processAfterAccept = true,
|
|
2350
|
+
): Promise<IMessageAcceptanceDecision> {
|
|
2351
|
+
if (!this.dcRouterDb?.isReady()) {
|
|
2352
|
+
throw new Error('DcRouterDb is not available for email acceptance');
|
|
2353
|
+
}
|
|
2354
|
+
this.throwIfMessageAcceptanceAborted(context.abortSignal);
|
|
2355
|
+
|
|
2356
|
+
const rawMessage = context.rawMessage;
|
|
2357
|
+
const session = context.session;
|
|
2358
|
+
const envelope = session.envelope;
|
|
2359
|
+
const envelopeRecipients = Array.isArray(envelope.rcptTo)
|
|
2360
|
+
? envelope.rcptTo.map((recipient) => recipient.address).filter(Boolean)
|
|
2361
|
+
: [];
|
|
2362
|
+
const email = context.email;
|
|
2363
|
+
const headers = email.headers;
|
|
2364
|
+
|
|
2365
|
+
const cachedEmail = CachedEmail.createNew();
|
|
2366
|
+
this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
|
|
2367
|
+
email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
|
|
2368
|
+
cachedEmail.messageId = headers['Message-ID'] || headers['message-id'] || cachedEmail.id;
|
|
2369
|
+
cachedEmail.from = envelope.mailFrom?.address || email.from || '';
|
|
2370
|
+
cachedEmail.to = envelopeRecipients.length > 0
|
|
2371
|
+
? envelopeRecipients
|
|
2372
|
+
: Array.isArray(email.to) ? email.to : [];
|
|
2373
|
+
cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
|
|
2374
|
+
cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
|
|
2375
|
+
cachedEmail.subject = email.subject || '';
|
|
2376
|
+
cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
|
|
2377
|
+
cachedEmail.status = 'pending';
|
|
2378
|
+
cachedEmail.nextAttempt = new Date();
|
|
2379
|
+
cachedEmail.routeData = JSON.stringify({
|
|
2380
|
+
acceptedAt: new Date().toISOString(),
|
|
2381
|
+
session: {
|
|
2382
|
+
id: session.id,
|
|
2383
|
+
remoteAddress: session.remoteAddress,
|
|
2384
|
+
clientHostname: session.clientHostname,
|
|
2385
|
+
secure: !!session.secure,
|
|
2386
|
+
authenticated: !!session.authenticated,
|
|
2387
|
+
user: session.user,
|
|
2388
|
+
envelope,
|
|
2389
|
+
},
|
|
2390
|
+
});
|
|
2391
|
+
cachedEmail.updateSenderDomain();
|
|
2392
|
+
await cachedEmail.save();
|
|
2393
|
+
if (context.abortSignal?.aborted) {
|
|
2394
|
+
cachedEmail.markFailed('Message acceptance aborted before SMTP success');
|
|
2395
|
+
await cachedEmail.save();
|
|
2396
|
+
throw new Error('Message acceptance aborted before SMTP success');
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
if (processAfterAccept) {
|
|
2400
|
+
this.runAcceptedEmailSpoolProcessor();
|
|
2401
|
+
} else {
|
|
2402
|
+
cachedEmail.markDelivered();
|
|
2403
|
+
await cachedEmail.save();
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
return {
|
|
2407
|
+
accepted: true,
|
|
2408
|
+
smtpCode: 250,
|
|
2409
|
+
smtpMessage: '2.0.0 Message accepted for delivery',
|
|
2410
|
+
continueProcessing: false,
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
private setDcRouterCacheIdHeader(rawContent: string, cachedEmailId: string): string {
|
|
2415
|
+
const headerRegex = new RegExp(`^${DCROUTER_CACHE_ID_HEADER}:.*(?:\r?\n[\t ].*)*\r?\n?`, 'gim');
|
|
2416
|
+
const sanitizedContent = rawContent.replace(headerRegex, '');
|
|
2417
|
+
return `${DCROUTER_CACHE_ID_HEADER}: ${cachedEmailId}\r\n${sanitizedContent}`;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
private async processAcceptedCachedEmail(
|
|
2421
|
+
cachedEmail: CachedEmail,
|
|
2422
|
+
emailData: Email | plugins.buffer.Buffer,
|
|
2423
|
+
session: IExtendedSmtpSession,
|
|
2424
|
+
emailServer: UnifiedEmailServer,
|
|
2425
|
+
): Promise<void> {
|
|
2426
|
+
cachedEmail.status = 'processing';
|
|
2427
|
+
cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
2428
|
+
await cachedEmail.save();
|
|
2429
|
+
|
|
2430
|
+
try {
|
|
2431
|
+
await emailServer.processEmailByMode(emailData, session);
|
|
2432
|
+
if (session.matchedRoute?.action.type === 'forward') {
|
|
2433
|
+
cachedEmail.markDelivered();
|
|
2434
|
+
} else {
|
|
2435
|
+
const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
|
|
2436
|
+
if (this.isCachedEmailTerminal(currentCachedEmail)) {
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
currentCachedEmail.status = 'queued';
|
|
2440
|
+
currentCachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
2441
|
+
await currentCachedEmail.save();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
await cachedEmail.save();
|
|
2445
|
+
} catch (error: unknown) {
|
|
2446
|
+
cachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
|
|
2447
|
+
cachedEmail.lastError = (error as Error).message;
|
|
2448
|
+
await cachedEmail.save();
|
|
2449
|
+
logger.log('warn', `Accepted email ${cachedEmail.id} deferred after SmartMTA handoff failure: ${(error as Error).message}`);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
private startAcceptedEmailSpoolProcessor(): void {
|
|
2454
|
+
this.clearAcceptedEmailSpoolTimer();
|
|
2455
|
+
this.acceptedEmailSpoolStopping = false;
|
|
2456
|
+
const runProcessor = () => {
|
|
2457
|
+
this.runAcceptedEmailSpoolProcessor();
|
|
2458
|
+
};
|
|
2459
|
+
this.acceptedEmailSpoolTimer = setInterval(
|
|
2460
|
+
runProcessor,
|
|
2461
|
+
ACCEPTED_EMAIL_SPOOL_INTERVAL_MS,
|
|
2462
|
+
) as ReturnType<typeof setInterval> & { unref?: () => void };
|
|
2463
|
+
this.acceptedEmailSpoolTimer.unref?.();
|
|
2464
|
+
runProcessor();
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
private clearAcceptedEmailSpoolTimer(): void {
|
|
2468
|
+
if (this.acceptedEmailSpoolTimer) {
|
|
2469
|
+
clearInterval(this.acceptedEmailSpoolTimer);
|
|
2470
|
+
this.acceptedEmailSpoolTimer = undefined;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
private async stopAcceptedEmailSpoolProcessor(): Promise<void> {
|
|
2475
|
+
this.acceptedEmailSpoolStopping = true;
|
|
2476
|
+
this.clearAcceptedEmailSpoolTimer();
|
|
2477
|
+
const spoolRun = this.acceptedEmailSpoolRun;
|
|
2478
|
+
if (spoolRun) {
|
|
2479
|
+
const settled = await this.waitForPromiseToSettleWithTimeout(
|
|
2480
|
+
spoolRun,
|
|
2481
|
+
ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
|
|
2482
|
+
);
|
|
2483
|
+
if (!settled) {
|
|
2484
|
+
logger.log('warn', 'Timed out waiting for accepted email spool processing to stop');
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
private runAcceptedEmailSpoolProcessor(): void {
|
|
2490
|
+
if (this.acceptedEmailSpoolRun) {
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const run = this.processAcceptedEmailSpool().catch((error) => {
|
|
2494
|
+
logger.log('warn', `Accepted email spool processing failed: ${(error as Error).message}`);
|
|
2495
|
+
});
|
|
2496
|
+
this.acceptedEmailSpoolRun = run;
|
|
2497
|
+
void run.finally(() => {
|
|
2498
|
+
if (this.acceptedEmailSpoolRun === run) {
|
|
2499
|
+
this.acceptedEmailSpoolRun = undefined;
|
|
2500
|
+
}
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
private async processAcceptedEmailSpool(): Promise<void> {
|
|
2505
|
+
const emailServer = this.emailServer;
|
|
2506
|
+
if (this.acceptedEmailSpoolProcessing || !emailServer || !this.dcRouterDb?.isReady()) {
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
this.acceptedEmailSpoolProcessing = true;
|
|
2510
|
+
try {
|
|
2511
|
+
const cachedEmails = await CachedEmail.findPendingForDelivery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
|
|
2512
|
+
for (const cachedEmail of cachedEmails) {
|
|
2513
|
+
if (this.acceptedEmailSpoolStopping || this.emailServer !== emailServer) {
|
|
2514
|
+
break;
|
|
2515
|
+
}
|
|
2516
|
+
const session = this.buildCachedEmailSession(cachedEmail);
|
|
2517
|
+
const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
|
|
2518
|
+
await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
|
|
2519
|
+
}
|
|
2520
|
+
} finally {
|
|
2521
|
+
this.acceptedEmailSpoolProcessing = false;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
private buildCachedEmailSession(cachedEmail: CachedEmail): IExtendedSmtpSession {
|
|
2526
|
+
const routeData = this.parseCachedEmailRouteData(cachedEmail);
|
|
2527
|
+
const storedSession = routeData.session || {};
|
|
2528
|
+
const storedEnvelope = storedSession.envelope || {};
|
|
2529
|
+
const storedRcptTo = Array.isArray(storedEnvelope.rcptTo) && storedEnvelope.rcptTo.length > 0
|
|
2530
|
+
? storedEnvelope.rcptTo
|
|
2531
|
+
: cachedEmail.to.map((address) => ({ address, args: {} }));
|
|
2532
|
+
const rcptTo = storedRcptTo
|
|
2533
|
+
.filter((recipient) => !!recipient.address)
|
|
2534
|
+
.map((recipient) => ({ address: recipient.address, args: recipient.args || {} }));
|
|
2535
|
+
const mailFrom = storedEnvelope.mailFrom
|
|
2536
|
+
? { address: storedEnvelope.mailFrom.address, args: storedEnvelope.mailFrom.args || {} }
|
|
2537
|
+
: { address: cachedEmail.from || '', args: {} };
|
|
2538
|
+
const session = {
|
|
2539
|
+
id: `${storedSession.id || cachedEmail.id}-replay-${Date.now()}`,
|
|
2540
|
+
state: 'DATA' as unknown as IExtendedSmtpSession['state'],
|
|
2541
|
+
clientHostname: storedSession.clientHostname || '',
|
|
2542
|
+
mailFrom: mailFrom.address,
|
|
2543
|
+
rcptTo: rcptTo.map((recipient) => recipient.address),
|
|
2544
|
+
emailData: cachedEmail.rawContent || '',
|
|
2545
|
+
useTLS: !!storedSession.secure,
|
|
2546
|
+
connectionEnded: false,
|
|
2547
|
+
remoteAddress: storedSession.remoteAddress || '127.0.0.1',
|
|
2548
|
+
secure: !!storedSession.secure,
|
|
2549
|
+
authenticated: !!storedSession.authenticated,
|
|
2550
|
+
envelope: {
|
|
2551
|
+
mailFrom,
|
|
2552
|
+
rcptTo,
|
|
2553
|
+
},
|
|
2554
|
+
} as IExtendedSmtpSession;
|
|
2555
|
+
if (storedSession.user) {
|
|
2556
|
+
session.user = storedSession.user;
|
|
2557
|
+
}
|
|
2558
|
+
return session;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
private parseCachedEmailRouteData(cachedEmail: CachedEmail): TStoredCachedEmailRouteData {
|
|
2562
|
+
try {
|
|
2563
|
+
return cachedEmail.routeData ? JSON.parse(cachedEmail.routeData) : {};
|
|
2564
|
+
} catch {
|
|
2565
|
+
return {};
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
private async updateAcceptedEmailFromQueueItem(
|
|
2570
|
+
item: TSmartMtaQueueItemLike,
|
|
2571
|
+
status: 'queued' | 'delivered' | 'failed',
|
|
2572
|
+
): Promise<void> {
|
|
2573
|
+
const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
|
|
2574
|
+
if (!cachedEmailId || !this.dcRouterDb?.isReady()) {
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
const cachedEmail = await CachedEmail.findById(cachedEmailId);
|
|
2578
|
+
if (!cachedEmail) {
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
if (this.isCachedEmailTerminal(cachedEmail) && status !== 'delivered') {
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
|
|
2585
|
+
if (status === 'delivered') {
|
|
2586
|
+
cachedEmail.markDelivered();
|
|
2587
|
+
} else if (status === 'failed') {
|
|
2588
|
+
cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
|
|
2589
|
+
} else {
|
|
2590
|
+
cachedEmail.status = 'queued';
|
|
2591
|
+
cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
2592
|
+
}
|
|
2593
|
+
await cachedEmail.save();
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
private trackAcceptedEmailQueueUpdate(
|
|
2597
|
+
item: TSmartMtaQueueItemLike,
|
|
2598
|
+
status: 'queued' | 'delivered' | 'failed',
|
|
2599
|
+
failureMessage: string,
|
|
2600
|
+
): void {
|
|
2601
|
+
const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
|
|
2602
|
+
logger.log('warn', `${failureMessage}: ${(error as Error).message}`);
|
|
2603
|
+
});
|
|
2604
|
+
this.acceptedEmailQueueUpdatePromises.add(updatePromise);
|
|
2605
|
+
void updatePromise.finally(() => {
|
|
2606
|
+
this.acceptedEmailQueueUpdatePromises.delete(updatePromise);
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
private async drainAcceptedEmailQueueUpdates(): Promise<void> {
|
|
2611
|
+
const queueUpdates = [...this.acceptedEmailQueueUpdatePromises];
|
|
2612
|
+
if (queueUpdates.length === 0) {
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
const settled = await this.waitForPromiseToSettleWithTimeout(
|
|
2616
|
+
Promise.allSettled(queueUpdates).then(() => undefined),
|
|
2617
|
+
ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
|
|
2618
|
+
);
|
|
2619
|
+
if (!settled) {
|
|
2620
|
+
for (const queueUpdate of queueUpdates) {
|
|
2621
|
+
this.acceptedEmailQueueUpdatePromises.delete(queueUpdate);
|
|
2622
|
+
}
|
|
2623
|
+
logger.log('warn', `Timed out waiting for ${queueUpdates.length} accepted email queue update(s) to settle`);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
private async waitForPromiseToSettleWithTimeout(
|
|
2628
|
+
promise: Promise<unknown>,
|
|
2629
|
+
timeoutMs: number,
|
|
2630
|
+
): Promise<boolean> {
|
|
2631
|
+
let timeout: (ReturnType<typeof setTimeout> & { unref?: () => void }) | undefined;
|
|
2632
|
+
return await new Promise<boolean>((resolve) => {
|
|
2633
|
+
let settled = false;
|
|
2634
|
+
const settle = (didSettle: boolean) => {
|
|
2635
|
+
if (settled) {
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
settled = true;
|
|
2639
|
+
if (timeout) {
|
|
2640
|
+
clearTimeout(timeout);
|
|
2641
|
+
}
|
|
2642
|
+
resolve(didSettle);
|
|
2643
|
+
};
|
|
2644
|
+
timeout = setTimeout(() => settle(false), timeoutMs) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
2645
|
+
timeout.unref?.();
|
|
2646
|
+
promise.then(
|
|
2647
|
+
() => settle(true),
|
|
2648
|
+
() => settle(true),
|
|
2649
|
+
);
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
private async recoverQueuedAcceptedEmails(): Promise<void> {
|
|
2654
|
+
while (true) {
|
|
2655
|
+
const queuedEmails = await CachedEmail.findQueuedForRecovery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
|
|
2656
|
+
if (queuedEmails.length === 0) {
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
for (const queuedEmail of queuedEmails) {
|
|
2660
|
+
if (this.isCachedEmailTerminal(queuedEmail)) {
|
|
2661
|
+
continue;
|
|
2662
|
+
}
|
|
2663
|
+
queuedEmail.status = 'pending';
|
|
2664
|
+
queuedEmail.nextAttempt = new Date();
|
|
2665
|
+
await queuedEmail.save();
|
|
2666
|
+
}
|
|
2667
|
+
if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
private isCachedEmailTerminal(cachedEmail: CachedEmail): boolean {
|
|
2674
|
+
return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
private getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
|
|
2678
|
+
return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
|
|
2679
|
+
|| this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
|
|
2683
|
+
if (!headers) {
|
|
2684
|
+
return undefined;
|
|
2685
|
+
}
|
|
2686
|
+
const normalizedHeaderName = headerName.toLowerCase();
|
|
2687
|
+
const matchingHeaderName = Object.keys(headers).find((key) => key.toLowerCase() === normalizedHeaderName);
|
|
2688
|
+
return matchingHeaderName ? headers[matchingHeaderName] : undefined;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
private removeHeader(headers: Record<string, string>, headerName: string): void {
|
|
2692
|
+
const normalizedHeaderName = headerName.toLowerCase();
|
|
2693
|
+
for (const key of Object.keys(headers)) {
|
|
2694
|
+
if (key.toLowerCase() === normalizedHeaderName) {
|
|
2695
|
+
delete headers[key];
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
private throwIfMessageAcceptanceAborted(abortSignal: AbortSignal | undefined): void {
|
|
2701
|
+
if (abortSignal?.aborted) {
|
|
2702
|
+
throw new Error('Message acceptance aborted before SMTP success');
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2169
2705
|
|
|
2170
2706
|
/**
|
|
2171
2707
|
* Update the unified email configuration
|
|
@@ -2232,10 +2768,18 @@ export class DcRouter {
|
|
|
2232
2768
|
try {
|
|
2233
2769
|
// Stop the unified email server which contains all components
|
|
2234
2770
|
if (this.emailServer) {
|
|
2235
|
-
this.
|
|
2236
|
-
await this.emailServer.stop();
|
|
2237
|
-
logger.log('info', 'Unified email server stopped');
|
|
2771
|
+
const emailServer = this.emailServer;
|
|
2238
2772
|
this.emailServer = undefined;
|
|
2773
|
+
this.acceptedEmailSpoolStopping = true;
|
|
2774
|
+
this.clearAcceptedEmailSpoolTimer();
|
|
2775
|
+
try {
|
|
2776
|
+
await emailServer.stop();
|
|
2777
|
+
} finally {
|
|
2778
|
+
await this.stopAcceptedEmailSpoolProcessor();
|
|
2779
|
+
await this.drainAcceptedEmailQueueUpdates();
|
|
2780
|
+
this.clearEmailEventSubscriptions();
|
|
2781
|
+
}
|
|
2782
|
+
logger.log('info', 'Unified email server stopped');
|
|
2239
2783
|
}
|
|
2240
2784
|
|
|
2241
2785
|
logger.log('info', 'All unified email components stopped');
|