feat(email): add persistent smartmta storage and runtime-managed email domain syncing

This commit is contained in:
2026-04-14 13:11:48 +00:00
parent 9a378ae87f
commit 1d7e5495fa
14 changed files with 690 additions and 163 deletions

View File

@@ -1,4 +1,5 @@
import * as plugins from '../plugins.js';
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
import { logger } from '../logger.js';
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
@@ -15,9 +16,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
private get dnsManager(): DnsManager | undefined {
@@ -32,6 +36,12 @@ export class EmailDomainManager {
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
}
public async start(): Promise<void> {
await this.syncManagedDomainsToRuntime();
}
public async stop(): Promise<void> {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
@@ -64,6 +74,9 @@ export class EmailDomainManager {
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
if (this.isDomainAlreadyConfigured(domainName)) {
throw new Error(`Email domain already configured for ${domainName}`);
}
const existing = await EmailDomainDoc.findByDomain(domainName);
if (existing) {
throw new Error(`Email domain already exists for ${domainName}`);
@@ -77,8 +90,8 @@ export class EmailDomainManager {
let publicKey: string | undefined;
if (this.dkimCreator) {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
// Extract public key from the DNS record value
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
publicKey = match ? match[1] : undefined;
@@ -110,6 +123,7 @@ export class EmailDomainManager {
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain created: ${domainName}`);
return this.docToInterface(doc);
@@ -131,12 +145,14 @@ export class EmailDomainManager {
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
doc.updatedAt = new Date().toISOString();
await doc.save();
await this.syncManagedDomainsToRuntime();
}
public async deleteEmailDomain(id: string): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
await doc.delete();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain deleted: ${doc.domain}`);
}
@@ -153,8 +169,17 @@ export class EmailDomainManager {
const domain = doc.domain;
const selector = doc.dkim.selector;
const publicKey = doc.dkim.publicKey || '';
const hostname = this.emailHostname;
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
if (this.dkimCreator) {
try {
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
dkimValue = dnsRecord.value;
} catch (err: unknown) {
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
}
}
const records: IEmailDnsRecord[] = [
{
@@ -172,7 +197,7 @@ export class EmailDomainManager {
{
type: 'TXT',
name: `${selector}._domainkey.${domain}`,
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
value: dkimValue,
status: doc.dnsStatus.dkim,
},
{
@@ -207,17 +232,7 @@ export class EmailDomainManager {
for (const required of requiredRecords) {
// Check if a matching record already exists
const exists = existingRecords.some((r) => {
if (required.type === 'MX') {
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
}
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
return false;
});
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
if (!exists) {
try {
@@ -259,16 +274,23 @@ export class EmailDomainManager {
const resolver = new plugins.dns.promises.Resolver();
// MX check
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
const requiredRecords = await this.getRequiredDnsRecords(id);
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
// SPF check
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
// DKIM check
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
// DMARC check
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
doc.updatedAt = new Date().toISOString();
@@ -277,10 +299,28 @@ export class EmailDomainManager {
return this.getRequiredDnsRecords(id);
}
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
return false;
}
return record.value.trim() === required.value.trim();
}
private async checkMx(
resolver: plugins.dns.promises.Resolver,
domain: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveMx(domain);
return records && records.length > 0 ? 'valid' : 'missing';
if (!records || records.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
@@ -289,13 +329,19 @@ export class EmailDomainManager {
private async checkTxtRecord(
resolver: plugins.dns.promises.Resolver,
name: string,
prefix: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveTxt(name);
const flat = records.map((r) => r.join(''));
const found = flat.some((r) => r.startsWith(prefix));
return found ? 'valid' : 'missing';
if (flat.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = flat.some((record) => record.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
@@ -318,4 +364,63 @@ export class EmailDomainManager {
updatedAt: doc.updatedAt,
};
}
private isDomainAlreadyConfigured(domainName: string): boolean {
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => domainConfig.domain.toLowerCase());
return configuredDomains.includes(domainName.toLowerCase());
}
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
const docs = await EmailDomainDoc.findAll();
const managedConfigs: IEmailDomainConfig[] = [];
for (const doc of docs) {
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
if (!linkedDomain) {
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
continue;
}
managedConfigs.push({
domain: doc.domain,
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
dkim: {
selector: doc.dkim.selector,
keySize: doc.dkim.keySize,
rotateKeys: doc.dkim.rotateKeys,
rotationInterval: doc.dkim.rotationIntervalDays,
},
rateLimits: doc.rateLimits,
});
}
return managedConfigs;
}
private async syncManagedDomainsToRuntime(): Promise<void> {
if (!this.dcRouter.options?.emailConfig) {
return;
}
const mergedDomains = new Map<string, IEmailDomainConfig>();
for (const domainConfig of this.baseEmailDomains) {
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
for (const managedConfig of await this.buildManagedDomainConfigs()) {
const key = managedConfig.domain.toLowerCase();
if (mergedDomains.has(key)) {
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
continue;
}
mergedDomains.set(key, managedConfig);
}
const domains = Array.from(mergedDomains.values());
this.dcRouter.options.emailConfig.domains = domains;
if (this.dcRouter.emailServer) {
this.dcRouter.emailServer.updateOptions({ domains });
}
}
}

View File

@@ -0,0 +1,108 @@
import * as plugins from '../plugins.js';
import type { IStorageManagerLike } from '@push.rocks/smartmta';
export class SmartMtaStorageManager implements IStorageManagerLike {
private readonly resolvedRootDir: string;
constructor(private rootDir: string) {
this.resolvedRootDir = plugins.path.resolve(rootDir);
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
}
private normalizeKey(key: string): string {
return key.replace(/^\/+/, '').replace(/\\/g, '/');
}
private resolvePathForKey(key: string): string {
const normalizedKey = this.normalizeKey(key);
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
if (
resolvedPath !== this.resolvedRootDir
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
) {
throw new Error(`Storage key escapes root directory: ${key}`);
}
return resolvedPath;
}
private toStorageKey(filePath: string): string {
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
return `/${relativePath}`;
}
public async get(key: string): Promise<string | null> {
const filePath = this.resolvePathForKey(key);
try {
return await plugins.fs.promises.readFile(filePath, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
public async set(key: string, value: string): Promise<void> {
const filePath = this.resolvePathForKey(key);
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
}
public async list(prefix: string): Promise<string[]> {
const prefixPath = this.resolvePathForKey(prefix);
try {
const stat = await plugins.fs.promises.stat(prefixPath);
if (stat.isFile()) {
return [this.toStorageKey(prefixPath)];
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const results: string[] = [];
const walk = async (currentPath: string): Promise<void> => {
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = plugins.path.join(currentPath, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
} else if (entry.isFile()) {
results.push(this.toStorageKey(entryPath));
}
}
};
await walk(prefixPath);
return results.sort();
}
public async delete(key: string): Promise<void> {
const targetPath = this.resolvePathForKey(key);
try {
const stat = await plugins.fs.promises.stat(targetPath);
if (stat.isDirectory()) {
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
} else {
await plugins.fs.promises.unlink(targetPath);
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
let currentDir = plugins.path.dirname(targetPath);
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
const entries = await plugins.fs.promises.readdir(currentDir);
if (entries.length > 0) {
break;
}
await plugins.fs.promises.rmdir(currentDir);
currentDir = plugins.path.dirname(currentDir);
}
}
}

View File

@@ -1 +1,2 @@
export * from './classes.email-domain.manager.js';
export * from './classes.smartmta-storage-manager.js';