2026-04-12 22:09:20 +00:00
|
|
|
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>
|
2026-04-12 23:46:31 +00:00
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'subdomain'}
|
|
|
|
|
.label=${'Subdomain'}
|
|
|
|
|
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
|
|
|
|
|
></dees-input-text>
|
2026-04-12 22:09:20 +00:00
|
|
|
<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);
|
|
|
|
|
|
2026-04-12 23:46:31 +00:00
|
|
|
const subdomain = data.subdomain?.trim() || undefined;
|
2026-04-12 22:09:20 +00:00
|
|
|
await appstate.emailDomainsStatePart.dispatchAction(
|
|
|
|
|
appstate.createEmailDomainAction,
|
|
|
|
|
{
|
|
|
|
|
linkedDomainId,
|
2026-04-12 23:46:31 +00:00
|
|
|
subdomain,
|
2026-04-12 22:09:20 +00:00
|
|
|
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() },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|