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 IStep, type IStatsTile } from '@design.estate/dees-catalog'; import { findNewEmailDomain, getEmailDomainCreationProgress, observeNewEmailDomain, withAbortableDeadline, } from './email-domain-creation-progress.js'; 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 settings = this.emailDomainsState.settings; 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: 'server', title: 'Server', value: settings?.enabled ? 'enabled' : 'disabled', type: 'text', icon: 'lucide:mail-check', color: settings?.enabled ? '#22c55e' : '#6b7280', }, { id: 'ports', title: 'SMTP Ports', value: settings?.ports?.join(', ') || 'none', type: 'text', icon: 'lucide:plug', color: '#0ea5e9', }, { 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, ); }, }, { name: 'Settings', iconName: 'lucide:settings', action: async () => { await this.showSettingsDialog(); }, }, ]} > ({ 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; const nextState = await appstate.emailDomainsStatePart.dispatchAction( appstate.validateEmailDomainAction, d.id, ); await this.showOperationToast(nextState, `DNS validated for ${d.domain}`); }, }, { name: 'Provision DNS', iconName: 'lucide:wand-sparkles', type: ['inRow', 'contextmenu'] as any, actionFunc: async (actionData: any) => { const d = actionData.item as interfaces.data.IEmailDomain; const nextState = await appstate.emailDomainsStatePart.dispatchAction( appstate.provisionEmailDomainDnsAction, d.id, ); await this.showOperationToast(nextState, `DNS records provisioned for ${d.domain}`); }, }, { 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; const nextState = await appstate.emailDomainsStatePart.dispatchAction( appstate.deleteEmailDomainAction, d.id, ); await this.showOperationToast(nextState, `Email domain ${d.domain} deleted`); }, }, ]} 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 showOperationToast( stateArg: appstate.IEmailDomainsState, successMessageArg: string, ): Promise { const { DeesToast } = await import('@design.estate/dees-catalog'); const operation = stateArg.lastOperation; if (!operation) { DeesToast.show({ message: stateArg.error || 'Email domain operation returned no result', type: 'error', duration: 5000, }); return false; } const isPending = operation.success && operation.lifecycleStatus !== undefined && operation.lifecycleStatus !== 'active'; DeesToast.show({ message: appstate.formatEmailDomainOperationMessage(operation, successMessageArg), type: operation.success ? (isPending ? 'warning' : 'success') : 'error', duration: operation.success && !isPending ? 3000 : 6000, }); return operation.success; } private parsePortList(value: string): number[] { return value .split(',') .map((part) => Number.parseInt(part.trim(), 10)) .filter((port) => Number.isInteger(port)); } private parsePortMapping(value: string): Record | null { const trimmed = value.trim(); if (!trimmed) return null; const mapping: Record = {}; for (const pair of trimmed.split(',')) { const [externalPort, internalPort] = pair .split(':') .map((part) => Number.parseInt(part.trim(), 10)); if (Number.isInteger(externalPort) && Number.isInteger(internalPort)) { mapping[externalPort] = internalPort; } } return Object.keys(mapping).length > 0 ? mapping : null; } private formatPortMapping(mapping: Record | null | undefined): string { if (!mapping) return ''; return Object.entries(mapping) .map(([externalPort, internalPort]) => `${externalPort}:${internalPort}`) .join(', '); } private async showSettingsDialog() { const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); const settings = this.emailDomainsState.settings; DeesModal.createAndShow({ heading: 'Email Server Settings', content: html` `, menuOptions: [ { name: 'Cancel', action: async (m: any) => m.destroy() }, { name: 'Save', action: async (m: any) => { const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); if (!form) return; const data = await form.collectFormData(); const maxMessageSizeRaw = String(data.maxMessageSize || '').trim(); await appstate.emailDomainsStatePart.dispatchAction( appstate.updateEmailServerSettingsAction, { enabled: Boolean(data.enabled), hostname: String(data.hostname || '').trim() || null, outboundMode: Boolean(data.outboundRemoteIngress) ? 'remoteIngress' : 'direct', ports: this.parsePortList(String(data.ports || '')), portMapping: this.parsePortMapping(String(data.portMapping || '')), maxMessageSize: maxMessageSizeRaw ? Number.parseInt(maxMessageSizeRaw, 10) : null, receivedEmailsPath: String(data.receivedEmailsPath || '').trim() || null, }, ); DeesToast.show({ message: 'Email settings saved', type: 'success', duration: 2500 }); m.destroy(); }, }, ], }); } private async showCreateDialog() { const { DeesStepper } = await import('@design.estate/dees-catalog'); const domainOptions = this.domainsState.domains.map((domain) => ({ option: `${domain.name} (${domain.source})`, key: domain.id, })); const initialProgress = getEmailDomainCreationProgress({ requestCompleted: false, }); let createArgs: { linkedDomainId: string; subdomain?: string; dkimSelector?: string; dkimKeySize?: number; rotateKeys?: boolean; } | undefined; let expectedDomain: string | undefined; let progressStepDefinition: IStep; progressStepDefinition = { title: 'Create Email Domain', allowBack: false, content: html`

dcrouter is creating the domain, preparing its managed DNS plan, and reconciling the records with the linked DNS provider. The final state and any scheduled retry remain visible here.

`, progressStep: { ...initialProgress, label: 'Email domain setup', statusRows: 8, autoAdvance: false, }, menuOptions: [], validationFunc: async (stepper, _selectedStep, signal) => { let observedDomain: interfaces.data.IEmailDomain | undefined; let observationError: Error | undefined; let observationPromise: Promise | undefined; const observationController = new AbortController(); const abortObservation = () => observationController.abort(); signal?.addEventListener('abort', abortObservation, { once: true }); const renderProgress = ( domainArg: interfaces.data.IEmailDomain | undefined, operationArg: interfaces.requests.IEmailDomainActionResult | null, requestCompletedArg: boolean, ) => { if (signal?.aborted || !stepper.isConnected) return; stepper.updateProgressStep({ ...getEmailDomainCreationProgress({ domain: domainArg, operation: operationArg, requestCompleted: requestCompletedArg, }), statusRows: 8, }); }; try { if (!createArgs || !expectedDomain) { throw new Error('Email domain configuration is incomplete'); } stepper.updateProgressStep({ percentage: 0, indeterminate: true, showPercentage: false, statusText: 'Checking the current email-domain state...', terminalLines: ['Checking for an existing email domain'], statusRows: 8, }); const initialDomains = await appstate.fetchEmailDomainsSnapshot(); if (signal?.aborted) return; const existingIds = new Set( initialDomains .filter((domain) => domain.domain.toLowerCase() === expectedDomain) .map((domain) => domain.id), ); renderProgress(undefined, null, false); observationPromise = observeNewEmailDomain({ expectedDomain, existingIds, signal: observationController.signal, listDomains: appstate.fetchEmailDomainsSnapshot, getDomain: appstate.fetchEmailDomainSnapshot, onDomain: (domain) => { observedDomain = domain; renderProgress(domain, null, false); }, subscribeToDomains: (listenerArg) => { const subscription = appstate.emailDomainsStatePart .select((state) => state.domains) .subscribe(listenerArg); return () => subscription.unsubscribe(); }, timeoutMs: 15 * 60_000, }); void observationPromise.then((domain) => { if (domain) observedDomain = domain; }).catch((error: unknown) => { observationError = error instanceof Error ? error : new Error('Failed to observe the new email domain'); }); const createPromise = appstate.emailDomainsStatePart.dispatchAction( appstate.createEmailDomainAction, createArgs, ); const nextState = await withAbortableDeadline(createPromise, { signal, timeoutMs: 120_000, timeoutMessage: 'Email domain creation timed out. The server operation may still complete; refresh the domain list before retrying.', }); const operation = nextState.lastOperation; const finalDomain = operation?.domain || observedDomain || findNewEmailDomain(nextState.domains, expectedDomain, existingIds); if (!finalDomain && observationError) { throw observationError; } renderProgress(finalDomain, operation, true); if (!signal?.aborted && stepper.isConnected) { progressStepDefinition.menuOptions = [ { name: 'Close', action: async (stepperArg) => stepperArg?.destroy(), }, ]; stepper.steps = [...stepper.steps]; } const lifecycleStatus = finalDomain?.reconciliation?.lifecycleStatus; if ( observationPromise && lifecycleStatus !== 'active' && lifecycleStatus !== 'failed' ) { const terminalDomain = await observationPromise; if (terminalDomain) { observedDomain = terminalDomain; renderProgress(terminalDomain, operation, true); } } } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Email domain creation failed'; renderProgress( observedDomain, { success: false, message, code: 'EMAIL_DOMAIN_CREATE_PROGRESS_FAILED', errors: [{ code: 'EMAIL_DOMAIN_CREATE_PROGRESS_FAILED', message, retryable: true, }], }, true, ); } finally { observationController.abort(); signal?.removeEventListener('abort', abortObservation); if (!signal?.aborted && stepper.isConnected) { progressStepDefinition.menuOptions = [ { name: 'Close', action: async (stepperArg) => stepperArg?.destroy(), }, ]; stepper.steps = [...stepper.steps]; } } }, }; await DeesStepper.createAndShow({ cancelable: false, steps: [ { title: 'Add Email Domain', content: html` `, menuOptions: [ { name: 'Cancel', action: async (stepperArg) => stepperArg?.destroy(), }, { name: 'Create', action: async (stepperArg) => { if (!stepperArg) return; const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any; if (!form) return; const data = await form.collectFormData(); const linkedDomainId = String( typeof data.linkedDomainId === 'object' ? data.linkedDomainId.key : data.linkedDomainId || '', ); const linkedDomain = this.domainsState.domains.find( (domain) => domain.id === linkedDomainId, ); if (!linkedDomain) { form.setStatus?.('error', 'Select an existing DNS domain.'); return; } const keySize = Number.parseInt( String( typeof data.dkimKeySize === 'object' ? data.dkimKeySize.key : data.dkimKeySize || '2048', ), 10, ); const subdomain = String(data.subdomain || '').trim().toLowerCase() || undefined; expectedDomain = ( subdomain ? `${subdomain}.${linkedDomain.name}` : linkedDomain.name ).toLowerCase(); createArgs = { linkedDomainId, subdomain, dkimSelector: String(data.dkimSelector || 'default').trim() || 'default', dkimKeySize: keySize, rotateKeys: Boolean(data.rotateKeys), }; stepperArg.goNext(); }, }, ], }, progressStepDefinition, ], }); } 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) => { const nextState = await appstate.emailDomainsStatePart.dispatchAction( appstate.provisionEmailDomainDnsAction, emailDomain.id, ); const wasAccepted = await this.showOperationToast( nextState, `DNS records provisioned for ${emailDomain.domain}`, ); if (wasAccepted) m.destroy(); }, }, { name: 'Close', action: async (m: any) => m.destroy() }, ], }); } }