메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 AriMail 도입 수정(KST 월 경계, 발신 키 오류, 도메인별 보장량, 100% 제한, 발송 큐·속도, 운영 상태, SSO) (§15~§18·§0.4)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:47:45 +00:00
co-authored by Claude Fable 5.1
parent a014e3cd05
commit a1cde81332
26 changed files with 1610 additions and 37 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"mail": {
"title": "메일", "list": "메일 서비스", "new": "메일 서비스 만들기", "kind": { "site_bound": "사이트 대표 메일", "standalone": "독립 메일 서비스" },
"status": { "needs_setup": "설정 필요", "activating": "활성화 중", "active": "운영 중", "cancelled": "읽기 전용 보존", "deleted": "삭제 완료", "suspended": "일시 중지" },
"tabs": { "overview": "개요", "mailboxes": "메일함", "usage": "사용량", "security": "도메인 보안", "settings": "설정", "convert": "독립 전환", "lifecycle": "생명주기" },
"noBackup": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다.",
"retention": "해지 뒤 30일 동안 기존 메일 읽기·내보내기가 가능하며 그 뒤 삭제됩니다.",
"counting": "월간 통수는 송신과 수신의 합계이며 최종 수신자 한 명당 한 통으로 셉니다.",
"limit100": { "storage": "저장 공간 100%: 기존 메일 조회·삭제는 유지되고 새 수신은 임시 실패로 응답해 발신 서버가 재시도합니다.", "monthly": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", "mailboxes": "메일함 수 100%: 기존 메일함은 유지되고 새 메일함 생성이 막힙니다." },
"sendLimit": "초당 10건 이상의 SMTP 제출은 자동 제한·경고·악용 확인 대상이며, 실제 외부 전달은 도메인과 서버 각각 초당 5건을 넘지 않습니다. 정상적인 대량 발송 상품이 아닙니다.",
"policyDeferred": "후속 상품정책에서 정합니다", "suspendedCounts": "정지된 메일함도 삭제 전까지 메일함 수와 저장 공간을 소비합니다.",
"initialPasswordOnce": "초기 비밀번호는 지금 한 번만 보입니다. 첫 로그인에서 비밀번호를 바꿔야 합니다.",
"protocolImpact": "POP3 또는 IMAP 을 끄면 그 방식으로 연결된 메일 프로그램(Outlook·Thunderbird·휴대전화 메일 등)이 즉시 접속에 실패합니다. 웹메일은 영향이 없습니다."
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO"
# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다.
if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi
git reset --hard --quiet origin/main; echo " $(git log --oneline -1)"
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage; do "$NODE_BIN" "test/$t.test.mjs"; done
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage ariweb; do "$NODE_BIN" "test/$t.test.mjs"; done
echo "▶ 릴리스 배치"; mkdir -p "$TARGET"
for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done
git rev-parse HEAD > "$TARGET/REVISION"
@@ -97,6 +97,9 @@ exports.deliver = function (next, connection) {
.then(({ status, data }) => {
if (status >= 200 && status < 300 && data && data.ok) {
if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); }
// [Ariweb Suite 변경] 월간 통수 100%·도메인 이전 중(동결)도 임시 거부(보낸 서버가 재시도). 기존 메일 읽기·삭제는 그대로입니다.
if (data.skipped === 'monthly_limit') { connection.lognotice(this, `monthly limit: ${rcpt.join(',')}`); return next(DENYSOFT, 'Monthly message limit reached, try again later'); }
if (data.skipped === 'domain_not_active') { connection.lognotice(this, `domain not active: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mail domain temporarily unavailable (migration), try again later'); }
connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`);
return next(OK, 'Message accepted for delivery');
}
@@ -0,0 +1,30 @@
-- [Ariweb Suite 변경] 1.0.11+ariweb: 발송 대기열과 악용 의심 표(Dev-Order §17.2).
-- outbound_queue: 발송 모듈(Resend/Haraka/SMTP 중계)에 넘기기 직전부터 끝날 때까지의 기록. 프로세스가 중간에 죽으면 'interrupted' 로 남아 관리자가 다시 보낼 수 있습니다(메일을 잃지 않음).
-- state: sending(발송 중) → sent(끝) | failed(발송 모듈 오류, 재시도 가능) | interrupted(재시작으로 끊김)
-- abuse_flags: 제출 속도 초과(초당 10건 이상) 등 운영자가 확인할 신호. 메일 내용은 넣지 않습니다.
CREATE TABLE outbound_queue (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
mailbox TEXT NOT NULL DEFAULT '',
send_key TEXT,
payload_json TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'sending' CHECK(state IN ('sending','sent','failed','interrupted')),
attempts INTEGER NOT NULL DEFAULT 0,
provider_id TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sent_at TEXT
);
CREATE INDEX outbound_queue_state ON outbound_queue(state, created_at);
CREATE TABLE abuse_flags (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
subject TEXT NOT NULL,
detail TEXT,
rate REAL,
created_at TEXT NOT NULL,
reviewed_at TEXT,
reviewed_by TEXT
);
CREATE INDEX abuse_flags_open ON abuse_flags(reviewed_at, created_at);
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server/index.mjs",
"migrate": "node server/db-migrate.mjs",
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs"
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs"
},
"engines": {
"node": ">=22.13"
+39 -2
View File
@@ -23,6 +23,9 @@ import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, ui
import { knownMailboxes as mailboxList } from './auth.mjs';
import { sanitizeHtml } from './sanitize.mjs';
import { verifySvix } from './webhook.mjs';
import { submit as submitRate, rateSnapshot, queueList, abuseList, reviewAbuse, recoverQueue } from './sendqueue.mjs'; // [Ariweb Suite 변경]
import { mailboxUser } from './auth.mjs';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs';
import { autoActionsOf, autoReplyActive } from './automation.mjs';
import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs';
@@ -82,6 +85,16 @@ export async function handle(env, request, params) {
if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request);
// 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림).
if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method);
// [Ariweb Suite 변경] 포털 SSO(§16.3): GET /api/auth/sso?ticket=<payload>.<sig> — 포털이 ARIWEB_SSO_SECRET 으로 서명한 티켓(메일함·만료·nonce)을 검증해
// 그 메일함 주인의 웹 메일 세션을 만들고 웹 메일 첫 화면으로 보냅니다. 비밀번호를 포털에 알려 주지 않아도 "현재 로그인 상태로 안전하게 연결" 됩니다.
if (path[0] === 'auth' && path[1] === 'sso' && method === 'GET') {
const v = verifySsoTicket(env, new URL(request.url).searchParams.get('ticket') || '');
if (!v.ok) { await audit(env, null, 'sso_denied', null, { ip, reason: v.reason }); return bad(v.reason, 401); }
const user = await mailboxUser(env, v.mailbox);
if (!user || user.status === 'suspended') { await audit(env, null, 'sso_denied', v.mailbox, { ip, reason: 'no_mailbox' }); return bad('mailbox_not_found', 404); }
const token = await createSession(env, user, request, { scope: 'mail' }); await audit(env, user.login, 'login', null, { ip, source: user.source, via: 'ariweb-sso' });
return new Response(null, { status: 302, headers: { Location: (prefix || '') + '/', 'Set-Cookie': sessionCookie(token, { scope: 'mail', path: prefix }), 'Cache-Control': 'no-store' } });
}
if (path[0] === 'login' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
@@ -124,6 +137,9 @@ export async function handle(env, request, params) {
if (path[0] === 'admin') {
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
// [Ariweb Suite 변경] 발송 속도·제출 창·대기열·악용 의심 표(§17.2, 운영자 확인용). 메일 내용은 없습니다.
if (path[1] === 'rates' && method === 'GET') return json({ ok: true, rates: rateSnapshot(), queue: await queueList(env), abuse: await abuseList(env) });
if (path[1] === 'abuse' && path[2] && method === 'PATCH') { await reviewAbuse(env, path[2], user.login); await audit(env, user.login, 'abuse_reviewed', path[2]); return json({ ok: true }); }
if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') });
// 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다.
if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) });
@@ -293,6 +309,8 @@ export async function handle(env, request, params) {
if (method === 'POST' && action === 'send') {
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
const b = await request.json().catch(() => ({}));
// [Ariweb Suite 변경] 웹 발송도 제출 속도 제한(계정·IP 초당 10건)을 받습니다.
{ const th = await submitRate(env, ['account:' + mailbox, ...(ip ? ['ip:' + ip] : [])]); if (th.throttled) return bad('mail_submission_throttled', 429, { until: th.until, rate: th.rate }); }
await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); }
if (method === 'POST' && action === 'attachments') {
// JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
@@ -324,10 +342,10 @@ export async function handle(env, request, params) {
}
return bad('not_found', 404);
} catch (e) {
const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
const status = e.message === 'mail_domain_frozen' ? 423 : e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
// weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다.
// 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다.
return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
return bad(e.message || 'server_error', status, { ...(e.code && e.code !== e.message ? { code: e.code } : {}), ...(e.resetAt ? { resetAt: e.resetAt } : {}), ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
}
}
@@ -477,3 +495,22 @@ async function adminStatus(env) {
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
});
}
/**
* [Ariweb Suite 변경] 포털 SSO 티켓 검증. 티켓 = base64url(JSON{ mailbox, exp(ms), nonce }) + '.' + base64url(HMAC-SHA256(ARIWEB_SSO_SECRET, payload)).
* 만료(exp)는 5분 이내, nonce 는 한 번만(메모리, 만료까지 보관). 비밀이 설정되지 않은 도메인은 거부합니다.
*/
const usedNonces = new Map();
export function verifySsoTicket(env, ticket) {
const secret = String(env.ARIWEB_SSO_SECRET || env.CONFIG?.ARIWEB_SSO_SECRET || ''); if (!secret) return { ok: false, reason: 'sso_not_configured' };
const [payload, sig] = String(ticket || '').split('.'); if (!payload || !sig) return { ok: false, reason: 'sso_bad_ticket' };
const expect = createHmac('sha256', secret).update(payload).digest('base64url');
const a = Buffer.from(expect), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: 'sso_bad_signature' };
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch { return { ok: false, reason: 'sso_bad_ticket' }; }
const t = Date.now(); if (!d || typeof d.mailbox !== 'string' || !Number.isFinite(d.exp) || d.exp < t || d.exp > t + 10 * 60 * 1000 || typeof d.nonce !== 'string') return { ok: false, reason: 'sso_expired' };
for (const [k, exp] of usedNonces) if (exp < t) usedNonces.delete(k);
if (usedNonces.has(d.nonce)) return { ok: false, reason: 'sso_replayed' };
usedNonces.set(d.nonce, d.exp);
const mailbox = d.mailbox.trim().toLowerCase(); if (mailbox.split('@')[1] !== mailDomain(env)) return { ok: false, reason: 'sso_wrong_domain' };
return { ok: true, mailbox };
}
+8
View File
@@ -77,6 +77,14 @@ export const CONFIG_SCHEMA = [
// 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음).
{ key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true },
{ key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' },
// [Ariweb Suite 변경] 도메인별 보장량(Dev-Order §0.4·§13.3): 테넌트(도메인)마다 설정 파일에 두는 상한. 값이 있으면 환경 파일(gateway.env)의 LIMIT_* 보다 우선합니다(limits.systemLimits).
// 환경 변수 이름을 두지 않는 이유: 환경 변수에 두면 테넌트가 기본 도메인의 값을 물려받지 못하게 되어(TENANT_OWN_KEYS) 기존 운영 서버의 동작이 바뀝니다.
{ key: 'LIMIT_ACCOUNTS', group: 'limits', type: 'number' },
{ key: 'LIMIT_TOTAL_BYTES', group: 'limits', type: 'text' },
{ key: 'LIMIT_MAILBOX_BYTES', group: 'limits', type: 'text' },
{ key: 'LIMIT_MONTHLY_MESSAGES', group: 'limits', type: 'number' },
// [Ariweb Suite 변경] 포털 SSO 티켓 서명 비밀(§16.3): 포털 보관소와 같은 값. 화면에는 보이지 않고 설정 API 로만 넣습니다.
{ key: 'ARIWEB_SSO_SECRET', group: 'ariweb', type: 'secret', hidden: true },
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
{ key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false },
];
+20 -4
View File
@@ -134,8 +134,22 @@ export function normalizeEntry(body, current = null, { keepAuth = false } = {})
}
const note = String(body.note === undefined ? (current?.note || '') : body.note || '').trim().slice(0, 200);
const enabled = body.enabled === undefined ? (current ? current.enabled !== false : true) : !(body.enabled === false || body.enabled === 'false' || body.enabled === '0' || body.enabled === 0);
// [Ariweb Suite 변경] Ariweb Suite 연동 정보(§0.4): 사이트 대표 메일(site_bound) / 독립 메일 서비스(standalone) 구분과 포털 식별자. 운영 상태(operating)는 이전 중 두 서버가 함께 처리하지 않게 합니다.
const ariweb = normalizeAriweb(body.ariweb === undefined ? current?.ariweb : body.ariweb);
const operating = body.operating === undefined ? (current?.operating || 'active') : String(body.operating || 'active');
if (!OPERATING_STATES.includes(operating)) throw invalid('operating');
const stamp = new Date().toISOString();
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, createdAt: current?.createdAt || stamp, updatedAt: stamp };
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, ariweb, operating, createdAt: current?.createdAt || stamp, updatedAt: stamp };
}
/** [Ariweb Suite 변경] 도메인 운영 상태. active 가 아니면 수신 임시 거부·발신 차단(limits.operatingState). */
export const OPERATING_STATES = ['active', 'migrating_out', 'migrating_in', 'frozen'];
export const ARIWEB_KINDS = ['site_bound', 'standalone'];
/** Ariweb 연동 정보 정규화: 없으면 null. kind 는 site_bound|standalone, 나머지는 포털 식별자 문자열(64자). */
export function normalizeAriweb(v) {
if (!v || typeof v !== 'object') return null;
const kind = String(v.kind || ''); if (!ARIWEB_KINDS.includes(kind)) throw invalid('ariweb.kind');
const id = (x) => { const s = String(x ?? '').trim().slice(0, 64); if (s && !/^[A-Za-z0-9_.:-]+$/.test(s)) throw invalid('ariweb'); return s; };
return { kind, siteId: id(v.siteId), groupId: id(v.groupId), mailServiceId: id(v.mailServiceId) };
}
/**
@@ -161,7 +175,8 @@ function primaryEntry(baseEnv, stored = null) {
// 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local.
const mode = String(baseEnv.LOGIN_MODE || '') === 'webmaker' && baseEnv.WEBMAKER_DIR ? 'webmaker' : baseEnv.WWW_DB_FILE || baseEnv.WWW_DB ? 'www' : 'local';
const auth = { mode, wwwDbFile: mode === 'www' ? String(baseEnv.WWW_DB_FILE || '') : '', ownerLogin: mode === 'www' ? String(baseEnv.MAILBOX_OWNER_LOGIN || '') : '', webmakerDir: mode === 'webmaker' ? String(baseEnv.WEBMAKER_DIR) : '', webmakerUser: mode === 'webmaker' ? String(baseEnv.WEBMAKER_USER || '') : '', siteId: mode === 'webmaker' ? String(baseEnv.WEBMAKER_SITE || '') : '' };
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
// [Ariweb Suite 변경] 기본 도메인도 ariweb·operating 은 등록부 값을 그대로 둡니다(환경 파일에는 없는 정보).
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', ariweb: stored?.ariweb || null, operating: OPERATING_STATES.includes(stored?.operating) ? stored.operating : 'active', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
}
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */
@@ -270,7 +285,7 @@ export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = con
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다.
let external = null;
if (e.auth.mode === 'webmaker') { try { const r = await externalUsers(env); external = r.ok ? { count: r.accounts.length, site: r.site } : { count: 0, error: r.error }; } catch (err) { external = { count: 0, error: err.message }; } }
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', ariweb: e.ariweb || null, operating: e.operating || 'active', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
}
return out;
},
@@ -305,7 +320,8 @@ export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = con
update(domain, body) {
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
const isPrimary = t.entry.domain === registry.primary;
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
// [Ariweb Suite 변경] 기본 도메인도 ariweb(연동 정보)·operating(운영 상태)은 바꿀 수 있습니다.
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note, ariweb: body.ariweb, operating: body.operating }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
// 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림).
if (isPrimary) { const ph = normalizeHost(baseEnv.PUBLIC_HOST || ''), first = ph + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); next.hosts = [first, ...next.hosts.filter((h) => hostNameOf(h) !== ph)]; }
for (const other of tenants.values()) if (other !== t) for (const h of next.hosts) if (other.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
+52 -14
View File
@@ -23,6 +23,13 @@
*/
import { now } from './util.mjs';
import { isWebmaker, webmakerByMailbox } from './webmaker.mjs';
// [Ariweb Suite 변경] 이 파일의 변경 요지(Dev-Order §0.4·§13.4·§17):
// - systemLimits: 테넌트 설정 파일(config.json)의 LIMIT_* 가 환경 파일 값보다 우선(도메인별 보장량)
// - countMail: 발신은 최종 수신자 한 명당 한 통(to+cc+bcc, 기록이 없으면 1통)으로 셉니다. sentMessages 에 통 수(메일 수)도 함께 돌려줍니다.
// - mailboxUsage/totalUsage: 원문(.eml, 일반·인라인 첨부 포함) + 대용량 첨부(large_files)를 저장 공간으로 셉니다.
// - checkInbound/checkOutbound: 월간 통수 100% 와 도메인 운영 상태(이전 중·동결)를 추가로 검사합니다. 100% 에서 기존 읽기·삭제는 그대로입니다.
// - statsNow/periodBounds: 기준 시각을 env.STATS_NOW 로 고정할 수 있어 시험이 월 경계를 결정적으로 검증합니다.
// - limitState: 70/80/90/95/100 경고 구간과 제한 사유(사용량 API·관리자 화면용).
// ------------------------------------------------------------------ 상수
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */
@@ -49,11 +56,17 @@ const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n
/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */
export function systemLimits(env) {
// [Ariweb Suite 변경] 도메인별 보장량: 설정 파일 값이 비어 있지 않으면 그 값을, 아니면 환경 파일 값을 씁니다.
const cfg = env.CONFIG || {}; const pick = (k) => (cfg[k] !== undefined && cfg[k] !== null && String(cfg[k]) !== '' ? cfg[k] : env[k]);
return {
accounts: nonNegInt(env.LIMIT_ACCOUNTS), totalBytes: parseSize(env.LIMIT_TOTAL_BYTES), mailboxBytes: parseSize(env.LIMIT_MAILBOX_BYTES),
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(env.LIMIT_MONTHLY_MESSAGES),
accounts: nonNegInt(pick('LIMIT_ACCOUNTS')), totalBytes: parseSize(pick('LIMIT_TOTAL_BYTES')), mailboxBytes: parseSize(pick('LIMIT_MAILBOX_BYTES')),
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(pick('LIMIT_MONTHLY_MESSAGES')),
};
}
/** [Ariweb Suite 변경] 통계·제한 판단의 기준 시각. env.STATS_NOW(ISO)가 있으면 그 시각(시험·재현용), 없으면 지금. */
export function statsNow(env) { const v = env?.STATS_NOW; if (v) { const d = new Date(v); if (!Number.isNaN(d.getTime())) return d; } return new Date(); }
/** [Ariweb Suite 변경] 도메인 운영 상태(등록부 operating). active 가 아니면 수신은 임시 거부, 발신은 차단합니다(이전 중 두 서버가 동시에 처리하지 않게, §0.4). */
export function operatingState(env) { const s = String(env?.TENANT?.operating || 'active'); return ['active', 'migrating_out', 'migrating_in', 'frozen'].includes(s) ? s : 'active'; }
// ------------------------------------------------------------------ settings 표 읽기·쓰기
async function readJson(env, key, defaults) {
@@ -130,12 +143,15 @@ export async function recipientLimit(env) { const admin = await readAdminLimits(
*/
export async function mailboxUsage(env, mailbox) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.mailbox=? AND d.raw_status='none'),0) AS bytes FROM messages WHERE mailbox=?`).bind(mailbox, mailbox).first();
return Number(row?.bytes || 0);
// [Ariweb Suite 변경] 대용량 첨부(별도 디스크 보관, state draft/sent)도 그 메일함의 저장 공간입니다(§0.4 "원문·일반 첨부·Inline 첨부·대용량 첨부 포함").
const large = await env.DB.prepare("SELECT COALESCE(SUM(size),0) AS b FROM large_files WHERE mailbox=? AND state IN ('draft','sent')").bind(mailbox).first().catch(() => null);
return Number(row?.bytes || 0) + Number(large?.b || 0);
}
/** 모든 메일함을 합친 사용량(바이트). */
/** 모든 메일함을 합친 사용량(바이트). 대용량 첨부 포함([Ariweb Suite 변경]). */
export async function totalUsage(env) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.raw_status='none'),0) AS bytes FROM messages`).first();
return Number(row?.bytes || 0);
const large = await env.DB.prepare("SELECT COALESCE(SUM(size),0) AS b FROM large_files WHERE state IN ('draft','sent')").first().catch(() => null);
return Number(row?.bytes || 0) + Number(large?.b || 0);
}
/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */
export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); }
@@ -175,13 +191,28 @@ export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) {
*/
export async function countMail(env, { start, end }, mailbox = null) {
const scope = mailbox ? ' AND mailbox=?' : '', extra = mailbox ? [mailbox] : [];
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
// [Ariweb Suite 변경] 발신 통수 = 최종 수신자 수(받는 사람+참조+숨은 참조). 수신자 기록이 없는 옛 행(또는 시험 데이터)은 1통으로 셉니다. sentMessages 는 메일 수.
const rcpt = (col) => `COALESCE(json_array_length(CASE WHEN json_valid(${col}) THEN ${col} ELSE '[]' END),0)`;
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(MAX(1, ${rcpt('recipients_json')} + ${rcpt('cc_json')} + ${rcpt('bcc_json')})),0) AS rcpts FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
const received = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?${scope}`).bind(start, end, ...extra).first();
const s = Number(sent?.n || 0), r = Number(received?.n || 0);
return { sent: s, received: r, total: s + r, start, end };
const s = Number(sent?.rcpts || 0), r = Number(received?.n || 0);
return { sent: s, sentMessages: Number(sent?.n || 0), received: r, total: s + r, start, end };
}
/** 이달의 수발신 통수(월간 제한과 비교). at 을 주면 그 시각이 속한 달(기본은 statsNow). */
export async function monthlyCount(env, at = statsNow(env)) { return (await countMail(env, periodBounds('month', statsTimezone(env), at))).total; }
/**
* [Ariweb Suite 변경] 경고 구간과 제한 상태(§13.4·§17). 저장 공간·월간 통수·계정 수 각각의 사용 비율로 넘은 가장 높은 구간(0/70/80/90/95/100)과,
* 지금 적용 중인 제한 사유 목록(storage_full / monthly_limit / accounts_full / domain_not_active)을 돌려줍니다. 다음 KST 월 초기화 시각(monthReset)도 함께.
*/
export async function limitState(env, { levels = [70, 80, 90, 95] } = {}) {
const sys = systemLimits(env), tz = statsTimezone(env), at = statsNow(env);
const total = await totalUsage(env), monthly = await monthlyCount(env, at), accounts = await accountCount(env);
const level = (used, limit) => { if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100; let l = 0; for (const x of levels) if (pct >= x) l = x; return l; };
const storage = level(total, sys.totalBytes), msgs = level(monthly, sys.monthlyMessages), acc = level(accounts, sys.accounts);
const reasons = []; if (storage === 100) reasons.push('storage_full'); if (msgs === 100) reasons.push('monthly_limit'); if (acc === 100) reasons.push('accounts_full');
const op = operatingState(env); if (op !== 'active') reasons.push('domain_not_active');
return { levels: { storage, monthly: msgs, accounts: acc }, warnLevel: Math.max(storage, msgs, acc), limitedReasons: reasons, operating: op, monthReset: periodBounds('month', tz, at).end, measuredAt: now(), used: { storageBytes: total, monthlyMessages: monthly, accounts } };
}
/** 이달의 수발신 통수(월간 제한과 비교). */
export async function monthlyCount(env) { return (await countMail(env, periodBounds('month', statsTimezone(env)))).total; }
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
/**
@@ -202,8 +233,11 @@ export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSi
if (maxRcpt > 0 && recipients > maxRcpt) { const e = new Error('too_many_recipients'); e.limit = maxRcpt; throw e; }
const maxAttach = await attachmentLimit(env);
for (const size of attachmentSizes) if (mimeSize(size) > maxAttach) { const e = new Error('attachment_too_large'); e.limit = maxAttach; throw e; }
// [Ariweb Suite 변경] 도메인이 이전 중·동결이면 새 발신을 막습니다(두 서버가 같은 도메인을 동시에 처리하지 않게).
if (operatingState(env) !== 'active') { const e = new Error('mail_domain_frozen'); e.code = 'mail_domain_frozen'; e.operating = operatingState(env); throw e; }
const sys = systemLimits(env);
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.limit = sys.monthlyMessages; throw e; }
// 월간 통수 100%: 새 발신 차단. 오류 코드는 예전 이름(monthly_limit_exceeded)을 유지하고 code 에 Ariweb 공통 코드(mail_monthly_limit)를 붙입니다.
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.code = 'mail_monthly_limit'; e.limit = sys.monthlyMessages; e.resetAt = periodBounds('month', statsTimezone(env), statsNow(env)).end; throw e; }
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) + bytes > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
if (sys.totalBytes > 0 && (await totalUsage(env)) + bytes > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
@@ -213,14 +247,18 @@ export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSi
* @returns {Promise<null|'quota_exceeded'|'storage_full'>} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다.
*/
export async function checkInbound(env, mailbox) {
// [Ariweb Suite 변경] 운영 상태가 active 가 아니면 임시 거부(보낸 서버가 재시도). 월간 통수 100% 도 새 수신을 임시 거부합니다(§13.4).
if (operatingState(env) !== 'active') return 'domain_not_active';
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) >= quota) return 'quota_exceeded';
const sys = systemLimits(env);
if (sys.totalBytes > 0 && (await totalUsage(env)) >= sys.totalBytes) return 'storage_full';
// 저장 공간 사유가 우선이고(정리하면 바로 풀림), 그다음 월간 통수(다음 KST 월에 풀림).
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) return 'monthly_limit';
return null;
}
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; }
export async function monthlyBlocked(env) { const sys = systemLimits(env); return operatingState(env) !== 'active' || (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages); }
// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정
/**
@@ -277,7 +315,7 @@ export async function writeSignup(env, patch) {
export async function mailStats(env, mailboxes, users = []) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const periods = {};
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz));
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz, statsNow(env)));
const boxes = [];
for (const mailbox of mailboxes) {
const used = await mailboxUsage(env, mailbox), quota = await mailboxQuota(env, mailbox), owner = users.find((u) => u.mailbox === mailbox);
@@ -286,7 +324,7 @@ export async function mailStats(env, mailboxes, users = []) {
}
const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total;
return {
timezone: tz, periods, mailboxes: boxes, system: sys, admin,
timezone: tz, periods, mailboxes: boxes, system: sys, admin, limitState: await limitState(env),
usage: {
total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) },
accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) },
+3
View File
@@ -30,6 +30,7 @@ import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDow
import { escapeHtml } from './util.mjs';
import { memoryRateStore, siteName } from './util.mjs';
import { systemLimits } from './limits.mjs';
import { recoverQueue } from './sendqueue.mjs';
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
@@ -131,6 +132,8 @@ const prefixOf = (req, resolved) => {
// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음).
const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant });
// [Ariweb Suite 변경] 지난 실행에서 발송 중(sending)으로 남은 대기열 행은 interrupted 로 표시해 관리자가 확인·재발송할 수 있게 합니다(메일을 잃지 않음).
for (const t of tenants.envs()) recoverQueue(t).then((n) => { if (n) console.warn(` [${t.DOMAIN}] ⚠️ 끊긴 발송 ${n}건 — 관리자 페이지 › 발송 대기열에서 확인하세요`); }).catch(() => {});
console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`);
console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다.
+11 -3
View File
@@ -16,7 +16,8 @@
import { resend, providerStatus as resendStatus } from './resend.mjs';
import { sendViaSmtp } from './smtp.mjs';
import { smtpProbe } from './smtp-client.mjs';
import { now } from '../util.mjs';
import { now, address } from '../util.mjs';
import { enqueue, finish, pace } from '../sendqueue.mjs';
export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka'];
/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */
@@ -43,8 +44,15 @@ export function relaySettings(env, mode = outboundMode(env)) {
*/
export async function sendMail(env, payload, key) {
const mode = outboundMode(env);
if (mode === 'resend') return resend(env, '/emails', 'POST', payload, key);
return sendViaSmtp(env, payload, relaySettings(env, mode));
// [Ariweb Suite 변경] 발송 대기열 기록 → 안전 발송 속도(도메인·서버 초당 5건) → 발송 모듈. 같은 멱등 키가 이미 보내졌으면 다시 보내지 않습니다.
const domain = String(env.MAILBOX || '').split('@')[1] || 'localhost';
const q = await enqueue(env, { domain, mailbox: address(payload.from), key, payload });
if (q.alreadySent) return { id: q.providerId, duplicate: true };
try {
await pace(domain);
const r = mode === 'resend' ? await resend(env, '/emails', 'POST', payload, key) : await sendViaSmtp(env, payload, relaySettings(env, mode));
await finish(env, q.id, { providerId: r.id }); return r;
} catch (e) { await finish(env, q.id, { error: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') }); throw e; }
}
// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다.
+120
View File
@@ -0,0 +1,120 @@
/**
* [Ariweb Suite 변경] 발송 속도 조절·제출 제한·발송 대기열(Dev-Order §17.2).
*
* 세 가지를 맡습니다.
* 1) 안전 발송 속도(pace): 실제 외부 전달은 도메인마다, 그리고 서버 전체로 초당 5건을 넘지 않습니다(토큰 버킷 두 개를 모두 통과해야 발송).
* 버킷이 비어 있으면 그 자리에서 기다립니다(최대 WAIT_MAX_MS, 넘으면 mail_rate_limited 로 실패시켜 메일 프로그램이 나중에 다시 보내게 함).
* 2) 제출 제한(submission): 계정·접속 IP 가 초당 10건 이상 제출하면 자동 제한(mail_submission_throttled)·경고 기록·악용 확인 표(abuse_flags)에 남깁니다.
* 제한 창(THROTTLE_WINDOW_MS)이 지나면 자동으로 풀리고, 관리자 화면(/api/admin/rates)이 현재 창의 제출량과 버킷 상태를 보여 줍니다.
* 3) 발송 대기열(outbound_queue): 발송 모듈에 넘기기 직전 행을 넣고(state sending) 끝나면 sent/failed 로 바꿉니다. 재시작 때 sending 으로 남은 행은
* interrupted 로 표시해 관리자가 다시 보낼 수 있게 합니다(메일을 잃지 않음). 같은 send_key 가 이미 sent 면 다시 보내지 않습니다(멱등).
*
* 시계는 clock() 으로 바꿀 수 있어 시험이 실제 시간을 기다리지 않습니다.
*/
import { now, uid } from './util.mjs';
export const DELIVERY_RATE = 5; // 초당 외부 전달 상한(도메인·서버 각각)
export const SUBMISSION_RATE = 10; // 초당 제출 상한(계정·IP 각각). 이 이상이면 제한·경고·악용 확인
export const THROTTLE_WINDOW_MS = 60 * 1000; // 제한이 유지되는 시간
export const WAIT_MAX_MS = 15 * 1000; // 버킷을 기다리는 최대 시간
let clock = () => Date.now();
/** 시험용: 시계를 바꿉니다(ms 를 돌려주는 함수). null 이면 실제 시계. */
export function setClock(fn) { clock = fn || (() => Date.now()); }
/** 시험용: 대기 함수(기본 setTimeout). 가짜 시계와 함께 바꿉니다. */
let sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export function setSleep(fn) { sleep = fn || ((ms) => new Promise((r) => setTimeout(r, ms))); }
// 토큰 버킷: { tokens, updatedAt }. 초당 rate 만큼 채워지고 최대 rate 개까지만 쌓입니다(순간 폭주 허용 폭 = 1초 분량).
const buckets = new Map();
function bucket(key, rate) { let b = buckets.get(key); if (!b) { b = { tokens: rate, updatedAt: clock(), rate }; buckets.set(key, b); } return b; }
function refill(b) { const t = clock(); const dt = Math.max(0, t - b.updatedAt) / 1000; b.tokens = Math.min(b.rate, b.tokens + dt * b.rate); b.updatedAt = t; }
/** 버킷에서 토큰 하나를 빼려고 시도. 성공하면 0, 아니면 다음 토큰까지 기다릴 ms. */
function take(key, rate) { const b = bucket(key, rate); refill(b); if (b.tokens >= 1) { b.tokens -= 1; return 0; } return Math.ceil(((1 - b.tokens) / b.rate) * 1000); }
/**
* 외부 전달 전 속도 조절. 도메인 버킷과 서버 버킷을 모두 통과할 때까지 기다립니다.
* @param {string} domain 발신 도메인
* @throws {Error} mail_rate_limited — WAIT_MAX_MS 안에 차례가 오지 않음
*/
export async function pace(domain) {
const started = clock();
for (;;) {
const w1 = take('domain:' + domain, DELIVERY_RATE);
if (w1 === 0) {
const w2 = take('server', DELIVERY_RATE);
if (w2 === 0) return;
// 서버 버킷이 비었으면 도메인 토큰을 돌려주고(공정성) 기다립니다.
bucket('domain:' + domain, DELIVERY_RATE).tokens += 1;
if (clock() - started + w2 > WAIT_MAX_MS) { const e = new Error('mail_rate_limited'); e.providerMessage = 'delivery rate limit (server)'; throw e; }
await sleep(w2); continue;
}
if (clock() - started + w1 > WAIT_MAX_MS) { const e = new Error('mail_rate_limited'); e.providerMessage = 'delivery rate limit (domain)'; throw e; }
await sleep(w1);
}
}
// 제출 창: key → { count, windowStart, throttledUntil }. 1초 창에서 SUBMISSION_RATE 이상이면 제한.
const submissions = new Map();
/**
* 제출 1건을 세고 제한 여부를 돌려줍니다. 제한되면 abuse_flags 에 한 번 기록(같은 키·같은 제한 창에서는 한 번만).
* @param {object} env DB 가 있으면 악용 표에 기록
* @param {string[]} keys 예: ['account:kim@x.com', 'ip:1.2.3.4']
* @returns {Promise<{throttled:boolean, key?:string, until?:string, rate?:number}>}
*/
export async function submit(env, keys) {
const t = clock();
for (const key of keys) {
let s = submissions.get(key); if (!s) { s = { count: 0, windowStart: t, throttledUntil: 0, flagged: false }; submissions.set(key, s); }
if (s.throttledUntil > t) return { throttled: true, key, until: new Date(s.throttledUntil).toISOString(), rate: s.lastRate || 0 };
if (t - s.windowStart >= 1000) { s.count = 0; s.windowStart = t; s.flagged = false; }
s.count += 1;
if (s.count >= SUBMISSION_RATE) {
s.throttledUntil = t + THROTTLE_WINDOW_MS; s.lastRate = s.count;
if (!s.flagged && env?.DB) { s.flagged = true; try { await env.DB.prepare('INSERT INTO abuse_flags(id,kind,subject,detail,rate,created_at) VALUES(?,?,?,?,?,?)').bind(uid(), 'submission_rate', key, `초당 ${s.count}건 제출 — ${THROTTLE_WINDOW_MS / 1000}초 제한`, s.count, now()).run(); } catch { /* 표가 없는 옛 DB */ } }
console.warn(`발송 제출 제한: ${key} 초당 ${s.count}건 (${THROTTLE_WINDOW_MS / 1000}초 동안 거부)`);
return { throttled: true, key, until: new Date(s.throttledUntil).toISOString(), rate: s.count };
}
}
return { throttled: false };
}
/** 관리자 화면용 현재 상태: 버킷(남은 토큰)·제출 창(최근 1초 제출량·제한 해제 시각). 비밀값 없음. */
export function rateSnapshot() {
const t = clock();
const b = [...buckets.entries()].map(([key, x]) => { refill(x); return { key, tokens: Math.round(x.tokens * 100) / 100, rate: x.rate }; });
const s = [...submissions.entries()].map(([key, x]) => ({ key, countInWindow: t - x.windowStart >= 1000 ? 0 : x.count, throttledUntil: x.throttledUntil > t ? new Date(x.throttledUntil).toISOString() : null, lastRate: x.lastRate || 0 }));
return { deliveryRate: DELIVERY_RATE, submissionRate: SUBMISSION_RATE, throttleWindowMs: THROTTLE_WINDOW_MS, buckets: b, submissions: s, at: new Date(t).toISOString() };
}
/** 시험·운영자용: 상태 초기화 */
export function resetRates() { buckets.clear(); submissions.clear(); }
// ------------------------------------------------------------------ 발송 대기열
/** 발송 직전 기록. 같은 send_key 가 이미 sent 면 null(= 이미 보냄). */
export async function enqueue(env, { domain, mailbox = '', key = null, payload }) {
if (!env?.DB) return { id: null };
try {
if (key) { const done = await env.DB.prepare("SELECT id, provider_id FROM outbound_queue WHERE send_key=? AND state='sent'").bind(key).first(); if (done) return { id: done.id, alreadySent: true, providerId: done.provider_id }; }
const id = uid(); const stamp = now();
// 메일 본문·첨부는 넣지 않습니다(저장소에 이미 있음): 받는 사람 수·제목 길이·첨부 수만 두어 재시도 때 저장소의 원본으로 다시 만듭니다.
const slim = { from: payload.from, to: payload.to, cc: payload.cc, bcc: payload.bcc, subject: String(payload.subject || '').slice(0, 200), attachments: (payload.attachments || []).length, messageId: payload.headers?.['Message-ID'] || null, ref: payload.__ref || null };
await env.DB.prepare('INSERT INTO outbound_queue(id,domain,mailbox,send_key,payload_json,state,attempts,created_at,updated_at) VALUES(?,?,?,?,?,\'sending\',1,?,?)').bind(id, domain, mailbox, key, JSON.stringify(slim), stamp, stamp).run();
return { id };
} catch { return { id: null }; } // 0009 마이그레이션 전의 DB 에서도 발송은 계속됩니다
}
export async function finish(env, id, { providerId = null, error = null } = {}) {
if (!id || !env?.DB) return;
try { await env.DB.prepare('UPDATE outbound_queue SET state=?, provider_id=?, error=?, sent_at=?, updated_at=? WHERE id=?').bind(error ? 'failed' : 'sent', providerId, error ? String(error).slice(0, 300) : null, error ? null : now(), now(), id).run(); } catch { /* 무시 */ }
}
/** 시작 때: 지난 실행에서 'sending' 으로 남은 행을 interrupted 로 표시합니다. 돌아온 값은 그 수. */
export async function recoverQueue(env) {
if (!env?.DB) return 0;
try { const r = await env.DB.prepare("UPDATE outbound_queue SET state='interrupted', updated_at=? WHERE state='sending'").bind(now()).run(); return Number(r.meta?.changes || 0); } catch { return 0; }
}
/** 관리자 화면: 최근 대기열 행(실패·끊김 먼저). */
export async function queueList(env, { limit = 50 } = {}) {
try { return (await env.DB.prepare("SELECT id, domain, mailbox, state, attempts, provider_id, error, created_at, updated_at, sent_at, payload_json FROM outbound_queue ORDER BY CASE state WHEN 'interrupted' THEN 0 WHEN 'failed' THEN 1 WHEN 'sending' THEN 2 ELSE 3 END, created_at DESC LIMIT ?").bind(limit).all()).results.map((r) => ({ ...r, payload: JSON.parse(r.payload_json || '{}'), payload_json: undefined })); } catch { return []; }
}
export async function abuseList(env, { open = true, limit = 100 } = {}) {
try { return (await env.DB.prepare(`SELECT * FROM abuse_flags ${open ? 'WHERE reviewed_at IS NULL' : ''} ORDER BY created_at DESC LIMIT ?`).bind(limit).all()).results; } catch { return []; }
}
export async function reviewAbuse(env, id, by) { try { await env.DB.prepare('UPDATE abuse_flags SET reviewed_at=?, reviewed_by=? WHERE id=?').bind(now(), by, id).run(); } catch { /* 무시 */ } }
+9 -3
View File
@@ -23,7 +23,8 @@ import net from 'node:net';
import tls from 'node:tls';
import { createHash } from 'node:crypto';
import { verifyUser, audit } from './auth.mjs';
import { sendMail } from './providers/index.mjs';
import { sendMail, outboundMode } from './providers/index.mjs';
import { submit } from './sendqueue.mjs';
import { normalizeCrlf } from './mime.mjs';
import { parseMime, summarize, header, parseAddresses, decodeWords } from './imap-mime.mjs';
import { appendMessage, setFlag } from './store.mjs';
@@ -87,7 +88,8 @@ function limitReply(e) {
case 'attachment_too_large': return new SmtpError(552, '5.3.4', `Attachment exceeds the size limit (${e.limit} bytes after MIME encoding)`);
case 'quota_exceeded': return new SmtpError(452, '4.2.2', 'Mailbox full (quota exceeded), free space and try again');
case 'storage_full': return new SmtpError(452, '4.3.1', 'Insufficient system storage');
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})`);
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})${e.resetAt ? ', resets at ' + e.resetAt : ''}`);
case 'mail_domain_frozen': return new SmtpError(451, '4.3.2', 'Mail domain is being migrated, try again later'); // [Ariweb Suite 변경] 이전 중·동결
default: return null;
}
}
@@ -106,7 +108,11 @@ function limitReply(e) {
* @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류
*/
export async function submitMessage(env, user, { mailFrom = '', rcpt = [], eml, ip = '' }) {
if (!env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
// [Ariweb Suite 변경] 오류 수정: Resend 키는 발신 방식이 resend 일 때만 필요합니다. 예전에는 Haraka·외부 SMTP 중계를 쓰는 도메인도 키가 없으면 451 로 모든 제출이 거부됐습니다.
if (outboundMode(env) === 'resend' && !env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
// [Ariweb Suite 변경] 제출 속도 제한(§17.2): 계정·IP 가 초당 10건 이상이면 임시 거부하고 악용 확인 표에 남깁니다.
const th = await submit(env, ['account:' + user.mailbox, ...(ip ? ['ip:' + ip] : [])]);
if (th.throttled) throw new SmtpError(451, '4.7.0', `Submission rate limit reached, try again after ${th.until}`);
const raw = normalizeCrlf(eml);
let parsed, info;
try { parsed = parseMime(raw); info = summarize(parsed); } catch { throw new SmtpError(550, '5.6.0', 'Malformed message'); }
+7 -4
View File
@@ -24,7 +24,7 @@
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
*/
import { json, bad, safeEqual, now } from './util.mjs';
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf } from './limits.mjs';
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf, limitState, statsNow } from './limits.mjs';
import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs';
import { largeFileStats } from './files.mjs';
import { inboundMode, outboundMode } from './providers/index.mjs';
@@ -128,7 +128,7 @@ const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { t
*/
async function dailySeries(env, days, tz) {
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
const today = periodBounds('day', tz); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
const today = periodBounds('day', tz, statsNow(env)); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end;
const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results;
const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); }
@@ -156,15 +156,18 @@ export async function domainUsage(env, { days = 30, accounts = true, series = tr
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first();
const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env);
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz));
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz, statsNow(env)));
const limits = await limitState(env); // [Ariweb Suite 변경] 경고 구간·제한 사유
const out = {
domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean),
authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(),
accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null },
storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) },
messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) },
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages) },
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages), countingRule: 'per_final_recipient', monthReset: limits.monthReset },
limits: { system: sys, admin },
// [Ariweb Suite 변경] 포털 표시용: 사이트 대표/독립 구분, 운영 상태, 경고 구간(0/70/80/90/95/100), 제한 사유.
ariweb: entry?.ariweb || env.TENANT?.ariweb || null, operating: limits.operating, warnLevel: limits.warnLevel, warnLevels: limits.levels, limitedReasons: limits.limitedReasons, measuredAt: limits.measuredAt,
};
if (series || accounts) {
const d = await dailySeries(env, days, tz);
+169
View File
@@ -0,0 +1,169 @@
/**
* [Ariweb Suite 변경] Ariweb Suite 0.1.1 도입 때 고친·더한 동작의 회귀 테스트(Dev-Order §0.4).
* 1) KST 월 경계(고정 시각) 2) Haraka/SMTP 중계 발신 때 Resend 키를 요구하지 않음 3) 등록부 ariweb·operating 4) 도메인별 보장량·수신자 기준 통수·대용량 첨부 포함 저장 공간
* 5) 100% 동작(저장 공간·월간 통수)과 다음 KST 월 초기화 6) 발송 속도·제출 제한·대기열 7) 운영 상태(동결) 8) 포털 SSO 티켓
* 실행: node test/ariweb.test.mjs
*/
import net from 'node:net';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHmac, randomBytes } from 'node:crypto';
import { makeEnv, runner, eq, ok, request, params, fakeResend } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, setupToken } from '../server/config.mjs';
import { openTenants, normalizeEntry } from '../server/domains.mjs';
import { setupApi } from '../server/setup.mjs';
import { usageApi } from '../server/usage.mjs';
import { periodBounds, monthlyCount, countMail, systemLimits, checkInbound, checkOutbound, totalUsage, limitState, mailboxUsage } from '../server/limits.mjs';
import { submitMessage } from '../server/smtp.mjs';
import { buildEml } from '../server/mime.mjs';
import { ingestRaw, saveDraft } from '../server/store.mjs';
import * as sq from '../server/sendqueue.mjs';
const { t, done } = runner();
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-ariweb-'));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), { ...params(path), prefix: '' });
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const setup = (env, path, method = 'GET', body, tenants = null) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1', tenants });
async function base(over = {}) { const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over }); loadConfig(env); return { env, dir }; }
/** 발신 메일 행(수신자 n명) */
async function sentRow(env, { at, recipients = ['a@x.test'], cc = [], bcc = [], mailbox = 'admin@arihakdang.com', size = 1000 }) {
const id = randomBytes(8).toString('hex');
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,'outbound','sent','a',?,?,?,'s','b','web','sent',?,?,?,?,'stored',?,?)").bind(id, JSON.stringify(recipients), JSON.stringify(cc), JSON.stringify(bcc), at, at, at, at, size, mailbox).run();
return id;
}
async function inboundRow(env, { at, mailbox = 'admin@arihakdang.com', size = 1000 }) {
const id = randomBytes(8).toString('hex');
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,raw_status,raw_size,mailbox) VALUES(?,'inbound','inbox','x@y.test','[]','s','b','sync','received',?,?,?,'stored',?,?)").bind(id, at, at, at, size, mailbox).run();
return id;
}
await t('1) KST 월 경계는 고정 시각으로 결정적이다: 10-31 23:59:59 KST 는 10월, 11-01 00:00:00 KST 는 11월; monthlyCount 는 STATS_NOW 를 따른다', async () => {
const b1 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T14:59:59Z')); eq(b1.start, '2026-09-30T15:00:00.000Z', '10월 시작'); eq(b1.end, '2026-10-31T15:00:00.000Z', '10월 끝');
const b2 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T15:00:00Z')); eq(b2.start, '2026-10-31T15:00:00.000Z', '11월 시작'); eq(b2.end, '2026-11-30T15:00:00.000Z', '11월 끝');
const { env } = await base();
await sentRow(env, { at: '2026-10-31T14:30:00Z' }); await sentRow(env, { at: '2026-10-31T15:30:00Z' }); await inboundRow(env, { at: '2026-10-31T15:10:00Z' });
env.STATS_NOW = '2026-10-31T14:59:59Z'; eq(await monthlyCount(env), 1, '10월: 발신 1');
env.STATS_NOW = '2026-10-31T15:00:00Z'; eq(await monthlyCount(env), 2, '11월: 발신 1 + 수신 1');
eq((await limitState(env)).monthReset, '2026-11-30T15:00:00.000Z', '다음 KST 월 초기화 시각');
});
await t('2) 발신 방식이 smtp/haraka 면 RESEND_API_KEY 없이도 SMTP 제출이 발송된다(예전에는 451)', async () => {
// 가짜 중계 서버: EHLO/MAIL/RCPT/DATA/QUIT 에 성공 응답
const got = []; const relay = net.createServer((s) => { s.write('220 relay.test ESMTP\r\n'); let data = false, buf = ''; s.on('data', (c) => { buf += c.toString('latin1'); if (data) { if (buf.includes('\r\n.\r\n')) { data = false; buf = ''; s.write('250 2.0.0 queued as relay-1\r\n'); } return; } let i; while ((i = buf.indexOf('\r\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 2); got.push(line); if (/^EHLO/i.test(line)) s.write('250-relay.test\r\n250 8BITMIME\r\n'); else if (/^(MAIL|RCPT)/i.test(line)) s.write('250 OK\r\n'); else if (/^DATA/i.test(line)) { data = true; s.write('354 go\r\n'); } else if (/^QUIT/i.test(line)) { s.write('221 bye\r\n'); s.end(); } else s.write('250 OK\r\n'); } }); });
await new Promise((r) => relay.listen(0, '127.0.0.1', r)); const port = relay.address().port;
try {
const env = await makeEnv({ RESEND_API_KEY: '', MAIL_OUTBOUND: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: String(port), SMTP_RELAY_SECURITY: 'none' });
const user = { id: 'www:admin-1', login: 'arihakdang', name: '관리자', role: 'admin', mailbox: 'admin@arihakdang.com', source: 'www' };
const eml = buildEml({ from: 'admin@arihakdang.com', to: ['friend@example.test'], subject: 'relay', text: 'hi', messageId: '<r1@arihakdang.com>' });
const r = await submitMessage(env, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml });
ok(r.id && r.providerId, '발송·저장: ' + JSON.stringify(r)); ok(got.some((l) => /^RCPT TO:<friend@example.test>/i.test(l)), '중계 서버가 받음');
// resend 방식이면 여전히 키가 필요(기존 동작 유지)
let threw = null; try { await submitMessage({ ...env, MAIL_OUTBOUND: 'resend' }, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml }); } catch (e) { threw = e; } eq(threw?.code, 451, 'resend 방식은 키 없으면 451');
} finally { relay.close(); }
});
await t('3) 등록부 ariweb(사이트 대표/독립)·operating 을 설정 API 로 정하고 목록·사용량 API 에 보인다', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
const e = normalizeEntry({ domain: 'x.test', ariweb: { kind: 'standalone', mailServiceId: 'ms_1', groupId: 'g_1' }, operating: 'migrating_in' }); eq(e.ariweb.kind, 'standalone', '정규화'); eq(e.operating, 'migrating_in', '운영 상태');
let threw = null; try { normalizeEntry({ domain: 'x.test', ariweb: { kind: 'nope' } }); } catch (x) { threw = x; } eq(threw?.field, 'ariweb.kind', '잘못된 종류 거부');
threw = null; try { normalizeEntry({ domain: 'x.test', operating: 'gone' }); } catch (x) { threw = x; } eq(threw?.field, 'operating', '잘못된 운영 상태 거부');
await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
let r = await (await setup(env, 'domains/second.test', 'PATCH', { ariweb: { kind: 'site_bound', siteId: 's_abc', groupId: 'g_1', mailServiceId: 'ms_2' } }, tenants)).json(); eq(r.domain.ariweb.siteId, 's_abc', 'PATCH 반영');
r = await (await setup(env, 'domains/arihakdang.com', 'PATCH', { ariweb: { kind: 'standalone', mailServiceId: 'ms_0' }, operating: 'frozen' }, tenants)).json(); eq(r.domain.ariweb.kind, 'standalone', '기본 도메인도 가능'); eq(r.domain.operating, 'frozen', '기본 도메인 운영 상태');
const list = (await (await setup(env, 'domains', 'GET', undefined, tenants)).json()).domains; eq(list.find((d) => d.domain === 'second.test').ariweb.kind, 'site_bound', '목록'); eq(list[0].operating, 'frozen', '목록 운영 상태');
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
const u = await (await usageApi(tenants, new Request('https://x/api/usage/second.test', { headers: { Authorization: 'Bearer ' + key } }), { path: ['second.test'], ip: '1.1.1.1', query: new URLSearchParams() })).json();
eq(u.ariweb.kind, 'site_bound', '사용량 API 에 ariweb'); eq(u.operating, 'active', '사용량 API 에 운영 상태'); eq(u.warnLevel, 0, '경고 구간'); eq(u.limitedReasons.length, 0, '제한 없음'); eq(u.traffic.countingRule, 'per_final_recipient', '통수 규칙');
tenants.close();
});
await t('4) 도메인별 보장량은 설정 파일이 환경보다 우선, 발신 통수는 최종 수신자 기준, 저장 공간은 대용량 첨부 포함', async () => {
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '100', LIMIT_TOTAL_BYTES: '1000000', LIMIT_ACCOUNTS: '5' });
eq(systemLimits(env).monthlyMessages, 100, '환경 값');
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: 10, LIMIT_TOTAL_BYTES: '5000', LIMIT_ACCOUNTS: 2 });
const l = systemLimits(env); eq(l.monthlyMessages, 10, '설정 파일 우선'); eq(l.totalBytes, 5000, '크기 접미사 없는 값'); eq(l.accounts, 2, '계정 수');
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: '' }); eq(systemLimits(env).monthlyMessages, 100, '비우면 환경 값으로');
env.STATS_NOW = '2026-10-15T03:00:00Z';
await sentRow(env, { at: '2026-10-10T03:00:00Z', recipients: ['a@x.test', 'b@x.test'], cc: ['c@x.test'], bcc: ['d@x.test'] }); await sentRow(env, { at: '2026-10-11T03:00:00Z', recipients: [] }); await inboundRow(env, { at: '2026-10-12T03:00:00Z' });
const c = await countMail(env, periodBounds('month', 'Asia/Seoul', new Date(env.STATS_NOW))); eq(c.sent, 5, '수신자 4 + 기록 없는 메일 1'); eq(c.sentMessages, 2, '메일 수'); eq(c.received, 1, '수신'); eq(c.total, 6, '합계');
const before = await totalUsage(env);
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,size,state,created_at) VALUES('lf1',NULL,'admin@arihakdang.com','big.bin',4000,'sent',?)").bind(new Date().toISOString()).run();
eq(await totalUsage(env), before + 4000, '대용량 첨부 포함'); eq(await mailboxUsage(env, 'admin@arihakdang.com'), before + 4000, '메일함 사용량에도 포함');
});
await t('5) 100%: 저장 공간이 차면 새 수신 임시 거부(읽기·삭제 유지), 월간 통수가 차면 발신 차단(mail_monthly_limit)·수신 임시 거부, 다음 KST 월에 풀린다', async () => {
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '2', RESEND_FETCH: fakeResend({}) });
env.STATS_NOW = '2026-10-20T03:00:00Z';
await sentRow(env, { at: '2026-10-10T03:00:00Z' }); await sentRow(env, { at: '2026-10-11T03:00:00Z' });
eq(await checkInbound(env, 'admin@arihakdang.com'), 'monthly_limit', '수신 임시 거부');
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'monthly_limit_exceeded', '발신 차단'); eq(threw?.code, 'mail_monthly_limit', '공통 코드'); eq(threw?.resetAt, '2026-10-31T15:00:00.000Z', '해제 시각');
const st = await limitState(env); eq(st.warnLevel, 100, '구간 100'); eq(st.limitedReasons.join(','), 'monthly_limit', '사유');
// Haraka 수신 전달도 건너뜀(플러그인이 DENYSOFT)
const eml = buildEml({ from: 'x@example.test', to: ['admin@arihakdang.com'], subject: 'in', text: 'hi', messageId: '<in1@example.test>' });
eq((await ingestRaw(env, eml, { id: 'tx1', rcpt: ['admin@arihakdang.com'], from: 'x@example.test' })).skipped, 'monthly_limit', 'Haraka 수신 건너뜀');
// 기존 메일 읽기·삭제는 그대로(API 목록 조회 가능)
const cookie = (await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).headers.get('set-cookie').split(';')[0];
eq((await api(env, 'mail?folder=sent', cookie)).status, 200, '읽기 유지');
const d = await saveDraft(env, { to: 'a@example.test', subject: 's', html: '<p>x</p>' }); const r = await (await api(env, 'mail/' + d + '/send', cookie, 'POST', {})).json(); eq(r.error, 'monthly_limit_exceeded', '웹 발송 차단'); eq(r.code, 'mail_monthly_limit', '응답에 공통 코드'); eq(r.resetAt, '2026-10-31T15:00:00.000Z', '응답에 해제 시각');
env.STATS_NOW = '2026-11-01T03:00:00Z'; eq(await checkInbound(env, 'admin@arihakdang.com'), null, '다음 달엔 수신 가능'); eq((await limitState(env)).warnLevel, 0, '구간 초기화');
// 저장 공간 100%
const env2 = (await base({ LIMIT_TOTAL_BYTES: '1500' })).env; await inboundRow(env2, { at: '2026-10-01T00:00:00Z', size: 1500 });
eq(await checkInbound(env2, 'admin@arihakdang.com'), 'storage_full', '저장 공간 100% 수신 거부'); eq((await limitState(env2)).limitedReasons.join(','), 'storage_full', '사유');
const env3 = (await base({ LIMIT_TOTAL_BYTES: '2000' })).env; await inboundRow(env3, { at: '2026-10-01T00:00:00Z', size: 1900 }); eq((await limitState(env3)).warnLevel, 95, '95% 경고');
});
await t('6) 발송 속도(도메인·서버 초당 5건)·제출 제한(초당 10건 → 60초 제한·악용 표)·대기열 기록·재시작 복구', async () => {
let nowMs = 1_000_000; sq.setClock(() => nowMs); const slept = []; sq.setSleep(async (ms) => { slept.push(ms); nowMs += ms; }); sq.resetRates();
try {
for (let i = 0; i < 5; i++) await sq.pace('a.test'); eq(slept.length, 0, '처음 5건은 즉시');
await sq.pace('a.test'); ok(slept.length >= 1 && slept[0] >= 150 && slept[0] <= 200, '6번째는 ~200ms 대기: ' + slept[0]);
// 다른 도메인이라도 서버 버킷(초당 5)을 함께 씁니다
slept.length = 0; nowMs += 2000; for (let i = 0; i < 5; i++) await sq.pace('b.test'); await sq.pace('c.test'); ok(slept.length >= 1, '서버 전체 버킷 적용');
// 제출 제한
const { env } = await base(); let th = null;
for (let i = 0; i < 12; i++) { th = await sq.submit(env, ['account:k@a.test', 'ip:1.2.3.4']); if (th.throttled) break; }
ok(th.throttled, '초당 10건에서 제한'); eq(th.rate, 10, '제출량'); ok(th.until, '해제 시각');
const flags = await sq.abuseList(env); eq(flags.length, 1, '악용 확인 표 1건'); eq(flags[0].subject, 'account:k@a.test', '대상');
const snap = sq.rateSnapshot(); ok(snap.submissions.find((s) => s.key === 'account:k@a.test')?.throttledUntil, '운영자 화면: 제한 해제 시각'); eq(snap.deliveryRate, 5, '전달 상한');
nowMs += sq.THROTTLE_WINDOW_MS + 1; eq((await sq.submit(env, ['account:k@a.test'])).throttled, false, '창이 지나면 정상 발송 재개');
// 대기열: 발송 성공 → sent, 같은 키 재전송은 발송 모듈 호출 없음, 실패 → failed, 재시작 → interrupted
const calls = []; const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); const { sendMail } = await import('../server/providers/index.mjs');
const r1 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); ok(r1.id, '발송');
const r2 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); eq(r2.duplicate, true, '같은 키는 중복 발송 안 함'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 1, '발송 모듈 1회');
env2.RESEND_FETCH = fakeResend({ failStatus: 500 }); let err = null; try { await sendMail(env2, { from: 'admin@arihakdang.com', to: ['b@x.test'], subject: 's', text: 'b' }, 'key-2'); } catch (e) { err = e; } ok(err, '실패');
const rows = (await env2.DB.prepare('SELECT send_key, state FROM outbound_queue ORDER BY created_at').all()).results; eq(rows.map((r) => r.state).join(','), 'sent,failed', '대기열 상태');
await env2.DB.prepare("UPDATE outbound_queue SET state='sending' WHERE send_key='key-2'").run(); eq(await sq.recoverQueue(env2), 1, '재시작 복구: interrupted'); eq((await sq.queueList(env2))[0].state, 'interrupted', '목록 맨 앞');
ok(!JSON.stringify(await sq.queueList(env2)).includes('"text"'), '대기열에 본문 없음');
} finally { sq.setClock(null); sq.setSleep(null); sq.resetRates(); }
});
await t('7) 운영 상태가 active 가 아니면 수신 임시 거부(domain_not_active)·발신 차단(mail_domain_frozen)', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'migrating_out' }, tenants);
eq(await checkInbound(env, 'admin@arihakdang.com'), 'domain_not_active', '수신 거부');
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'mail_domain_frozen', '발신 차단');
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'active' }, tenants); eq(await checkInbound(env, 'admin@arihakdang.com'), null, '복귀');
tenants.close();
});
await t('8) 포털 SSO 티켓: 서명·만료·재사용·도메인을 검사하고 메일함 세션 쿠키로 웹 메일에 들어간다', async () => {
const { env } = await base(); const secret = randomBytes(24).toString('base64url');
const ticket = (payload, sec = secret) => { const p = Buffer.from(JSON.stringify(payload)).toString('base64url'); return p + '.' + createHmac('sha256', sec).update(p).digest('base64url'); };
eq((await api(env, 'auth/sso?ticket=' + ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n0' }))).status, 401, '비밀 미설정이면 거부');
saveConfig(env, { ARIWEB_SSO_SECRET: secret }, { allowHidden: true });
const good = ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n1' });
const r = await api(env, 'auth/sso?ticket=' + encodeURIComponent(good)); eq(r.status, 302, '세션 발급·이동'); ok(/mg_session=/.test(r.headers.get('set-cookie') || ''), '세션 쿠키'); eq(r.headers.get('location'), '/', '웹 메일로');
const cookie = r.headers.get('set-cookie').split(';')[0]; eq((await (await api(env, 'session', cookie)).json()).user.mailbox, 'admin@arihakdang.com', '세션의 메일함');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(good))).json()).error, 'sso_replayed', '재사용 거부');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n2' }, 'other')))).json()).error, 'sso_bad_signature', '서명 불일치');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() - 1, nonce: 'n3' })))).json()).error, 'sso_expired', '만료');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@other.test', exp: Date.now() + 60000, nonce: 'n4' })))).json()).error, 'sso_wrong_domain', '다른 도메인');
eq((await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'nobody@arihakdang.com', exp: Date.now() + 60000, nonce: 'n5' })))).status, 404, '없는 메일함');
ok(!JSON.stringify(await (await api(env, 'admin/rates', cookie, 'GET', undefined, { 'X-Mail-Scope': 'admin' })).json()).includes(secret), '관리자 응답에 비밀 없음');
});
done();
+3 -2
View File
@@ -119,7 +119,7 @@ await t('0004 마이그레이션은 기존 만료일을 비우고 Resend 형식
for (const f of readdirSync(all).filter((x) => x < '0004')) copyFileSync(join(all, f), join(old, f));
const db = openDb(':memory:'); migrate(db, old, { quiet: true });
db.prepare("INSERT INTO messages(id,direction,folder,received_at,sent_at,created_at,updated_at,expires_at) VALUES('m1','outbound','sent','2026-09-28 06:20:26.514000+00','2026-09-28 06:20:26.514000+00','x','x','2026-10-28T06:20:26.514Z'),('m2','inbound','inbox','2026-09-28T07:00:00.000Z',NULL,'x','x','2026-10-28T07:00:00.000Z')").run();
eq(migrate(db, all, { quiet: true }), 5, '0004·0005·0006·0007·0008 적용');
eq(migrate(db, all, { quiet: true }), 6, '0004·0005·0006·0007·0008·0009 적용'); // [Ariweb Suite 변경] 0009 발송 대기열
const rows = db.prepare('SELECT id,received_at,sent_at,expires_at FROM messages ORDER BY received_at DESC').all();
eq(rows.map((r) => r.id).join(','), 'm2,m1', '정렬이 실제 시각 순서');
eq(rows[1].received_at, '2026-09-28T06:20:26.514Z', 'received_at ISO'); eq(rows[1].sent_at, '2026-09-28T06:20:26.514Z', 'sent_at ISO');
@@ -813,7 +813,8 @@ await t('0.9.0 제한: 시스템(환경)·관리자·계정별 메일함 크기,
const d4 = await saveDraft(env, { to: 'a@example.test', subject: '월간', html: '<p>x</p>' });
r = await api(env, 'mail/' + d4 + '/send', cookie, 'POST', {}); const e4 = await r.json(); eq(e4.error, 'monthly_limit_exceeded', '월간 초과'); eq(e4.limit, 5, '상한');
await writePrefs(env, { id: 'www:admin-1', mailbox: 'admin@arihakdang.com' }, { forward: { enabled: true, to: 'fw@example.test' } });
env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.auto?.forward?.detail, 'limit', '자동 전달 건너뜀(월간 한도)');
// [Ariweb Suite 변경] 월간 통수 100% 에서는 새 수신도 임시 거부(skipped='monthly_limit', 보낸 서버가 재시도)합니다(Dev-Order §13.4). 예전에는 받되 자동 전달만 건너뛰었습니다.
env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.skipped, 'monthly_limit', '월간 한도에서 새 수신 임시 거부');
// 현황
const st = await mailStats(env, await knownMailboxes(env), await listUsers(env));
eq(st.timezone, 'Asia/Seoul', '시간대'); ok(st.periods.month.total >= 5, '월간 합계'); ok(st.periods.day.received >= 1, '일일 수신');
+3 -1
View File
@@ -191,7 +191,9 @@ await t('0.8.25 오류 수정: 같은 DATA 를 받는 사람만 바꿔 따로
const c2 = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
await c2.reply(); await c2.cmd('EHLO x'); await c2.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
await c2.cmd('MAIL FROM:<admin@arihakdang.com>'); await c2.cmd('RCPT TO:<alice@example.test>'); await c2.cmd('DATA'); c2.raw(data); await c2.reply(); await c2.cmd('QUIT');
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again[2].key, posts[0].key, '재전송은 같은 키');
// [Ariweb Suite 변경] 같은 키의 재전송은 발송 대기열(outbound_queue)이 "이미 보냄" 으로 알아 발송 모듈을 다시 부르지 않고 같은 발송 id 로 250 을 돌려줍니다(두 번 발송 없음).
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again.length, 2, '재전송은 발송 모듈을 다시 부르지 않음');
const q = (await env.DB.prepare("SELECT state, send_key FROM outbound_queue ORDER BY created_at").all()).results; eq(q.length, 2, '대기열 두 건'); ok(q.every((r) => r.state === 'sent'), '모두 sent');
} finally { smtp.close(); }
});
+3 -1
View File
@@ -39,7 +39,9 @@ async function putMail(env, { direction, at, size = 1000, mailbox, state }) {
.bind(id, direction, outbound ? 'sent' : 'inbox', 'a@b.test', '["x@y.test"]', 's', 'body', outbound ? 'web' : 'sync', state || (outbound ? 'sent' : 'received'), at, at, at, outbound ? at : null, 'stored', size, mailbox).run();
return id;
}
const daysAgo = (n, hour = 12) => { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); d.setUTCHours(hour, 0, 0, 0); return d.toISOString(); };
// [Ariweb Suite 변경] 결정적 시각: 예전에는 UTC 12시 기준이라 UTC 15:00~24:00(= KST 다음 날 0~9시)에 실행하면 "오늘" 이 KST 어제가 되어 오늘 칸이 비었습니다.
// 이제 통계 시간대(Asia/Seoul)의 오늘 0시를 periodBounds 로 구하고 그 날 정오(KST)에서 n 일을 뺍니다 — 어느 시각에 실행해도 같은 칸에 들어갑니다.
const daysAgo = (n) => new Date(Date.parse(periodBounds('day', 'Asia/Seoul').start) + 12 * 3600 * 1000 - n * 86400000).toISOString();
await t('IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부', () => {
eq(parseAllowList(' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ').join('|'), '1.2.3.4|10.0.0.0/8|2001:db8::1', '파싱');
+1 -1
View File
@@ -99,7 +99,7 @@ function domainsCard() {
const cur = d.domain === DOMAIN;
const btns = [`<button type="button" class="small ${cur ? '' : 'primary'}" data-domain-select="${esc(d.domain)}" ${cur ? 'disabled' : ''}>${cur ? '보는 중' : '선택'}</button>`, `<button type="button" class="small" data-domain-edit="${esc(d.domain)}">수정</button>`];
if (!d.primary) btns.push(`<button type="button" class="small" data-domain-toggle="${esc(d.domain)}" data-enabled="${d.enabled ? '1' : '0'}">${d.enabled ? '중지' : '사용'}</button>`, `<button type="button" class="small ghost" data-domain-remove="${esc(d.domain)}">삭제</button>`);
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}${d.operating && d.operating !== 'active' ? `<div class="meta warn">운영 상태: ${esc({ migrating_out: '이전 중(보내는 쪽)', migrating_in: '이전 중(받는 쪽)', frozen: '동결' }[d.operating] || d.operating)}</div>` : ''}${d.ariweb ? `<div class="meta muted">Ariweb: ${esc(d.ariweb.kind === 'site_bound' ? '사이트 대표 메일' : '독립 메일 서비스')}${d.ariweb.siteId ? ' · 사이트 ' + esc(d.ariweb.siteId) : ''}</div>` : ''}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
}).join('');
const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary;
const form = `<form id="f-domain" class="domain-form ${editing ? 'editing' : ''}"><h3>${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}</h3><div class="grid">
+33
View File
@@ -0,0 +1,33 @@
# AriMail 기능 도입 기록(§0.3·§0.4·§0.5) — 원본 AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924` (1.0.11)
| 기능 | 결과 | Ariweb Suite 에서 접근하는 위치 | 회귀 테스트 |
|---|---|---|---|
| Multi-domain Mail Service(도메인별 테넌트) | 그대로 도입 + 최소 연결 변경(등록부 `ariweb`·`operating` 필드) | 포털 메일 서비스 생성 → 어댑터 `addDomain` | modules/arimail test/domains·ariweb 3) |
| Webmail 수신함·발신함·작성·회신·전달·검색 | 그대로 도입 | 메일 서비스/메일함 화면 "웹메일 열기"(SSO 티켓 또는 로그인 안내) | test/gateway(기존) |
| SMTP 발신·Mail 수신 | 그대로 도입 + 오류 수정(Haraka/SMTP 중계 때 Resend 키 요구) + 정책 개선(제출·전달 속도, 대기열) | AriMail 서버 그대로(포털은 상태만) | test/ariweb 2)·6) |
| IMAP·POP3 접속 | 그대로 도입 | 메일함 화면 "메일 프로그램 설정"(호스트·포트·암호화·사용자, 비밀번호 비표시) | test/imap·gateway(기존) |
| Resend·Haraka·External SMTP 발송 방식 | 그대로 도입 | 운영자 콘솔(전체 설정 화면 링크) | test/provision(기존) |
| Mail Domain·Mail Account 관리 | 그대로 도입(관리자 API) + 최소 연결(포털 메일함 표와 대응) | 메일함 목록·생성·설정 | test/mail.test(포털) |
| Mailbox 용량 관리 | 그대로 도입 + 정책 개선(저장 공간에 대용량 첨부 포함) | 메일함 설정 "저장 공간" | test/ariweb 4) |
| 자동 응답·Mail 전달 | 그대로 도입(웹메일 설정) | 메일함 화면에서 웹메일 설정으로 이동(포털은 상태 표시만) | test/gateway(기존) |
| 일반 첨부·링크 방식 대용량 첨부 | 그대로 도입 + 정책 표시(허용 크기·횟수·기한은 상품정책) | 메일 서비스 설정 › 대용량 첨부 | test/gateway 0.9.3(기존) |
| POP3·IMAP 사용 설정 | 그대로 도입 + 최소 연결(서비스 단위 스위치 + 적용 뒤 접속 확인) | 메일 서비스 설정 | test/mail.test |
| DNS 설정과 Mail Domain 확인 | 그대로 도입(dnsRecords·plan) + 최소 연결(포털에서 공용 DNS 조회·복사값) | 메일 서비스 개요·도메인 보안 탭 | test/mail.test |
| Mail 사용량·Account 별 사용량(usage API) | 그대로 도입 + 정책 개선(수신자 기준 통수·경고 구간·제한 사유·운영 상태) | 사용량 탭·사용량 영역 | test/usage·ariweb 3)·4)·5) |
| Webmaker 연동(webmaker 로그인 모드) | 그대로 도입(변경 없음) | 운영자 설정 | test/webmaker(기존) |
| 초기 설정·관리자 설정·진단 | 그대로 도입 | 운영자 콘솔 링크(전용 포트 설정 화면) | test/config·setup(기존) |
| `usage.test.mjs` KST·UTC 월 경계 | 오류 수정(고정 시각 기준) | — | test/usage(수정)·ariweb 1) |
| 사이트 대표 메일/독립 MailService 구분 | 정책 개선(등록부 `ariweb.kind`) | 메일 목록·상세 | test/ariweb 3) |
| Site 별 보장량(Storage·월간 통수·Account 수) | 정책 개선(테넌트 설정 LIMIT_*) | 사용량 탭(배정 변경은 사용량 영역) | test/ariweb 4) |
| 100% 제한 동작·70/80/90/95 경고 | 정책 개선 | 사용량 탭·알림 | test/ariweb 5) |
| Domain·Server 별 발송 Queue·안전 발송 속도·제출 제한 | 정책 개선(sendqueue.mjs, 0009 마이그레이션) | 운영자 › 메일 운영(발송 제한) | test/ariweb 6) |
| Mail Domain 이전 중 운영 상태 | 정책 개선(`operating`) | 운영자 › 이전 요청, 고객 상세 상태 | test/ariweb 7) |
| Site-bound → 독립 전환 | 정책 개선(포털 전환 작업 + AriMail `ariweb.kind` 변경) | 메일 상세 › 독립 전환 | test/mail.test |
| 고객용 Mail Backup·복원 미제공 표시 | 정책 개선(포털 문구) | 메일 모든 화면·생명주기 | test/mail.test |
| 해지 뒤 30일 읽기·Export·삭제 예정일 | 정책 개선(포털 생명주기 + AriMail `frozen`) | 메일 상세 › 생명주기 | test/mail.test |
| 포털 SSO 진입 | 정책 개선(`GET /api/auth/sso`) | 웹메일 열기 | test/ariweb 8) |
| 별칭·전달 전용 주소 생성 | 미사용(이번 버전에서 지원하지 않음 — AriMail 에 별칭 기능이 없고 정책 미확정) | 메일함 설정에 정책 안내만 | test/mail.test |
| 일괄 .eml 내보내기 | 미사용(AriMail 에 일괄 API 없음 — 보존 기간 중 IMAP/POP3·메일별 원본 다운로드 안내) | 생명주기 탭 | test/mail.test |
회귀 테스트 결과(2026-10-26, 도입 후): modules/arimail 9개 파일 모두 통과(gateway 48 · imap 7 · smtp 7 · config 4 · provision 8 · domains 8 · webmaker 10 · usage 5 · ariweb 8).
변경 파일(원본 대비): server/config.mjs · limits.mjs · domains.mjs · smtp.mjs · providers/index.mjs · api.mjs · usage.mjs · main.mjs · sendqueue.mjs(신규) · migrations/0009_ariweb_queue.sql(신규) · haraka/plugins/arimail.js · web/setup.js · test/*(gateway·smtp·usage 기대값, ariweb 신규) · package.json · deploy/deploy.sh.
+111
View File
@@ -0,0 +1,111 @@
/**
* 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) })));
}
+201
View File
@@ -0,0 +1,201 @@
/**
* portal/mail/arimail.mjs — AriMail 연동 어댑터(docs/ARCHITECTURE.md §7).
*
* 포털은 AriMail 의 기존 기능(웹메일·IMAP·POP3·SMTP·다중 도메인·사용량 API·전체 설정 API·관리자 API)을 다시 만들지 않고 이 어댑터로 부른다(§0.3·§38).
* - 전체 설정 API(/api/setup/*, 설정 토큰): 도메인(테넌트) 추가·변경·삭제, 도메인별 설정(보장량·SSO 비밀·DNS 레코드·자동 설정 계획), 발신 상태 확인.
* - 관리자 API(/api/admin/*, 관리자 세션): 메일함(계정) 목록·생성·수정·삭제, POP/IMAP 스위치, 서버 상태, 발송 속도·대기열.
* - 사용량 API(/api/usage, 비밀 키): 도메인·계정별 저장 공간·통수·경고 구간·제한 사유.
* - SSO(/api/auth/sso): 포털이 서명한 티켓으로 웹메일 세션(§16.3).
* - 송수신 확인: SMTP 제출(modules/arimail/server/providers/smtp-client.mjs 재사용)과 웹 API 수신함 조회로 실제 외부 왕복을 확인.
* 서버 등록은 mail_servers 표, 비밀은 보관소: arimail:<서버id>:setup-token · :usage-key · :sso-secret · :admin:<도메인>(관리자 로그인 JSON). 응답에 비밀값을 넣지 않는다.
* 네트워크 오류는 code='service_unreachable' 로 던져 화면이 '정보 확인 지연' 을 보이게 한다. createFake() 는 같은 인터페이스의 메모리 구현(다른 영역의 테스트용).
*/
import { randomBytes, createHmac } from 'node:crypto';
import { newId } from '../../lib/crypto.mjs';
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
/** 공통 fetch: JSON 응답, 네트워크 오류는 service_unreachable, AriMail 의 { ok:false, error } 는 code=error 로 던진다. */
async function call(fetchImpl, url, { method = 'GET', headers = {}, body, host = null, timeoutMs = 15000 } = {}) {
let r;
try { r = await fetchImpl(url, { method, headers: { 'Content-Type': 'application/json', ...(host ? { Host: host, 'X-Forwarded-Host': host } : {}), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' }); }
catch (e) { const err = new Error(`AriMail 에 연결할 수 없습니다 (${new URL(url).host})`); err.code = 'service_unreachable'; err.cause = e; throw err; }
let json = null; try { json = await r.json(); } catch (_) {}
if (!r.ok || (json && json.ok === false)) { const err = new Error((json && json.error) || `AriMail 오류 ${r.status}`); err.code = (json && json.error) || 'arimail_' + r.status; err.status = r.status; err.detail = json; throw err; }
return { json, headers: r.headers, status: r.status };
}
export function create(deps) {
const { store, vault } = deps; const fetchImpl = deps.fetchImpl || fetch;
const secretName = (serverId, kind) => `arimail:${serverId}:${kind}`;
const serverRow = (id) => { const r = id ? store.get('SELECT * FROM mail_servers WHERE id=?', id) : store.get('SELECT * FROM mail_servers WHERE is_default=1') || store.get('SELECT * FROM mail_servers ORDER BY created_at LIMIT 1'); if (!r) { const e = new Error('등록된 AriMail 서버가 없습니다. 운영자 콘솔 › 메일 서비스 › 서버 등록에서 먼저 등록하세요'); e.code = 'mail_server_missing'; throw e; } return r; };
const internalOf = (s) => (s.internal_url || s.base_url).replace(/\/$/, '');
const setupOf = (s) => (s.setup_url || s.base_url.replace(/\/$/, '') + ':8791').replace(/\/$/, '');
const hostOf = (s, domain) => (domain ? (domain.host || `mail.${domain}`) : new URL(s.base_url).host);
/** 도메인 → 그 도메인의 웹 메일 호스트(등록부 hosts[0]). 캐시(60초). */
const hostCache = new Map();
async function domainHost(s, domain) {
const c = hostCache.get(s.id + '|' + domain); if (c && Date.now() - c.at < 60000) return c.host;
const list = await api.domains(s.id); const d = list.find((x) => x.domain === domain); const host = d ? String(d.hosts[0]).split('/')[0] : `mail.${domain}`;
hostCache.set(s.id + '|' + domain, { host, at: Date.now() }); return host;
}
const setupHeaders = (s) => { const tok = vault.get(secretName(s.id, 'setup-token')); if (!tok) { const e = new Error('AriMail 전체 설정 토큰이 보관소에 없습니다'); e.code = 'mail_server_token_missing'; throw e; } return { 'X-Setup-Token': tok }; };
/** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */
const adminCache = new Map();
async function adminCookie(s, domain) {
const k = s.id + '|' + domain; const c = adminCache.get(k); if (c && Date.now() - c.at < 10 * 60 * 1000) return c.cookie;
const cred = parseJson(vault.get(secretName(s.id, 'admin:' + domain)), null); if (!cred || !cred.login) { const e = new Error(`AriMail 관리자 로그인 정보가 없습니다(${domain})`); e.code = 'mail_admin_missing'; throw e; }
const host = await domainHost(s, domain);
const r = await call(fetchImpl, internalOf(s) + '/api/login', { method: 'POST', host, headers: { 'X-Mail-Scope': 'admin' }, body: { login: cred.login, password: cred.password } });
const cookie = String(r.headers.get('set-cookie') || '').split(';')[0]; adminCache.set(k, { cookie, at: Date.now() }); return cookie;
}
const admin = async (s, domain, path, opts = {}) => call(fetchImpl, internalOf(s) + '/api/' + path, { ...opts, host: await domainHost(s, domain), headers: { 'X-Mail-Scope': 'admin', Cookie: await adminCookie(s, domain), ...(opts.headers || {}) } });
const api = {
kind: 'arimail',
// ---- 서버 등록부(운영자)
listServers() { return store.all('SELECT * FROM mail_servers ORDER BY is_default DESC, created_at').map((s) => ({ ...s, last_health: parseJson(s.last_health_json, null), secrets: { setupToken: vault.has(secretName(s.id, 'setup-token')), usageKey: vault.has(secretName(s.id, 'usage-key')), ssoSecret: vault.has(secretName(s.id, 'sso-secret')) } })); },
defaultServer() { return serverRow(null); },
getServer: (id) => serverRow(id),
/** 서버 등록·수정. secrets 는 보관소에만 저장하고 응답에 돌려주지 않는다. */
saveServer({ id = null, name, baseUrl, internalUrl = null, setupUrl = null, mailHost = null, isDefault = false, secrets = {} }) {
const now = nowIso(); const sid = id || newId('ms_');
if (!/^https?:\/\//.test(String(baseUrl || ''))) throw Object.assign(new Error('공개 주소는 http(s):// 로 시작해야 합니다'), { code: 'validation', status: 422 });
if (isDefault) store.run('UPDATE mail_servers SET is_default=0');
if (id) store.run('UPDATE mail_servers SET name=?, base_url=?, internal_url=?, setup_url=?, mail_host=?, is_default=?, updated_at=? WHERE id=?', name, baseUrl, internalUrl, setupUrl, mailHost, isDefault ? 1 : 0, now, id);
else store.run('INSERT INTO mail_servers(id, name, base_url, internal_url, setup_url, mail_host, is_default, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', sid, name, baseUrl, internalUrl, setupUrl, mailHost, isDefault || !store.get('SELECT 1 FROM mail_servers') ? 1 : 0, now, now);
for (const [k, v] of Object.entries(secrets)) if (v) vault.set(secretName(sid, k), typeof v === 'string' ? v : JSON.stringify(v), { kind: 'arimail' });
return api.listServers().find((s) => s.id === sid);
},
/** 도메인 관리자 로그인 보관(도메인 추가 때 초기 관리자 비밀번호를 받아 저장) */
storeAdmin(serverId, domain, login, password) { vault.set(secretName(serverId, 'admin:' + domain), JSON.stringify({ login, password }), { kind: 'arimail-admin' }); adminCache.delete(serverId + '|' + domain); },
async health(serverId) {
const s = serverRow(serverId); const t0 = Date.now();
try { const r = await call(fetchImpl, internalOf(s) + '/api/health'); const h = { ok: true, version: r.json.version, domains: r.json.domains, ms: Date.now() - t0, at: nowIso() }; store.run('UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?', h.at, toJson(h), s.id); return h; }
catch (e) { const h = { ok: false, error: e.code || e.message, at: nowIso() }; store.run('UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?', h.at, toJson(h), s.id); return h; }
},
// ---- 사용량 API
async usage(serverId, domain = '', { days = 30 } = {}) {
const s = serverRow(serverId); const key = vault.get(secretName(s.id, 'usage-key')); if (!key) { const e = new Error('AriMail 사용량 API 키가 보관소에 없습니다'); e.code = 'mail_usage_key_missing'; throw e; }
const r = await call(fetchImpl, `${internalOf(s)}/api/usage${domain ? '/' + encodeURIComponent(domain) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key } });
return r.json;
},
// ---- 전체 설정 API(도메인 등록부)
async domains(serverId) { const s = serverRow(serverId); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
async addDomain(serverId, { domain, hosts = null, mailbox = 'admin', adminPassword, ariweb = null, operating = 'active', note = '' }) {
const s = serverRow(serverId); const pw = adminPassword || randomBytes(12).toString('base64url');
const r = await call(fetchImpl, setupOf(s) + '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
hostCache.delete(s.id + '|' + domain);
if (r.json.initialAdmin) api.storeAdmin(s.id, domain, r.json.initialAdmin.login, pw);
return { entry: r.json.domain, initialAdmin: r.json.initialAdmin ? { login: r.json.initialAdmin.login } : null };
},
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
/** 도메인 설정(비밀 가림)·메타(DNS 레코드·발신 상태·호스트). */
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
/** 도메인별 보장량(§13.3): 메일함 수·저장 공간·월간 통수를 AriMail 설정으로 */
setDomainLimits(serverId, domain, { mailboxes, storageBytes, monthlyMessages }) { return api.setDomainConfig(serverId, domain, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
async plan(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
// ---- 관리자 API(메일함)
async listUsers(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users')).json; },
async createUser(serverId, domain, { login, name = '', password, quotaBytes = undefined, services = undefined, role = 'user' }) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users', { method: 'POST', body: { login, name, password, role, ...(quotaBytes !== undefined ? { quotaBytes } : {}), ...(services ? { services } : {}) } })).json; },
async updateUser(serverId, domain, id, patch) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users/' + encodeURIComponent(id), { method: 'PATCH', body: patch })).json; },
async deleteUser(serverId, domain, id) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users/' + encodeURIComponent(id), { method: 'DELETE' })).json; },
async setServices(serverId, domain, services) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config', { method: 'PATCH', body: { services } })).json; },
async adminConfig(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config')).json; },
async setLargeFiles(serverId, domain, largeFiles) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config', { method: 'PATCH', body: { largeFiles } })).json; },
async adminStatus(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/status')).json; },
async rates(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/rates')).json; },
/** 메일함의 모든 세션 종료: AriMail 은 비밀번호 재설정이 세션을 무효화하므로 임시 비밀번호 재설정으로 구현(사용자는 다시 정해야 함). */
async endSessions(serverId, domain, id) { const pw = randomBytes(12).toString('base64url'); await api.updateUser(serverId, domain, id, { password: pw }); return { ok: true, passwordReset: true }; },
// ---- SSO·접속 정보
/** 웹메일 SSO 주소(§16.3). AriMail 도메인 설정의 ARIWEB_SSO_SECRET 과 같은 비밀로 서명. 5분 유효, 1회용. */
async ssoUrl(serverId, domain, mailbox) {
const s = serverRow(serverId); const secret = vault.get(secretName(s.id, 'sso-secret')); if (!secret) return null;
const payload = Buffer.from(JSON.stringify({ mailbox, exp: Date.now() + 5 * 60 * 1000, nonce: randomBytes(12).toString('base64url') })).toString('base64url');
const host = await domainHost(s, domain);
return `https://${host}/api/auth/sso?ticket=${encodeURIComponent(payload + '.' + createHmac('sha256', secret).update(payload).digest('base64url'))}`;
},
/** SSO 비밀을 만들어 보관소와 AriMail 도메인 설정 양쪽에 둔다(값은 돌려주지 않음). */
async ensureSso(serverId, domain) { const s = serverRow(serverId); let secret = vault.get(secretName(s.id, 'sso-secret')); if (!secret) { secret = randomBytes(32).toString('base64url'); vault.set(secretName(s.id, 'sso-secret'), secret, { kind: 'arimail' }); } await api.setDomainConfig(s.id, domain, { ARIWEB_SSO_SECRET: secret }); return true; },
/**
* 실제 외부 왕복 확인(§15.3 8단계): 검증 메일함(ariweb-check@도메인, 비밀번호는 보관소)이 SMTP 제출(587 STARTTLS, AriMail smtp-client 재사용)로 외부 검증 주소에 보내고,
* 그 주소의 자동 회신이 검증 메일함 수신함에 들어오는지 웹 API 로 기다린다. 메일 내용은 표식(marker)뿐이며 결과에 본문을 넣지 않는다.
*/
async roundTrip(serverId, domain, { to, timeoutMs = 90000 } = {}) {
const s = serverRow(serverId); const host = await domainHost(s, domain); const login = 'ariweb-check'; const from = `${login}@${domain}`;
let pw = vault.get(secretName(s.id, 'check:' + domain));
if (!pw) { pw = randomBytes(12).toString('base64url'); try { await api.createUser(s.id, domain, { login, name: 'Ariweb 검증', password: pw }); } catch (e) { if (e.code !== 'login_taken') throw e; const list = await api.listUsers(s.id, domain); const u = (list.users || []).find((x) => x.login === login); if (u) await api.updateUser(s.id, domain, u.id, { password: pw }); } vault.set(secretName(s.id, 'check:' + domain), pw, { kind: 'arimail-check' }); }
const marker = 'AWCHK-' + randomBytes(6).toString('hex'); const mailHost = s.mail_host || new URL(s.base_url).host;
const eml = Buffer.from(`From: ${from}\r\nTo: ${to}\r\nSubject: ${marker}\r\nDate: ${new Date().toUTCString()}\r\nMessage-ID: <${marker}@${domain}>\r\n\r\nAriweb Suite mail verification ${marker}. Please reply to this message.\r\n`);
let sent = false, sendDetail = '';
try { const { smtpSend } = await import('../../modules/arimail/server/providers/smtp-client.mjs'); await smtpSend({ host: mailHost, port: Number(deps.config?.values?.mailSubmitPort || 587), security: deps.config?.values?.mailSubmitSecurity || 'starttls', user: from, pass: pw, hostname: 'ariweb-portal', from, to: [to], eml, rejectUnauthorized: deps.config?.values?.mailVerifierInsecureTls ? false : true }); sent = true; }
catch (e) { sendDetail = `SMTP 제출 실패: ${e.message}${e.providerMessage ? ' ' + e.providerMessage : ''}`; }
let received = false, replied = false; const deadline = Date.now() + timeoutMs;
if (sent) {
const lg = await call(fetchImpl, internalOf(s) + '/api/login', { method: 'POST', host, body: { login, password: pw } }); const cookie = String(lg.headers.get('set-cookie') || '').split(';')[0];
while (Date.now() < deadline) { const r = await call(fetchImpl, internalOf(s) + '/api/mail?folder=inbox', { host, headers: { Cookie: cookie } }).catch(() => null); const msgs = r?.json?.messages || []; if (msgs.some((m) => String(m.subject || '').includes(marker))) { received = true; replied = true; break; } await new Promise((res) => setTimeout(res, deps.verifyPollMs || 5000)); }
}
return { sent, sendDetail, received, replied, marker, to, receiveDetail: sent && !received ? '제한 시간 안에 회신이 수신함에 들어오지 않았습니다(외부 검증 주소의 자동 회신·DNS·MX 확인)' : '', replyDetail: '' };
},
/** 메일 프로그램 설정값(비밀번호 없음) */
programSettings(serverId, domain, address) { const s = serverRow(serverId); const host = s.mail_host || new URL(s.base_url).host; return { username: address, imap: { host, ports: { tls: 993, starttls: 143 } }, pop3: { host, ports: { tls: 995, starttls: 110 } }, smtp: { host, ports: { tls: 465, starttls: 587 } }, webmail: `https://${host}` }; },
/** 서버 상태 화면용: 등록부·건강·버전(비밀 없음) */
async overview() { const out = []; for (const s of api.listServers()) { let domains = null, health = await api.health(s.id); try { domains = await api.domains(s.id); } catch (e) { domains = { error: e.code || e.message }; } out.push({ ...s, health, domains }); } return out; },
secretName,
};
return api;
}
/**
* 메모리 구현(다른 영역의 테스트용). 같은 메서드 이름·모양을 가지며 상태를 this.state 로 들여다볼 수 있다.
* failWith 를 두면 모든 호출이 그 code 로 실패한다(연결 불가 시나리오).
*/
export function createFake(opts = {}) {
const state = { servers: [{ id: 'fake', name: '가짜 AriMail', base_url: 'https://mail.example.test', internal_url: 'http://127.0.0.1:1', setup_url: 'http://127.0.0.1:1', mail_host: 'mail.example.test', is_default: 1, status: 'active' }], domains: new Map(), users: new Map(), configs: new Map(), usage: new Map(), services: new Map(), failWith: opts.failWith || null, calls: [] };
const guard = (name) => { state.calls.push(name); if (state.failWith) { const e = new Error('fake failure'); e.code = state.failWith; throw e; } };
const usersOf = (d) => { if (!state.users.has(d)) state.users.set(d, []); return state.users.get(d); };
const fake = {
kind: 'arimail-fake', state,
listServers: () => state.servers.map((s) => ({ ...s, secrets: { setupToken: true, usageKey: true, ssoSecret: true } })), defaultServer: () => state.servers[0], getServer: () => state.servers[0], saveServer: (s) => ({ ...state.servers[0], ...s }), storeAdmin() {},
async health() { guard('health'); return { ok: true, version: '1.0.11-fake', domains: state.domains.size, ms: 1, at: nowIso() }; },
async usage(_s, domain = '', o = {}) {
guard('usage'); const one = (d) => { const u = state.usage.get(d) || {}; const users = usersOf(d); const cfg = state.configs.get(d) || {}; const lim = { accounts: Number(cfg.LIMIT_ACCOUNTS || 0), totalBytes: Number(cfg.LIMIT_TOTAL_BYTES || 0), monthlyMessages: Number(cfg.LIMIT_MONTHLY_MESSAGES || 0) };
const storage = u.storageBytes || 0, month = u.monthly || 0; const lvl = (x, l) => (!l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0);
const levels = { storage: lvl(storage, lim.totalBytes), monthly: lvl(month, lim.monthlyMessages), accounts: lvl(users.length, lim.accounts) }; const reasons = []; if (levels.storage === 100) reasons.push('storage_full'); if (levels.monthly === 100) reasons.push('monthly_limit'); if (levels.accounts === 100) reasons.push('accounts_full');
return { domain: d, enabled: true, hosts: [`mail.${d}`], authMode: 'local', inbound: 'haraka', outbound: 'haraka', timezone: 'Asia/Seoul', accounts: { local: users.length, external: 0, total: users.length, limit: lim.accounts }, storage: { bytes: storage, limit: lim.totalBytes, largeFiles: { count: 0, bytes: u.largeBytes || 0, sent: 0 } }, messages: { total: u.messages || 0 }, traffic: { month: { sent: u.sent || 0, received: u.received || 0, total: month }, monthlyLimit: lim.monthlyMessages, countingRule: 'per_final_recipient', monthReset: u.monthReset || '2026-10-31T15:00:00.000Z' }, ariweb: (state.domains.get(d) || {}).ariweb || null, operating: (state.domains.get(d) || {}).operating || 'active', warnLevel: Math.max(levels.storage, levels.monthly, levels.accounts), warnLevels: levels, limitedReasons: reasons, measuredAt: nowIso(), accountList: users.map((x) => ({ login: x.login, name: x.name, mailbox: x.mailbox, role: x.role, source: 'local', status: x.status, messages: x.messages || 0, bytes: x.bytes || 0, limit: x.quotaBytes || 0, lastLoginAt: x.lastLoginAt || null, sent: x.sent || 0, received: x.received || 0 })), limits: { system: lim, admin: {} } }; };
if (domain) { if (!state.domains.has(domain)) { const e = new Error('domain_not_found'); e.code = 'domain_not_found'; e.status = 404; throw e; } return { ok: true, version: '1.0.11-fake', ...one(domain) }; }
return { ok: true, version: '1.0.11-fake', primary: [...state.domains.keys()][0] || null, totals: {}, domains: [...state.domains.keys()].map(one) };
},
async domains() { guard('domains'); return [...state.domains.values()]; },
async addDomain(_s, { domain, hosts, mailbox = 'admin', ariweb = null, operating = 'active' }) { guard('addDomain'); if (state.domains.has(domain)) { const e = new Error('domain_exists'); e.code = 'domain_exists'; e.status = 409; throw e; } const entry = { domain, enabled: true, hosts: hosts || [`mail.${domain}`], mailbox: `${mailbox}@${domain}`, auth: { mode: 'local' }, ariweb, operating, accounts: 0, messages: 0 }; state.domains.set(domain, entry); usersOf(domain).push({ id: 'u_admin_' + domain, login: mailbox, name: '관리자', mailbox: `${mailbox}@${domain}`, role: 'admin', status: 'active' }); return { entry, initialAdmin: { login: mailbox } }; },
async updateDomain(_s, domain, patch) { guard('updateDomain'); const e = state.domains.get(domain); if (!e) { const x = new Error('not_found'); x.code = 'not_found'; x.status = 404; throw x; } Object.assign(e, patch); return e; },
async removeDomain(_s, domain) { guard('removeDomain'); state.domains.delete(domain); return { domain }; },
async domainConfig(_s, domain) { guard('domainConfig'); const cfg = state.configs.get(domain) || {}; return { ok: true, values: { ...cfg, ARIWEB_SSO_SECRET: undefined }, meta: { host: `mail.${domain}`, domain, dns: { records: [{ type: 'MX', name: domain, value: `10 mail.${domain}` }, { type: 'TXT', name: domain, value: 'v=spf1 a mx -all' }, { type: 'TXT', name: `arimail._domainkey.${domain}`, value: 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=quarantine' }] }, outboundStatus: { mode: 'haraka', provider: 'ok' }, outbound: 'haraka', inbound: 'haraka' } }; },
async setDomainConfig(_s, domain, values) { guard('setDomainConfig'); state.configs.set(domain, { ...(state.configs.get(domain) || {}), ...values }); return { ok: true }; },
setDomainLimits(s, d, { mailboxes, storageBytes, monthlyMessages }) { return fake.setDomainConfig(s, d, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
async plan() { guard('plan'); return { steps: [] }; }, async runPlan() { guard('runPlan'); return { ok: true, log: [] }; },
async dnsProbe(_s, domain) { guard('dnsProbe'); return state.dnsProbe || { mx: [`mail.${domain}`], spf: true, dkim: true, dmarc: true }; },
async outboundCheck() { guard('outboundCheck'); return { mode: 'haraka', provider: 'ok' }; },
async listUsers(_s, domain) { guard('listUsers'); const cfg = state.configs.get(domain) || {}; return { ok: true, users: usersOf(domain).filter((u) => u.role !== 'admin' || true), domain, accounts: { used: usersOf(domain).length, limit: Number(cfg.LIMIT_ACCOUNTS || 0) }, services: state.services.get(domain) || { pop: true, imap: true } }; },
async createUser(_s, domain, { login, name, password, quotaBytes, services }) { guard('createUser'); const list = usersOf(domain); if (list.some((u) => u.login === login)) { const e = new Error('login_taken'); e.code = 'login_taken'; e.status = 409; throw e; } const cfg = state.configs.get(domain) || {}; if (Number(cfg.LIMIT_ACCOUNTS || 0) && list.length >= Number(cfg.LIMIT_ACCOUNTS)) { const e = new Error('account_limit_reached'); e.code = 'account_limit_reached'; e.status = 409; throw e; } if (!password || password.length < 6) { const e = new Error('weak_password'); e.code = 'weak_password'; e.status = 422; throw e; } const id = 'u_' + randomBytes(4).toString('hex'); list.push({ id, login, name, mailbox: `${login}@${domain}`, role: 'user', status: 'active', quotaBytes: quotaBytes || 0, services: services || { pop: true, imap: true } }); return { ok: true, id }; },
async updateUser(_s, domain, id, patch) { guard('updateUser'); const u = usersOf(domain).find((x) => x.id === id); if (!u) { const e = new Error('not_found'); e.code = 'not_found'; e.status = 404; throw e; } const { password, ...rest } = patch; Object.assign(u, rest); if (password) u.passwordChanged = (u.passwordChanged || 0) + 1; return { ok: true }; },
async deleteUser(_s, domain, id) { guard('deleteUser'); const list = usersOf(domain); const i = list.findIndex((x) => x.id === id); if (i >= 0) list.splice(i, 1); return { ok: true }; },
async setServices(_s, domain, services) { guard('setServices'); state.services.set(domain, { ...(state.services.get(domain) || { pop: true, imap: true }), ...services }); return { ok: true, services: state.services.get(domain) }; },
async adminConfig(_s, domain) { guard('adminConfig'); return { ok: true, services: state.services.get(domain) || { pop: true, imap: true }, largeFiles: state.largeFiles || { enabled: false, maxBytes: 0, maxDays: 0, maxDownloads: 0 }, systemLargeFiles: { bytes: 1024 ** 3, days: 30, downloads: 20 } }; },
async setLargeFiles(_s, _d, lf) { guard('setLargeFiles'); state.largeFiles = { ...(state.largeFiles || {}), ...lf }; return { ok: true }; },
async adminStatus(_s, domain) { guard('adminStatus'); return { ok: true, host: `mail.${domain}`, tls: true, ports: { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }, services: state.services.get(domain) || { pop: true, imap: true }, mail: { inbound: 'haraka', outbound: 'haraka', status: { provider: 'ok' } } }; },
async rates() { guard('rates'); return { ok: true, rates: { deliveryRate: 5, submissionRate: 10, buckets: [], submissions: [] }, queue: [], abuse: [] }; },
async endSessions() { guard('endSessions'); return { ok: true, passwordReset: true }; },
async roundTrip(_s, domain, { to }) { guard('roundTrip'); return state.roundTrip || { sent: true, received: true, replied: true, marker: 'AWCHK-fake', to }; },
async ssoUrl(_s, domain, mailbox) { guard('ssoUrl'); return `https://mail.${domain}/api/auth/sso?ticket=fake-${encodeURIComponent(mailbox)}`; },
async ensureSso() { guard('ensureSso'); return true; },
programSettings(_s, domain, address) { return { username: address, imap: { host: `mail.${domain}`, ports: { tls: 993, starttls: 143 } }, pop3: { host: `mail.${domain}`, ports: { tls: 995, starttls: 110 } }, smtp: { host: `mail.${domain}`, ports: { tls: 465, starttls: 587 } }, webmail: `https://mail.${domain}` }; },
async overview() { return state.servers.map((s) => ({ ...s, health: { ok: true }, domains: [...state.domains.values()] })); },
secretName: (id, k) => `arimail:${id}:${k}`,
};
return fake;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* portal/mail/model.mjs — 메일 영역 세부 표(§15~§18, §23.2). 중심 표(mail_services·mailboxes·domains)는 portal/core/model.mjs 에 있다.
*
* mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail:<id>:*'.
* mail_tests 실제 외부 송신·수신·회신 확인 기록(활성화·전환 전후·수동). 단계와 결과만, 메일 내용은 없음.
* mail_invites 메일함 초대(초기 비밀번호 대신 링크로 비밀번호를 정함): 토큰 해시·만료·사용 완료·재발송 횟수.
* mail_aliases 별칭·전달 전용 주소 기록(정책 미확정이면 생성 불가, §16.2).
* mail_exports 해지 뒤 30일 읽기·내보내기(.eml 묶음) 요청 기록(§23.2).
* mail_conversions 사이트 대표 메일 → 독립 메일 서비스 전환 점검표와 전후 확인(§14).
* mail_usage_log 서비스·메일함 사용량 계측 이력(화면 "마지막 계측 시각"·추이).
*/
export const MIGRATIONS = [{ id: 'mail_0001', sql: `
CREATE TABLE IF NOT EXISTS mail_servers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, base_url TEXT NOT NULL, internal_url TEXT, setup_url TEXT, mail_host TEXT, is_default INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', last_health_at TEXT, last_health_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mail_tests (
id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, verifier TEXT, job_id TEXT, steps_json TEXT, result_json TEXT,
created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ended_at TEXT
);
CREATE INDEX IF NOT EXISTS mail_tests_service ON mail_tests(mail_service_id, created_at);
CREATE TABLE IF NOT EXISTS mail_invites (
id TEXT PRIMARY KEY, mailbox_id TEXT NOT NULL, group_id TEXT NOT NULL, email TEXT, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, used_at TEXT, resent_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mail_aliases (
id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, address TEXT NOT NULL UNIQUE, target TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'alias', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mail_exports (
id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, status TEXT NOT NULL, location TEXT, size_bytes INTEGER, created_by TEXT, created_at TEXT NOT NULL, expires_at TEXT, ended_at TEXT, error TEXT
);
CREATE TABLE IF NOT EXISTS mail_conversions (
id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, site_id TEXT NOT NULL, status TEXT NOT NULL, checklist_json TEXT NOT NULL, before_test_id TEXT, after_test_id TEXT, job_id TEXT,
created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ended_at TEXT, error TEXT
);
CREATE TABLE IF NOT EXISTS mail_usage_log (
id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, storage_bytes INTEGER, monthly_messages INTEGER, mailboxes INTEGER, warn_level INTEGER, limited_json TEXT, measured_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS mail_usage_log_subject ON mail_usage_log(subject_type, subject_id, measured_at);
` }];
+415
View File
@@ -0,0 +1,415 @@
/**
* 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 || '운영자 문의' }; }
+128
View File
@@ -0,0 +1,128 @@
/* portal/mail/ui.js — 메일 영역 화면 스크립트(서버 틀 + /api/mail/* JSON). 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음을 aw.state 로 보인다. 360px 에서 표는 .r 로 카드처럼 접힌다. */
(() => {
const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw;
const root = document.querySelector('[data-view]'); if (!root) return;
const view = root.dataset.view; const B = (n) => (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : (n || 0) + ' B');
const N = (n) => Number(n || 0).toLocaleString('ko-KR');
const bar = (used, limit, level) => `<div class="bar w${level || 0}"><i style="width:${limit ? Math.min(100, (used / limit) * 100) : 0}%"></i></div>`;
const badge = (kind, label) => `<span class="badge ${kind}">${{ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' }[kind] || '○'} ${esc(label)}</span>`;
const stBadge = (s) => badge(s === 'active' ? 'ok' : ['cancelled', 'deleted'].includes(s) ? 'failed' : s === 'activating' ? 'running' : 'warning', { active: '운영 중', activating: '활성화 중', needs_setup: '설정 필요', cancelled: '읽기 전용 보존', deleted: '삭제 완료', suspended: '일시 중지' }[s] || s);
const lvlBadge = (l) => (l >= 100 ? badge('failed', '100% 제한') : l >= 90 ? badge('failed', l + '% 경고') : l >= 70 ? badge('warning', l + '% 경고') : badge('ok', '정상'));
const fail = (el, e) => state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' ? 'stale' : 'failed', e.message, { retry: () => location.reload() });
const copy = (v) => `<span class="copy mono" data-action="copy" data-copy="${esc(v)}" title="복사">${esc(v)}</span>`;
const jobActions = async (a, j, id) => { try { await api('POST', `/api/mail/services/${id}/job/${a}`); toast('요청했습니다'); } catch (e) { toast(e.message); } };
// ---------------------------------------------------------------- 목록(§15.2)
if (view === 'list') (async () => {
try { const d = await api('GET', '/api/mail/services'); if (!d.services.length) return state(root, 'empty', '메일 서비스가 없습니다. 사이트 대표 메일 또는 독립 메일 서비스를 만드세요', { href: '/mail/new', hrefLabel: '메일 서비스 만들기' });
root.innerHTML = `<table class="r"><thead><tr><th>서비스</th><th>종류 · 사이트</th><th>대표 메일 도메인</th><th>상태</th><th>메일함</th><th>저장 공간</th><th>송수신</th><th>DNS 보안</th><th>최근 경고</th></tr></thead><tbody>${d.services.map((s) => `<tr><td data-l="서비스"><a href="/mail/${s.id}">${esc(s.name)}</a></td><td data-l="종류">${esc(s.kindLabel)}${s.site ? `<div class="sub"><a href="/sites/${s.site.id}">${esc(s.site.name)}</a></div>` : ''}</td><td data-l="도메인" class="mono">${esc(s.domain)}</td><td data-l="상태">${stBadge(s.status)}${s.prepError ? ` <span class="badge warning">! 서버 준비 실패</span>` : ''}</td><td data-l="메일함">${s.mailboxes.count} / ${s.mailboxes.allocated}</td><td data-l="저장 공간">${B(s.storage.used)} / ${B(s.storage.allocated)}${s.stale ? ' <span class="sub">(정보 지연)</span>' : ''}</td><td data-l="송수신">${s.send_state === 'ok' && s.receive_state === 'ok' ? badge('ok', '정상') : s.send_state === 'stopped' ? badge('failed', '중지') : s.send_state === 'stale' ? badge('warning', '정보 확인 지연') : badge('warning', '확인 전')}</td><td data-l="DNS">${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '오류') : badge('warning', '확인 필요')}</td><td data-l="경고">${lvlBadge(s.warnLevel)}</td></tr>`).join('')}</tbody></table><p class="sub">${esc(d.notes.retention)}</p>`;
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 생성(§15.3)
if (view === 'new') (async () => {
try { const o = await api('GET', '/api/mail/options'); const q = o.quota;
root.innerHTML = `<form id="f" class="card" data-dirty-guard>
<h2>1. 종류</h2><div class="f"><label><input type="radio" name="kind" value="site_bound" checked> 사이트 대표 메일 — 사이트 하나에 대표 메일 도메인 하나. 운영 사이트 수를 더 쓰지 않습니다</label><label><input type="radio" name="kind" value="standalone"> 독립 메일 서비스 — 사이트 없이 메일만. 독립 메일 옵션 1개를 씁니다(사용 가능 ${q.mail_services.unallocated}개, 구매 ${q.mail_services.purchased}개)</label></div>
<div class="f" id="site-f"><label for="siteId">2. 사이트</label><select id="siteId" name="siteId">${o.sites.map((s) => `<option value="${esc(s.id)}" ${s.hasPrimaryMail ? 'disabled' : ''}>${esc(s.name)}${s.hasPrimaryMail ? ' (이미 대표 메일 있음)' : ''}</option>`).join('') || '<option value="">사이트가 없습니다</option>'}</select><span class="hint">사이트마다 대표 메일 도메인은 하나입니다</span></div>
<div class="f"><label for="domain">3. 도메인</label><select id="domain" name="domain">${o.domains.map((d) => `<option value="${esc(d.name)}" data-site="${esc(d.site_id || '')}" ${d.inUse ? 'disabled' : ''}>${esc(d.name)}${d.inUse ? ' (다른 메일 서비스 사용 중)' : d.ownership_state !== 'verified' ? ' (소유 확인 전)' : ''}</option>`).join('') || '<option value="">등록된 도메인이 없습니다 — 먼저 도메인 화면에서 등록하세요</option>'}</select><span class="hint">도메인 화면에서 소유를 확인한 도메인만 메일에 쓸 수 있습니다. <a href="/domains">도메인으로 이동</a></span></div>
<div class="f"><label for="name">서비스 이름(선택)</label><input id="name" name="name" maxlength="80"></div>
<h2>4. 보장량 배정</h2><p class="hint">구매 총량 안에서 이 서비스에 배정합니다. 다른 서비스 배정·진행 중 예약을 뺀 미배정량까지만 가능합니다.</p>
<div class="f"><label for="mailboxes">메일함 수 (미배정 ${N(q.mailboxes.unallocated)} / 구매 ${N(q.mailboxes.purchased)})</label><input id="mailboxes" name="mailboxes" type="number" min="1" max="${q.mailboxes.unallocated}" value="${Math.min(1, q.mailboxes.unallocated)}" required></div>
<div class="f"><label for="storage">저장 공간 GB (미배정 ${B(q.mail_storage.unallocated)} / 구매 ${B(q.mail_storage.purchased)})</label><input id="storage" name="storage" type="number" min="0" step="0.1" max="${(q.mail_storage.unallocated / 1024 ** 3).toFixed(1)}" value="${Math.min(1, q.mail_storage.unallocated / 1024 ** 3).toFixed(1)}"></div>
<div class="f"><label for="monthly">월간 송신·수신 통수 (미배정 ${N(q.mail_msgs.unallocated)} / 구매 ${N(q.mail_msgs.purchased)})</label><input id="monthly" name="monthly" type="number" min="0" max="${q.mail_msgs.unallocated}" value="${Math.min(1000, q.mail_msgs.unallocated)}"><span class="hint">송신과 수신의 합계, 최종 수신자 한 명당 한 통</span></div>
${o.servers.length > 1 ? `<div class="f"><label for="serverId">메일 서버</label><select id="serverId" name="serverId">${o.servers.map((s) => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}</select></div>` : ''}
<p class="hint">만든 뒤 5) DNS 값 확인 → 6) 도메인·DNS 확인 → 7) 서비스 준비 → 8) 실제 외부 송수신 확인 → 9) 활성화 순서로 진행합니다. 결제·승인 전에는 활성화되지 않습니다. 메일 백업·복원 기능은 제공되지 않습니다.</p>
<div class="actions"><button class="btn primary">만들기</button></div><div id="err" class="err"></div></form>`;
const f = root.querySelector('#f'); const kindOf = () => f.querySelector('input[name=kind]:checked').value; const sync = () => { f.querySelector('#site-f').style.display = kindOf() === 'site_bound' ? '' : 'none'; }; f.querySelectorAll('input[name=kind]').forEach((r) => (r.onchange = sync)); sync();
f.onsubmit = async (e) => { e.preventDefault(); const b = { kind: kindOf(), siteId: kindOf() === 'site_bound' ? f.siteId.value : null, domain: f.domain.value, name: f.name.value, alloc: { mailboxes: Number(f.mailboxes.value), storageBytes: Math.round(Number(f.storage.value) * 1024 ** 3), monthlyMessages: Number(f.monthly.value) }, serverId: f.serverId ? f.serverId.value : null }; try { const r = await api('POST', '/api/mail/services', b); window.aw.markClean(); location.href = `/mail/${r.service.id}`; } catch (err) { f.querySelector('#err').innerHTML = esc(err.message) + (err.detail?.link ? ` <a href="${esc(err.detail.link)}">이동</a>` : ''); } };
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 상세
if (view === 'detail') (async () => {
const id = root.dataset.id, tab = root.dataset.tab;
document.querySelector('[data-action=webmail]')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/webmail`, {}); toast(r.note); window.open(r.url, '_blank'); } catch (e) { toast(e.message); } });
try {
const d = await api('GET', `/api/mail/services/${id}`); const s = d.service, f = d.figures;
if (tab === 'overview') {
const dnsRows = d.records && !d.records.error ? d.records.records.map((r) => { const st = (d.dns?.items || []).find((i) => i.name === r.name && i.type === r.type); return `<tr><td data-l="종류">${esc(r.type)}</td><td data-l="이름">${copy(r.name)}</td><td data-l="값">${copy(r.value)}</td><td data-l="상태">${!st ? badge('neutral', '확인 전') : st.state === 'ok' ? badge('ok', '반영됨') : st.state === 'wrong' ? badge('failed', '잘못된 값') : st.state === 'missing' ? badge('warning', '미반영') : st.state === 'recommended' ? badge('warning', '권장') : badge('neutral', st.state)}</td></tr>`; }).join('') : `<tr><td colspan="4">${d.records?.error === 'service_unreachable' ? '메일 서버에 연결할 수 없어 DNS 값을 가져오지 못했습니다(정보 확인 지연)' : esc(d.records?.error || '먼저 메일 서버 준비가 필요합니다')}</td></tr>`;
root.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>서비스</dt><dd>${stBadge(s.status)}</dd><dt>종류</dt><dd>${esc(s.kindLabel)}${s.site ? ` · <a href="/sites/${s.site.id}">${esc(s.site.name)}</a>` : ''}</dd><dt>송신 / 수신</dt><dd>${esc({ ok: '정상', unknown: '확인 전', unverified: '검증 주소 미설정', error: '오류', stale: '정보 확인 지연', stopped: '중지' }[s.send_state] || s.send_state)} / ${esc({ ok: '정상', unknown: '확인 전', unverified: '검증 주소 미설정', error: '오류', stale: '정보 확인 지연', stopped: '중지' }[s.receive_state] || s.receive_state)}</dd><dt>DNS</dt><dd>${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '잘못된 값') : badge('warning', '확인 필요')}${d.dns ? ` <span class="sub">마지막 확인 ${esc(fmt(d.dns.checkedAt))}</span>` : ''}</dd><dt>메일함</dt><dd>${f.mailboxes.count} / ${f.mailboxes.allocated}</dd><dt>경고</dt><dd>${lvlBadge(f.warnLevel)}</dd>${s.prepError ? `<dt>서버 준비</dt><dd class="err">${s.prepError === 'service_unreachable' ? '메일 서버에 연결할 수 없습니다(정보 확인 지연)' : esc(s.prepError)} <button class="btn" data-x="prepare">다시 준비</button></dd>` : ''}</dl></div>
<div class="card"><h2>작업</h2><div id="job"></div><div class="actions">${s.status !== 'active' && s.status !== 'cancelled' && d.canManage ? '<button class="btn primary" data-x="activate">활성화 시작</button>' : ''}${d.canManage ? '<button class="btn" data-x="dns">DNS 확인</button><button class="btn" data-x="verify">외부 송수신 확인</button>' : ''}</div><div id="test">${d.lastTest ? `<p class="sub">마지막 외부 확인: ${esc({ ok: '정상', failed: '실패', needs_setup: '검증 주소 미설정', running: '진행 중', stale: '정보 확인 지연' }[d.lastTest.status] || d.lastTest.status)} (${esc(fmt(d.lastTest.ended_at || d.lastTest.created_at))})</p><ul class="steps">${d.lastTest.steps.map((x) => `<li class="${esc(x.state === 'needs_setup' ? 'needs_user' : x.state)}"><span class="s">${x.state === 'done' ? '✔' : x.state === 'failed' ? '✘' : '!'}</span><span>${esc({ outbound_ready: '발신 서비스 준비', send: '외부로 송신', receive: '외부 회신 수신', reply: '회신 확인', round_trip: '왕복 확인', error: '오류' }[x.id] || x.id)}${x.detail ? ` <span class="sub">— ${esc(x.detail)}</span>` : ''}</span></li>`).join('')}</ul>` : '<p class="sub">아직 외부 송수신을 확인하지 않았습니다</p>'}</div></div></div>
<div class="card"><h2>DNS 값 (복사해서 DNS 관리 화면에 넣으세요)</h2><table class="r"><thead><tr><th>종류</th><th>이름</th><th>값</th><th>상태</th></tr></thead><tbody>${dnsRows}</tbody></table></div>
<div class="card"><h2>메일 프로그램 설정</h2>${d.program ? `<dl class="kv"><dt>IMAP</dt><dd>${esc(d.program.imap.host)} · ${d.program.imap.ports.tls}(TLS) / ${d.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(d.program.pop3.host)} · ${d.program.pop3.ports.tls}(TLS) / ${d.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(d.program.smtp.host)} · ${d.program.smtp.ports.tls}(TLS) / ${d.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>메일 주소 전체(예: ${esc(d.program.username)})</dd><dt>비밀번호</dt><dd>메일함 비밀번호(여기서 다시 보여 주지 않습니다)</dd></dl>` : '<p class="sub">메일 서버가 준비되면 표시됩니다</p>'}</div>`;
const renderJob = async () => { try { const r = await api('GET', `/api/mail/services/${id}/job`); if (r.job) { job('#job', r.job, { onAction: (a, j) => jobActions(a, j, id).then(renderJob) }); return !r.job.terminal; } document.getElementById('job').innerHTML = '<p class="sub">진행 중인 작업 없음</p>'; return false; } catch (_) { return false; } };
poll(renderJob, 4000);
root.querySelectorAll('[data-x]').forEach((b) => (b.onclick = async () => { b.disabled = true; try { const x = b.dataset.x; if (x === 'activate') { await api('POST', `/api/mail/services/${id}/activate`); toast('활성화 작업을 시작했습니다'); renderJob(); } if (x === 'dns') { const r = await api('POST', `/api/mail/services/${id}/dns-check`); toast(r.result.ok ? 'DNS 반영 확인 완료' : `미반영 ${r.result.summary.missing} · 잘못된 값 ${r.result.summary.wrong}`); location.reload(); } if (x === 'verify') { toast('외부 송수신 확인 중…(최대 90초)'); const r = await api('POST', `/api/mail/services/${id}/verify`); toast(r.test.status === 'ok' ? '외부 송수신 정상' : r.test.status === 'needs_setup' ? '외부 검증 주소가 설정되지 않았습니다' : '외부 송수신 확인 실패'); location.reload(); } if (x === 'prepare') { await api('POST', `/api/mail/services/${id}/prepare`); location.reload(); } } catch (e) { toast(e.message); b.disabled = false; } }));
}
if (tab === 'mailboxes') { const m = await api('GET', `/api/mail/services/${id}/mailboxes`); const c = m.capacity;
root.innerHTML = `<p class="sub">남은 메일함 ${c.mailboxes.remaining}개(배정 ${c.mailboxes.allocated}) · 남은 저장 공간 ${B(c.storage.remaining)}(배정 ${B(c.storage.allocated)}) · ${esc(m.notes.suspendedCounts)}</p>${m.mailboxes.length ? `<table class="r"><thead><tr><th>주소</th><th>표시 이름</th><th>상태</th><th>공간</th><th>마지막 로그인</th><th>자동 응답·전달</th><th>비밀번호</th></tr></thead><tbody>${m.mailboxes.map((b) => `<tr><td data-l="주소"><a href="/mail/${id}/mailboxes/${b.id}">${esc(b.address)}</a></td><td data-l="이름">${esc(b.displayName || '')}</td><td data-l="상태">${b.status === 'active' ? badge('ok', '활성') : b.status === 'suspended' ? badge('warning', '정지') : badge('failed', b.statusLabel)}</td><td data-l="공간">${b.usage ? `${B(b.usage.bytes)} / ${b.usage.limit ? B(b.usage.limit) : '제한 없음'} ${bar(b.usage.bytes, b.usage.limit, b.level)}` : `${b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} <span class="sub">(계측 전)</span>`}</td><td data-l="로그인">${esc(fmt(b.lastLoginAt) || '없음')}</td><td data-l="설정"><span class="sub">웹메일 설정에서 관리</span></td><td data-l="비밀번호">${b.inviteState === 'pending' ? badge('waiting', '초대 대기') : b.mustChangePassword ? badge('warning', '변경 필요') : badge('ok', '설정됨')}</td></tr>`).join('')}</tbody></table>` : state(document.createElement('div'), 'empty') || `<div class="aw-state empty"><b>비어 있음</b><p>메일함이 없습니다</p><a class="btn" href="/mail/${id}/mailboxes/new">메일함 만들기</a></div>`}`;
}
if (tab === 'usage') { const u = await api('GET', `/api/mail/services/${id}/usage`); const lu = u.lastUsage; const g = u.figures;
const row = (label, x, unit, extra = '') => `<div class="card"><h2>${label} ${lvlBadge(x.level)}</h2><div class="stat">${unit === 'B' ? B(x.used) : N(x.used)} <span class="sub">/ ${unit === 'B' ? B(x.allocated) : N(x.allocated)} (${x.percent}%)</span></div>${bar(x.used, x.allocated, x.level)}<p class="sub">${extra}</p></div>`;
root.innerHTML = `${u.usageError ? `<div class="aw-state stale"><b>정보 확인 지연</b><p>메일 서버 사용량을 읽지 못했습니다(${esc(u.usageError)}). 마지막 계측값을 보여 줍니다. 오래된 정보로 신규 증가를 허용하지 않습니다</p></div>` : ''}<div class="cards">${row('메일 저장 공간', g.storage, 'B', esc(u.notes.limit100.storage))}${row('월간 송신·수신 통수', g.monthly, 'n', `${esc(u.notes.counting)} ${lu ? `송신 ${N(lu.sent)} · 수신 ${N(lu.received)}.` : ''} 다음 KST 월 초기화 ${esc(fmt(u.nextReset))}. ${esc(u.notes.limit100.monthly)}`)}${row('메일함 수', { ...g.mailboxes, used: g.mailboxes.count }, 'n', esc(u.notes.limit100.mailboxes))}</div>
<div class="card"><h2>발송 제한</h2><p>${esc(u.notes.sendLimit)}</p>${u.sendLimits.stale ? '<p class="sub">현재 제한 상태: 정보 확인 지연</p>' : u.sendLimits.throttled.length ? `<ul>${u.sendLimits.throttled.map((x) => `<li>${esc(x.key)} — 초당 ${x.rate}건 제출로 제한, 해제 예상 ${esc(fmt(x.until))}</li>`).join('')}</ul>` : '<p class="sub">현재 제한 없음</p>'}<p class="sub">문의: ${esc(u.sendLimits.contact)}</p></div>
<div class="card"><h2>대용량 첨부</h2><p>${u.policy.largeAttachment.deferred ? '허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정합니다(임의로 고정하지 않음). 시스템 안전 상한과 악용 방지 제한은 상품보다 우선합니다' : `허용 크기 ${B(u.policy.largeAttachment.maxBytes)} · 다운로드 ${u.policy.largeAttachment.maxDownloads}회 · 보관 ${u.policy.largeAttachment.maxDays}일`}. 대용량 첨부는 별도 옵션이며 저장된 파일은 메일 저장 공간을 소비합니다(업로드 전 예상 사용량 표시).</p><p class="sub">일반 첨부 최대 크기·메일당 최대 최종 수신자 수: ${u.policy.attachment.deferred ? '후속 상품정책(시스템 상한 25MB·50명)' : `${B(u.policy.attachment.maxBytes)} · ${u.policy.attachment.maxRecipients}명`}</p></div>
<div class="card"><h2>메일함별 사용량</h2><table class="r"><thead><tr><th>주소</th><th>사용</th><th>한도</th><th>경고</th></tr></thead><tbody>${u.mailboxes.map((b) => `<tr><td data-l="주소">${esc(b.address)}</td><td data-l="사용">${b.usage ? B(b.usage.bytes) : '계측 전'}</td><td data-l="한도">${b.usage?.limit ? B(b.usage.limit) : b.quotaBytes ? B(b.quotaBytes) : '제한 없음'}</td><td data-l="경고">${lvlBadge(b.level)}</td></tr>`).join('') || '<tr><td colspan="4">메일함 없음</td></tr>'}</tbody></table></div>
<p class="sub">적용 정책: ${esc(u.policy.note)} · 마지막 계측 ${esc(fmt(u.measuredAt) || '없음')}${u.stale ? ' (정보 확인 지연)' : ''} · <button class="btn" id="refresh">지금 계측</button> · ${esc(u.notes.noBackup)}</p>`;
root.querySelector('#refresh').onclick = async () => { try { await api('GET', `/api/mail/services/${id}/usage?refresh=1`); location.reload(); } catch (e) { toast(e.message); } };
}
if (tab === 'security') { const r = (await api('GET', `/api/mail/services/${id}/security`)).report;
root.innerHTML = `${r.stale ? '<div class="aw-state stale"><b>정보 확인 지연</b><p>DNS 조회에 실패해 마지막 결과를 보여 줍니다</p></div>' : ''}<div class="card"><h2>도메인 보안 점검 <span class="sub">${esc(fmt(r.checkedAt) || '')}</span></h2><table class="r"><thead><tr><th>항목</th><th>상태</th><th>설명</th></tr></thead><tbody>${r.items.map((i) => `<tr><td data-l="항목">${esc(i.label)}</td><td data-l="상태">${i.state === 'ok' ? badge('ok', '정상') : i.state === 'recommended' ? badge('warning', '권장 개선') : i.state === 'stale' ? badge('warning', '정보 확인 지연') : badge('failed', '오류')}</td><td data-l="설명">${esc(i.text)}</td></tr>`).join('')}</tbody></table></div><div class="card"><h2>필요한 DNS 값</h2><table class="r"><thead><tr><th>종류</th><th>이름</th><th>필요한 값</th><th>현재 값</th><th>상태</th></tr></thead><tbody>${r.records.map((x) => `<tr><td data-l="종류">${esc(x.type)}</td><td data-l="이름">${copy(x.name)}</td><td data-l="필요한 값">${copy(x.value)}</td><td data-l="현재 값" class="mono">${esc((x.found || []).join(', ') || '없음')}</td><td data-l="상태">${x.state === 'ok' ? badge('ok', '정상') : x.state === 'wrong' ? badge('failed', '값이 다름') : x.state === 'missing' ? badge('warning', '미반영') : badge('neutral', x.state)}</td></tr>`).join('')}</tbody></table></div>`;
}
if (tab === 'settings') { const st = await api('GET', `/api/mail/services/${id}/settings`);
root.innerHTML = `<div class="card"><h2>POP3 · IMAP</h2>${st.protocols.stale ? '<p class="sub">메일 서버 상태: 정보 확인 지연</p>' : ''}<p>${esc(st.notes.protocolImpact)}</p><label><input type="checkbox" id="pop" ${st.protocols.pop ? 'checked' : ''}> POP3 사용</label><br><label><input type="checkbox" id="imap" ${st.protocols.imap ? 'checked' : ''}> IMAP 사용</label><div class="actions"><button class="btn primary" id="save-p">적용 후 접속 확인</button></div><div id="pres"></div></div>
<div class="card"><h2>대용량 첨부(링크 방식)</h2><p>${st.policy.largeAttachment.deferred ? '구체 수치는 후속 상품정책에서 정합니다. 시스템 안전 상한: ' + (st.systemLargeFiles ? `${B(st.systemLargeFiles.bytes)} · ${st.systemLargeFiles.days}일 · ${st.systemLargeFiles.downloads}회` : '확인 전') : `허용 ${B(st.policy.largeAttachment.maxBytes)} · ${st.policy.largeAttachment.maxDownloads}회 · ${st.policy.largeAttachment.maxDays}일`}</p><p class="sub">현재 메일 서버 설정: ${st.largeFiles ? (st.largeFiles.enabled ? `사용 · 최대 ${B(st.largeFiles.maxBytes)} · ${st.largeFiles.maxDays}일 · ${st.largeFiles.maxDownloads}회` : '사용 안 함') : '정보 확인 지연'}</p></div>
<div class="card"><h2>별칭·전달 전용 주소</h2><p class="sub">계산 방식은 최신 상품정책을 따릅니다(${st.policy.aliases.deferred ? esc(st.notes.policyDeferred) + ' — 미확정이면 무제한 제공하지 않습니다' : `메일함당 ${st.policy.aliases.perMailbox}개`}). 생성은 이번 버전에서 지원하지 않으며, 전달은 각 메일함의 웹메일 설정을 쓰세요.</p></div>
<div class="card"><h2>해지</h2><p class="sub">해지하면 송수신이 즉시 중지되고 30일 동안 읽기·내보내기만 가능하며 그 뒤 삭제됩니다. 메일 백업·복원 기능은 제공되지 않습니다.</p><button class="btn danger" id="cancel-svc">메일 서비스 해지</button></div>`;
root.querySelector('#save-p').onclick = async () => { const pop = root.querySelector('#pop').checked, imap = root.querySelector('#imap').checked; if (!(await confirmDanger({ title: 'POP3 · IMAP 설정 변경', target: s.domain, impacts: [st.notes.protocolImpact, `POP3 ${pop ? '사용' : '중지'} · IMAP ${imap ? '사용' : '중지'}`], confirmLabel: '적용' }))) return; try { const r = await api('POST', `/api/mail/services/${id}/protocols`, { pop, imap, confirmed: true }); root.querySelector('#pres').innerHTML = `<p>적용됨 — 접속 확인: ${r.check.summary === 'ok' ? badge('ok', '모든 포트 응답') : r.check.summary === 'stale' ? badge('warning', '정보 확인 지연') : badge('warning', '일부 포트 응답 없음')} ${Object.entries(r.check.ports).map(([k, v]) => `${k}:${v.port} ${v.reachable === null ? '?' : v.reachable ? '✔' : '✘'}`).join(' · ')}</p>`; } catch (e) { toast(e.message); } };
root.querySelector('#cancel-svc').onclick = async () => { if (!(await confirmDanger({ title: '메일 서비스 해지', target: s.domain, impacts: ['송수신 즉시 중지', '30일 읽기·내보내기 뒤 삭제(되돌릴 수 없음)', '배정 용량은 삭제 때 반환', '메일 백업·복원 미제공'], typeToConfirm: s.domain, confirmLabel: '해지' }))) return; try { await api('POST', `/api/mail/services/${id}/cancel`, { confirm: s.domain, acknowledgeSite: true }); location.href = `/mail/${id}?tab=lifecycle`; } catch (e) { toast(e.message); } };
}
if (tab === 'convert') { const c = await api('GET', `/api/mail/services/${id}/convert`);
root.innerHTML = `<div class="card"><h2>사이트 대표 메일 → 독립 메일 서비스 전환</h2><p class="sub">전환하면 사이트와 분리되어 사이트 수 반환(§14)이 가능해집니다. 다음 항목이 항목별로 유지되는지 확인합니다.</p><table class="r"><thead><tr><th>항목</th><th>유지</th><th>내용</th><th>확인</th></tr></thead><tbody>${c.checklist.items.map((i) => `<tr><td data-l="항목">${esc(i.label)}</td><td data-l="유지">${i.keep === true ? '유지' : i.keep === false ? '종료' : '조건'}</td><td data-l="내용">${esc(i.detail)}${i.link ? ` <a href="${esc(i.link)}">이동</a>` : ''}</td><td data-l="확인">${i.ok ? badge('ok', '충족') : badge('failed', i.note || '미충족')}</td></tr>`).join('')}</tbody></table><p class="sub">전환 전후에 실제 송수신을 확인하며 누락이 있으면 완료하지 않고 사이트 수를 반환하지 않습니다.</p><div class="actions"><button class="btn primary" id="go" ${c.checklist.canConvert ? '' : 'disabled'}>전환 시작</button></div><div id="job"></div></div>`;
root.querySelector('#go').onclick = async () => { if (!(await confirmDanger({ title: '독립 메일 서비스로 전환', target: s.domain, impacts: ['독립 메일 옵션 1개 소비', '사이트와 분리(사이트 해지 뒤에도 메일 유지)', '주소·메일함·별칭·전달·비밀번호·DNS 유지'], typeToConfirm: s.domain, confirmLabel: '전환' }))) return; try { await api('POST', `/api/mail/services/${id}/convert`, { confirm: s.domain }); toast('전환 작업을 시작했습니다'); } catch (e) { toast(e.message); } };
poll(async () => { const r = await api('GET', `/api/mail/services/${id}/job`); if (r.job && r.job.kind === 'mail.convert') { job('#job', r.job, { onAction: (a, j) => jobActions(a, j, id) }); return !r.job.terminal; } return true; }, 4000);
}
if (tab === 'lifecycle') { const l = d.lifecycle;
root.innerHTML = `<div class="card"><h2>생명주기 — ${esc(l.label)}</h2><dl class="kv"><dt>공개 서비스</dt><dd>${esc(l.publicService)}</dd><dt>관리자 접근</dt><dd>${esc(l.adminAccess)}</dd><dt>권리 소비</dt><dd>${esc(l.consumes)}</dd><dt>청구</dt><dd>${esc(l.billing)}</dd><dt>도메인·메일</dt><dd>${esc(l.domainMail)}</dd><dt>백업</dt><dd>${esc(l.backup)}</dd><dt>내보내기</dt><dd>${esc(l.exportable)}</dd><dt>재개</dt><dd>${esc(l.resumable)}</dd><dt>되돌릴 수 없는 시점</dt><dd>${esc(fmt(l.irreversibleAt) || '해당 없음')}</dd><dt>보존 종료</dt><dd>${esc(fmt(l.retentionUntil) || '해당 없음')}</dd><dt>삭제 예정일</dt><dd>${esc(fmt(l.deleteAfter) || '해당 없음')}</dd><dt>다음 알림</dt><dd>${esc(fmt(l.nextNotice) || '해당 없음')}</dd></dl>${s.status === 'cancelled' ? '<div class="actions"><button class="btn" id="exp">내보내기 안내</button></div><div id="expr"></div>' : ''}</div>`;
root.querySelector('#exp')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/export`, {}); root.querySelector('#expr').innerHTML = `<ul>${r.export.guidance.map((g) => `<li>${esc(g)}</li>`).join('')}</ul><p class="sub">${esc(fmt(r.export.until))} 까지</p>`; } catch (e) { toast(e.message); } });
}
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 메일함 만들기(§16.1)
if (view === 'mailbox-new') (async () => {
const id = root.dataset.id, domain = root.dataset.domain;
try { const m = await api('GET', `/api/mail/services/${id}/mailboxes`); const c = m.capacity;
if (c.mailboxes.remaining <= 0) return state(root, 'failed', `메일함 수가 한도(${c.mailboxes.allocated}개)에 찼습니다. 사용량 화면에서 배정을 늘리거나 상품을 추가하세요`, { href: '/usage', hrefLabel: '사용량으로' });
root.innerHTML = `<form id="f" class="card" data-dirty-guard><p class="sub">남은 메일함 ${c.mailboxes.remaining}개 · 남은 저장 공간 ${B(c.storage.remaining)}</p><div class="f"><label for="local">메일 주소</label><div style="display:flex;gap:6px;align-items:center"><input id="local" required pattern="[a-z0-9][a-z0-9._-]{0,63}" style="max-width:240px"><span>@${esc(domain)}</span></div><span class="hint">영문 소문자·숫자로 시작, 1~64자(영문, 숫자, . _ -)</span></div><div class="f"><label for="dn">표시 이름</label><input id="dn" maxlength="80"></div><div class="f"><label for="q">저장 공간 (MB, 0 = 서비스 배정 안에서 제한 없음)</label><input id="q" type="number" min="0" value="0" max="${Math.floor(c.storage.remaining / 1024 ** 2)}"></div><div class="f"><label>비밀번호 방식</label><label><input type="radio" name="mode" value="password" checked> 초기 비밀번호(한 번만 표시, 첫 로그인에서 변경)</label><label><input type="radio" name="mode" value="invite"> 초대 링크(사용자가 비밀번호를 정함, 3일 유효)</label></div><div class="f" id="inv" style="display:none"><label for="ie">초대 보낼 이메일(선택)</label><input id="ie" type="email"></div><p class="hint">웹메일·POP3·IMAP 접속 안내는 만든 뒤 표시됩니다. ${esc(m.notes.suspendedCounts)}</p><div class="actions"><button class="btn primary">만들기</button></div><div id="err" class="err"></div></form><div id="res"></div>`;
const f = root.querySelector('#f'); f.querySelectorAll('input[name=mode]').forEach((r) => (r.onchange = () => { f.querySelector('#inv').style.display = f.querySelector('input[name=mode]:checked').value === 'invite' ? '' : 'none'; }));
f.onsubmit = async (e) => { e.preventDefault(); try { const r = await api('POST', `/api/mail/services/${id}/mailboxes`, { local: f.querySelector('#local').value.trim().toLowerCase(), displayName: f.querySelector('#dn').value, quotaBytes: Number(f.querySelector('#q').value) * 1024 ** 2, mode: f.querySelector('input[name=mode]:checked').value, inviteEmail: f.querySelector('#ie').value }); window.aw.markClean(); f.remove();
root.querySelector('#res').innerHTML = `<div class="card"><h2>만들었습니다: ${esc(r.mailbox.address)}</h2>${r.initialPassword ? `<p><b>초기 비밀번호:</b> ${copy(r.initialPassword)}</p><p class="err">${esc(m.notes.initialPasswordOnce)}</p>` : ''}${r.invite ? `<p><b>초대 링크:</b> ${copy(r.invite.url)}</p><p class="sub">만료 ${esc(fmt(r.invite.expiresAt))}${r.invite.email ? ` · ${esc(r.invite.email)} 로 보냈습니다` : ''}</p>` : ''}<dl class="kv"><dt>웹메일</dt><dd><a href="${esc(r.program.webmail)}" target="_blank">${esc(r.program.webmail)}</a></dd><dt>IMAP</dt><dd>${esc(r.program.imap.host)} ${r.program.imap.ports.tls}(TLS)/${r.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(r.program.pop3.host)} ${r.program.pop3.ports.tls}(TLS)/${r.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(r.program.smtp.host)} ${r.program.smtp.ports.tls}(TLS)/${r.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>${esc(r.mailbox.address)}</dd></dl><a class="btn" href="/mail/${id}?tab=mailboxes">메일함 목록으로</a></div>`; } catch (err) { f.querySelector('#err').innerHTML = esc(err.message) + (err.detail?.link ? ` <a href="${esc(err.detail.link)}">이동</a>` : ''); } };
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 메일함 설정(§16.2)
if (view === 'mailbox') (async () => {
const id = root.dataset.id, mid = root.dataset.mid;
document.querySelector('[data-action=webmail-box]')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/webmail`, { mailboxId: mid }); toast(r.note); window.open(r.url, '_blank'); } catch (e) { toast(e.message); } });
try { const d = await api('GET', `/api/mail/services/${id}/mailboxes/${mid}`); const b = d.mailbox; const ro = !d.canManage;
root.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>상태</dt><dd>${b.status === 'active' ? badge('ok', '활성') : b.status === 'suspended' ? badge('warning', '정지') : badge('failed', b.statusLabel)}</dd><dt>공간</dt><dd>${b.usage ? `${B(b.usage.bytes)} / ${b.usage.limit ? B(b.usage.limit) : '제한 없음'}` : `${b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} (계측 전)`} ${lvlBadge(b.level)}</dd><dt>마지막 로그인</dt><dd>${esc(fmt(b.lastLoginAt) || '없음')}</dd><dt>비밀번호</dt><dd>${b.mustChangePassword ? '변경 필요(초기·재설정 비밀번호 사용 중)' : '설정됨'}</dd><dt>초대</dt><dd>${d.invite ? `${{ pending: '대기(만료 ' + fmt(d.invite.expires_at) + ')', used: '사용 완료', expired: '만료' }[d.invite.state]}${d.invite.resent_count ? ` · 재발송 ${d.invite.resent_count}회` : ''}` : '없음'}</dd><dt>자동 응답·전달</dt><dd>웹메일 설정에서 관리(기존 AriMail 기능)</dd></dl></div>
<div class="card"><h2>메일 프로그램 설정</h2><dl class="kv"><dt>IMAP</dt><dd>${esc(d.program.imap.host)} ${d.program.imap.ports.tls}(TLS)/${d.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(d.program.pop3.host)} ${d.program.pop3.ports.tls}(TLS)/${d.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(d.program.smtp.host)} ${d.program.smtp.ports.tls}(TLS)/${d.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>${esc(b.address)}</dd><dt>비밀번호</dt><dd>다시 표시하지 않습니다</dd></dl></div></div>
${ro ? '<div class="aw-state forbidden"><b>권한 없음</b><p>설정 변경은 그룹 관리자 또는 사이트 "도메인과 메일 설정" 권한이 필요합니다</p></div>' : `<form id="f" class="card" data-dirty-guard><h2>설정</h2><div class="f"><label for="dn">표시 이름</label><input id="dn" value="${esc(b.displayName || '')}" maxlength="80"></div><div class="f"><label for="q">저장 공간 (MB, 0 = 제한 없음, 서비스 배정 ${B(d.capacity.storage.allocated)} 안)</label><input id="q" type="number" min="0" value="${Math.round((b.quotaBytes || 0) / 1024 ** 2)}"></div><div class="actions"><button class="btn primary">저장</button></div></form>
<div class="card"><h2>별칭·전달 전용 주소</h2><p class="sub">${d.policy.aliases.deferred ? '계산 방식은 최신 상품정책(미확정 — ' + esc(d.notes.policyDeferred) + ')에서 정하며 무제한으로 제공하지 않습니다.' : `정책: 메일함당 ${d.policy.aliases.perMailbox}개`} 이번 버전에서는 생성을 지원하지 않습니다. 전달은 웹메일 설정의 "메일 전달" 을 쓰세요.</p></div>
<div class="card"><h2>보안·정지·삭제</h2><p class="sub">${esc(d.notes.suspendedCounts)}</p><div class="actions"><button class="btn" data-x="reset">비밀번호 재설정</button><button class="btn" data-x="end">모든 세션 종료</button>${b.inviteState === 'pending' || b.inviteState === 'expired' ? '<button class="btn" data-x="invite">초대 재발송</button>' : ''}<button class="btn" data-x="${b.status === 'suspended' ? 'resume' : 'suspend'}">${b.status === 'suspended' ? '정지 해제' : '정지'}</button><button class="btn danger" data-x="delete">삭제</button></div><div id="xres"></div></div>`}`;
const f = root.querySelector('#f'); if (f) f.onsubmit = async (e) => { e.preventDefault(); try { await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { displayName: f.querySelector('#dn').value, quotaBytes: Number(f.querySelector('#q').value) * 1024 ** 2 }); window.aw.markClean(); toast('저장했습니다'); } catch (err) { toast(err.message); } };
root.querySelectorAll('[data-x]').forEach((btn) => (btn.onclick = async () => { const x = btn.dataset.x; const out = root.querySelector('#xres'); try {
if (x === 'reset') { if (!(await confirmDanger({ title: '비밀번호 재설정', target: b.address, impacts: ['새 임시 비밀번호가 한 번만 표시됩니다', '기존 비밀번호와 메일 프로그램 연결이 즉시 무효가 됩니다'], confirmLabel: '재설정' }))) return; const r = await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { resetPassword: true }); out.innerHTML = `<p><b>새 임시 비밀번호:</b> ${copy(r.newPassword)}</p><p class="err">지금 한 번만 보입니다. 첫 로그인에서 바꾸세요</p>`; }
if (x === 'end') { if (!(await confirmDanger({ title: '모든 세션 종료', target: b.address, impacts: ['웹메일·IMAP·POP3·SMTP 의 모든 로그인이 끊깁니다', '비밀번호가 재설정되어 사용자가 다시 정해야 합니다'], confirmLabel: '종료' }))) return; const r = await api('POST', `/api/mail/services/${id}/mailboxes/${mid}/sessions/end`, {}); out.innerHTML = `<p>${esc(r.note)}</p>`; }
if (x === 'invite') { const r = await api('POST', `/api/mail/services/${id}/mailboxes/${mid}/invite`, {}); out.innerHTML = `<p>초대 링크: ${copy(r.invite.url)} <span class="sub">만료 ${esc(fmt(r.invite.expiresAt))}</span></p>`; }
if (x === 'suspend' || x === 'resume') { if (x === 'suspend' && !(await confirmDanger({ title: '메일함 정지', target: b.address, impacts: ['로그인·송수신이 막힙니다(받는 메일은 발신 서버가 재시도)', '정지 중에도 메일함 수와 저장 공간을 소비합니다'], confirmLabel: '정지' }))) return; await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { status: x === 'suspend' ? 'suspended' : 'active' }); location.reload(); }
if (x === 'delete') { if (!(await confirmDanger({ title: '메일함 삭제', target: b.address, impacts: ['메일함의 모든 메일·첨부가 삭제되며 복구할 수 없습니다', '메일 백업·복원 기능은 제공되지 않습니다', '메일함 수·저장 공간이 반환됩니다'], typeToConfirm: b.address, confirmLabel: '삭제' }))) return; await api('DELETE', `/api/mail/services/${id}/mailboxes/${mid}`, { confirm: b.address }); location.href = `/mail/${id}?tab=mailboxes`; }
} catch (e) { toast(e.message); } }));
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 초대 수락
if (view === 'invite') { const f = root.querySelector('form'); f.onsubmit = async (e) => { e.preventDefault(); const pw = f.querySelector('#pw').value, pw2 = f.querySelector('#pw2').value; if (pw !== pw2) return toast('비밀번호가 서로 다릅니다'); try { const r = await api('POST', '/api/mail/invite/accept', { token: root.dataset.token, password: pw }); window.aw.markClean(); f.remove(); root.querySelector('#invite-result').innerHTML = `<p>${esc(r.address)} 의 비밀번호를 설정했습니다.</p><a class="btn primary" href="${esc(r.webmail)}">웹메일 열기</a>`; } catch (err) { root.querySelector('#invite-result').innerHTML = `<p class="err">${esc(err.message)}</p>`; } }; }
})();
+185
View File
@@ -0,0 +1,185 @@
/** test/mail.test.mjs — 메일 영역(§15~§18·§23.2·§14) 회귀 테스트: 생성 규칙·배정·메일함·경고·100%·전환·해지·운영자·어댑터(가짜 AriMail HTTP 서버). */
import http from 'node:http';
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFake, create as createAdapter } from '../portal/mail/arimail.mjs';
import * as svc from '../portal/mail/service.mjs';
import * as quota from '../lib/quota.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { listAudit } from '../lib/audit.mjs';
import { newId } from '../lib/crypto.mjs';
import { nowIso } from '../lib/db.mjs';
/** 그룹·사이트·도메인·권리를 갖춘 시험 환경 */
async function env({ entitle = true } = {}) {
const deps = await makeDeps(); const fake = createFake(); deps.arimail = fake; deps.opsAuthBypass = true; deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=quarantine'] : ['v=spf1 a mx -all']) }; deps.tcpProbe = async () => true;
const w = seedGroup(deps); const g = w.group.id; const now = nowIso();
const site = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, primary_domain, created_at, updated_at) VALUES(?,?,?,\'live\',?,?,?)', site, g, '내 사이트', 'site.example.test', now, now);
for (const [name, sid] of [['site.example.test', site], ['solo.example.test', null], ['other.example.test', null]]) deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, ownership_state, created_at, updated_at) VALUES(?,?,?,?,?,\'verified\',?,?)', newId('d_'), g, name, sid ? 'web' : 'mail', sid, now, now);
if (entitle) { quota.grantEntitlement(deps.store, { groupId: g, resource: 'mailboxes', amount: 5 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_storage', amount: 10 * 1024 ** 3 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_msgs', amount: 1000 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_services', amount: 1 }); }
deps.config.values = { mailVerifier: 'verify@external.example.test' };
const app = await appFor(deps, ['mail'], w);
return { deps, fake, w, g, site, app };
}
const alloc = { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 };
await test('생성 규칙: 사이트 대표 메일은 사이트 연결 도메인만·사이트당 하나·사이트 수 소비 없음, 독립은 mail_services 권리 소비, 권리 없으면 안내', async () => {
const { deps, g, site, app, fake } = await env();
let r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'solo.example.test', alloc }); eq(r.json.error, 'mail_domain_not_site', '사이트 연결 도메인만');
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc }); eq(r.status, 200, '대표 메일 생성: ' + JSON.stringify(r.json).slice(0, 200)); const s1 = r.json.service; eq(s1.kind, 'site_bound'); eq(s1.status, 'needs_setup');
eq(quota.figures(deps.store, g, 'sites').allocated, 0, '운영 사이트 수를 쓰지 않음'); eq(quota.figures(deps.store, g, 'mailboxes').allocated, 2, '메일함 배정'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 0, '대표 메일은 독립 옵션을 쓰지 않음');
ok(fake.state.domains.has('site.example.test'), 'AriMail 테넌트 준비'); eq(fake.state.domains.get('site.example.test').ariweb.kind, 'site_bound', 'ariweb 표시'); eq(fake.state.configs.get('site.example.test').LIMIT_ACCOUNTS, 2, '도메인별 보장량 적용');
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc }); eq(r.json.error, 'mail_domain_in_use', '같은 도메인 재사용 불가');
deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, ownership_state, created_at, updated_at) VALUES(?,?,?,?,?,\'verified\',?,?)', newId('d_'), g, 'two.example.test', 'web', site, nowIso(), nowIso());
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'two.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'mail_site_has_primary', '사이트당 대표 메일 하나');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'solo.example.test', alloc: { mailboxes: 1, storageBytes: 1024 ** 3, monthlyMessages: 10 } }); eq(r.status, 200, '독립 생성'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 1, '독립 옵션 1 소비'); eq(quota.figures(deps.store, g, 'sites').allocated, 0, '사이트 수 소비 없음');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'other.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'quota_insufficient', '독립 옵션 부족 안내'); eq(r.json.detail.link, '/billing', '상품 안내 링크');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'nowhere.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'mail_domain_unknown', '등록되지 않은 도메인');
// 배정 산술: 남은 메일함 5-2-1=2 → 3 요청은 거부
const { deps: d2, app: a2, site: s2 } = await env(); r = await call(a2, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: s2, domain: 'site.example.test', alloc: { mailboxes: 6 } }); eq(r.json.error, 'quota_insufficient', '구매 총량 초과'); eq(d2.store.get('SELECT COUNT(*) AS n FROM mail_services').n, 0, '실패하면 서비스를 만들지 않음');
const { app: a3 } = await env({ entitle: false }); r = await call(a3, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: (await env({ entitle: false })).site, domain: 'site.example.test', alloc: { mailboxes: 1 } }); ok(['quota_insufficient', 'not_found'].includes(r.json.error), '권리 없으면 생성 불가');
const list = await call(app, 'GET', '/api/mail/services'); eq(list.json.services.length, 2, '목록'); ok(list.json.notes.noBackup.includes('백업'), '메일 백업 미제공 문구');
});
await test('활성화 작업: 권리 → DNS(미반영이면 외부 대기) → 준비 → 외부 왕복 확인(검증 주소 없으면 needs_user) → 활성화', async () => {
const { deps, app, site, fake } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
deps.dnsResolver = { mx: async () => [], txt: async () => [] };
let r = await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); eq(r.json.result.ok, false, 'DNS 미반영'); ok(r.json.result.summary.missing >= 3, '누락 수'); eq(r.json.result.items.find((i) => i.kind === 'mx').state, 'missing', 'MX 미반영');
deps.dnsResolver = { mx: async () => ['mail.wrong.test'], txt: async () => ['v=spf1 include:other -all'] }; r = await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); eq(r.json.result.items.find((i) => i.kind === 'mx').state, 'wrong', '잘못된 값과 미반영 구분');
r = await call(app, 'POST', `/api/mail/services/${s.id}/activate`); eq(r.status, 200); await deps.jobs.drain(); let j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'awaiting_external', 'DNS 대기'); ok(j.waitReason.includes('DNS'), '대기 사유');
eq((await call(app, 'POST', `/api/mail/services/${s.id}/activate`)).json.job.id, j.id, '중복 활성화 없음(멱등)');
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=quarantine'] : ['v=spf1 a mx -all']) };
deps.config.values.mailVerifier = ''; await call(app, 'POST', `/api/mail/services/${s.id}/job/resume`); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'needs_user', '검증 주소 없으면 사용자 확인'); ok(j.userMessage.includes('검증 주소'), '안내'); eq(deps.store.get('SELECT status FROM mail_services WHERE id=?', s.id).status, 'activating', '아직 활성화 아님');
deps.config.values.mailVerifier = 'verify@external.example.test'; await call(app, 'POST', `/api/mail/services/${s.id}/job/resume`); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'done', '활성화 완료: ' + JSON.stringify(j.error)); eq(deps.store.get('SELECT status, send_state, receive_state FROM mail_services WHERE id=?', s.id).status, 'active', 'active');
ok(fake.state.calls.includes('roundTrip'), '실제 왕복 확인 호출'); ok(listNotifications(deps.store, { groupId: s.group_id }).some((n) => n.title.includes('활성화')), '알림');
// 왕복 실패면 활성화하지 않음
fake.state.roundTrip = { sent: true, received: false, replied: false }; const d = (await call(app, 'GET', `/api/mail/services/${s.id}`)).json; eq(d.service.status, 'active'); r = await call(app, 'POST', `/api/mail/services/${s.id}/verify`); eq(r.json.test.status, 'failed', '수동 확인 실패 표시'); eq(deps.store.get('SELECT receive_state FROM mail_services WHERE id=?', s.id).receive_state, 'error', '수신 상태 오류');
});
await test('메일함: 사전 확인(남은 수·공간), 초기 비밀번호 1회 표시, 초대 링크 만료·재발송·사용 완료, 설정·정지·세션 종료·삭제, 정지도 수를 소비', async () => {
const { deps, app, site, fake } = await env();
// 메일함 3개 배정: 서비스 관리자 메일함(admin@도메인, AriMail 이 도메인을 만들 때 생김)도 메일함 수를 소비한다 → kim·lee 까지 가능, park 은 한도.
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc: { ...alloc, mailboxes: 3 } })).json.service;
let r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'Bad Name', displayName: 'x' }); eq(r.json.error, 'validation', '주소 형식');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); eq(r.status, 200, '생성: ' + JSON.stringify(r.json).slice(0, 200)); ok(r.json.initialPassword && r.json.initialPassword.length >= 12, '초기 비밀번호 1회'); eq(r.json.mailbox.mustChangePassword, true, '변경 필요'); ok(r.json.program.imap.host, '접속 안내');
const again = await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${r.json.mailbox.id}`); ok(!JSON.stringify(again.json).includes(r.json.initialPassword), '비밀번호 재표시 없음');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.example.test' }); eq(r.status, 200); ok(r.json.invite.url.includes('/mail/invite/'), '초대 링크'); eq(r.json.mailbox.inviteState, 'pending', '초대 대기'); const lee = r.json.mailbox; const token = r.json.invite.url.split('/').pop();
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'park' }); eq(r.json.error, 'mailbox_limit', '메일함 수 한도(3)'); eq(r.json.detail.link, '/usage', '배정 안내');
r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'short' }); eq(r.json.error, 'weak_password'); r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' }); eq(r.status, 200, '초대 수락'); eq(r.json.address, 'lee@site.example.test');
r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' }); eq(r.json.error, 'invite_used', '사용 완료 표시'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${lee.id}`)).json.invite.state, 'used', '초대 상태');
deps.store.run('UPDATE mail_invites SET used_at=NULL, expires_at=? WHERE mailbox_id=?', new Date(0).toISOString(), lee.id); eq((await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' })).json.error, 'invite_expired', '만료'); r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes/${lee.id}/invite`, {}); ok(r.json.invite.url, '재발송'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${lee.id}`)).json.invite.resent_count, 1, '재발송 횟수');
const kim = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.mailboxes.find((m) => m.address === 'kim@site.example.test');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { displayName: '김철수', quotaBytes: 200 * 1024 ** 2 }); eq(r.json.mailbox.displayName, '김철수'); eq(fake.state.users.get('site.example.test').find((u) => u.login === 'kim').quotaBytes, 200 * 1024 ** 2, 'AriMail 반영');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { quotaBytes: 5 * 1024 ** 3 }); eq(r.json.error, 'storage_limit', '배정 초과 거부');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { resetPassword: true }); ok(r.json.newPassword, '재설정 비밀번호 1회');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes/${kim.id}/sessions/end`, {}); eq(r.json.passwordReset, true, '세션 종료');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { status: 'suspended' }); eq(r.json.mailbox.status, 'suspended'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.capacity.mailboxes.remaining, 0, '정지도 수를 소비');
r = await call(app, 'DELETE', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { confirm: 'wrong' }); eq(r.json.error, 'confirm_required'); r = await call(app, 'DELETE', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { confirm: 'kim@site.example.test' }); eq(r.status, 200); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.capacity.mailboxes.remaining, 1, '삭제 뒤 반환');
ok(listAudit(deps.store, { groupId: s.group_id, action: 'mail.mailbox' }).length >= 5, '감사 기록'); ok(!JSON.stringify(listAudit(deps.store, { groupId: s.group_id })).includes('new-password-12'), '감사에 비밀번호 없음');
// 서버 연결 불가: 503 + 정보 확인 지연
fake.state.failWith = 'service_unreachable'; r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'zzz' }); eq(r.json.error, 'service_unreachable'); ok(r.json.message.includes('정보 확인 지연'), '지연 안내'); fake.state.failWith = null;
});
await test('사용량·경고·100%: 계측이 quota_usage 와 메일함에 기록되고 70/80/90/95/100 알림이 사건으로 묶이며 100% 문구·다음 KST 월·발송 제한·정책 미확정 표시', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
const kim = (await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim', quotaBytes: 1000 })).json.mailbox;
fake.state.usage.set('site.example.test', { storageBytes: 0.95 * 1024 ** 3, monthly: 100, sent: 60, received: 40, monthReset: '2026-10-31T15:00:00.000Z' }); fake.state.users.get('site.example.test').find((u) => u.login === 'kim').bytes = 950;
const c = await svc.collectMailUsage(deps, s.id); eq(c.ok, true); eq(quota.usageOf(deps.store, g, 'mail_storage', 'mail_service', s.id).used, Math.round(0.95 * 1024 ** 3), 'quota_usage 저장 공간'); eq(quota.usageOf(deps.store, g, 'mail_msgs', 'mail_service', s.id).used, 100, '월간 통수');
const u = (await call(app, 'GET', `/api/mail/services/${s.id}/usage`)).json; eq(u.figures.storage.level, 95, '95% 구간'); eq(u.figures.monthly.level, 100, '월간 100%'); eq(u.lastUsage.sent, 60, '송신'); eq(u.lastUsage.received, 40, '수신'); eq(u.lastUsage.countingRule, 'per_final_recipient', '수신자 기준'); ok(u.notes.counting.includes('최종 수신자'), '통수 설명'); ok(u.notes.limit100.monthly.includes('새 발신을 막고'), '월간 100% 문구'); ok(u.nextReset, '다음 KST 월'); ok(u.measuredAt, '마지막 계측'); eq(u.policy.largeAttachment.deferred, true, '대용량 첨부 정책 미확정'); ok(u.sendLimits.note.includes('초당 10건'), '발송 제한 안내');
const mb = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${kim.id}`)).json.mailbox; eq(mb.level, 95, '메일함 95%'); const n1 = listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id); eq(n1.length, 1, '메일함 경고 1건'); eq(n1[0].incident_key, `${g}:mail_storage:${kim.id}:95`, '사건 키 규약');
await svc.collectMailUsage(deps, s.id); eq(listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id).length, 1, '같은 구간은 묶임'); eq(listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id)[0].count, 2, '갱신 횟수');
fake.state.users.get('site.example.test').find((u) => u.login === 'kim').bytes = 1000; await svc.collectMailUsage(deps, s.id); const n2 = listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id && n.incident_key.endsWith(':100')); eq(n2.length, 1, '100% 알림'); eq(n2[0].mandatory, 1, '필수 알림'); ok(n2[0].body.includes('임시 실패'), '100% 동작 설명'); eq(deps.store.get('SELECT status FROM mailboxes WHERE id=?', kim.id).status, 'over_quota', '용량 초과 상태');
fake.state.failWith = 'service_unreachable'; const c2 = await svc.collectMailUsage(deps, s.id); eq(c2.ok, false); fake.state.failWith = null; const u2 = (await call(app, 'GET', `/api/mail/services/${s.id}/usage`)).json; eq(u2.usageError, 'service_unreachable', '정보 확인 지연 표시, 마지막 값 유지'); eq(u2.figures.monthly.used, 100, '마지막 계측값');
// 정책 확정 시 표시
svc.writePolicy(deps, { largeAttachment: { maxBytes: 500 * 1024 ** 2, maxDownloads: 10, maxDays: 7 } }); eq(svc.readPolicy(deps).largeAttachment.deferred, false, '정책 확정');
});
await test('도메인 보안(§18)·POP/IMAP 스위치(영향 확인 + 적용 후 접속 확인)·웹메일 SSO·메일 프로그램 설정(비밀번호 없음)', async () => {
const { deps, app, site, fake } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
const sec = (await call(app, 'GET', `/api/mail/services/${s.id}/security`)).json.report; eq(sec.items.find((i) => i.id === 'spf').state, 'ok', 'SPF 정상'); eq(sec.items.find((i) => i.id === 'dkim').state, 'ok', 'DKIM'); eq(sec.items.find((i) => i.id === 'dmarc').state, 'ok', 'DMARC'); eq(sec.items.find((i) => i.id === 'roundtrip').state, 'recommended', '시험 전'); ok(sec.records.every((r) => r.value), '복사 가능한 값');
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? [] : ['v=spf1 a mx -all']) }; deps.store.run('UPDATE mail_services SET settings_json=\'{}\' WHERE id=?', s.id);
const sec2 = (await call(app, 'GET', `/api/mail/services/${s.id}/security`)).json.report; eq(sec2.items.find((i) => i.id === 'dmarc').state, 'recommended', 'DMARC 없으면 권장 개선');
let r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { pop: false }); eq(r.json.error, 'confirm_required', '영향 확인 필요'); ok(r.json.message.includes('메일 프로그램'), '영향 안내');
r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { pop: false, confirmed: true }); eq(r.json.services.pop, false, 'POP 끔'); eq(r.json.check.summary, 'ok', '적용 후 접속 확인'); eq(deps.store.get('SELECT pop_enabled FROM mail_services WHERE id=?', s.id).pop_enabled, 0);
deps.tcpProbe = async (h, p) => p !== 995; r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { imap: true, confirmed: true }); eq(r.json.check.summary, 'partial', '일부 포트 응답 없음 표시');
const kim = (await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim' })).json.mailbox;
r = await call(app, 'POST', `/api/mail/services/${s.id}/webmail`, { mailboxId: kim.id }); eq(r.json.sso, true, 'SSO'); ok(r.json.url.includes('/api/auth/sso?ticket='), 'SSO 주소');
const d = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${kim.id}`)).json; ok(d.program.imap.ports.tls === 993 && d.program.smtp.ports.starttls === 587, '접속 정보'); ok(!JSON.stringify(d).toLowerCase().includes('password":"'), '비밀번호 없음');
r = await call(app, 'POST', `/api/mail/services/${s.id}/aliases`, { address: 'info', target: 'kim' }); ok(['policy_deferred', 'unsupported_in_version'].includes(r.json.error), '별칭: 정책 미확정이면 거부'); ok(r.json.message.includes('상품정책'), '정책 안내');
});
await test('전환(§14): 점검표(주소·데이터·별칭·전달·비밀번호·DNS·옵션·작업), 옵션 부족이면 차단, 전후 확인 뒤 독립 전환, 누락이면 미완료, canReturnSiteCount', async () => {
const { deps, app, site, fake, g } = await env();
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=none'] : ['v=spf1 a mx -all']) };
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service; await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim' });
let c = svc.canReturnSiteCount(deps, site); eq(c.ok, false, '대표 메일이 있으면 사이트 수 반환 불가'); eq(c.reasons[0].code, 'site_primary_mail_active'); eq(c.reasons[0].canConvert, true, '옵션 있음');
let r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.canConvert, false, 'DNS 확인 전'); eq(r.json.checklist.items.find((i) => i.id === 'dns').ok, false);
await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.canConvert, true, '점검표 충족: ' + JSON.stringify(r.json.checklist.items.filter((i) => !i.ok))); eq(r.json.checklist.items.length, 8, '항목 수');
// 옵션 부족
const ent = deps.store.get('SELECT id FROM quota_entitlements WHERE resource=\'mail_services\''); quota.endEntitlement(deps.store, ent.id); r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.items.find((i) => i.id === 'entitlement').ok, false, '옵션 부족'); eq(r.json.checklist.items.find((i) => i.id === 'entitlement').link, '/billing'); r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.json.error, 'conversion_blocked', '차단'); eq(svc.canReturnSiteCount(deps, site).reasons[0].canConvert, false, '반환 화면 안내: 먼저 구매');
quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_services', amount: 1 });
// 전환 뒤 누락 시나리오: after 확인에서 메일함이 사라짐 → 미완료 + 되돌리기
// listUsers 호출 순서(POST 부터): 요청 검사 점검표(1) → 작업 점검표(2) → 전환 전(3) → 전환 후(4). 전환 후 조회에서 메일함 하나가 사라진 상황을 흉내낸다.
const users = fake.state.users.get('site.example.test'); let calls = 0; const origList = fake.listUsers; fake.listUsers = async (...a) => { calls++; const rr = await origList(...a); if (calls === 4) rr.users = rr.users.slice(1); return rr; };
r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.status, 200); await deps.jobs.drain(); let j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'failed', '누락이면 완료하지 않음: ' + j.error); eq(j.errorCode, 'conversion_incomplete'); fake.listUsers = origList;
await call(app, 'POST', `/api/mail/services/${s.id}/job/revert`); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'reverted', '되돌림'); eq(deps.store.get('SELECT kind, site_id FROM mail_services WHERE id=?', s.id).kind, 'site_bound', '종류 복원'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 0, '옵션 반환');
// 정상 전환
deps.store.run('UPDATE mail_services SET updated_at=? WHERE id=?', nowIso(), s.id); r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.status, 200, JSON.stringify(r.json)); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'done', '전환 완료: ' + j.error);
const after = deps.store.get('SELECT kind, site_id FROM mail_services WHERE id=?', s.id); eq(after.kind, 'standalone'); eq(after.site_id, null); eq(fake.state.domains.get('site.example.test').ariweb.kind, 'standalone', 'AriMail 표시'); eq(users.length, 2, '메일함 유지(admin + kim)'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 1, '옵션 소비');
eq(svc.canReturnSiteCount(deps, site).ok, true, '전환 뒤 사이트 수 반환 가능'); eq(deps.store.all('SELECT * FROM mail_tests WHERE kind IN (\'conversion_before\',\'conversion_after\')').length >= 2, true, '전후 확인 기록'); ok(listAudit(deps.store, { groupId: g, action: 'mail.service.convert' })[0]?.immutable === 1, '삭제 불가 감사');
});
await test('해지·보존(§23.2): 송수신 즉시 중지(AriMail frozen), 30일 읽기·내보내기 안내·삭제 예정일, 배정 반환 중, 보존 종료 뒤 삭제·반환·복구 불가 표시, 삭제 보류', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
let r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'nope' }); eq(r.json.error, 'confirm_required'); r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'site.example.test' }); eq(r.json.error, 'site_live', '운영 사이트 영향 안내');
r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'site.example.test', acknowledgeSite: true }); eq(r.status, 200); eq(r.json.service.status, 'cancelled'); eq(fake.state.domains.get('site.example.test').operating, 'frozen', '송수신 즉시 중지'); const lc = r.json.lifecycle; eq(lc.state, 'readonly_retention', '읽기 전용 보존'); ok(lc.retentionUntil && lc.deleteAfter, '보존 종료·삭제 예정일'); ok(lc.backup.includes('제공되지 않습니다'), '백업 미제공'); ok(lc.exportable.includes('가능'), '내보내기 가능');
eq(quota.allocationOf(deps.store, g, 'mailboxes', 'mail_service', s.id).state, 'returning', '배정 반환 중'); ok(listNotifications(deps.store, { groupId: g }).some((n) => n.mandatory && n.title.includes('해지')), '필수 알림');
r = await call(app, 'POST', `/api/mail/services/${s.id}/export`, {}); eq(r.json.export.status, 'guidance'); ok(r.json.export.guidance.some((x) => x.includes('IMAP')), '내보내기 안내');
eq((await svc.purgeExpired(deps)).length, 0, '보존 중에는 삭제 안 함'); deps.store.run('UPDATE mail_services SET delete_after=? WHERE id=?', new Date(0).toISOString(), s.id);
deps.store.run('UPDATE mail_services SET settings_json=? WHERE id=?', JSON.stringify({ deleteHold: true }), s.id); eq((await svc.purgeExpired(deps)).length, 0, '삭제 보류'); deps.store.run('UPDATE mail_services SET settings_json=\'{}\' WHERE id=?', s.id);
eq((await svc.purgeExpired(deps)).length, 1, '보존 종료 삭제'); eq(deps.store.get('SELECT status FROM mail_services WHERE id=?', s.id).status, 'deleted'); eq(quota.allocationOf(deps.store, g, 'mailboxes', 'mail_service', s.id), null, '배정 반환 완료'); ok(!fake.state.domains.has('site.example.test'), 'AriMail 도메인 제거');
eq(svc.lifecycle(deps, svc.getService(deps, g, s.id)).exportable, '불가', '삭제 뒤 복구 불가 표시'); eq((await call(app, 'GET', '/api/mail/services')).json.services.length, 0, '목록에서 제외');
});
await test('운영자 API(§30.2): 개요·서비스 상세(접근 사유 기록)·운영 상태(이전 중 동결)·정책·메일 원문 없음', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
let r = await call(app, 'GET', '/internal/ops/mail/overview'); eq(r.status, 200); eq(r.json.services.length, 1); ok(r.json.servers[0].health.ok, '서버 건강');
r = await call(app, 'GET', `/internal/ops/mail/services/${s.id}?reason=customer-ticket-1`, undefined, { 'x-ops-actor': 'ops-kim' }); eq(r.json.service.domain, 'site.example.test'); const a = listAudit(deps.store, { subjectType: 'mail_service', subjectId: s.id, action: 'ops.mail.service.read' })[0]; eq(a.actor_id, 'ops-kim', '접근 기록'); eq(a.detail.reason, 'customer-ticket-1', '사유'); ok(!JSON.stringify(r.json).includes('"text"') && !JSON.stringify(r.json).includes('"html"'), '메일 원문 없음');
r = await call(app, 'POST', `/internal/ops/mail/services/${s.id}/operating`, { operating: 'migrating_out', reason: '서버 이전' }); eq(r.json.operating, 'migrating_out'); eq(fake.state.domains.get('site.example.test').operating, 'migrating_out', 'AriMail 반영'); eq(deps.store.get('SELECT migrating FROM mail_services WHERE id=?', s.id).migrating, 1);
r = await call(app, 'POST', `/internal/ops/mail/services/${s.id}/operating`, { operating: 'bogus' }); eq(r.status, 422);
r = await call(app, 'PUT', '/internal/ops/mail/policy', { verifier: 'check@external.example.test', aliases: { perMailbox: 3 } }); eq(r.json.policy.aliases.deferred, false); eq((await call(app, 'GET', '/internal/ops/mail/policy')).json.policy.verifier, 'check@external.example.test');
r = await call(app, 'GET', '/internal/ops/mail/servers/fake/rates?domain=site.example.test'); eq(r.json.rates.deliveryRate, 5, '발송 속도');
// 서비스 키 우회를 끄면 거부
deps.opsAuthBypass = false; r = await call(app, 'GET', '/internal/ops/mail/overview'); eq(r.status, 401, '서명 없으면 401');
});
await test('어댑터: 가짜 AriMail HTTP 서버에 대해 설정 토큰·관리자 세션·사용량 키·SSO 티켓·연결 불가(service_unreachable) 동작', async () => {
const deps = await makeDeps(); const seen = []; const token = 'setup-token-' + Math.random().toString(36).slice(2);
const srv = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const u = new URL(req.url, 'http://x'); seen.push({ m: req.method, p: u.pathname, host: req.headers['x-forwarded-host'] || req.headers.host, auth: req.headers.authorization, cookie: req.headers.cookie, scope: req.headers['x-mail-scope'], token: req.headers['x-setup-token'] }); const j = (code, o, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
if (u.pathname === '/api/health') return j(200, { ok: true, version: '1.0.11-test', domains: 2 });
if (u.pathname.startsWith('/api/setup/')) { if (req.headers['x-setup-token'] !== token) return j(401, { ok: false, error: 'setup_token_invalid' }); if (u.pathname === '/api/setup/domains' && req.method === 'GET') return j(200, { ok: true, domains: [{ domain: 'a.test', hosts: ['mail.a.test'], ariweb: null, operating: 'active' }] }); if (u.pathname === '/api/setup/domains' && req.method === 'POST') return j(201, { ok: true, domain: { domain: JSON.parse(body).domain, hosts: ['mail.' + JSON.parse(body).domain] }, initialAdmin: { login: 'admin', password: JSON.parse(body).adminPassword } }); if (u.pathname.startsWith('/api/setup/domains/') && req.method === 'PATCH') return j(200, { ok: true, domain: { domain: 'a.test', ...JSON.parse(body) } }); if (u.pathname === '/api/setup/config' && req.method === 'GET') return j(200, { ok: true, values: {}, meta: { host: 'mail.' + u.searchParams.get('domain'), dns: { records: [{ type: 'MX', name: 'a.test', value: '10 mail.a.test' }] } } }); if (u.pathname === '/api/setup/config' && req.method === 'PUT') return j(200, { ok: true, saved: JSON.parse(body).values }); return j(404, { ok: false, error: 'not_found' }); }
if (u.pathname === '/api/usage/a.test') { if (req.headers.authorization !== 'Bearer usage-key-1') return j(401, { ok: false, error: 'unauthenticated' }); return j(200, { ok: true, domain: 'a.test', storage: { bytes: 10 }, traffic: { month: { total: 3 } }, warnLevel: 0, limitedReasons: [], accountList: [] }); }
if (u.pathname === '/api/login' && req.method === 'POST') { const b = JSON.parse(body); if (b.login === 'admin' && b.password === 'admin-pw-1') return j(200, { ok: true }, { 'Set-Cookie': 'mg_admin=tok123; Path=/; HttpOnly' }); return j(401, { ok: false, error: 'invalid_credentials' }); }
if (u.pathname === '/api/admin/users' && req.method === 'GET') { if (req.headers.cookie !== 'mg_admin=tok123' || req.headers['x-mail-scope'] !== 'admin') return j(403, { ok: false, error: 'admin_login_required' }); return j(200, { ok: true, users: [{ id: 'u1', login: 'admin', mailbox: 'admin@a.test' }], domain: 'a.test' }); }
if (u.pathname === '/api/admin/users' && req.method === 'POST') return j(200, { ok: true, id: 'u2' });
j(404, { ok: false, error: 'not_found' }); }); });
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; const base = `http://127.0.0.1:${port}`;
try {
const ad = createAdapter(deps); const s = ad.saveServer({ name: '시험 서버', baseUrl: base, internalUrl: base, setupUrl: base, mailHost: 'mail.a.test', isDefault: true, secrets: { 'setup-token': token, 'usage-key': 'usage-key-1', 'sso-secret': 'sso-secret-1' } });
ok(!JSON.stringify(s).includes(token) && s.secrets.setupToken === true, '응답에 비밀 없음'); eq((await ad.health(s.id)).ok, true, '건강');
eq((await ad.domains(s.id)).length, 1, '도메인 목록(토큰)'); const added = await ad.addDomain(s.id, { domain: 'b.test', ariweb: { kind: 'standalone' } }); eq(added.initialAdmin.login, 'admin', '초기 관리자'); ok(deps.vault.get('arimail:' + s.id + ':admin:b.test'), '관리자 로그인 보관');
eq((await ad.updateDomain(s.id, 'a.test', { operating: 'frozen' })).operating, 'frozen', 'PATCH'); eq((await ad.requiredRecords ? null : (await ad.domainConfig(s.id, 'a.test')).meta.dns.records[0].type), 'MX', '설정·DNS 레코드'); await ad.setDomainLimits(s.id, 'a.test', { mailboxes: 3, storageBytes: 100, monthlyMessages: 10 }); ok(seen.find((x) => x.m === 'PUT' && x.p === '/api/setup/config'), '보장량 PUT');
eq((await ad.usage(s.id, 'a.test')).storage.bytes, 10, '사용량 키'); ok(seen.find((x) => x.p === '/api/usage/a.test').auth === 'Bearer usage-key-1', 'Bearer');
ad.storeAdmin(s.id, 'a.test', 'admin', 'admin-pw-1'); const users = await ad.listUsers(s.id, 'a.test'); eq(users.users[0].login, 'admin', '관리자 세션'); const req = seen.find((x) => x.p === '/api/admin/users'); eq(req.host, 'mail.a.test', '도메인 호스트 전달'); eq(req.scope, 'admin', '관리자 범위'); eq((await ad.createUser(s.id, 'a.test', { login: 'kim', password: 'kim-pw-123' })).id, 'u2', '계정 생성');
const sso = await ad.ssoUrl(s.id, 'a.test', 'kim@a.test'); ok(sso.startsWith('https://mail.a.test/api/auth/sso?ticket='), 'SSO 주소'); const tk = decodeURIComponent(sso.split('ticket=')[1]); const payload = JSON.parse(Buffer.from(tk.split('.')[0], 'base64url')); eq(payload.mailbox, 'kim@a.test'); ok(payload.exp > Date.now(), '만료');
ad.storeAdmin(s.id, 'a.test', 'admin', 'wrong'); await rejects(() => ad.listUsers(s.id, 'a.test'), 'invalid_credentials', '관리자 로그인 실패 코드');
srv.close(); await rejects(() => ad.health(s.id).then((h) => { if (!h.ok) throw Object.assign(new Error(h.error), { code: h.error }); }), 'service_unreachable', '연결 불가'); await rejects(() => ad.domains(s.id), 'service_unreachable', '연결 불가(설정)');
} finally { try { srv.close(); } catch (_) {} }
});
done();