Compare commits

..

2 Commits

Author SHA1 Message Date
jkunz a97c4963d6 v14.1.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m40s
2026-06-05 14:39:09 +00:00
jkunz 62271c1819 feat(workapp-mail): add shared WorkApp mail address binding APIs 2026-06-05 14:34:32 +00:00
10 changed files with 689 additions and 18 deletions
+7 -1
View File
@@ -1,8 +1,14 @@
# Changelog
## Pending
## 2026-06-05 - 14.1.0
### Features
- add shared WorkApp mail address binding APIs (workapp-mail)
- Adds list, sync, and delete support for shared mail address bindings.
- Maps shared mail address bindings to stored WorkApp mail identities and grouped WorkApp mail bindings.
- Enforces gateway client ownership and allowed mail forward targets for gateway-scoped tokens.
- Updates interface dependencies for shared mail binding request types.
## 2026-06-05 - 14.0.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "14.0.1",
"version": "14.1.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "14.0.1",
"version": "14.1.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -29,7 +29,7 @@
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.2",
"@git.zone/tsdocker": "^2.4.3",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
@@ -70,7 +70,7 @@
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.13.0",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/interfaces": "^6.3.0",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
+10 -10
View File
@@ -111,8 +111,8 @@ importers:
specifier: ^2.13.0
version: 2.13.0(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^6.2.1
version: 6.2.1
specifier: ^6.3.0
version: 6.3.0
'@serve.zone/remoteingress':
specifier: ^4.23.0
version: 4.23.0
@@ -142,8 +142,8 @@ importers:
specifier: ^1.5.0
version: 1.5.0
'@git.zone/tsdocker':
specifier: ^2.4.2
version: 2.4.2
specifier: ^2.4.3
version: 2.4.3
'@git.zone/tsrun':
specifier: ^2.0.4
version: 2.0.4
@@ -733,8 +733,8 @@ packages:
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
hasBin: true
'@git.zone/tsdocker@2.4.2':
resolution: {integrity: sha512-cLG/o1TL7P62td/MUiJgnKYm0HjOFuPzh1YvVo8PwC+754QuW/bFGFnWQvbmoulzfkpzVoP2wwQwk4ECe+XBlQ==}
'@git.zone/tsdocker@2.4.3':
resolution: {integrity: sha512-ufLp7lyMj4tCpERd/xIE+z9II6o7qp+T7OZUexbLkIB8ZHzBGj/+zB9r2hFfMiUXcznd1vwAF3VNwADYwWut3Q==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -1692,8 +1692,8 @@ packages:
'@serve.zone/catalog@2.13.0':
resolution: {integrity: sha512-w2zfbcbJLR1jbwJQkeLNCOW/WB71FyMBfVb+uiIO5XTVK+7zTD0cFozySjBDOrueCFDcL7GcoO8Ohgs9jCfuhQ==}
'@serve.zone/interfaces@6.2.1':
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
'@serve.zone/interfaces@6.3.0':
resolution: {integrity: sha512-TjpOLCejOcwyMvkBoqwjdptVGwaNMAru+6jC3Lw2Em3+7Z0xVNU/du0DQtoLbaLqTb0vHbuxx6urqw12uR9/2A==}
'@serve.zone/remoteingress@4.23.0':
resolution: {integrity: sha512-ddF7k3ZfgpPn9rwfprDMWZR2CNzwQlmiETpST2obJPN5VrAZMLj2aT7yQYGiqLgVqUQBSXeSz9St2ygYyQ8PSQ==}
@@ -5155,7 +5155,7 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.4.2':
'@git.zone/tsdocker@2.4.3':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -6928,7 +6928,7 @@ snapshots:
- supports-color
- vue
'@serve.zone/interfaces@6.2.1':
'@serve.zone/interfaces@6.3.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
+54
View File
@@ -172,4 +172,58 @@ tap.test('WorkAppMailManager applies persisted identities to startup email confi
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
});
tap.test('WorkAppMailManager maps shared mail address bindings to WorkApp identities', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const syncResult = await manager.syncMailAddressBinding({
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.4',
port: 2527,
},
},
}, 'tester');
expect(syncResult.success).toEqual(true);
expect(syncResult.binding?.owner).toEqual({
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
});
expect(syncResult.binding?.inboundTarget?.smtpForward?.host).toEqual('10.0.0.4');
expect(syncResult.binding?.outboundIdentityId?.startsWith('workapp-')).toEqual(true);
const addressBindings = await manager.listMailAddressBindings({
owner: { appInstanceId: 'app-1' },
domain: 'example.com',
});
expect(addressBindings.length).toEqual(1);
expect(addressBindings[0].address).toEqual('hello@example.com');
expect(addressBindings[0].recipientPolicy?.staticRecipients).toEqual(['hello@example.com']);
const workAppBindings = await manager.listWorkAppMailBindings({
gatewayClientId: 'box-1',
});
expect(workAppBindings.length).toEqual(1);
expect(workAppBindings[0].addressBindingIds).toEqual([syncResult.binding!.id]);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.action.forward.host).toEqual('10.0.0.4');
const deleteResult = await manager.deleteMailAddressBinding(syncResult.binding!.id, 'tester');
expect(deleteResult.success).toEqual(true);
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
});
export default tap.start();
+247
View File
@@ -587,4 +587,251 @@ tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write'
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('WorkHosterHandler exposes shared mail address binding handlers', async () => {
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-1',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => filter.owner?.appInstanceId === 'app-1' ? [binding] : [],
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async () => [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}],
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].userId).toEqual('token-user');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-1']);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-1', userId: 'token-user' });
});
tap.test('WorkHosterHandler scopes shared mail handlers to gateway client token policy', async () => {
const listFilters: any[] = [];
const workAppFilters: any[] = [];
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-owned',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-policy',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
allowedRouteTargets: [{ host: '10.0.0.2', ports: [2525] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => {
listFilters.push(filter);
return filter.owner?.gatewayClientId === 'box-policy' ? [binding] : [];
},
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding: data };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async (owner: any) => {
workAppFilters.push(owner);
return owner?.gatewayClientId === 'box-policy' ? [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}] : [];
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
expect(listFilters[0].owner.gatewayClientId).toEqual('box-policy');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-owned']);
expect(workAppFilters[0].gatewayClientId).toEqual('box-policy');
const spoofResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
owner: { ...binding.owner, gatewayClientId: 'other-box' },
},
});
expect(spoofResult.error).toBeUndefined();
expect(spoofResult.response.success).toEqual(false);
expect(spoofResult.response.message).toEqual('gateway client token cannot act for this ownership');
const blockedTargetResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
inboundTarget: {
type: 'smtpForward',
smtpForward: { host: '10.0.0.9', port: 2525 },
},
},
});
expect(blockedTargetResult.error).toBeUndefined();
expect(blockedTargetResult.response.success).toEqual(false);
expect(blockedTargetResult.response.message).toEqual('mail target is outside token policy: 10.0.0.9:2525');
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].binding.owner.gatewayClientId).toEqual('box-policy');
const skippedDeleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: 'mail-other',
});
expect(skippedDeleteResult.error).toBeUndefined();
expect(skippedDeleteResult.response.success).toEqual(true);
expect(deletedRequests.length).toEqual(0);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-owned', userId: 'token-user' });
});
tap.test('WorkHosterHandler rejects shared mail sync without gateway-clients:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailAddressBinding: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
},
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '14.0.1',
version: '14.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+234
View File
@@ -6,6 +6,12 @@ import * as plugins from '../plugins.js';
import type * as interfaces from '../../ts_interfaces/index.js';
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
type TMailResourceOwner = plugins.servezoneInterfaces.data.IMailResourceOwner;
type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
smtpPassword: string;
@@ -109,6 +115,89 @@ export class WorkAppMailManager {
return response;
}
public async listMailAddressBindings(options: {
owner?: Partial<TMailResourceOwner>;
domain?: string;
address?: string;
} = {}): Promise<TMailAddressBinding[]> {
const domain = options.domain ? this.normalizeDomain(options.domain) : undefined;
const address = options.address ? this.normalizeAddress(options.address) : undefined;
const identities = await this.readStoredIdentities();
return identities
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), options.owner))
.filter((identity) => domain ? identity.domain === domain : true)
.filter((identity) => address ? identity.address === address : true)
.map((identity) => this.toMailAddressBinding(identity));
}
public async listWorkAppMailBindings(
owner?: Partial<TMailResourceOwner>,
): Promise<TWorkAppMailBinding[]> {
const identities = (await this.readStoredIdentities())
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), owner));
const groups = new Map<string, IStoredWorkAppMailIdentity[]>();
for (const identity of identities) {
const ownerKey = this.buildMailOwnerKey(this.toMailOwner(identity.ownership));
const group = groups.get(ownerKey) || [];
group.push(identity);
groups.set(ownerKey, group);
}
return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
}
public async syncMailAddressBinding(
binding: TMailAddressBindingSync,
createdBy: string,
): Promise<TMailAddressBindingSyncResponse> {
const ownership = this.normalizeMailResourceOwner(binding.owner);
const { localPart, domain } = this.normalizeMailAddressParts(binding);
const syncRequest: TSyncRequest = {
ownership,
localPart,
domain,
inbound: this.toLegacyInboundRoute(binding.inboundTarget),
enabled: binding.enabled,
};
if (binding.outboundIdentityId !== undefined) {
syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
}
const result = await this.syncMailIdentity(syncRequest, createdBy);
return {
success: result.success,
binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
message: result.message,
};
}
public async deleteMailAddressBinding(
id: string,
createdBy: string,
): Promise<TMailAddressBindingDeleteResponse> {
const identities = await this.readStoredIdentities();
const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
if (!identity) {
return { success: true };
}
const result = await this.syncMailIdentity({
ownership: identity.ownership,
localPart: identity.localPart,
domain: identity.domain,
delete: true,
}, createdBy);
return {
success: result.success,
message: result.message,
};
}
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
): Promise<TConfig> {
@@ -251,6 +340,63 @@ export class WorkAppMailManager {
return normalized;
}
private normalizeAddress(address: string): string {
const normalized = address?.trim().toLowerCase();
const [localPart, domain, extra] = normalized?.split('@') || [];
if (!localPart || !domain || extra) {
throw new Error(`Invalid email address: ${address}`);
}
return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
}
private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
const gatewayClientType = owner.gatewayClientType;
const gatewayClientId = owner.gatewayClientId?.trim();
const appInstanceId = owner.appInstanceId?.trim();
if (gatewayClientType !== 'onebox' && gatewayClientType !== 'cloudly' && gatewayClientType !== 'custom') {
throw new Error(`Invalid gateway client type: ${gatewayClientType}`);
}
if (!gatewayClientId) throw new Error('gatewayClientId is required');
if (!appInstanceId) throw new Error('appInstanceId is required');
return {
workHosterType: gatewayClientType as interfaces.data.TGatewayClientType,
workHosterId: gatewayClientId,
workAppId: appInstanceId,
};
}
private normalizeMailAddressParts(binding: TMailAddressBindingSync): {
localPart: string;
domain: string;
} {
const localPart = this.normalizeLocalPart(binding.localPart);
const domain = this.normalizeDomain(binding.domain);
const address = this.normalizeAddress(binding.address);
if (address !== `${localPart}@${domain}`) {
throw new Error('mail address, localPart, and domain do not match');
}
return { localPart, domain };
}
private toLegacyInboundRoute(
inboundTarget?: TMailAddressBinding['inboundTarget'],
): interfaces.data.IWorkAppMailInboundRoute | undefined {
if (!inboundTarget) return undefined;
if (inboundTarget.type !== 'smtpForward' || !inboundTarget.smtpForward) {
throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
}
return this.normalizeInboundRoute({
enabled: true,
targetHost: inboundTarget.smtpForward.host,
targetPort: inboundTarget.smtpForward.port,
preserveHeaders: inboundTarget.smtpForward.preserveHeaders,
addHeaders: inboundTarget.smtpForward.addHeaders,
});
}
private normalizeInboundRoute(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): interfaces.data.IWorkAppMailInboundRoute | undefined {
@@ -282,6 +428,17 @@ export class WorkAppMailManager {
return true;
}
private matchesMailOwner(
owner: TMailResourceOwner,
filter?: Partial<TMailResourceOwner>,
): boolean {
if (!filter) return true;
if (filter.gatewayClientType && filter.gatewayClientType !== owner.gatewayClientType) return false;
if (filter.gatewayClientId && filter.gatewayClientId !== owner.gatewayClientId) return false;
if (filter.appInstanceId && filter.appInstanceId !== owner.appInstanceId) return false;
return true;
}
private buildExternalKey(
ownership: interfaces.data.IWorkAppMailOwnership,
address: string,
@@ -298,6 +455,14 @@ export class WorkAppMailManager {
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
}
private buildMailOwnerKey(owner: TMailResourceOwner): string {
return [
owner.gatewayClientType,
owner.gatewayClientId,
owner.appInstanceId,
].join(':');
}
private buildRouteName(externalKey: string): string {
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
}
@@ -334,6 +499,75 @@ export class WorkAppMailManager {
};
}
private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
return {
gatewayClientType: ownership.workHosterType,
gatewayClientId: ownership.workHosterId,
appInstanceId: ownership.workAppId,
};
}
private toMailInboundTarget(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): TMailAddressBinding['inboundTarget'] {
if (!inbound?.enabled) return undefined;
return {
type: 'smtpForward',
smtpForward: {
host: inbound.targetHost,
port: inbound.targetPort,
preserveHeaders: inbound.preserveHeaders,
addHeaders: inbound.addHeaders,
},
};
}
private toMailAddressBinding(
identity: interfaces.data.IWorkAppMailIdentity,
): TMailAddressBinding {
return {
id: identity.id,
owner: this.toMailOwner(identity.ownership),
address: identity.address,
localPart: identity.localPart,
domain: identity.domain,
enabled: identity.enabled,
status: identity.enabled ? 'active' : 'disabled',
inboundTarget: this.toMailInboundTarget(identity.inbound),
outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
recipientPolicy: {
mode: 'staticList',
staticRecipients: [identity.address],
},
createdAt: identity.createdAt,
updatedAt: identity.updatedAt,
createdBy: identity.createdBy,
};
}
private toWorkAppMailBinding(
identities: IStoredWorkAppMailIdentity[],
): TWorkAppMailBinding {
const [firstIdentity] = identities;
const owner = this.toMailOwner(firstIdentity.ownership);
const enabledIdentities = identities.filter((identity) => identity.enabled);
const smtpIdentities = identities.filter((identity) => identity.smtp.enabled);
return {
id: `workapp-mail-${this.hashExternalKey(this.buildMailOwnerKey(owner)).slice(0, 32)}`,
owner,
enabled: enabledIdentities.length > 0,
status: enabledIdentities.length > 0 ? 'active' : 'disabled',
addressBindingIds: identities.map((identity) => identity.id),
outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
createdBy: firstIdentity.createdBy,
};
}
private toPublicIdentity(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailIdentity {
+131 -1
View File
@@ -257,6 +257,83 @@ export class WorkHosterHandler {
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListMailAddressBindings>(
'listMailAddressBindings',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { bindings: [] };
return {
bindings: await manager.listMailAddressBindings({
owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
domain: dataArg.domain,
address: dataArg.address,
}),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding>(
'syncMailAddressBinding',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
try {
const binding = {
...dataArg.binding,
owner: this.resolveMailOwner(auth, dataArg.binding.owner),
};
this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
return await manager.syncMailAddressBinding(binding, auth.userId);
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding>(
'deleteMailAddressBinding',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
if (auth.token?.policy?.role === 'gatewayClient') {
const bindings = await manager.listMailAddressBindings({
owner: this.resolveMailOwnerFilter(auth),
});
const binding = bindings.find((candidate) => candidate.id === dataArg.id);
if (!binding) return { success: true };
return await manager.deleteMailAddressBinding(binding.id, auth.userId);
}
return await manager.deleteMailAddressBinding(dataArg.id, auth.userId);
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListWorkAppMailBindings>(
'listWorkAppMailBindings',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { bindings: [] };
return { bindings: await manager.listWorkAppMailBindings(this.resolveMailOwnerFilter(auth, dataArg.owner)) };
},
),
);
}
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
@@ -335,7 +412,7 @@ export class WorkHosterHandler {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (policy.capabilities?.[capability] === true) return;
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
}
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
@@ -376,6 +453,39 @@ export class WorkHosterHandler {
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private resolveMailOwnerFilter(
auth: TAuthContext,
owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
const policy = auth.token?.policy;
if (policy?.role !== 'gatewayClient') return owner;
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (owner?.gatewayClientType && owner.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (owner?.gatewayClientId && owner.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
...owner,
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
};
}
private resolveMailOwner(
auth: TAuthContext,
owner: plugins.servezoneInterfaces.data.IMailResourceOwner,
): plugins.servezoneInterfaces.data.IMailResourceOwner {
const resolvedOwner = this.resolveMailOwnerFilter(auth, owner);
if (!resolvedOwner?.gatewayClientType || !resolvedOwner.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('mail owner is missing gateway client type or id');
}
return resolvedOwner as plugins.servezoneInterfaces.data.IMailResourceOwner;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
@@ -404,6 +514,26 @@ export class WorkHosterHandler {
}
}
private assertMailForwardTargetAllowed(
auth: TAuthContext,
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return;
const allowedTargets = policy.allowedRouteTargets || [];
if (allowedTargets.length === 0) {
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
}
const host = target.smtpForward.host.trim().toLowerCase();
const port = Number(target.smtpForward.port);
const allowed = allowedTargets.some((allowedTarget) => {
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
});
if (!allowed) {
throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${host}:${port}`);
}
}
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
if (!normalizedHostname) return false;
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '14.0.1',
version: '14.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}