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() },
],
});
}
}