feat(email): add persistent smartmta storage and runtime-managed email domain syncing
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
108
ts/email/classes.smartmta-storage-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
|
||||
Reference in New Issue
Block a user