@serve.zone/dcrouter 14.0.0 → 14.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.
@@ -6,6 +6,12 @@ import * as plugins from '../plugins.js';
6
6
  import type * as interfaces from '../../ts_interfaces/index.js';
7
7
 
8
8
  type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
9
+ type TMailResourceOwner = plugins.servezoneInterfaces.data.IMailResourceOwner;
10
+ type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
11
+ type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
12
+ type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
13
+ type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
14
+ type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
9
15
 
10
16
  interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
11
17
  smtpPassword: string;
@@ -109,6 +115,89 @@ export class WorkAppMailManager {
109
115
  return response;
110
116
  }
111
117
 
118
+ public async listMailAddressBindings(options: {
119
+ owner?: Partial<TMailResourceOwner>;
120
+ domain?: string;
121
+ address?: string;
122
+ } = {}): Promise<TMailAddressBinding[]> {
123
+ const domain = options.domain ? this.normalizeDomain(options.domain) : undefined;
124
+ const address = options.address ? this.normalizeAddress(options.address) : undefined;
125
+ const identities = await this.readStoredIdentities();
126
+
127
+ return identities
128
+ .filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), options.owner))
129
+ .filter((identity) => domain ? identity.domain === domain : true)
130
+ .filter((identity) => address ? identity.address === address : true)
131
+ .map((identity) => this.toMailAddressBinding(identity));
132
+ }
133
+
134
+ public async listWorkAppMailBindings(
135
+ owner?: Partial<TMailResourceOwner>,
136
+ ): Promise<TWorkAppMailBinding[]> {
137
+ const identities = (await this.readStoredIdentities())
138
+ .filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), owner));
139
+ const groups = new Map<string, IStoredWorkAppMailIdentity[]>();
140
+
141
+ for (const identity of identities) {
142
+ const ownerKey = this.buildMailOwnerKey(this.toMailOwner(identity.ownership));
143
+ const group = groups.get(ownerKey) || [];
144
+ group.push(identity);
145
+ groups.set(ownerKey, group);
146
+ }
147
+
148
+ return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
149
+ }
150
+
151
+ public async syncMailAddressBinding(
152
+ binding: TMailAddressBindingSync,
153
+ createdBy: string,
154
+ ): Promise<TMailAddressBindingSyncResponse> {
155
+ const ownership = this.normalizeMailResourceOwner(binding.owner);
156
+ const { localPart, domain } = this.normalizeMailAddressParts(binding);
157
+ const syncRequest: TSyncRequest = {
158
+ ownership,
159
+ localPart,
160
+ domain,
161
+ inbound: this.toLegacyInboundRoute(binding.inboundTarget),
162
+ enabled: binding.enabled,
163
+ };
164
+
165
+ if (binding.outboundIdentityId !== undefined) {
166
+ syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
167
+ }
168
+
169
+ const result = await this.syncMailIdentity(syncRequest, createdBy);
170
+
171
+ return {
172
+ success: result.success,
173
+ binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
174
+ message: result.message,
175
+ };
176
+ }
177
+
178
+ public async deleteMailAddressBinding(
179
+ id: string,
180
+ createdBy: string,
181
+ ): Promise<TMailAddressBindingDeleteResponse> {
182
+ const identities = await this.readStoredIdentities();
183
+ const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
184
+ if (!identity) {
185
+ return { success: true };
186
+ }
187
+
188
+ const result = await this.syncMailIdentity({
189
+ ownership: identity.ownership,
190
+ localPart: identity.localPart,
191
+ domain: identity.domain,
192
+ delete: true,
193
+ }, createdBy);
194
+
195
+ return {
196
+ success: result.success,
197
+ message: result.message,
198
+ };
199
+ }
200
+
112
201
  public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
113
202
  emailConfig: TConfig,
114
203
  ): Promise<TConfig> {
@@ -251,6 +340,63 @@ export class WorkAppMailManager {
251
340
  return normalized;
252
341
  }
253
342
 
343
+ private normalizeAddress(address: string): string {
344
+ const normalized = address?.trim().toLowerCase();
345
+ const [localPart, domain, extra] = normalized?.split('@') || [];
346
+ if (!localPart || !domain || extra) {
347
+ throw new Error(`Invalid email address: ${address}`);
348
+ }
349
+ return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
350
+ }
351
+
352
+ private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
353
+ const gatewayClientType = owner.gatewayClientType;
354
+ const gatewayClientId = owner.gatewayClientId?.trim();
355
+ const appInstanceId = owner.appInstanceId?.trim();
356
+
357
+ if (gatewayClientType !== 'onebox' && gatewayClientType !== 'cloudly' && gatewayClientType !== 'custom') {
358
+ throw new Error(`Invalid gateway client type: ${gatewayClientType}`);
359
+ }
360
+ if (!gatewayClientId) throw new Error('gatewayClientId is required');
361
+ if (!appInstanceId) throw new Error('appInstanceId is required');
362
+
363
+ return {
364
+ workHosterType: gatewayClientType as interfaces.data.TGatewayClientType,
365
+ workHosterId: gatewayClientId,
366
+ workAppId: appInstanceId,
367
+ };
368
+ }
369
+
370
+ private normalizeMailAddressParts(binding: TMailAddressBindingSync): {
371
+ localPart: string;
372
+ domain: string;
373
+ } {
374
+ const localPart = this.normalizeLocalPart(binding.localPart);
375
+ const domain = this.normalizeDomain(binding.domain);
376
+ const address = this.normalizeAddress(binding.address);
377
+ if (address !== `${localPart}@${domain}`) {
378
+ throw new Error('mail address, localPart, and domain do not match');
379
+ }
380
+ return { localPart, domain };
381
+ }
382
+
383
+ private toLegacyInboundRoute(
384
+ inboundTarget?: TMailAddressBinding['inboundTarget'],
385
+ ): interfaces.data.IWorkAppMailInboundRoute | undefined {
386
+ if (!inboundTarget) return undefined;
387
+ if (inboundTarget.type !== 'smtpForward' || !inboundTarget.smtpForward) {
388
+ throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
389
+ }
390
+
391
+ return this.normalizeInboundRoute({
392
+ enabled: true,
393
+ targetHost: inboundTarget.smtpForward.host,
394
+ targetPort: inboundTarget.smtpForward.port,
395
+ preserveHeaders: inboundTarget.smtpForward.preserveHeaders,
396
+ addHeaders: inboundTarget.smtpForward.addHeaders,
397
+ });
398
+ }
399
+
254
400
  private normalizeInboundRoute(
255
401
  inbound?: interfaces.data.IWorkAppMailInboundRoute,
256
402
  ): interfaces.data.IWorkAppMailInboundRoute | undefined {
@@ -282,6 +428,17 @@ export class WorkAppMailManager {
282
428
  return true;
283
429
  }
284
430
 
431
+ private matchesMailOwner(
432
+ owner: TMailResourceOwner,
433
+ filter?: Partial<TMailResourceOwner>,
434
+ ): boolean {
435
+ if (!filter) return true;
436
+ if (filter.gatewayClientType && filter.gatewayClientType !== owner.gatewayClientType) return false;
437
+ if (filter.gatewayClientId && filter.gatewayClientId !== owner.gatewayClientId) return false;
438
+ if (filter.appInstanceId && filter.appInstanceId !== owner.appInstanceId) return false;
439
+ return true;
440
+ }
441
+
285
442
  private buildExternalKey(
286
443
  ownership: interfaces.data.IWorkAppMailOwnership,
287
444
  address: string,
@@ -298,6 +455,14 @@ export class WorkAppMailManager {
298
455
  return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
299
456
  }
300
457
 
458
+ private buildMailOwnerKey(owner: TMailResourceOwner): string {
459
+ return [
460
+ owner.gatewayClientType,
461
+ owner.gatewayClientId,
462
+ owner.appInstanceId,
463
+ ].join(':');
464
+ }
465
+
301
466
  private buildRouteName(externalKey: string): string {
302
467
  return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
303
468
  }
@@ -334,6 +499,75 @@ export class WorkAppMailManager {
334
499
  };
335
500
  }
336
501
 
502
+ private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
503
+ return {
504
+ gatewayClientType: ownership.workHosterType,
505
+ gatewayClientId: ownership.workHosterId,
506
+ appInstanceId: ownership.workAppId,
507
+ };
508
+ }
509
+
510
+ private toMailInboundTarget(
511
+ inbound?: interfaces.data.IWorkAppMailInboundRoute,
512
+ ): TMailAddressBinding['inboundTarget'] {
513
+ if (!inbound?.enabled) return undefined;
514
+ return {
515
+ type: 'smtpForward',
516
+ smtpForward: {
517
+ host: inbound.targetHost,
518
+ port: inbound.targetPort,
519
+ preserveHeaders: inbound.preserveHeaders,
520
+ addHeaders: inbound.addHeaders,
521
+ },
522
+ };
523
+ }
524
+
525
+ private toMailAddressBinding(
526
+ identity: interfaces.data.IWorkAppMailIdentity,
527
+ ): TMailAddressBinding {
528
+ return {
529
+ id: identity.id,
530
+ owner: this.toMailOwner(identity.ownership),
531
+ address: identity.address,
532
+ localPart: identity.localPart,
533
+ domain: identity.domain,
534
+ enabled: identity.enabled,
535
+ status: identity.enabled ? 'active' : 'disabled',
536
+ inboundTarget: this.toMailInboundTarget(identity.inbound),
537
+ outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
538
+ recipientPolicy: {
539
+ mode: 'staticList',
540
+ staticRecipients: [identity.address],
541
+ },
542
+ createdAt: identity.createdAt,
543
+ updatedAt: identity.updatedAt,
544
+ createdBy: identity.createdBy,
545
+ };
546
+ }
547
+
548
+ private toWorkAppMailBinding(
549
+ identities: IStoredWorkAppMailIdentity[],
550
+ ): TWorkAppMailBinding {
551
+ const [firstIdentity] = identities;
552
+ const owner = this.toMailOwner(firstIdentity.ownership);
553
+ const enabledIdentities = identities.filter((identity) => identity.enabled);
554
+ const smtpIdentities = identities.filter((identity) => identity.smtp.enabled);
555
+
556
+ return {
557
+ id: `workapp-mail-${this.hashExternalKey(this.buildMailOwnerKey(owner)).slice(0, 32)}`,
558
+ owner,
559
+ enabled: enabledIdentities.length > 0,
560
+ status: enabledIdentities.length > 0 ? 'active' : 'disabled',
561
+ addressBindingIds: identities.map((identity) => identity.id),
562
+ outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
563
+ defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
564
+ inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
565
+ createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
566
+ updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
567
+ createdBy: firstIdentity.createdBy,
568
+ };
569
+ }
570
+
337
571
  private toPublicIdentity(
338
572
  identity: IStoredWorkAppMailIdentity,
339
573
  ): interfaces.data.IWorkAppMailIdentity {
@@ -39,14 +39,7 @@ export class ConfigHandler {
39
39
  ? 'custom'
40
40
  : 'filesystem';
41
41
 
42
- // Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
43
- let proxyIps = opts.proxyIps || [];
44
- if (proxyIps.length === 0 && dcRouter.smartProxy) {
45
- const spSettings = (dcRouter.smartProxy as any).settings;
46
- if (spSettings?.proxyIPs?.length > 0) {
47
- proxyIps = spSettings.proxyIPs;
48
- }
49
- }
42
+ const proxyIps = opts.proxyIps || [];
50
43
 
51
44
  const system: interfaces.requests.IConfigData['system'] = {
52
45
  baseDir: resolvedPaths.dcrouterHomeDir,
@@ -257,6 +257,83 @@ export class WorkHosterHandler {
257
257
  },
258
258
  ),
259
259
  );
260
+
261
+ this.typedrouter.addTypedHandler(
262
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListMailAddressBindings>(
263
+ 'listMailAddressBindings',
264
+ async (dataArg) => {
265
+ const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
266
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
267
+ if (!manager) return { bindings: [] };
268
+ return {
269
+ bindings: await manager.listMailAddressBindings({
270
+ owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
271
+ domain: dataArg.domain,
272
+ address: dataArg.address,
273
+ }),
274
+ };
275
+ },
276
+ ),
277
+ );
278
+
279
+ this.typedrouter.addTypedHandler(
280
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding>(
281
+ 'syncMailAddressBinding',
282
+ async (dataArg) => {
283
+ const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
284
+ this.assertCapability(auth, 'syncRoutes');
285
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
286
+ if (!manager) {
287
+ return { success: false, message: 'WorkApp mail manager not initialized' };
288
+ }
289
+ try {
290
+ const binding = {
291
+ ...dataArg.binding,
292
+ owner: this.resolveMailOwner(auth, dataArg.binding.owner),
293
+ };
294
+ this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
295
+ return await manager.syncMailAddressBinding(binding, auth.userId);
296
+ } catch (error) {
297
+ return { success: false, message: (error as Error).message };
298
+ }
299
+ },
300
+ ),
301
+ );
302
+
303
+ this.typedrouter.addTypedHandler(
304
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding>(
305
+ 'deleteMailAddressBinding',
306
+ async (dataArg) => {
307
+ const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
308
+ this.assertCapability(auth, 'syncRoutes');
309
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
310
+ if (!manager) {
311
+ return { success: false, message: 'WorkApp mail manager not initialized' };
312
+ }
313
+ if (auth.token?.policy?.role === 'gatewayClient') {
314
+ const bindings = await manager.listMailAddressBindings({
315
+ owner: this.resolveMailOwnerFilter(auth),
316
+ });
317
+ const binding = bindings.find((candidate) => candidate.id === dataArg.id);
318
+ if (!binding) return { success: true };
319
+ return await manager.deleteMailAddressBinding(binding.id, auth.userId);
320
+ }
321
+ return await manager.deleteMailAddressBinding(dataArg.id, auth.userId);
322
+ },
323
+ ),
324
+ );
325
+
326
+ this.typedrouter.addTypedHandler(
327
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListWorkAppMailBindings>(
328
+ 'listWorkAppMailBindings',
329
+ async (dataArg) => {
330
+ const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
331
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
332
+ if (!manager) return { bindings: [] };
333
+ return { bindings: await manager.listWorkAppMailBindings(this.resolveMailOwnerFilter(auth, dataArg.owner)) };
334
+ },
335
+ ),
336
+ );
260
337
  }
261
338
 
262
339
  private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
@@ -335,7 +412,7 @@ export class WorkHosterHandler {
335
412
  const policy = auth.token?.policy;
336
413
  if (!policy || policy.role !== 'gatewayClient') return;
337
414
  if (policy.capabilities?.[capability] === true) return;
338
- throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
415
+ throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
339
416
  }
340
417
 
341
418
  private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
@@ -376,6 +453,39 @@ export class WorkHosterHandler {
376
453
  return ownership as Required<interfaces.data.IGatewayClientOwnership>;
377
454
  }
378
455
 
456
+ private resolveMailOwnerFilter(
457
+ auth: TAuthContext,
458
+ owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
459
+ ): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
460
+ const policy = auth.token?.policy;
461
+ if (policy?.role !== 'gatewayClient') return owner;
462
+ if (!policy.gatewayClient) {
463
+ throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
464
+ }
465
+ if (owner?.gatewayClientType && owner.gatewayClientType !== policy.gatewayClient.type) {
466
+ throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
467
+ }
468
+ if (owner?.gatewayClientId && owner.gatewayClientId !== policy.gatewayClient.id) {
469
+ throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
470
+ }
471
+ return {
472
+ ...owner,
473
+ gatewayClientType: policy.gatewayClient.type,
474
+ gatewayClientId: policy.gatewayClient.id,
475
+ };
476
+ }
477
+
478
+ private resolveMailOwner(
479
+ auth: TAuthContext,
480
+ owner: plugins.servezoneInterfaces.data.IMailResourceOwner,
481
+ ): plugins.servezoneInterfaces.data.IMailResourceOwner {
482
+ const resolvedOwner = this.resolveMailOwnerFilter(auth, owner);
483
+ if (!resolvedOwner?.gatewayClientType || !resolvedOwner.gatewayClientId) {
484
+ throw new plugins.typedrequest.TypedResponseError('mail owner is missing gateway client type or id');
485
+ }
486
+ return resolvedOwner as plugins.servezoneInterfaces.data.IMailResourceOwner;
487
+ }
488
+
379
489
  private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
380
490
  const policy = auth.token?.policy;
381
491
  if (!policy || policy.role !== 'gatewayClient') return;
@@ -404,6 +514,26 @@ export class WorkHosterHandler {
404
514
  }
405
515
  }
406
516
 
517
+ private assertMailForwardTargetAllowed(
518
+ auth: TAuthContext,
519
+ target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
520
+ ): void {
521
+ const policy = auth.token?.policy;
522
+ if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return;
523
+ const allowedTargets = policy.allowedRouteTargets || [];
524
+ if (allowedTargets.length === 0) {
525
+ throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
526
+ }
527
+ const host = target.smtpForward.host.trim().toLowerCase();
528
+ const port = Number(target.smtpForward.port);
529
+ const allowed = allowedTargets.some((allowedTarget) => {
530
+ return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
531
+ });
532
+ if (!allowed) {
533
+ throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${host}:${port}`);
534
+ }
535
+ }
536
+
407
537
  private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
408
538
  const normalizedHostname = hostname.trim().toLowerCase();
409
539
  if (!normalizedHostname) return false;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '14.0.0',
6
+ version: '14.1.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }