import type { IOutboundConnectionProxy, IOutboundConnectionProxyContext, } from '@push.rocks/smartmta'; import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js'; import type { DcRouter } from '../classes.dcrouter.js'; import type { IEligibleMailEdge, MailEdgeEligibility } from './classes.mail-edge-eligibility.js'; import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js'; /** * Routes outbound SMTP deliveries over RemoteIngress edges: resolves the * outbound mode per attempt (no silent direct fallback), maps sender-domain * pins to an edgeFilter, opens the per-delivery QUIC egress proxy, and * presents the selected edge's FCrDNS hostname as the EHLO identity. */ export class MailEgressCoordinator { private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns; constructor( private dcRouterRef: DcRouter, private edgeEligibility: MailEdgeEligibility, ) {} /** * smartmta connectionProxyProvider. Wired unconditionally so the outbound * mode is resolved per delivery attempt: a failed/late settings manager * defers mail loudly instead of silently degrading to direct-from-hub * egress, and mode changes take effect without an email server restart. */ public async provideConnectionProxy( context: IOutboundConnectionProxyContext, ): Promise { const mode = this.resolveOutboundModeStrict(); if (mode === 'direct') { if (context.senderDomain && !this.dcRouterRef.emailDomainManager) { throw new Error('EmailDomainManager is unavailable; refusing to classify a sender for direct hub delivery'); } if (context.senderDomain && await this.dcRouterRef.emailDomainManager!.getByDomain(context.senderDomain)) { throw new Error( `Managed sender domain ${context.senderDomain} has no validated direct-egress identity policy; refusing direct hub delivery`, ); } return null; } return await this.createRemoteIngressConnectionProxy(context); } /** * Resolve the effective email outbound mode for a delivery attempt. * Throws when the settings manager is unavailable — deferring the delivery — * because guessing here is what silently degraded outbound to direct egress. */ public resolveOutboundModeStrict(): TEmailOutboundMode { if (this.dcRouterRef.emailSettingsManager) { return this.dcRouterRef.emailSettingsManager.getOutboundMode(); } if (this.dcRouterRef.options.emailOutboundMode) { return this.dcRouterRef.options.emailOutboundMode; } throw new Error('EmailSettingsManager unavailable: refusing to resolve email outbound mode implicitly'); } /** * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges). */ public getOutboundEgressStatus(): IEmailOutboundEgressStatus { const eligibility = this.edgeEligibility.getLastUnscopedResult(); return { ready: eligibility.supported && eligibility.edges.length > 0, eligibleEdgeCount: eligibility.edges.length, ...(eligibility.edges.length === 0 ? { reason: eligibility.reason || 'no validated RemoteIngress mail edge is available' } : {}), }; } /** * Post-start preflight: with remoteIngress outbound (the default), mail * defers until a QUIC mail egress edge connects. Surface that loudly * instead of silently queueing. */ public logEgressPreflight(): void { try { if (this.resolveOutboundModeStrict() === 'remoteIngress') { const egressStatus = this.getOutboundEgressStatus(); if (!egressStatus.ready) { logger.log('warn', `Outbound mail egress is NOT ready (${egressStatus.reason}); outbound mail will defer until a QUIC mail egress edge connects`); } } } catch (error: unknown) { logger.log('error', `Outbound mail mode could not be resolved: ${(error as Error).message}; outbound deliveries will defer until EmailSettingsManager is available`); } } private async createRemoteIngressConnectionProxy( context: IOutboundConnectionProxyContext, ): Promise { const tunnelManager = this.dcRouterRef.tunnelManager; if (!tunnelManager) { throw new Error('RemoteIngress outbound SMTP egress is enabled, but the hub is not running'); } await this.assertTargetIsNotOwnMailEdge(context); const managedDoc = context.senderDomain ? await EmailDomainDoc.findByDomain(context.senderDomain) : null; if (managedDoc) { const readiness = await this.dcRouterRef.emailDomainManager?.getOutboundReadiness(managedDoc.domain); if (!readiness?.ready) { throw new Error( `Outbound SMTP is not eligible for ${managedDoc.domain}: ${readiness?.reason || 'EmailDomainManager is unavailable'}`, ); } } const eligible = managedDoc ? await this.edgeEligibility.resolveActiveForDomain(managedDoc) : await this.edgeEligibility.resolveForDomain(); if (!eligible.supported || eligible.edges.length === 0) { throw new Error( `Outbound SMTP is not eligible${context.senderDomain ? ` for ${context.senderDomain}` : ''}: ${eligible.reason || 'no validated mail edge'}`, ); } const eligibleById = new Map( eligible.edges.map((candidate) => [candidate.edge.id, candidate]), ); const edgeFilter = [...eligibleById.keys()]; const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000; const proxy = await tunnelManager.startEgressTcpProxy({ logicalHost: context.host, port: context.port, serverFirst: !context.secure, connectTimeoutMs: connectionTimeoutMs, acceptTimeoutMs: connectionTimeoutMs, edgeFilter, }); const selected = eligibleById.get(proxy.edgeId); if (!selected) { await tunnelManager.stopEgressTcpProxy(proxy.proxyId).catch(() => undefined); throw new Error(`RemoteIngress selected edge ${proxy.edgeId} outside the validated mail pool`); } let closed = false; return { connectHost: proxy.listenHost, connectPort: proxy.listenPort, poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`, ehloHostname: selected.hostname, sourceEvidence: tunnelManager.waitForEgressConnectionEvidence(proxy.proxyId, proxy.edgeId) .then((eventArg) => { if (!eventArg?.sourceIp || !eventArg.addressFamily) return null; return { sourceIp: eventArg.sourceIp, addressFamily: eventArg.addressFamily, }; }), close: async () => { if (closed) { return; } closed = true; try { await tunnelManager.stopEgressTcpProxy(proxy.proxyId); } catch (err: unknown) { logger.log('warn', `Failed to stop RemoteIngress egress proxy ${proxy.proxyId}: ${(err as Error).message}`); } }, }; } /** * Refuse egress when the target resolves to one of our own mail edges: * the edge would dial its own listener and the delivery would hairpin * straight back into our inbound pipeline — a mail loop. Complements * smartmta's hostname-level self-MX guard for targets that only reveal * themselves at the IP level (e.g. an A-record fallback pointing at an * edge). DNS failures do not block the delivery; the loop guard is not * allowed to become an availability hazard. */ private async assertTargetIsNotOwnMailEdge(context: IOutboundConnectionProxyContext): Promise { const edgeIps = new Set(); for (const edge of this.dcRouterRef.remoteIngressManager?.getAllEdges?.() || []) { if (edge.publicIp) edgeIps.add(edge.publicIp); if (edge.publicIpV6) edgeIps.add(edge.publicIpV6); } if (edgeIps.size === 0) { return; } let targetIps: string[] = []; if (plugins.smartnetwork.getIpVersion(context.host)) { targetIps = [context.host]; } else { try { if (!this.dnsClient) { this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({}); } const [aRecords, aaaaRecords] = await Promise.all([ this.dnsClient.getRecordsA(context.host), this.dnsClient.getRecordsAAAA(context.host), ]); targetIps = [...(aRecords || []), ...(aaaaRecords || [])].map((record) => record.value); } catch { return; } } const loopIp = targetIps.find((ip) => edgeIps.has(ip)); if (loopIp) { throw new Error( `Refusing outbound mail egress to ${context.host}:${context.port} — it resolves to our own mail edge (${loopIp}); delivering there would hairpin back into this server (mail loop)`, ); } } }