import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta'; import { EmailServerSettingsDoc } from '../db/index.js'; import { logger } from '../logger.js'; import { isSmtpAccountRouteName } from './classes.smtp-account.manager.js'; import { isWorkAppManagedMailRouteName } from './classes.workapp-mail-manager.js'; import type { IDcRouterOptions } from '../classes.dcrouter.js'; import type { IEmailPortConfig, IEmailServerSettings, TEmailOutboundMode, TEmailServerSettingsUpdate, } from '../../ts_interfaces/data/email-settings.js'; const defaultEmailPorts = [25, 587, 465]; function clonePlain(value: T | undefined): T | undefined { if (value === undefined) return undefined; return JSON.parse(JSON.stringify(value)) as T; } function hasOwn(objectArg: object, keyArg: string): boolean { return Object.prototype.hasOwnProperty.call(objectArg, keyArg); } export class EmailSettingsManager { private cachedEmailConfig?: IUnifiedEmailServerOptions; private cachedEmailPortConfig?: IEmailPortConfig; private outboundMode: TEmailOutboundMode = 'remoteIngress'; private enabled = false; private updatedAt = 0; private updatedBy = 'default'; constructor(private options: IDcRouterOptions) {} public async start(): Promise { let doc = await EmailServerSettingsDoc.load(); if (!doc) { doc = new EmailServerSettingsDoc(); doc.settingsId = 'email-server-settings'; doc.enabled = false; doc.updatedAt = Date.now(); doc.updatedBy = 'default'; await doc.save(); } await this.removeLegacyDefaultRejectRoute(doc); this.loadFromDoc(doc); this.applyToRuntimeOptions(); } /** * One-time persisted-data normalization: the legacy bootstrap config carried * a catch-all `default-reject` route (recipients '*' → 550) that outranks * the per-domain inbound catch-all store policy, making every configured * domain send-only. The domain inboundPolicy is the canonical way to reject * inbound mail now, so the legacy route is removed outright. */ private async removeLegacyDefaultRejectRoute(doc: EmailServerSettingsDoc): Promise { const routes = doc.emailConfig?.routes; if (!Array.isArray(routes)) return; const isLegacyDefaultReject = (route: any): boolean => route?.name === 'default-reject' && route?.action?.type === 'reject' && (route?.match?.recipients === '*' || (Array.isArray(route?.match?.recipients) && route.match.recipients.length === 1 && route.match.recipients[0] === '*')); const remaining = routes.filter((route: any) => !isLegacyDefaultReject(route)); if (remaining.length === routes.length) return; doc.emailConfig!.routes = remaining; doc.updatedAt = Date.now(); doc.updatedBy = 'legacy-default-reject-normalization'; await doc.save(); logger.log( 'warn', 'Removed legacy catch-all default-reject email route from persisted settings; per-domain inbound policies now govern unrouted recipients', ); } public async stop(): Promise { this.cachedEmailConfig = undefined; this.cachedEmailPortConfig = undefined; this.enabled = false; } public isEnabled(): boolean { return this.enabled && Boolean(this.cachedEmailConfig); } public getEmailConfig(): IUnifiedEmailServerOptions | undefined { return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined; } public getEmailPortConfig(): IEmailPortConfig | undefined { return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined; } public getOutboundMode(): TEmailOutboundMode { return this.outboundMode; } public getPublicSettings(): IEmailServerSettings { const emailConfig = this.cachedEmailConfig; const emailPortConfig = this.cachedEmailPortConfig; return { enabled: this.isEnabled(), hostname: emailConfig?.hostname || null, outboundMode: this.outboundMode, ports: [...(emailConfig?.ports || [])], portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null, receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null, maxMessageSize: emailConfig?.maxMessageSize ?? null, domainCount: emailConfig?.domains?.length || 0, routeCount: emailConfig?.routes?.length || 0, authUserCount: emailConfig?.auth?.users?.length || 0, updatedAt: this.updatedAt, updatedBy: this.updatedBy, }; } public async updateSettings( updates: TEmailServerSettingsUpdate, updatedBy: string, ): Promise { let doc = await EmailServerSettingsDoc.load(); if (!doc) { doc = new EmailServerSettingsDoc(); doc.settingsId = 'email-server-settings'; } const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled; const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled); const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates); const nextOutboundMode = this.patchOutboundMode(doc.outboundMode, updates); doc.enabled = nextEnabled; doc.emailConfig = nextEmailConfig; doc.emailPortConfig = nextEmailPortConfig; doc.outboundMode = nextOutboundMode; doc.updatedAt = Date.now(); doc.updatedBy = updatedBy; await doc.save(); this.loadFromDoc(doc); this.applyToRuntimeOptions(); return this.getPublicSettings(); } private loadFromDoc(doc: EmailServerSettingsDoc): void { this.enabled = doc.enabled; this.cachedEmailConfig = clonePlain(doc.emailConfig); this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig); this.outboundMode = this.normalizeOutboundMode(doc.outboundMode); this.updatedAt = doc.updatedAt; this.updatedBy = doc.updatedBy; } private applyToRuntimeOptions(): void { this.options.emailConfig = this.getEmailConfig(); this.options.emailPortConfig = this.getEmailPortConfig(); this.options.emailOutboundMode = this.outboundMode; } private patchOutboundMode( existingMode: TEmailOutboundMode | undefined, updates: TEmailServerSettingsUpdate, ): TEmailOutboundMode { if (hasOwn(updates, 'outboundMode') && updates.outboundMode !== undefined) { return this.normalizeOutboundMode(updates.outboundMode); } return this.normalizeOutboundMode(existingMode); } private normalizeOutboundMode(mode: TEmailOutboundMode | undefined): TEmailOutboundMode { if (!mode) return 'remoteIngress'; if (mode !== 'direct' && mode !== 'remoteIngress') { throw new Error('Invalid email outbound mode'); } return mode; } private patchEmailConfig( existingConfig: IUnifiedEmailServerOptions | undefined, updates: TEmailServerSettingsUpdate, nextEnabled: boolean, ): IUnifiedEmailServerOptions | undefined { const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? { hostname: 'localhost', ports: [...defaultEmailPorts], queue: { storageMode: 'managed' }, domains: [], routes: [], } : undefined); if (!nextConfig) return undefined; if (hasOwn(updates, 'hostname')) { const hostname = updates.hostname?.trim() || ''; if (nextEnabled && !hostname) { throw new Error('Email hostname is required when email is enabled'); } nextConfig.hostname = hostname || nextConfig.hostname; } if (hasOwn(updates, 'ports')) { nextConfig.ports = this.normalizePorts(updates.ports || []); } if (hasOwn(updates, 'maxMessageSize')) { if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) { delete nextConfig.maxMessageSize; } else { const maxMessageSize = Number(updates.maxMessageSize); if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) { throw new Error('maxMessageSize must be a positive integer'); } nextConfig.maxMessageSize = maxMessageSize; } } if (hasOwn(updates, 'removeRouteNames') && updates.removeRouteNames?.length) { nextConfig.routes = this.removeRoutesByName(nextConfig.routes || [], updates.removeRouteNames); } if (nextEnabled) { if (!nextConfig.hostname?.trim()) { throw new Error('Email hostname is required when email is enabled'); } nextConfig.ports = this.normalizePorts(nextConfig.ports || []); } nextConfig.domains = nextConfig.domains || []; nextConfig.routes = nextConfig.routes || []; return nextConfig; } /** * Remove persisted legacy routes by exact name — the internal-relay * retirement lever, not a general route editor. Refuses generated managed * route names (those are composed at runtime, never persisted here) and * fails loudly on unknown names instead of silently no-oping. */ private removeRoutesByName( routes: NonNullable, routeNames: string[], ): NonNullable { let remaining = routes; for (const routeName of routeNames) { const name = (routeName || '').trim(); if (!name) { throw new Error('Route removal requires a non-empty route name'); } if (isWorkAppManagedMailRouteName(name) || isSmtpAccountRouteName(name)) { throw new Error(`Route '${name}' is a generated managed route and cannot be removed from persisted settings`); } const next = remaining.filter((route) => route.name !== name); if (next.length === remaining.length) { throw new Error(`Email route not found in persisted settings: ${name}`); } remaining = next; } return remaining; } private patchEmailPortConfig( existingPortConfig: IEmailPortConfig | undefined, updates: TEmailServerSettingsUpdate, ): IEmailPortConfig | undefined { const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {}; if (hasOwn(updates, 'portMapping')) { if (updates.portMapping === null) { delete nextPortConfig.portMapping; } else { nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {}); } } if (hasOwn(updates, 'receivedEmailsPath')) { const receivedEmailsPath = updates.receivedEmailsPath?.trim() || ''; if (receivedEmailsPath) { nextPortConfig.receivedEmailsPath = receivedEmailsPath; } else { delete nextPortConfig.receivedEmailsPath; } } return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined; } private normalizePorts(ports: number[]): number[] { const normalized = [...new Set(ports.map((port) => Number(port)))]; if (normalized.length === 0) { throw new Error('At least one email port is required when email is enabled'); } for (const port of normalized) { if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`Invalid email port: ${port}`); } } return normalized.sort((a, b) => a - b); } private normalizePortMapping(portMapping: Record): Record { const normalized: Record = {}; for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) { const externalPort = Number(externalPortString); const internalPort = Number(internalPortValue); for (const port of [externalPort, internalPort]) { if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`Invalid email port mapping value: ${port}`); } } normalized[externalPort] = internalPort; } return normalized; } }