@serve.zone/dcrouter 13.25.0 → 13.27.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 (75) hide show
  1. package/.smartconfig.json +3 -11
  2. package/dist_serve/bundle.js +4046 -3552
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -1
  5. package/dist_ts/classes.dcrouter.js +6 -4
  6. package/dist_ts/config/classes.api-token-manager.d.ts +4 -2
  7. package/dist_ts/config/classes.api-token-manager.js +68 -6
  8. package/dist_ts/config/classes.route-config-manager.d.ts +1 -0
  9. package/dist_ts/config/classes.route-config-manager.js +38 -1
  10. package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
  11. package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
  12. package/dist_ts/email/classes.email-domain.manager.d.ts +4 -1
  13. package/dist_ts/email/classes.email-domain.manager.js +30 -1
  14. package/dist_ts/email/classes.workapp-mail-manager.d.ts +35 -0
  15. package/dist_ts/email/classes.workapp-mail-manager.js +273 -0
  16. package/dist_ts/email/index.d.ts +1 -0
  17. package/dist_ts/email/index.js +2 -1
  18. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  19. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  20. package/dist_ts/opsserver/handlers/admin.handler.js +9 -4
  21. package/dist_ts/opsserver/handlers/api-token.handler.js +2 -2
  22. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +4 -0
  23. package/dist_ts/opsserver/handlers/certificate.handler.js +41 -11
  24. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  25. package/dist_ts/opsserver/handlers/index.js +2 -1
  26. package/dist_ts/opsserver/handlers/workhoster.handler.d.ts +26 -0
  27. package/dist_ts/opsserver/handlers/workhoster.handler.js +402 -0
  28. package/dist_ts_apiclient/classes.dcrouterapiclient.d.ts +2 -0
  29. package/dist_ts_apiclient/classes.dcrouterapiclient.js +4 -1
  30. package/dist_ts_apiclient/classes.workhoster.d.ts +14 -0
  31. package/dist_ts_apiclient/classes.workhoster.js +29 -0
  32. package/dist_ts_apiclient/index.d.ts +1 -0
  33. package/dist_ts_apiclient/index.js +2 -1
  34. package/dist_ts_interfaces/data/index.d.ts +1 -0
  35. package/dist_ts_interfaces/data/index.js +2 -1
  36. package/dist_ts_interfaces/data/route-management.d.ts +38 -1
  37. package/dist_ts_interfaces/data/workhoster.d.ts +131 -0
  38. package/dist_ts_interfaces/data/workhoster.js +2 -0
  39. package/dist_ts_interfaces/requests/api-tokens.d.ts +2 -1
  40. package/dist_ts_interfaces/requests/certificate.d.ts +12 -6
  41. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  42. package/dist_ts_interfaces/requests/index.js +2 -1
  43. package/dist_ts_interfaces/requests/workhoster.d.ts +98 -0
  44. package/dist_ts_interfaces/requests/workhoster.js +2 -0
  45. package/dist_ts_migrations/index.js +8 -2
  46. package/dist_ts_web/00_commitinfo_data.js +1 -1
  47. package/dist_ts_web/appstate.d.ts +1 -1
  48. package/dist_ts_web/appstate.js +3 -2
  49. package/dist_ts_web/elements/access/ops-view-apitokens.d.ts +1 -0
  50. package/dist_ts_web/elements/access/ops-view-apitokens.js +58 -3
  51. package/package.json +24 -23
  52. package/readme.md +108 -128
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/classes.dcrouter.ts +5 -3
  55. package/ts/config/classes.api-token-manager.ts +73 -4
  56. package/ts/config/classes.route-config-manager.ts +37 -0
  57. package/ts/db/documents/classes.api-token.doc.ts +4 -1
  58. package/ts/email/classes.email-domain.manager.ts +33 -1
  59. package/ts/email/classes.workapp-mail-manager.ts +343 -0
  60. package/ts/email/index.ts +1 -0
  61. package/ts/opsserver/classes.opsserver.ts +3 -1
  62. package/ts/opsserver/handlers/admin.handler.ts +11 -4
  63. package/ts/opsserver/handlers/api-token.handler.ts +1 -0
  64. package/ts/opsserver/handlers/certificate.handler.ts +45 -12
  65. package/ts/opsserver/handlers/index.ts +2 -1
  66. package/ts/opsserver/handlers/workhoster.handler.ts +490 -0
  67. package/ts/readme.md +2 -2
  68. package/ts_apiclient/classes.dcrouterapiclient.ts +3 -0
  69. package/ts_apiclient/classes.workhoster.ts +49 -0
  70. package/ts_apiclient/index.ts +1 -0
  71. package/ts_apiclient/readme.md +54 -44
  72. package/ts_web/00_commitinfo_data.ts +1 -1
  73. package/ts_web/appstate.ts +7 -1
  74. package/ts_web/elements/access/ops-view-apitokens.ts +58 -3
  75. package/ts_web/readme.md +36 -19
@@ -31,7 +31,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
31
31
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
32
32
  import { DnsManager } from './dns/manager.dns.js';
33
33
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
34
- import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
34
+ import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
35
35
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
36
36
  import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
37
37
 
@@ -285,6 +285,7 @@ export class DcRouter {
285
285
  // ACME configuration (DB-backed singleton, replaces tls.contactEmail)
286
286
  public acmeConfigManager?: AcmeConfigManager;
287
287
  public emailDomainManager?: EmailDomainManager;
288
+ public workAppMailManager: WorkAppMailManager;
288
289
  public securityPolicyManager?: SecurityPolicyManager;
289
290
 
290
291
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
@@ -339,6 +340,7 @@ export class DcRouter {
339
340
  this.storageManager = new SmartMtaStorageManager(
340
341
  plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
341
342
  );
343
+ this.workAppMailManager = new WorkAppMailManager(this);
342
344
 
343
345
  // Initialize service manager and register all services
344
346
  this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -1630,7 +1632,7 @@ export class DcRouter {
1630
1632
  }
1631
1633
 
1632
1634
  // Create config with mapped ports
1633
- const emailConfig: IUnifiedEmailServerOptions = {
1635
+ const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1634
1636
  ...this.options.emailConfig,
1635
1637
  domains: transformedDomains,
1636
1638
  ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
@@ -1640,7 +1642,7 @@ export class DcRouter {
1640
1642
  persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
1641
1643
  ...this.options.emailConfig.queue,
1642
1644
  },
1643
- };
1645
+ });
1644
1646
 
1645
1647
  // Create unified email server
1646
1648
  this.emailServer = new UnifiedEmailServer(this, emailConfig);
@@ -2,12 +2,15 @@ import * as plugins from '../plugins.js';
2
2
  import { logger } from '../logger.js';
3
3
  import { ApiTokenDoc } from '../db/index.js';
4
4
  import type {
5
+ IApiTokenPolicy,
5
6
  IStoredApiToken,
6
7
  IApiTokenInfo,
7
8
  TApiTokenScope,
8
9
  } from '../../ts_interfaces/data/route-management.js';
9
10
 
10
11
  const TOKEN_PREFIX_STR = 'dcr_';
12
+ const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
13
+ const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
11
14
 
12
15
  export class ApiTokenManager {
13
16
  private tokens = new Map<string, IStoredApiToken>();
@@ -16,6 +19,7 @@ export class ApiTokenManager {
16
19
 
17
20
  public async initialize(): Promise<void> {
18
21
  await this.loadTokens();
22
+ await this.ensureEnvAdminToken();
19
23
  if (this.tokens.size > 0) {
20
24
  logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
21
25
  }
@@ -33,13 +37,14 @@ export class ApiTokenManager {
33
37
  scopes: TApiTokenScope[],
34
38
  expiresInDays: number | null,
35
39
  createdBy: string,
40
+ policy?: IApiTokenPolicy,
36
41
  ): Promise<{ id: string; rawToken: string }> {
37
42
  const id = plugins.uuid.v4();
38
43
  const randomBytes = plugins.crypto.randomBytes(32);
39
44
  const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
40
45
  const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
41
46
 
42
- const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
47
+ const tokenHash = this.hashToken(rawToken);
43
48
 
44
49
  const now = Date.now();
45
50
  const stored: IStoredApiToken = {
@@ -47,6 +52,7 @@ export class ApiTokenManager {
47
52
  name,
48
53
  tokenHash,
49
54
  scopes,
55
+ policy,
50
56
  createdAt: now,
51
57
  expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
52
58
  lastUsedAt: null,
@@ -67,7 +73,7 @@ export class ApiTokenManager {
67
73
  public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
68
74
  if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
69
75
 
70
- const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
76
+ const hash = this.hashToken(rawToken);
71
77
 
72
78
  for (const stored of this.tokens.values()) {
73
79
  if (stored.tokenHash === hash) {
@@ -87,7 +93,31 @@ export class ApiTokenManager {
87
93
  * Check if a token has a specific scope.
88
94
  */
89
95
  public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
90
- return token.scopes.includes(scope);
96
+ if (token.policy?.role === 'admin') return true;
97
+
98
+ const isGatewayClientToken = token.policy?.role === 'gatewayClient';
99
+ const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
100
+ 'gateway-clients:read',
101
+ 'gateway-clients:write',
102
+ 'workhosters:read',
103
+ 'workhosters:write',
104
+ ]);
105
+ if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
106
+ return false;
107
+ }
108
+
109
+ if (!isGatewayClientToken && token.scopes.includes('*')) return true;
110
+
111
+ const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
112
+ if (scopes.has(scope)) return true;
113
+
114
+ const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
115
+ 'gateway-clients:read': ['workhosters:read'],
116
+ 'gateway-clients:write': ['workhosters:write'],
117
+ 'workhosters:read': ['gateway-clients:read'],
118
+ 'workhosters:write': ['gateway-clients:write'],
119
+ };
120
+ return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
91
121
  }
92
122
 
93
123
  /**
@@ -100,6 +130,7 @@ export class ApiTokenManager {
100
130
  id: stored.id,
101
131
  name: stored.name,
102
132
  scopes: stored.scopes,
133
+ policy: stored.policy,
103
134
  createdAt: stored.createdAt,
104
135
  expiresAt: stored.expiresAt,
105
136
  lastUsedAt: stored.lastUsedAt,
@@ -134,7 +165,7 @@ export class ApiTokenManager {
134
165
  const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
135
166
  const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
136
167
 
137
- stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
168
+ stored.tokenHash = this.hashToken(rawToken);
138
169
  await this.persistToken(stored);
139
170
  logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
140
171
  return { id, rawToken };
@@ -165,6 +196,7 @@ export class ApiTokenManager {
165
196
  name: doc.name,
166
197
  tokenHash: doc.tokenHash,
167
198
  scopes: doc.scopes,
199
+ policy: doc.policy,
168
200
  createdAt: doc.createdAt,
169
201
  expiresAt: doc.expiresAt,
170
202
  lastUsedAt: doc.lastUsedAt,
@@ -175,12 +207,48 @@ export class ApiTokenManager {
175
207
  }
176
208
  }
177
209
 
210
+ private async ensureEnvAdminToken(): Promise<void> {
211
+ const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
212
+ if (!rawToken) return;
213
+
214
+ if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
215
+ throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
216
+ }
217
+ if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
218
+ throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
219
+ }
220
+
221
+ const now = Date.now();
222
+ const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
223
+ const stored: IStoredApiToken = {
224
+ id: ENV_ADMIN_TOKEN_ID,
225
+ name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
226
+ tokenHash: this.hashToken(rawToken),
227
+ scopes: ['*'],
228
+ policy: { role: 'admin' },
229
+ createdAt: existing?.createdAt || now,
230
+ expiresAt: null,
231
+ lastUsedAt: existing?.lastUsedAt || null,
232
+ createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
233
+ enabled: true,
234
+ };
235
+
236
+ this.tokens.set(stored.id, stored);
237
+ await this.persistToken(stored);
238
+ logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
239
+ }
240
+
241
+ private hashToken(rawToken: string): string {
242
+ return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
243
+ }
244
+
178
245
  private async persistToken(stored: IStoredApiToken): Promise<void> {
179
246
  const existing = await ApiTokenDoc.findById(stored.id);
180
247
  if (existing) {
181
248
  existing.name = stored.name;
182
249
  existing.tokenHash = stored.tokenHash;
183
250
  existing.scopes = stored.scopes;
251
+ existing.policy = stored.policy;
184
252
  existing.createdAt = stored.createdAt;
185
253
  existing.expiresAt = stored.expiresAt;
186
254
  existing.lastUsedAt = stored.lastUsedAt;
@@ -193,6 +261,7 @@ export class ApiTokenManager {
193
261
  doc.name = stored.name;
194
262
  doc.tokenHash = stored.tokenHash;
195
263
  doc.scopes = stored.scopes;
264
+ doc.policy = stored.policy;
196
265
  doc.createdAt = stored.createdAt;
197
266
  doc.expiresAt = stored.expiresAt;
198
267
  doc.lastUsedAt = stored.lastUsedAt;
@@ -256,6 +256,15 @@ export class RouteConfigManager {
256
256
  return this.updateRoute(id, { enabled });
257
257
  }
258
258
 
259
+ public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
260
+ for (const route of this.routes.values()) {
261
+ if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
262
+ return route;
263
+ }
264
+ }
265
+ return undefined;
266
+ }
267
+
259
268
  // =========================================================================
260
269
  // Private: seed routes from constructor config
261
270
  // =========================================================================
@@ -443,6 +452,20 @@ export class RouteConfigManager {
443
452
  lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
444
453
  ? metadata.lastResolvedAt
445
454
  : undefined,
455
+ ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
456
+ ? metadata.ownerType
457
+ : undefined,
458
+ gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
459
+ ? metadata.gatewayClientType
460
+ : metadata.workHosterType,
461
+ gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
462
+ gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
463
+ workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
464
+ ? metadata.workHosterType
465
+ : metadata.gatewayClientType,
466
+ workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
467
+ workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
468
+ externalKey: normalizeString(metadata.externalKey),
446
469
  };
447
470
 
448
471
  if (!normalized.sourceProfileRef) {
@@ -454,6 +477,20 @@ export class RouteConfigManager {
454
477
  if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
455
478
  normalized.lastResolvedAt = undefined;
456
479
  }
480
+ if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
481
+ normalized.gatewayClientType = undefined;
482
+ normalized.gatewayClientId = undefined;
483
+ normalized.gatewayClientAppId = undefined;
484
+ normalized.workHosterType = undefined;
485
+ normalized.workHosterId = undefined;
486
+ normalized.workAppId = undefined;
487
+ normalized.externalKey = undefined;
488
+ } else {
489
+ normalized.ownerType = 'gatewayClient';
490
+ normalized.workHosterType = normalized.gatewayClientType;
491
+ normalized.workHosterId = normalized.gatewayClientId;
492
+ normalized.workAppId = normalized.gatewayClientAppId;
493
+ }
457
494
 
458
495
  if (Object.values(normalized).every((value) => value === undefined)) {
459
496
  return undefined;
@@ -1,6 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
3
- import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
3
+ import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
4
4
 
5
5
  const getDb = () => DcRouterDb.getInstance().getDb();
6
6
 
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
19
19
  @plugins.smartdata.svDb()
20
20
  public scopes!: TApiTokenScope[];
21
21
 
22
+ @plugins.smartdata.svDb()
23
+ public policy?: IApiTokenPolicy;
24
+
22
25
  @plugins.smartdata.svDb()
23
26
  public createdAt!: number;
24
27
 
@@ -57,6 +57,31 @@ export class EmailDomainManager {
57
57
  return doc ? this.docToInterface(doc) : null;
58
58
  }
59
59
 
60
+ public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
61
+ const doc = await EmailDomainDoc.findByDomain(domainName);
62
+ return doc ? this.docToInterface(doc) : null;
63
+ }
64
+
65
+ public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
66
+ const normalizedDomain = domainName.trim().toLowerCase();
67
+ const existing = await this.getByDomain(normalizedDomain);
68
+ if (existing) return existing;
69
+ if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
70
+
71
+ const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
72
+ if (!linkedDomain) {
73
+ throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
74
+ }
75
+
76
+ const subdomain = normalizedDomain === linkedDomain.name
77
+ ? undefined
78
+ : normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
79
+ return await this.createEmailDomain({
80
+ linkedDomainId: linkedDomain.id,
81
+ subdomain,
82
+ });
83
+ }
84
+
60
85
  public async createEmailDomain(opts: {
61
86
  linkedDomainId: string;
62
87
  subdomain?: string;
@@ -351,6 +376,13 @@ export class EmailDomainManager {
351
376
  return configuredDomains.includes(domainName.toLowerCase());
352
377
  }
353
378
 
379
+ private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
380
+ const domains = await DomainDoc.findAll();
381
+ return domains
382
+ .filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
383
+ .sort((a, b) => b.name.length - a.name.length)[0] || null;
384
+ }
385
+
354
386
  private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
355
387
  const docs = await EmailDomainDoc.findAll();
356
388
  const managedConfigs: IEmailDomainConfig[] = [];
@@ -378,7 +410,7 @@ export class EmailDomainManager {
378
410
  return managedConfigs;
379
411
  }
380
412
 
381
- private async syncManagedDomainsToRuntime(): Promise<void> {
413
+ public async syncManagedDomainsToRuntime(): Promise<void> {
382
414
  if (!this.dcRouter.options?.emailConfig) {
383
415
  return;
384
416
  }
@@ -0,0 +1,343 @@
1
+ import type {
2
+ IEmailRoute,
3
+ IUnifiedEmailServerOptions,
4
+ } from '@push.rocks/smartmta';
5
+ import * as plugins from '../plugins.js';
6
+ import type * as interfaces from '../../ts_interfaces/index.js';
7
+
8
+ type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
9
+
10
+ interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
11
+ smtpPassword: string;
12
+ }
13
+
14
+ interface IStoredWorkAppMailState {
15
+ version: 1;
16
+ identities: IStoredWorkAppMailIdentity[];
17
+ }
18
+
19
+ export class WorkAppMailManager {
20
+ private readonly storageKey = '/workhosters/mail-identities.json';
21
+
22
+ constructor(private dcRouterRef: any) {}
23
+
24
+ public async listMailIdentities(
25
+ ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
26
+ ): Promise<interfaces.data.IWorkAppMailIdentity[]> {
27
+ const identities = await this.readStoredIdentities();
28
+ return identities
29
+ .filter((identity) => this.matchesOwnership(identity.ownership, ownership))
30
+ .map((identity) => this.toPublicIdentity(identity));
31
+ }
32
+
33
+ public async syncMailIdentity(
34
+ request: TSyncRequest,
35
+ createdBy: string,
36
+ ): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
37
+ if (!this.dcRouterRef.options.emailConfig) {
38
+ return { success: false, message: 'Email server is not configured' };
39
+ }
40
+
41
+ const ownership = this.normalizeOwnership(request.ownership);
42
+ const domain = this.normalizeDomain(request.domain);
43
+ const localPart = this.normalizeLocalPart(request.localPart);
44
+ const address = `${localPart}@${domain}`;
45
+ const externalKey = this.buildExternalKey(ownership, address);
46
+ const identities = await this.readStoredIdentities();
47
+ const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
48
+
49
+ if (request.delete) {
50
+ if (existingIndex < 0) {
51
+ return { success: true, action: 'unchanged' };
52
+ }
53
+ const [deletedIdentity] = identities.splice(existingIndex, 1);
54
+ await this.writeStoredIdentities(identities);
55
+ await this.applyStoredIdentitiesToRuntime(identities);
56
+ return {
57
+ success: true,
58
+ action: 'deleted',
59
+ identity: this.toPublicIdentity(deletedIdentity),
60
+ };
61
+ }
62
+
63
+ await this.ensureEmailDomainConfigured(domain);
64
+
65
+ const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
66
+ const now = Date.now();
67
+ const smtpPassword = existingIdentity && !request.resetSmtpPassword
68
+ ? existingIdentity.smtpPassword
69
+ : this.generateSmtpPassword();
70
+ const identity: IStoredWorkAppMailIdentity = {
71
+ id: existingIdentity?.id || plugins.smartunique.shortId(),
72
+ externalKey,
73
+ ownership,
74
+ address,
75
+ localPart,
76
+ domain,
77
+ enabled: request.enabled ?? existingIdentity?.enabled ?? true,
78
+ displayName: request.displayName ?? existingIdentity?.displayName,
79
+ inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
80
+ smtp: {
81
+ enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
82
+ username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
83
+ },
84
+ createdAt: existingIdentity?.createdAt || now,
85
+ updatedAt: now,
86
+ createdBy: existingIdentity?.createdBy || createdBy,
87
+ smtpPassword,
88
+ };
89
+
90
+ if (existingIndex >= 0) {
91
+ identities[existingIndex] = identity;
92
+ } else {
93
+ identities.push(identity);
94
+ }
95
+
96
+ await this.writeStoredIdentities(identities);
97
+ await this.applyStoredIdentitiesToRuntime(identities);
98
+
99
+ const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
100
+ success: true,
101
+ action: existingIndex >= 0 ? 'updated' : 'created',
102
+ identity: this.toPublicIdentity(identity),
103
+ };
104
+
105
+ if (existingIndex < 0 || request.resetSmtpPassword) {
106
+ response.smtpCredentials = this.buildSmtpCredentials(identity);
107
+ }
108
+
109
+ return response;
110
+ }
111
+
112
+ public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
113
+ emailConfig: TConfig,
114
+ ): Promise<TConfig> {
115
+ const identities = await this.readStoredIdentities();
116
+ return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
117
+ }
118
+
119
+ public async applyStoredIdentitiesToRuntime(
120
+ identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
121
+ ): Promise<void> {
122
+ const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
123
+ if (!emailConfig) return;
124
+
125
+ const nextConfig = this.mergeIdentitiesIntoEmailConfig(
126
+ emailConfig,
127
+ identities || await this.readStoredIdentities(),
128
+ );
129
+
130
+ this.dcRouterRef.options.emailConfig = nextConfig;
131
+ if (this.dcRouterRef.emailServer) {
132
+ this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
133
+ await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
134
+ }
135
+ }
136
+
137
+ private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
138
+ const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
139
+ if (!storedData) return [];
140
+ const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
141
+ return Array.isArray(parsed) ? parsed : parsed.identities || [];
142
+ }
143
+
144
+ private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
145
+ const state: IStoredWorkAppMailState = {
146
+ version: 1,
147
+ identities,
148
+ };
149
+ await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
150
+ }
151
+
152
+ private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
153
+ emailConfig: TConfig,
154
+ identities: IStoredWorkAppMailIdentity[],
155
+ ): TConfig {
156
+ const generatedRoutes = identities
157
+ .filter((identity) => identity.enabled && identity.inbound?.enabled)
158
+ .map((identity) => this.buildInboundRoute(identity));
159
+ const configuredRoutes = (emailConfig.routes || [])
160
+ .filter((route) => !this.isManagedMailRouteName(route.name));
161
+ const generatedUsers = identities
162
+ .filter((identity) => identity.enabled && identity.smtp.enabled)
163
+ .map((identity) => ({
164
+ username: identity.smtp.username,
165
+ password: identity.smtpPassword,
166
+ }));
167
+ const configuredUsers = (emailConfig.auth?.users || [])
168
+ .filter((user) => !this.isManagedSmtpUsername(user.username));
169
+
170
+ return {
171
+ ...emailConfig,
172
+ routes: [...configuredRoutes, ...generatedRoutes],
173
+ auth: {
174
+ ...(emailConfig.auth || {}),
175
+ users: [...configuredUsers, ...generatedUsers],
176
+ },
177
+ };
178
+ }
179
+
180
+ private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
181
+ const inbound = identity.inbound!;
182
+ return {
183
+ name: this.buildRouteName(identity.externalKey),
184
+ priority: 1000,
185
+ match: {
186
+ recipients: identity.address,
187
+ },
188
+ action: {
189
+ type: 'forward',
190
+ forward: {
191
+ host: inbound.targetHost,
192
+ port: inbound.targetPort,
193
+ preserveHeaders: inbound.preserveHeaders ?? true,
194
+ addHeaders: {
195
+ 'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
196
+ 'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
197
+ 'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
198
+ ...(inbound.addHeaders || {}),
199
+ },
200
+ },
201
+ },
202
+ };
203
+ }
204
+
205
+ private async ensureEmailDomainConfigured(domain: string): Promise<void> {
206
+ const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
207
+ if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
208
+ return;
209
+ }
210
+
211
+ const emailDomainManager = this.dcRouterRef.emailDomainManager;
212
+ if (!emailDomainManager) {
213
+ throw new Error(`Email domain is not configured: ${domain}`);
214
+ }
215
+
216
+ if (await emailDomainManager.getByDomain(domain)) {
217
+ await emailDomainManager.syncManagedDomainsToRuntime();
218
+ return;
219
+ }
220
+
221
+ await emailDomainManager.ensureEmailDomainForDomainName(domain);
222
+ }
223
+
224
+ private normalizeOwnership(
225
+ ownership: interfaces.data.IWorkAppMailOwnership,
226
+ ): interfaces.data.IWorkAppMailOwnership {
227
+ const workHosterType = ownership.workHosterType;
228
+ const workHosterId = ownership.workHosterId?.trim();
229
+ const workAppId = ownership.workAppId?.trim();
230
+ if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
231
+ throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
232
+ }
233
+ if (!workHosterId) throw new Error('workHosterId is required');
234
+ if (!workAppId) throw new Error('workAppId is required');
235
+ return { workHosterType, workHosterId, workAppId };
236
+ }
237
+
238
+ private normalizeDomain(domain: string): string {
239
+ const normalized = domain?.trim().toLowerCase();
240
+ if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
241
+ throw new Error(`Invalid email domain: ${domain}`);
242
+ }
243
+ return normalized;
244
+ }
245
+
246
+ private normalizeLocalPart(localPart: string): string {
247
+ const normalized = localPart?.trim().toLowerCase();
248
+ if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
249
+ throw new Error(`Invalid email local part: ${localPart}`);
250
+ }
251
+ return normalized;
252
+ }
253
+
254
+ private normalizeInboundRoute(
255
+ inbound?: interfaces.data.IWorkAppMailInboundRoute,
256
+ ): interfaces.data.IWorkAppMailInboundRoute | undefined {
257
+ if (!inbound) return undefined;
258
+ if (!inbound.enabled) {
259
+ return { ...inbound, enabled: false };
260
+ }
261
+ const targetHost = inbound.targetHost?.trim();
262
+ const targetPort = Number(inbound.targetPort);
263
+ if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
264
+ if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
265
+ throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
266
+ }
267
+ return {
268
+ ...inbound,
269
+ targetHost,
270
+ targetPort,
271
+ };
272
+ }
273
+
274
+ private matchesOwnership(
275
+ ownership: interfaces.data.IWorkAppMailOwnership,
276
+ filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
277
+ ): boolean {
278
+ if (!filter) return true;
279
+ if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
280
+ if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
281
+ if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
282
+ return true;
283
+ }
284
+
285
+ private buildExternalKey(
286
+ ownership: interfaces.data.IWorkAppMailOwnership,
287
+ address: string,
288
+ ): string {
289
+ return [
290
+ ownership.workHosterType,
291
+ ownership.workHosterId,
292
+ ownership.workAppId,
293
+ address,
294
+ ].join(':');
295
+ }
296
+
297
+ private buildSmtpUsername(externalKey: string): string {
298
+ return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
299
+ }
300
+
301
+ private buildRouteName(externalKey: string): string {
302
+ return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
303
+ }
304
+
305
+ private hashExternalKey(externalKey: string): string {
306
+ return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
307
+ }
308
+
309
+ private generateSmtpPassword(): string {
310
+ return plugins.crypto.randomBytes(24).toString('base64url');
311
+ }
312
+
313
+ private isManagedMailRouteName(routeName: string): boolean {
314
+ return routeName.startsWith('workapp-mail-');
315
+ }
316
+
317
+ private isManagedSmtpUsername(username: string): boolean {
318
+ return username.startsWith('workapp-');
319
+ }
320
+
321
+ private buildSmtpCredentials(
322
+ identity: IStoredWorkAppMailIdentity,
323
+ ): interfaces.data.IWorkAppMailCredentials {
324
+ return {
325
+ username: identity.smtp.username,
326
+ password: identity.smtpPassword,
327
+ host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
328
+ || this.dcRouterRef.options.emailConfig?.hostname,
329
+ ports: {
330
+ smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
331
+ submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
332
+ smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
333
+ },
334
+ };
335
+ }
336
+
337
+ private toPublicIdentity(
338
+ identity: IStoredWorkAppMailIdentity,
339
+ ): interfaces.data.IWorkAppMailIdentity {
340
+ const { smtpPassword, ...publicIdentity } = identity;
341
+ return publicIdentity;
342
+ }
343
+ }
package/ts/email/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './classes.email-domain.manager.js';
2
2
  export * from './classes.smartmta-storage-manager.js';
3
+ export * from './classes.workapp-mail-manager.js';
3
4
  export * from './email-dns-records.js';