diff --git a/test/test.workapp-mail-manager.node.ts b/test/test.workapp-mail-manager.node.ts new file mode 100644 index 0000000..c78e88f --- /dev/null +++ b/test/test.workapp-mail-manager.node.ts @@ -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(); + + public async get(key: string): Promise { + return this.store.get(key) || null; + } + + public async set(key: string, value: string): Promise { + 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) => { + 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(); diff --git a/test/test.workhoster-handler.node.ts b/test/test.workhoster-handler.node.ts index 35d64b0..8b51153 100644 --- a/test/test.workhoster-handler.node.ts +++ b/test/test.workhoster-handler.node.ts @@ -285,4 +285,98 @@ tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write 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(); diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 91b5b0d..3dd5875 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -31,7 +31,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { DnsManager } from './dns/manager.dns.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 { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js'; @@ -285,6 +285,7 @@ export class DcRouter { // ACME configuration (DB-backed singleton, replaces tls.contactEmail) public acmeConfigManager?: AcmeConfigManager; public emailDomainManager?: EmailDomainManager; + public workAppMailManager: WorkAppMailManager; public securityPolicyManager?: SecurityPolicyManager; // Auto-discovered public IP (populated by generateAuthoritativeRecords) @@ -339,6 +340,7 @@ export class DcRouter { this.storageManager = new SmartMtaStorageManager( plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage') ); + this.workAppMailManager = new WorkAppMailManager(this); // Initialize service manager and register all services this.serviceManager = new plugins.taskbuffer.ServiceManager({ @@ -1630,7 +1632,7 @@ export class DcRouter { } // Create config with mapped ports - const emailConfig: IUnifiedEmailServerOptions = { + const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({ ...this.options.emailConfig, domains: transformedDomains, 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'), ...this.options.emailConfig.queue, }, - }; + }); // Create unified email server this.emailServer = new UnifiedEmailServer(this, emailConfig); diff --git a/ts/email/classes.email-domain.manager.ts b/ts/email/classes.email-domain.manager.ts index 9d02fab..8c8325b 100644 --- a/ts/email/classes.email-domain.manager.ts +++ b/ts/email/classes.email-domain.manager.ts @@ -57,6 +57,31 @@ export class EmailDomainManager { return doc ? this.docToInterface(doc) : null; } + public async getByDomain(domainName: string): Promise { + const doc = await EmailDomainDoc.findByDomain(domainName); + return doc ? this.docToInterface(doc) : null; + } + + public async ensureEmailDomainForDomainName(domainName: string): Promise { + 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: { linkedDomainId: string; subdomain?: string; @@ -351,6 +376,13 @@ export class EmailDomainManager { return configuredDomains.includes(domainName.toLowerCase()); } + private async findLinkedDnsDomain(domainName: string): Promise { + 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 { const docs = await EmailDomainDoc.findAll(); const managedConfigs: IEmailDomainConfig[] = []; @@ -378,7 +410,7 @@ export class EmailDomainManager { return managedConfigs; } - private async syncManagedDomainsToRuntime(): Promise { + public async syncManagedDomainsToRuntime(): Promise { if (!this.dcRouter.options?.emailConfig) { return; } diff --git a/ts/email/classes.workapp-mail-manager.ts b/ts/email/classes.workapp-mail-manager.ts new file mode 100644 index 0000000..87b57eb --- /dev/null +++ b/ts/email/classes.workapp-mail-manager.ts @@ -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, + ): Promise { + 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 { + 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( + emailConfig: TConfig, + ): Promise { + const identities = await this.readStoredIdentities(); + return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities); + } + + public async applyStoredIdentitiesToRuntime( + identities = undefined as IStoredWorkAppMailIdentity[] | undefined, + ): Promise { + 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 { + 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 { + const state: IStoredWorkAppMailState = { + version: 1, + identities, + }; + await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2)); + } + + private mergeIdentitiesIntoEmailConfig( + 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 { + 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, + ): 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; + } +} diff --git a/ts/email/index.ts b/ts/email/index.ts index 24d399c..2aa0edc 100644 --- a/ts/email/index.ts +++ b/ts/email/index.ts @@ -1,3 +1,4 @@ export * from './classes.email-domain.manager.js'; export * from './classes.smartmta-storage-manager.js'; +export * from './classes.workapp-mail-manager.js'; export * from './email-dns-records.js'; diff --git a/ts/opsserver/handlers/workhoster.handler.ts b/ts/opsserver/handlers/workhoster.handler.ts index dfb2cdc..486a158 100644 --- a/ts/opsserver/handlers/workhoster.handler.ts +++ b/ts/opsserver/handlers/workhoster.handler.ts @@ -129,6 +129,36 @@ export class WorkHosterHandler { }, ), ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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( + '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 { diff --git a/ts_interfaces/data/workhoster.ts b/ts_interfaces/data/workhoster.ts index b7f5c52..90e1418 100644 --- a/ts_interfaces/data/workhoster.ts +++ b/ts_interfaces/data/workhoster.ts @@ -54,3 +54,55 @@ export interface IWorkAppRouteSyncResult { routeId?: 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; +} + +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; +} diff --git a/ts_interfaces/requests/workhoster.ts b/ts_interfaces/requests/workhoster.ts index bb1d07e..95dbfbc 100644 --- a/ts_interfaces/requests/workhoster.ts +++ b/ts_interfaces/requests/workhoster.ts @@ -2,6 +2,10 @@ import * as plugins from '../plugins.js'; import type * as authInterfaces from '../data/auth.js'; import type { IGatewayCapabilities, + IWorkAppMailIdentity, + IWorkAppMailIdentitySyncResult, + IWorkAppMailInboundRoute, + IWorkAppMailOwnership, IWorkAppRouteOwnership, IWorkAppRouteSyncResult, IWorkHosterDomain, @@ -51,3 +55,39 @@ export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.im }; 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; + }; + 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; +}