import type { IEmailRoute, ISmtpAuthAccount, IUnifiedEmailServerOptions, } from '@push.rocks/smartmta'; import { assertValidAuthAccounts } from '@push.rocks/smartmta'; import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { SmtpAccountDoc } from '../db/index.js'; import type { DcRouter } from '../classes.dcrouter.js'; import type { ISmtpAccountDomainReadiness, ISmtpAccountInfo, ISmtpAccountMailPolicy, ISmtpAccountRecipientScope, ISmtpAccountSenderScope, } from '../../ts_interfaces/data/smtp-account.js'; import { isWorkAppManagedMailRouteName, isWorkAppManagedSmtpUsername, type IWorkAppMailRuntimeContribution, } from './classes.workapp-mail-manager.js'; import { deriveSmtpScramVerifier, generateSmtpAccountPassword } from './smtp-scram.js'; /** Route names generated (and owned wholesale) by SmtpAccountManager. */ export function isSmtpAccountRouteName(routeName: string): boolean { return routeName.startsWith('smtp-account-'); } const SMTP_ACCOUNT_USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/; /** Literal domain label chain — no wildcards, at least one dot. */ const SMTP_ACCOUNT_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; /** Address pattern: optional '*' glob in the local part only, literal domain. */ const SMTP_ACCOUNT_ADDRESS_LOCAL_PATTERN = /^[a-z0-9*][a-z0-9.*_+-]*$/; export interface ISmtpAccountCreateOptions { username: string; description?: string; senderScope?: ISmtpAccountSenderScope; recipientScope?: ISmtpAccountRecipientScope; mailPolicy?: ISmtpAccountMailPolicy; createdBy: string; } export interface ISmtpAccountUpdateOptions { description?: string; senderScope?: ISmtpAccountSenderScope; recipientScope?: ISmtpAccountRecipientScope; mailPolicy?: ISmtpAccountMailPolicy; } export interface ISmtpAccountMutationResult { account: ISmtpAccountInfo; warnings: string[]; } export interface ISmtpAccountSecretResult extends ISmtpAccountMutationResult { /** Plaintext password — exists only in this response, never persisted. */ password: string; } /** * SmtpAccountManager — operator-managed authenticated SMTP submission * accounts, backed by SmtpAccountDoc rows holding hashed SCRAM verifiers only. * * Single composition owner of the email runtime `auth` block and route list: * every runtime application of auth/routes goes through composeEmailConfig / * applyToRuntime here. WorkAppMailManager identities feed in as an explicit * contribution; nothing else writes `auth` wholesale. * * Accounts are served from an in-memory map loaded at start() — the SMTP auth * path (smartmta's Rust bridge, 5s callback timeout) never touches the DB. */ export class SmtpAccountManager { private accounts = new Map(); private started = false; private mutationChain: Promise = Promise.resolve(); /** Explicit upstream capability marker; absence is deliberately fail-closed. */ private get authAccountsCapability(): boolean { return (plugins.smartmta as any).smartMtaCapabilities?.smtpAuthAccountsAndScopes === true; } constructor(private dcRouterRef: DcRouter) {} public async start(): Promise { const docs = await SmtpAccountDoc.findAll(); this.accounts = new Map(docs.map((doc) => [doc.id, doc])); this.started = true; if (this.accounts.size > 0) { logger.log('info', `SmtpAccountManager loaded ${this.accounts.size} SMTP account(s)`); } } public async stop(): Promise { this.accounts.clear(); this.started = false; } public isStarted(): boolean { return this.started; } public getAccountCount(): number { return this.accounts.size; } // ========================================================================== // Composition — the single owner of auth + route-list assembly // ========================================================================== /** * Compose the runtime email config: operator-configured routes/users plus * the workapp identity contribution plus DB-backed SMTP accounts and their * generated relay routes. Idempotent — previously generated entries are * stripped by their reserved name prefixes before re-adding. */ public async composeEmailConfig( emailConfig: TConfig, workappContribution?: IWorkAppMailRuntimeContribution, ): Promise { const contribution = workappContribution ?? await this.dcRouterRef.workAppMailManager.getStoredIdentityContribution(emailConfig); if (!this.authAccountsCapability) { // Fail closed: without the smartmta capability the accounts field would // be silently ignored (or rejected), so never pretend they are active — // any pre-existing accounts entry is dropped from the composed auth too. if (this.accounts.size > 0) { logger.log('error', `SmartMTA is missing the smtpAuthAccountsAndScopes capability — ${this.accounts.size} stored SMTP account(s) are NOT active`); } const authWithoutAccounts = { ...(emailConfig.auth || {}) } as NonNullable; delete authWithoutAccounts.accounts; return { ...emailConfig, routes: [ ...(emailConfig.routes || []) .filter((route) => !isWorkAppManagedMailRouteName(route.name) && !isSmtpAccountRouteName(route.name)), ...contribution.routes, ], auth: { ...authWithoutAccounts, users: [ ...(emailConfig.auth?.users || []).filter((user) => !isWorkAppManagedSmtpUsername(user.username)), ...contribution.users, ], }, }; } const accountDocs = [...this.accounts.values()] .sort((a, b) => a.username.localeCompare(b.username)); const smtpAccounts: ISmtpAuthAccount[] = []; const accountRoutes: IEmailRoute[] = []; for (const doc of accountDocs) { const account = this.buildAuthAccount(doc); try { // smartmta's own validator — construction and updateOptions throw on // invalid accounts, so a bad DB row must never reach the push. // Validated per account so one row cannot take the whole auth block // down. assertValidAuthAccounts([account]); } catch (error: unknown) { const message = (error as Error).message; logger.log('error', `SMTP account ${doc.username} excluded from runtime auth (invalid stored configuration): ${message}`); this.recordAccountConflictEvent(doc.username, message); continue; } smtpAccounts.push(account); if (doc.enabled) { accountRoutes.push(...this.buildAccountRoutes(doc)); } } const configuredRoutes = (emailConfig.routes || []) .filter((route) => !isWorkAppManagedMailRouteName(route.name) && !isSmtpAccountRouteName(route.name)); const configuredUsers = (emailConfig.auth?.users || []) .filter((user) => !isWorkAppManagedSmtpUsername(user.username)); return { ...emailConfig, routes: [...configuredRoutes, ...contribution.routes, ...accountRoutes], auth: { ...(emailConfig.auth || {}), users: [...configuredUsers, ...contribution.users], accounts: smtpAccounts, }, }; } /** * Recompose from the live runtime options and push the result: replace the * whole `auth` block (smartmta toggles listener AUTH live) and re-apply the * route list. */ public async applyToRuntime(workappContribution?: IWorkAppMailRuntimeContribution): Promise { const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined; if (!emailConfig) return; const nextConfig = await this.composeEmailConfig(emailConfig, workappContribution); this.dcRouterRef.options.emailConfig = nextConfig; if (this.dcRouterRef.emailServer) { this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth }); await this.dcRouterRef.updateEmailRoutes(nextConfig.routes); } } private buildAuthAccount(doc: SmtpAccountDoc): ISmtpAuthAccount { const senders = [ ...(doc.senderScope?.addresses || []), ...(doc.senderScope?.domains || []).map((domain) => `*@${domain}`), ]; const recipientsRestricted = doc.recipientScope?.mode === 'restricted'; const recipientPatterns = [ ...(doc.recipientScope?.addresses || []), ...(doc.recipientScope?.domains || []).map((domain) => `*@${domain}`), ]; let scope: ISmtpAuthAccount['scope']; if (senders.length > 0 || recipientsRestricted) { scope = { // Unrestricted senders with restricted recipients still needs a scope // block; '*' matches every sender including the null sender. senders: senders.length > 0 ? senders : ['*'], ...(recipientsRestricted ? { recipients: { mode: 'patterns' as const, patterns: recipientPatterns } } : {}), }; } return { username: doc.username, enabled: doc.enabled, credential: { verifier: doc.credentialVerifier }, ...(scope ? { scope } : {}), }; } /** * One generated relay route per (account × sender-scope domain), name * `smtp-account--`, priority 850 — below workapp * per-address routes (900), above operator-configured DB routes. * * Accounts without a sender scope generate no routes: their relay * permission stays exactly whatever operator-configured routes grant * (this is what keeps the legacy-user migration from widening relay). * * DKIM policy rides on `process.dkim` only — smartmta ≥9.1 resolves the * active selector per sender domain from its domain registry, so no * selector is snapshotted into routes and rotation stays owned by * EmailDomainManager/mail-dns-sync. */ private buildAccountRoutes(doc: SmtpAccountDoc): IEmailRoute[] { const senderDomains = this.collectSenderDomainPatterns(doc.senderScope); const routes: IEmailRoute[] = []; for (const [domain, patterns] of senderDomains) { routes.push({ name: `smtp-account-${doc.id}-${this.hashDomain(domain)}`, priority: 850, match: { authenticated: true, authenticatedUser: doc.username, senders: patterns, }, action: { type: 'process', allowRelay: true, process: { dkim: Boolean(doc.mailPolicy?.dkimSign), queue: doc.mailPolicy?.queue ?? 'normal', }, }, }); } return routes; } /** Map of sender-scope domain -> the scope patterns belonging to it. */ private collectSenderDomainPatterns(senderScope: ISmtpAccountSenderScope | undefined): Map { const byDomain = new Map(); for (const domain of senderScope?.domains || []) { const key = domain.toLowerCase(); byDomain.set(key, [...(byDomain.get(key) || []), `*@${key}`]); } for (const address of senderScope?.addresses || []) { const domainPart = address.split('@')[1]?.toLowerCase(); if (!domainPart) continue; byDomain.set(domainPart, [...(byDomain.get(domainPart) || []), address.toLowerCase()]); } return byDomain; } private hashDomain(domain: string): string { return plugins.crypto.createHash('sha256').update(domain.toLowerCase()).digest('hex').slice(0, 8); } private recordAccountConflictEvent(username: string, message: string): void { const opsEventManager = this.dcRouterRef.opsEventManager; if (!opsEventManager) return; opsEventManager.recordEvent({ severity: 'error', category: 'smtp-accounts', title: 'SMTP account excluded from runtime authentication', detail: `Stored SMTP account '${username}' failed validation and was excluded from the composed auth block: ${message}`, context: { recordName: username }, }).catch((error: unknown) => { logger.log('warn', `Failed to record SMTP account conflict event for ${username}: ${(error as Error).message}`); }); } // ========================================================================== // CRUD // ========================================================================== public async listAccounts(): Promise { const docs = [...this.accounts.values()] .sort((a, b) => a.username.localeCompare(b.username)); const accounts: ISmtpAccountInfo[] = []; for (const doc of docs) { accounts.push(await this.decorateWithDomainReadiness(doc)); } return accounts; } public async createAccount(options: ISmtpAccountCreateOptions): Promise { return await this.runMutationExclusive(async () => { if (!this.authAccountsCapability) { throw new Error('SmartMTA runtime does not support SMTP auth accounts (smtpAuthAccountsAndScopes capability missing)'); } const username = this.normalizeUsername(options.username); const senderScope = this.normalizeSenderScope(options.senderScope); const recipientScope = this.normalizeRecipientScope(options.recipientScope); const mailPolicy = this.normalizeMailPolicy(options.mailPolicy); await this.assertDkimPolicyAllowed(mailPolicy, senderScope, username); if ([...this.accounts.values()].some((doc) => doc.username === username) || await SmtpAccountDoc.findByUsername(username)) { throw new Error(`SMTP account username is already taken: ${username}`); } const password = generateSmtpAccountPassword(); const now = Date.now(); const doc = new SmtpAccountDoc(); doc.id = `smtpacct_${now.toString(36)}_${plugins.crypto.randomBytes(6).toString('hex')}`; doc.username = username; doc.description = (options.description || '').trim(); doc.enabled = true; doc.credentialVerifier = deriveSmtpScramVerifier(password); doc.senderScope = senderScope; doc.recipientScope = recipientScope; doc.mailPolicy = mailPolicy; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = options.createdBy; await doc.save(); this.accounts.set(doc.id, doc); await this.applyToRuntime(); const warnings = await this.collectSenderDomainWarnings(senderScope); logger.log('info', `SMTP account '${username}' created by ${options.createdBy} (id: ${doc.id})`); return { account: await this.decorateWithDomainReadiness(doc), password, warnings }; }); } public async updateAccount( id: string, updates: ISmtpAccountUpdateOptions, updatedBy: string, ): Promise { return await this.runMutationExclusive(async () => { const doc = this.requireAccount(id); const senderScope = updates.senderScope !== undefined ? this.normalizeSenderScope(updates.senderScope) : doc.senderScope; const recipientScope = updates.recipientScope !== undefined ? this.normalizeRecipientScope(updates.recipientScope) : doc.recipientScope; const mailPolicy = updates.mailPolicy !== undefined ? this.normalizeMailPolicy(updates.mailPolicy) : doc.mailPolicy; await this.assertDkimPolicyAllowed(mailPolicy, senderScope, doc.username); if (updates.description !== undefined) { doc.description = updates.description.trim(); } doc.senderScope = senderScope; doc.recipientScope = recipientScope; doc.mailPolicy = mailPolicy; doc.updatedAt = Date.now(); await doc.save(); this.accounts.set(doc.id, doc); await this.applyToRuntime(); const warnings = await this.collectSenderDomainWarnings(senderScope); logger.log('info', `SMTP account '${doc.username}' updated by ${updatedBy}`); return { account: await this.decorateWithDomainReadiness(doc), warnings }; }); } public async toggleAccount(id: string, enabled: boolean, updatedBy: string): Promise { return await this.runMutationExclusive(async () => { const doc = this.requireAccount(id); doc.enabled = enabled; doc.updatedAt = Date.now(); await doc.save(); this.accounts.set(doc.id, doc); await this.applyToRuntime(); logger.log('info', `SMTP account '${doc.username}' ${enabled ? 'enabled' : 'disabled'} by ${updatedBy}`); return { account: await this.decorateWithDomainReadiness(doc), warnings: [] }; }); } public async rotatePassword(id: string, rotatedBy: string): Promise { return await this.runMutationExclusive(async () => { const doc = this.requireAccount(id); const password = generateSmtpAccountPassword(); doc.credentialVerifier = deriveSmtpScramVerifier(password); doc.lastRotatedAt = Date.now(); doc.updatedAt = doc.lastRotatedAt; await doc.save(); this.accounts.set(doc.id, doc); await this.applyToRuntime(); logger.log('info', `SMTP account '${doc.username}' password rotated by ${rotatedBy}`); return { account: await this.decorateWithDomainReadiness(doc), password, warnings: [] }; }); } public async deleteAccount(id: string, deletedBy: string): Promise { return await this.runMutationExclusive(async () => { const doc = this.requireAccount(id); await doc.delete(); this.accounts.delete(id); await this.applyToRuntime(); logger.log('info', `SMTP account '${doc.username}' deleted by ${deletedBy}`); }); } // ========================================================================== // Validation / normalization // ========================================================================== private requireAccount(id: string): SmtpAccountDoc { const doc = this.accounts.get(id); if (!doc) { throw new Error(`SMTP account not found: ${id}`); } return doc; } private normalizeUsername(usernameArg: string): string { const username = (usernameArg || '').trim().toLowerCase(); if (!SMTP_ACCOUNT_USERNAME_PATTERN.test(username)) { throw new Error('SMTP account username must be 3-64 characters of a-z, 0-9, dot, underscore, or dash, starting alphanumeric'); } if (isWorkAppManagedSmtpUsername(username)) { throw new Error(`SMTP account usernames may not use the reserved 'workapp-' prefix: ${username}`); } return username; } private normalizeDomain(domainArg: string, context: string): string { const domain = (domainArg || '').trim().toLowerCase(); if (!SMTP_ACCOUNT_DOMAIN_PATTERN.test(domain)) { throw new Error(`Invalid ${context} domain (wildcards are not allowed in domains): ${domainArg}`); } return domain; } private normalizeAddressPattern(addressArg: string, context: string): string { const address = (addressArg || '').trim().toLowerCase(); const parts = address.split('@'); if (parts.length !== 2 || !SMTP_ACCOUNT_ADDRESS_LOCAL_PATTERN.test(parts[0]) || !SMTP_ACCOUNT_DOMAIN_PATTERN.test(parts[1])) { throw new Error(`Invalid ${context} address pattern ('local@domain', '*' allowed in the local part only): ${addressArg}`); } return address; } private normalizeSenderScope(scopeArg: ISmtpAccountSenderScope | undefined): ISmtpAccountSenderScope { return { addresses: this.dedupe((scopeArg?.addresses || []).map((address) => this.normalizeAddressPattern(address, 'sender scope'))), domains: this.dedupe((scopeArg?.domains || []).map((domain) => this.normalizeDomain(domain, 'sender scope'))), }; } private normalizeRecipientScope(scopeArg: ISmtpAccountRecipientScope | undefined): ISmtpAccountRecipientScope { const mode = scopeArg?.mode === 'restricted' ? 'restricted' : 'any'; const addresses = this.dedupe((scopeArg?.addresses || []).map((address) => this.normalizeAddressPattern(address, 'recipient scope'))); const domains = this.dedupe((scopeArg?.domains || []).map((domain) => this.normalizeDomain(domain, 'recipient scope'))); if (mode === 'restricted' && addresses.length === 0 && domains.length === 0) { throw new Error('A restricted recipient scope requires at least one address or domain (an empty list would silently deny all mail)'); } return { mode, addresses, domains }; } private normalizeMailPolicy(policyArg: ISmtpAccountMailPolicy | undefined): ISmtpAccountMailPolicy { const queue = policyArg?.queue; if (queue !== undefined && !['normal', 'priority', 'bulk'].includes(queue)) { throw new Error(`Invalid mail policy queue: ${queue}`); } return { dkimSign: Boolean(policyArg?.dkimSign), ...(queue ? { queue } : {}), }; } /** * DKIM signing fails at configuration time, never at delivery time: it is * only allowed when every sender-scope domain is a managed email domain * that is outbound-ready with active DKIM material. */ private async assertDkimPolicyAllowed( mailPolicy: ISmtpAccountMailPolicy, senderScope: ISmtpAccountSenderScope, username: string, ): Promise { if (!mailPolicy.dkimSign) return; const senderDomains = [...this.collectSenderDomainPatterns(senderScope).keys()]; if (senderDomains.length === 0) { throw new Error(`DKIM signing for SMTP account '${username}' requires an explicit sender scope naming the domain(s) to sign for`); } const emailDomainManager = this.dcRouterRef.emailDomainManager; if (!emailDomainManager) { throw new Error('DKIM signing requires the email domain manager, which is not available'); } for (const domain of senderDomains) { if (!await emailDomainManager.getByDomain(domain)) { throw new Error(`DKIM signing requires managed email domains; '${domain}' is not managed by dcrouter`); } const readiness = await emailDomainManager.getOutboundReadiness(domain); if (!readiness.ready || !readiness.selector) { throw new Error(`DKIM signing requires outbound-ready DKIM material for '${domain}': ${readiness.reason || 'no active DKIM selector'}`); } } } /** Warn-and-allow: sender domains outside managed email domains are permitted but flagged. */ private async collectSenderDomainWarnings(senderScope: ISmtpAccountSenderScope): Promise { const warnings: string[] = []; const emailDomainManager = this.dcRouterRef.emailDomainManager; if (!emailDomainManager) return warnings; for (const domain of this.collectSenderDomainPatterns(senderScope).keys()) { try { if (!await emailDomainManager.getByDomain(domain)) { warnings.push(`Sender domain '${domain}' is not a managed email domain — outbound DNS alignment (SPF/DKIM/DMARC) is not managed by dcrouter`); } } catch (error: unknown) { warnings.push(`Could not verify sender domain '${domain}': ${(error as Error).message}`); } } return warnings; } private async decorateWithDomainReadiness(doc: SmtpAccountDoc): Promise { const info = doc.toApiObject(); const emailDomainManager = this.dcRouterRef.emailDomainManager; const senderDomains = [...this.collectSenderDomainPatterns(doc.senderScope).keys()]; if (!emailDomainManager || senderDomains.length === 0) return info; const domainReadiness: ISmtpAccountDomainReadiness[] = []; for (const domain of senderDomains) { try { if (!await emailDomainManager.getByDomain(domain)) { domainReadiness.push({ domain, ready: !doc.mailPolicy?.dkimSign, reason: 'not a managed email domain', }); continue; } const readiness = await emailDomainManager.getOutboundReadiness(domain); domainReadiness.push({ domain, ready: readiness.ready, ...(readiness.reason ? { reason: readiness.reason } : {}), }); } catch (error: unknown) { domainReadiness.push({ domain, ready: false, reason: `readiness check failed: ${(error as Error).message}`, }); } } return { ...info, domainReadiness }; } private dedupe(values: string[]): string[] { return [...new Set(values)]; } private async runMutationExclusive(action: () => Promise): Promise { const run = this.mutationChain.then(action, action); this.mutationChain = run.catch(() => undefined); return await run; } }