feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-12 - 13.10.0 - feat(web-ui)
|
||||||
standardize settings views for ACME and email security panels
|
standardize settings views for ACME and email security panels
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.10.0',
|
version: '13.11.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
|||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||||
|
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** 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)
|
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||||
public acmeConfigManager?: AcmeConfigManager;
|
public acmeConfigManager?: AcmeConfigManager;
|
||||||
|
public emailDomainManager?: EmailDomainManager;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
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)
|
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||||
const smartProxyDeps: string[] = [];
|
const smartProxyDeps: string[] = [];
|
||||||
if (this.options.dbConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
|
|||||||
53
ts/db/documents/classes.email-domain.doc.ts
Normal file
53
ts/db/documents/classes.email-domain.doc.ts
Normal file
@@ -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<EmailDomainDoc, EmailDomainDoc> {
|
||||||
|
@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<EmailDomainDoc | null> {
|
||||||
|
return await EmailDomainDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||||
|
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||||
|
return await EmailDomainDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,3 +33,6 @@ export * from './classes.dns-record.doc.js';
|
|||||||
|
|
||||||
// ACME configuration (singleton)
|
// ACME configuration (singleton)
|
||||||
export * from './classes.acme-config.doc.js';
|
export * from './classes.acme-config.doc.js';
|
||||||
|
|
||||||
|
// Email domain management
|
||||||
|
export * from './classes.email-domain.doc.js';
|
||||||
|
|||||||
316
ts/email/classes.email-domain.manager.ts
Normal file
316
ts/email/classes.email-domain.manager.ts
Normal file
@@ -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<IEmailDomain[]> {
|
||||||
|
const docs = await EmailDomainDoc.findAll();
|
||||||
|
return docs.map((d) => this.docToInterface(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||||
|
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<IEmailDomain> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<IEmailDnsRecord[]> {
|
||||||
|
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<number> {
|
||||||
|
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<IEmailDnsRecord[]> {
|
||||||
|
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<TDnsRecordStatus> {
|
||||||
|
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<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';
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/email/index.ts
Normal file
1
ts/email/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.email-domain.manager.js';
|
||||||
@@ -37,6 +37,7 @@ export class OpsServer {
|
|||||||
private domainHandler!: handlers.DomainHandler;
|
private domainHandler!: handlers.DomainHandler;
|
||||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||||
|
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -104,6 +105,7 @@ export class OpsServer {
|
|||||||
this.domainHandler = new handlers.DomainHandler(this);
|
this.domainHandler = new handlers.DomainHandler(this);
|
||||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||||
|
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
194
ts/opsserver/handlers/email-domain.handler.ts
Normal file
194
ts/opsserver/handlers/email-domain.handler.ts
Normal file
@@ -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<string> {
|
||||||
|
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<interfaces.requests.IReq_GetEmailDomains>(
|
||||||
|
'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<interfaces.requests.IReq_GetEmailDomain>(
|
||||||
|
'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<interfaces.requests.IReq_CreateEmailDomain>(
|
||||||
|
'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<interfaces.requests.IReq_UpdateEmailDomain>(
|
||||||
|
'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<interfaces.requests.IReq_DeleteEmailDomain>(
|
||||||
|
'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<interfaces.requests.IReq_ValidateEmailDomain>(
|
||||||
|
'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<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
|
||||||
|
'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<interfaces.requests.IReq_ProvisionEmailDomainDns>(
|
||||||
|
'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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from './dns-provider.handler.js';
|
|||||||
export * from './domain.handler.js';
|
export * from './domain.handler.js';
|
||||||
export * from './dns-record.handler.js';
|
export * from './dns-record.handler.js';
|
||||||
export * from './acme-config.handler.js';
|
export * from './acme-config.handler.js';
|
||||||
|
export * from './email-domain.handler.js';
|
||||||
73
ts_interfaces/data/email-domain.ts
Normal file
73
ts_interfaces/data/email-domain.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export * from './dns-provider.js';
|
|||||||
export * from './domain.js';
|
export * from './domain.js';
|
||||||
export * from './dns-record.js';
|
export * from './dns-record.js';
|
||||||
export * from './acme-config.js';
|
export * from './acme-config.js';
|
||||||
|
export * from './email-domain.js';
|
||||||
176
ts_interfaces/requests/email-domains.ts
Normal file
176
ts_interfaces/requests/email-domains.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from './dns-providers.js';
|
|||||||
export * from './domains.js';
|
export * from './domains.js';
|
||||||
export * from './dns-records.js';
|
export * from './dns-records.js';
|
||||||
export * from './acme-config.js';
|
export * from './acme-config.js';
|
||||||
|
export * from './email-domains.js';
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.10.0',
|
version: '13.11.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<IEmailDomainsState>(
|
||||||
|
'emailDomains',
|
||||||
|
{
|
||||||
|
domains: [],
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IEmailDomainsState> => {
|
||||||
|
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<string>(
|
||||||
|
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<string>(
|
||||||
|
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<string>(
|
||||||
|
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
|
// TypedSocket Client for Real-time Log Streaming
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './ops-view-emails.js';
|
export * from './ops-view-emails.js';
|
||||||
export * from './ops-view-email-security.js';
|
export * from './ops-view-email-security.js';
|
||||||
|
export * from './ops-view-email-domains.js';
|
||||||
|
|||||||
389
ts_web/elements/email/ops-view-email-domains.ts
Normal file
389
ts_web/elements/email/ops-view-email-domains.ts
Normal file
@@ -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`
|
||||||
|
<dees-heading level="3">Email Domains</dees-heading>
|
||||||
|
|
||||||
|
<div class="emailDomainsContainer">
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${tiles}
|
||||||
|
.minTileWidth=${200}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:RefreshCw',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchEmailDomainsAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Email Domains'}
|
||||||
|
.heading2=${'DKIM, SPF, DMARC and MX management'}
|
||||||
|
.data=${domains}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(d: interfaces.data.IEmailDomain) => ({
|
||||||
|
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"
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDnsStatus(status: interfaces.data.TDnsRecordStatus): TemplateResult {
|
||||||
|
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSourceBadge(linkedDomainId: string): TemplateResult {
|
||||||
|
const domain = this.domainsState.domains.find((d) => d.id === linkedDomainId);
|
||||||
|
if (!domain) return html`<span class="sourceBadge">unknown</span>`;
|
||||||
|
const label =
|
||||||
|
domain.source === 'dcrouter'
|
||||||
|
? 'dcrouter'
|
||||||
|
: this.domainsState.providers.find((p) => p.id === domain.providerId)?.name || 'provider';
|
||||||
|
return html`<span class="sourceBadge">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'linkedDomainId'}
|
||||||
|
.label=${'Domain'}
|
||||||
|
.description=${'Select an existing DNS domain'}
|
||||||
|
.options=${domainOptions}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'dkimSelector'}
|
||||||
|
.label=${'DKIM Selector'}
|
||||||
|
.description=${'Identifier used in DNS record name'}
|
||||||
|
.value=${'default'}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'dkimKeySize'}
|
||||||
|
.label=${'DKIM Key Size'}
|
||||||
|
.options=${[
|
||||||
|
{ option: '2048 (recommended)', key: '2048' },
|
||||||
|
{ option: '1024', key: '1024' },
|
||||||
|
{ option: '4096', key: '4096' },
|
||||||
|
]}
|
||||||
|
.selectedOption=${{ option: '2048 (recommended)', key: '2048' }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'rotateKeys'}
|
||||||
|
.label=${'Auto-rotate DKIM keys'}
|
||||||
|
.value=${false}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
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`
|
||||||
|
<dees-table
|
||||||
|
.data=${records}
|
||||||
|
.displayFunction=${(r: interfaces.data.IEmailDnsRecord) => ({
|
||||||
|
Type: r.type,
|
||||||
|
Name: r.name,
|
||||||
|
Value: r.value,
|
||||||
|
Status: html`<span class="statusBadge ${r.status}">${r.status}</span>`,
|
||||||
|
})}
|
||||||
|
.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"
|
||||||
|
></dees-table>
|
||||||
|
`,
|
||||||
|
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() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js';
|
|||||||
// Email group
|
// Email group
|
||||||
import { OpsViewEmails } from './email/ops-view-emails.js';
|
import { OpsViewEmails } from './email/ops-view-emails.js';
|
||||||
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||||
|
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
|
||||||
|
|
||||||
// Access group
|
// Access group
|
||||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||||
@@ -108,6 +109,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
subViews: [
|
subViews: [
|
||||||
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
||||||
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
||||||
|
{ slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const flatViews = ['logs'] as const;
|
|||||||
const subviewMap: Record<string, readonly string[]> = {
|
const subviewMap: Record<string, readonly string[]> = {
|
||||||
overview: ['stats', 'configuration'] as const,
|
overview: ['stats', 'configuration'] as const,
|
||||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] 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,
|
access: ['apitokens', 'users'] as const,
|
||||||
security: ['overview', 'blocked', 'authentication'] as const,
|
security: ['overview', 'blocked', 'authentication'] as const,
|
||||||
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user