@serve.zone/dcrouter 16.4.1 → 16.5.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 +852 -852
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -1
- package/dist_ts/classes.dcrouter.js +20 -4
- package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
- package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
- package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
- package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
- package/dist_ts/dns/manager.dns.d.ts +6 -0
- package/dist_ts/dns/manager.dns.js +81 -32
- package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
- package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
- package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
- package/dist_ts/email/classes.email-domain.manager.js +391 -324
- package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
- package/dist_ts/email/classes.mail-dns-sync.js +857 -141
- package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
- package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
- package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
- package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
- package/dist_ts/email/classes.workapp-mail-manager.js +58 -12
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
- package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
- package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
- package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
- package/dist_ts_migrations/index.js +152 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/email-domains.d.ts +16 -0
- package/dist_ts_web/appstate/email-domains.js +140 -19
- package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
- package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
- package/package.json +3 -3
- package/readme.md +13 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +22 -3
- package/ts/db/documents/classes.dns-record.doc.ts +20 -0
- package/ts/db/documents/classes.email-domain.doc.ts +14 -0
- package/ts/dns/manager.dns.ts +89 -29
- package/ts/dns/providers/cloudflare.provider.ts +46 -27
- package/ts/email/classes.email-domain.manager.ts +416 -368
- package/ts/email/classes.mail-dns-sync.ts +913 -146
- package/ts/email/classes.mail-edge-eligibility.ts +205 -0
- package/ts/email/classes.mail-egress-coordinator.ts +46 -23
- package/ts/email/classes.workapp-mail-manager.ts +69 -13
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
- package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
- package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/email-domains.ts +187 -18
- package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
|
|
2
|
+
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
3
|
+
import type { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
4
|
+
import type { DcRouter } from '../classes.dcrouter.js';
|
|
5
|
+
|
|
6
|
+
export const MAIL_EGRESS_IDENTITY_MAX_AGE_MS = 45_000;
|
|
7
|
+
|
|
8
|
+
export interface IMailEgressIdentity {
|
|
9
|
+
edgeId: string;
|
|
10
|
+
family: 4 | 6;
|
|
11
|
+
address: string;
|
|
12
|
+
heloHostname: string;
|
|
13
|
+
proof: 'sourceBound';
|
|
14
|
+
/** Edge-side time at which the configured source address was successfully bound. */
|
|
15
|
+
observedAt: number;
|
|
16
|
+
/** Hub-side heartbeat receipt time. */
|
|
17
|
+
hubReceivedAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IMailEgressIdentitySnapshot {
|
|
21
|
+
supported: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
identities: IMailEgressIdentity[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Boundary implemented against a released RemoteIngress identity-proof API. */
|
|
27
|
+
export interface IEgressIdentitySource {
|
|
28
|
+
getSnapshot(): Promise<IMailEgressIdentitySnapshot>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads the versioned RemoteIngress identity capability through TunnelManager.
|
|
33
|
+
* Older hubs do not expose `egressIdentityV1`; they remain explicitly
|
|
34
|
+
* unsupported instead of falling back to configured or peer addresses.
|
|
35
|
+
*/
|
|
36
|
+
export class ReleasedRemoteIngressEgressIdentitySource implements IEgressIdentitySource {
|
|
37
|
+
constructor(private dcRouterRef: DcRouter) {}
|
|
38
|
+
|
|
39
|
+
public async getSnapshot(): Promise<IMailEgressIdentitySnapshot> {
|
|
40
|
+
const status = await this.dcRouterRef.tunnelManager?.getAuthoritativeHubStatus();
|
|
41
|
+
const connectedEdges = status?.connectedEdges ?? [];
|
|
42
|
+
const capableEdges = connectedEdges.filter((edge) => (
|
|
43
|
+
Array.isArray(edge.capabilities) && edge.capabilities.includes('egressIdentityV1')
|
|
44
|
+
));
|
|
45
|
+
if (capableEdges.length > 0) {
|
|
46
|
+
const identities: IMailEgressIdentity[] = [];
|
|
47
|
+
for (const edge of capableEdges) {
|
|
48
|
+
const report = edge.egressIdentity;
|
|
49
|
+
if (!report || report.proofMethod !== 'localSocketBind') continue;
|
|
50
|
+
for (const [family, observation] of [[4, report.ipv4], [6, report.ipv6]] as const) {
|
|
51
|
+
if (!observation || observation.stale === true) continue;
|
|
52
|
+
if (
|
|
53
|
+
typeof observation.address !== 'string'
|
|
54
|
+
|| !Number.isFinite(observation.edgeObservedAtUnixMs)
|
|
55
|
+
|| !Number.isFinite(observation.hubReceivedAtUnixMs)
|
|
56
|
+
) continue;
|
|
57
|
+
const configured = this.dcRouterRef.remoteIngressManager?.getEdge(String(edge.edgeId));
|
|
58
|
+
const heloHostname = configured?.mailHostname?.trim();
|
|
59
|
+
if (!heloHostname) continue;
|
|
60
|
+
identities.push({
|
|
61
|
+
edgeId: String(edge.edgeId),
|
|
62
|
+
family,
|
|
63
|
+
address: observation.address,
|
|
64
|
+
heloHostname,
|
|
65
|
+
proof: 'sourceBound',
|
|
66
|
+
observedAt: observation.edgeObservedAtUnixMs,
|
|
67
|
+
hubReceivedAt: observation.hubReceivedAtUnixMs,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { supported: true, identities };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
supported: false,
|
|
75
|
+
reason: 'RemoteIngress egress identity proof is unavailable until the capability release is consumed',
|
|
76
|
+
identities: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface IEligibleMailEdge {
|
|
82
|
+
edge: IRemoteIngress;
|
|
83
|
+
hostname: string;
|
|
84
|
+
identities: IMailEgressIdentity[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IMailEdgeEligibilityResult {
|
|
88
|
+
supported: boolean;
|
|
89
|
+
reason?: string;
|
|
90
|
+
edges: IEligibleMailEdge[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shared topology predicate used by DNS publication and outbound selection. */
|
|
94
|
+
export class MailEdgeEligibility {
|
|
95
|
+
private lastUnscopedResult: IMailEdgeEligibilityResult = {
|
|
96
|
+
supported: false,
|
|
97
|
+
reason: 'mail edge eligibility has not been evaluated yet',
|
|
98
|
+
edges: [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
constructor(
|
|
102
|
+
private dcRouterRef: DcRouter,
|
|
103
|
+
private identitySource: IEgressIdentitySource,
|
|
104
|
+
) {}
|
|
105
|
+
|
|
106
|
+
public async resolveForDomain(doc?: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
|
|
107
|
+
const snapshot = await this.identitySource.getSnapshot();
|
|
108
|
+
if (!snapshot.supported) {
|
|
109
|
+
return this.recordUnscoped(doc, { supported: false, reason: snapshot.reason, edges: [] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const manager = this.dcRouterRef.remoteIngressManager;
|
|
113
|
+
const tunnelManager = this.dcRouterRef.tunnelManager;
|
|
114
|
+
if (!manager || !tunnelManager) {
|
|
115
|
+
return this.recordUnscoped(doc, { supported: true, reason: 'RemoteIngress hub is not running', edges: [] });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pin = doc?.remoteIngress?.edgeFilter || [];
|
|
119
|
+
const pinIds = pin.length > 0
|
|
120
|
+
? new Set(manager.resolveEdgesByFilter(pin).map((edge) => edge.id))
|
|
121
|
+
: undefined;
|
|
122
|
+
const identitiesByEdge = new Map<string, IMailEgressIdentity[]>();
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
for (const identity of snapshot.identities) {
|
|
125
|
+
if (identity.proof !== 'sourceBound') continue;
|
|
126
|
+
if (now - identity.observedAt > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
|
|
127
|
+
if (now - identity.hubReceivedAt > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
|
|
128
|
+
const entries = identitiesByEdge.get(identity.edgeId) || [];
|
|
129
|
+
entries.push(identity);
|
|
130
|
+
identitiesByEdge.set(identity.edgeId, entries);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const edges: IEligibleMailEdge[] = [];
|
|
134
|
+
for (const edge of manager.getAllEdges()) {
|
|
135
|
+
if (!edge.enabled || !edge.tags?.includes(REMOTE_INGRESS_MAIL_TAG)) continue;
|
|
136
|
+
// A domain pin narrows the mail-tagged pool; it never replaces the mail tag.
|
|
137
|
+
if (pinIds && !pinIds.has(edge.id)) continue;
|
|
138
|
+
if (!edge.egress?.enabled || !edge.egress.allowedPorts?.includes(25)) continue;
|
|
139
|
+
const status = tunnelManager.getEdgeStatus(edge.id);
|
|
140
|
+
if (!status?.connected || !status.lastHeartbeat) continue;
|
|
141
|
+
if (now - status.lastHeartbeat > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
|
|
142
|
+
if (status.egressEnabled !== true || !status.capabilities?.includes('egressTcpV1')) continue;
|
|
143
|
+
const nativeQuic = status.transportMode === 'quic'
|
|
144
|
+
|| (status.transportMode === 'quicWithFallback' && status.fallbackUsed === false);
|
|
145
|
+
if (!nativeQuic) continue;
|
|
146
|
+
const hostname = edge.mailHostname?.toLowerCase().replace(/\.$/, '');
|
|
147
|
+
if (!hostname) continue;
|
|
148
|
+
|
|
149
|
+
const identities = (identitiesByEdge.get(edge.id) || []).filter((identity) => {
|
|
150
|
+
if (identity.heloHostname.toLowerCase().replace(/\.$/, '') !== hostname) return false;
|
|
151
|
+
return identity.family === 4
|
|
152
|
+
? Boolean(edge.publicIp && edge.publicIp === identity.address)
|
|
153
|
+
: Boolean(edge.publicIpV6 && edge.publicIpV6 === identity.address);
|
|
154
|
+
});
|
|
155
|
+
if (identities.length > 0) {
|
|
156
|
+
edges.push({ edge, hostname, identities });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
edges.sort((left, right) => left.edge.id.localeCompare(right.edge.id));
|
|
161
|
+
return this.recordUnscoped(doc, {
|
|
162
|
+
supported: true,
|
|
163
|
+
...(edges.length === 0 ? { reason: 'no live mail-tagged edge has a fresh source-bound identity' } : {}),
|
|
164
|
+
edges,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public getLastUnscopedResult(): IMailEdgeEligibilityResult {
|
|
169
|
+
return this.lastUnscopedResult;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private recordUnscoped(
|
|
173
|
+
doc: EmailDomainDoc | undefined,
|
|
174
|
+
result: IMailEdgeEligibilityResult,
|
|
175
|
+
): IMailEdgeEligibilityResult {
|
|
176
|
+
if (!doc) this.lastUnscopedResult = result;
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public async resolveActiveForDomain(doc: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
|
|
181
|
+
const live = await this.resolveForDomain(doc);
|
|
182
|
+
const active = doc.reconciliation?.activeRevision;
|
|
183
|
+
if (!live.supported || !active) {
|
|
184
|
+
return {
|
|
185
|
+
supported: live.supported,
|
|
186
|
+
reason: live.reason || 'email domain has no validated active DNS revision',
|
|
187
|
+
edges: [],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const activeByEdge = new Map(active.edgeIdentities.map((identity) => [identity.edgeId, identity]));
|
|
191
|
+
const edges = live.edges.filter((candidate) => {
|
|
192
|
+
const activeIdentity = activeByEdge.get(candidate.edge.id);
|
|
193
|
+
if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return false;
|
|
194
|
+
return candidate.identities.length > 0 && candidate.identities.every((identity) => (
|
|
195
|
+
(identity.family === 4 && identity.address === activeIdentity.ipv4)
|
|
196
|
+
|| (identity.family === 6 && identity.address === activeIdentity.ipv6)
|
|
197
|
+
));
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
supported: live.supported,
|
|
201
|
+
...(edges.length === 0 ? { reason: 'no live edge is present in the validated active DNS revision' } : {}),
|
|
202
|
+
edges,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -4,9 +4,9 @@ import type {
|
|
|
4
4
|
} from '@push.rocks/smartmta';
|
|
5
5
|
import * as plugins from '../plugins.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
|
-
import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
|
|
8
7
|
import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js';
|
|
9
8
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
9
|
+
import type { MailEdgeEligibility } from './classes.mail-edge-eligibility.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Routes outbound SMTP deliveries over RemoteIngress edges: resolves the
|
|
@@ -17,7 +17,10 @@ import type { DcRouter } from '../classes.dcrouter.js';
|
|
|
17
17
|
export class MailEgressCoordinator {
|
|
18
18
|
private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
|
|
19
19
|
|
|
20
|
-
constructor(
|
|
20
|
+
constructor(
|
|
21
|
+
private dcRouterRef: DcRouter,
|
|
22
|
+
private edgeEligibility: MailEdgeEligibility,
|
|
23
|
+
) {}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* smartmta connectionProxyProvider. Wired unconditionally so the outbound
|
|
@@ -30,6 +33,11 @@ export class MailEgressCoordinator {
|
|
|
30
33
|
): Promise<IOutboundConnectionProxy | null> {
|
|
31
34
|
const mode = this.resolveOutboundModeStrict();
|
|
32
35
|
if (mode === 'direct') {
|
|
36
|
+
if (context.senderDomain && await this.dcRouterRef.emailDomainManager?.getByDomain(context.senderDomain)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Managed sender domain ${context.senderDomain} has no validated direct-egress identity policy; refusing direct hub delivery`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
33
41
|
return null;
|
|
34
42
|
}
|
|
35
43
|
return await this.createRemoteIngressConnectionProxy(context);
|
|
@@ -54,11 +62,14 @@ export class MailEgressCoordinator {
|
|
|
54
62
|
* Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
|
|
55
63
|
*/
|
|
56
64
|
public getOutboundEgressStatus(): IEmailOutboundEgressStatus {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
const eligibility = this.edgeEligibility.getLastUnscopedResult();
|
|
66
|
+
return {
|
|
67
|
+
ready: eligibility.supported && eligibility.edges.length > 0,
|
|
68
|
+
eligibleEdgeCount: eligibility.edges.length,
|
|
69
|
+
...(eligibility.edges.length === 0
|
|
70
|
+
? { reason: eligibility.reason || 'no validated RemoteIngress mail edge is available' }
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
@@ -89,15 +100,19 @@ export class MailEgressCoordinator {
|
|
|
89
100
|
|
|
90
101
|
await this.assertTargetIsNotOwnMailEdge(context);
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
const managedDoc = context.senderDomain
|
|
104
|
+
? await (await import('../db/documents/classes.email-domain.doc.js')).EmailDomainDoc.findByDomain(context.senderDomain)
|
|
105
|
+
: null;
|
|
106
|
+
const eligible = managedDoc
|
|
107
|
+
? await this.edgeEligibility.resolveActiveForDomain(managedDoc)
|
|
108
|
+
: await this.edgeEligibility.resolveForDomain();
|
|
109
|
+
if (!eligible.supported || eligible.edges.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Outbound SMTP is not eligible${context.senderDomain ? ` for ${context.senderDomain}` : ''}: ${eligible.reason || 'no validated mail edge'}`,
|
|
112
|
+
);
|
|
100
113
|
}
|
|
114
|
+
const eligibleById = new Map(eligible.edges.map((candidate) => [candidate.edge.id, candidate]));
|
|
115
|
+
const edgeFilter = [...eligibleById.keys()];
|
|
101
116
|
|
|
102
117
|
const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000;
|
|
103
118
|
const proxy = await tunnelManager.startEgressTcpProxy({
|
|
@@ -108,12 +123,17 @@ export class MailEgressCoordinator {
|
|
|
108
123
|
acceptTimeoutMs: connectionTimeoutMs,
|
|
109
124
|
edgeFilter,
|
|
110
125
|
});
|
|
126
|
+
const selected = eligibleById.get(proxy.edgeId);
|
|
127
|
+
if (!selected) {
|
|
128
|
+
await tunnelManager.stopEgressTcpProxy(proxy.proxyId).catch(() => undefined);
|
|
129
|
+
throw new Error(`RemoteIngress selected edge ${proxy.edgeId} outside the validated mail pool`);
|
|
130
|
+
}
|
|
111
131
|
let closed = false;
|
|
112
132
|
return {
|
|
113
133
|
connectHost: proxy.listenHost,
|
|
114
134
|
connectPort: proxy.listenPort,
|
|
115
135
|
poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`,
|
|
116
|
-
ehloHostname:
|
|
136
|
+
ehloHostname: selected.hostname,
|
|
117
137
|
close: async () => {
|
|
118
138
|
if (closed) {
|
|
119
139
|
return;
|
|
@@ -138,11 +158,11 @@ export class MailEgressCoordinator {
|
|
|
138
158
|
* allowed to become an availability hazard.
|
|
139
159
|
*/
|
|
140
160
|
private async assertTargetIsNotOwnMailEdge(context: IOutboundConnectionProxyContext): Promise<void> {
|
|
141
|
-
const edgeIps = new Set(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
161
|
+
const edgeIps = new Set<string>();
|
|
162
|
+
for (const edge of this.dcRouterRef.remoteIngressManager?.getAllEdges?.() || []) {
|
|
163
|
+
if (edge.publicIp) edgeIps.add(edge.publicIp);
|
|
164
|
+
if (edge.publicIpV6) edgeIps.add(edge.publicIpV6);
|
|
165
|
+
}
|
|
146
166
|
if (edgeIps.size === 0) {
|
|
147
167
|
return;
|
|
148
168
|
}
|
|
@@ -155,8 +175,11 @@ export class MailEgressCoordinator {
|
|
|
155
175
|
if (!this.dnsClient) {
|
|
156
176
|
this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
|
|
157
177
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
178
|
+
const [aRecords, aaaaRecords] = await Promise.all([
|
|
179
|
+
this.dnsClient.getRecordsA(context.host),
|
|
180
|
+
this.dnsClient.getRecordsAAAA(context.host),
|
|
181
|
+
]);
|
|
182
|
+
targetIps = [...(aRecords || []), ...(aaaaRecords || [])].map((record) => record.value);
|
|
160
183
|
} catch {
|
|
161
184
|
return;
|
|
162
185
|
}
|
|
@@ -158,6 +158,10 @@ export class WorkAppMailManager {
|
|
|
158
158
|
const smtpPassword = shouldRotateSmtpPassword
|
|
159
159
|
? this.generateSmtpPassword()
|
|
160
160
|
: existingIdentity.smtpPassword;
|
|
161
|
+
const requestedSmtpEnabled = request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true;
|
|
162
|
+
const outboundReadiness = requestedSmtpEnabled
|
|
163
|
+
? await this.dcRouterRef.emailDomainManager?.getOutboundReadiness(domain)
|
|
164
|
+
: { ready: false, reason: 'outbound identity disabled', edgeIds: [] };
|
|
161
165
|
const identity: IStoredWorkAppMailIdentity = {
|
|
162
166
|
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
|
163
167
|
externalKey,
|
|
@@ -170,7 +174,7 @@ export class WorkAppMailManager {
|
|
|
170
174
|
inboundTarget: this.resolveNextInboundTarget(request, existingIdentity),
|
|
171
175
|
inbound: this.toLegacyInboundRouteIfSmtpForward(this.resolveNextInboundTarget(request, existingIdentity)),
|
|
172
176
|
smtp: {
|
|
173
|
-
enabled:
|
|
177
|
+
enabled: requestedSmtpEnabled,
|
|
174
178
|
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
|
|
175
179
|
},
|
|
176
180
|
createdAt: existingIdentity?.createdAt || now,
|
|
@@ -197,9 +201,12 @@ export class WorkAppMailManager {
|
|
|
197
201
|
identity: this.toPublicIdentity(identity),
|
|
198
202
|
};
|
|
199
203
|
|
|
200
|
-
if (existingIndex < 0 || request.resetSmtpPassword) {
|
|
204
|
+
if ((existingIndex < 0 || request.resetSmtpPassword) && outboundReadiness?.ready) {
|
|
201
205
|
response.smtpCredentials = this.buildSmtpCredentials(identity);
|
|
202
206
|
}
|
|
207
|
+
if (requestedSmtpEnabled && !outboundReadiness?.ready) {
|
|
208
|
+
response.message = `Outbound identity is persisted but remains gated: ${outboundReadiness?.reason || 'email domain is not active'}`;
|
|
209
|
+
}
|
|
203
210
|
|
|
204
211
|
return response;
|
|
205
212
|
}
|
|
@@ -313,6 +320,11 @@ export class WorkAppMailManager {
|
|
|
313
320
|
if (!identity.enabled || !identity.smtp.enabled) {
|
|
314
321
|
return { success: false, message: 'Mail credential is disabled' };
|
|
315
322
|
}
|
|
323
|
+
try {
|
|
324
|
+
await this.assertIdentityOutboundReady(identity);
|
|
325
|
+
} catch (error: unknown) {
|
|
326
|
+
return { success: false, message: (error as Error).message };
|
|
327
|
+
}
|
|
316
328
|
|
|
317
329
|
const now = Date.now();
|
|
318
330
|
identities[identityIndex] = {
|
|
@@ -344,6 +356,11 @@ export class WorkAppMailManager {
|
|
|
344
356
|
if (!identity || !identity.enabled || !identity.smtp.enabled) {
|
|
345
357
|
return this.rejectSenderAuthorization('Managed SMTP credential is not active');
|
|
346
358
|
}
|
|
359
|
+
try {
|
|
360
|
+
await this.assertIdentityOutboundReady(identity);
|
|
361
|
+
} catch (error: unknown) {
|
|
362
|
+
return this.rejectSenderAuthorization((error as Error).message);
|
|
363
|
+
}
|
|
347
364
|
|
|
348
365
|
const envelopeFrom = this.normalizeMailboxAddress(
|
|
349
366
|
context.session.envelope?.mailFrom?.address || context.session.mailFrom || '',
|
|
@@ -373,6 +390,7 @@ export class WorkAppMailManager {
|
|
|
373
390
|
messageArg.from,
|
|
374
391
|
outboundIdentityIdArg,
|
|
375
392
|
);
|
|
393
|
+
await this.assertIdentityOutboundReady(identity);
|
|
376
394
|
const rawMessage = this.buildOutboundRawMessage(identity, messageArg);
|
|
377
395
|
const recipients = this.getMessageRecipients(messageArg);
|
|
378
396
|
const session = this.buildAuthenticatedSession(identity, messageArg.from, recipients, rawMessage);
|
|
@@ -555,7 +573,8 @@ export class WorkAppMailManager {
|
|
|
555
573
|
emailConfig: TConfig,
|
|
556
574
|
): Promise<TConfig> {
|
|
557
575
|
const identities = await this.readStoredIdentities();
|
|
558
|
-
|
|
576
|
+
const readyDomains = await this.getReadyOutboundDomains(identities);
|
|
577
|
+
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities, readyDomains);
|
|
559
578
|
}
|
|
560
579
|
|
|
561
580
|
public async applyStoredIdentitiesToRuntime(
|
|
@@ -564,10 +583,9 @@ export class WorkAppMailManager {
|
|
|
564
583
|
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
|
565
584
|
if (!emailConfig) return;
|
|
566
585
|
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
);
|
|
586
|
+
const nextIdentities = identities || await this.readStoredIdentities();
|
|
587
|
+
const readyDomains = await this.getReadyOutboundDomains(nextIdentities);
|
|
588
|
+
const nextConfig = this.mergeIdentitiesIntoEmailConfig(emailConfig, nextIdentities, readyDomains);
|
|
571
589
|
|
|
572
590
|
this.dcRouterRef.options.emailConfig = nextConfig;
|
|
573
591
|
if (this.dcRouterRef.emailServer) {
|
|
@@ -603,6 +621,7 @@ export class WorkAppMailManager {
|
|
|
603
621
|
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
|
604
622
|
emailConfig: TConfig,
|
|
605
623
|
identities: IStoredWorkAppMailIdentity[],
|
|
624
|
+
readyDomains: Set<string>,
|
|
606
625
|
): TConfig {
|
|
607
626
|
const generatedRoutes = identities
|
|
608
627
|
.filter((identity) => {
|
|
@@ -611,12 +630,12 @@ export class WorkAppMailManager {
|
|
|
611
630
|
})
|
|
612
631
|
.map((identity) => this.buildInboundRoute(identity));
|
|
613
632
|
const generatedOutboundRoutes = identities
|
|
614
|
-
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
|
615
|
-
.map((identity) => this.buildOutboundRoute(identity));
|
|
633
|
+
.filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
|
|
634
|
+
.map((identity) => this.buildOutboundRoute(identity, emailConfig));
|
|
616
635
|
const configuredRoutes = (emailConfig.routes || [])
|
|
617
636
|
.filter((route) => !this.isManagedMailRouteName(route.name));
|
|
618
637
|
const generatedUsers = identities
|
|
619
|
-
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
|
638
|
+
.filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
|
|
620
639
|
.map((identity) => ({
|
|
621
640
|
username: identity.smtp.username,
|
|
622
641
|
password: identity.smtpPassword,
|
|
@@ -679,7 +698,10 @@ export class WorkAppMailManager {
|
|
|
679
698
|
};
|
|
680
699
|
}
|
|
681
700
|
|
|
682
|
-
private buildOutboundRoute(
|
|
701
|
+
private buildOutboundRoute(
|
|
702
|
+
identity: IStoredWorkAppMailIdentity,
|
|
703
|
+
emailConfig: IUnifiedEmailServerOptions,
|
|
704
|
+
): IEmailRoute {
|
|
683
705
|
return {
|
|
684
706
|
name: this.buildOutboundRouteName(identity.externalKey),
|
|
685
707
|
priority: 900,
|
|
@@ -699,7 +721,7 @@ export class WorkAppMailManager {
|
|
|
699
721
|
dkimSign: true,
|
|
700
722
|
dkimOptions: {
|
|
701
723
|
domainName: identity.domain,
|
|
702
|
-
keySelector:
|
|
724
|
+
keySelector: this.getActiveDkimSelector(identity.domain, emailConfig),
|
|
703
725
|
},
|
|
704
726
|
},
|
|
705
727
|
},
|
|
@@ -726,6 +748,40 @@ export class WorkAppMailManager {
|
|
|
726
748
|
await emailDomainManager.ensureEmailDomainForDomainName(domain);
|
|
727
749
|
}
|
|
728
750
|
|
|
751
|
+
private async getReadyOutboundDomains(
|
|
752
|
+
identities: IStoredWorkAppMailIdentity[],
|
|
753
|
+
): Promise<Set<string>> {
|
|
754
|
+
const ready = new Set<string>();
|
|
755
|
+
const requestedDomains = [...new Set(
|
|
756
|
+
identities
|
|
757
|
+
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
|
758
|
+
.map((identity) => identity.domain),
|
|
759
|
+
)];
|
|
760
|
+
for (const domain of requestedDomains) {
|
|
761
|
+
const readiness = await this.dcRouterRef.emailDomainManager?.getOutboundReadiness(domain);
|
|
762
|
+
if (readiness?.ready) ready.add(domain);
|
|
763
|
+
}
|
|
764
|
+
return ready;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private getActiveDkimSelector(
|
|
768
|
+
domain: string,
|
|
769
|
+
emailConfig: IUnifiedEmailServerOptions = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions,
|
|
770
|
+
): string {
|
|
771
|
+
return emailConfig?.domains
|
|
772
|
+
?.find((domainConfig) => domainConfig.domain.toLowerCase() === domain.toLowerCase())
|
|
773
|
+
?.dkim?.selector || 'default';
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private async assertIdentityOutboundReady(identity: IStoredWorkAppMailIdentity): Promise<void> {
|
|
777
|
+
const readiness = await this.dcRouterRef.emailDomainManager?.getOutboundReadiness(identity.domain);
|
|
778
|
+
if (!readiness?.ready) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
`Outbound mail for ${identity.domain} is gated until DNS and egress identity validation succeeds: ${readiness?.reason || 'EmailDomainManager unavailable'}`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
729
785
|
private normalizeOwnership(
|
|
730
786
|
ownership: interfaces.data.IWorkAppMailOwnership,
|
|
731
787
|
): interfaces.data.IWorkAppMailOwnership {
|
|
@@ -1257,7 +1313,7 @@ export class WorkAppMailManager {
|
|
|
1257
1313
|
allowedFromPatterns: [{ type: 'exactAddress', domain: identityArg.domain, localPart: identityArg.localPart }],
|
|
1258
1314
|
defaultFrom: identityArg.address,
|
|
1259
1315
|
domain: identityArg.domain,
|
|
1260
|
-
dkimSelector:
|
|
1316
|
+
dkimSelector: this.getActiveDkimSelector(identityArg.domain),
|
|
1261
1317
|
credential: this.toMailCredentialPublic(identityArg),
|
|
1262
1318
|
createdAt: identityArg.createdAt,
|
|
1263
1319
|
updatedAt: identityArg.updatedAt,
|
package/ts/email/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './classes.email-domain.manager.js';
|
|
|
3
3
|
export * from './classes.email-route-builder.js';
|
|
4
4
|
export * from './classes.mail-dns-sync.js';
|
|
5
5
|
export * from './classes.mail-egress-coordinator.js';
|
|
6
|
+
export * from './classes.mail-edge-eligibility.js';
|
|
6
7
|
export * from './classes.email-settings.manager.js';
|
|
7
8
|
export * from './classes.smartmta-storage-manager.js';
|
|
8
9
|
export * from './classes.workapp-mail-manager.js';
|