Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a97c4963d6 | |||
| 62271c1819 |
+7
-1
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "14.0.1",
|
||||
"version": "14.1.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
Generated
+10
-10
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user