Files
AriWeb-Suite/portal/mail/api.mjs
T

112 lines
26 KiB
JavaScript

/**
* portal/mail/api.mjs — 메일 영역 라우트(§15~§18, §23.2, §30.2).
*
* 화면(서버가 틀을 내고 /mail/ui.js 가 /api/mail/* 를 읽어 채움)
* /mail 메일 목록(§15.2) /mail/new 생성 흐름(§15.3)
* /mail/:id?tab=… 상세(개요·메일함·사용량·도메인 보안·설정·독립 전환·생명주기)
* /mail/:id/mailboxes/new 메일함 만들기(§16.1) /mail/:id/mailboxes/:mid 메일함 설정(§16.2)
* /mail/invite/:token 초대 수락(로그인 없이, 비밀번호 설정)
* API(/api/mail/*): 목록·생성·상세·DNS 확인·활성화·메일함 CRUD·초대·프로토콜·사용량·보안·전환·해지·내보내기·웹메일 SSO·메일 프로그램 설정
* 운영자(/internal/ops/mail/*): 서버 등록부·서비스·도메인·메일함과 공간·발송 제한·대기열·외부 시험·DNS 보안·정지/재개·이전 요청·정책. 메일 원문은 절대 내지 않는다.
* 권한: 조회 member 이상, 변경(생성·메일함·설정) 은 그룹 admin 또는 사이트별 domain_mail 권한, 해지는 billing 권한(§4.3).
*/
import * as svc from './service.mjs';
import { requireUser, requireGroup, requireRole, requireBilling, requireSitePerm } from '../auth/guard.mjs';
import { requireOps } from '../commerce/opsauth.mjs';
import { describe } from '../../lib/jobs.mjs';
import { esc } from '../../lib/http.mjs';
import { audit } from '../../lib/audit.mjs';
import { notify } from '../../lib/notify.mjs';
import { nowIso, parseJson } from '../../lib/db.mjs';
import { readFileSync } from 'node:fs';
import * as quotaLib from '../../lib/quota.mjs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function register(app, deps) {
const { store, t, page, badge, statePanel } = deps;
if (!deps.arimail) { const m = await import('./arimail.mjs'); deps.arimail = m.create(deps); }
svc.registerJobs(deps);
if (deps.scheduler) { deps.scheduler.every('mail.usage', 5 * 60 * 1000, async () => { let n = 0; for (const r of store.all('SELECT id FROM mail_services WHERE status IN (\'active\',\'activating\',\'cancelled\')')) { await svc.collectMailUsage(deps, r.id).catch(() => {}); n++; } return n; }); deps.scheduler.every('mail.purge', 3600 * 1000, () => svc.purgeExpired(deps)); }
// 다른 영역이 쓰는 도우미(§14 사이트 수 반환, §23 해지)
deps.mail = { canReturnSiteCount: (siteId) => svc.canReturnSiteCount(deps, siteId), convertToStandalone: (id, user) => svc.convertToStandalone(deps, id, user), cancelMailService: (id, user, o) => svc.cancelMailService(deps, id, user, o), primaryMailOfSite: (siteId) => svc.primaryMailOfSite(deps, siteId), collectMailUsage: (id) => svc.collectMailUsage(deps, id), purgeExpired: () => svc.purgeExpired(deps), readPolicy: () => svc.readPolicy(deps) };
// ---- 권한 도우미: 서비스의 사이트별 권한(domain_mail) 또는 그룹 admin
const canManage = (ctx, s) => { if (['owner', 'admin'].includes(ctx.membership.role)) return true; if (s.site_id) { const site = store.get('SELECT * FROM sites WHERE id=?', s.site_id); if (site) { try { requireSitePerm(ctx, site, 'domain_mail'); return true; } catch (_) { return false; } } } return false; };
const requireManage = (ctx, s) => { if (!canManage(ctx, s)) throw ctx.error(403, 'forbidden', '이 메일 서비스를 변경할 권한이 없습니다. 필요한 권한: 그룹 관리자 또는 사이트 "도메인과 메일 설정"', { requiredRole: ['admin'], requiredPerm: 'domain_mail' }); };
const load = (ctx) => { requireGroup(ctx); return svc.getService(deps, ctx.group.id, ctx.params.id); };
const common = (ctx, extra = {}) => ({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.user ? store.all('SELECT g.id, g.name, g.kind FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND m.status=\'active\'', ctx.user.id) : []), csrf: ctx.session?.csrf || '', unread: ctx.unread || 0, nav: 'mail', ...extra });
const view = (s) => ({ ...s, settings: undefined, prepError: s.settings?.prepError || null, lastUsage: s.settings?.lastUsage || null, dns: s.settings?.dns ? { ok: s.settings.dns.ok, summary: s.settings.dns.summary, checkedAt: s.settings.dns.checkedAt } : null, kindLabel: t('mail.kind.' + s.kind), statusLabel: t('mail.status.' + s.status), site: s.site_id ? store.get('SELECT id, name, primary_domain FROM sites WHERE id=?', s.site_id) : null, job: latestJob(s.id) });
const latestJob = (id) => { const j = deps.jobs.list({ subjectType: 'mail_service', subjectId: id, limit: 1 })[0]; return j ? describe(j) : null; };
const noBackup = t('mail.noBackup');
// ================================================================ 화면
app.get('/mail/ui.js', (ctx) => { ctx.set(200, { 'Content-Type': 'text/javascript; charset=utf-8' }); return ctx.text(readFileSync(join(HERE, 'ui.js'), 'utf8')); });
app.get('/mail', (ctx) => { requireGroup(ctx); return ctx.html(page(common(ctx, { title: t('mail.list'), crumbs: [{ label: t('mail.title') }], actions: [{ label: t('mail.new'), href: '/mail/new', kind: 'primary' }], body: `<p class="sub">${esc(noBackup)}</p><div id="mail-list" data-view="list">${statePanel('loading')}</div>`, scripts: ['/mail/ui.js'] }))); });
app.get('/mail/new', (ctx) => { requireGroup(ctx); return ctx.html(page(common(ctx, { title: t('mail.new'), crumbs: [{ label: t('mail.title'), href: '/mail' }, { label: t('mail.new') }], body: `<div id="mail-new" data-view="new">${statePanel('loading')}</div>`, scripts: ['/mail/ui.js'] }))); });
app.get('/mail/invite/:token', (ctx) => ctx.html(page({ title: '메일함 초대', nav: 'mail', body: `<div class="card" id="mail-invite" data-view="invite" data-token="${esc(ctx.params.token)}"><h2>메일함 비밀번호 정하기</h2><form data-dirty-guard><div class="f"><label for="pw">새 비밀번호(8자 이상)</label><input id="pw" type="password" minlength="8" required autocomplete="new-password"></div><div class="f"><label for="pw2">다시 입력</label><input id="pw2" type="password" required autocomplete="new-password"></div><button class="btn primary">비밀번호 설정</button></form><div id="invite-result"></div></div>`, scripts: ['/mail/ui.js'] })));
app.get('/mail/:id', (ctx) => { const s = load(ctx); const tab = ['overview', 'mailboxes', 'usage', 'security', 'settings', 'convert', 'lifecycle'].includes(ctx.query.tab) ? ctx.query.tab : 'overview';
const tabs = ['overview', 'mailboxes', 'usage', 'security', 'settings', ...(s.kind === 'site_bound' ? ['convert'] : []), 'lifecycle'].map((k) => `<a href="/mail/${esc(s.id)}?tab=${k}" class="${k === tab ? 'on' : ''}">${esc(t('mail.tabs.' + k))}</a>`).join('');
return ctx.html(page(common(ctx, { title: `${s.name} (${s.domain})`, crumbs: [{ label: t('mail.title'), href: '/mail' }, { label: s.domain }], actions: [{ label: '웹메일 열기', action: 'webmail' }, ...(tab === 'mailboxes' ? [{ label: '메일함 만들기', href: `/mail/${s.id}/mailboxes/new`, kind: 'primary' }] : [])], body: `<div class="tabs">${tabs}</div><p class="sub">${esc(noBackup)}</p><div id="mail-detail" data-view="detail" data-id="${esc(s.id)}" data-tab="${tab}" data-kind="${esc(s.kind)}">${statePanel('loading')}</div>`, scripts: ['/mail/ui.js'] }))); });
app.get('/mail/:id/mailboxes/new', (ctx) => { const s = load(ctx); return ctx.html(page(common(ctx, { title: '메일함 만들기', crumbs: [{ label: t('mail.title'), href: '/mail' }, { label: s.domain, href: `/mail/${s.id}?tab=mailboxes` }, { label: '새 메일함' }], body: `<div id="mailbox-new" data-view="mailbox-new" data-id="${esc(s.id)}" data-domain="${esc(s.domain)}">${statePanel('loading')}</div>`, scripts: ['/mail/ui.js'] }))); });
app.get('/mail/:id/mailboxes/:mid', (ctx) => { const s = load(ctx); const mb = svc.getMailbox(deps, s, ctx.params.mid); return ctx.html(page(common(ctx, { title: mb.address, crumbs: [{ label: t('mail.title'), href: '/mail' }, { label: s.domain, href: `/mail/${s.id}?tab=mailboxes` }, { label: mb.address }], actions: [{ label: '웹메일 열기', action: 'webmail-box' }], body: `<div id="mailbox-detail" data-view="mailbox" data-id="${esc(s.id)}" data-mid="${esc(mb.id)}">${statePanel('loading')}</div>`, scripts: ['/mail/ui.js'] }))); });
// ================================================================ API — 서비스
app.get('/api/mail/services', (ctx) => { requireGroup(ctx); const list = svc.listServices(deps, ctx.group.id).map(view); return { ok: true, services: list, policy: svc.readPolicy(deps), notes: { noBackup, retention: t('mail.retention') } }; });
app.get('/api/mail/options', (ctx) => { requireGroup(ctx); const g = ctx.group.id;
const sites = store.all('SELECT id, name, primary_domain, lifecycle FROM sites WHERE group_id=? AND lifecycle NOT IN (\'deleted\',\'pending_delete\') ORDER BY created_at DESC', g).map((s) => ({ ...s, hasPrimaryMail: !!svc.primaryMailOfSite(deps, s.id), domains: store.all('SELECT name, ownership_state FROM domains WHERE site_id=?', s.id) }));
const domains = store.all('SELECT id, name, purpose, site_id, mail_service_id, ownership_state FROM domains WHERE group_id=? ORDER BY name', g);
const quota = {}; for (const r of ['mailboxes', 'mail_storage', 'mail_msgs', 'mail_services']) quota[r] = quotaLib.figures(store, g, r);
return { ok: true, sites, domains: domains.map((d) => ({ ...d, inUse: !!(d.mail_service_id && store.get('SELECT 1 FROM mail_services WHERE id=? AND status NOT IN (\'deleted\',\'cancelled\')', d.mail_service_id)) })), quota, servers: deps.arimail.listServers().map((s) => ({ id: s.id, name: s.name })), policy: svc.readPolicy(deps) }; });
app.post('/api/mail/services', async (ctx) => { requireGroup(ctx); requireRole(ctx, 'admin'); const b = await ctx.body(); const s = await svc.createService(deps, { group: ctx.group, user: ctx.user, kind: b.kind, siteId: b.siteId || null, domain: b.domain, name: b.name, alloc: b.alloc || {}, serverId: b.serverId || null, ip: ctx.ip }); return { ok: true, service: view(s), next: s.settings.prepError ? { step: 'prepare', message: '메일 서버 준비에 실패했습니다. 잠시 후 "다시 준비" 를 누르세요' } : { step: 'dns', message: 'DNS 레코드를 넣고 확인을 누르세요' } }; });
app.get('/api/mail/services/:id', async (ctx) => { const s = load(ctx); const f = svc.figures(deps, s); const boxes = svc.listMailboxes(deps, s).map(boxView); let records = null; try { records = s.status !== 'deleted' ? await svc.requiredRecords(deps, s) : null; } catch (e) { records = { error: e.code || e.message }; }
return { ok: true, service: view(s), figures: f, mailboxes: boxes, records, dns: s.settings.dns || null, lastTest: svc.lastTest(deps, s.id), lifecycle: svc.lifecycle(deps, s), program: s.arimail_server_id ? deps.arimail.programSettings(s.arimail_server_id, s.domain, '아이디@' + s.domain) : null, canManage: canManage(ctx, s), canBilling: ['owner', 'admin', 'billing'].includes(ctx.membership.role), notes: { noBackup, retention: t('mail.retention'), counting: t('mail.counting'), limit100: { storage: t('mail.limit100.storage'), monthly: t('mail.limit100.monthly'), mailboxes: t('mail.limit100.mailboxes') }, sendLimit: t('mail.sendLimit') } }; });
app.post('/api/mail/services/:id/prepare', async (ctx) => { const s = load(ctx); requireManage(ctx, s); return { ok: true, result: await svc.prepareTenant(deps, s), service: view(svc.getService(deps, ctx.group.id, s.id)) }; });
app.post('/api/mail/services/:id/dns-check', async (ctx) => { const s = load(ctx); requireManage(ctx, s); return { ok: true, result: await svc.checkDns(deps, s) }; });
app.post('/api/mail/services/:id/activate', (ctx) => { const s = load(ctx); requireManage(ctx, s); const job = svc.startActivation(deps, s, ctx.user); audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: s.group_id, subjectType: 'mail_service', subjectId: s.id, action: 'mail.service.activate.start', detail: { jobId: job.id }, ip: ctx.ip }); return { ok: true, job: describe(job) }; });
app.post('/api/mail/services/:id/verify', async (ctx) => { const s = load(ctx); requireManage(ctx, s); return { ok: true, test: await svc.runVerification(deps, s, { kind: 'manual', by: ctx.user.id }) }; });
app.get('/api/mail/services/:id/job', (ctx) => { const s = load(ctx); return { ok: true, job: latestJob(s.id) }; });
app.post('/api/mail/services/:id/job/:action', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const j = deps.jobs.list({ subjectType: 'mail_service', subjectId: s.id, limit: 1 })[0]; if (!j) throw ctx.error(404, 'not_found', '작업이 없습니다'); const a = ctx.params.action; const r = a === 'retry' ? deps.jobs.retry(j.id, ctx.user.id) : a === 'resume' ? deps.jobs.resume(j.id, ctx.user.id) : a === 'cancel' ? deps.jobs.cancel(j.id, ctx.user.id) : a === 'revert' ? await deps.jobs.revert(j.id, ctx.user.id) : null; if (!r) throw ctx.error(400, 'bad_action', '알 수 없는 작업'); return { ok: true, job: describe(deps.jobs.get(j.id)) }; });
app.get('/api/mail/services/:id/usage', async (ctx) => { const s = load(ctx); const fresh = ctx.query.refresh === '1' ? await svc.collectMailUsage(deps, s.id).catch((e) => ({ ok: false, error: e.code || e.message })) : null; const s2 = svc.getService(deps, ctx.group.id, s.id); const f = svc.figures(deps, s2); const lim = await svc.sendLimits(deps, s2); const log = store.all('SELECT storage_bytes, monthly_messages, mailboxes, warn_level, measured_at FROM mail_usage_log WHERE mail_service_id=? ORDER BY measured_at DESC LIMIT 30', s.id);
return { ok: true, figures: f, lastUsage: s2.settings.lastUsage || null, usageError: s2.settings.usageError || null, refreshed: fresh, sendLimits: lim, history: log, mailboxes: svc.listMailboxes(deps, s2).map(boxView), policy: svc.readPolicy(deps), notes: { counting: t('mail.counting'), limit100: { storage: t('mail.limit100.storage'), monthly: t('mail.limit100.monthly'), mailboxes: t('mail.limit100.mailboxes') }, sendLimit: t('mail.sendLimit'), noBackup }, nextReset: f.nextReset, measuredAt: f.storage.measuredAt, stale: f.stale }; });
app.get('/api/mail/services/:id/security', async (ctx) => { const s = load(ctx); return { ok: true, report: await svc.securityReport(deps, s) }; });
app.get('/api/mail/services/:id/settings', async (ctx) => { const s = load(ctx); let cfg = null; try { cfg = await deps.arimail.adminConfig(s.arimail_server_id, s.domain); } catch (e) { cfg = { error: e.code || e.message }; } return { ok: true, protocols: { pop: !!s.pop_enabled, imap: !!s.imap_enabled, remote: cfg?.services || null, stale: !!cfg?.error }, largeFiles: cfg?.largeFiles || null, systemLargeFiles: cfg?.systemLargeFiles || null, policy: svc.readPolicy(deps), notes: { protocolImpact: t('mail.protocolImpact'), policyDeferred: t('mail.policyDeferred') } }; });
app.post('/api/mail/services/:id/protocols', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const b = await ctx.body(); if (!b.confirmed) throw ctx.error(409, 'confirm_required', t('mail.protocolImpact'), { needsConfirm: true }); return { ok: true, ...(await svc.setProtocols(deps, s, ctx.user, { pop: b.pop, imap: b.imap })) }; });
app.get('/api/mail/services/:id/convert', async (ctx) => { const s = load(ctx); return { ok: true, checklist: await svc.conversionChecklist(deps, s), job: latestJob(s.id) }; });
app.post('/api/mail/services/:id/convert', async (ctx) => { const s = load(ctx); requireBilling(ctx); const b = await ctx.body(); if (b.confirm !== s.domain) throw ctx.error(409, 'confirm_required', '확인을 위해 도메인 이름을 입력하세요'); const job = await svc.convertToStandalone(deps, s.id, ctx.user); return { ok: true, job: describe(job) }; });
app.post('/api/mail/services/:id/cancel', async (ctx) => { const s = load(ctx); requireBilling(ctx); const b = await ctx.body(); if (b.confirm !== s.domain) throw ctx.error(409, 'confirm_required', '확인을 위해 도메인 이름을 입력하세요'); if (s.kind === 'site_bound' && s.site_id && store.get('SELECT 1 FROM sites WHERE id=? AND lifecycle=\'live\'', s.site_id) && !b.acknowledgeSite) throw ctx.error(409, 'site_live', '운영 중인 사이트의 대표 메일입니다. 사이트 수는 유지되며 메일만 종료됩니다. 계속하려면 acknowledgeSite 를 보내세요'); const r = await svc.cancelMailService(deps, s.id, ctx.user, { reason: b.reason || '' }); return { ok: true, service: view(r), lifecycle: svc.lifecycle(deps, r) }; });
app.post('/api/mail/services/:id/export', (ctx) => { const s = load(ctx); requireManage(ctx, s); return { ok: true, export: svc.requestExport(deps, s, ctx.user) }; });
app.post('/api/mail/services/:id/webmail', async (ctx) => { const s = load(ctx); const b = await ctx.body(); const mb = b.mailboxId ? svc.getMailbox(deps, s, b.mailboxId) : null; const url = mb ? await deps.arimail.ssoUrl(s.arimail_server_id, s.domain, mb.address).catch(() => null) : null; const ps = deps.arimail.programSettings(s.arimail_server_id, s.domain, mb?.address || ''); return { ok: true, url: url || ps.webmail, sso: !!url, login: mb?.address || null, note: url ? '현재 로그인 상태로 안전하게 연결합니다(5분 안에 열어야 합니다)' : '웹메일 로그인 화면으로 이동합니다. 메일 주소와 비밀번호로 로그인하세요' }; });
app.post('/api/mail/services/:id/aliases', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const b = await ctx.body(); return { ok: true, alias: svc.createAlias(deps, s, ctx.user, b) }; });
app.get('/api/mail/lifecycle/:id', (ctx) => { const s = load(ctx); return { ok: true, lifecycle: svc.lifecycle(deps, s) }; });
// ================================================================ API — 메일함
const boxView = (m) => ({ id: m.id, address: m.address, displayName: m.display_name, status: m.status, statusLabel: { active: '활성', suspended: '정지', over_quota: '용량 초과', deleted: '삭제' }[m.status] || m.status, quotaBytes: m.quota_bytes || 0, usage: m.settings?.usage || null, level: m.settings?.level || 0, lastLoginAt: m.last_login_at, mustChangePassword: !!m.must_change_password, inviteState: m.invite_state, createdAt: m.created_at, autoReply: 'webmail', forward: 'webmail' });
app.get('/api/mail/services/:id/mailboxes', (ctx) => { const s = load(ctx); return { ok: true, mailboxes: svc.listMailboxes(deps, s).map(boxView), capacity: svc.mailboxCapacity(deps, s), notes: { suspendedCounts: t('mail.suspendedCounts'), initialPasswordOnce: t('mail.initialPasswordOnce') }, policy: svc.readPolicy(deps) }; });
app.post('/api/mail/services/:id/mailboxes', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const b = await ctx.body(); const r = await svc.createMailbox(deps, s, ctx.user, { local: b.local, displayName: b.displayName, quotaBytes: b.quotaBytes, mode: b.mode === 'invite' ? 'invite' : 'password', inviteEmail: b.inviteEmail, ip: ctx.ip }); return { ok: true, mailbox: boxView(r.mailbox), initialPassword: r.initialPassword || null, invite: r.invite || null, note: r.note || null, program: deps.arimail.programSettings(s.arimail_server_id, s.domain, r.mailbox.address) }; });
app.get('/api/mail/services/:id/mailboxes/:mid', (ctx) => { const s = load(ctx); const mb = svc.getMailbox(deps, s, ctx.params.mid); return { ok: true, mailbox: boxView(mb), invite: svc.inviteState(deps, mb.id), program: deps.arimail.programSettings(s.arimail_server_id, s.domain, mb.address), capacity: svc.mailboxCapacity(deps, s), policy: svc.readPolicy(deps), canManage: canManage(ctx, s), notes: { suspendedCounts: t('mail.suspendedCounts'), policyDeferred: t('mail.policyDeferred') } }; });
app.patch('/api/mail/services/:id/mailboxes/:mid', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const mb = svc.getMailbox(deps, s, ctx.params.mid); const b = await ctx.body(); const r = await svc.updateMailbox(deps, s, mb, ctx.user, b, ctx.ip); return { ok: true, mailbox: boxView(r.mailbox), newPassword: r.newPassword || null }; });
app.post('/api/mail/services/:id/mailboxes/:mid/sessions/end', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const mb = svc.getMailbox(deps, s, ctx.params.mid); return { ok: true, ...(await svc.endMailboxSessions(deps, s, mb, ctx.user)) }; });
app.post('/api/mail/services/:id/mailboxes/:mid/invite', (ctx) => { const s = load(ctx); requireManage(ctx, s); const mb = svc.getMailbox(deps, s, ctx.params.mid); return { ok: true, invite: svc.resendInvite(deps, mb) }; });
app.delete('/api/mail/services/:id/mailboxes/:mid', async (ctx) => { const s = load(ctx); requireManage(ctx, s); const mb = svc.getMailbox(deps, s, ctx.params.mid); const b = await ctx.body(); if (b.confirm !== mb.address) throw ctx.error(409, 'confirm_required', '확인을 위해 메일 주소를 입력하세요'); return { ok: true, ...(await svc.deleteMailbox(deps, s, mb, ctx.user, ctx.ip)) }; });
app.post('/api/mail/invite/accept', async (ctx) => { const b = await ctx.body(); return { ok: true, ...(await svc.acceptInvite(deps, b.token, b.password)) }; });
// ================================================================ 운영자(§30.2) — HAL 이 서비스 키로 호출. 메일 원문은 내지 않는다. 진단은 접근 사유를 기록.
const ops = (fn) => async (ctx) => { const who = await requireOps(ctx, deps); ctx.opsActor = who.actor; return fn(ctx); };
const opsAudit = (ctx, action, subjectId, detail) => audit(store, { actorType: 'operator', actorId: ctx.opsActor, subjectType: 'mail_service', subjectId, action, detail, ip: ctx.ip });
app.get('/internal/ops/mail/overview', ops(async () => ({ ok: true, servers: await deps.arimail.overview(), services: store.all('SELECT id, group_id, kind, site_id, name, domain, status, dns_state, send_state, receive_state, migrating, cancelled_at, retention_until, delete_after, updated_at FROM mail_services WHERE status<>\'deleted\' ORDER BY updated_at DESC LIMIT 500').map((s) => ({ ...s, figures: svc.figures(deps, { ...s, settings: {} }) })), policy: svc.readPolicy(deps) })));
app.post('/internal/ops/mail/servers', ops(async (ctx) => { const b = await ctx.body(); const s = deps.arimail.saveServer(b); opsAudit(ctx, 'ops.mail.server.save', s.id, { name: s.name, baseUrl: s.base_url }); return { ok: true, server: s }; }));
app.get('/internal/ops/mail/servers/:sid/rates', ops(async (ctx) => { const domain = ctx.query.domain || (deps.arimail.getServer(ctx.params.sid), null); const r = await deps.arimail.rates(ctx.params.sid, domain || ctx.query.domain); opsAudit(ctx, 'ops.mail.rates.read', null, { server: ctx.params.sid, domain }); return { ok: true, ...r }; }));
app.get('/internal/ops/mail/services/:id', ops(async (ctx) => { const s = svc.getService(deps, store.get('SELECT group_id FROM mail_services WHERE id=?', ctx.params.id)?.group_id || '', ctx.params.id); opsAudit(ctx, 'ops.mail.service.read', s.id, { reason: ctx.query.reason || '' }); let usage = null; try { usage = await deps.arimail.usage(s.arimail_server_id, s.domain); } catch (e) { usage = { error: e.code || e.message }; } return { ok: true, service: view(s), figures: svc.figures(deps, s), mailboxes: svc.listMailboxes(deps, s).map(boxView), usage: usage && !usage.error ? { storage: usage.storage, traffic: usage.traffic, accounts: usage.accounts, warnLevel: usage.warnLevel, limitedReasons: usage.limitedReasons, operating: usage.operating } : usage, lastTest: svc.lastTest(deps, s.id), dns: s.settings.dns || null, lifecycle: svc.lifecycle(deps, s) }; }));
app.post('/internal/ops/mail/services/:id/operating', ops(async (ctx) => { const b = await ctx.body(); const s = svc.getService(deps, store.get('SELECT group_id FROM mail_services WHERE id=?', ctx.params.id)?.group_id || '', ctx.params.id); const op = ['active', 'migrating_out', 'migrating_in', 'frozen'].includes(b.operating) ? b.operating : null; if (!op) throw ctx.error(422, 'validation', 'operating 값이 올바르지 않습니다'); await deps.arimail.updateDomain(s.arimail_server_id, s.domain, { operating: op }); store.run('UPDATE mail_services SET migrating=?, updated_at=? WHERE id=?', op === 'active' ? 0 : 1, nowIso(), s.id); opsAudit(ctx, 'ops.mail.operating', s.id, { operating: op, reason: b.reason || '' }); if (op !== 'active') notify(store, { groupId: s.group_id, category: 'mail', severity: 'warning', subjectType: 'mail_service', subjectId: s.id, incidentKey: `mail:${s.id}:operating`, title: `메일 서비스 ${op === 'frozen' ? '일시 중지' : '이전 작업 중'}: ${s.domain}`, body: '작업 중에는 새 수신이 임시 거부되고 발신이 막힙니다(두 서버가 동시에 처리하지 않게). 끝나면 자동으로 복구됩니다', link: `/mail/${s.id}` }); return { ok: true, operating: op }; }));
app.post('/internal/ops/mail/services/:id/test', ops(async (ctx) => { const s = svc.getService(deps, store.get('SELECT group_id FROM mail_services WHERE id=?', ctx.params.id)?.group_id || '', ctx.params.id); opsAudit(ctx, 'ops.mail.test', s.id, {}); return { ok: true, test: await svc.runVerification(deps, s, { kind: 'ops', by: ctx.opsActor }) }; }));
app.post('/internal/ops/mail/services/:id/security', ops(async (ctx) => { const s = svc.getService(deps, store.get('SELECT group_id FROM mail_services WHERE id=?', ctx.params.id)?.group_id || '', ctx.params.id); return { ok: true, report: await svc.securityReport(deps, s) }; }));
app.post('/internal/ops/mail/services/:id/cancel', ops(async (ctx) => { const b = await ctx.body(); const r = await svc.cancelMailService(deps, ctx.params.id, null, { reason: `운영자(${ctx.opsActor}): ${b.reason || ''}` }); opsAudit(ctx, 'ops.mail.cancel', r.id, { reason: b.reason || '' }); return { ok: true, service: view(r) }; }));
app.post('/internal/ops/mail/services/:id/delete-hold', ops(async (ctx) => { const b = await ctx.body(); const s = svc.getService(deps, store.get('SELECT group_id FROM mail_services WHERE id=?', ctx.params.id)?.group_id || '', ctx.params.id); store.run('UPDATE mail_services SET settings_json=?, updated_at=? WHERE id=?', JSON.stringify({ ...s.settings, deleteHold: !!b.hold, deleteHoldReason: b.reason || '' }), nowIso(), s.id); opsAudit(ctx, 'ops.mail.delete_hold', s.id, { hold: !!b.hold, reason: b.reason || '' }); return { ok: true }; }));
app.get('/internal/ops/mail/policy', ops(() => ({ ok: true, policy: svc.readPolicy(deps) })));
app.put('/internal/ops/mail/policy', ops(async (ctx) => { const b = await ctx.body(); const p = svc.writePolicy(deps, b, ctx.opsActor); opsAudit(ctx, 'ops.mail.policy', null, { keys: Object.keys(b) }); return { ok: true, policy: p }; }));
app.post('/internal/ops/mail/purge', ops(async (ctx) => ({ ok: true, deleted: await svc.purgeExpired(deps) })));
}