From 433047bbf1c97515a2d93be9336fa729d48fe363 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 12 Apr 2026 22:09:20 +0000 Subject: [PATCH] feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 17 + ts/db/documents/classes.email-domain.doc.ts | 53 +++ ts/db/documents/index.ts | 3 + ts/email/classes.email-domain.manager.ts | 316 ++++++++++++++ ts/email/index.ts | 1 + ts/opsserver/classes.opsserver.ts | 2 + ts/opsserver/handlers/email-domain.handler.ts | 194 +++++++++ ts/opsserver/handlers/index.ts | 3 +- ts_interfaces/data/email-domain.ts | 73 ++++ ts_interfaces/data/index.ts | 3 +- ts_interfaces/requests/email-domains.ts | 176 ++++++++ ts_interfaces/requests/index.ts | 3 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 123 ++++++ ts_web/elements/email/index.ts | 1 + .../elements/email/ops-view-email-domains.ts | 389 ++++++++++++++++++ ts_web/elements/ops-dashboard.ts | 2 + ts_web/router.ts | 2 +- 20 files changed, 1366 insertions(+), 6 deletions(-) create mode 100644 ts/db/documents/classes.email-domain.doc.ts create mode 100644 ts/email/classes.email-domain.manager.ts create mode 100644 ts/email/index.ts create mode 100644 ts/opsserver/handlers/email-domain.handler.ts create mode 100644 ts_interfaces/data/email-domain.ts create mode 100644 ts_interfaces/requests/email-domains.ts create mode 100644 ts_web/elements/email/ops-view-email-domains.ts diff --git a/changelog.md b/changelog.md index cca37c0..66548fe 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-12 - 13.11.0 - feat(email-domains) +add email domain management with DNS provisioning, validation, and ops dashboard support + +- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation. +- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains. +- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records. + ## 2026-04-12 - 13.10.0 - feat(web-ui) standardize settings views for ACME and email security panels diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 924d19a..75a2d3f 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.10.0', + version: '13.11.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index ab915c4..db4146a 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -29,6 +29,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/ import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { DnsManager } from './dns/manager.dns.js'; import { AcmeConfigManager } from './acme/manager.acme-config.js'; +import { EmailDomainManager } from './email/classes.email-domain.manager.js'; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ @@ -279,6 +280,7 @@ export class DcRouter { // ACME configuration (DB-backed singleton, replaces tls.contactEmail) public acmeConfigManager?: AcmeConfigManager; + public emailDomainManager?: EmailDomainManager; // Auto-discovered public IP (populated by generateAuthoritativeRecords) public detectedPublicIp: string | null = null; @@ -439,6 +441,21 @@ export class DcRouter { ); } + // Email Domain Manager: optional, depends on DcRouterDb + if (this.options.dbConfig?.enabled !== false) { + this.serviceManager.addService( + new plugins.taskbuffer.Service('EmailDomainManager') + .optional() + .dependsOn('DcRouterDb') + .withStart(async () => { + this.emailDomainManager = new EmailDomainManager(this); + }) + .withStop(async () => { + this.emailDomainManager = undefined; + }), + ); + } + // SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled) const smartProxyDeps: string[] = []; if (this.options.dbConfig?.enabled !== false) { diff --git a/ts/db/documents/classes.email-domain.doc.ts b/ts/db/documents/classes.email-domain.doc.ts new file mode 100644 index 0000000..fcc3b27 --- /dev/null +++ b/ts/db/documents/classes.email-domain.doc.ts @@ -0,0 +1,53 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { + IEmailDomainDkim, + IEmailDomainRateLimits, + IEmailDomainDnsStatus, +} from '../../../ts_interfaces/data/email-domain.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public domain: string = ''; + + @plugins.smartdata.svDb() + public linkedDomainId: string = ''; + + @plugins.smartdata.svDb() + public dkim!: IEmailDomainDkim; + + @plugins.smartdata.svDb() + public rateLimits?: IEmailDomainRateLimits; + + @plugins.smartdata.svDb() + public dnsStatus!: IEmailDomainDnsStatus; + + @plugins.smartdata.svDb() + public createdAt!: string; + + @plugins.smartdata.svDb() + public updatedAt!: string; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await EmailDomainDoc.getInstance({ id }); + } + + public static async findByDomain(domain: string): Promise { + return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() }); + } + + public static async findAll(): Promise { + return await EmailDomainDoc.getInstances({}); + } +} diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index 17809dd..16933b5 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -33,3 +33,6 @@ export * from './classes.dns-record.doc.js'; // ACME configuration (singleton) export * from './classes.acme-config.doc.js'; + +// Email domain management +export * from './classes.email-domain.doc.js'; diff --git a/ts/email/classes.email-domain.manager.ts b/ts/email/classes.email-domain.manager.ts new file mode 100644 index 0000000..44030aa --- /dev/null +++ b/ts/email/classes.email-domain.manager.ts @@ -0,0 +1,316 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js'; +import { DomainDoc } from '../db/documents/classes.domain.doc.js'; +import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js'; +import type { DnsManager } from '../dns/manager.dns.js'; +import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js'; + +/** + * EmailDomainManager — orchestrates email domain setup. + * + * Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager + * (record creation for dcrouter-hosted and provider-managed zones) to provide + * a single entry point for setting up an email domain from A to Z. + */ +export class EmailDomainManager { + private dcRouter: any; // DcRouter — avoids circular import + + constructor(dcRouterRef: any) { + this.dcRouter = dcRouterRef; + } + + private get dnsManager(): DnsManager | undefined { + return this.dcRouter.dnsManager; + } + + private get dkimCreator(): any | undefined { + return this.dcRouter.emailServer?.dkimCreator; + } + + private get emailHostname(): string { + return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost'; + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + public async getAll(): Promise { + const docs = await EmailDomainDoc.findAll(); + return docs.map((d) => this.docToInterface(d)); + } + + public async getById(id: string): Promise { + const doc = await EmailDomainDoc.findById(id); + return doc ? this.docToInterface(doc) : null; + } + + public async createEmailDomain(opts: { + linkedDomainId: string; + dkimSelector?: string; + dkimKeySize?: number; + rotateKeys?: boolean; + rotationIntervalDays?: number; + }): Promise { + // Resolve the linked DNS domain + const domainDoc = await DomainDoc.findById(opts.linkedDomainId); + if (!domainDoc) { + throw new Error(`DNS domain not found: ${opts.linkedDomainId}`); + } + const domainName = domainDoc.name; + + // Check for duplicates + const existing = await EmailDomainDoc.findByDomain(domainName); + if (existing) { + throw new Error(`Email domain already exists for ${domainName}`); + } + + const selector = opts.dkimSelector || 'default'; + const keySize = opts.dkimKeySize || 2048; + const now = new Date().toISOString(); + + // Generate DKIM keys + let publicKey: string | undefined; + if (this.dkimCreator) { + try { + await this.dkimCreator.handleDKIMKeysForDomain(domainName); + const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(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; + logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`); + } catch (err: unknown) { + logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`); + } + } + + // Create the document + const doc = new EmailDomainDoc(); + doc.id = plugins.smartunique.shortId(); + doc.domain = domainName.toLowerCase(); + doc.linkedDomainId = opts.linkedDomainId; + doc.dkim = { + selector, + keySize, + publicKey, + rotateKeys: opts.rotateKeys ?? false, + rotationIntervalDays: opts.rotationIntervalDays ?? 90, + }; + doc.dnsStatus = { + mx: 'unchecked', + spf: 'unchecked', + dkim: 'unchecked', + dmarc: 'unchecked', + }; + doc.createdAt = now; + doc.updatedAt = now; + await doc.save(); + + logger.log('info', `Email domain created: ${domainName}`); + return this.docToInterface(doc); + } + + public async updateEmailDomain( + id: string, + changes: { + rotateKeys?: boolean; + rotationIntervalDays?: number; + rateLimits?: IEmailDomain['rateLimits']; + }, + ): Promise { + const doc = await EmailDomainDoc.findById(id); + if (!doc) throw new Error(`Email domain not found: ${id}`); + + if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys; + if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays; + if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits; + doc.updatedAt = new Date().toISOString(); + await doc.save(); + } + + public async deleteEmailDomain(id: string): Promise { + const doc = await EmailDomainDoc.findById(id); + if (!doc) throw new Error(`Email domain not found: ${id}`); + await doc.delete(); + logger.log('info', `Email domain deleted: ${doc.domain}`); + } + + // --------------------------------------------------------------------------- + // DNS record computation + // --------------------------------------------------------------------------- + + /** + * Compute the 4 required DNS records for an email domain. + */ + public async getRequiredDnsRecords(id: string): Promise { + const doc = await EmailDomainDoc.findById(id); + if (!doc) throw new Error(`Email domain not found: ${id}`); + + const domain = doc.domain; + const selector = doc.dkim.selector; + const publicKey = doc.dkim.publicKey || ''; + const hostname = this.emailHostname; + + const records: IEmailDnsRecord[] = [ + { + type: 'MX', + name: domain, + value: `10 ${hostname}`, + status: doc.dnsStatus.mx, + }, + { + type: 'TXT', + name: domain, + value: 'v=spf1 a mx ~all', + status: doc.dnsStatus.spf, + }, + { + type: 'TXT', + name: `${selector}._domainkey.${domain}`, + value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`, + status: doc.dnsStatus.dkim, + }, + { + type: 'TXT', + name: `_dmarc.${domain}`, + value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`, + status: doc.dnsStatus.dmarc, + }, + ]; + + return records; + } + + // --------------------------------------------------------------------------- + // DNS provisioning + // --------------------------------------------------------------------------- + + /** + * Auto-create missing DNS records via the linked domain's DNS path. + */ + public async provisionDnsRecords(id: string): Promise { + const doc = await EmailDomainDoc.findById(id); + if (!doc) throw new Error(`Email domain not found: ${id}`); + if (!this.dnsManager) throw new Error('DnsManager not available'); + + const requiredRecords = await this.getRequiredDnsRecords(id); + const domainId = doc.linkedDomainId; + + // Get existing DNS records for the linked domain + const existingRecords = await DnsRecordDoc.findByDomainId(domainId); + let provisioned = 0; + + 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; + }); + + if (!exists) { + try { + await this.dnsManager.createRecord({ + domainId, + name: required.name, + type: required.type as any, + value: required.value, + ttl: 3600, + createdBy: 'email-domain-manager', + }); + provisioned++; + logger.log('info', `Provisioned ${required.type} record for ${required.name}`); + } catch (err: unknown) { + logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`); + } + } + } + + // Re-validate after provisioning + await this.validateDns(id); + + return provisioned; + } + + // --------------------------------------------------------------------------- + // DNS validation + // --------------------------------------------------------------------------- + + /** + * Validate DNS records via live lookups. + */ + public async validateDns(id: string): Promise { + const doc = await EmailDomainDoc.findById(id); + if (!doc) throw new Error(`Email domain not found: ${id}`); + + const domain = doc.domain; + const selector = doc.dkim.selector; + const resolver = new plugins.dns.promises.Resolver(); + + // MX check + doc.dnsStatus.mx = await this.checkMx(resolver, domain); + + // SPF check + doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1'); + + // DKIM check + doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1'); + + // DMARC check + doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1'); + + doc.dnsStatus.lastCheckedAt = new Date().toISOString(); + doc.updatedAt = new Date().toISOString(); + await doc.save(); + + return this.getRequiredDnsRecords(id); + } + + private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise { + try { + const records = await resolver.resolveMx(domain); + return records && records.length > 0 ? 'valid' : 'missing'; + } catch { + return 'missing'; + } + } + + private async checkTxtRecord( + resolver: plugins.dns.promises.Resolver, + name: string, + prefix: string, + ): Promise { + 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'; + } catch { + return 'missing'; + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private docToInterface(doc: EmailDomainDoc): IEmailDomain { + return { + id: doc.id, + domain: doc.domain, + linkedDomainId: doc.linkedDomainId, + dkim: doc.dkim, + rateLimits: doc.rateLimits, + dnsStatus: doc.dnsStatus, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/ts/email/index.ts b/ts/email/index.ts new file mode 100644 index 0000000..4b65ee6 --- /dev/null +++ b/ts/email/index.ts @@ -0,0 +1 @@ +export * from './classes.email-domain.manager.js'; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 92f875e..c1a090b 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -37,6 +37,7 @@ export class OpsServer { private domainHandler!: handlers.DomainHandler; private dnsRecordHandler!: handlers.DnsRecordHandler; private acmeConfigHandler!: handlers.AcmeConfigHandler; + private emailDomainHandler!: handlers.EmailDomainHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -104,6 +105,7 @@ export class OpsServer { this.domainHandler = new handlers.DomainHandler(this); this.dnsRecordHandler = new handlers.DnsRecordHandler(this); this.acmeConfigHandler = new handlers.AcmeConfigHandler(this); + this.emailDomainHandler = new handlers.EmailDomainHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/email-domain.handler.ts b/ts/opsserver/handlers/email-domain.handler.ts new file mode 100644 index 0000000..ed4d8bd --- /dev/null +++ b/ts/opsserver/handlers/email-domain.handler.ts @@ -0,0 +1,194 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +/** + * CRUD + DNS provisioning handler for email domains. + * + * Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope. + */ +export class EmailDomainHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private get manager() { + return this.opsServerRef.dcRouterRef.emailDomainManager; + } + + private registerHandlers(): void { + // List all email domains + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getEmailDomains', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:read' as any); + if (!this.manager) return { domains: [] }; + return { domains: await this.manager.getAll() }; + }, + ), + ); + + // Get single email domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getEmailDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:read' as any); + if (!this.manager) return { domain: null }; + return { domain: await this.manager.getById(dataArg.id) }; + }, + ), + ); + + // Create email domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createEmailDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:write' as any); + if (!this.manager) { + return { success: false, message: 'EmailDomainManager not initialized' }; + } + try { + const domain = await this.manager.createEmailDomain({ + linkedDomainId: dataArg.linkedDomainId, + dkimSelector: dataArg.dkimSelector, + dkimKeySize: dataArg.dkimKeySize, + rotateKeys: dataArg.rotateKeys, + rotationIntervalDays: dataArg.rotationIntervalDays, + }); + return { success: true, domain }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Update email domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateEmailDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:write' as any); + if (!this.manager) { + return { success: false, message: 'EmailDomainManager not initialized' }; + } + try { + await this.manager.updateEmailDomain(dataArg.id, { + rotateKeys: dataArg.rotateKeys, + rotationIntervalDays: dataArg.rotationIntervalDays, + rateLimits: dataArg.rateLimits, + }); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Delete email domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteEmailDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:write' as any); + if (!this.manager) { + return { success: false, message: 'EmailDomainManager not initialized' }; + } + try { + await this.manager.deleteEmailDomain(dataArg.id); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Validate DNS records + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'validateEmailDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:read' as any); + if (!this.manager) { + return { success: false, message: 'EmailDomainManager not initialized' }; + } + try { + const records = await this.manager.validateDns(dataArg.id); + const domain = await this.manager.getById(dataArg.id); + return { success: true, domain: domain ?? undefined, records }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Get required DNS records + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getEmailDomainDnsRecords', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:read' as any); + if (!this.manager) return { records: [] }; + return { records: await this.manager.getRequiredDnsRecords(dataArg.id) }; + }, + ), + ); + + // Auto-provision DNS records + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'provisionEmailDomainDns', + async (dataArg) => { + await this.requireAuth(dataArg, 'email-domains:write' as any); + if (!this.manager) { + return { success: false, message: 'EmailDomainManager not initialized' }; + } + try { + const provisioned = await this.manager.provisionDnsRecords(dataArg.id); + return { success: true, provisioned }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index 10232fd..e6c7216 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -17,4 +17,5 @@ export * from './users.handler.js'; export * from './dns-provider.handler.js'; export * from './domain.handler.js'; export * from './dns-record.handler.js'; -export * from './acme-config.handler.js'; \ No newline at end of file +export * from './acme-config.handler.js'; +export * from './email-domain.handler.js'; \ No newline at end of file diff --git a/ts_interfaces/data/email-domain.ts b/ts_interfaces/data/email-domain.ts new file mode 100644 index 0000000..aa416fe --- /dev/null +++ b/ts_interfaces/data/email-domain.ts @@ -0,0 +1,73 @@ +/** + * DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC). + */ +export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked'; + +/** + * An email domain managed by dcrouter. + * + * Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted + * or provider-managed). The DNS management path is inherited from the linked domain + * — no separate DNS mode is needed. + */ +export interface IEmailDomain { + id: string; + /** Fully qualified domain name (e.g. 'example.com'). */ + domain: string; + /** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */ + linkedDomainId: string; + /** DKIM configuration and key state. */ + dkim: IEmailDomainDkim; + /** Optional per-domain rate limits. */ + rateLimits?: IEmailDomainRateLimits; + /** DNS record validation status — populated by validateDns(). */ + dnsStatus: IEmailDomainDnsStatus; + createdAt: string; + updatedAt: string; +} + +export interface IEmailDomainDkim { + /** DKIM selector (default: 'default'). */ + selector: string; + /** RSA key size in bits (default: 2048). */ + keySize: number; + /** Base64-encoded public key — populated after key generation. */ + publicKey?: string; + /** Whether automatic key rotation is enabled. */ + rotateKeys: boolean; + /** Days between key rotations (default: 90). */ + rotationIntervalDays: number; + /** ISO date of last key rotation. */ + lastRotatedAt?: string; +} + +export interface IEmailDomainRateLimits { + outbound?: { + messagesPerMinute?: number; + messagesPerHour?: number; + messagesPerDay?: number; + }; + inbound?: { + messagesPerMinute?: number; + connectionsPerIp?: number; + recipientsPerMessage?: number; + }; +} + +export interface IEmailDomainDnsStatus { + mx: TDnsRecordStatus; + spf: TDnsRecordStatus; + dkim: TDnsRecordStatus; + dmarc: TDnsRecordStatus; + lastCheckedAt?: string; +} + +/** + * A single required DNS record for an email domain — used for display / copy-paste. + */ +export interface IEmailDnsRecord { + type: 'MX' | 'TXT'; + name: string; + value: string; + status: TDnsRecordStatus; +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index cbd9a7f..6cd0189 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -7,4 +7,5 @@ export * from './vpn.js'; export * from './dns-provider.js'; export * from './domain.js'; export * from './dns-record.js'; -export * from './acme-config.js'; \ No newline at end of file +export * from './acme-config.js'; +export * from './email-domain.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/email-domains.ts b/ts_interfaces/requests/email-domains.ts new file mode 100644 index 0000000..af7880f --- /dev/null +++ b/ts_interfaces/requests/email-domains.ts @@ -0,0 +1,176 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js'; + +// ============================================================================ +// Email Domain Endpoints +// ============================================================================ + +/** + * List all email domains. + */ +export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetEmailDomains +> { + method: 'getEmailDomains'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + domains: IEmailDomain[]; + }; +} + +/** + * Get a single email domain by id. + */ +export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetEmailDomain +> { + method: 'getEmailDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + domain: IEmailDomain | null; + }; +} + +/** + * Create an email domain. Links to an existing dcrouter DNS domain. + * Generates DKIM keys and computes the required DNS records. + */ +export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateEmailDomain +> { + method: 'createEmailDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + /** ID of the existing dcrouter DNS domain to link to. */ + linkedDomainId: string; + /** DKIM selector (default: 'default'). */ + dkimSelector?: string; + /** RSA key size (default: 2048). */ + dkimKeySize?: number; + /** Enable automatic key rotation (default: false). */ + rotateKeys?: boolean; + /** Days between rotations (default: 90). */ + rotationIntervalDays?: number; + }; + response: { + success: boolean; + domain?: IEmailDomain; + message?: string; + }; +} + +/** + * Update an email domain's configuration. + */ +export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateEmailDomain +> { + method: 'updateEmailDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + rotateKeys?: boolean; + rotationIntervalDays?: number; + rateLimits?: IEmailDomain['rateLimits']; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete an email domain. + */ +export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteEmailDomain +> { + method: 'deleteEmailDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Trigger DNS validation for an email domain. + * Performs live lookups for MX, SPF, DKIM, and DMARC records. + */ +export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ValidateEmailDomain +> { + method: 'validateEmailDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + domain?: IEmailDomain; + records?: IEmailDnsRecord[]; + message?: string; + }; +} + +/** + * Get the required DNS records for an email domain (for display / copy-paste). + */ +export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetEmailDomainDnsRecords +> { + method: 'getEmailDomainDnsRecords'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + records: IEmailDnsRecord[]; + }; +} + +/** + * Auto-provision DNS records for an email domain. + * Creates any missing MX, SPF, DKIM, and DMARC records via the linked + * domain's DNS path (dcrouter zone or provider API). + */ +export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ProvisionEmailDomainDns +> { + method: 'provisionEmailDomainDns'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + /** Number of records created. */ + provisioned?: number; + message?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index da0c28d..a180f3e 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -17,4 +17,5 @@ export * from './users.js'; export * from './dns-providers.js'; export * from './domains.js'; export * from './dns-records.js'; -export * from './acme-config.js'; \ No newline at end of file +export * from './acme-config.js'; +export * from './email-domains.js'; \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 924d19a..75a2d3f 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.10.0', + version: '13.11.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index c776b04..f2c1694 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -2377,6 +2377,129 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{ } }); +// ============================================================================ +// Email Domains State +// ============================================================================ + +export interface IEmailDomainsState { + domains: interfaces.data.IEmailDomain[]; + isLoading: boolean; + lastUpdated: number; +} + +export const emailDomainsStatePart = await appState.getStatePart( + 'emailDomains', + { + domains: [], + isLoading: false, + lastUpdated: 0, + }, + 'soft', +); + +export const fetchEmailDomainsAction = emailDomainsStatePart.createAction( + async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetEmailDomains + >('/typedrequest', 'getEmailDomains'); + const response = await request.fire({ identity: context.identity }); + return { + ...currentState, + domains: response.domains, + isLoading: false, + lastUpdated: Date.now(), + }; + } catch { + return { ...currentState, isLoading: false }; + } + }, +); + +export const createEmailDomainAction = emailDomainsStatePart.createAction<{ + linkedDomainId: string; + dkimSelector?: string; + dkimKeySize?: number; + rotateKeys?: boolean; + rotationIntervalDays?: number; +}>(async (statePartArg, args, actionContext) => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateEmailDomain + >('/typedrequest', 'createEmailDomain'); + await request.fire({ identity: context.identity!, ...args }); + return await actionContext!.dispatch(fetchEmailDomainsAction, null); + } catch { + return currentState; + } +}); + +export const deleteEmailDomainAction = emailDomainsStatePart.createAction( + async (statePartArg, id, actionContext) => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteEmailDomain + >('/typedrequest', 'deleteEmailDomain'); + await request.fire({ identity: context.identity!, id }); + return await actionContext!.dispatch(fetchEmailDomainsAction, null); + } catch { + return currentState; + } + }, +); + +export const validateEmailDomainAction = emailDomainsStatePart.createAction( + async (statePartArg, id, actionContext) => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ValidateEmailDomain + >('/typedrequest', 'validateEmailDomain'); + await request.fire({ identity: context.identity!, id }); + return await actionContext!.dispatch(fetchEmailDomainsAction, null); + } catch { + return currentState; + } + }, +); + +export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction( + async (statePartArg, id, actionContext) => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ProvisionEmailDomainDns + >('/typedrequest', 'provisionEmailDomainDns'); + await request.fire({ identity: context.identity!, id }); + return await actionContext!.dispatch(fetchEmailDomainsAction, null); + } catch { + return currentState; + } + }, +); + +// ============================================================================ +// Email Domain Standalone Functions +// ============================================================================ + +export async function fetchEmailDomainDnsRecords(id: string) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetEmailDomainDnsRecords + >('/typedrequest', 'getEmailDomainDnsRecords'); + return request.fire({ identity: context.identity!, id }); +} + // ============================================================================ // TypedSocket Client for Real-time Log Streaming // ============================================================================ diff --git a/ts_web/elements/email/index.ts b/ts_web/elements/email/index.ts index f8c2e1c..c95d720 100644 --- a/ts_web/elements/email/index.ts +++ b/ts_web/elements/email/index.ts @@ -1,2 +1,3 @@ export * from './ops-view-emails.js'; export * from './ops-view-email-security.js'; +export * from './ops-view-email-domains.js'; diff --git a/ts_web/elements/email/ops-view-email-domains.ts b/ts_web/elements/email/ops-view-email-domains.ts new file mode 100644 index 0000000..95653a9 --- /dev/null +++ b/ts_web/elements/email/ops-view-email-domains.ts @@ -0,0 +1,389 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-email-domains': OpsViewEmailDomains; + } +} + +@customElement('ops-view-email-domains') +export class OpsViewEmailDomains extends DeesElement { + @state() + accessor emailDomainsState: appstate.IEmailDomainsState = + appstate.emailDomainsStatePart.getState()!; + + @state() + accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.emailDomainsStatePart.select().subscribe((s) => { + this.emailDomainsState = s; + }); + this.rxSubscriptions.push(sub); + const domSub = appstate.domainsStatePart.select().subscribe((s) => { + this.domainsState = s; + }); + this.rxSubscriptions.push(domSub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.emailDomainsStatePart.dispatchAction(appstate.fetchEmailDomainsAction, null); + await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .emailDomainsContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .statusBadge.valid { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.missing { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .statusBadge.invalid { + background: ${cssManager.bdTheme('#fff7ed', '#431407')}; + color: ${cssManager.bdTheme('#9a3412', '#fb923c')}; + } + + .statusBadge.unchecked { + background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; + color: ${cssManager.bdTheme('#4b5563', '#9ca3af')}; + } + + .sourceBadge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; + color: ${cssManager.bdTheme('#374151', '#d1d5db')}; + } + `, + ]; + + public render(): TemplateResult { + const domains = this.emailDomainsState.domains; + const validCount = domains.filter( + (d) => + d.dnsStatus.mx === 'valid' && + d.dnsStatus.spf === 'valid' && + d.dnsStatus.dkim === 'valid' && + d.dnsStatus.dmarc === 'valid', + ).length; + const issueCount = domains.length - validCount; + + const tiles: IStatsTile[] = [ + { + id: 'total', + title: 'Total Domains', + value: domains.length, + type: 'number', + icon: 'lucide:globe', + color: '#3b82f6', + }, + { + id: 'valid', + title: 'Valid DNS', + value: validCount, + type: 'number', + icon: 'lucide:Check', + color: '#22c55e', + }, + { + id: 'issues', + title: 'Issues', + value: issueCount, + type: 'number', + icon: 'lucide:TriangleAlert', + color: issueCount > 0 ? '#ef4444' : '#22c55e', + }, + { + id: 'dkim', + title: 'DKIM Active', + value: domains.filter((d) => d.dkim.publicKey).length, + type: 'number', + icon: 'lucide:KeyRound', + color: '#8b5cf6', + }, + ]; + + return html` + Email Domains + +
+ { + await appstate.emailDomainsStatePart.dispatchAction( + appstate.fetchEmailDomainsAction, + null, + ); + }, + }, + ]} + > + + ({ + Domain: d.domain, + Source: this.renderSourceBadge(d.linkedDomainId), + MX: this.renderDnsStatus(d.dnsStatus.mx), + SPF: this.renderDnsStatus(d.dnsStatus.spf), + DKIM: this.renderDnsStatus(d.dnsStatus.dkim), + DMARC: this.renderDnsStatus(d.dnsStatus.dmarc), + })} + .dataActions=${[ + { + name: 'Add Email Domain', + iconName: 'lucide:plus', + type: ['header'] as any, + actionFunc: async () => { + await this.showCreateDialog(); + }, + }, + { + name: 'Validate DNS', + iconName: 'lucide:search-check', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const d = actionData.item as interfaces.data.IEmailDomain; + await appstate.emailDomainsStatePart.dispatchAction( + appstate.validateEmailDomainAction, + d.id, + ); + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ message: `DNS validated for ${d.domain}`, type: 'success', duration: 2500 }); + }, + }, + { + name: 'Provision DNS', + iconName: 'lucide:wand-sparkles', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const d = actionData.item as interfaces.data.IEmailDomain; + await appstate.emailDomainsStatePart.dispatchAction( + appstate.provisionEmailDomainDnsAction, + d.id, + ); + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ message: `DNS records provisioned for ${d.domain}`, type: 'success', duration: 2500 }); + }, + }, + { + name: 'View DNS Records', + iconName: 'lucide:list', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const d = actionData.item as interfaces.data.IEmailDomain; + await this.showDnsRecordsDialog(d); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const d = actionData.item as interfaces.data.IEmailDomain; + await appstate.emailDomainsStatePart.dispatchAction( + appstate.deleteEmailDomainAction, + d.id, + ); + }, + }, + ]} + dataName="email domain" + > +
+ `; + } + + private renderDnsStatus(status: interfaces.data.TDnsRecordStatus): TemplateResult { + return html`${status}`; + } + + private renderSourceBadge(linkedDomainId: string): TemplateResult { + const domain = this.domainsState.domains.find((d) => d.id === linkedDomainId); + if (!domain) return html`unknown`; + const label = + domain.source === 'dcrouter' + ? 'dcrouter' + : this.domainsState.providers.find((p) => p.id === domain.providerId)?.name || 'provider'; + return html`${label}`; + } + + private async showCreateDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + const domainOptions = this.domainsState.domains.map((d) => ({ + option: `${d.name} (${d.source})`, + key: d.id, + })); + + DeesModal.createAndShow({ + heading: 'Add Email Domain', + content: html` + + + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (m: any) => m.destroy() }, + { + name: 'Create', + action: async (m: any) => { + const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const linkedDomainId = + typeof data.linkedDomainId === 'object' + ? data.linkedDomainId.key + : data.linkedDomainId; + const keySize = + typeof data.dkimKeySize === 'object' + ? parseInt(data.dkimKeySize.key, 10) + : parseInt(data.dkimKeySize || '2048', 10); + + await appstate.emailDomainsStatePart.dispatchAction( + appstate.createEmailDomainAction, + { + linkedDomainId, + dkimSelector: data.dkimSelector || 'default', + dkimKeySize: keySize, + rotateKeys: Boolean(data.rotateKeys), + }, + ); + m.destroy(); + }, + }, + ], + }); + } + + private async showDnsRecordsDialog(emailDomain: interfaces.data.IEmailDomain) { + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + + // Fetch required DNS records + let records: interfaces.data.IEmailDnsRecord[] = []; + try { + const response = await appstate.fetchEmailDomainDnsRecords(emailDomain.id); + records = response.records; + } catch { + records = []; + } + + DeesModal.createAndShow({ + heading: `DNS Records: ${emailDomain.domain}`, + content: html` + ({ + Type: r.type, + Name: r.name, + Value: r.value, + Status: html`${r.status}`, + })} + .dataActions=${[ + { + name: 'Copy Value', + iconName: 'lucide:copy', + type: ['inRow'] as any, + actionFunc: async (actionData: any) => { + const rec = actionData.item as interfaces.data.IEmailDnsRecord; + await navigator.clipboard.writeText(rec.value); + DeesToast.show({ message: 'Copied to clipboard', type: 'success', duration: 1500 }); + }, + }, + ]} + dataName="DNS record" + > + `, + menuOptions: [ + { + name: 'Auto-Provision All', + action: async (m: any) => { + await appstate.emailDomainsStatePart.dispatchAction( + appstate.provisionEmailDomainDnsAction, + emailDomain.id, + ); + DeesToast.show({ message: 'DNS records provisioned', type: 'success', duration: 2500 }); + m.destroy(); + }, + }, + { name: 'Close', action: async (m: any) => m.destroy() }, + ], + }); + } +} diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index a7a1df2..3797a85 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -32,6 +32,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js'; // Email group import { OpsViewEmails } from './email/ops-view-emails.js'; import { OpsViewEmailSecurity } from './email/ops-view-email-security.js'; +import { OpsViewEmailDomains } from './email/ops-view-email-domains.js'; // Access group import { OpsViewApiTokens } from './access/ops-view-apitokens.js'; @@ -108,6 +109,7 @@ export class OpsDashboard extends DeesElement { subViews: [ { slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails }, { slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity }, + { slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains }, ], }, { diff --git a/ts_web/router.ts b/ts_web/router.ts index 247bb29..5989824 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -10,7 +10,7 @@ const flatViews = ['logs'] as const; const subviewMap: Record = { overview: ['stats', 'configuration'] as const, network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const, - email: ['log', 'security'] as const, + email: ['log', 'security', 'domains'] as const, access: ['apitokens', 'users'] as const, security: ['overview', 'blocked', 'authentication'] as const, domains: ['providers', 'domains', 'dns', 'certificates'] as const,