feat: add workapp mail sync API
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { WorkAppMailManager } from '../ts/email/classes.workapp-mail-manager.js';
|
||||||
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
class MemoryStorageManager {
|
||||||
|
public store = new Map<string, string>();
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
return this.store.get(key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(key: string, value: string): Promise<void> {
|
||||||
|
this.store.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDcRouterStub = () => {
|
||||||
|
const storageManager = new MemoryStorageManager();
|
||||||
|
const emailConfig: IUnifiedEmailServerOptions = {
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
ports: [25, 587, 465],
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'example.com',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'operator-route',
|
||||||
|
match: { recipients: 'ops@example.com' },
|
||||||
|
action: { type: 'reject', reject: { code: 550, message: 'not here' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
auth: {
|
||||||
|
users: [{ username: 'operator', password: 'secret' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dcRouterRef: any = {
|
||||||
|
storageManager,
|
||||||
|
options: { emailConfig },
|
||||||
|
emailServer: {
|
||||||
|
updateOptions: (patch: Partial<IUnifiedEmailServerOptions>) => {
|
||||||
|
dcRouterRef.options.emailConfig = {
|
||||||
|
...dcRouterRef.options.emailConfig,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateEmailRoutes: async (routes: IUnifiedEmailServerOptions['routes']) => {
|
||||||
|
dcRouterRef.options.emailConfig.routes = routes;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { dcRouterRef, storageManager };
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('WorkAppMailManager syncs SMTP identity and inbound smartmta route', async () => {
|
||||||
|
const { dcRouterRef } = createDcRouterStub();
|
||||||
|
const manager = new WorkAppMailManager(dcRouterRef);
|
||||||
|
|
||||||
|
const createResult = await manager.syncMailIdentity({
|
||||||
|
ownership: {
|
||||||
|
workHosterType: 'onebox',
|
||||||
|
workHosterId: 'box-1',
|
||||||
|
workAppId: 'app-1',
|
||||||
|
},
|
||||||
|
localPart: 'Hello',
|
||||||
|
domain: 'Example.com',
|
||||||
|
inbound: {
|
||||||
|
enabled: true,
|
||||||
|
targetHost: '10.0.0.2',
|
||||||
|
targetPort: 2525,
|
||||||
|
},
|
||||||
|
}, 'tester');
|
||||||
|
|
||||||
|
expect(createResult.success).toEqual(true);
|
||||||
|
expect(createResult.action).toEqual('created');
|
||||||
|
expect(createResult.identity?.address).toEqual('hello@example.com');
|
||||||
|
expect(createResult.identity?.smtp.username.startsWith('workapp-')).toEqual(true);
|
||||||
|
expect((createResult.identity as any).smtpPassword).toBeUndefined();
|
||||||
|
expect(createResult.smtpCredentials?.password.length).toBeGreaterThan(20);
|
||||||
|
|
||||||
|
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||||
|
expect(generatedRoute.match.recipients).toEqual('hello@example.com');
|
||||||
|
expect(generatedRoute.action.forward.host).toEqual('10.0.0.2');
|
||||||
|
expect(generatedRoute.action.forward.port).toEqual(2525);
|
||||||
|
expect(generatedRoute.action.forward.addHeaders['X-Dcrouter-WorkApp-Id']).toEqual('app-1');
|
||||||
|
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name === 'operator-route')).toEqual(true);
|
||||||
|
|
||||||
|
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||||
|
expect(generatedUser.password).toEqual(createResult.smtpCredentials?.password);
|
||||||
|
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||||
|
|
||||||
|
const listResult = await manager.listMailIdentities({ workAppId: 'app-1' });
|
||||||
|
expect(listResult.length).toEqual(1);
|
||||||
|
expect(listResult[0].address).toEqual('hello@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('WorkAppMailManager updates, resets credentials, and deletes identities', async () => {
|
||||||
|
const { dcRouterRef } = createDcRouterStub();
|
||||||
|
const manager = new WorkAppMailManager(dcRouterRef);
|
||||||
|
const ownership = {
|
||||||
|
workHosterType: 'onebox' as const,
|
||||||
|
workHosterId: 'box-1',
|
||||||
|
workAppId: 'app-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResult = await manager.syncMailIdentity({
|
||||||
|
ownership,
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||||
|
}, 'tester');
|
||||||
|
const firstPassword = createResult.smtpCredentials!.password;
|
||||||
|
|
||||||
|
const updateResult = await manager.syncMailIdentity({
|
||||||
|
ownership,
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
inbound: { enabled: true, targetHost: '10.0.0.3', targetPort: 2526 },
|
||||||
|
}, 'tester');
|
||||||
|
expect(updateResult.action).toEqual('updated');
|
||||||
|
expect(updateResult.smtpCredentials).toBeUndefined();
|
||||||
|
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||||
|
expect(generatedUser.password).toEqual(firstPassword);
|
||||||
|
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||||
|
expect(generatedRoute.action.forward.host).toEqual('10.0.0.3');
|
||||||
|
|
||||||
|
const resetResult = await manager.syncMailIdentity({
|
||||||
|
ownership,
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
resetSmtpPassword: true,
|
||||||
|
}, 'tester');
|
||||||
|
expect(resetResult.smtpCredentials?.password !== firstPassword).toEqual(true);
|
||||||
|
|
||||||
|
const deleteResult = await manager.syncMailIdentity({
|
||||||
|
ownership,
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
delete: true,
|
||||||
|
}, 'tester');
|
||||||
|
expect(deleteResult.action).toEqual('deleted');
|
||||||
|
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
|
||||||
|
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username.startsWith('workapp-'))).toEqual(false);
|
||||||
|
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('WorkAppMailManager applies persisted identities to startup email config', async () => {
|
||||||
|
const { dcRouterRef } = createDcRouterStub();
|
||||||
|
const manager = new WorkAppMailManager(dcRouterRef);
|
||||||
|
await manager.syncMailIdentity({
|
||||||
|
ownership: {
|
||||||
|
workHosterType: 'onebox',
|
||||||
|
workHosterId: 'box-1',
|
||||||
|
workAppId: 'app-1',
|
||||||
|
},
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||||
|
}, 'tester');
|
||||||
|
|
||||||
|
const baseStartupConfig: IUnifiedEmailServerOptions = {
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
ports: [25],
|
||||||
|
domains: [{ domain: 'example.com', dnsMode: 'external-dns' }],
|
||||||
|
routes: [],
|
||||||
|
};
|
||||||
|
const startupConfig = await manager.applyStoredIdentitiesToEmailConfig(baseStartupConfig);
|
||||||
|
|
||||||
|
expect(startupConfig.routes.some((route) => route.name.startsWith('workapp-mail-'))).toEqual(true);
|
||||||
|
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -285,4 +285,98 @@ tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write
|
|||||||
expect(routeConfig.routes.size).toEqual(0);
|
expect(routeConfig.routes.size).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
|
||||||
|
const syncedRequests: Array<{ data: any; userId: string }> = [];
|
||||||
|
const identity: interfaces.data.IWorkAppMailIdentity = {
|
||||||
|
id: 'mail-1',
|
||||||
|
externalKey: 'onebox:box-1:app-1:hello@example.com',
|
||||||
|
ownership: {
|
||||||
|
workHosterType: 'onebox',
|
||||||
|
workHosterId: 'box-1',
|
||||||
|
workAppId: 'app-1',
|
||||||
|
},
|
||||||
|
address: 'hello@example.com',
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
enabled: true,
|
||||||
|
inbound: {
|
||||||
|
enabled: true,
|
||||||
|
targetHost: '10.0.0.2',
|
||||||
|
targetPort: 2525,
|
||||||
|
},
|
||||||
|
smtp: {
|
||||||
|
enabled: true,
|
||||||
|
username: 'workapp-user',
|
||||||
|
},
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
createdBy: 'token-user',
|
||||||
|
};
|
||||||
|
const { typedrouter } = setupHandler({
|
||||||
|
scopes: ['workhosters:read', 'workhosters:write'],
|
||||||
|
dcRouterRef: {
|
||||||
|
options: {},
|
||||||
|
workAppMailManager: {
|
||||||
|
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
|
||||||
|
syncMailIdentity: async (data: any, userId: string) => {
|
||||||
|
syncedRequests.push({ data, userId });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'created',
|
||||||
|
identity,
|
||||||
|
smtpCredentials: {
|
||||||
|
username: 'workapp-user',
|
||||||
|
password: 'generated-password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
|
||||||
|
apiToken: 'valid-token',
|
||||||
|
ownership: { workAppId: 'app-1' },
|
||||||
|
});
|
||||||
|
expect(listResult.error).toBeUndefined();
|
||||||
|
expect(listResult.response.identities).toEqual([identity]);
|
||||||
|
|
||||||
|
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||||
|
apiToken: 'valid-token',
|
||||||
|
ownership: identity.ownership,
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
inbound: identity.inbound,
|
||||||
|
});
|
||||||
|
expect(syncResult.error).toBeUndefined();
|
||||||
|
expect(syncResult.response.success).toEqual(true);
|
||||||
|
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
|
||||||
|
expect(syncedRequests[0].userId).toEqual('token-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
|
||||||
|
const { typedrouter } = setupHandler({
|
||||||
|
scopes: ['workhosters:read'],
|
||||||
|
dcRouterRef: {
|
||||||
|
options: {},
|
||||||
|
workAppMailManager: {
|
||||||
|
syncMailIdentity: async () => ({ success: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||||
|
apiToken: 'valid-token',
|
||||||
|
ownership: {
|
||||||
|
workHosterType: 'onebox',
|
||||||
|
workHosterId: 'box-1',
|
||||||
|
workAppId: 'app-1',
|
||||||
|
},
|
||||||
|
localPart: 'hello',
|
||||||
|
domain: 'example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error?.text).toEqual('insufficient scope');
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
|
|||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||||
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||||
|
|
||||||
@@ -285,6 +285,7 @@ export class DcRouter {
|
|||||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||||
public acmeConfigManager?: AcmeConfigManager;
|
public acmeConfigManager?: AcmeConfigManager;
|
||||||
public emailDomainManager?: EmailDomainManager;
|
public emailDomainManager?: EmailDomainManager;
|
||||||
|
public workAppMailManager: WorkAppMailManager;
|
||||||
public securityPolicyManager?: SecurityPolicyManager;
|
public securityPolicyManager?: SecurityPolicyManager;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
@@ -339,6 +340,7 @@ export class DcRouter {
|
|||||||
this.storageManager = new SmartMtaStorageManager(
|
this.storageManager = new SmartMtaStorageManager(
|
||||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||||
);
|
);
|
||||||
|
this.workAppMailManager = new WorkAppMailManager(this);
|
||||||
|
|
||||||
// Initialize service manager and register all services
|
// Initialize service manager and register all services
|
||||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||||
@@ -1630,7 +1632,7 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create config with mapped ports
|
// Create config with mapped ports
|
||||||
const emailConfig: IUnifiedEmailServerOptions = {
|
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
||||||
...this.options.emailConfig,
|
...this.options.emailConfig,
|
||||||
domains: transformedDomains,
|
domains: transformedDomains,
|
||||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||||
@@ -1640,7 +1642,7 @@ export class DcRouter {
|
|||||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||||
...this.options.emailConfig.queue,
|
...this.options.emailConfig.queue,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
// Create unified email server
|
// Create unified email server
|
||||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ export class EmailDomainManager {
|
|||||||
return doc ? this.docToInterface(doc) : null;
|
return doc ? this.docToInterface(doc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
|
||||||
|
const doc = await EmailDomainDoc.findByDomain(domainName);
|
||||||
|
return doc ? this.docToInterface(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
|
||||||
|
const normalizedDomain = domainName.trim().toLowerCase();
|
||||||
|
const existing = await this.getByDomain(normalizedDomain);
|
||||||
|
if (existing) return existing;
|
||||||
|
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
|
||||||
|
|
||||||
|
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
|
||||||
|
if (!linkedDomain) {
|
||||||
|
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomain = normalizedDomain === linkedDomain.name
|
||||||
|
? undefined
|
||||||
|
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
|
||||||
|
return await this.createEmailDomain({
|
||||||
|
linkedDomainId: linkedDomain.id,
|
||||||
|
subdomain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async createEmailDomain(opts: {
|
public async createEmailDomain(opts: {
|
||||||
linkedDomainId: string;
|
linkedDomainId: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
@@ -351,6 +376,13 @@ export class EmailDomainManager {
|
|||||||
return configuredDomains.includes(domainName.toLowerCase());
|
return configuredDomains.includes(domainName.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
|
||||||
|
const domains = await DomainDoc.findAll();
|
||||||
|
return domains
|
||||||
|
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
|
||||||
|
.sort((a, b) => b.name.length - a.name.length)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||||
const docs = await EmailDomainDoc.findAll();
|
const docs = await EmailDomainDoc.findAll();
|
||||||
const managedConfigs: IEmailDomainConfig[] = [];
|
const managedConfigs: IEmailDomainConfig[] = [];
|
||||||
@@ -378,7 +410,7 @@ export class EmailDomainManager {
|
|||||||
return managedConfigs;
|
return managedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
public async syncManagedDomainsToRuntime(): Promise<void> {
|
||||||
if (!this.dcRouter.options?.emailConfig) {
|
if (!this.dcRouter.options?.emailConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import type {
|
||||||
|
IEmailRoute,
|
||||||
|
IUnifiedEmailServerOptions,
|
||||||
|
} from '@push.rocks/smartmta';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
|
||||||
|
|
||||||
|
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
|
||||||
|
smtpPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IStoredWorkAppMailState {
|
||||||
|
version: 1;
|
||||||
|
identities: IStoredWorkAppMailIdentity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkAppMailManager {
|
||||||
|
private readonly storageKey = '/workhosters/mail-identities.json';
|
||||||
|
|
||||||
|
constructor(private dcRouterRef: any) {}
|
||||||
|
|
||||||
|
public async listMailIdentities(
|
||||||
|
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||||
|
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
|
||||||
|
const identities = await this.readStoredIdentities();
|
||||||
|
return identities
|
||||||
|
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
|
||||||
|
.map((identity) => this.toPublicIdentity(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncMailIdentity(
|
||||||
|
request: TSyncRequest,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
||||||
|
if (!this.dcRouterRef.options.emailConfig) {
|
||||||
|
return { success: false, message: 'Email server is not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownership = this.normalizeOwnership(request.ownership);
|
||||||
|
const domain = this.normalizeDomain(request.domain);
|
||||||
|
const localPart = this.normalizeLocalPart(request.localPart);
|
||||||
|
const address = `${localPart}@${domain}`;
|
||||||
|
const externalKey = this.buildExternalKey(ownership, address);
|
||||||
|
const identities = await this.readStoredIdentities();
|
||||||
|
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
|
||||||
|
|
||||||
|
if (request.delete) {
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
return { success: true, action: 'unchanged' };
|
||||||
|
}
|
||||||
|
const [deletedIdentity] = identities.splice(existingIndex, 1);
|
||||||
|
await this.writeStoredIdentities(identities);
|
||||||
|
await this.applyStoredIdentitiesToRuntime(identities);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'deleted',
|
||||||
|
identity: this.toPublicIdentity(deletedIdentity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureEmailDomainConfigured(domain);
|
||||||
|
|
||||||
|
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const smtpPassword = existingIdentity && !request.resetSmtpPassword
|
||||||
|
? existingIdentity.smtpPassword
|
||||||
|
: this.generateSmtpPassword();
|
||||||
|
const identity: IStoredWorkAppMailIdentity = {
|
||||||
|
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
||||||
|
externalKey,
|
||||||
|
ownership,
|
||||||
|
address,
|
||||||
|
localPart,
|
||||||
|
domain,
|
||||||
|
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
|
||||||
|
displayName: request.displayName ?? existingIdentity?.displayName,
|
||||||
|
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
|
||||||
|
smtp: {
|
||||||
|
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
|
||||||
|
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
|
||||||
|
},
|
||||||
|
createdAt: existingIdentity?.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: existingIdentity?.createdBy || createdBy,
|
||||||
|
smtpPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
identities[existingIndex] = identity;
|
||||||
|
} else {
|
||||||
|
identities.push(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.writeStoredIdentities(identities);
|
||||||
|
await this.applyStoredIdentitiesToRuntime(identities);
|
||||||
|
|
||||||
|
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
|
||||||
|
success: true,
|
||||||
|
action: existingIndex >= 0 ? 'updated' : 'created',
|
||||||
|
identity: this.toPublicIdentity(identity),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIndex < 0 || request.resetSmtpPassword) {
|
||||||
|
response.smtpCredentials = this.buildSmtpCredentials(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||||
|
emailConfig: TConfig,
|
||||||
|
): Promise<TConfig> {
|
||||||
|
const identities = await this.readStoredIdentities();
|
||||||
|
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async applyStoredIdentitiesToRuntime(
|
||||||
|
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||||
|
if (!emailConfig) return;
|
||||||
|
|
||||||
|
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
|
||||||
|
emailConfig,
|
||||||
|
identities || await this.readStoredIdentities(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dcRouterRef.options.emailConfig = nextConfig;
|
||||||
|
if (this.dcRouterRef.emailServer) {
|
||||||
|
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
|
||||||
|
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
|
||||||
|
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
|
||||||
|
if (!storedData) return [];
|
||||||
|
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
|
||||||
|
return Array.isArray(parsed) ? parsed : parsed.identities || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
|
||||||
|
const state: IStoredWorkAppMailState = {
|
||||||
|
version: 1,
|
||||||
|
identities,
|
||||||
|
};
|
||||||
|
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||||
|
emailConfig: TConfig,
|
||||||
|
identities: IStoredWorkAppMailIdentity[],
|
||||||
|
): TConfig {
|
||||||
|
const generatedRoutes = identities
|
||||||
|
.filter((identity) => identity.enabled && identity.inbound?.enabled)
|
||||||
|
.map((identity) => this.buildInboundRoute(identity));
|
||||||
|
const configuredRoutes = (emailConfig.routes || [])
|
||||||
|
.filter((route) => !this.isManagedMailRouteName(route.name));
|
||||||
|
const generatedUsers = identities
|
||||||
|
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
||||||
|
.map((identity) => ({
|
||||||
|
username: identity.smtp.username,
|
||||||
|
password: identity.smtpPassword,
|
||||||
|
}));
|
||||||
|
const configuredUsers = (emailConfig.auth?.users || [])
|
||||||
|
.filter((user) => !this.isManagedSmtpUsername(user.username));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emailConfig,
|
||||||
|
routes: [...configuredRoutes, ...generatedRoutes],
|
||||||
|
auth: {
|
||||||
|
...(emailConfig.auth || {}),
|
||||||
|
users: [...configuredUsers, ...generatedUsers],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
|
||||||
|
const inbound = identity.inbound!;
|
||||||
|
return {
|
||||||
|
name: this.buildRouteName(identity.externalKey),
|
||||||
|
priority: 1000,
|
||||||
|
match: {
|
||||||
|
recipients: identity.address,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: inbound.targetHost,
|
||||||
|
port: inbound.targetPort,
|
||||||
|
preserveHeaders: inbound.preserveHeaders ?? true,
|
||||||
|
addHeaders: {
|
||||||
|
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
|
||||||
|
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
|
||||||
|
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
|
||||||
|
...(inbound.addHeaders || {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
|
||||||
|
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||||
|
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailDomainManager = this.dcRouterRef.emailDomainManager;
|
||||||
|
if (!emailDomainManager) {
|
||||||
|
throw new Error(`Email domain is not configured: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await emailDomainManager.getByDomain(domain)) {
|
||||||
|
await emailDomainManager.syncManagedDomainsToRuntime();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailDomainManager.ensureEmailDomainForDomainName(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOwnership(
|
||||||
|
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||||
|
): interfaces.data.IWorkAppMailOwnership {
|
||||||
|
const workHosterType = ownership.workHosterType;
|
||||||
|
const workHosterId = ownership.workHosterId?.trim();
|
||||||
|
const workAppId = ownership.workAppId?.trim();
|
||||||
|
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
|
||||||
|
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
|
||||||
|
}
|
||||||
|
if (!workHosterId) throw new Error('workHosterId is required');
|
||||||
|
if (!workAppId) throw new Error('workAppId is required');
|
||||||
|
return { workHosterType, workHosterId, workAppId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDomain(domain: string): string {
|
||||||
|
const normalized = domain?.trim().toLowerCase();
|
||||||
|
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
|
||||||
|
throw new Error(`Invalid email domain: ${domain}`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLocalPart(localPart: string): string {
|
||||||
|
const normalized = localPart?.trim().toLowerCase();
|
||||||
|
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
|
||||||
|
throw new Error(`Invalid email local part: ${localPart}`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeInboundRoute(
|
||||||
|
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
||||||
|
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
||||||
|
if (!inbound) return undefined;
|
||||||
|
if (!inbound.enabled) {
|
||||||
|
return { ...inbound, enabled: false };
|
||||||
|
}
|
||||||
|
const targetHost = inbound.targetHost?.trim();
|
||||||
|
const targetPort = Number(inbound.targetPort);
|
||||||
|
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
|
||||||
|
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
||||||
|
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...inbound,
|
||||||
|
targetHost,
|
||||||
|
targetPort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesOwnership(
|
||||||
|
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||||
|
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||||
|
): boolean {
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
|
||||||
|
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
|
||||||
|
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildExternalKey(
|
||||||
|
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||||
|
address: string,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
ownership.workHosterType,
|
||||||
|
ownership.workHosterId,
|
||||||
|
ownership.workAppId,
|
||||||
|
address,
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSmtpUsername(externalKey: string): string {
|
||||||
|
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRouteName(externalKey: string): string {
|
||||||
|
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashExternalKey(externalKey: string): string {
|
||||||
|
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSmtpPassword(): string {
|
||||||
|
return plugins.crypto.randomBytes(24).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isManagedMailRouteName(routeName: string): boolean {
|
||||||
|
return routeName.startsWith('workapp-mail-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isManagedSmtpUsername(username: string): boolean {
|
||||||
|
return username.startsWith('workapp-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSmtpCredentials(
|
||||||
|
identity: IStoredWorkAppMailIdentity,
|
||||||
|
): interfaces.data.IWorkAppMailCredentials {
|
||||||
|
return {
|
||||||
|
username: identity.smtp.username,
|
||||||
|
password: identity.smtpPassword,
|
||||||
|
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|
||||||
|
|| this.dcRouterRef.options.emailConfig?.hostname,
|
||||||
|
ports: {
|
||||||
|
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
|
||||||
|
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
|
||||||
|
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPublicIdentity(
|
||||||
|
identity: IStoredWorkAppMailIdentity,
|
||||||
|
): interfaces.data.IWorkAppMailIdentity {
|
||||||
|
const { smtpPassword, ...publicIdentity } = identity;
|
||||||
|
return publicIdentity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './classes.email-domain.manager.js';
|
export * from './classes.email-domain.manager.js';
|
||||||
export * from './classes.smartmta-storage-manager.js';
|
export * from './classes.smartmta-storage-manager.js';
|
||||||
|
export * from './classes.workapp-mail-manager.js';
|
||||||
export * from './email-dns-records.js';
|
export * from './email-dns-records.js';
|
||||||
|
|||||||
@@ -129,6 +129,36 @@ export class WorkHosterHandler {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkAppMailIdentities>(
|
||||||
|
'getWorkAppMailIdentities',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'workhosters:read');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||||
|
if (!manager) return { identities: [] };
|
||||||
|
return { identities: await manager.listMailIdentities(dataArg.ownership) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
|
||||||
|
'syncWorkAppMailIdentity',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'workhosters:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await manager.syncMailIdentity(dataArg, userId);
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: (error as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
|
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
|
||||||
|
|||||||
@@ -54,3 +54,55 @@ export interface IWorkAppRouteSyncResult {
|
|||||||
routeId?: string;
|
routeId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IWorkAppMailOwnership {
|
||||||
|
workHosterType: 'onebox' | 'cloudly' | 'custom';
|
||||||
|
workHosterId: string;
|
||||||
|
workAppId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkAppMailInboundRoute {
|
||||||
|
enabled: boolean;
|
||||||
|
targetHost: string;
|
||||||
|
targetPort: number;
|
||||||
|
preserveHeaders?: boolean;
|
||||||
|
addHeaders?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkAppMailIdentity {
|
||||||
|
id: string;
|
||||||
|
externalKey: string;
|
||||||
|
ownership: IWorkAppMailOwnership;
|
||||||
|
address: string;
|
||||||
|
localPart: string;
|
||||||
|
domain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
displayName?: string;
|
||||||
|
inbound?: IWorkAppMailInboundRoute;
|
||||||
|
smtp: {
|
||||||
|
enabled: boolean;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkAppMailCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host?: string;
|
||||||
|
ports?: {
|
||||||
|
smtp?: number;
|
||||||
|
submission?: number;
|
||||||
|
smtps?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkAppMailIdentitySyncResult {
|
||||||
|
success: boolean;
|
||||||
|
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||||
|
identity?: IWorkAppMailIdentity;
|
||||||
|
smtpCredentials?: IWorkAppMailCredentials;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import * as plugins from '../plugins.js';
|
|||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type {
|
import type {
|
||||||
IGatewayCapabilities,
|
IGatewayCapabilities,
|
||||||
|
IWorkAppMailIdentity,
|
||||||
|
IWorkAppMailIdentitySyncResult,
|
||||||
|
IWorkAppMailInboundRoute,
|
||||||
|
IWorkAppMailOwnership,
|
||||||
IWorkAppRouteOwnership,
|
IWorkAppRouteOwnership,
|
||||||
IWorkAppRouteSyncResult,
|
IWorkAppRouteSyncResult,
|
||||||
IWorkHosterDomain,
|
IWorkHosterDomain,
|
||||||
@@ -51,3 +55,39 @@ export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.im
|
|||||||
};
|
};
|
||||||
response: IWorkAppRouteSyncResult;
|
response: IWorkAppRouteSyncResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetWorkAppMailIdentities extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetWorkAppMailIdentities
|
||||||
|
> {
|
||||||
|
method: 'getWorkAppMailIdentities';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
ownership?: Partial<IWorkAppMailOwnership>;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
identities: IWorkAppMailIdentity[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_SyncWorkAppMailIdentity extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_SyncWorkAppMailIdentity
|
||||||
|
> {
|
||||||
|
method: 'syncWorkAppMailIdentity';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
ownership: IWorkAppMailOwnership;
|
||||||
|
localPart: string;
|
||||||
|
domain: string;
|
||||||
|
displayName?: string;
|
||||||
|
inbound?: IWorkAppMailInboundRoute;
|
||||||
|
enabled?: boolean;
|
||||||
|
smtpEnabled?: boolean;
|
||||||
|
resetSmtpPassword?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
};
|
||||||
|
response: IWorkAppMailIdentitySyncResult;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user