344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|