@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
@@ -93,6 +93,26 @@ interface IStoredWorkAppMailState {
93
93
  identities: IStoredWorkAppMailIdentity[];
94
94
  }
95
95
 
96
+ /**
97
+ * WorkApp-generated routes and SMTP users contributed to the composed email
98
+ * runtime config. Consumed by SmtpAccountManager, the single owner of the
99
+ * final `auth` block and route-list composition.
100
+ */
101
+ export interface IWorkAppMailRuntimeContribution {
102
+ routes: IEmailRoute[];
103
+ users: Array<{ username: string; password: string }>;
104
+ }
105
+
106
+ /** Route names generated (and owned wholesale) by WorkAppMailManager. */
107
+ export function isWorkAppManagedMailRouteName(routeName: string): boolean {
108
+ return routeName.startsWith('workapp-mail-');
109
+ }
110
+
111
+ /** SMTP usernames generated (and owned wholesale) by WorkAppMailManager. */
112
+ export function isWorkAppManagedSmtpUsername(username: string): boolean {
113
+ return username.startsWith('workapp-');
114
+ }
115
+
96
116
  export class WorkAppMailManager {
97
117
  private readonly storageKey = '/workhosters/mail-identities.json';
98
118
  private identityMutationPromise: Promise<unknown> = Promise.resolve();
@@ -659,12 +679,36 @@ export class WorkAppMailManager {
659
679
  };
660
680
  }
661
681
 
662
- public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
663
- emailConfig: TConfig,
664
- ): Promise<TConfig> {
665
- const identities = await this.readStoredIdentities();
666
- const readyDomains = await this.getReadyOutboundDomains(identities);
667
- return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities, readyDomains);
682
+ /**
683
+ * Compute the workapp-generated routes and SMTP users for the given
684
+ * candidate email config. Pure contribution — the final composition of
685
+ * routes and the `auth` block is owned exclusively by SmtpAccountManager.
686
+ */
687
+ public async getStoredIdentityContribution(
688
+ emailConfig: IUnifiedEmailServerOptions,
689
+ identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
690
+ ): Promise<IWorkAppMailRuntimeContribution> {
691
+ const nextIdentities = identities || await this.readStoredIdentities();
692
+ const readyDomains = await this.getReadyOutboundDomains(nextIdentities);
693
+ const generatedRoutes = nextIdentities
694
+ .filter((identity) => {
695
+ const inboundTarget = this.getCanonicalInboundTarget(identity);
696
+ return identity.enabled && ['smtpForward', 'typedEndpoint'].includes(inboundTarget?.type || '');
697
+ })
698
+ .map((identity) => this.buildInboundRoute(identity));
699
+ const generatedOutboundRoutes = nextIdentities
700
+ .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
701
+ .map((identity) => this.buildOutboundRoute(identity, emailConfig));
702
+ const generatedUsers = nextIdentities
703
+ .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
704
+ .map((identity) => ({
705
+ username: identity.smtp.username,
706
+ password: identity.smtpPassword,
707
+ }));
708
+ return {
709
+ routes: [...generatedRoutes, ...generatedOutboundRoutes],
710
+ users: generatedUsers,
711
+ };
668
712
  }
669
713
 
670
714
  public async applyStoredIdentitiesToRuntime(
@@ -673,15 +717,8 @@ export class WorkAppMailManager {
673
717
  const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
674
718
  if (!emailConfig) return;
675
719
 
676
- const nextIdentities = identities || await this.readStoredIdentities();
677
- const readyDomains = await this.getReadyOutboundDomains(nextIdentities);
678
- const nextConfig = this.mergeIdentitiesIntoEmailConfig(emailConfig, nextIdentities, readyDomains);
679
-
680
- this.dcRouterRef.options.emailConfig = nextConfig;
681
- if (this.dcRouterRef.emailServer) {
682
- this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
683
- await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
684
- }
720
+ const contribution = await this.getStoredIdentityContribution(emailConfig, identities);
721
+ await this.dcRouterRef.smtpAccountManager.applyToRuntime(contribution);
685
722
  }
686
723
 
687
724
  private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
@@ -717,41 +754,6 @@ export class WorkAppMailManager {
717
754
  >;
718
755
  }
719
756
 
720
- private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
721
- emailConfig: TConfig,
722
- identities: IStoredWorkAppMailIdentity[],
723
- readyDomains: Set<string>,
724
- ): TConfig {
725
- const generatedRoutes = identities
726
- .filter((identity) => {
727
- const inboundTarget = this.getCanonicalInboundTarget(identity);
728
- return identity.enabled && ['smtpForward', 'typedEndpoint'].includes(inboundTarget?.type || '');
729
- })
730
- .map((identity) => this.buildInboundRoute(identity));
731
- const generatedOutboundRoutes = identities
732
- .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
733
- .map((identity) => this.buildOutboundRoute(identity, emailConfig));
734
- const configuredRoutes = (emailConfig.routes || [])
735
- .filter((route) => !this.isManagedMailRouteName(route.name));
736
- const generatedUsers = identities
737
- .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
738
- .map((identity) => ({
739
- username: identity.smtp.username,
740
- password: identity.smtpPassword,
741
- }));
742
- const configuredUsers = (emailConfig.auth?.users || [])
743
- .filter((user) => !this.isManagedSmtpUsername(user.username));
744
-
745
- return {
746
- ...emailConfig,
747
- routes: [...configuredRoutes, ...generatedRoutes, ...generatedOutboundRoutes],
748
- auth: {
749
- ...(emailConfig.auth || {}),
750
- users: [...configuredUsers, ...generatedUsers],
751
- },
752
- };
753
- }
754
-
755
757
  private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
756
758
  const inboundTarget = this.getCanonicalInboundTarget(identity);
757
759
  if (inboundTarget?.type === 'typedEndpoint') {
@@ -1594,12 +1596,8 @@ export class WorkAppMailManager {
1594
1596
  return plugins.crypto.randomBytes(24).toString('base64url');
1595
1597
  }
1596
1598
 
1597
- private isManagedMailRouteName(routeName: string): boolean {
1598
- return routeName.startsWith('workapp-mail-');
1599
- }
1600
-
1601
1599
  private isManagedSmtpUsername(username: string): boolean {
1602
- return username.startsWith('workapp-');
1600
+ return isWorkAppManagedSmtpUsername(username);
1603
1601
  }
1604
1602
 
1605
1603
  private buildSmtpCredentials(
package/ts/email/index.ts CHANGED
@@ -11,5 +11,7 @@ export * from './classes.email-settings.manager.js';
11
11
  export * from './classes.smartmta-blob-storage-manager.js';
12
12
  export * from './classes.smartmta-storage-manager.js';
13
13
  export * from './smartmta-storage-policy.js';
14
+ export * from './classes.smtp-account.manager.js';
15
+ export * from './smtp-scram.js';
14
16
  export * from './classes.workapp-mail-manager.js';
15
17
  export * from './email-dns-records.js';
@@ -0,0 +1,24 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { deriveScramVerifier } from '@push.rocks/smartmta';
3
+ import type { ISmtpScramVerifier } from '@push.rocks/smartmta';
4
+
5
+ /**
6
+ * Derive an RFC 5802 SCRAM-SHA-256 verifier from a plaintext password using
7
+ * smartmta's canonical exported derivation (fresh random salt, OWASP-aligned
8
+ * default iteration count) — the exact algorithm the server verifies at AUTH
9
+ * time. Only the verifier is ever persisted; the plaintext is shown once at
10
+ * create/rotate time and then discarded.
11
+ */
12
+ export function deriveSmtpScramVerifier(
13
+ password: string,
14
+ iterations?: number,
15
+ ): ISmtpScramVerifier {
16
+ return deriveScramVerifier(password, iterations);
17
+ }
18
+
19
+ /**
20
+ * Machine-generated SMTP account password (~192 bits of entropy). Shown once.
21
+ */
22
+ export function generateSmtpAccountPassword(): string {
23
+ return plugins.crypto.randomBytes(24).toString('base64url');
24
+ }
@@ -41,6 +41,7 @@ export class OpsServer {
41
41
  private dnsRecordHandler!: handlers.DnsRecordHandler;
42
42
  private acmeConfigHandler!: handlers.AcmeConfigHandler;
43
43
  private emailDomainHandler!: handlers.EmailDomainHandler;
44
+ private smtpAccountHandler!: handlers.SmtpAccountHandler;
44
45
  private gatewayClientHandler!: handlers.GatewayClientHandler;
45
46
  private webPushControlHandler!: handlers.WebPushControlHandler;
46
47
  private webPushAppHandler!: handlers.WebPushAppHandler;
@@ -109,6 +110,7 @@ export class OpsServer {
109
110
  this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
110
111
  this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
111
112
  this.emailDomainHandler = new handlers.EmailDomainHandler(this);
113
+ this.smtpAccountHandler = new handlers.SmtpAccountHandler(this);
112
114
  this.gatewayClientHandler = new handlers.GatewayClientHandler(this);
113
115
  this.webPushControlHandler = new handlers.WebPushControlHandler(this);
114
116
  this.webPushAppHandler = new handlers.WebPushAppHandler(this);
@@ -54,9 +54,19 @@ export class EmailSettingsHandler {
54
54
  const mailerBridgeState = dcRouter.emailServer?.getBridgeState() as
55
55
  | interfaces.data.TEmailBridgeState
56
56
  | undefined;
57
+ const smtpAccountCount = dcRouter.smtpAccountManager?.getAccountCount() ?? 0;
58
+ const smtpListenerActive = dcRouter.emailServer
59
+ ? dcRouter.emailServer.isSmtpListenerActive()
60
+ : undefined;
57
61
  const manager = dcRouter.emailSettingsManager;
58
62
  if (manager) {
59
- return { ...manager.getPublicSettings(), outboundEgress, mailerBridgeState };
63
+ return {
64
+ ...manager.getPublicSettings(),
65
+ outboundEgress,
66
+ mailerBridgeState,
67
+ smtpAccountCount,
68
+ ...(smtpListenerActive !== undefined ? { smtpListenerActive } : {}),
69
+ };
60
70
  }
61
71
  const emailConfig = dcRouter.options.emailConfig;
62
72
  const emailPortConfig = dcRouter.options.emailPortConfig;
@@ -75,6 +85,8 @@ export class EmailSettingsHandler {
75
85
  domainCount: emailConfig?.domains?.length || 0,
76
86
  routeCount: emailConfig?.routes?.length || 0,
77
87
  authUserCount: emailConfig?.auth?.users?.length || 0,
88
+ smtpAccountCount,
89
+ ...(smtpListenerActive !== undefined ? { smtpListenerActive } : {}),
78
90
  updatedAt: 0,
79
91
  updatedBy: 'runtime-options',
80
92
  };
@@ -21,6 +21,7 @@ export * from './dns-authority.handler.js';
21
21
  export * from './dns-record.handler.js';
22
22
  export * from './acme-config.handler.js';
23
23
  export * from './email-domain.handler.js';
24
+ export * from './smtp-account.handler.js';
24
25
  export * from './gatewayclient.handler.js';
25
26
  export * from './realtime.handler.js';
26
27
  export * from './config-event.handler.js';
@@ -0,0 +1,199 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type { OpsServer } from '../classes.opsserver.js';
3
+ import * as interfaces from '../../../ts_interfaces/index.js';
4
+ import { requireOpsAuth } from '../helpers/auth.js';
5
+ import { logger } from '../../logger.js';
6
+
7
+ /**
8
+ * CRUD handler for operator-managed SMTP submission accounts, plus the
9
+ * legacy-route retirement lever.
10
+ *
11
+ * Auth: admin JWT or API token with `smtp-accounts:read` / `smtp-accounts:write`
12
+ * scope; every write additionally requires an admin identity when a JWT is
13
+ * used. Successful admin actions are audit-logged to the ops log.
14
+ */
15
+ export class SmtpAccountHandler {
16
+ public typedrouter = new plugins.typedrequest.TypedRouter();
17
+
18
+ constructor(private opsServerRef: OpsServer) {
19
+ this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
20
+ this.registerHandlers();
21
+ }
22
+
23
+ private async requireAuth(
24
+ request: { identity?: interfaces.data.IIdentity; apiToken?: string },
25
+ requiredScope: interfaces.data.TApiTokenScope,
26
+ ): Promise<string> {
27
+ const auth = await requireOpsAuth(this.opsServerRef, request, {
28
+ scope: requiredScope,
29
+ requireAdminIdentity: requiredScope.endsWith(':write'),
30
+ });
31
+ return auth.userId;
32
+ }
33
+
34
+ private get manager() {
35
+ return this.opsServerRef.dcRouterRef.smtpAccountManager;
36
+ }
37
+
38
+ private failure(errorArg: unknown, fallbackMessageArg: string): interfaces.requests.ISmtpAccountActionResult {
39
+ return {
40
+ success: false,
41
+ message: errorArg instanceof Error ? errorArg.message : fallbackMessageArg,
42
+ code: 'SMTP_ACCOUNT_ACTION_FAILED',
43
+ };
44
+ }
45
+
46
+ private registerHandlers(): void {
47
+ this.typedrouter.addTypedHandler(
48
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSmtpAccounts>(
49
+ 'getSmtpAccounts',
50
+ async (dataArg) => {
51
+ await this.requireAuth(dataArg, 'smtp-accounts:read');
52
+ if (!this.manager?.isStarted()) return { accounts: [] };
53
+ return { accounts: await this.manager.listAccounts() };
54
+ },
55
+ ),
56
+ );
57
+
58
+ this.typedrouter.addTypedHandler(
59
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSmtpAccount>(
60
+ 'createSmtpAccount',
61
+ async (dataArg) => {
62
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
63
+ if (!this.manager?.isStarted()) {
64
+ return { success: false, message: 'SmtpAccountManager not initialized', code: 'SMTP_ACCOUNT_MANAGER_UNAVAILABLE' };
65
+ }
66
+ try {
67
+ const result = await this.manager.createAccount({
68
+ username: dataArg.username,
69
+ description: dataArg.description,
70
+ senderScope: dataArg.senderScope,
71
+ recipientScope: dataArg.recipientScope,
72
+ mailPolicy: dataArg.mailPolicy,
73
+ createdBy: userId,
74
+ });
75
+ return {
76
+ success: true,
77
+ warnings: result.warnings.length > 0 ? result.warnings : undefined,
78
+ account: result.account,
79
+ password: result.password,
80
+ };
81
+ } catch (err: unknown) {
82
+ return this.failure(err, 'Failed to create SMTP account');
83
+ }
84
+ },
85
+ ),
86
+ );
87
+
88
+ this.typedrouter.addTypedHandler(
89
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSmtpAccount>(
90
+ 'updateSmtpAccount',
91
+ async (dataArg) => {
92
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
93
+ if (!this.manager?.isStarted()) {
94
+ return { success: false, message: 'SmtpAccountManager not initialized', code: 'SMTP_ACCOUNT_MANAGER_UNAVAILABLE' };
95
+ }
96
+ try {
97
+ const result = await this.manager.updateAccount(dataArg.id, {
98
+ description: dataArg.description,
99
+ senderScope: dataArg.senderScope,
100
+ recipientScope: dataArg.recipientScope,
101
+ mailPolicy: dataArg.mailPolicy,
102
+ }, userId);
103
+ return {
104
+ success: true,
105
+ warnings: result.warnings.length > 0 ? result.warnings : undefined,
106
+ account: result.account,
107
+ };
108
+ } catch (err: unknown) {
109
+ return this.failure(err, 'Failed to update SMTP account');
110
+ }
111
+ },
112
+ ),
113
+ );
114
+
115
+ this.typedrouter.addTypedHandler(
116
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleSmtpAccount>(
117
+ 'toggleSmtpAccount',
118
+ async (dataArg) => {
119
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
120
+ if (!this.manager?.isStarted()) {
121
+ return { success: false, message: 'SmtpAccountManager not initialized', code: 'SMTP_ACCOUNT_MANAGER_UNAVAILABLE' };
122
+ }
123
+ try {
124
+ const result = await this.manager.toggleAccount(dataArg.id, dataArg.enabled, userId);
125
+ return { success: true, account: result.account };
126
+ } catch (err: unknown) {
127
+ return this.failure(err, 'Failed to toggle SMTP account');
128
+ }
129
+ },
130
+ ),
131
+ );
132
+
133
+ this.typedrouter.addTypedHandler(
134
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateSmtpAccountPassword>(
135
+ 'rotateSmtpAccountPassword',
136
+ async (dataArg) => {
137
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
138
+ if (!this.manager?.isStarted()) {
139
+ return { success: false, message: 'SmtpAccountManager not initialized', code: 'SMTP_ACCOUNT_MANAGER_UNAVAILABLE' };
140
+ }
141
+ try {
142
+ const result = await this.manager.rotatePassword(dataArg.id, userId);
143
+ return { success: true, account: result.account, password: result.password };
144
+ } catch (err: unknown) {
145
+ return this.failure(err, 'Failed to rotate SMTP account password');
146
+ }
147
+ },
148
+ ),
149
+ );
150
+
151
+ this.typedrouter.addTypedHandler(
152
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSmtpAccount>(
153
+ 'deleteSmtpAccount',
154
+ async (dataArg) => {
155
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
156
+ if (!this.manager?.isStarted()) {
157
+ return { success: false, message: 'SmtpAccountManager not initialized', code: 'SMTP_ACCOUNT_MANAGER_UNAVAILABLE' };
158
+ }
159
+ try {
160
+ await this.manager.deleteAccount(dataArg.id, userId);
161
+ return { success: true };
162
+ } catch (err: unknown) {
163
+ return this.failure(err, 'Failed to delete SMTP account');
164
+ }
165
+ },
166
+ ),
167
+ );
168
+
169
+ // Internal-relay retirement lever: removes one persisted legacy route by
170
+ // exact name through the standard settings restart path. Guarded in
171
+ // EmailSettingsManager (unknown names and generated managed names fail).
172
+ this.typedrouter.addTypedHandler(
173
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveLegacyEmailRoute>(
174
+ 'removeLegacyEmailRoute',
175
+ async (dataArg) => {
176
+ const userId = await this.requireAuth(dataArg, 'smtp-accounts:write');
177
+ const routeName = (dataArg.routeName || '').trim();
178
+ logger.log('info', `removeLegacyEmailRoute invoked by ${userId} for route '${routeName}'`);
179
+ if (!routeName) {
180
+ return { success: false, message: 'routeName is required', code: 'SMTP_ACCOUNT_ACTION_FAILED' };
181
+ }
182
+ if (!this.opsServerRef.dcRouterRef.emailSettingsManager) {
183
+ return { success: false, message: 'EmailSettingsManager not initialized', code: 'EMAIL_SETTINGS_MANAGER_UNAVAILABLE' };
184
+ }
185
+ try {
186
+ await this.opsServerRef.dcRouterRef.updateEmailServerSettings(
187
+ { removeRouteNames: [routeName] },
188
+ userId,
189
+ );
190
+ logger.log('info', `Legacy email route '${routeName}' removed from persisted settings by ${userId}`);
191
+ return { success: true, message: `Route '${routeName}' removed` };
192
+ } catch (err: unknown) {
193
+ return this.failure(err, 'Failed to remove legacy email route');
194
+ }
195
+ },
196
+ ),
197
+ );
198
+ }
199
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.0.1',
6
+ version: '18.1.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -0,0 +1,143 @@
1
+ import * as plugins from '../plugins.js';
2
+ import * as interfaces from '../../ts_interfaces/index.js';
3
+ import { appState } from './shared.js';
4
+ import { getActionContext } from './login.js';
5
+
6
+ // ============================================================================
7
+ // SMTP Accounts State
8
+ // ============================================================================
9
+
10
+ export interface ISmtpAccountsState {
11
+ accounts: interfaces.data.ISmtpAccountInfo[];
12
+ isLoading: boolean;
13
+ error: string | null;
14
+ lastUpdated: number;
15
+ }
16
+
17
+ export const smtpAccountsStatePart = await appState.getStatePart<ISmtpAccountsState>(
18
+ 'smtpAccounts',
19
+ {
20
+ accounts: [],
21
+ isLoading: false,
22
+ error: null,
23
+ lastUpdated: 0,
24
+ },
25
+ 'soft',
26
+ );
27
+
28
+ export const fetchSmtpAccountsAction = smtpAccountsStatePart.createAction(
29
+ async (statePartArg): Promise<ISmtpAccountsState> => {
30
+ const context = getActionContext();
31
+ const currentState = statePartArg.getState()!;
32
+ if (!context.identity) return currentState;
33
+ try {
34
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
35
+ interfaces.requests.IReq_GetSmtpAccounts
36
+ >('/typedrequest', 'getSmtpAccounts');
37
+ const response = await request.fire({ identity: context.identity });
38
+ return {
39
+ ...statePartArg.getState()!,
40
+ accounts: response.accounts,
41
+ isLoading: false,
42
+ error: null,
43
+ lastUpdated: Date.now(),
44
+ };
45
+ } catch (error: unknown) {
46
+ return {
47
+ ...statePartArg.getState()!,
48
+ isLoading: false,
49
+ error: error instanceof Error ? error.message : 'Failed to fetch SMTP accounts',
50
+ };
51
+ }
52
+ },
53
+ );
54
+
55
+ export const toggleSmtpAccountAction = smtpAccountsStatePart.createAction<{
56
+ id: string;
57
+ enabled: boolean;
58
+ }>(async (statePartArg, dataArg, actionContext): Promise<ISmtpAccountsState> => {
59
+ const context = getActionContext();
60
+ const currentState = statePartArg.getState()!;
61
+ try {
62
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
63
+ interfaces.requests.IReq_ToggleSmtpAccount
64
+ >('/typedrequest', 'toggleSmtpAccount');
65
+ const response = await request.fire({
66
+ identity: context.identity!,
67
+ id: dataArg.id,
68
+ enabled: dataArg.enabled,
69
+ });
70
+ if (!response.success) {
71
+ return { ...currentState, error: response.message || 'Failed to toggle SMTP account' };
72
+ }
73
+ return await actionContext!.dispatch(fetchSmtpAccountsAction, null);
74
+ } catch (error: unknown) {
75
+ return {
76
+ ...currentState,
77
+ error: error instanceof Error ? error.message : 'Failed to toggle SMTP account',
78
+ };
79
+ }
80
+ });
81
+
82
+ export const deleteSmtpAccountAction = smtpAccountsStatePart.createAction<string>(
83
+ async (statePartArg, id, actionContext): Promise<ISmtpAccountsState> => {
84
+ const context = getActionContext();
85
+ const currentState = statePartArg.getState()!;
86
+ try {
87
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
88
+ interfaces.requests.IReq_DeleteSmtpAccount
89
+ >('/typedrequest', 'deleteSmtpAccount');
90
+ const response = await request.fire({ identity: context.identity!, id });
91
+ if (!response.success) {
92
+ return { ...currentState, error: response.message || 'Failed to delete SMTP account' };
93
+ }
94
+ return await actionContext!.dispatch(fetchSmtpAccountsAction, null);
95
+ } catch (error: unknown) {
96
+ return {
97
+ ...currentState,
98
+ error: error instanceof Error ? error.message : 'Failed to delete SMTP account',
99
+ };
100
+ }
101
+ },
102
+ );
103
+
104
+ // ============================================================================
105
+ // Secret-returning calls — standalone functions, deliberately NOT actions:
106
+ // the one-time password must never enter (persisted) app state.
107
+ // ============================================================================
108
+
109
+ export async function createSmtpAccount(options: {
110
+ username: string;
111
+ description?: string;
112
+ senderScope?: interfaces.data.ISmtpAccountSenderScope;
113
+ recipientScope?: interfaces.data.ISmtpAccountRecipientScope;
114
+ mailPolicy?: interfaces.data.ISmtpAccountMailPolicy;
115
+ }) {
116
+ const context = getActionContext();
117
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
118
+ interfaces.requests.IReq_CreateSmtpAccount
119
+ >('/typedrequest', 'createSmtpAccount');
120
+ return request.fire({ identity: context.identity!, ...options });
121
+ }
122
+
123
+ export async function rotateSmtpAccountPassword(id: string) {
124
+ const context = getActionContext();
125
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
126
+ interfaces.requests.IReq_RotateSmtpAccountPassword
127
+ >('/typedrequest', 'rotateSmtpAccountPassword');
128
+ return request.fire({ identity: context.identity!, id });
129
+ }
130
+
131
+ export async function updateSmtpAccount(options: {
132
+ id: string;
133
+ description?: string;
134
+ senderScope?: interfaces.data.ISmtpAccountSenderScope;
135
+ recipientScope?: interfaces.data.ISmtpAccountRecipientScope;
136
+ mailPolicy?: interfaces.data.ISmtpAccountMailPolicy;
137
+ }) {
138
+ const context = getActionContext();
139
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
140
+ interfaces.requests.IReq_UpdateSmtpAccount
141
+ >('/typedrequest', 'updateSmtpAccount');
142
+ return request.fire({ identity: context.identity!, ...options });
143
+ }
@@ -20,6 +20,7 @@ export * from './appstate/domains.js';
20
20
  export * from './appstate/routes.js';
21
21
  export * from './appstate/users.js';
22
22
  export * from './appstate/email-domains.js';
23
+ export * from './appstate/smtp-accounts.js';
23
24
  export * from './appstate/realtime.js';
24
25
 
25
26
  // Side-effect module: auto-refresh scheduling and TypedSocket log streaming.
@@ -1,3 +1,4 @@
1
1
  export * from './ops-view-emails.js';
2
2
  export * from './ops-view-email-security.js';
3
3
  export * from './ops-view-email-domains.js';
4
+ export * from './ops-view-smtp-accounts.js';