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