import { existsSync } from 'node:fs'; import { config } from '../config.js'; import { run } from '../utils/shell.js'; import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js'; export interface DmsAccount { email: string; localPart: string; domain: string; } export interface DmsQuota { usedBytes: number; quotaBytes: number | null; quotaPercent: number | null; messageCount: number | null; messageLimit: number | null; } function parseAccounts(output: string): DmsAccount[] { const accounts: DmsAccount[] = []; for (const line of output.split('\n')) { const match = line.match(/([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/i); if (!match) continue; const email = normalizeEmail(match[1]); accounts.push({ email, localPart: localPartFromEmail(email), domain: domainFromEmail(email), }); } return [...new Map(accounts.map((a) => [a.email, a])).values()] .sort((a, b) => a.email.localeCompare(b.email)); } function parseQuotaNumber(value: string | undefined): number | null { if (!value || value === '-' || value.trim() === '') return null; const parsed = Number.parseInt(value.trim(), 10); return Number.isFinite(parsed) ? parsed : null; } function parseDoveadmQuota(output: string): DmsQuota { let usedStorageKb: number | null = null; let storageLimitKb: number | null = null; let storagePercent: number | null = null; let messageCount: number | null = null; let messageLimit: number | null = null; for (const line of output.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.toLowerCase().startsWith('quota name')) continue; const parts = trimmed.split(/\s+/); const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE'); if (typeIndex < 0) continue; const type = parts[typeIndex]; const value = parseQuotaNumber(parts[typeIndex + 1]); const limit = parseQuotaNumber(parts[typeIndex + 2]); const percent = parseQuotaNumber(parts[typeIndex + 3]); if (type === 'STORAGE') { usedStorageKb = value; storageLimitKb = limit; storagePercent = percent; } if (type === 'MESSAGE') { messageCount = value; messageLimit = limit; } } const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024; const quotaBytes = storageLimitKb && storageLimitKb > 0 ? storageLimitKb * 1024 : null; const calculatedPercent = quotaBytes && quotaBytes > 0 ? (usedBytes / quotaBytes) * 100 : null; return { usedBytes, quotaBytes, quotaPercent: calculatedPercent ?? storagePercent, messageCount, messageLimit, }; } export class DmsService { async assertDockerAccess(): Promise { await run('docker', ['ps', '--format', '{{.Names}}'], 30000); } async listAccounts(): Promise { console.log(`[dms] listing accounts from container: ${config.dmsContainer}`); const { stdout } = await run( 'docker', ['exec', config.dmsContainer, 'setup', 'email', 'list'], 60000, ); const accounts = parseAccounts(stdout); console.log(`[dms] found ${accounts.length} account(s)`); return accounts; } async addMailbox(email: string, password: string): Promise { const normalized = normalizeEmail(email); console.log(`[dms] creating mailbox: ${normalized}`); await this.assertDockerAccess(); if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { console.log(`[dms] using manage script: ${config.manageMailUserScript}`); await run(config.manageMailUserScript, ['add', normalized, password]); console.log(`[dms] mailbox created through manage script: ${normalized}`); return; } console.log(`[dms] using docker-mailserver setup command`); await run( 'docker', ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password], 120000, ); console.log(`[dms] mailbox created: ${normalized}`); } async deleteMailbox(email: string): Promise { const normalized = normalizeEmail(email); console.log(`[dms] deleting mailbox: ${normalized}`); await this.assertDockerAccess(); if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { console.log(`[dms] using manage script: ${config.manageMailUserScript}`); await run(config.manageMailUserScript, ['del', normalized]); console.log(`[dms] mailbox deleted through manage script: ${normalized}`); return; } await run( 'docker', ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized], 120000, ); console.log(`[dms] mailbox deleted: ${normalized}`); } async updatePassword(email: string, password: string): Promise { const normalized = normalizeEmail(email); console.log(`[dms] updating password for mailbox: ${normalized}`); await this.assertDockerAccess(); await run( 'docker', ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password], 120000, ); console.log(`[dms] password updated for mailbox: ${normalized}`); } /** * Set the storage quota for a mailbox via the docker-mailserver * `setup quota set` subcommand. quotaGb must be a positive integer. * Equivalent to: docker exec mailserver setup quota set user@example.com 35G */ async setQuota(email: string, quotaGb: number): Promise { const normalized = normalizeEmail(email); const gb = Math.floor(Number(quotaGb)); if (!Number.isFinite(gb) || gb <= 0 || gb > 1024) { throw new Error(`Invalid quota size: ${quotaGb}`); } console.log(`[dms] setting quota for ${normalized} to ${gb}G`); await this.assertDockerAccess(); await run( 'docker', ['exec', config.dmsContainer, 'setup', 'quota', 'set', normalized, `${gb}G`], 120000, ); console.log(`[dms] quota set for ${normalized}: ${gb}G`); } async syncSesDomain(domain: string): Promise { const normalizedDomain = domain.toLowerCase(); if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { console.log(`[dms] syncing SES for domain through manage script: ${normalizedDomain}`); await run(config.manageMailUserScript, ['sync', normalizedDomain], 120000); return; } console.log(`[dms] no manage script configured, skipping SES sync for ${normalizedDomain}`); } async getMailboxQuota(email: string): Promise { const normalized = normalizeEmail(email); console.log(`[dms] reading quota for mailbox: ${normalized}`); const { stdout } = await run( 'docker', ['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized], 60000, ); return parseDoveadmQuota(stdout); } }