@serve.zone/dcrouter 15.10.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +34 -34
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/classes.dcrouter.d.ts +10 -3
- package/dist_ts/classes.dcrouter.js +40 -44
- package/dist_ts/db/documents/classes.cached.email.d.ts +32 -0
- package/dist_ts/db/documents/classes.cached.email.js +67 -3
- package/dist_ts/db/documents/classes.email-domain.doc.d.ts +2 -1
- package/dist_ts/db/documents/classes.email-domain.doc.js +8 -2
- package/dist_ts/db/documents/classes.email-server-settings.doc.js +2 -2
- package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +3 -0
- package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +20 -2
- package/dist_ts/dns/classes.dns-server-runtime.js +3 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +9 -2
- package/dist_ts/email/classes.accepted-email-spool.js +53 -2
- package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
- package/dist_ts/email/classes.email-domain.manager.js +100 -17
- package/dist_ts/email/classes.email-route-builder.js +5 -2
- package/dist_ts/email/classes.email-settings.manager.js +3 -3
- package/dist_ts/email/classes.mail-dns-sync.d.ts +27 -0
- package/dist_ts/email/classes.mail-dns-sync.js +196 -0
- package/dist_ts/email/classes.mail-egress-coordinator.d.ts +37 -0
- package/dist_ts/email/classes.mail-egress-coordinator.js +113 -0
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +14 -1
- package/dist_ts/email/classes.workapp-mail-manager.js +138 -1
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/opsserver/handlers/config.handler.js +4 -2
- package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
- package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
- package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +30 -1
- package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
- package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
- package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
- package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
- package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
- package/dist_ts/remoteingress/index.d.ts +1 -0
- package/dist_ts/remoteingress/index.js +2 -1
- package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
- package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
- package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
- package/dist_ts_interfaces/data/remoteingress.js +5 -2
- package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
- package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
- package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
- package/dist_ts_migrations/index.js +91 -9
- package/dist_ts_web/00_commitinfo_data.js +2 -2
- package/package.json +5 -5
- package/readme.md +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +42 -49
- package/ts/db/documents/classes.cached.email.ts +72 -1
- package/ts/db/documents/classes.email-domain.doc.ts +4 -0
- package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
- package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
- package/ts/dns/classes.dns-server-runtime.ts +2 -0
- package/ts/email/classes.accepted-email-spool.ts +80 -4
- package/ts/email/classes.email-domain.manager.ts +118 -17
- package/ts/email/classes.email-route-builder.ts +4 -1
- package/ts/email/classes.email-settings.manager.ts +2 -2
- package/ts/email/classes.mail-dns-sync.ts +218 -0
- package/ts/email/classes.mail-egress-coordinator.ts +125 -0
- package/ts/email/classes.workapp-mail-manager.ts +194 -1
- package/ts/email/index.ts +2 -0
- package/ts/opsserver/handlers/config.handler.ts +3 -1
- package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
- package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
- package/ts/opsserver/handlers/gatewayclient.handler.ts +44 -0
- package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
- package/ts/readme.md +1 -1
- package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
- package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
- package/ts/remoteingress/edge-derivation-routes.ts +29 -0
- package/ts/remoteingress/index.ts +1 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -8,6 +8,12 @@ const TTL = plugins.smartdata.smartdataTtlValues;
|
|
|
8
8
|
*/
|
|
9
9
|
export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Direction of an accepted email: 'inbound' = received from a remote peer,
|
|
13
|
+
* 'outbound' = locally submitted for delivery.
|
|
14
|
+
*/
|
|
15
|
+
export type TCachedEmailDirection = 'inbound' | 'outbound';
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Helper to get the smartdata database instance
|
|
13
19
|
*/
|
|
@@ -112,6 +118,24 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
112
118
|
@plugins.smartdata.svDb()
|
|
113
119
|
public senderDomain!: string;
|
|
114
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Direction of the accepted message (inbound receipt vs local submission)
|
|
123
|
+
*/
|
|
124
|
+
@plugins.smartdata.svDb()
|
|
125
|
+
public direction: TCachedEmailDirection = 'outbound';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Lowercased recipient domains (for querying/filtering)
|
|
129
|
+
*/
|
|
130
|
+
@plugins.smartdata.svDb()
|
|
131
|
+
public recipientDomains: string[] = [];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Epoch ms when the message was accepted (for newest-first listings)
|
|
135
|
+
*/
|
|
136
|
+
@plugins.smartdata.svDb()
|
|
137
|
+
public acceptedAt: number = 0;
|
|
138
|
+
|
|
115
139
|
/**
|
|
116
140
|
* Priority level (higher = more important)
|
|
117
141
|
*/
|
|
@@ -183,7 +207,11 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
183
207
|
}, limit);
|
|
184
208
|
}
|
|
185
209
|
|
|
186
|
-
private static async findLimited(
|
|
210
|
+
private static async findLimited(
|
|
211
|
+
filter: Record<string, any>,
|
|
212
|
+
limit: number,
|
|
213
|
+
sort?: Record<string, 1 | -1>,
|
|
214
|
+
): Promise<CachedEmail[]> {
|
|
187
215
|
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 25;
|
|
188
216
|
if (safeLimit === 0) {
|
|
189
217
|
return [];
|
|
@@ -195,6 +223,9 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
195
223
|
filter,
|
|
196
224
|
CachedEmail as unknown as typeof plugins.smartdata.SmartDataDbDoc,
|
|
197
225
|
);
|
|
226
|
+
if (sort) {
|
|
227
|
+
cursor.mongodbCursor.sort(sort);
|
|
228
|
+
}
|
|
198
229
|
cursor.mongodbCursor.limit(safeLimit);
|
|
199
230
|
try {
|
|
200
231
|
return await cursor.toArray();
|
|
@@ -203,6 +234,32 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Find the most recently accepted emails, newest first.
|
|
239
|
+
*/
|
|
240
|
+
public static async findRecent(
|
|
241
|
+
limit = 200,
|
|
242
|
+
filter: { direction?: TCachedEmailDirection; recipientDomain?: string } = {},
|
|
243
|
+
): Promise<CachedEmail[]> {
|
|
244
|
+
const query: Record<string, any> = {};
|
|
245
|
+
if (filter.direction) {
|
|
246
|
+
query.direction = filter.direction;
|
|
247
|
+
}
|
|
248
|
+
if (filter.recipientDomain) {
|
|
249
|
+
query.recipientDomains = filter.recipientDomain.toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
return await CachedEmail.findLimited(query, limit, { acceptedAt: -1 });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Find emails addressed to a recipient domain
|
|
256
|
+
*/
|
|
257
|
+
public static async findByRecipientDomain(domain: string): Promise<CachedEmail[]> {
|
|
258
|
+
return await CachedEmail.getInstances({
|
|
259
|
+
recipientDomains: { $all: [domain.toLowerCase()] },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
206
263
|
/**
|
|
207
264
|
* Find emails by sender domain
|
|
208
265
|
*/
|
|
@@ -254,4 +311,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
254
311
|
}
|
|
255
312
|
}
|
|
256
313
|
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Derive recipient domains from the recipient address list
|
|
317
|
+
*/
|
|
318
|
+
public updateRecipientDomains(): void {
|
|
319
|
+
const domains = new Set<string>();
|
|
320
|
+
for (const address of this.to || []) {
|
|
321
|
+
const match = address.match(/@([^>]+)>?$/);
|
|
322
|
+
if (match) {
|
|
323
|
+
domains.add(match[1].toLowerCase());
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
this.recipientDomains = [...domains];
|
|
327
|
+
}
|
|
257
328
|
}
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
IEmailDomainDkim,
|
|
5
5
|
IEmailDomainRateLimits,
|
|
6
6
|
IEmailDomainDnsStatus,
|
|
7
|
+
IEmailDomainRemoteIngress,
|
|
7
8
|
} from '../../../ts_interfaces/data/email-domain.js';
|
|
8
9
|
|
|
9
10
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
@@ -29,6 +30,9 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
|
|
|
29
30
|
@plugins.smartdata.svDb()
|
|
30
31
|
public rateLimits?: IEmailDomainRateLimits;
|
|
31
32
|
|
|
33
|
+
@plugins.smartdata.svDb()
|
|
34
|
+
public remoteIngress?: IEmailDomainRemoteIngress;
|
|
35
|
+
|
|
32
36
|
@plugins.smartdata.svDb()
|
|
33
37
|
public dnsStatus!: IEmailDomainDnsStatus;
|
|
34
38
|
|
|
@@ -18,7 +18,7 @@ export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<Ema
|
|
|
18
18
|
public emailConfig?: IUnifiedEmailServerOptions;
|
|
19
19
|
|
|
20
20
|
@plugins.smartdata.svDb()
|
|
21
|
-
public outboundMode: TEmailOutboundMode = '
|
|
21
|
+
public outboundMode: TEmailOutboundMode = 'remoteIngress';
|
|
22
22
|
|
|
23
23
|
@plugins.smartdata.svDb()
|
|
24
24
|
public emailPortConfig?: IEmailPortConfig;
|
|
@@ -37,6 +37,15 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
|
|
|
37
37
|
@plugins.smartdata.svDb()
|
|
38
38
|
public tags!: string[];
|
|
39
39
|
|
|
40
|
+
@plugins.smartdata.svDb()
|
|
41
|
+
public publicIp?: string;
|
|
42
|
+
|
|
43
|
+
@plugins.smartdata.svDb()
|
|
44
|
+
public publicIpV6?: string;
|
|
45
|
+
|
|
46
|
+
@plugins.smartdata.svDb()
|
|
47
|
+
public mailHostname?: string;
|
|
48
|
+
|
|
40
49
|
@plugins.smartdata.svDb()
|
|
41
50
|
public createdAt!: number;
|
|
42
51
|
|
|
@@ -446,6 +446,7 @@ export class DnsServerRuntime {
|
|
|
446
446
|
// Use explicitly configured public IP
|
|
447
447
|
publicIp = options.publicIp;
|
|
448
448
|
this.dcRouterRef.detectedPublicIp = publicIp;
|
|
449
|
+
this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]);
|
|
449
450
|
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
|
|
450
451
|
} else {
|
|
451
452
|
// Auto-discover public IP using smartnetwork
|
|
@@ -457,6 +458,7 @@ export class DnsServerRuntime {
|
|
|
457
458
|
if (publicIps.v4) {
|
|
458
459
|
publicIp = publicIps.v4;
|
|
459
460
|
this.dcRouterRef.detectedPublicIp = publicIp;
|
|
461
|
+
this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]);
|
|
460
462
|
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
|
|
461
463
|
} else {
|
|
462
464
|
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
import { logger } from '../logger.js';
|
|
3
3
|
import { CachedEmail } from '../db/index.js';
|
|
4
|
+
import type { TCachedEmailDirection } from '../db/documents/classes.cached.email.js';
|
|
4
5
|
import type {
|
|
5
6
|
Email,
|
|
6
7
|
IExtendedSmtpSession,
|
|
@@ -28,6 +29,10 @@ export type TSmartMtaQueueItemLike = {
|
|
|
28
29
|
lastError?: string;
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
type TSmartMtaQueueReader = UnifiedEmailServer & {
|
|
33
|
+
getQueueItems?: () => TSmartMtaQueueItemLike[];
|
|
34
|
+
};
|
|
35
|
+
|
|
31
36
|
type TStoredCachedEmailEnvelopeAddress = {
|
|
32
37
|
address: string;
|
|
33
38
|
args?: Record<string, string>;
|
|
@@ -48,6 +53,12 @@ type TStoredCachedEmailSession = {
|
|
|
48
53
|
|
|
49
54
|
type TStoredCachedEmailRouteData = {
|
|
50
55
|
session?: TStoredCachedEmailSession;
|
|
56
|
+
smartMta?: {
|
|
57
|
+
status?: 'queued' | 'deferred' | 'delivered' | 'failed';
|
|
58
|
+
nextAttempt?: string;
|
|
59
|
+
lastError?: string;
|
|
60
|
+
updatedAt?: string;
|
|
61
|
+
};
|
|
51
62
|
};
|
|
52
63
|
|
|
53
64
|
export interface IAcceptRawEmailOptions {
|
|
@@ -60,6 +71,8 @@ export interface IAcceptRawEmailOptions {
|
|
|
60
71
|
messageId?: string;
|
|
61
72
|
subject?: string;
|
|
62
73
|
processAfterAccept?: boolean;
|
|
74
|
+
/** Explicit direction override for programmatic submitters. Defaults to the session-authenticated rule. */
|
|
75
|
+
direction?: TCachedEmailDirection;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
/**
|
|
@@ -108,6 +121,8 @@ export class AcceptedEmailSpool {
|
|
|
108
121
|
cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
|
|
109
122
|
cachedEmail.status = 'pending';
|
|
110
123
|
cachedEmail.nextAttempt = new Date();
|
|
124
|
+
cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
|
|
125
|
+
cachedEmail.acceptedAt = Date.now();
|
|
111
126
|
cachedEmail.routeData = JSON.stringify({
|
|
112
127
|
acceptedAt: new Date().toISOString(),
|
|
113
128
|
session: {
|
|
@@ -121,6 +136,7 @@ export class AcceptedEmailSpool {
|
|
|
121
136
|
},
|
|
122
137
|
});
|
|
123
138
|
cachedEmail.updateSenderDomain();
|
|
139
|
+
cachedEmail.updateRecipientDomains();
|
|
124
140
|
await cachedEmail.save();
|
|
125
141
|
if (context.abortSignal?.aborted) {
|
|
126
142
|
cachedEmail.markFailed('Message acceptance aborted before SMTP success');
|
|
@@ -161,6 +177,9 @@ export class AcceptedEmailSpool {
|
|
|
161
177
|
cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
|
|
162
178
|
cachedEmail.status = 'pending';
|
|
163
179
|
cachedEmail.nextAttempt = new Date();
|
|
180
|
+
cachedEmail.direction = optionsArg.direction
|
|
181
|
+
?? (optionsArg.session.authenticated ? 'outbound' : 'inbound');
|
|
182
|
+
cachedEmail.acceptedAt = Date.now();
|
|
164
183
|
cachedEmail.routeData = JSON.stringify({
|
|
165
184
|
acceptedAt: new Date().toISOString(),
|
|
166
185
|
session: {
|
|
@@ -174,6 +193,7 @@ export class AcceptedEmailSpool {
|
|
|
174
193
|
},
|
|
175
194
|
});
|
|
176
195
|
cachedEmail.updateSenderDomain();
|
|
196
|
+
cachedEmail.updateRecipientDomains();
|
|
177
197
|
await cachedEmail.save();
|
|
178
198
|
|
|
179
199
|
if (optionsArg.processAfterAccept !== false) {
|
|
@@ -246,7 +266,7 @@ export class AcceptedEmailSpool {
|
|
|
246
266
|
|
|
247
267
|
public trackQueueUpdate(
|
|
248
268
|
item: TSmartMtaQueueItemLike,
|
|
249
|
-
status: 'queued' | 'delivered' | 'failed',
|
|
269
|
+
status: 'queued' | 'deferred' | 'delivered' | 'failed',
|
|
250
270
|
failureMessage: string,
|
|
251
271
|
): void {
|
|
252
272
|
const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
|
|
@@ -308,6 +328,9 @@ export class AcceptedEmailSpool {
|
|
|
308
328
|
if (this.stopping || this.dcRouterRef.emailServer !== emailServer) {
|
|
309
329
|
break;
|
|
310
330
|
}
|
|
331
|
+
if (await this.postponeLiveSmartMtaOwnedEmail(cachedEmail, emailServer)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
311
334
|
const session = this.buildCachedEmailSession(cachedEmail);
|
|
312
335
|
const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
|
|
313
336
|
await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
|
|
@@ -346,7 +369,7 @@ export class AcceptedEmailSpool {
|
|
|
346
369
|
cachedEmail.markDelivered();
|
|
347
370
|
} else {
|
|
348
371
|
const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
|
|
349
|
-
if (this.isCachedEmailTerminal(currentCachedEmail)) {
|
|
372
|
+
if (this.isCachedEmailTerminal(currentCachedEmail) || currentCachedEmail.status === 'deferred') {
|
|
350
373
|
return;
|
|
351
374
|
}
|
|
352
375
|
currentCachedEmail.status = 'queued';
|
|
@@ -419,7 +442,7 @@ export class AcceptedEmailSpool {
|
|
|
419
442
|
|
|
420
443
|
private async updateAcceptedEmailFromQueueItem(
|
|
421
444
|
item: TSmartMtaQueueItemLike,
|
|
422
|
-
status: 'queued' | 'delivered' | 'failed',
|
|
445
|
+
status: 'queued' | 'deferred' | 'delivered' | 'failed',
|
|
423
446
|
): Promise<void> {
|
|
424
447
|
const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
|
|
425
448
|
if (!cachedEmailId || !this.dcRouterRef.dcRouterDb?.isReady()) {
|
|
@@ -433,10 +456,16 @@ export class AcceptedEmailSpool {
|
|
|
433
456
|
return;
|
|
434
457
|
}
|
|
435
458
|
cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
|
|
459
|
+
this.updateSmartMtaRouteData(cachedEmail, item, status);
|
|
436
460
|
if (status === 'delivered') {
|
|
437
461
|
cachedEmail.markDelivered();
|
|
438
462
|
} else if (status === 'failed') {
|
|
439
463
|
cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
|
|
464
|
+
} else if (status === 'deferred') {
|
|
465
|
+
cachedEmail.status = 'deferred';
|
|
466
|
+
cachedEmail.lastError = item.lastError || 'SmartMTA delivery deferred';
|
|
467
|
+
const smartMtaNextAttempt = item.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
|
|
468
|
+
cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
440
469
|
} else {
|
|
441
470
|
cachedEmail.status = 'queued';
|
|
442
471
|
cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
@@ -487,11 +516,43 @@ export class AcceptedEmailSpool {
|
|
|
487
516
|
return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
|
|
488
517
|
}
|
|
489
518
|
|
|
490
|
-
|
|
519
|
+
/** The CachedEmail row a live queue item was spooled from (via the cache-id header). */
|
|
520
|
+
public getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
|
|
491
521
|
return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
|
|
492
522
|
|| this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
|
|
493
523
|
}
|
|
494
524
|
|
|
525
|
+
private async postponeLiveSmartMtaOwnedEmail(
|
|
526
|
+
cachedEmail: CachedEmail,
|
|
527
|
+
emailServer: UnifiedEmailServer,
|
|
528
|
+
): Promise<boolean> {
|
|
529
|
+
if (cachedEmail.status !== 'deferred') {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
const queueItem = this.getLiveQueueItemForCachedEmail(cachedEmail.id, emailServer);
|
|
533
|
+
if (!queueItem) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
const status = queueItem.status === 'deferred' ? 'deferred' : 'queued';
|
|
537
|
+
cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, queueItem.attempts || 0);
|
|
538
|
+
if (queueItem.lastError) {
|
|
539
|
+
cachedEmail.lastError = queueItem.lastError;
|
|
540
|
+
}
|
|
541
|
+
this.updateSmartMtaRouteData(cachedEmail, queueItem, status);
|
|
542
|
+
const smartMtaNextAttempt = queueItem.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
|
|
543
|
+
cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
544
|
+
await cachedEmail.save();
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private getLiveQueueItemForCachedEmail(
|
|
549
|
+
cachedEmailId: string,
|
|
550
|
+
emailServer: UnifiedEmailServer,
|
|
551
|
+
): TSmartMtaQueueItemLike | undefined {
|
|
552
|
+
const queueItems = (emailServer as TSmartMtaQueueReader).getQueueItems?.() || [];
|
|
553
|
+
return queueItems.find((item) => this.getCachedEmailIdFromQueueItem(item) === cachedEmailId);
|
|
554
|
+
}
|
|
555
|
+
|
|
495
556
|
private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
|
|
496
557
|
if (!headers) {
|
|
497
558
|
return undefined;
|
|
@@ -501,6 +562,21 @@ export class AcceptedEmailSpool {
|
|
|
501
562
|
return matchingHeaderName ? headers[matchingHeaderName] : undefined;
|
|
502
563
|
}
|
|
503
564
|
|
|
565
|
+
private updateSmartMtaRouteData(
|
|
566
|
+
cachedEmail: CachedEmail,
|
|
567
|
+
item: TSmartMtaQueueItemLike,
|
|
568
|
+
status: 'queued' | 'deferred' | 'delivered' | 'failed',
|
|
569
|
+
): void {
|
|
570
|
+
const routeData = this.parseCachedEmailRouteData(cachedEmail);
|
|
571
|
+
routeData.smartMta = {
|
|
572
|
+
status,
|
|
573
|
+
nextAttempt: item.nextAttempt?.toISOString(),
|
|
574
|
+
lastError: item.lastError,
|
|
575
|
+
updatedAt: new Date().toISOString(),
|
|
576
|
+
};
|
|
577
|
+
cachedEmail.routeData = JSON.stringify(routeData);
|
|
578
|
+
}
|
|
579
|
+
|
|
504
580
|
private removeHeader(headers: Record<string, string>, headerName: string): void {
|
|
505
581
|
const normalizedHeaderName = headerName.toLowerCase();
|
|
506
582
|
for (const key of Object.keys(headers)) {
|
|
@@ -5,7 +5,8 @@ import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
|
5
5
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
6
6
|
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
|
7
7
|
import type { DnsManager } from '../dns/manager.dns.js';
|
|
8
|
-
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
8
|
+
import type { IEmailDomain, IEmailDnsRecord, IEmailDomainRemoteIngress, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
9
|
+
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
9
10
|
import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -18,6 +19,7 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
|
18
19
|
export class EmailDomainManager {
|
|
19
20
|
private dcRouter: any; // DcRouter — avoids circular import
|
|
20
21
|
private baseEmailDomains: IEmailDomainConfig[] = [];
|
|
22
|
+
private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
|
|
21
23
|
|
|
22
24
|
constructor(dcRouterRef: any) {
|
|
23
25
|
this.dcRouter = dcRouterRef;
|
|
@@ -45,7 +47,10 @@ export class EmailDomainManager {
|
|
|
45
47
|
await this.syncManagedDomainsToRuntime();
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
public async stop(): Promise<void> {
|
|
50
|
+
public async stop(): Promise<void> {
|
|
51
|
+
this.dnsClient?.destroy();
|
|
52
|
+
this.dnsClient = undefined;
|
|
53
|
+
}
|
|
49
54
|
|
|
50
55
|
// ---------------------------------------------------------------------------
|
|
51
56
|
// CRUD
|
|
@@ -93,6 +98,7 @@ export class EmailDomainManager {
|
|
|
93
98
|
dkimKeySize?: number;
|
|
94
99
|
rotateKeys?: boolean;
|
|
95
100
|
rotationIntervalDays?: number;
|
|
101
|
+
remoteIngress?: IEmailDomainRemoteIngress;
|
|
96
102
|
}): Promise<IEmailDomain> {
|
|
97
103
|
// Resolve the linked DNS domain
|
|
98
104
|
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
|
@@ -150,10 +156,12 @@ export class EmailDomainManager {
|
|
|
150
156
|
dkim: 'unchecked',
|
|
151
157
|
dmarc: 'unchecked',
|
|
152
158
|
};
|
|
159
|
+
doc.remoteIngress = this.normalizeRemoteIngressPin(opts.remoteIngress, domainName);
|
|
153
160
|
doc.createdAt = now;
|
|
154
161
|
doc.updatedAt = now;
|
|
155
162
|
await doc.save();
|
|
156
163
|
await this.syncManagedDomainsToRuntime();
|
|
164
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain created: ${domainName}`);
|
|
157
165
|
|
|
158
166
|
logger.log('info', `Email domain created: ${domainName}`);
|
|
159
167
|
return this.docToInterface(doc);
|
|
@@ -165,6 +173,8 @@ export class EmailDomainManager {
|
|
|
165
173
|
rotateKeys?: boolean;
|
|
166
174
|
rotationIntervalDays?: number;
|
|
167
175
|
rateLimits?: IEmailDomain['rateLimits'];
|
|
176
|
+
/** null clears the pin. */
|
|
177
|
+
remoteIngress?: IEmailDomainRemoteIngress | null;
|
|
168
178
|
},
|
|
169
179
|
): Promise<void> {
|
|
170
180
|
const doc = await EmailDomainDoc.findById(id);
|
|
@@ -173,9 +183,43 @@ export class EmailDomainManager {
|
|
|
173
183
|
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
|
174
184
|
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
|
175
185
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
|
186
|
+
if (changes.remoteIngress !== undefined) {
|
|
187
|
+
doc.remoteIngress = changes.remoteIngress === null
|
|
188
|
+
? undefined
|
|
189
|
+
: this.normalizeRemoteIngressPin(changes.remoteIngress, doc.domain);
|
|
190
|
+
}
|
|
176
191
|
doc.updatedAt = new Date().toISOString();
|
|
177
192
|
await doc.save();
|
|
178
193
|
await this.syncManagedDomainsToRuntime();
|
|
194
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain updated: ${doc.domain}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalize a per-domain edge pin. Pins may reference edges that are not
|
|
199
|
+
* registered yet — that only warns, so pins can precede edge registration.
|
|
200
|
+
*/
|
|
201
|
+
private normalizeRemoteIngressPin(
|
|
202
|
+
pin: IEmailDomainRemoteIngress | undefined,
|
|
203
|
+
domainName: string,
|
|
204
|
+
): IEmailDomainRemoteIngress | undefined {
|
|
205
|
+
const edgeFilter = [...new Set((pin?.edgeFilter || []).map((entry) => `${entry}`.trim()).filter(Boolean))];
|
|
206
|
+
if (edgeFilter.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const manager = this.dcRouter.remoteIngressManager;
|
|
210
|
+
if (manager && manager.resolveEdgesByFilter(edgeFilter).length === 0) {
|
|
211
|
+
logger.log('warn', `Email domain ${domainName}: edge pin [${edgeFilter.join(', ')}] matches no registered enabled edge yet`);
|
|
212
|
+
}
|
|
213
|
+
return { edgeFilter };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Enabled edges pinned for this domain (empty when unpinned). */
|
|
217
|
+
private resolvePinnedEdges(doc: EmailDomainDoc): IRemoteIngress[] {
|
|
218
|
+
const edgeFilter = doc.remoteIngress?.edgeFilter;
|
|
219
|
+
if (!edgeFilter?.length) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
return this.dcRouter.remoteIngressManager?.resolveEdgesByFilter(edgeFilter) || [];
|
|
179
223
|
}
|
|
180
224
|
|
|
181
225
|
public async deleteEmailDomain(id: string): Promise<void> {
|
|
@@ -183,6 +227,7 @@ export class EmailDomainManager {
|
|
|
183
227
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
184
228
|
await doc.delete();
|
|
185
229
|
await this.syncManagedDomainsToRuntime();
|
|
230
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain deleted: ${doc.domain}`);
|
|
186
231
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
|
187
232
|
}
|
|
188
233
|
|
|
@@ -199,7 +244,10 @@ export class EmailDomainManager {
|
|
|
199
244
|
|
|
200
245
|
const domain = doc.domain;
|
|
201
246
|
const selector = doc.dkim.selector;
|
|
202
|
-
|
|
247
|
+
// Pinned domains get their own MX host whose A records are the pinned edges'
|
|
248
|
+
// public IPs; unpinned domains use the shared mail hostname.
|
|
249
|
+
const pinnedEdges = this.resolvePinnedEdges(doc);
|
|
250
|
+
const hostname = pinnedEdges.length > 0 ? `mx.${domain}` : this.emailHostname;
|
|
203
251
|
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
|
204
252
|
|
|
205
253
|
if (this.dkimCreator) {
|
|
@@ -211,13 +259,24 @@ export class EmailDomainManager {
|
|
|
211
259
|
}
|
|
212
260
|
}
|
|
213
261
|
|
|
214
|
-
|
|
262
|
+
const records = buildEmailDnsRecords({
|
|
215
263
|
domain,
|
|
216
264
|
hostname,
|
|
217
265
|
selector,
|
|
218
266
|
dkimValue,
|
|
219
267
|
statuses: doc.dnsStatus,
|
|
220
268
|
});
|
|
269
|
+
|
|
270
|
+
for (const edge of pinnedEdges) {
|
|
271
|
+
if (edge.publicIp) {
|
|
272
|
+
records.push({ type: 'A', name: hostname, value: edge.publicIp, status: doc.dnsStatus.mx });
|
|
273
|
+
}
|
|
274
|
+
if (edge.publicIpV6) {
|
|
275
|
+
records.push({ type: 'AAAA', name: hostname, value: edge.publicIpV6, status: doc.dnsStatus.mx });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return records;
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
// ---------------------------------------------------------------------------
|
|
@@ -280,7 +339,7 @@ export class EmailDomainManager {
|
|
|
280
339
|
|
|
281
340
|
const domain = doc.domain;
|
|
282
341
|
const selector = doc.dkim.selector;
|
|
283
|
-
const
|
|
342
|
+
const dnsClient = this.getDnsClient();
|
|
284
343
|
|
|
285
344
|
// MX check
|
|
286
345
|
const requiredRecords = await this.getRequiredDnsRecords(id);
|
|
@@ -290,16 +349,26 @@ export class EmailDomainManager {
|
|
|
290
349
|
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
|
291
350
|
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
|
292
351
|
|
|
293
|
-
doc.dnsStatus.mx = await this.checkMx(
|
|
352
|
+
doc.dnsStatus.mx = await this.checkMx(dnsClient, domain, mxRecord?.value);
|
|
353
|
+
|
|
354
|
+
// Pinned domains: the MX host's A records must cover the pinned edges' IPs.
|
|
355
|
+
const requiredARecords = requiredRecords.filter((record) => record.type === 'A');
|
|
356
|
+
if (doc.dnsStatus.mx === 'valid' && requiredARecords.length > 0) {
|
|
357
|
+
const mxHostname = mxRecord?.value?.split(/\s+/)[1] || `mx.${domain}`;
|
|
358
|
+
const aStatus = await this.checkARecords(dnsClient, mxHostname, requiredARecords.map((record) => record.value));
|
|
359
|
+
if (aStatus !== 'valid') {
|
|
360
|
+
doc.dnsStatus.mx = aStatus;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
294
363
|
|
|
295
364
|
// SPF check
|
|
296
|
-
doc.dnsStatus.spf = await this.checkTxtRecord(
|
|
365
|
+
doc.dnsStatus.spf = await this.checkTxtRecord(dnsClient, domain, spfRecord?.value);
|
|
297
366
|
|
|
298
367
|
// DKIM check
|
|
299
|
-
doc.dnsStatus.dkim = await this.checkTxtRecord(
|
|
368
|
+
doc.dnsStatus.dkim = await this.checkTxtRecord(dnsClient, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
|
300
369
|
|
|
301
370
|
// DMARC check
|
|
302
|
-
doc.dnsStatus.dmarc = await this.checkTxtRecord(
|
|
371
|
+
doc.dnsStatus.dmarc = await this.checkTxtRecord(dnsClient, `_dmarc.${domain}`, dmarcRecord?.value);
|
|
303
372
|
|
|
304
373
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
|
305
374
|
doc.updatedAt = new Date().toISOString();
|
|
@@ -315,41 +384,72 @@ export class EmailDomainManager {
|
|
|
315
384
|
return record.value.trim() === required.value.trim();
|
|
316
385
|
}
|
|
317
386
|
|
|
387
|
+
/** Lazy shared smartdns client; the Rust bridge spawns on first non-system lookup. */
|
|
388
|
+
private getDnsClient(): plugins.smartdns.dnsClientMod.Smartdns {
|
|
389
|
+
if (!this.dnsClient) {
|
|
390
|
+
this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
|
|
391
|
+
}
|
|
392
|
+
return this.dnsClient;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 'pref exchange' normalized for comparison: numeric pref, lowercased exchange, no trailing dot. */
|
|
396
|
+
private normalizeMxValue(value: string): string {
|
|
397
|
+
const [pref, exchange = ''] = value.trim().split(/\s+/);
|
|
398
|
+
return `${Number(pref)} ${exchange.toLowerCase().replace(/\.$/, '')}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
318
401
|
private async checkMx(
|
|
319
|
-
|
|
402
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
320
403
|
domain: string,
|
|
321
404
|
expectedValue?: string,
|
|
322
405
|
): Promise<TDnsRecordStatus> {
|
|
323
406
|
try {
|
|
324
|
-
const records = await
|
|
407
|
+
const records = await dnsClient.getRecords(domain, 'MX');
|
|
325
408
|
if (!records || records.length === 0) {
|
|
326
409
|
return 'missing';
|
|
327
410
|
}
|
|
328
411
|
if (!expectedValue) {
|
|
329
412
|
return 'valid';
|
|
330
413
|
}
|
|
331
|
-
const
|
|
414
|
+
const expected = this.normalizeMxValue(expectedValue);
|
|
415
|
+
const found = records.some((record) => this.normalizeMxValue(record.value) === expected);
|
|
332
416
|
return found ? 'valid' : 'invalid';
|
|
333
417
|
} catch {
|
|
334
418
|
return 'missing';
|
|
335
419
|
}
|
|
336
420
|
}
|
|
337
421
|
|
|
422
|
+
private async checkARecords(
|
|
423
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
424
|
+
name: string,
|
|
425
|
+
expectedIps: string[],
|
|
426
|
+
): Promise<TDnsRecordStatus> {
|
|
427
|
+
try {
|
|
428
|
+
const records = await dnsClient.getRecordsA(name);
|
|
429
|
+
if (!records || records.length === 0) {
|
|
430
|
+
return 'missing';
|
|
431
|
+
}
|
|
432
|
+
const resolved = new Set(records.map((record) => record.value));
|
|
433
|
+
return expectedIps.every((ip) => resolved.has(ip)) ? 'valid' : 'invalid';
|
|
434
|
+
} catch {
|
|
435
|
+
return 'missing';
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
338
439
|
private async checkTxtRecord(
|
|
339
|
-
|
|
440
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
340
441
|
name: string,
|
|
341
442
|
expectedValue?: string,
|
|
342
443
|
): Promise<TDnsRecordStatus> {
|
|
343
444
|
try {
|
|
344
|
-
const records = await
|
|
345
|
-
|
|
346
|
-
if (flat.length === 0) {
|
|
445
|
+
const records = await dnsClient.getRecordsTxt(name);
|
|
446
|
+
if (!records || records.length === 0) {
|
|
347
447
|
return 'missing';
|
|
348
448
|
}
|
|
349
449
|
if (!expectedValue) {
|
|
350
450
|
return 'valid';
|
|
351
451
|
}
|
|
352
|
-
const found =
|
|
452
|
+
const found = records.some((record) => record.value.trim() === expectedValue.trim());
|
|
353
453
|
return found ? 'valid' : 'invalid';
|
|
354
454
|
} catch {
|
|
355
455
|
return 'missing';
|
|
@@ -368,6 +468,7 @@ export class EmailDomainManager {
|
|
|
368
468
|
subdomain: doc.subdomain,
|
|
369
469
|
dkim: doc.dkim,
|
|
370
470
|
rateLimits: doc.rateLimits,
|
|
471
|
+
remoteIngress: doc.remoteIngress,
|
|
371
472
|
dnsStatus: doc.dnsStatus,
|
|
372
473
|
createdAt: doc.createdAt,
|
|
373
474
|
updatedAt: doc.updatedAt,
|
|
@@ -3,6 +3,7 @@ import { logger } from '../logger.js';
|
|
|
3
3
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
|
4
4
|
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|
5
5
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
|
+
import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
7
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
7
8
|
|
|
8
9
|
type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
|
|
@@ -99,7 +100,9 @@ export class EmailRouteBuilder {
|
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
if (this.dcRouterRef.isRemoteIngressHubEnabled()) {
|
|
102
|
-
|
|
103
|
+
// Only mail-tagged edges bind SMTP ports — other edges may sit on hosts
|
|
104
|
+
// where 25/465/587 belong to a different mail system.
|
|
105
|
+
routeConfig.remoteIngress = { enabled: true, edgeFilter: [REMOTE_INGRESS_MAIL_TAG] };
|
|
103
106
|
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
|
|
104
107
|
if (inboundProxyProtocol) {
|
|
105
108
|
routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
|
|
@@ -22,7 +22,7 @@ function hasOwn(objectArg: object, keyArg: string): boolean {
|
|
|
22
22
|
export class EmailSettingsManager {
|
|
23
23
|
private cachedEmailConfig?: IUnifiedEmailServerOptions;
|
|
24
24
|
private cachedEmailPortConfig?: IEmailPortConfig;
|
|
25
|
-
private outboundMode: TEmailOutboundMode = '
|
|
25
|
+
private outboundMode: TEmailOutboundMode = 'remoteIngress';
|
|
26
26
|
private enabled = false;
|
|
27
27
|
private updatedAt = 0;
|
|
28
28
|
private updatedBy = 'default';
|
|
@@ -140,7 +140,7 @@ export class EmailSettingsManager {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
private normalizeOutboundMode(mode: TEmailOutboundMode | undefined): TEmailOutboundMode {
|
|
143
|
-
if (!mode) return '
|
|
143
|
+
if (!mode) return 'remoteIngress';
|
|
144
144
|
if (mode !== 'direct' && mode !== 'remoteIngress') {
|
|
145
145
|
throw new Error('Invalid email outbound mode');
|
|
146
146
|
}
|