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-smtp-accounts': OpsViewSmtpAccounts;
}
}
@customElement('ops-view-smtp-accounts')
export class OpsViewSmtpAccounts extends DeesElement {
@state()
accessor smtpAccountsState: appstate.ISmtpAccountsState =
appstate.smtpAccountsStatePart.getState()!;
constructor() {
super();
const sub = appstate.smtpAccountsStatePart.select().subscribe((s) => {
this.smtpAccountsState = s;
});
this.rxSubscriptions.push(sub);
// Re-fetch when the user logs in (the view can be created before
// authentication completes).
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.smtpAccountsContainer {
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.active {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.scopePill {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
color: ${cssManager.bdTheme('#0369a1', '#0af')};
margin-right: 4px;
margin-bottom: 2px;
}
.scopePill.unrestricted {
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
}
.readinessWarning {
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
font-size: 11px;
}
`,
];
public render(): TemplateResult {
const accounts = this.smtpAccountsState.accounts;
const enabledCount = accounts.filter((account) => account.enabled).length;
const dkimCount = accounts.filter((account) => account.mailPolicy.dkimSign).length;
const restrictedRecipientsCount = accounts.filter(
(account) => account.recipientScope.mode === 'restricted',
).length;
const tiles: IStatsTile[] = [
{
id: 'total',
title: 'Accounts',
value: accounts.length,
type: 'number',
icon: 'lucide:keyRound',
color: '#3b82f6',
},
{
id: 'enabled',
title: 'Enabled',
value: enabledCount,
type: 'number',
icon: 'lucide:check',
color: '#22c55e',
},
{
id: 'dkim',
title: 'DKIM Signing',
value: dkimCount,
type: 'number',
icon: 'lucide:penTool',
color: '#8b5cf6',
},
{
id: 'restricted',
title: 'Recipient-Restricted',
value: restrictedRecipientsCount,
type: 'number',
icon: 'lucide:shieldCheck',
color: '#0ea5e9',
},
];
return html`
SMTP Accounts
{
await appstate.smtpAccountsStatePart.dispatchAction(
appstate.fetchSmtpAccountsAction,
null,
);
},
},
]}
>
({
Username: account.username,
Description: account.description || '—',
'Send AS': this.renderSenderScope(account),
'Send TO': this.renderRecipientScope(account),
DKIM: account.mailPolicy.dkimSign ? 'signing' : 'off',
Status: this.renderStatusBadge(account),
Rotated: account.lastRotatedAt
? new Date(account.lastRotatedAt).toLocaleDateString()
: 'never',
})}
.dataActions=${[
{
name: 'Add Account',
iconName: 'lucide:plus',
type: ['header'] as any,
actionFunc: async () => {
await this.showCreateDialog();
},
},
{
name: 'Edit Scopes',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu', 'doubleClick'] as any,
actionFunc: async (actionData: any) => {
const account = actionData.item as interfaces.data.ISmtpAccountInfo;
await this.showEditDialog(account);
},
},
{
name: 'Rotate Password',
iconName: 'lucide:rotateCw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const account = actionData.item as interfaces.data.ISmtpAccountInfo;
await this.showRotateDialog(account);
},
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (account: any) => !account.enabled,
actionFunc: async (actionData: any) => {
const account = actionData.item as interfaces.data.ISmtpAccountInfo;
await appstate.smtpAccountsStatePart.dispatchAction(
appstate.toggleSmtpAccountAction,
{ id: account.id, enabled: true },
);
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (account: any) => account.enabled,
actionFunc: async (actionData: any) => {
const account = actionData.item as interfaces.data.ISmtpAccountInfo;
await appstate.smtpAccountsStatePart.dispatchAction(
appstate.toggleSmtpAccountAction,
{ id: account.id, enabled: false },
);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const account = actionData.item as interfaces.data.ISmtpAccountInfo;
await this.showDeleteDialog(account);
},
},
]}
>
`;
}
private renderStatusBadge(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
return account.enabled
? html`Active`
: html`Disabled`;
}
private renderSenderScope(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
const patterns = [
...account.senderScope.addresses,
...account.senderScope.domains.map((domain) => `*@${domain}`),
];
if (patterns.length === 0) {
return html`unrestricted`;
}
const notReady = (account.domainReadiness || []).filter((entry) => !entry.ready);
return html`
${patterns.map((pattern) => html`${pattern}`)}
${notReady.length > 0
? html` `${entry.domain}: ${entry.reason || 'not ready'}`).join('\n')}>
${notReady.length} domain${notReady.length > 1 ? 's' : ''} not ready
`
: ''}
`;
}
private renderRecipientScope(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
if (account.recipientScope.mode !== 'restricted') {
return html`any`;
}
const patterns = [
...account.recipientScope.addresses,
...account.recipientScope.domains.map((domain) => `*@${domain}`),
];
return html`
${patterns.map((pattern) => html`${pattern}`)}
`;
}
private parseTagsValue(form: any, key: string, formData: any): string[] {
// dees-input-tags historically was not part of dees-form's collected input
// types (see ops-view-apitokens.ts); read the input directly with a
// formData fallback so both catalog generations work.
const tagsInput = form.querySelector(`dees-input-tags[key="${key}"]`)
|| [...form.querySelectorAll('dees-input-tags')].find((el: any) => el.key === key);
const raw = (tagsInput as any)?.getValue?.() ?? (tagsInput as any)?.value ?? formData[key] ?? [];
return (Array.isArray(raw) ? raw : [])
.map((value: string) => String(value).trim())
.filter(Boolean);
}
private async showCreateDialog() {
await this.showAccountForm(null);
}
private async showEditDialog(account: interfaces.data.ISmtpAccountInfo) {
await this.showAccountForm(account);
}
private async showAccountForm(account: interfaces.data.ISmtpAccountInfo | null) {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const isEdit = account !== null;
await DeesModal.createAndShow({
heading: isEdit ? `Edit SMTP Account: ${account!.username}` : 'Create SMTP Account',
content: html`
${isEdit
? ''
: html`
The password is machine-generated and shown once after creation. Copy it immediately.
`}
${isEdit
? ''
: html``}
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: isEdit ? 'Save' : 'Create',
iconName: isEdit ? 'lucide:check' : 'lucide:keyRound',
action: async (modalArg: any) => {
const contentEl = modalArg.shadowRoot?.querySelector('.content');
const form = contentEl?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
const senderScope: interfaces.data.ISmtpAccountSenderScope = {
addresses: this.parseTagsValue(form, 'senderAddresses', formData),
domains: this.parseTagsValue(form, 'senderDomains', formData),
};
const dropdown = form.querySelector('dees-input-dropdown') as any;
const recipientMode = (dropdown?.selectedOption?.key || formData.recipientMode || 'any') === 'restricted'
? 'restricted' as const
: 'any' as const;
// Lists are kept for both modes; the backend only enforces them
// when mode is 'restricted'.
const recipientScope: interfaces.data.ISmtpAccountRecipientScope = {
mode: recipientMode,
addresses: this.parseTagsValue(form, 'recipientAddresses', formData),
domains: this.parseTagsValue(form, 'recipientDomains', formData),
};
// The form does not expose the queue policy; keep an existing
// API-set queue instead of silently resetting it on edit.
const mailPolicy: interfaces.data.ISmtpAccountMailPolicy = {
dkimSign: Boolean(formData.dkimSign),
...(account?.mailPolicy.queue ? { queue: account.mailPolicy.queue } : {}),
};
try {
if (isEdit) {
const response = await appstate.updateSmtpAccount({
id: account!.id,
description: String(formData.description || ''),
senderScope,
recipientScope,
mailPolicy,
});
if (!response.success) {
DeesToast.show({ message: response.message || 'Failed to update SMTP account', type: 'error', duration: 6000 });
return;
}
await modalArg.destroy();
this.showWarningsToast(response.warnings);
DeesToast.show({ message: `SMTP account ${account!.username} updated`, type: 'success', duration: 3000 });
await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
} else {
const username = String(formData.username || '').trim();
if (!username) return;
const response = await appstate.createSmtpAccount({
username,
description: String(formData.description || ''),
senderScope,
recipientScope,
mailPolicy,
});
if (!response.success || !response.password) {
DeesToast.show({ message: response.message || 'Failed to create SMTP account', type: 'error', duration: 6000 });
return;
}
await modalArg.destroy();
this.showWarningsToast(response.warnings);
await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
await this.showPasswordOnceModal(
'SMTP Account Created',
response.account?.username || username,
response.password,
);
}
} catch (error) {
console.error('SMTP account form submit failed:', error);
DeesToast.show({
message: error instanceof Error ? error.message : 'SMTP account request failed',
type: 'error',
duration: 6000,
});
}
},
},
],
});
}
private showWarningsToast(warnings?: string[]) {
if (!warnings?.length) return;
void import('@design.estate/dees-catalog').then(({ DeesToast }) => {
for (const warning of warnings) {
DeesToast.show({ message: warning, type: 'warning', duration: 6000 });
}
});
}
private async showRotateDialog(account: interfaces.data.ISmtpAccountInfo) {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Rotate SMTP Password',
content: html`
This will generate a new password for ${account.username}. The old password stops working immediately.
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Rotate Password',
iconName: 'lucide:rotateCw',
action: async (modalArg: any) => {
await modalArg.destroy();
try {
const response = await appstate.rotateSmtpAccountPassword(account.id);
if (response.success && response.password) {
await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
await this.showPasswordOnceModal('Password Rotated', account.username, response.password);
} else {
DeesToast.show({ message: response.message || 'Failed to rotate password', type: 'error', duration: 6000 });
}
} catch (error) {
console.error('Failed to rotate SMTP account password:', error);
}
},
},
],
});
}
private async showPasswordOnceModal(heading: string, username: string, password: string) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading,
content: html`
Copy this password now. It will not be shown again.
Username
${username}
Password
${password}
`,
menuOptions: [
{
name: 'Copy Password',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(password);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: 'Password copied to clipboard', type: 'success', duration: 2500 });
},
},
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
private async showDeleteDialog(account: interfaces.data.ISmtpAccountInfo) {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Delete SMTP Account',
content: html`
Delete ${account.username}? Clients using this account will stop authenticating immediately.
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await modalArg.destroy();
const nextState = await appstate.smtpAccountsStatePart.dispatchAction(
appstate.deleteSmtpAccountAction,
account.id,
);
DeesToast.show({
message: nextState.error || `SMTP account ${account.username} deleted`,
type: nextState.error ? 'error' : 'success',
duration: nextState.error ? 6000 : 3000,
});
},
},
],
});
}
}