@serve.zone/dcrouter 15.3.0 → 15.4.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.
@@ -1,7 +1,10 @@
1
1
  import type {
2
2
  IEmailRoute,
3
+ IMessageAcceptanceContext,
4
+ IMessageAcceptanceDecision,
3
5
  IUnifiedEmailServerOptions,
4
6
  } from '@push.rocks/smartmta';
7
+ import type { Buffer } from 'node:buffer';
5
8
  import * as plugins from '../plugins.js';
6
9
  import type * as interfaces from '../../ts_interfaces/index.js';
7
10
 
@@ -21,10 +24,28 @@ type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
21
24
  type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
22
25
  type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
23
26
  type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
27
+ type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
24
28
  type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
25
29
 
26
- interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
30
+ interface IStoredWorkAppMailIdentity {
31
+ id: string;
32
+ externalKey: string;
33
+ ownership: interfaces.data.IWorkAppMailOwnership;
34
+ address: string;
35
+ localPart: string;
36
+ domain: string;
37
+ enabled: boolean;
38
+ displayName?: string;
39
+ inbound?: interfaces.data.IWorkAppMailInboundRoute;
40
+ smtp: {
41
+ enabled: boolean;
42
+ username: string;
43
+ };
44
+ createdAt: number;
45
+ updatedAt: number;
46
+ createdBy: string;
27
47
  smtpPassword: string;
48
+ smtpLastRotatedAt?: number;
28
49
  }
29
50
 
30
51
  interface IStoredWorkAppMailState {
@@ -34,6 +55,7 @@ interface IStoredWorkAppMailState {
34
55
 
35
56
  export class WorkAppMailManager {
36
57
  private readonly storageKey = '/workhosters/mail-identities.json';
58
+ private identityMutationPromise: Promise<unknown> = Promise.resolve();
37
59
 
38
60
  constructor(private dcRouterRef: any) {}
39
61
 
@@ -49,6 +71,21 @@ export class WorkAppMailManager {
49
71
  public async syncMailIdentity(
50
72
  request: TSyncRequest,
51
73
  createdBy: string,
74
+ ): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
75
+ return await this.runIdentityMutationExclusive(async () => {
76
+ return await this.syncMailIdentityInner(request, createdBy);
77
+ });
78
+ }
79
+
80
+ private async runIdentityMutationExclusive<T>(actionArg: () => Promise<T>): Promise<T> {
81
+ const actionPromise = this.identityMutationPromise.catch(() => undefined).then(actionArg);
82
+ this.identityMutationPromise = actionPromise.then(() => undefined, () => undefined);
83
+ return await actionPromise;
84
+ }
85
+
86
+ private async syncMailIdentityInner(
87
+ request: TSyncRequest,
88
+ createdBy: string,
52
89
  ): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
53
90
  if (!this.dcRouterRef.options.emailConfig) {
54
91
  return { success: false, message: 'Email server is not configured' };
@@ -80,9 +117,10 @@ export class WorkAppMailManager {
80
117
 
81
118
  const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
82
119
  const now = Date.now();
83
- const smtpPassword = existingIdentity && !request.resetSmtpPassword
84
- ? existingIdentity.smtpPassword
85
- : this.generateSmtpPassword();
120
+ const shouldRotateSmtpPassword = !existingIdentity || request.resetSmtpPassword;
121
+ const smtpPassword = shouldRotateSmtpPassword
122
+ ? this.generateSmtpPassword()
123
+ : existingIdentity.smtpPassword;
86
124
  const identity: IStoredWorkAppMailIdentity = {
87
125
  id: existingIdentity?.id || plugins.smartunique.shortId(),
88
126
  externalKey,
@@ -101,6 +139,9 @@ export class WorkAppMailManager {
101
139
  updatedAt: now,
102
140
  createdBy: existingIdentity?.createdBy || createdBy,
103
141
  smtpPassword,
142
+ smtpLastRotatedAt: shouldRotateSmtpPassword
143
+ ? now
144
+ : existingIdentity?.smtpLastRotatedAt || existingIdentity?.createdAt || now,
104
145
  };
105
146
 
106
147
  if (existingIndex >= 0) {
@@ -162,32 +203,135 @@ export class WorkAppMailManager {
162
203
  binding: TMailAddressBindingSync,
163
204
  createdBy: string,
164
205
  ): Promise<TMailAddressBindingSyncResponse> {
165
- const ownership = this.normalizeMailResourceOwner(binding.owner);
166
- const { localPart, domain } = this.normalizeMailAddressParts(binding);
167
- const syncRequest: TSyncRequest = {
168
- ownership,
169
- localPart,
170
- domain,
171
- inbound: this.toLegacyInboundRoute(binding.inboundTarget),
172
- enabled: binding.enabled,
173
- };
206
+ return await this.runIdentityMutationExclusive(async () => {
207
+ const ownership = this.normalizeMailResourceOwner(binding.owner);
208
+ const { localPart, domain } = this.normalizeMailAddressParts(binding);
209
+ const syncRequest: TSyncRequest = {
210
+ ownership,
211
+ localPart,
212
+ domain,
213
+ inbound: this.toLegacyInboundRoute(binding.inboundTarget),
214
+ enabled: binding.enabled,
215
+ };
216
+
217
+ if (binding.outboundEnabled !== undefined) {
218
+ syncRequest.smtpEnabled = binding.outboundEnabled;
219
+ } else if (binding.outboundIdentityId !== undefined) {
220
+ syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
221
+ }
222
+
223
+ const result = await this.syncMailIdentityInner(syncRequest, createdBy);
224
+ const storedIdentity = result.identity
225
+ ? (await this.readStoredIdentities()).find((identity) => identity.id === result.identity!.id)
226
+ : undefined;
227
+
228
+ return {
229
+ success: result.success,
230
+ binding: storedIdentity ? this.toMailAddressBinding(storedIdentity) : undefined,
231
+ message: result.message,
232
+ };
233
+ });
234
+ }
235
+
236
+ public async rotateMailCredential(
237
+ credentialId: string,
238
+ createdBy: string,
239
+ owner?: Partial<TMailResourceOwner>,
240
+ ): Promise<TMailCredentialRotateResponse> {
241
+ return await this.runIdentityMutationExclusive(async () => {
242
+ return await this.rotateMailCredentialInner(credentialId, createdBy, owner);
243
+ });
244
+ }
245
+
246
+ private async rotateMailCredentialInner(
247
+ credentialId: string,
248
+ createdBy: string,
249
+ owner?: Partial<TMailResourceOwner>,
250
+ ): Promise<TMailCredentialRotateResponse> {
251
+ if (!this.dcRouterRef.options.emailConfig) {
252
+ return { success: false, message: 'Email server is not configured' };
253
+ }
174
254
 
175
- if (binding.outboundIdentityId !== undefined) {
176
- syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
255
+ const normalizedCredentialId = credentialId?.trim();
256
+ if (!normalizedCredentialId) {
257
+ return { success: false, message: 'credentialId is required' };
177
258
  }
178
259
 
179
- const result = await this.syncMailIdentity(syncRequest, createdBy);
260
+ const identities = await this.readStoredIdentities();
261
+ const identityIndex = identities.findIndex((identityArg) => {
262
+ return this.matchesMailOwner(this.toMailOwner(identityArg.ownership), owner)
263
+ && (
264
+ identityArg.id === normalizedCredentialId
265
+ || identityArg.externalKey === normalizedCredentialId
266
+ || identityArg.smtp.username === normalizedCredentialId
267
+ );
268
+ });
269
+ if (identityIndex < 0) {
270
+ return { success: false, message: 'Mail credential not found' };
271
+ }
272
+
273
+ const identity = identities[identityIndex];
274
+ if (!identity.enabled || !identity.smtp.enabled) {
275
+ return { success: false, message: 'Mail credential is disabled' };
276
+ }
277
+
278
+ const now = Date.now();
279
+ identities[identityIndex] = {
280
+ ...identity,
281
+ smtpPassword: this.generateSmtpPassword(),
282
+ smtpLastRotatedAt: now,
283
+ updatedAt: now,
284
+ };
285
+
286
+ await this.writeStoredIdentities(identities);
287
+ await this.applyStoredIdentitiesToRuntime(identities);
180
288
 
181
289
  return {
182
- success: result.success,
183
- binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
184
- message: result.message,
290
+ success: true,
291
+ credential: this.toMailCredentialSecret(identities[identityIndex]),
185
292
  };
186
293
  }
187
294
 
295
+ public async enforceManagedSmtpSender(
296
+ context: IMessageAcceptanceContext,
297
+ ): Promise<IMessageAcceptanceDecision | undefined> {
298
+ const username = context.session.user?.username;
299
+ if (!username || !this.isManagedSmtpUsername(username)) {
300
+ return undefined;
301
+ }
302
+
303
+ const identities = await this.readStoredIdentities();
304
+ const identity = identities.find((identityArg) => identityArg.smtp.username === username);
305
+ if (!identity || !identity.enabled || !identity.smtp.enabled) {
306
+ return this.rejectSenderAuthorization('Managed SMTP credential is not active');
307
+ }
308
+
309
+ const envelopeFrom = this.normalizeMailboxAddress(
310
+ context.session.envelope?.mailFrom?.address || context.session.mailFrom || '',
311
+ );
312
+ const headerFrom = this.extractSingleHeaderFromAddress(context.rawMessage, context.email.from);
313
+ if (!envelopeFrom || !headerFrom) {
314
+ return this.rejectSenderAuthorization('Managed SMTP sender must use exactly one From address');
315
+ }
316
+ if (envelopeFrom !== identity.address || headerFrom !== identity.address) {
317
+ return this.rejectSenderAuthorization('Managed SMTP sender is not authorized for this From address');
318
+ }
319
+
320
+ return undefined;
321
+ }
322
+
188
323
  public async deleteMailAddressBinding(
189
324
  id: string,
190
325
  createdBy: string,
326
+ ): Promise<TMailAddressBindingDeleteResponse> {
327
+ return await this.runIdentityMutationExclusive(async () => {
328
+ return await this.deleteMailAddressBindingInner(id, createdBy);
329
+ });
330
+ }
331
+
332
+ private async deleteMailAddressBindingInner(
333
+ id: string,
334
+ createdBy: string,
191
335
  ): Promise<TMailAddressBindingDeleteResponse> {
192
336
  const identities = await this.readStoredIdentities();
193
337
  const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
@@ -195,7 +339,7 @@ export class WorkAppMailManager {
195
339
  return { success: true };
196
340
  }
197
341
 
198
- const result = await this.syncMailIdentity({
342
+ const result = await this.syncMailIdentityInner({
199
343
  ownership: identity.ownership,
200
344
  localPart: identity.localPart,
201
345
  domain: identity.domain,
@@ -255,6 +399,9 @@ export class WorkAppMailManager {
255
399
  const generatedRoutes = identities
256
400
  .filter((identity) => identity.enabled && identity.inbound?.enabled)
257
401
  .map((identity) => this.buildInboundRoute(identity));
402
+ const generatedOutboundRoutes = identities
403
+ .filter((identity) => identity.enabled && identity.smtp.enabled)
404
+ .map((identity) => this.buildOutboundRoute(identity));
258
405
  const configuredRoutes = (emailConfig.routes || [])
259
406
  .filter((route) => !this.isManagedMailRouteName(route.name));
260
407
  const generatedUsers = identities
@@ -268,7 +415,7 @@ export class WorkAppMailManager {
268
415
 
269
416
  return {
270
417
  ...emailConfig,
271
- routes: [...configuredRoutes, ...generatedRoutes],
418
+ routes: [...configuredRoutes, ...generatedRoutes, ...generatedOutboundRoutes],
272
419
  auth: {
273
420
  ...(emailConfig.auth || {}),
274
421
  users: [...configuredUsers, ...generatedUsers],
@@ -301,6 +448,34 @@ export class WorkAppMailManager {
301
448
  };
302
449
  }
303
450
 
451
+ private buildOutboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
452
+ return {
453
+ name: this.buildOutboundRouteName(identity.externalKey),
454
+ priority: 900,
455
+ match: {
456
+ authenticated: true,
457
+ senders: identity.address,
458
+ },
459
+ action: {
460
+ type: 'process',
461
+ allowRelay: true,
462
+ process: {
463
+ dkim: true,
464
+ queue: 'normal',
465
+ },
466
+ options: {
467
+ mtaOptions: {
468
+ dkimSign: true,
469
+ dkimOptions: {
470
+ domainName: identity.domain,
471
+ keySelector: 'default',
472
+ },
473
+ },
474
+ },
475
+ },
476
+ };
477
+ }
478
+
304
479
  private async ensureEmailDomainConfigured(domain: string): Promise<void> {
305
480
  const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
306
481
  if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
@@ -359,6 +534,53 @@ export class WorkAppMailManager {
359
534
  return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
360
535
  }
361
536
 
537
+ private normalizeMailboxAddress(addressArg: string): string | undefined {
538
+ const trimmed = addressArg?.trim();
539
+ if (!trimmed || trimmed === '<>') return undefined;
540
+ const angleMatch = trimmed.match(/<([^<>]+)>/);
541
+ const candidate = (angleMatch ? angleMatch[1] : trimmed).replace(/^mailto:/i, '').trim();
542
+ try {
543
+ return this.normalizeAddress(candidate);
544
+ } catch {
545
+ return undefined;
546
+ }
547
+ }
548
+
549
+ private extractSingleHeaderFromAddress(rawMessageArg: Buffer, parsedFromArg: string): string | undefined {
550
+ const headerSlice = rawMessageArg.subarray(0, 64 * 1024);
551
+ const crlfHeaderEnd = headerSlice.indexOf('\r\n\r\n');
552
+ const lfHeaderEnd = headerSlice.indexOf('\n\n');
553
+ const headerEnd = crlfHeaderEnd >= 0
554
+ ? crlfHeaderEnd
555
+ : lfHeaderEnd >= 0
556
+ ? lfHeaderEnd
557
+ : headerSlice.length;
558
+ const headersText = headerSlice.subarray(0, headerEnd).toString('utf8');
559
+ const unfoldedLines: string[] = [];
560
+ for (const line of headersText.split(/\r?\n/)) {
561
+ if (/^[\t ]/.test(line) && unfoldedLines.length > 0) {
562
+ unfoldedLines[unfoldedLines.length - 1] += ` ${line.trim()}`;
563
+ } else {
564
+ unfoldedLines.push(line);
565
+ }
566
+ }
567
+ const fromHeaders = unfoldedLines.filter((line) => /^from\s*:/i.test(line));
568
+ if (fromHeaders.length > 1) return undefined;
569
+ const headerValue = fromHeaders.length === 1
570
+ ? fromHeaders[0].replace(/^from\s*:/i, '').trim()
571
+ : parsedFromArg;
572
+ if (!headerValue || headerValue.includes(',')) return undefined;
573
+ return this.normalizeMailboxAddress(headerValue);
574
+ }
575
+
576
+ private rejectSenderAuthorization(messageArg: string): IMessageAcceptanceDecision {
577
+ return {
578
+ accepted: false,
579
+ smtpCode: 553,
580
+ smtpMessage: messageArg,
581
+ };
582
+ }
583
+
362
584
  private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
363
585
  const gatewayClientType = owner.gatewayClientType;
364
586
  const gatewayClientId = owner.gatewayClientId?.trim();
@@ -477,6 +699,10 @@ export class WorkAppMailManager {
477
699
  return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
478
700
  }
479
701
 
702
+ private buildOutboundRouteName(externalKey: string): string {
703
+ return `workapp-mail-outbound-${this.hashExternalKey(externalKey).slice(0, 32)}`;
704
+ }
705
+
480
706
  private hashExternalKey(externalKey: string): string {
481
707
  return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
482
708
  }
@@ -509,6 +735,31 @@ export class WorkAppMailManager {
509
735
  };
510
736
  }
511
737
 
738
+ private toMailCredentialPublic(
739
+ identity: IStoredWorkAppMailIdentity,
740
+ ): plugins.servezoneInterfaces.data.IMailCredentialPublic {
741
+ return {
742
+ id: identity.smtp.username,
743
+ type: 'smtp',
744
+ status: identity.enabled && identity.smtp.enabled ? 'active' : 'disabled',
745
+ username: identity.smtp.username,
746
+ scopes: [`from:${identity.address}`],
747
+ createdAt: identity.createdAt,
748
+ updatedAt: identity.updatedAt,
749
+ lastRotatedAt: identity.smtpLastRotatedAt || identity.createdAt,
750
+ };
751
+ }
752
+
753
+ private toMailCredentialSecret(
754
+ identity: IStoredWorkAppMailIdentity,
755
+ ): plugins.servezoneInterfaces.data.IMailCredentialOneTimeSecret {
756
+ return {
757
+ credential: this.toMailCredentialPublic(identity),
758
+ secret: identity.smtpPassword,
759
+ secretShownOnce: true,
760
+ };
761
+ }
762
+
512
763
  private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
513
764
  return {
514
765
  gatewayClientType: ownership.workHosterType,
@@ -545,6 +796,9 @@ export class WorkAppMailManager {
545
796
  status: identity.enabled ? 'active' : 'disabled',
546
797
  inboundTarget: this.toMailInboundTarget(identity.inbound),
547
798
  outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
799
+ ...(identity.smtp.enabled ? {
800
+ outboundCredential: this.toMailCredentialPublic(identity as IStoredWorkAppMailIdentity),
801
+ } : {}),
548
802
  recipientPolicy: {
549
803
  mode: 'staticList',
550
804
  staticRecipients: [identity.address],
@@ -552,7 +806,7 @@ export class WorkAppMailManager {
552
806
  createdAt: identity.createdAt,
553
807
  updatedAt: identity.updatedAt,
554
808
  createdBy: identity.createdBy,
555
- };
809
+ } as TMailAddressBinding;
556
810
  }
557
811
 
558
812
  private toWorkAppMailBinding(
@@ -581,7 +835,7 @@ export class WorkAppMailManager {
581
835
  private toPublicIdentity(
582
836
  identity: IStoredWorkAppMailIdentity,
583
837
  ): interfaces.data.IWorkAppMailIdentity {
584
- const { smtpPassword, ...publicIdentity } = identity;
838
+ const { smtpPassword, smtpLastRotatedAt, ...publicIdentity } = identity;
585
839
  return publicIdentity;
586
840
  }
587
841
  }
@@ -211,12 +211,13 @@ export class GatewayClientHandler {
211
211
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
212
212
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
213
213
  if (!manager) return { bindings: [] };
214
+ const bindings = await manager.listMailAddressBindings({
215
+ owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
216
+ domain: dataArg.domain,
217
+ address: dataArg.address,
218
+ });
214
219
  return {
215
- bindings: await manager.listMailAddressBindings({
216
- owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
217
- domain: dataArg.domain,
218
- address: dataArg.address,
219
- }),
220
+ bindings: this.filterMailBindingsAllowed(auth, bindings),
220
221
  };
221
222
  },
222
223
  ),
@@ -237,6 +238,7 @@ export class GatewayClientHandler {
237
238
  ...dataArg.binding,
238
239
  owner: this.resolveMailOwner(auth, dataArg.binding.owner),
239
240
  };
241
+ this.assertMailAddressAllowed(auth, binding.address, binding.domain);
240
242
  this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
241
243
  return await manager.syncMailAddressBinding(binding, auth.userId);
242
244
  } catch (error) {
@@ -257,9 +259,9 @@ export class GatewayClientHandler {
257
259
  return { success: false, message: 'WorkApp mail manager not initialized' };
258
260
  }
259
261
  if (auth.token?.policy?.role === 'gatewayClient') {
260
- const bindings = await manager.listMailAddressBindings({
262
+ const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({
261
263
  owner: this.resolveMailOwnerFilter(auth),
262
- });
264
+ }));
263
265
  const binding = bindings.find((candidate) => candidate.id === dataArg.id);
264
266
  if (!binding) return { success: true };
265
267
  return await manager.deleteMailAddressBinding(binding.id, auth.userId);
@@ -276,7 +278,39 @@ export class GatewayClientHandler {
276
278
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
277
279
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
278
280
  if (!manager) return { bindings: [] };
279
- return { bindings: await manager.listWorkAppMailBindings(this.resolveMailOwnerFilter(auth, dataArg.owner)) };
281
+ const owner = this.resolveMailOwnerFilter(auth, dataArg.owner);
282
+ const bindings = await manager.listWorkAppMailBindings(owner);
283
+ return { bindings: await this.filterWorkAppMailBindingsAllowed(auth, manager, owner, bindings) };
284
+ },
285
+ ),
286
+ );
287
+
288
+ this.typedrouter.addTypedHandler(
289
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential>(
290
+ 'rotateMailCredential',
291
+ async (dataArg) => {
292
+ const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
293
+ this.assertCapability(auth, 'syncRoutes');
294
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
295
+ if (!manager) {
296
+ return { success: false, message: 'WorkApp mail manager not initialized' };
297
+ }
298
+ const owner = this.resolveMailOwnerFilter(auth);
299
+ if (auth.token?.policy?.role === 'gatewayClient') {
300
+ const credentialId = dataArg.credentialId?.trim();
301
+ const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner }));
302
+ const binding = bindings.find((bindingArg) => bindingArg.outboundIdentityId === credentialId
303
+ || bindingArg.outboundCredential?.id === credentialId
304
+ || bindingArg.outboundCredential?.username === credentialId);
305
+ if (!binding) {
306
+ return { success: false, message: 'Mail credential not found' };
307
+ }
308
+ }
309
+ return await manager.rotateMailCredential(
310
+ dataArg.credentialId,
311
+ auth.userId,
312
+ owner,
313
+ );
280
314
  },
281
315
  ),
282
316
  );
@@ -456,20 +490,101 @@ export class GatewayClientHandler {
456
490
  auth: TAuthContext,
457
491
  target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
458
492
  ): void {
459
- const policy = auth.token?.policy;
460
- if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return;
461
- const allowedTargets = policy.allowedRouteTargets || [];
462
- if (allowedTargets.length === 0) {
493
+ if (this.isMailForwardTargetAllowed(auth, target)) return;
494
+ const targetDescription = target?.smtpForward
495
+ ? `${target.smtpForward.host.trim().toLowerCase()}:${Number(target.smtpForward.port)}`
496
+ : 'unknown';
497
+ if ((auth.token?.policy?.allowedRouteTargets || []).length === 0) {
463
498
  throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
464
499
  }
500
+ throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${targetDescription}`);
501
+ }
502
+
503
+ private isMailForwardTargetAllowed(
504
+ auth: TAuthContext,
505
+ target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
506
+ ): boolean {
507
+ const policy = auth.token?.policy;
508
+ if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return true;
509
+ const allowedTargets = policy.allowedRouteTargets || [];
510
+ if (allowedTargets.length === 0) return false;
465
511
  const host = target.smtpForward.host.trim().toLowerCase();
466
512
  const port = Number(target.smtpForward.port);
467
- const allowed = allowedTargets.some((allowedTarget) => {
513
+ return allowedTargets.some((allowedTarget) => {
468
514
  return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
469
515
  });
470
- if (!allowed) {
471
- throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${host}:${port}`);
516
+ }
517
+
518
+ private assertMailAddressAllowed(
519
+ auth: TAuthContext,
520
+ addressArg: string,
521
+ domainArg: string,
522
+ ): void {
523
+ if (this.isMailAddressAllowed(auth, addressArg, domainArg)) return;
524
+ throw new plugins.typedrequest.TypedResponseError('mail domain is outside token policy');
525
+ }
526
+
527
+ private filterMailBindingsAllowed(
528
+ auth: TAuthContext,
529
+ bindingsArg: plugins.servezoneInterfaces.data.IMailAddressBinding[],
530
+ ): plugins.servezoneInterfaces.data.IMailAddressBinding[] {
531
+ return bindingsArg.filter((bindingArg) => {
532
+ try {
533
+ return this.isMailAddressAllowed(auth, bindingArg.address, bindingArg.domain)
534
+ && this.isMailForwardTargetAllowed(auth, bindingArg.inboundTarget);
535
+ } catch {
536
+ return false;
537
+ }
538
+ });
539
+ }
540
+
541
+ private async filterWorkAppMailBindingsAllowed(
542
+ auth: TAuthContext,
543
+ managerArg: any,
544
+ ownerArg: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined,
545
+ bindingsArg: plugins.servezoneInterfaces.data.IWorkAppMailBinding[],
546
+ ): Promise<plugins.servezoneInterfaces.data.IWorkAppMailBinding[]> {
547
+ if (auth.token?.policy?.role !== 'gatewayClient') return bindingsArg;
548
+ const allowedAddressBindings = this.filterMailBindingsAllowed(
549
+ auth,
550
+ await managerArg.listMailAddressBindings({ owner: ownerArg }),
551
+ );
552
+ const allowedAddressIds = new Set(allowedAddressBindings.map((bindingArg) => bindingArg.id));
553
+ return bindingsArg
554
+ .map((bindingArg) => {
555
+ const addressBindingIds = (bindingArg.addressBindingIds || []).filter((idArg) => allowedAddressIds.has(idArg));
556
+ const allowedForBinding = allowedAddressBindings.filter((addressBindingArg) => addressBindingIds.includes(addressBindingArg.id));
557
+ const allowedOutboundIds = new Set(allowedForBinding
558
+ .map((addressBindingArg) => addressBindingArg.outboundIdentityId)
559
+ .filter((identityIdArg): identityIdArg is string => Boolean(identityIdArg)));
560
+ const defaultFrom = allowedForBinding.find((addressBindingArg) => allowedOutboundIds.has(addressBindingArg.outboundIdentityId || ''))?.address
561
+ || allowedForBinding[0]?.address;
562
+ return {
563
+ ...bindingArg,
564
+ addressBindingIds,
565
+ outboundIdentityIds: (bindingArg.outboundIdentityIds || []).filter((idArg) => allowedOutboundIds.has(idArg)),
566
+ defaultFrom,
567
+ inboundTarget: undefined,
568
+ };
569
+ })
570
+ .filter((bindingArg) => (bindingArg.addressBindingIds || []).length > 0);
571
+ }
572
+
573
+ private isMailAddressAllowed(
574
+ auth: TAuthContext,
575
+ addressArg: string,
576
+ domainArg: string,
577
+ ): boolean {
578
+ const policy = auth.token?.policy;
579
+ if (!policy || policy.role !== 'gatewayClient') return true;
580
+ const normalizedDomain = domainArg.trim().toLowerCase();
581
+ const normalizedAddress = addressArg.trim().toLowerCase();
582
+ if (!normalizedAddress.endsWith(`@${normalizedDomain}`)) {
583
+ throw new plugins.typedrequest.TypedResponseError('mail address does not match domain');
472
584
  }
585
+ const patterns = policy.hostnamePatterns || [];
586
+ return this.matchesHostnamePatterns(normalizedDomain, patterns)
587
+ || patterns.some((patternArg) => patternArg.trim().toLowerCase() === `*.${normalizedDomain}`);
473
588
  }
474
589
 
475
590
  private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
package/ts/readme.md CHANGED
@@ -56,7 +56,7 @@ await router.start();
56
56
  ## What `DcRouter` Manages
57
57
 
58
58
  - SmartProxy for HTTP/HTTPS/TCP routes
59
- - `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
59
+ - `UnifiedEmailServer` for SMTP ingress, delivery, managed app address bindings, and outbound SMTP submission when `emailConfig` is present
60
60
  - DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
61
61
  - embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
62
62
  - VPN, RADIUS, and remote ingress services when their config blocks are enabled
@@ -68,6 +68,7 @@ await router.start();
68
68
  - The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
69
69
  - System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
70
70
  - API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
71
+ - Gateway-client mail bindings can create inbound `smtpForward` routes and managed SMTP users. Managed users are restricted to their exact claimed address for envelope and header `From`.
71
72
  - Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
72
73
  - The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
73
74
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '15.3.0',
6
+ version: '15.4.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }