@serve.zone/dcrouter 18.0.1 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1522 -1375
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +3 -1
  5. package/dist_ts/classes.dcrouter.js +32 -4
  6. package/dist_ts/db/documents/classes.smtp-account.doc.d.ts +31 -0
  7. package/dist_ts/db/documents/classes.smtp-account.doc.js +176 -0
  8. package/dist_ts/db/documents/index.d.ts +1 -0
  9. package/dist_ts/db/documents/index.js +2 -1
  10. package/dist_ts/email/classes.email-settings.manager.d.ts +7 -0
  11. package/dist_ts/email/classes.email-settings.manager.js +30 -1
  12. package/dist_ts/email/classes.smtp-account.manager.d.ts +110 -0
  13. package/dist_ts/email/classes.smtp-account.manager.js +507 -0
  14. package/dist_ts/email/classes.workapp-mail-manager.d.ts +23 -4
  15. package/dist_ts/email/classes.workapp-mail-manager.js +39 -46
  16. package/dist_ts/email/index.d.ts +2 -0
  17. package/dist_ts/email/index.js +3 -1
  18. package/dist_ts/email/smtp-scram.d.ts +13 -0
  19. package/dist_ts/email/smtp-scram.js +19 -0
  20. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  21. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  22. package/dist_ts/opsserver/handlers/email-settings.handler.js +14 -2
  23. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  24. package/dist_ts/opsserver/handlers/index.js +2 -1
  25. package/dist_ts/opsserver/handlers/smtp-account.handler.d.ts +20 -0
  26. package/dist_ts/opsserver/handlers/smtp-account.handler.js +155 -0
  27. package/dist_ts_interfaces/data/email-settings.d.ts +10 -0
  28. package/dist_ts_interfaces/data/index.d.ts +1 -0
  29. package/dist_ts_interfaces/data/index.js +2 -1
  30. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  31. package/dist_ts_interfaces/data/route-management.js +3 -1
  32. package/dist_ts_interfaces/data/smtp-account.d.ts +50 -0
  33. package/dist_ts_interfaces/data/smtp-account.js +9 -0
  34. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  35. package/dist_ts_interfaces/requests/index.js +2 -1
  36. package/dist_ts_interfaces/requests/smtp-accounts.d.ts +125 -0
  37. package/dist_ts_interfaces/requests/smtp-accounts.js +2 -0
  38. package/dist_ts_migrations/index.js +198 -9
  39. package/dist_ts_web/00_commitinfo_data.js +1 -1
  40. package/dist_ts_web/appstate/smtp-accounts.d.ts +38 -0
  41. package/dist_ts_web/appstate/smtp-accounts.js +94 -0
  42. package/dist_ts_web/appstate.d.ts +1 -0
  43. package/dist_ts_web/appstate.js +2 -1
  44. package/dist_ts_web/elements/email/index.d.ts +1 -0
  45. package/dist_ts_web/elements/email/index.js +2 -1
  46. package/dist_ts_web/elements/email/ops-view-smtp-accounts.d.ts +25 -0
  47. package/dist_ts_web/elements/email/ops-view-smtp-accounts.js +598 -0
  48. package/dist_ts_web/elements/ops-dashboard.js +3 -1
  49. package/dist_ts_web/router.js +2 -2
  50. package/package.json +2 -2
  51. package/readme.md +12 -0
  52. package/ts/00_commitinfo_data.ts +1 -1
  53. package/ts/classes.dcrouter.ts +35 -3
  54. package/ts/db/documents/classes.smtp-account.doc.ts +103 -0
  55. package/ts/db/documents/index.ts +1 -0
  56. package/ts/email/classes.email-settings.manager.ts +34 -0
  57. package/ts/email/classes.smtp-account.manager.ts +601 -0
  58. package/ts/email/classes.workapp-mail-manager.ts +53 -55
  59. package/ts/email/index.ts +2 -0
  60. package/ts/email/smtp-scram.ts +24 -0
  61. package/ts/opsserver/classes.opsserver.ts +2 -0
  62. package/ts/opsserver/handlers/email-settings.handler.ts +13 -1
  63. package/ts/opsserver/handlers/index.ts +1 -0
  64. package/ts/opsserver/handlers/smtp-account.handler.ts +199 -0
  65. package/ts_web/00_commitinfo_data.ts +1 -1
  66. package/ts_web/appstate/smtp-accounts.ts +143 -0
  67. package/ts_web/appstate.ts +1 -0
  68. package/ts_web/elements/email/index.ts +1 -0
  69. package/ts_web/elements/email/ops-view-smtp-accounts.ts +586 -0
  70. package/ts_web/elements/ops-dashboard.ts +2 -0
  71. package/ts_web/router.ts +1 -1
@@ -0,0 +1,586 @@
1
+ import {
2
+ DeesElement,
3
+ html,
4
+ customElement,
5
+ type TemplateResult,
6
+ css,
7
+ state,
8
+ cssManager,
9
+ } from '@design.estate/dees-element';
10
+ import * as appstate from '../../appstate.js';
11
+ import * as interfaces from '../../../dist_ts_interfaces/index.js';
12
+ import { viewHostCss } from '../shared/css.js';
13
+ import { type IStatsTile } from '@design.estate/dees-catalog';
14
+
15
+ declare global {
16
+ interface HTMLElementTagNameMap {
17
+ 'ops-view-smtp-accounts': OpsViewSmtpAccounts;
18
+ }
19
+ }
20
+
21
+ @customElement('ops-view-smtp-accounts')
22
+ export class OpsViewSmtpAccounts extends DeesElement {
23
+ @state()
24
+ accessor smtpAccountsState: appstate.ISmtpAccountsState =
25
+ appstate.smtpAccountsStatePart.getState()!;
26
+
27
+ constructor() {
28
+ super();
29
+ const sub = appstate.smtpAccountsStatePart.select().subscribe((s) => {
30
+ this.smtpAccountsState = s;
31
+ });
32
+ this.rxSubscriptions.push(sub);
33
+ // Re-fetch when the user logs in (the view can be created before
34
+ // authentication completes).
35
+ const loginSub = appstate.loginStatePart
36
+ .select((s) => s.isLoggedIn)
37
+ .subscribe((isLoggedIn) => {
38
+ if (isLoggedIn) {
39
+ appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
40
+ }
41
+ });
42
+ this.rxSubscriptions.push(loginSub);
43
+ }
44
+
45
+ async connectedCallback() {
46
+ await super.connectedCallback();
47
+ await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
48
+ }
49
+
50
+ public static styles = [
51
+ cssManager.defaultStyles,
52
+ viewHostCss,
53
+ css`
54
+ .smtpAccountsContainer {
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: 24px;
58
+ }
59
+
60
+ .statusBadge {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ padding: 3px 10px;
64
+ border-radius: 12px;
65
+ font-size: 12px;
66
+ font-weight: 600;
67
+ letter-spacing: 0.02em;
68
+ text-transform: uppercase;
69
+ }
70
+
71
+ .statusBadge.active {
72
+ background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
73
+ color: ${cssManager.bdTheme('#166534', '#4ade80')};
74
+ }
75
+
76
+ .statusBadge.disabled {
77
+ background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
78
+ color: ${cssManager.bdTheme('#991b1b', '#f87171')};
79
+ }
80
+
81
+ .scopePill {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ padding: 2px 6px;
85
+ border-radius: 3px;
86
+ font-size: 11px;
87
+ background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
88
+ color: ${cssManager.bdTheme('#0369a1', '#0af')};
89
+ margin-right: 4px;
90
+ margin-bottom: 2px;
91
+ }
92
+
93
+ .scopePill.unrestricted {
94
+ background: ${cssManager.bdTheme('#fff7ed', '#431407')};
95
+ color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
96
+ }
97
+
98
+ .readinessWarning {
99
+ color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
100
+ font-size: 11px;
101
+ }
102
+ `,
103
+ ];
104
+
105
+ public render(): TemplateResult {
106
+ const accounts = this.smtpAccountsState.accounts;
107
+ const enabledCount = accounts.filter((account) => account.enabled).length;
108
+ const dkimCount = accounts.filter((account) => account.mailPolicy.dkimSign).length;
109
+ const restrictedRecipientsCount = accounts.filter(
110
+ (account) => account.recipientScope.mode === 'restricted',
111
+ ).length;
112
+
113
+ const tiles: IStatsTile[] = [
114
+ {
115
+ id: 'total',
116
+ title: 'Accounts',
117
+ value: accounts.length,
118
+ type: 'number',
119
+ icon: 'lucide:keyRound',
120
+ color: '#3b82f6',
121
+ },
122
+ {
123
+ id: 'enabled',
124
+ title: 'Enabled',
125
+ value: enabledCount,
126
+ type: 'number',
127
+ icon: 'lucide:check',
128
+ color: '#22c55e',
129
+ },
130
+ {
131
+ id: 'dkim',
132
+ title: 'DKIM Signing',
133
+ value: dkimCount,
134
+ type: 'number',
135
+ icon: 'lucide:penTool',
136
+ color: '#8b5cf6',
137
+ },
138
+ {
139
+ id: 'restricted',
140
+ title: 'Recipient-Restricted',
141
+ value: restrictedRecipientsCount,
142
+ type: 'number',
143
+ icon: 'lucide:shieldCheck',
144
+ color: '#0ea5e9',
145
+ },
146
+ ];
147
+
148
+ return html`
149
+ <dees-heading level="3">SMTP Accounts</dees-heading>
150
+
151
+ <div class="smtpAccountsContainer">
152
+ <dees-statsgrid
153
+ .tiles=${tiles}
154
+ .minTileWidth=${200}
155
+ .gridActions=${[
156
+ {
157
+ name: 'Refresh',
158
+ iconName: 'lucide:RefreshCw',
159
+ action: async () => {
160
+ await appstate.smtpAccountsStatePart.dispatchAction(
161
+ appstate.fetchSmtpAccountsAction,
162
+ null,
163
+ );
164
+ },
165
+ },
166
+ ]}
167
+ ></dees-statsgrid>
168
+
169
+ <dees-table
170
+ .heading1=${'SMTP Accounts'}
171
+ .heading2=${'Authenticated submission accounts with sender and recipient scopes'}
172
+ .data=${accounts}
173
+ .dataName=${'account'}
174
+ .searchable=${true}
175
+ .displayFunction=${(account: interfaces.data.ISmtpAccountInfo) => ({
176
+ Username: account.username,
177
+ Description: account.description || '—',
178
+ 'Send AS': this.renderSenderScope(account),
179
+ 'Send TO': this.renderRecipientScope(account),
180
+ DKIM: account.mailPolicy.dkimSign ? 'signing' : 'off',
181
+ Status: this.renderStatusBadge(account),
182
+ Rotated: account.lastRotatedAt
183
+ ? new Date(account.lastRotatedAt).toLocaleDateString()
184
+ : 'never',
185
+ })}
186
+ .dataActions=${[
187
+ {
188
+ name: 'Add Account',
189
+ iconName: 'lucide:plus',
190
+ type: ['header'] as any,
191
+ actionFunc: async () => {
192
+ await this.showCreateDialog();
193
+ },
194
+ },
195
+ {
196
+ name: 'Edit Scopes',
197
+ iconName: 'lucide:pencil',
198
+ type: ['inRow', 'contextmenu', 'doubleClick'] as any,
199
+ actionFunc: async (actionData: any) => {
200
+ const account = actionData.item as interfaces.data.ISmtpAccountInfo;
201
+ await this.showEditDialog(account);
202
+ },
203
+ },
204
+ {
205
+ name: 'Rotate Password',
206
+ iconName: 'lucide:rotateCw',
207
+ type: ['inRow', 'contextmenu'] as any,
208
+ actionFunc: async (actionData: any) => {
209
+ const account = actionData.item as interfaces.data.ISmtpAccountInfo;
210
+ await this.showRotateDialog(account);
211
+ },
212
+ },
213
+ {
214
+ name: 'Enable',
215
+ iconName: 'lucide:play',
216
+ type: ['inRow', 'contextmenu'] as any,
217
+ actionRelevancyCheckFunc: (account: any) => !account.enabled,
218
+ actionFunc: async (actionData: any) => {
219
+ const account = actionData.item as interfaces.data.ISmtpAccountInfo;
220
+ await appstate.smtpAccountsStatePart.dispatchAction(
221
+ appstate.toggleSmtpAccountAction,
222
+ { id: account.id, enabled: true },
223
+ );
224
+ },
225
+ },
226
+ {
227
+ name: 'Disable',
228
+ iconName: 'lucide:pause',
229
+ type: ['inRow', 'contextmenu'] as any,
230
+ actionRelevancyCheckFunc: (account: any) => account.enabled,
231
+ actionFunc: async (actionData: any) => {
232
+ const account = actionData.item as interfaces.data.ISmtpAccountInfo;
233
+ await appstate.smtpAccountsStatePart.dispatchAction(
234
+ appstate.toggleSmtpAccountAction,
235
+ { id: account.id, enabled: false },
236
+ );
237
+ },
238
+ },
239
+ {
240
+ name: 'Delete',
241
+ iconName: 'lucide:trash2',
242
+ type: ['inRow', 'contextmenu'] as any,
243
+ actionFunc: async (actionData: any) => {
244
+ const account = actionData.item as interfaces.data.ISmtpAccountInfo;
245
+ await this.showDeleteDialog(account);
246
+ },
247
+ },
248
+ ]}
249
+ ></dees-table>
250
+ </div>
251
+ `;
252
+ }
253
+
254
+ private renderStatusBadge(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
255
+ return account.enabled
256
+ ? html`<span class="statusBadge active">Active</span>`
257
+ : html`<span class="statusBadge disabled">Disabled</span>`;
258
+ }
259
+
260
+ private renderSenderScope(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
261
+ const patterns = [
262
+ ...account.senderScope.addresses,
263
+ ...account.senderScope.domains.map((domain) => `*@${domain}`),
264
+ ];
265
+ if (patterns.length === 0) {
266
+ return html`<span class="scopePill unrestricted">unrestricted</span>`;
267
+ }
268
+ const notReady = (account.domainReadiness || []).filter((entry) => !entry.ready);
269
+ return html`<div style="display: flex; flex-wrap: wrap; gap: 2px; align-items: center;">
270
+ ${patterns.map((pattern) => html`<span class="scopePill">${pattern}</span>`)}
271
+ ${notReady.length > 0
272
+ ? html`<span class="readinessWarning" title=${notReady.map((entry) => `${entry.domain}: ${entry.reason || 'not ready'}`).join('\n')}>
273
+ ${notReady.length} domain${notReady.length > 1 ? 's' : ''} not ready
274
+ </span>`
275
+ : ''}
276
+ </div>`;
277
+ }
278
+
279
+ private renderRecipientScope(account: interfaces.data.ISmtpAccountInfo): TemplateResult {
280
+ if (account.recipientScope.mode !== 'restricted') {
281
+ return html`<span class="scopePill">any</span>`;
282
+ }
283
+ const patterns = [
284
+ ...account.recipientScope.addresses,
285
+ ...account.recipientScope.domains.map((domain) => `*@${domain}`),
286
+ ];
287
+ return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">
288
+ ${patterns.map((pattern) => html`<span class="scopePill">${pattern}</span>`)}
289
+ </div>`;
290
+ }
291
+
292
+ private parseTagsValue(form: any, key: string, formData: any): string[] {
293
+ // dees-input-tags historically was not part of dees-form's collected input
294
+ // types (see ops-view-apitokens.ts); read the input directly with a
295
+ // formData fallback so both catalog generations work.
296
+ const tagsInput = form.querySelector(`dees-input-tags[key="${key}"]`)
297
+ || [...form.querySelectorAll('dees-input-tags')].find((el: any) => el.key === key);
298
+ const raw = (tagsInput as any)?.getValue?.() ?? (tagsInput as any)?.value ?? formData[key] ?? [];
299
+ return (Array.isArray(raw) ? raw : [])
300
+ .map((value: string) => String(value).trim())
301
+ .filter(Boolean);
302
+ }
303
+
304
+ private async showCreateDialog() {
305
+ await this.showAccountForm(null);
306
+ }
307
+
308
+ private async showEditDialog(account: interfaces.data.ISmtpAccountInfo) {
309
+ await this.showAccountForm(account);
310
+ }
311
+
312
+ private async showAccountForm(account: interfaces.data.ISmtpAccountInfo | null) {
313
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
314
+ const isEdit = account !== null;
315
+
316
+ await DeesModal.createAndShow({
317
+ heading: isEdit ? `Edit SMTP Account: ${account!.username}` : 'Create SMTP Account',
318
+ content: html`
319
+ ${isEdit
320
+ ? ''
321
+ : html`<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
322
+ The password is machine-generated and shown once after creation. Copy it immediately.
323
+ </div>`}
324
+ <dees-form>
325
+ ${isEdit
326
+ ? ''
327
+ : html`<dees-input-text
328
+ .key=${'username'}
329
+ .label=${'Username'}
330
+ .description=${'3-64 characters: a-z, 0-9, dot, underscore, dash'}
331
+ .required=${true}
332
+ ></dees-input-text>`}
333
+ <dees-input-text
334
+ .key=${'description'}
335
+ .label=${'Description'}
336
+ .value=${account?.description || ''}
337
+ ></dees-input-text>
338
+ <dees-input-tags
339
+ .key=${'senderAddresses'}
340
+ .label=${'Send AS: addresses'}
341
+ .description=${'Exact addresses this account may use as sender, e.g. billing@example.com'}
342
+ .value=${account?.senderScope.addresses || []}
343
+ ></dees-input-tags>
344
+ <dees-input-tags
345
+ .key=${'senderDomains'}
346
+ .label=${'Send AS: domains'}
347
+ .description=${'Whole domains this account may send as (*@domain). Leave both empty for unrestricted senders — then relay depends on configured routes.'}
348
+ .value=${account?.senderScope.domains || []}
349
+ ></dees-input-tags>
350
+ <dees-input-dropdown
351
+ .key=${'recipientMode'}
352
+ .label=${'Send TO'}
353
+ .options=${[
354
+ { option: 'Any recipient', key: 'any' },
355
+ { option: 'Restricted to listed recipients', key: 'restricted' },
356
+ ]}
357
+ .selectedOption=${account?.recipientScope.mode === 'restricted'
358
+ ? { option: 'Restricted to listed recipients', key: 'restricted' }
359
+ : { option: 'Any recipient', key: 'any' }}
360
+ ></dees-input-dropdown>
361
+ <dees-input-tags
362
+ .key=${'recipientAddresses'}
363
+ .label=${'Send TO: addresses'}
364
+ .description=${'Only used when Send TO is restricted'}
365
+ .value=${account?.recipientScope.addresses || []}
366
+ ></dees-input-tags>
367
+ <dees-input-tags
368
+ .key=${'recipientDomains'}
369
+ .label=${'Send TO: domains'}
370
+ .description=${'Only used when Send TO is restricted (*@domain)'}
371
+ .value=${account?.recipientScope.domains || []}
372
+ ></dees-input-tags>
373
+ <dees-input-checkbox
374
+ .key=${'dkimSign'}
375
+ .label=${'DKIM-sign outbound mail (requires every sender domain to be a managed, outbound-ready email domain)'}
376
+ .value=${account?.mailPolicy.dkimSign ?? false}
377
+ ></dees-input-checkbox>
378
+ </dees-form>
379
+ `,
380
+ menuOptions: [
381
+ {
382
+ name: 'Cancel',
383
+ iconName: 'lucide:x',
384
+ action: async (modalArg: any) => await modalArg.destroy(),
385
+ },
386
+ {
387
+ name: isEdit ? 'Save' : 'Create',
388
+ iconName: isEdit ? 'lucide:check' : 'lucide:keyRound',
389
+ action: async (modalArg: any) => {
390
+ const contentEl = modalArg.shadowRoot?.querySelector('.content');
391
+ const form = contentEl?.querySelector('dees-form');
392
+ if (!form) return;
393
+ const formData = await form.collectFormData();
394
+
395
+ const senderScope: interfaces.data.ISmtpAccountSenderScope = {
396
+ addresses: this.parseTagsValue(form, 'senderAddresses', formData),
397
+ domains: this.parseTagsValue(form, 'senderDomains', formData),
398
+ };
399
+ const dropdown = form.querySelector('dees-input-dropdown') as any;
400
+ const recipientMode = (dropdown?.selectedOption?.key || formData.recipientMode || 'any') === 'restricted'
401
+ ? 'restricted' as const
402
+ : 'any' as const;
403
+ // Lists are kept for both modes; the backend only enforces them
404
+ // when mode is 'restricted'.
405
+ const recipientScope: interfaces.data.ISmtpAccountRecipientScope = {
406
+ mode: recipientMode,
407
+ addresses: this.parseTagsValue(form, 'recipientAddresses', formData),
408
+ domains: this.parseTagsValue(form, 'recipientDomains', formData),
409
+ };
410
+ // The form does not expose the queue policy; keep an existing
411
+ // API-set queue instead of silently resetting it on edit.
412
+ const mailPolicy: interfaces.data.ISmtpAccountMailPolicy = {
413
+ dkimSign: Boolean(formData.dkimSign),
414
+ ...(account?.mailPolicy.queue ? { queue: account.mailPolicy.queue } : {}),
415
+ };
416
+
417
+ try {
418
+ if (isEdit) {
419
+ const response = await appstate.updateSmtpAccount({
420
+ id: account!.id,
421
+ description: String(formData.description || ''),
422
+ senderScope,
423
+ recipientScope,
424
+ mailPolicy,
425
+ });
426
+ if (!response.success) {
427
+ DeesToast.show({ message: response.message || 'Failed to update SMTP account', type: 'error', duration: 6000 });
428
+ return;
429
+ }
430
+ await modalArg.destroy();
431
+ this.showWarningsToast(response.warnings);
432
+ DeesToast.show({ message: `SMTP account ${account!.username} updated`, type: 'success', duration: 3000 });
433
+ await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
434
+ } else {
435
+ const username = String(formData.username || '').trim();
436
+ if (!username) return;
437
+ const response = await appstate.createSmtpAccount({
438
+ username,
439
+ description: String(formData.description || ''),
440
+ senderScope,
441
+ recipientScope,
442
+ mailPolicy,
443
+ });
444
+ if (!response.success || !response.password) {
445
+ DeesToast.show({ message: response.message || 'Failed to create SMTP account', type: 'error', duration: 6000 });
446
+ return;
447
+ }
448
+ await modalArg.destroy();
449
+ this.showWarningsToast(response.warnings);
450
+ await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
451
+ await this.showPasswordOnceModal(
452
+ 'SMTP Account Created',
453
+ response.account?.username || username,
454
+ response.password,
455
+ );
456
+ }
457
+ } catch (error) {
458
+ console.error('SMTP account form submit failed:', error);
459
+ DeesToast.show({
460
+ message: error instanceof Error ? error.message : 'SMTP account request failed',
461
+ type: 'error',
462
+ duration: 6000,
463
+ });
464
+ }
465
+ },
466
+ },
467
+ ],
468
+ });
469
+ }
470
+
471
+ private showWarningsToast(warnings?: string[]) {
472
+ if (!warnings?.length) return;
473
+ void import('@design.estate/dees-catalog').then(({ DeesToast }) => {
474
+ for (const warning of warnings) {
475
+ DeesToast.show({ message: warning, type: 'warning', duration: 6000 });
476
+ }
477
+ });
478
+ }
479
+
480
+ private async showRotateDialog(account: interfaces.data.ISmtpAccountInfo) {
481
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
482
+
483
+ await DeesModal.createAndShow({
484
+ heading: 'Rotate SMTP Password',
485
+ content: html`
486
+ <div style="color: #ccc; padding: 8px 0;">
487
+ <p>This will generate a new password for <strong>${account.username}</strong>. The old password stops working immediately.</p>
488
+ </div>
489
+ `,
490
+ menuOptions: [
491
+ {
492
+ name: 'Cancel',
493
+ iconName: 'lucide:x',
494
+ action: async (modalArg: any) => await modalArg.destroy(),
495
+ },
496
+ {
497
+ name: 'Rotate Password',
498
+ iconName: 'lucide:rotateCw',
499
+ action: async (modalArg: any) => {
500
+ await modalArg.destroy();
501
+ try {
502
+ const response = await appstate.rotateSmtpAccountPassword(account.id);
503
+ if (response.success && response.password) {
504
+ await appstate.smtpAccountsStatePart.dispatchAction(appstate.fetchSmtpAccountsAction, null);
505
+ await this.showPasswordOnceModal('Password Rotated', account.username, response.password);
506
+ } else {
507
+ DeesToast.show({ message: response.message || 'Failed to rotate password', type: 'error', duration: 6000 });
508
+ }
509
+ } catch (error) {
510
+ console.error('Failed to rotate SMTP account password:', error);
511
+ }
512
+ },
513
+ },
514
+ ],
515
+ });
516
+ }
517
+
518
+ private async showPasswordOnceModal(heading: string, username: string, password: string) {
519
+ const { DeesModal } = await import('@design.estate/dees-catalog');
520
+ await DeesModal.createAndShow({
521
+ heading,
522
+ content: html`
523
+ <div style="color: #ccc; padding: 8px 0;">
524
+ <p>Copy this password now. It will not be shown again.</p>
525
+ <div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
526
+ <div style="color: #888; font-size: 12px; margin-bottom: 4px;">Username</div>
527
+ <code style="color: #0f8; word-break: break-all; font-size: 13px;">${username}</code>
528
+ <div style="color: #888; font-size: 12px; margin: 8px 0 4px;">Password</div>
529
+ <code style="color: #0f8; word-break: break-all; font-size: 13px;">${password}</code>
530
+ </div>
531
+ </div>
532
+ `,
533
+ menuOptions: [
534
+ {
535
+ name: 'Copy Password',
536
+ iconName: 'lucide:copy',
537
+ action: async () => {
538
+ await navigator.clipboard.writeText(password);
539
+ const { DeesToast } = await import('@design.estate/dees-catalog');
540
+ DeesToast.show({ message: 'Password copied to clipboard', type: 'success', duration: 2500 });
541
+ },
542
+ },
543
+ {
544
+ name: 'Done',
545
+ iconName: 'lucide:check',
546
+ action: async (m: any) => await m.destroy(),
547
+ },
548
+ ],
549
+ });
550
+ }
551
+
552
+ private async showDeleteDialog(account: interfaces.data.ISmtpAccountInfo) {
553
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
554
+ await DeesModal.createAndShow({
555
+ heading: 'Delete SMTP Account',
556
+ content: html`
557
+ <div style="color: #ccc; padding: 8px 0;">
558
+ <p>Delete <strong>${account.username}</strong>? Clients using this account will stop authenticating immediately.</p>
559
+ </div>
560
+ `,
561
+ menuOptions: [
562
+ {
563
+ name: 'Cancel',
564
+ iconName: 'lucide:x',
565
+ action: async (modalArg: any) => await modalArg.destroy(),
566
+ },
567
+ {
568
+ name: 'Delete',
569
+ iconName: 'lucide:trash2',
570
+ action: async (modalArg: any) => {
571
+ await modalArg.destroy();
572
+ const nextState = await appstate.smtpAccountsStatePart.dispatchAction(
573
+ appstate.deleteSmtpAccountAction,
574
+ account.id,
575
+ );
576
+ DeesToast.show({
577
+ message: nextState.error || `SMTP account ${account.username} deleted`,
578
+ type: nextState.error ? 'error' : 'success',
579
+ duration: nextState.error ? 6000 : 3000,
580
+ });
581
+ },
582
+ },
583
+ ],
584
+ });
585
+ }
586
+ }
@@ -33,6 +33,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js';
33
33
  import { OpsViewEmails } from './email/ops-view-emails.js';
34
34
  import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
35
35
  import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
36
+ import { OpsViewSmtpAccounts } from './email/ops-view-smtp-accounts.js';
36
37
 
37
38
  // Access group
38
39
  import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
@@ -114,6 +115,7 @@ export class OpsDashboard extends DeesElement {
114
115
  { slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
115
116
  { slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
116
117
  { slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
118
+ { slug: 'accounts', name: 'SMTP Accounts', iconName: 'lucide:keyRound', element: OpsViewSmtpAccounts },
117
119
  ],
118
120
  },
119
121
  {
package/ts_web/router.ts CHANGED
@@ -10,7 +10,7 @@ const flatViews = ['logs'] as const;
10
10
  const subviewMap: Record<string, readonly string[]> = {
11
11
  overview: ['stats', 'configuration'] as const,
12
12
  network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
13
- email: ['log', 'security', 'domains'] as const,
13
+ email: ['log', 'security', 'domains', 'accounts'] as const,
14
14
  access: ['gatewayclients', 'apitokens', 'users'] as const,
15
15
  security: ['overview', 'blocked', 'authentication'] as const,
16
16
  domains: ['providers', 'domains', 'dns', 'certificates'] as const,