Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
416 lines
63 KiB
JavaScript
416 lines
63 KiB
JavaScript
/**
|
|
* portal/mail/service.mjs — 메일 영역 업무 규칙(§14 4~5단계, §15~§18, §23.2).
|
|
*
|
|
* 생각의 틀
|
|
* 실제 메일 기능(웹메일·IMAP·POP3·SMTP·계정·사용량)은 AriMail 이 가지며(§0.3), 포털은 "어느 그룹의 어느 서비스가 어떤 상품 권리로 어떤 도메인을 쓰는지" 와
|
|
* 상태·배정·경고·전환·해지 생명주기를 가진다. AriMail 호출은 모두 deps.arimail(어댑터)로 하고, 실패하면 상태를 '정보 확인 지연' 으로 드러낸다(§26.2).
|
|
*
|
|
* 서비스 종류(§15.1)
|
|
* site_bound 사이트 대표 메일: 사이트당 대표 메일 도메인 하나, 운영 사이트 수를 더 쓰지 않음. 사이트 수 반환(§14) 전에 독립으로 전환하거나 종료해야 한다.
|
|
* standalone 독립 메일 서비스: 독립 메일 옵션(mail_services 권리) 1개를 소비, 운영 사이트 수는 쓰지 않음.
|
|
* 보장량(§13.3): 메일함 수(mailboxes)·저장 공간(mail_storage)·월간 통수(mail_msgs)는 그룹 구매 총량 안에서 서비스별로 배정하며 lib/quota.setAllocation 이 동시 요청·사용량 미만 축소를 막는다.
|
|
* 활성화(§15.3): 결제·승인된 권리가 있어야 하고, DNS 확인 → AriMail 테넌트 준비 → 실제 외부 송수신 확인(검증 메일함 ↔ 운영자가 정한 외부 검증 주소 왕복) 뒤에만 active 가 된다.
|
|
*/
|
|
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
|
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
|
|
import * as quota from '../../lib/quota.mjs';
|
|
import { notify } from '../../lib/notify.mjs';
|
|
import { audit } from '../../lib/audit.mjs';
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { JobError } from '../../lib/jobs.mjs';
|
|
import { nextMonthStart, monthStart } from '../../lib/kst.mjs';
|
|
import { safeError } from '../../lib/redact.mjs';
|
|
|
|
export const RETENTION_DAYS = 30; // 해지 뒤 읽기·내보내기 보존(§23.2)
|
|
export const VERIFY_TIMEOUT_MS = 90 * 1000; // 외부 왕복 확인 대기
|
|
const err = (status, code, message, detail) => new HttpError(status, code, message, detail);
|
|
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
const LOCAL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
|
|
// ------------------------------------------------------------------ 정책(상품정책에서 오는 값, 미확정이면 '후속 상품정책')
|
|
/** 메일 정책: 운영자가 settings 'mail.policy' 에 두는 값. 비어 있는 항목은 deferred 로 표시하고 무제한으로 제공하지 않는다(§16.2·§17.3). */
|
|
export function readPolicy(deps) {
|
|
const row = deps.store.get('SELECT value_json FROM settings WHERE key=?', 'mail.policy'); const v = parseJson(row?.value_json, {}) || {};
|
|
const item = (x, keys) => (x && keys.every((k) => x[k] != null && x[k] !== '') ? { ...x, deferred: false } : { ...(x || {}), deferred: true });
|
|
return { largeAttachment: item(v.largeAttachment, ['maxBytes', 'maxDownloads', 'maxDays']), aliases: item(v.aliases, ['perMailbox']), attachment: item(v.attachment, ['maxBytes', 'maxRecipients']), verifier: v.verifier || deps.config?.values?.mailVerifier || null, warnLevels: v.warnLevels || quota.DEFAULT_WARN_LEVELS, note: '수치는 활성 상품정책(운영자 콘솔 › 상품과 정책)에서 정합니다. 시스템 안전 상한과 악용 방지 제한은 상품보다 우선합니다' };
|
|
}
|
|
export function writePolicy(deps, patch, by = 'ops') { const cur = parseJson(deps.store.get('SELECT value_json FROM settings WHERE key=?', 'mail.policy')?.value_json, {}) || {}; const next = { ...cur, ...patch }; deps.store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', 'mail.policy', toJson(next), nowIso(), by); return readPolicy(deps); }
|
|
|
|
// ------------------------------------------------------------------ 조회
|
|
const svcRow = (r) => (r ? { ...r, settings: parseJson(r.settings_json, {}) } : null);
|
|
export function getService(deps, groupId, id) { const r = svcRow(deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=?', id, groupId)); if (!r) throw err(404, 'not_found', '메일 서비스를 찾을 수 없습니다'); return r; }
|
|
export function listServices(deps, groupId) {
|
|
return deps.store.all('SELECT * FROM mail_services WHERE group_id=? AND status<>\'deleted\' ORDER BY created_at DESC', groupId).map(svcRow).map((s) => ({ ...s, ...figures(deps, s) }));
|
|
}
|
|
/** 서비스별 배정·사용·경고(화면 목록용). 사용량은 마지막 계측값(quota_usage). */
|
|
export function figures(deps, svc) {
|
|
const f = (res) => { const a = quota.allocationOf(deps.store, svc.group_id, res, 'mail_service', svc.id); const u = quota.usageOf(deps.store, svc.group_id, res, 'mail_service', svc.id); return { allocated: a?.amount || 0, allocState: a?.state || 'available', used: u?.used || 0, measuredAt: u?.measured_at || null, percent: quota.percent(u?.used || 0, a?.amount || 0), level: quota.warnLevel(u?.used || 0, a?.amount || 0) }; };
|
|
const mailboxCount = deps.store.get('SELECT COUNT(*) AS n FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL', svc.id).n;
|
|
const out = { mailboxes: { ...f('mailboxes'), count: mailboxCount }, storage: f('mail_storage'), monthly: f('mail_msgs'), nextReset: nextMonthStart(), monthStart: monthStart() };
|
|
out.warnLevel = Math.max(out.mailboxes.level, out.storage.level, out.monthly.level);
|
|
out.stale = !out.storage.measuredAt || Date.now() - Date.parse(out.storage.measuredAt) > 15 * 60 * 1000;
|
|
return out;
|
|
}
|
|
/** 사이트의 대표 메일(살아 있는 것) */
|
|
export function primaryMailOfSite(deps, siteId) { return svcRow(deps.store.get('SELECT * FROM mail_services WHERE site_id=? AND kind=\'site_bound\' AND status NOT IN (\'deleted\',\'cancelled\') ORDER BY created_at LIMIT 1', siteId)); }
|
|
|
|
// ------------------------------------------------------------------ 생성(§15.3)
|
|
/**
|
|
* 메일 서비스 만들기. 권리·도메인·사이트 규칙을 검사하고 보장량을 배정한 뒤 AriMail 테넌트를 준비한다(실패해도 서비스는 needs_setup 으로 남아 다시 준비할 수 있다).
|
|
* @param {object} p { group, user, kind, siteId, domain, name, alloc:{mailboxes, storageBytes, monthlyMessages}, serverId }
|
|
*/
|
|
export async function createService(deps, { group, user, kind, siteId = null, domain, name = '', alloc = {}, serverId = null, ip = '' }) {
|
|
const { store } = deps; domain = String(domain || '').trim().toLowerCase();
|
|
if (!['site_bound', 'standalone'].includes(kind)) throw err(422, 'validation', '메일 서비스 종류를 고르세요', { field: 'kind' });
|
|
if (!DOMAIN_RE.test(domain)) throw err(422, 'validation', '도메인 이름이 올바르지 않습니다', { field: 'domain' });
|
|
const dom = store.get('SELECT * FROM domains WHERE group_id=? AND name=?', group.id, domain);
|
|
if (!dom) throw err(409, 'mail_domain_unknown', '먼저 도메인 화면에서 이 도메인을 등록하고 소유를 확인하세요', { link: '/domains', domain });
|
|
if (dom.mail_service_id && store.get('SELECT 1 FROM mail_services WHERE id=? AND status NOT IN (\'deleted\',\'cancelled\')', dom.mail_service_id)) throw err(409, 'mail_domain_in_use', '이 도메인은 이미 다른 메일 서비스가 쓰고 있습니다');
|
|
let site = null;
|
|
if (kind === 'site_bound') {
|
|
if (!siteId) throw err(422, 'validation', '대표 메일을 붙일 사이트를 고르세요', { field: 'siteId' });
|
|
site = store.get('SELECT * FROM sites WHERE id=? AND group_id=? AND lifecycle NOT IN (\'deleted\',\'pending_delete\')', siteId, group.id); if (!site) throw err(404, 'not_found', '사이트를 찾을 수 없습니다');
|
|
if (primaryMailOfSite(deps, site.id)) throw err(409, 'mail_site_has_primary', '이 사이트에는 이미 대표 메일 도메인이 있습니다. 사이트마다 대표 메일 도메인은 하나입니다(§15.1)');
|
|
if (dom.site_id !== site.id && site.primary_domain !== domain) throw err(409, 'mail_domain_not_site', '사이트 대표 메일은 그 사이트에 연결된 도메인만 쓸 수 있습니다', { siteDomains: store.all('SELECT name FROM domains WHERE site_id=?', site.id).map((d) => d.name) });
|
|
}
|
|
const id = newId('msv_'); const now = nowIso();
|
|
// 독립 메일은 권리(mail_services) 1개를 소비한다 — 예약·확정을 한 트랜잭션처럼 처리해 동시 생성 중 하나만 성공한다(§13.3).
|
|
let reservation = null;
|
|
if (kind === 'standalone') { try { reservation = quota.reserve(store, { groupId: group.id, resource: 'mail_services', amount: 1, subjectType: 'mail_service', subjectId: id, reason: '독립 메일 서비스 생성' }); } catch (e) { if (e.code === 'quota_insufficient') throw err(409, 'quota_insufficient', '독립 메일 서비스 옵션이 부족합니다. 상품 및 결제에서 독립 메일 옵션을 추가하세요', { ...e.detail, link: '/billing' }); throw e; } }
|
|
try {
|
|
store.tx(() => {
|
|
// 보장량 배정(§13.3): 구매 총량·다른 서비스 배정·예약을 넘지 않는지 setAllocation 이 검사한다.
|
|
for (const [res, amount] of [['mailboxes', alloc.mailboxes], ['mail_storage', alloc.storageBytes], ['mail_msgs', alloc.monthlyMessages]]) { if (amount == null || amount === '') continue; const n = Number(amount); if (!Number.isFinite(n) || n < 0) throw err(422, 'validation', '배정량은 0 이상의 숫자여야 합니다', { field: res }); quota.setAllocation(store, { groupId: group.id, resource: res, subjectType: 'mail_service', subjectId: id, amount: n }); }
|
|
if (!(Number(alloc.mailboxes) > 0)) throw err(422, 'validation', '메일함 수를 1개 이상 배정해야 메일 서비스를 만들 수 있습니다. 메일함 권리가 없으면 상품 및 결제에서 추가하세요', { field: 'mailboxes', link: '/billing' });
|
|
store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, domain, arimail_server_id, status, dns_state, send_state, receive_state, settings_json, created_at, updated_at) VALUES(?,?,?,?,?,?,?,\'needs_setup\',\'pending\',\'unknown\',\'unknown\',?,?,?)', id, group.id, kind, site?.id || null, name || domain, domain, serverId, toJson({ createdBy: user.id }), now, now);
|
|
store.run('UPDATE domains SET mail_service_id=?, purpose=CASE WHEN purpose=\'web\' THEN \'both\' ELSE purpose END, updated_at=? WHERE id=?', id, now, dom.id);
|
|
if (reservation) quota.commit(store, reservation.id);
|
|
});
|
|
} catch (e) { if (reservation) quota.release(store, reservation.id); if (e.code === 'quota_insufficient' || e.code === 'allocation_below_usage') throw err(409, e.code, e.message, { ...e.detail, link: '/usage' }); throw e; }
|
|
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'mail_service', subjectId: id, action: 'mail.service.create', detail: { kind, domain, siteId: site?.id || null, alloc }, ip });
|
|
const svc = getService(deps, group.id, id);
|
|
await prepareTenant(deps, svc).catch(() => {}); // 준비 실패는 서비스 행의 prep_error 로 드러나고 '다시 준비' 로 재시도한다
|
|
return getService(deps, group.id, id);
|
|
}
|
|
/** AriMail 테넌트(도메인) 준비: 도메인 추가(이미 있으면 ariweb 표시만), 보장량 적용, SSO 비밀. 실패는 settings.prepError 에 남긴다. */
|
|
export async function prepareTenant(deps, svc) {
|
|
const { store, arimail } = deps; const serverId = svc.arimail_server_id || arimail.defaultServer().id; const alloc = figures(deps, svc);
|
|
const ariweb = { kind: svc.kind, siteId: svc.site_id || '', groupId: svc.group_id, mailServiceId: svc.id };
|
|
try {
|
|
const existing = (await arimail.domains(serverId)).find((d) => d.domain === svc.domain);
|
|
let initialAdmin = null;
|
|
if (!existing) { const r = await arimail.addDomain(serverId, { domain: svc.domain, ariweb, operating: 'active', note: `Ariweb Suite ${svc.kind}` }); initialAdmin = r.initialAdmin; }
|
|
else await arimail.updateDomain(serverId, svc.domain, { ariweb, operating: existing.operating === 'frozen' ? 'active' : existing.operating || 'active' });
|
|
await arimail.setDomainLimits(serverId, svc.domain, { mailboxes: alloc.mailboxes.allocated || '', storageBytes: alloc.storage.allocated || null, monthlyMessages: alloc.monthly.allocated || '' });
|
|
await arimail.ensureSso(serverId, svc.domain).catch(() => {});
|
|
// 서비스 관리자 메일함(admin@도메인)은 AriMail 이 도메인을 만들 때 생긴다. 포털 메일함 표에도 한 행을 둔다(사용자가 비밀번호를 모르므로 '재설정 필요').
|
|
if (initialAdmin && !store.get('SELECT 1 FROM mailboxes WHERE mail_service_id=? AND address=?', svc.id, `${initialAdmin.login}@${svc.domain}`)) store.run('INSERT INTO mailboxes(id, mail_service_id, group_id, address, display_name, status, must_change_password, arimail_user_id, created_at, updated_at) VALUES(?,?,?,?,?,\'active\',1,?,?,?)', newId('mb_'), svc.id, svc.group_id, `${initialAdmin.login}@${svc.domain}`, '서비스 관리자', null, nowIso(), nowIso());
|
|
store.run('UPDATE mail_services SET arimail_server_id=?, settings_json=?, updated_at=? WHERE id=?', serverId, toJson({ ...svc.settings, prepError: null, preparedAt: nowIso() }), nowIso(), svc.id);
|
|
return { ok: true };
|
|
} catch (e) { store.run('UPDATE mail_services SET arimail_server_id=?, settings_json=?, updated_at=? WHERE id=?', serverId, toJson({ ...svc.settings, prepError: e.code === 'service_unreachable' ? 'service_unreachable' : safeError(e), prepErrorAt: nowIso() }), nowIso(), svc.id); return { ok: false, error: e.code || e.message }; }
|
|
}
|
|
|
|
// ------------------------------------------------------------------ DNS(§15.3 5~6단계, §18)
|
|
/** AriMail 이 요구하는 DNS 레코드(MX·SPF·DKIM·DMARC·Resend 전용 레코드 등) — 복사 가능한 정확한 값 */
|
|
export async function requiredRecords(deps, svc) {
|
|
const cfg = await deps.arimail.domainConfig(svc.arimail_server_id || deps.arimail.defaultServer().id, svc.domain);
|
|
const recs = cfg?.meta?.dns?.records || cfg?.meta?.dns || []; const list = Array.isArray(recs) ? recs : Object.values(recs).flat().filter((r) => r && r.type);
|
|
return { records: list.map((r) => ({ type: String(r.type || '').toUpperCase(), name: r.name || svc.domain, value: r.value || r.data || '', ttl: r.ttl || 3600, note: r.note || '' })), host: cfg?.meta?.host || `mail.${svc.domain}`, outbound: cfg?.meta?.outbound, inbound: cfg?.meta?.inbound, outboundStatus: cfg?.meta?.outboundStatus || null };
|
|
}
|
|
/** 공용 DNS 조회(deps.dnsResolver 로 바꿀 수 있음: { mx(name)→[host], txt(name)→[string] }) */
|
|
async function resolver(deps) {
|
|
if (deps.dnsResolver) return deps.dnsResolver;
|
|
const dns = await import('node:dns'); const r = new dns.promises.Resolver({ timeout: 4000, tries: 2 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
|
|
return { mx: async (n) => (await r.resolveMx(n).catch(() => [])).map((x) => String(x.exchange).toLowerCase().replace(/\.$/, '')), txt: async (n) => (await r.resolveTxt(n).catch(() => [])).map((x) => x.join('')) };
|
|
}
|
|
/**
|
|
* DNS 반영 확인(§12.2·§18): 미반영(missing)과 잘못된 값(wrong)을 구분한다. 결과는 domains.check_json·mail_dns_state 와 mail_services.dns_state 에 저장.
|
|
* @returns {{ok:boolean, items:[{type,name,expected,found,state:'ok'|'missing'|'wrong'|'recommended'}], checkedAt}}
|
|
*/
|
|
export async function checkDns(deps, svc) {
|
|
const { records, host } = await requiredRecords(deps, svc); const r = await resolver(deps); const items = [];
|
|
for (const rec of records) {
|
|
if (rec.type === 'MX') { const found = await r.mx(rec.name); const want = String(rec.value).replace(/^\d+\s+/, '').toLowerCase().replace(/\.$/, ''); items.push({ ...rec, found, state: found.includes(want) ? 'ok' : found.length ? 'wrong' : 'missing', kind: 'mx' }); }
|
|
else if (rec.type === 'TXT') { const found = await r.txt(rec.name); const want = String(rec.value).trim(); const isSpf = /^v=spf1/i.test(want), isDkim = /^v=DKIM1/i.test(want) || /_domainkey/.test(rec.name), isDmarc = /_dmarc\./.test(rec.name); const kind = isSpf ? 'spf' : isDkim ? 'dkim' : isDmarc ? 'dmarc' : 'txt';
|
|
const match = found.some((f) => f.replace(/\s+/g, ' ').trim() === want.replace(/\s+/g, ' ')) || (isSpf && found.some((f) => /^v=spf1/i.test(f) && f.includes(want.replace(/^v=spf1\s*/i, '').replace(/\s*[-~?]all$/i, '').trim()))) || (isDkim && found.some((f) => f.replace(/\s+/g, '') === want.replace(/\s+/g, '')));
|
|
const anySame = found.some((f) => (isSpf ? /^v=spf1/i.test(f) : isDkim ? /v=DKIM1/i.test(f) : isDmarc ? /v=DMARC1/i.test(f) : false));
|
|
items.push({ ...rec, found, kind, state: match ? 'ok' : anySame ? 'wrong' : isDmarc ? 'recommended' : 'missing' }); }
|
|
else if (rec.type === 'CNAME' || rec.type === 'A') { items.push({ ...rec, found: [], state: 'unchecked', kind: rec.type.toLowerCase() }); }
|
|
}
|
|
const required = items.filter((i) => ['mx', 'spf', 'dkim'].includes(i.kind)); const ok = required.length > 0 && required.every((i) => i.state === 'ok');
|
|
const result = { ok, items, host, checkedAt: nowIso(), summary: { ok: items.filter((i) => i.state === 'ok').length, missing: items.filter((i) => i.state === 'missing').length, wrong: items.filter((i) => i.state === 'wrong').length, recommended: items.filter((i) => i.state === 'recommended').length } };
|
|
deps.store.run('UPDATE mail_services SET dns_state=?, settings_json=?, updated_at=? WHERE id=?', ok ? 'ok' : result.summary.wrong ? 'error' : 'pending', toJson({ ...svc.settings, dns: result }), nowIso(), svc.id);
|
|
deps.store.run('UPDATE domains SET mail_dns_state=?, last_checked_at=?, check_json=? WHERE name=? AND group_id=?', ok ? 'ok' : result.summary.wrong ? 'error' : 'pending', result.checkedAt, toJson({ mail: result }), svc.domain, svc.group_id);
|
|
return result;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 외부 송수신 확인(§15.3 8단계)
|
|
/**
|
|
* 실제 외부 왕복 확인: 서비스의 검증 메일함이 운영자가 정한 외부 검증 주소로 보내고(송신), 그 주소의 자동 회신이 수신함에 들어오는지(수신·회신) 본다.
|
|
* 검증 주소가 없으면 정직하게 needs_setup 으로 끝난다(내부 준비 상태만으로 완료하지 않음).
|
|
*/
|
|
export async function runVerification(deps, svc, { kind = 'activation', by = null } = {}) {
|
|
const { store, arimail } = deps; const policy = readPolicy(deps); const id = newId('mt_'); const now = nowIso();
|
|
store.run('INSERT INTO mail_tests(id, mail_service_id, group_id, kind, status, verifier, steps_json, created_by, created_at, updated_at) VALUES(?,?,?,?,\'running\',?,?,?,?,?)', id, svc.id, svc.group_id, kind, policy.verifier || null, '[]', by, now, now);
|
|
const steps = []; const step = (sid, state, detail) => { steps.push({ id: sid, state, detail: detail || '' }); store.run('UPDATE mail_tests SET steps_json=?, updated_at=? WHERE id=?', toJson(steps), nowIso(), id); };
|
|
let status = 'failed'; let result = {};
|
|
try {
|
|
const ob = await arimail.outboundCheck(svc.arimail_server_id, svc.domain).catch((e) => ({ provider: e.code || e.message }));
|
|
step('outbound_ready', ob && (ob.provider === 'ok' || ob.provider === 'verified' || ob.domain?.status === 'verified') ? 'done' : 'failed', `발신 서비스 ${ob?.mode || ''} ${ob?.provider || ''}`.trim());
|
|
if (!policy.verifier) { step('round_trip', 'needs_setup', '외부 검증 주소가 설정되지 않았습니다(운영자 콘솔 › 메일 서비스 › 정책 › 외부 검증 주소)'); status = 'needs_setup'; }
|
|
else if (!arimail.roundTrip) { step('round_trip', 'needs_setup', '이 연동에서는 왕복 확인을 지원하지 않습니다'); status = 'needs_setup'; }
|
|
else { const rt = await arimail.roundTrip(svc.arimail_server_id, svc.domain, { to: policy.verifier, timeoutMs: deps.verifyTimeoutMs || VERIFY_TIMEOUT_MS }); step('send', rt.sent ? 'done' : 'failed', rt.sendDetail || ''); step('receive', rt.received ? 'done' : 'failed', rt.receiveDetail || ''); step('reply', rt.replied ? 'done' : 'failed', rt.replyDetail || ''); result = rt; status = rt.sent && rt.received && rt.replied ? 'ok' : 'failed'; }
|
|
} catch (e) { step('error', 'failed', safeError(e)); result = { error: e.code || e.message }; status = e.code === 'service_unreachable' ? 'stale' : 'failed'; }
|
|
store.run('UPDATE mail_tests SET status=?, result_json=?, ended_at=?, updated_at=? WHERE id=?', status, toJson(result), nowIso(), nowIso(), id);
|
|
store.run('UPDATE mail_services SET send_state=?, receive_state=?, updated_at=? WHERE id=?', status === 'ok' ? 'ok' : status === 'needs_setup' ? 'unverified' : status === 'stale' ? 'stale' : 'error', status === 'ok' ? 'ok' : status === 'needs_setup' ? 'unverified' : status === 'stale' ? 'stale' : 'error', nowIso(), svc.id);
|
|
return { id, status, steps, result };
|
|
}
|
|
export const lastTest = (deps, svcId, kind = null) => { const r = deps.store.get(`SELECT * FROM mail_tests WHERE mail_service_id=? ${kind ? 'AND kind=?' : ''} ORDER BY created_at DESC LIMIT 1`, ...(kind ? [svcId, kind] : [svcId])); return r ? { ...r, steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null) } : null; };
|
|
|
|
// ------------------------------------------------------------------ 활성화 작업(§15.3·§35)
|
|
/** 활성화 작업 등록(portal/mail/api.mjs register 가 부른다). 단계: 권리 → DNS → 준비 → 외부 확인 → 활성화 */
|
|
export function registerJobs(deps) {
|
|
deps.jobs.register('mail.activate', { title: '메일 서비스 활성화', maxAttempts: 3, steps: [{ id: 'entitlement', title: '상품 권리 확인' }, { id: 'dns', title: 'DNS 반영 확인' }, { id: 'prepare', title: '메일 서버 준비' }, { id: 'verify', title: '외부 송신·수신·회신 확인' }, { id: 'activate', title: '활성화' }],
|
|
async run(job, ctx) {
|
|
const svc = getService(deps, job.group_id, job.subject.mailServiceId);
|
|
await ctx.step('entitlement', () => { const f = figures(deps, svc); if (!(f.mailboxes.allocated > 0)) throw new JobError('entitlement_missing', '결제·승인된 메일함 권리가 없어 활성화할 수 없습니다'); return { mailboxes: f.mailboxes.allocated, storage: f.storage.allocated, monthly: f.monthly.allocated }; });
|
|
const dns = await ctx.step('dns', async () => { const r = await checkDns(deps, svc); if (!r.ok) { ctx.log(`DNS 미완료: 누락 ${r.summary.missing} · 잘못된 값 ${r.summary.wrong}`); } return r.summary; }, 'DNS 반영 확인');
|
|
if (dns && (dns.missing || dns.wrong)) { ctx.steps().find((s) => s.id === 'dns').state = 'pending'; ctx.waitExternal(`DNS 레코드가 아직 반영되지 않았습니다(누락 ${dns.missing}, 잘못된 값 ${dns.wrong}). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요`); }
|
|
await ctx.step('prepare', async () => { const r = await prepareTenant(deps, getService(deps, job.group_id, svc.id)); if (!r.ok) throw new JobError('prepare_failed', `메일 서버 준비 실패: ${r.error}`, { retryable: r.error === 'service_unreachable' }); return r; });
|
|
const v = await ctx.step('verify', async () => runVerification(deps, getService(deps, job.group_id, svc.id), { kind: 'activation', by: job.created_by }));
|
|
// 확인이 끝나지 않았으면 단계를 '대기' 로 되돌려 재개 때 다시 확인한다(done 으로 두면 재개해도 같은 결과만 돌려준다).
|
|
if (v.status !== 'ok') ctx.steps().find((s) => s.id === 'verify').state = 'pending';
|
|
if (v.status === 'needs_setup') ctx.needsUser('외부 검증 주소가 설정되지 않아 실제 송수신을 확인하지 못했습니다. 운영자에게 설정을 요청한 뒤 이어서 진행하세요', [{ id: 'resume', label: '다시 확인' }]);
|
|
if (v.status !== 'ok') { ctx.steps().find((s) => s.id === 'verify').state = 'failed'; throw new JobError('verify_failed', '외부 송수신 확인에 실패했습니다. DNS·발신 설정을 확인한 뒤 다시 시도하세요'); }
|
|
await ctx.step('activate', () => { deps.store.run('UPDATE mail_services SET status=\'active\', updated_at=? WHERE id=?', nowIso(), svc.id); notify(deps.store, { groupId: svc.group_id, category: 'mail', severity: 'info', subjectType: 'mail_service', subjectId: svc.id, incidentKey: `mail:${svc.id}:activate`, title: `메일 서비스 활성화: ${svc.domain}`, body: '외부 송신·수신·회신 확인을 마쳤습니다', link: `/mail/${svc.id}` }); audit(deps.store, { actorType: 'system', automatic: true, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, action: 'mail.service.activate' }); return { activatedAt: nowIso() }; });
|
|
return { ok: true };
|
|
} });
|
|
deps.jobs.register('mail.convert', { title: '사이트 대표 메일 → 독립 메일 전환', maxAttempts: 1, steps: [{ id: 'checklist', title: '전환 점검표 확인' }, { id: 'entitlement', title: '독립 메일 옵션 예약' }, { id: 'before', title: '전환 전 송수신 확인' }, { id: 'switch', title: '서비스 종류 전환' }, { id: 'after', title: '전환 후 송수신 확인' }, { id: 'compare', title: '주소·메일함·설정·DNS 유지 확인' }],
|
|
async run(job, ctx) { return convertRun(deps, job, ctx); }, async revert(job, ctx) { return convertRevert(deps, job, ctx); } });
|
|
}
|
|
export function startActivation(deps, svc, user) {
|
|
if (svc.status === 'active') throw err(409, 'already_active', '이미 활성화된 서비스입니다');
|
|
if (['cancelled', 'deleted'].includes(svc.status)) throw err(409, 'mail_cancelled', '해지된 서비스는 활성화할 수 없습니다');
|
|
const { job, created } = deps.jobs.enqueue({ kind: 'mail.activate', subject: { mailServiceId: svc.id }, idempotencyKey: `mail.activate:${svc.id}`, createdBy: user.id, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, label: `메일 활성화 ${svc.domain}` });
|
|
if (created || ['done', 'failed', 'cancelled'].includes(job.status)) { if (!created) { deps.store.run('UPDATE jobs SET idempotency_key=NULL WHERE id=?', job.id); return startActivation(deps, svc, user); } deps.store.run('UPDATE mail_services SET status=\'activating\', updated_at=? WHERE id=?', nowIso(), svc.id); }
|
|
return job;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 메일함(§16)
|
|
const mbRow = (r) => (r ? { ...r, settings: parseJson(r.settings_json, {}) } : null);
|
|
export function listMailboxes(deps, svc) { return deps.store.all('SELECT * FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL ORDER BY created_at', svc.id).map(mbRow); }
|
|
export function getMailbox(deps, svc, id) { const r = mbRow(deps.store.get('SELECT * FROM mailboxes WHERE id=? AND mail_service_id=? AND deleted_at IS NULL', id, svc.id)); if (!r) throw err(404, 'not_found', '메일함을 찾을 수 없습니다'); return r; }
|
|
/** 생성 전 남은 메일함 수·공간(§16.1). 정지된 메일함도 센다. */
|
|
export function mailboxCapacity(deps, svc) { const f = figures(deps, svc); const quotas = deps.store.get('SELECT COALESCE(SUM(quota_bytes),0) AS q FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL', svc.id).q; return { mailboxes: { allocated: f.mailboxes.allocated, used: f.mailboxes.count, remaining: Math.max(0, f.mailboxes.allocated - f.mailboxes.count) }, storage: { allocated: f.storage.allocated, used: f.storage.used, assignedQuotas: quotas, remaining: Math.max(0, f.storage.allocated - f.storage.used) }, note: '정지된 메일함도 삭제 전까지 메일함 수와 저장 공간을 소비합니다' }; }
|
|
/**
|
|
* 메일함 만들기. 초기 비밀번호 방식(한 번만 돌려줌·첫 로그인 변경)이나 초대 방식(링크로 비밀번호 설정, 만료·재발송·사용 완료 표시).
|
|
* @returns {{mailbox, initialPassword?:string, invite?:{url, expiresAt}}}
|
|
*/
|
|
export async function createMailbox(deps, svc, user, { local, displayName = '', quotaBytes = 0, mode = 'password', inviteEmail = '', ip = '' }) {
|
|
const { store, arimail } = deps; local = String(local || '').trim().toLowerCase();
|
|
if (!LOCAL_RE.test(local)) throw err(422, 'validation', '메일 주소 앞부분은 영문 소문자·숫자로 시작하는 1~64자(영문, 숫자, . _ -)여야 합니다', { field: 'local' });
|
|
if (!['active', 'activating', 'needs_setup'].includes(svc.status)) throw err(409, 'mail_not_active', '해지·중지된 서비스에는 메일함을 만들 수 없습니다');
|
|
const cap = mailboxCapacity(deps, svc);
|
|
if (cap.mailboxes.remaining <= 0) throw err(409, 'mailbox_limit', `메일함 수가 한도(${cap.mailboxes.allocated}개)에 찼습니다. 사용량 화면에서 메일함 배정을 늘리거나 상품을 추가하세요`, { ...cap.mailboxes, link: '/usage' });
|
|
quotaBytes = Math.max(0, Math.round(Number(quotaBytes) || 0));
|
|
if (quotaBytes > cap.storage.remaining + 0) throw err(409, 'storage_limit', `남은 저장 공간(${cap.storage.remaining} B)보다 큰 메일함 크기를 줄 수 없습니다`, { ...cap.storage, link: '/usage' });
|
|
const address = `${local}@${svc.domain}`; if (store.get('SELECT 1 FROM mailboxes WHERE address=? AND deleted_at IS NULL', address)) throw err(409, 'mailbox_exists', '이미 있는 메일 주소입니다');
|
|
const password = randomToken(12);
|
|
let created; try { created = await arimail.createUser(svc.arimail_server_id, svc.domain, { login: local, name: displayName, password, quotaBytes: quotaBytes || undefined }); }
|
|
catch (e) { if (e.code === 'login_taken') throw err(409, 'mailbox_exists', '메일 서버에 같은 이름의 계정이 이미 있습니다'); if (e.code === 'account_limit_reached') throw err(409, 'mailbox_limit', '메일 서버의 계정 한도에 찼습니다'); if (e.code === 'service_unreachable') throw err(409, 'service_unreachable', '메일 서버에 연결할 수 없어 지금은 메일함을 만들 수 없습니다(정보 확인 지연). 잠시 후 다시 시도하세요'); throw err(409, e.code || 'arimail_error', `메일 서버 오류: ${e.message}`); }
|
|
const id = newId('mb_'); const now = nowIso();
|
|
store.run('INSERT INTO mailboxes(id, mail_service_id, group_id, address, display_name, status, quota_bytes, arimail_user_id, invite_state, must_change_password, settings_json, created_at, updated_at) VALUES(?,?,?,?,?,\'active\',?,?,?,?,?,?,?)', id, svc.id, svc.group_id, address, displayName, quotaBytes, created.id, mode === 'invite' ? 'pending' : null, mode === 'password' ? 1 : 0, toJson({ createdBy: user.id, mode }), now, now);
|
|
audit(store, { actorType: 'user', actorId: user.id, groupId: svc.group_id, subjectType: 'mailbox', subjectId: id, action: 'mail.mailbox.create', detail: { address, mode, quotaBytes }, ip });
|
|
const mailbox = getMailbox(deps, svc, id);
|
|
if (mode === 'invite') { const inv = issueInvite(deps, mailbox, inviteEmail); return { mailbox, invite: inv }; }
|
|
return { mailbox, initialPassword: password, note: '초기 비밀번호는 지금 한 번만 보입니다. 첫 로그인 뒤 웹메일 설정에서 비밀번호를 바꾸세요' };
|
|
}
|
|
/** 초대 링크 발급(토큰 원문은 한 번만). */
|
|
export function issueInvite(deps, mailbox, email = '', { resentCount = 0 } = {}) {
|
|
const token = randomToken(24); const id = newId('mi_'); const expiresAt = new Date(Date.now() + 3 * 86400000).toISOString();
|
|
deps.store.run('INSERT INTO mail_invites(id, mailbox_id, group_id, email, token_hash, expires_at, resent_count, created_at) VALUES(?,?,?,?,?,?,?,?)', id, mailbox.id, mailbox.group_id, email || null, sha256(token), expiresAt, resentCount, nowIso());
|
|
deps.store.run('UPDATE mailboxes SET invite_state=\'pending\', updated_at=? WHERE id=?', nowIso(), mailbox.id);
|
|
const url = `${deps.config.publicUrl.replace(/\/$/, '')}/mail/invite/${token}`;
|
|
if (email && deps.mailer?.send) deps.mailer.send({ to: email, subject: `[Ariweb] ${mailbox.address} 메일함 초대`, text: `메일함 비밀번호를 정하려면 다음 링크를 여세요(3일 안에): ${url}` }).catch(() => {});
|
|
return { id, url, expiresAt, email: email || null };
|
|
}
|
|
export function inviteState(deps, mailboxId) { const r = deps.store.get('SELECT id, email, expires_at, used_at, resent_count, created_at FROM mail_invites WHERE mailbox_id=? ORDER BY created_at DESC LIMIT 1', mailboxId); if (!r) return null; return { ...r, state: r.used_at ? 'used' : Date.parse(r.expires_at) < Date.now() ? 'expired' : 'pending' }; }
|
|
export function resendInvite(deps, mailbox) { const prev = deps.store.get('SELECT * FROM mail_invites WHERE mailbox_id=? ORDER BY created_at DESC LIMIT 1', mailbox.id); if (prev?.used_at) throw err(409, 'invite_used', '이미 사용된 초대입니다'); if (prev) deps.store.run('UPDATE mail_invites SET expires_at=? WHERE id=?', new Date(0).toISOString(), prev.id); return issueInvite(deps, mailbox, prev?.email || '', { resentCount: (prev?.resent_count || 0) + 1 }); } // 옛 링크는 즉시 만료, 재발송 횟수는 새 링크에 이어서 센다
|
|
/** 초대 수락: 토큰 확인 → AriMail 비밀번호 설정 → 사용 완료 */
|
|
export async function acceptInvite(deps, token, password) {
|
|
const inv = deps.store.get('SELECT * FROM mail_invites WHERE token_hash=?', sha256(String(token || ''))); if (!inv) throw err(404, 'invite_invalid', '초대 링크가 올바르지 않습니다');
|
|
if (inv.used_at) throw err(410, 'invite_used', '이미 사용한 초대 링크입니다'); if (Date.parse(inv.expires_at) < Date.now()) throw err(410, 'invite_expired', '초대 링크가 만료되었습니다. 관리자에게 재발송을 요청하세요');
|
|
if (String(password || '').length < 8) throw err(422, 'weak_password', '비밀번호는 8자 이상이어야 합니다');
|
|
const mb = mbRow(deps.store.get('SELECT * FROM mailboxes WHERE id=?', inv.mailbox_id)); const svc = svcRow(deps.store.get('SELECT * FROM mail_services WHERE id=?', mb.mail_service_id));
|
|
await deps.arimail.updateUser(svc.arimail_server_id, svc.domain, mb.arimail_user_id, { password });
|
|
deps.store.run('UPDATE mail_invites SET used_at=? WHERE id=?', nowIso(), inv.id); deps.store.run('UPDATE mailboxes SET invite_state=\'used\', must_change_password=0, updated_at=? WHERE id=?', nowIso(), mb.id);
|
|
return { address: mb.address, webmail: deps.arimail.programSettings(svc.arimail_server_id, svc.domain, mb.address).webmail };
|
|
}
|
|
export async function updateMailbox(deps, svc, mb, user, patch, ip = '') {
|
|
const { store, arimail } = deps; const out = {}; const ariPatch = {};
|
|
if (patch.displayName !== undefined) { ariPatch.name = String(patch.displayName).slice(0, 80); store.run('UPDATE mailboxes SET display_name=?, updated_at=? WHERE id=?', ariPatch.name, nowIso(), mb.id); }
|
|
if (patch.quotaBytes !== undefined) { const q = Math.max(0, Math.round(Number(patch.quotaBytes) || 0)); const cap = mailboxCapacity(deps, svc); if (q > cap.storage.allocated) throw err(409, 'storage_limit', '서비스에 배정된 저장 공간을 넘는 크기입니다', { link: '/usage' }); ariPatch.quotaBytes = q; store.run('UPDATE mailboxes SET quota_bytes=?, updated_at=? WHERE id=?', q, nowIso(), mb.id); }
|
|
if (patch.resetPassword) { const pw = randomToken(12); ariPatch.password = pw; out.newPassword = pw; store.run('UPDATE mailboxes SET must_change_password=1, updated_at=? WHERE id=?', nowIso(), mb.id); }
|
|
if (patch.status && ['active', 'suspended'].includes(patch.status)) { ariPatch.status = patch.status; store.run('UPDATE mailboxes SET status=?, updated_at=? WHERE id=?', patch.status, nowIso(), mb.id); }
|
|
if (patch.services && typeof patch.services === 'object') ariPatch.services = { pop: patch.services.pop !== false, imap: patch.services.imap !== false };
|
|
if (Object.keys(ariPatch).length && mb.arimail_user_id) { try { await arimail.updateUser(svc.arimail_server_id, svc.domain, mb.arimail_user_id, ariPatch); } catch (e) { if (e.code === 'service_unreachable') throw err(409, 'service_unreachable', '메일 서버에 연결할 수 없어 변경을 적용하지 못했습니다(정보 확인 지연)'); throw err(409, e.code || 'arimail_error', `메일 서버 오류: ${e.message}`); } }
|
|
audit(store, { actorType: 'user', actorId: user.id, groupId: svc.group_id, subjectType: 'mailbox', subjectId: mb.id, action: 'mail.mailbox.update', detail: { keys: Object.keys(patch) }, ip });
|
|
return { mailbox: getMailbox(deps, svc, mb.id), ...out };
|
|
}
|
|
export async function endMailboxSessions(deps, svc, mb, user) { const r = await deps.arimail.endSessions(svc.arimail_server_id, svc.domain, mb.arimail_user_id); deps.store.run('UPDATE mailboxes SET must_change_password=1, updated_at=? WHERE id=?', nowIso(), mb.id); audit(deps.store, { actorType: 'user', actorId: user.id, groupId: svc.group_id, subjectType: 'mailbox', subjectId: mb.id, action: 'mail.mailbox.end_sessions' }); return { ...r, note: '모든 세션을 끊기 위해 비밀번호를 재설정했습니다. 사용자는 비밀번호를 다시 정해야 합니다' }; }
|
|
export async function deleteMailbox(deps, svc, mb, user, ip = '') { if (mb.arimail_user_id) { try { await deps.arimail.deleteUser(svc.arimail_server_id, svc.domain, mb.arimail_user_id); } catch (e) { if (e.code !== 'not_found') throw err(409, e.code || 'arimail_error', e.message); } } deps.store.run('UPDATE mailboxes SET deleted_at=?, status=\'deleted\', updated_at=? WHERE id=?', nowIso(), nowIso(), mb.id); audit(deps.store, { actorType: 'user', actorId: user.id, groupId: svc.group_id, subjectType: 'mailbox', subjectId: mb.id, action: 'mail.mailbox.delete', detail: { address: mb.address }, ip }); return { ok: true }; }
|
|
|
|
// ------------------------------------------------------------------ 서비스 설정(POP/IMAP, 대용량 첨부)
|
|
/** POP3/IMAP 서비스 스위치: 영향 안내 → 적용 → 실제 접속 가능 여부 확인(§16.2) */
|
|
export async function setProtocols(deps, svc, user, { pop, imap }) {
|
|
const { arimail, store } = deps; const r = await arimail.setServices(svc.arimail_server_id, svc.domain, { ...(pop !== undefined ? { pop: !!pop } : {}), ...(imap !== undefined ? { imap: !!imap } : {}) });
|
|
const services = r.services || { pop: !!pop, imap: !!imap }; store.run('UPDATE mail_services SET pop_enabled=?, imap_enabled=?, updated_at=? WHERE id=?', services.pop ? 1 : 0, services.imap ? 1 : 0, nowIso(), svc.id);
|
|
const check = await protocolCheck(deps, svc); audit(store, { actorType: 'user', actorId: user.id, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, action: 'mail.service.protocols', detail: { services, check: check.summary } });
|
|
return { services, check };
|
|
}
|
|
/** 적용 뒤 실제 접속 확인: 메일 서버 포트 TCP 접속(deps.tcpProbe 로 바꿀 수 있음) + AriMail 상태 */
|
|
export async function protocolCheck(deps, svc) {
|
|
const st = await deps.arimail.adminStatus(svc.arimail_server_id, svc.domain).catch((e) => ({ error: e.code || e.message })); const host = st.host || `mail.${svc.domain}`;
|
|
const probe = deps.tcpProbe || (async (h, p) => { const net = await import('node:net'); return new Promise((res) => { const s = net.connect({ host: h, port: p, timeout: 4000 }); s.once('connect', () => { s.destroy(); res(true); }); s.once('error', () => res(false)); s.once('timeout', () => { s.destroy(); res(false); }); }); });
|
|
const ports = st.ports || { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }; const results = {};
|
|
for (const [k, p] of Object.entries(ports)) results[k] = { port: p, reachable: st.error ? null : await probe(host, p) };
|
|
return { host, services: st.services || null, ports: results, stale: !!st.error, summary: st.error ? 'stale' : Object.values(results).every((x) => x.reachable) ? 'ok' : 'partial', checkedAt: nowIso() };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 사용량 계측(§17)
|
|
/**
|
|
* AriMail 사용량 API 로 서비스·메일함 사용량을 읽어 lib/quota.recordUsage(mail_storage·mail_msgs·mailboxes, subject mail_service)와 mailboxes 행에 기록한다.
|
|
* 메일함별 저장 공간 70/80/90/95/100 경고는 여기서 알림으로 만든다(서비스 단위 경고는 사용량 영역의 공통 엔진이 quota_usage 를 보고 만든다).
|
|
*/
|
|
export async function collectMailUsage(deps, svcId) {
|
|
const { store, arimail } = deps; const svc = svcRow(store.get('SELECT * FROM mail_services WHERE id=?', svcId)); if (!svc || !svc.domain) return null;
|
|
let u; try { u = await arimail.usage(svc.arimail_server_id, svc.domain, { days: 30 }); } catch (e) { store.run('UPDATE mail_services SET settings_json=?, updated_at=? WHERE id=?', toJson({ ...svc.settings, usageError: e.code || e.message, usageErrorAt: nowIso() }), nowIso(), svc.id); return { ok: false, error: e.code || e.message }; }
|
|
const at = nowIso(); const storage = Number(u.storage?.bytes || 0), monthly = Number(u.traffic?.month?.total || 0), mailboxes = store.get('SELECT COUNT(*) AS n FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL', svc.id).n;
|
|
const ps = monthStart();
|
|
quota.recordUsage(store, { groupId: svc.group_id, resource: 'mail_storage', subjectType: 'mail_service', subjectId: svc.id, used: storage, source: 'arimail', measuredAt: at });
|
|
quota.recordUsage(store, { groupId: svc.group_id, resource: 'mail_msgs', subjectType: 'mail_service', subjectId: svc.id, used: monthly, periodStart: ps, source: 'arimail', measuredAt: at });
|
|
quota.recordUsage(store, { groupId: svc.group_id, resource: 'mailboxes', subjectType: 'mail_service', subjectId: svc.id, used: mailboxes, source: 'portal', measuredAt: at });
|
|
const f = figures(deps, svc); const levels = readPolicy(deps).warnLevels;
|
|
store.run('INSERT INTO mail_usage_log(id, mail_service_id, group_id, subject_type, subject_id, storage_bytes, monthly_messages, mailboxes, warn_level, limited_json, measured_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', newId('mu_'), svc.id, svc.group_id, 'mail_service', svc.id, storage, monthly, mailboxes, f.warnLevel, toJson(u.limitedReasons || []), at);
|
|
// 메일함별
|
|
for (const a of u.accountList || []) {
|
|
const mb = store.get('SELECT * FROM mailboxes WHERE mail_service_id=? AND address=? AND deleted_at IS NULL', svc.id, String(a.mailbox).toLowerCase()); if (!mb) continue;
|
|
const used = Number(a.bytes || 0), limit = Number(a.limit || 0) || mb.quota_bytes || 0; const level = quota.warnLevel(used, limit, levels);
|
|
store.run('UPDATE mailboxes SET settings_json=?, last_login_at=COALESCE(?, last_login_at), status=CASE WHEN status=\'suspended\' THEN status WHEN ? THEN \'over_quota\' ELSE \'active\' END, updated_at=? WHERE id=?', toJson({ ...parseJson(mb.settings_json, {}), usage: { bytes: used, limit, messages: a.messages || 0, sent: a.sent || 0, received: a.received || 0, measuredAt: at }, level }), a.lastLoginAt || null, level === 100 ? 1 : 0, at, mb.id);
|
|
if (level >= 70) notify(store, { groupId: svc.group_id, category: 'usage', severity: level >= 95 ? 'critical' : level >= 90 ? 'warning' : 'info', mandatory: level === 100, subjectType: 'mailbox', subjectId: mb.id, incidentKey: `${svc.group_id}:mail_storage:${mb.id}:${level}`, title: `메일함 ${mb.address} 저장 공간 ${level === 100 ? '100% — 새 수신 임시 거부' : level + '% 경고'}`, body: level === 100 ? '기존 메일 읽기·삭제는 그대로이며 새 수신은 임시 실패로 응답해 발신 서버가 재시도합니다. 오래된 메일을 지우거나 메일함 크기를 늘리세요' : `현재 ${used} B / ${limit} B. 정리하거나 크기를 늘리세요`, link: `/mail/${svc.id}/mailboxes/${mb.id}` });
|
|
}
|
|
// AriMail 쪽 제한 사유(저장 공간·월간 통수·계정 수)를 서비스 상태에 반영
|
|
store.run('UPDATE mail_services SET settings_json=?, updated_at=? WHERE id=?', toJson({ ...svc.settings, usageError: null, lastUsage: { storage, monthly, sent: u.traffic?.month?.sent || 0, received: u.traffic?.month?.received || 0, mailboxes, warnLevel: u.warnLevel, limitedReasons: u.limitedReasons || [], operating: u.operating, monthReset: u.traffic?.monthReset || nextMonthStart(), measuredAt: at, countingRule: u.traffic?.countingRule || 'per_final_recipient' } }), at, svc.id);
|
|
return { ok: true, storage, monthly, mailboxes, warnLevel: f.warnLevel, limitedReasons: u.limitedReasons || [] };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 보안(§18)
|
|
/** 송신 도메인·수신·SPF·DKIM·DMARC·인증서·외부 시험을 정상/권장 개선/오류로 */
|
|
export async function securityReport(deps, svc) {
|
|
const dns = svc.settings.dns || null; const fresh = dns && Date.now() - Date.parse(dns.checkedAt) < 10 * 60 * 1000 ? dns : await checkDns(deps, svc).catch((e) => ({ error: e.code || e.message, items: [], summary: {} }));
|
|
const byKind = (k) => fresh.items.filter((i) => i.kind === k);
|
|
const stateOf = (k, required = true) => { const list = byKind(k); if (!list.length) return { state: required ? 'error' : 'recommended', text: required ? '필요한 값이 없습니다' : '권장 레코드' }; const bad = list.find((i) => i.state !== 'ok'); if (!bad) return { state: 'ok', text: '정상' }; return { state: bad.state === 'recommended' ? 'recommended' : bad.state === 'wrong' ? 'error' : required ? 'error' : 'recommended', text: bad.state === 'wrong' ? '값이 다릅니다' : bad.state === 'missing' ? '아직 반영되지 않았습니다' : '권장 개선' }; };
|
|
const test = lastTest(deps, svc.id); const ob = svc.settings.outboundStatus || null;
|
|
const protocols = await protocolCheck(deps, svc).catch(() => ({ summary: 'stale', ports: {} }));
|
|
return { domain: svc.domain, checkedAt: fresh.checkedAt || null, stale: !!fresh.error, items: [
|
|
{ id: 'sender', label: '송신 도메인 확인', ...(svc.send_state === 'ok' ? { state: 'ok', text: '정상' } : svc.send_state === 'stale' ? { state: 'stale', text: '정보 확인 지연' } : { state: 'error', text: '외부 발신 확인 전' }) },
|
|
{ id: 'mx', label: '수신 설정(MX)', ...stateOf('mx') }, { id: 'spf', label: 'SPF', ...stateOf('spf') }, { id: 'dkim', label: 'DKIM', ...stateOf('dkim') }, { id: 'dmarc', label: 'DMARC', ...stateOf('dmarc', false) },
|
|
{ id: 'tls', label: '인증서(TLS)', ...(protocols.summary === 'stale' ? { state: 'stale', text: '정보 확인 지연' } : protocols.ports?.imaps?.reachable && protocols.ports?.smtps?.reachable ? { state: 'ok', text: 'TLS 포트 응답' } : { state: 'error', text: 'TLS 포트에 접속할 수 없습니다' }) },
|
|
{ id: 'roundtrip', label: '외부 송수신 시험', ...(test?.status === 'ok' ? { state: 'ok', text: `정상 (${test.ended_at})` } : test?.status === 'needs_setup' ? { state: 'recommended', text: '외부 검증 주소 미설정' } : test ? { state: 'error', text: `실패 (${test.ended_at})` } : { state: 'recommended', text: '아직 시험하지 않음' }) },
|
|
], records: fresh.items.map((i) => ({ type: i.type, name: i.name, value: i.value, state: i.state, found: i.found })), outboundStatus: ob };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 전환(§14 4~5단계)
|
|
/** 전환 점검표: 유지되는 항목을 항목별로 표시하고, 독립 메일 옵션이 있는지 확인한다. */
|
|
export async function conversionChecklist(deps, svc, { excludeJobId = null } = {}) {
|
|
if (svc.kind !== 'site_bound') throw err(409, 'not_site_bound', '이미 독립 메일 서비스입니다');
|
|
const boxes = listMailboxes(deps, svc); const aliases = deps.store.all('SELECT * FROM mail_aliases WHERE mail_service_id=? AND status=\'active\'', svc.id); const f = quota.figures(deps.store, svc.group_id, 'mail_services');
|
|
let remote = null; try { remote = await deps.arimail.listUsers(svc.arimail_server_id, svc.domain); } catch (e) { remote = { error: e.code || e.message }; }
|
|
const items = [
|
|
{ id: 'addresses', label: '기존 메일 주소', keep: true, detail: `${boxes.length}개 (${boxes.map((b) => b.address).slice(0, 5).join(', ')}${boxes.length > 5 ? ' …' : ''})`, ok: !remote?.error && (remote.users || []).length >= boxes.filter((b) => b.arimail_user_id).length, note: remote?.error ? '메일 서버 확인 지연' : '' },
|
|
{ id: 'data', label: '메일함 데이터', keep: true, detail: '메일 서버의 메일함을 그대로 둡니다(이동 없음)', ok: true },
|
|
{ id: 'aliases', label: '별칭·전달 전용 주소', keep: true, detail: aliases.length ? `${aliases.length}개 유지` : '없음', ok: true },
|
|
{ id: 'forwards', label: '전달·자동 응답 설정', keep: true, detail: '각 메일함의 웹메일 설정 그대로 유지', ok: true },
|
|
{ id: 'passwords', label: '사용자 비밀번호', keep: true, detail: '메일 서버 계정이 유지되므로 바뀌지 않습니다', ok: true },
|
|
{ id: 'dns', label: 'DNS 설정', keep: true, detail: 'MX·SPF·DKIM·DMARC 유지(도메인이 같음)', ok: svc.dns_state === 'ok', note: svc.dns_state === 'ok' ? '' : 'DNS 확인이 끝나지 않았습니다' },
|
|
{ id: 'entitlement', label: '독립 메일 옵션', keep: null, detail: f.unallocated > 0 ? `사용 가능 ${f.unallocated}개` : '부족 — 상품 및 결제에서 독립 메일 옵션을 추가하세요', ok: f.unallocated > 0, link: f.unallocated > 0 ? null : '/billing' },
|
|
// 전환 작업 자신(excludeJobId)은 '진행 중 작업' 으로 세지 않는다.
|
|
{ id: 'jobs', label: '진행 중 작업', keep: null, detail: '', ok: !deps.store.get('SELECT 1 FROM jobs WHERE subject_type=\'mail_service\' AND subject_id=? AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\') AND id<>?', svc.id, excludeJobId || '') },
|
|
];
|
|
return { items, canConvert: items.every((i) => i.ok), siteId: svc.site_id };
|
|
}
|
|
export async function convertToStandalone(deps, mailServiceId, user) {
|
|
const svc = svcRow(deps.store.get('SELECT * FROM mail_services WHERE id=?', mailServiceId)); if (!svc) throw err(404, 'not_found', '메일 서비스를 찾을 수 없습니다');
|
|
const cl = await conversionChecklist(deps, svc); if (!cl.canConvert) throw err(409, 'conversion_blocked', '전환 점검표에 충족되지 않은 항목이 있습니다', { items: cl.items.filter((i) => !i.ok) });
|
|
const { job } = deps.jobs.enqueue({ kind: 'mail.convert', subject: { mailServiceId: svc.id, siteId: svc.site_id }, idempotencyKey: `mail.convert:${svc.id}:${svc.updated_at}`, createdBy: user?.id || null, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, label: `독립 메일 전환 ${svc.domain}` });
|
|
deps.store.run('INSERT INTO mail_conversions(id, mail_service_id, group_id, site_id, status, checklist_json, job_id, created_by, created_at, updated_at) VALUES(?,?,?,?,\'running\',?,?,?,?,?)', newId('mc_'), svc.id, svc.group_id, svc.site_id, toJson(cl.items), job.id, user?.id || null, nowIso(), nowIso());
|
|
return job;
|
|
}
|
|
async function convertRun(deps, job, ctx) {
|
|
const { store, arimail } = deps; const svc = () => svcRow(store.get('SELECT * FROM mail_services WHERE id=?', job.subject.mailServiceId)); const s0 = svc();
|
|
await ctx.step('checklist', async () => { const cl = await conversionChecklist(deps, s0, { excludeJobId: job.id }); if (!cl.canConvert) throw new JobError('conversion_blocked', '점검표 미충족: ' + cl.items.filter((i) => !i.ok).map((i) => i.label).join(', ')); return cl.items.map((i) => i.id); });
|
|
const res = await ctx.step('entitlement', () => { try { return quota.reserve(store, { groupId: s0.group_id, resource: 'mail_services', amount: 1, subjectType: 'mail_service', subjectId: s0.id, jobId: job.id, reason: '대표 메일 → 독립 전환' }); } catch (e) { throw new JobError(e.code || 'quota', e.message); } });
|
|
const before = await ctx.step('before', async () => { const v = await runVerification(deps, s0, { kind: 'conversion_before', by: job.created_by }); const users = await arimail.listUsers(s0.arimail_server_id, s0.domain).catch(() => null); return { test: v.status, addresses: (users?.users || []).map((u) => u.mailbox).sort(), count: (users?.users || []).length }; });
|
|
if (before.test !== 'ok' && before.test !== 'needs_setup') throw new JobError('verify_failed', '전환 전 송수신 확인에 실패해 전환하지 않습니다');
|
|
await ctx.step('switch', async () => { await arimail.updateDomain(s0.arimail_server_id, s0.domain, { ariweb: { kind: 'standalone', siteId: '', groupId: s0.group_id, mailServiceId: s0.id } }); store.run('UPDATE mail_services SET kind=\'standalone\', site_id=NULL, settings_json=?, updated_at=? WHERE id=?', toJson({ ...s0.settings, convertedFromSite: s0.site_id, convertedAt: nowIso() }), nowIso(), s0.id); quota.commit(store, res.id); return { convertedAt: nowIso() }; });
|
|
const after = await ctx.step('after', async () => { const v = await runVerification(deps, svc(), { kind: 'conversion_after', by: job.created_by }); const users = await arimail.listUsers(s0.arimail_server_id, s0.domain).catch(() => null); return { test: v.status, addresses: (users?.users || []).map((u) => u.mailbox).sort(), count: (users?.users || []).length }; });
|
|
await ctx.step('compare', () => { const missing = before.addresses.filter((a) => !after.addresses.includes(a)); if (missing.length || (before.test === 'ok' && after.test !== 'ok')) throw new JobError('conversion_incomplete', `전환 뒤 확인에서 누락이 있습니다(주소 ${missing.length}개, 송수신 ${after.test}). 사이트 수를 반환하지 않습니다`, { detail: { missing } }); return { missing: [] }; });
|
|
store.run('UPDATE mail_conversions SET status=\'done\', ended_at=?, updated_at=? WHERE job_id=?', nowIso(), nowIso(), job.id);
|
|
notify(store, { groupId: s0.group_id, category: 'mail', severity: 'info', subjectType: 'mail_service', subjectId: s0.id, incidentKey: `mail:${s0.id}:convert`, title: `독립 메일 서비스로 전환 완료: ${s0.domain}`, body: '주소·메일함·별칭·전달·비밀번호·DNS 가 그대로 유지되었습니다', link: `/mail/${s0.id}` });
|
|
audit(store, { actorType: 'user', actorId: job.created_by, groupId: s0.group_id, subjectType: 'mail_service', subjectId: s0.id, action: 'mail.service.convert', detail: { fromSite: s0.site_id }, immutable: true });
|
|
return { ok: true, siteId: s0.site_id };
|
|
}
|
|
async function convertRevert(deps, job, ctx) {
|
|
const { store, arimail } = deps; const s = svcRow(store.get('SELECT * FROM mail_services WHERE id=?', job.subject.mailServiceId)); const siteId = job.subject.siteId;
|
|
await ctx.step('revert', async () => { if (s.kind === 'standalone' && s.settings.convertedFromSite) { await arimail.updateDomain(s.arimail_server_id, s.domain, { ariweb: { kind: 'site_bound', siteId, groupId: s.group_id, mailServiceId: s.id } }).catch(() => {}); store.run('UPDATE mail_services SET kind=\'site_bound\', site_id=?, updated_at=? WHERE id=?', siteId, nowIso(), s.id); const a = quota.allocationOf(store, s.group_id, 'mail_services', 'mail_service', s.id); if (a) quota.finishReturn(store, a.id); } for (const r of store.all('SELECT id FROM quota_reservations WHERE job_id=? AND state=\'reserved\'', job.id)) quota.release(store, r.id); store.run('UPDATE mail_conversions SET status=\'reverted\', ended_at=?, updated_at=? WHERE job_id=?', nowIso(), nowIso(), job.id); return { reverted: true }; });
|
|
}
|
|
/** 사이트 수 반환(§14) 전 메일 조건: 대표 메일이 남아 있으면 반환 불가 — 전환하거나 종료해야 한다. */
|
|
export function canReturnSiteCount(deps, siteId) {
|
|
const p = primaryMailOfSite(deps, siteId); if (!p) return { ok: true, reasons: [], mailServiceId: null };
|
|
const f = quota.figures(deps.store, p.group_id, 'mail_services');
|
|
return { ok: false, mailServiceId: p.id, domain: p.domain, reasons: [{ code: 'site_primary_mail_active', message: `사이트 대표 메일(${p.domain})이 남아 있습니다. 유지하려면 독립 메일 서비스로 전환하고(독립 메일 옵션 ${f.unallocated > 0 ? '사용 가능' : '부족 — 먼저 구매'}), 아니면 메일 서비스를 종료하세요`, link: `/mail/${p.id}/convert`, canConvert: f.unallocated > 0 }] };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 해지·보존·삭제(§23.2)
|
|
/** 해지: 송수신 즉시 중지(AriMail operating=frozen), 30일 읽기·내보내기, 삭제 예정일. 배정은 '반환 중' 으로 두고 삭제 때 반환한다. */
|
|
export async function cancelMailService(deps, id, user = null, { reason = '' } = {}) {
|
|
const { store, arimail } = deps; const svc = svcRow(store.get('SELECT * FROM mail_services WHERE id=?', id)); if (!svc) throw err(404, 'not_found', '메일 서비스를 찾을 수 없습니다'); if (['cancelled', 'deleted'].includes(svc.status)) return svc;
|
|
const now = Date.now(); const retention = new Date(now + RETENTION_DAYS * 86400000).toISOString();
|
|
try { await arimail.updateDomain(svc.arimail_server_id, svc.domain, { operating: 'frozen' }); } catch (e) { if (e.code !== 'not_found') store.run('UPDATE mail_services SET settings_json=? WHERE id=?', toJson({ ...svc.settings, freezeError: e.code || e.message }), svc.id); }
|
|
store.run('UPDATE mail_services SET status=\'cancelled\', cancelled_at=?, retention_until=?, delete_after=?, send_state=\'stopped\', receive_state=\'stopped\', updated_at=? WHERE id=?', nowIso(), retention, retention, nowIso(), svc.id);
|
|
for (const res of ['mailboxes', 'mail_storage', 'mail_msgs', 'mail_services']) { const a = quota.allocationOf(store, svc.group_id, res, 'mail_service', svc.id); if (a) quota.beginReturn(store, a.id, retention); }
|
|
store.run('UPDATE domains SET mail_service_id=NULL, mail_dns_state=NULL, updated_at=? WHERE mail_service_id=?', nowIso(), svc.id);
|
|
notify(store, { groupId: svc.group_id, category: 'mail', severity: 'critical', mandatory: true, subjectType: 'mail_service', subjectId: svc.id, incidentKey: `mail:${svc.id}:cancel`, title: `메일 서비스 해지: ${svc.domain}`, body: `송수신은 지금 중지되었습니다. ${retention.slice(0, 10)} 까지 기존 메일 읽기·내보내기가 가능하며 그 뒤 삭제됩니다. 메일 백업·복원 기능은 제공되지 않습니다`, link: `/mail/${svc.id}` });
|
|
audit(store, { actorType: user ? 'user' : 'system', actorId: user?.id || null, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, action: 'cancel.mail_service', detail: { reason, retentionUntil: retention }, immutable: true });
|
|
return svcRow(store.get('SELECT * FROM mail_services WHERE id=?', svc.id));
|
|
}
|
|
/** 보존 종료 → 삭제(주기 작업). 삭제 뒤에는 복구 가능한 것처럼 표시하지 않는다. */
|
|
export async function purgeExpired(deps, at = nowIso()) {
|
|
const rows = deps.store.all('SELECT * FROM mail_services WHERE status=\'cancelled\' AND delete_after IS NOT NULL AND delete_after<=?', at); const done = [];
|
|
for (const r of rows) {
|
|
const svc = svcRow(r); if (svc.settings.deleteHold) continue; // 법적 보존·분쟁(§23.2): 삭제 보류
|
|
try { await deps.arimail.removeDomain(svc.arimail_server_id, svc.domain); } catch (e) { if (e.code === 'service_unreachable') continue; }
|
|
deps.store.run('UPDATE mail_services SET status=\'deleted\', deleted_at=?, updated_at=? WHERE id=?', at, at, svc.id); deps.store.run('UPDATE mailboxes SET deleted_at=COALESCE(deleted_at, ?), status=\'deleted\' WHERE mail_service_id=?', at, svc.id);
|
|
for (const res of ['mailboxes', 'mail_storage', 'mail_msgs', 'mail_services']) { const a = quota.allocationOf(deps.store, svc.group_id, res, 'mail_service', svc.id); if (a) quota.finishReturn(deps.store, a.id); }
|
|
notify(deps.store, { groupId: svc.group_id, category: 'mail', severity: 'info', subjectType: 'mail_service', subjectId: svc.id, incidentKey: `mail:${svc.id}:deleted`, title: `메일 서비스 삭제 완료: ${svc.domain}`, body: '보존 기간이 끝나 메일 데이터를 삭제했습니다. 복구할 수 없습니다', link: '/mail' });
|
|
audit(deps.store, { actorType: 'system', automatic: true, groupId: svc.group_id, subjectType: 'mail_service', subjectId: svc.id, action: 'terminate.mail_service.delete', immutable: true }); done.push(svc.id);
|
|
}
|
|
return done;
|
|
}
|
|
/** 생명주기 화면(§23.3) */
|
|
export function lifecycle(deps, svc) {
|
|
const f = figures(deps, svc); const retention = svc.retention_until; const deleted = svc.status === 'deleted';
|
|
return { state: deleted ? 'deleted' : svc.status === 'cancelled' ? 'readonly_retention' : svc.status, label: { active: '운영 중', activating: '활성화 중', needs_setup: '설정 필요', cancelled: '읽기 전용 보존', deleted: '삭제 완료', suspended: '일시 중지' }[svc.status] || svc.status,
|
|
publicService: svc.status === 'active' ? '송수신 가능' : '송수신 중지', adminAccess: deleted ? '불가' : '가능(웹메일 읽기·삭제·내보내기)', consumes: deleted ? '없음' : `메일함 ${f.mailboxes.allocated}개 · 저장 ${f.storage.allocated} B · 월 ${f.monthly.allocated}통${svc.kind === 'standalone' ? ' · 독립 메일 옵션 1' : ''}${svc.status === 'cancelled' ? ' (반환 중, 삭제 때 반환)' : ''}`,
|
|
billing: svc.status === 'cancelled' ? `해지 시각 ${svc.cancelled_at} 이후 청구 종료(다음 청구에 반영)` : '청구 지속', domainMail: svc.status === 'active' ? 'DNS·메일 동작' : 'DNS 레코드는 남아 있으나 메일 서버가 임시 거부', backup: '메일 백업·복원 기능은 제공되지 않습니다(§17.4)', exportable: svc.status === 'cancelled' && !deleted ? `가능(${retention} 까지, IMAP/POP3 또는 웹메일 원본 다운로드)` : deleted ? '불가' : '가능', resumable: svc.status === 'cancelled' ? '해지 취소는 보존 기간 안에 운영자 문의' : '해당 없음', irreversibleAt: svc.delete_after || null, retentionUntil: retention, deleteAfter: svc.delete_after, nextNotice: svc.delete_after ? new Date(Date.parse(svc.delete_after) - 7 * 86400000).toISOString() : null };
|
|
}
|
|
/** 내보내기 요청(§23.2): AriMail 에는 일괄 내보내기 API 가 없어 안내형 기록을 남긴다(IMAP/POP3·웹메일 원본 다운로드). */
|
|
export function requestExport(deps, svc, user) { const id = newId('mx_'); deps.store.run('INSERT INTO mail_exports(id, mail_service_id, group_id, status, created_by, created_at, expires_at) VALUES(?,?,?,\'guidance\',?,?,?)', id, svc.id, svc.group_id, user.id, nowIso(), svc.retention_until); return { id, status: 'guidance', guidance: ['보존 기간 동안 웹메일에서 메일별 "원본 다운로드(.eml)" 를 쓸 수 있습니다', 'IMAP(993) 또는 POP3(995)으로 메일 프로그램에 받아 보관할 수 있습니다(송수신은 중지되어 있어도 읽기는 됩니다)', '일괄 .eml 묶음 내보내기는 이번 버전에서 지원하지 않습니다'], until: svc.retention_until }; }
|
|
/** 별칭·전달 전용 주소(§16.2): 정책이 확정된 경우에만 만들고, 수는 정책 한도 안. 메일 서버 쪽 별칭 기능은 이번 버전에서 지원하지 않아 기록만 둔다. */
|
|
export function createAlias(deps, svc, user, { address, target, kind = 'alias' }) {
|
|
const policy = readPolicy(deps); if (policy.aliases.deferred) throw err(409, 'policy_deferred', '별칭·전달 전용 주소의 제공 수는 최신 상품정책에서 정합니다. 정책이 확정되기 전에는 만들 수 없습니다(무제한 제공 없음)');
|
|
throw err(409, 'unsupported_in_version', '별칭·전달 전용 주소 생성은 이번 버전(0.1.1)에서 지원하지 않습니다. 전달은 각 메일함의 웹메일 설정(메일 전달)을 쓰세요');
|
|
}
|
|
/** 발송 제한 상태(§17.2 화면) */
|
|
export async function sendLimits(deps, svc) { const r = await deps.arimail.rates(svc.arimail_server_id, svc.domain).catch((e) => ({ error: e.code || e.message })); const sub = (r.rates?.submissions || []).filter((s) => s.key.endsWith('@' + svc.domain) || s.key.startsWith('account:')); return { stale: !!r.error, deliveryRate: r.rates?.deliveryRate || 5, submissionRate: r.rates?.submissionRate || 10, throttled: sub.filter((s) => s.throttledUntil).map((s) => ({ key: s.key, until: s.throttledUntil, rate: s.lastRate })), note: '초당 10건 이상 제출은 자동 제한·악용 확인 대상이며, 외부 전달은 도메인·서버 각각 초당 5건을 넘지 않습니다. 대량 발송 상품이 아닙니다', contact: deps.config.operatorEmail || '운영자 문의' }; }
|