Files
AriWeb-Suite/modules/arimail/server/api.mjs
T

480 lines
54 KiB
JavaScript

/**
* HTTP API. 표준 Request/Response 로 작성해 www 사이트 핸들러와 같은 방식으로 실행합니다.
* 모든 메일 조회는 세션 사용자의 메일함으로 범위를 제한합니다. 0.9.0 부터는 관리자도 예외가 아닙니다
* (예전의 ?mailbox= 로 다른 메일함을 여는 기능은 요구사항에 따라 삭제했습니다. 관리자 권한은 /admin 의 계정·설정 관리에만 쓰입니다).
*
* 세션 범위(0.8.4~): 요청 헤더 X-Mail-Scope 가 'admin' 이면 관리자 페이지(/admin)의 요청입니다. 이 요청은 관리자 세션 쿠키(mg_admin)로
* 인증하고 /api/admin/* 와 /api/session 만 쓸 수 있습니다. 헤더가 없으면 웹 메일 요청이며 메일 세션 쿠키(mg_session)로 인증하고
* /api/admin/* 는 쓸 수 없습니다(관리자도 /admin 에서 따로 로그인해야 합니다).
*
* 0.9.0 에 더한 경로
* POST /api/signup 회원 가입(관리자가 켠 경우). 옵션에 따라 즉시 사용 또는 승인 대기.
* PATCH /api/password 로그인한 계정의 비밀번호 변경(현재 비밀번호 + 새 비밀번호 2회). 웹 메일 "도구 → 설정" 이 씁니다.
* GET/PATCH /api/admin/config 회원 가입 설정·메일 제한(관리자 층)·POP/IMAP 서비스 스위치. 시스템 제한 값도 함께 돌려줍니다.
* PATCH /api/admin/users/<id> quotaBytes(계정별 메일함 크기), status:'active'(승인) 추가. 자기 비밀번호는 여기서 바꿀 수 없음(설정 화면으로).
*/
import { json, bad, now, uid, clean, addresses } from './util.mjs';
import { verifyUser, createSession, sessionCookie, currentUser, destroySession, requireUser, audit, listUsers, createUser, updateUser, deleteUser, knownMailboxes, readPrefs, writePrefs, mailDomain, readPasswordPolicy, writePasswordPolicy, usesInitialPassword, checkCurrentPassword, pendingLogin, externalUsers, authMode } from './auth.mjs';
import { outboundStatus, inboundMode, outboundMode, usesHaraka } from './providers/index.mjs';
import { configFile } from './config.mjs';
import { setupApi, harakaStatus } from './setup.mjs';
import { ingestRaw as ingestRawMail } from './store.mjs';
import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, uid as newId, now as nowIso } from './util.mjs';
import { knownMailboxes as mailboxList } from './auth.mjs';
import { sanitizeHtml } from './sanitize.mjs';
import { verifySvix } from './webhook.mjs';
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';
import { systemLimits, readAdminLimits, writeAdminLimits, readUserQuota, writeUserQuota, checkUserQuota, mailboxQuota, mailboxUsage, attachmentLimit, recipientLimit, readServices, writeServices, plainLoginAllowed, readSignup, writeSignup, mailStats, accountCount } from './limits.mjs';
const FOLDERS = ['inbox', 'sent', 'drafts', 'trash'];
/**
* 메일이 상태와 맞게 들어갈 수 있는 폴더 목록(웹의 "이동" 메뉴·API 공통 규칙).
* - 받은 메일: 받은 메일함·휴지통
* - 발송한 메일(state='sent'): 보낸 메일함·휴지통
* - 그 밖의 보내는 메일(초안·발송 실패): 임시 보관함·휴지통
* 왜: 예전에는 아무 폴더로나 옮길 수 있어, 초안을 보낸 메일함에 넣으면 보내지 않은 메일이 보낸 것처럼 보이고,
* 받은 메일을 임시 보관함에 넣으면 작성 창으로 열려 저장·발송이 mail_locked 로 실패했습니다.
*/
export const movableFolders = (m) => m.direction === 'inbound' ? ['inbox', 'trash'] : m.state === 'sent' ? ['sent', 'trash'] : ['drafts', 'trash'];
// prefix(1.0.8): 화면이 경로 접두사(/mail) 아래에서 돌 때 인라인 이미지 주소도 그 아래로 만듭니다(저장된 본문은 늘 /api/mail/… 로 두고 내보낼 때만 붙임).
const previewHtml = (html, id, attachments, prefix = '') => String(html || '').replace(/(<img\b[^>]*\bsrc=")cid:([^"]+)("[^>]*>)/g, (m, a, cid, b) => {
const match = attachments.find((x) => x.content_id && x.content_id === cid.replace(/&amp;/g, '&'));
return match ? `${a}${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(match.id)}?inline=1${b}` : `${a}${b}`;
// 초안·보낸 메일에 이미 /api/mail/… 로 저장된 인라인 이미지도 접두사 아래에서 열리게 같이 바꿉니다(없으면 사이트의 404 를 보게 됨).
}).replace(/(<img\b[^>]*\bsrc=")\/api\/mail\//g, (m, a) => prefix ? `${a}${prefix}/api/mail/` : m);
/**
* 초안 본문의 인라인 이미지 주소에서 경로 접두사를 뗍니다(1.0.8). 편집기는 화면 기준 경로(/mail/api/mail/…)로 넣는데, 저장·발송(cid: 변환)은 /api/mail/… 만 알아보므로
* 저장 전에 되돌립니다. 접두사가 없으면 그대로입니다.
*/
const stripPrefix = (html, prefix, env) => { let out = String(html || ''); for (const p of new Set([prefix, env?.PUBLIC_PATH].filter(Boolean))) out = out.split(`src="${p}/api/mail/`).join('src="/api/mail/'); return html ? out : html; };
async function applyAction(env, mail, body, user) {
if (body.folder === 'trash' || body.action === 'trash') { if (mail.state === 'sending') throw new Error('mail_sending'); await env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=? AND folder<>'trash'").bind(now(), mail.id).run(); }
else if (body.restore === true || body.action === 'restore') await env.DB.prepare("UPDATE messages SET folder=COALESCE(previous_folder,CASE direction WHEN 'inbound' THEN 'inbox' ELSE 'sent' END),previous_folder=NULL,updated_at=? WHERE id=? AND folder='trash'").bind(now(), mail.id).run();
else if (body.action === 'move' && FOLDERS.includes(body.target)) { if (mail.state === 'sending') throw new Error('mail_sending'); if (!movableFolders(mail).includes(body.target)) throw new Error('mail_move_invalid'); await env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(body.target, now(), mail.id).run(); }
else if (body.unread === true || body.action === 'unread') await env.DB.prepare('UPDATE messages SET read_at=NULL WHERE id=?').bind(mail.id).run();
else if (body.read === true || body.action === 'read') await env.DB.prepare('UPDATE messages SET read_at=COALESCE(read_at,?) WHERE id=?').bind(now(), mail.id).run();
else if (body.flagged !== undefined || body.action === 'flag' || body.action === 'unflag') await setFlag(env, mail.id, '\\Flagged', body.flagged !== undefined ? !!body.flagged : body.action === 'flag');
else if (body.popReset === true || body.action === 'popReset') { await env.DB.prepare('DELETE FROM pop_deliveries WHERE message_id=?').bind(mail.id).run(); await audit(env, user.login, 'pop_reset', mail.id); }
else if (body.action === 'delete') { if (mail.folder !== 'trash') throw new Error('mail_trash_first'); await purge(env, mail); await audit(env, user.login, 'mail_delete', mail.id); }
else return false;
return true;
}
async function purge(env, mail) {
const statements = [];
if (mail.provider_id) statements.push(env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(mail.provider_id, now()));
statements.push(env.DB.prepare('DELETE FROM messages WHERE id=?').bind(mail.id));
await env.DB.batch(statements);
}
/** 메일함 폴더별 건수·읽지 않음 수(사이드바용). */
const folderCounts = async (env, mailbox) => (await env.DB.prepare("SELECT folder,COUNT(*) n,SUM(CASE WHEN read_at IS NULL AND direction='inbound' THEN 1 ELSE 0 END) unread FROM messages WHERE mailbox=? GROUP BY folder").bind(mailbox).all()).results;
export async function handle(env, request, params) {
const { path, ip } = params, prefix = params.prefix || '';
const method = request.method;
// scope: 이 요청이 어느 화면에서 왔는지. 관리자 페이지는 모든 API 호출에 X-Mail-Scope: admin 을 붙입니다.
const sessionScope = request.headers.get('x-mail-scope') === 'admin' ? 'admin' : 'mail';
try {
// ---------------------------------------------------------------- 공개
// 상태 확인. version 은 배포 후 실제로 새 코드가 돌고 있는지 확인하는 데 씁니다.
if (path[0] === 'health') return json({ ok: true, version: env.APP_VERSION || null });
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);
if (path[0] === 'login' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
// 로그인 ID 는 login 또는 email 필드로 옵니다. 제한 키·검사·기록 모두 같은 값을 씁니다(예전에는 email 필드 로그인이 빈 계정 키 'login:' 하나를 함께 써서 서로 잠겼습니다, 0.9.3 수정).
const loginId = clean(body.login ?? body.email, 200).toLowerCase();
const keys = ['login:' + ip, 'login:' + loginId];
if (keys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
const password = String(body.password ?? '');
const user = await verifyUser(env, loginId, password);
if (!user) {
// 회원 가입 뒤 승인을 기다리는 계정(비밀번호까지 맞을 때만)에는 "승인 대기" 를 알려 줍니다. 실패 횟수로 세지 않습니다.
if (await pendingLogin(env, loginId, password)) { await audit(env, null, 'login_pending', loginId, { ip }); return bad('pending_approval', 403); }
keys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, null, 'login_failed', loginId, { ip, scope: sessionScope }); return bad('invalid_credentials', 401);
}
// 관리자 페이지 로그인은 관리자 역할 계정만 통과합니다. 비밀번호는 맞아도 일반 계정이면 거절 사유를 따로 알려 줍니다.
if (sessionScope === 'admin' && user.role !== 'admin') { await audit(env, user.login, 'admin_login_denied', null, { ip }); return bad('admin_only', 403); }
const token = await createSession(env, user, request, { scope: sessionScope }); await audit(env, user.login, sessionScope === 'admin' ? 'admin_login' : 'login', null, { ip, source: user.source });
// 쿠키 Path(1.0.8): 경로 접두사 아래의 웹 메일이면 그 접두사(/mail)로 한정해 같은 사이트의 다른 경로(사이트 자체 백엔드)에는 세션 쿠키가 가지 않게 합니다.
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie(token, { scope: sessionScope, path: prefix }) });
}
if (path[0] === 'session' && method === 'GET') {
const user = await currentUser(env, request, { scope: sessionScope });
const prefs = user ? await readPrefs(env, user) : {};
// autoReplyActive: 자동 응답이 지금 동작 중인지(기간 판단은 서버 시각 기준). 화면은 이 값으로 사이드바에 "자동 응답 작동 중" 을 표시합니다.
// initialPassword(0.8.24~): 독립 실행의 초기 관리자가 아직 adminpasswd 를 쓰면 true. 화면이 비밀번호를 바꾸라는 경고를 띄웁니다.
// signup(0.9.0~): 회원 가입이 켜져 있는지(로그인 화면의 "회원 가입" 단추). mailboxes 는 항상 자기 메일함 하나입니다(메일함 전환 삭제).
// largeFiles(0.9.3): 대용량 첨부파일 설정(사용 여부·상한). 작성 창이 단추를 보일지와 발송 조건 입력 칸의 기본값·상한을 정하는 데 씁니다. attachmentBytes 는 일반 첨부 상한(이보다 큰 파일은 대용량으로 안내).
return json({ ok: true, user, mailbox: user?.mailbox || mailboxOf(env), mailboxes: user ? [user.mailbox] : [], prefs, autoReplyActive: user ? autoReplyActive(prefs.autoReply) : false, initialPassword: user?.source === 'local' ? await usesInitialPassword(env, user) : false, domain: mailDomain(env), host: (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), authMode: authMode(env), version: env.APP_VERSION || null, signup: (await readSignup(env)).enabled,
...(user && sessionScope === 'mail' ? { largeFiles: await readLargeFileSettings(env), attachmentBytes: await attachmentLimit(env) } : {}) });
}
if (path[0] === 'session' && method === 'DELETE') { await destroySession(env, request, { scope: sessionScope }); return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: sessionScope, path: prefix }) }); }
// return await: try 안에서 프라미스를 그냥 return 하면 거부가 아래 catch 에 잡히지 않습니다(createUser 의 weak_password 등이 500 으로 새어 나감).
if (path[0] === 'signup' && method === 'POST') return await signup(env, request, ip);
// ---------------------------------------------------------------- 로그인 사용자
const user = await requireUser(env, request, { scope: sessionScope }); if (user instanceof Response) return user;
// ---------------------------------------------------------------- 관리자 페이지(/admin) 전용
// 관리자 세션으로는 아래 경로만 쓸 수 있고, 반대로 웹 메일 세션으로는 /api/admin 을 쓸 수 없습니다(관리자 페이지에서 따로 로그인).
// 초기 설정 API(1.0.0): 관리자 세션으로도 같은 API 를 씁니다(/setup 화면을 관리자 페이지에서 열 때). 토큰 인증 쪽은 setup.mjs 의 전용 포트 서버가 처리합니다.
if (path[0] === 'setup') { if (sessionScope !== 'admin') return bad('admin_login_required', 403); return await setupApi(env, request, path.slice(1), { ip, user }); }
if (path[0] === 'admin') {
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
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) });
if (path[1] === 'policy' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const policy = await writePasswordPolicy(env, body); await audit(env, user.login, 'policy_update', null, policy);
return json({ ok: true, policy });
}
// 관리자 설정(0.9.0~): 회원 가입·메일 제한·서비스 스위치. system 은 설치 시 정한 상위 제한(읽기 전용)입니다.
if (path[1] === 'config' && method === 'GET') return json({ ok: true, ...(await adminConfig(env)) });
if (path[1] === 'config' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const changed = {};
if (body.signup && typeof body.signup === 'object') changed.signup = await writeSignup(env, body.signup);
if (body.limits && typeof body.limits === 'object') changed.limits = await writeAdminLimits(env, body.limits);
if (body.services && typeof body.services === 'object') changed.services = await writeServices(env, body.services);
if (body.largeFiles && typeof body.largeFiles === 'object') changed.largeFiles = await writeLargeFileSettings(env, body.largeFiles);
await audit(env, user.login, 'config_update', null, changed);
return json({ ok: true, ...(await adminConfig(env)) });
}
if (path[1] !== 'users') return bad('not_found', 404);
const target = path[2];
if (method === 'GET' && !target) {
// 계정마다 메일 프로그램 접속 허용 여부(prefs 의 pop/imap)와 메일함 크기·사용량(0.9.0)을 붙여 돌려줍니다.
const users = await listUsers(env);
for (const u of users) {
const p = await readPrefs(env, u); u.services = { pop: p.pop, imap: p.imap };
u.quotaBytes = await readUserQuota(env, u.id); u.limit = await mailboxQuota(env, u.mailbox); u.usageBytes = await mailboxUsage(env, u.mailbox);
}
// me: www 관리자 계정의 행. 역할이 관리자인 게이트웨이 계정으로 들어온 경우에는 그 계정이 users 목록에 이미 있으므로 me 를 주지 않습니다
// (예전에는 로그인한 누구든 "admin" 행으로 그려져, 게이트웨이 관리자의 설정이 www 관리자 행에 표시됐습니다). 웹메이커 관리자(1.0.8)는 아래 external 목록에 있으므로 역시 me 가 없습니다.
const mine = user.source === 'www' ? await readPrefs(env, user) : null;
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정(읽기 전용 — 이름·비밀번호·역할은 웹메이커가 관리). POP/IMAP 허용·메일함 크기·사용량만 붙여 줍니다.
const ext = await externalUsers(env);
const external = [];
for (const a of ext.accounts) { const p = await readPrefs(env, a); external.push({ ...a, services: { pop: p.pop, imap: p.imap }, quotaBytes: await readUserQuota(env, a.id), limit: await mailboxQuota(env, a.mailbox), usageBytes: await mailboxUsage(env, a.mailbox) }); }
// policy: 계정 추가·수정 창이 비밀번호 입력 칸의 최소 길이와 안내문을 정책대로 그리는 데 씁니다. system/limits: 메일함 크기 입력 칸의 상한 안내.
const sys = systemLimits(env);
return json({ ok: true, users, external, externalSource: authMode(env) === 'webmaker' ? { kind: 'webmaker', ok: ext.ok, error: ext.error || null, site: ext.site || null, readAt: ext.readAt || null } : null, defaultMailbox: mailboxOf(env), domain: mailDomain(env), authMode: authMode(env), policy: await readPasswordPolicy(env), system: sys, limits: await readAdminLimits(env), accounts: { used: await accountCount(env), limit: sys.accounts },
me: mine ? { id: user.id, login: user.login, name: user.name, source: user.source, services: { pop: mine.pop, imap: mine.imap }, usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) } : null });
}
const body = method === 'DELETE' ? {} : await request.json().catch(() => null); if (!body) return bad('bad_request');
if (method === 'POST' && !target) {
// 메일함 크기 값은 계정을 만들기 전에 검사합니다(틀린 값으로 계정만 생기고 저장은 실패하는 일 방지).
if (body.quotaBytes !== undefined) checkUserQuota(env, body.quotaBytes);
const id = await createUser(env, body, user.login);
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
if (body.quotaBytes !== undefined) await writeUserQuota(env, id, body.quotaBytes);
await audit(env, user.login, 'user_create', id, { login: clean(body.login, 64) }); return json({ ok: true, id }, 201);
}
// 'me' 는 관리자 자신(www 계정)의 메일 프로그램 접속 설정만 바꿉니다. www 계정 자체는 www 사이트에서 관리합니다.
if (method === 'PATCH' && target === 'me') { if (user.source !== 'www') return bad('not_found', 404); const prefs = await writePrefs(env, user, body.services || {}); await audit(env, user.login, 'user_update', user.id, { services: true }); return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } }); }
// 웹메이커 계정(1.0.8, id 'wm:…'): 이름·비밀번호·역할·상태는 웹메이커가 주인이라 받지 않고, 메일 프로그램 접속 허용(services)과 메일함 크기(quotaBytes)만 저장합니다. 삭제도 할 수 없습니다.
if (target && target.startsWith('wm:')) {
if (method !== 'PATCH') return bad('webmaker_managed', 403);
const ext = await externalUsers(env); const acc = ext.accounts.find((a) => a.id === target); if (!acc) return bad('not_found', 404);
if (body.quotaBytes !== undefined) { checkUserQuota(env, body.quotaBytes); await writeUserQuota(env, target, body.quotaBytes); }
const prefs = body.services && typeof body.services === 'object' ? await writePrefs(env, acc, body.services) : await readPrefs(env, acc);
await audit(env, user.login, 'user_update', target, { services: !!body.services, ...(body.quotaBytes !== undefined ? { quotaBytes: Number(body.quotaBytes) } : {}) });
return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } });
}
if (method === 'PATCH' && target) {
// 0.9.0: 자기 계정의 비밀번호는 관리자 페이지에서 바꾸지 않습니다(웹 메일 "도구 → 설정" 에서 현재 비밀번호 확인을 거쳐 바꿈).
// 다른 계정의 비밀번호는 관리자가 한 번 입력으로 재설정합니다. 화면 보조 필드(passwordConfirm/currentPassword)는 무시합니다.
const { currentPassword, passwordConfirm, quotaBytes, ...fields } = body;
if (fields.password && target === user.id) return bad('self_password_settings', 422);
// 자기 계정의 역할을 내리거나 정지하면 관리자 페이지에서 바로 쫓겨나고(독립 실행이면 남은 관리자가 없어 영영 잠김) 되돌릴 길이 없으므로 거부합니다(0.9.3).
if (target === user.id && ((fields.role !== undefined && fields.role !== 'admin') || fields.status === 'suspended')) return bad('cannot_modify_self', 409);
// 메일함 크기는 먼저 검사만 하고, 계정 수정이 성공한 뒤에 저장합니다(예전에는 없는 계정·정책 위반 비밀번호로 실패해도 크기가 먼저 저장됐습니다, 0.9.3).
if (quotaBytes !== undefined) checkUserQuota(env, quotaBytes);
await updateUser(env, target, fields);
if (quotaBytes !== undefined) await writeUserQuota(env, target, quotaBytes);
await audit(env, user.login, 'user_update', target, { fields: Object.keys(fields).filter((k) => k !== 'password'), password: fields.password ? true : undefined, ...(quotaBytes !== undefined ? { quotaBytes: Number(quotaBytes) } : {}), ...(fields.status === 'active' ? { approve: true } : {}) });
return json({ ok: true });
}
if (method === 'DELETE' && target) { if (target === user.id) return bad('cannot_delete_self', 409); await deleteUser(env, target); await audit(env, user.login, 'user_delete', target); return json({ ok: true }); }
return bad('not_found', 404);
}
if (sessionScope === 'admin') return bad('forbidden', 403);
// ---------------------------------------------------------------- 웹 메일
if (path[0] === 'settings' && method === 'GET') return await settings(env, user);
if (path[0] === 'prefs' && method === 'GET') return json({ ok: true, prefs: await readPrefs(env, user) });
if (path[0] === 'prefs' && method === 'PATCH') {
// 화면 기본값(remoteImages)·메일 프로그램 접속 허용(pop/imap)·메일 전달(forward)·자동 응답(autoReply)을 사용자가 직접 바꿉니다.
// 아는 키만 저장하고, 전달 주소·자동 응답 기간 검사 실패는 writePrefs 가 던지는 코드(forward_invalid 등)를 422 로 돌려줍니다.
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
const next = await writePrefs(env, user, body);
await audit(env, user.login, 'prefs_update', null, { keys: Object.keys(body).slice(0, 10), ...(body.forward ? { forward: next.forward.enabled ? next.forward.to : 'off' } : {}), ...(body.autoReply ? { autoReply: next.autoReply.enabled ? (next.autoReply.noLimit ? 'unlimited' : `${next.autoReply.start || 'now'}~${next.autoReply.end}`) : 'off' } : {}) });
return json({ ok: true, prefs: next, autoReplyActive: autoReplyActive(next.autoReply) });
}
// 비밀번호 변경(0.9.0~): 웹 메일 설정 화면. 게이트웨이 계정만(www 관리자는 www 사이트에서 관리). 현재 비밀번호가 맞고 새 비밀번호 두 입력이 같아야 합니다.
// 바꾸면 이 계정의 세션이 모두 끝나므로(탈취된 세션 차단) 화면은 다시 로그인하게 안내합니다.
if (path[0] === 'password' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
// 외부 계정(www 관리자·웹메이커 계정)의 비밀번호는 그쪽 서비스에서 바꿉니다(1.0.8: webmaker_managed).
if (user.source !== 'local') return bad(user.source === 'webmaker' ? 'webmaker_managed' : 'www_managed', 403);
const password = String(body.password ?? ''); if (!password) return bad('bad_request');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
// 현재 비밀번호 확인에도 로그인과 같은 제한(계정·IP 당 15분 10회)을 둡니다. 훔친 세션으로 비밀번호를 무제한 대입하지 못하게(0.9.3).
const pwKeys = ['pwchange:' + user.id, 'pwchange:' + ip];
if (pwKeys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
if (!(await checkCurrentPassword(env, user.id, body.currentPassword))) { pwKeys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, user.login, 'password_change_denied', user.id, { ip }); return bad('current_password_invalid', 422); }
await updateUser(env, user.id, { password }); await audit(env, user.login, 'password_change', user.id, { ip });
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: 'mail', path: prefix }) });
}
if (path[0] !== 'mail') return bad('not_found', 404);
const [, id, action, sub] = path;
const url = new URL(request.url);
// 메일함은 언제나 로그인한 계정의 메일함입니다(0.9.0: 관리자의 다른 메일함 열람 삭제). ?mailbox= 가 와도 무시합니다.
const mailbox = user.mailbox, scope = user;
const canSee = (m) => m.mailbox === mailbox;
if (id === 'status' && method === 'GET') return json({ ok: true, mailbox, ...await outboundStatus(env, { force: url.searchParams.get('force') === '1' }) });
// 동기화 시작. 목록 화면의 1분 자동 새로 고침은 { quick: true } 를 보내 "빠른 확인"(이미 아는 메일에서 멈춤)으로,
// 설정 화면의 "지금 동기화" 버튼은 본문 없이 보내 전체 훑기로 동작합니다(store.syncMailbox 설명 참고).
if (id === 'sync' && method === 'POST') { const b = await request.json().catch(() => ({})); const quick = b?.quick === true; return json({ ok: true, ...startSync(env, quick ? 'poll' : 'manual', { quick }) }); }
// usageBytes/quotaBytes(0.9.0): 사용량은 MIME 기준(저장 원본 크기), quotaBytes 는 이 메일함에 적용 중인 최대 크기(0 = 제한 없음). 사이드바 사용량 막대가 씁니다.
if (id === 'counts' && method === 'GET') return json({ ok: true, counts: await folderCounts(env, mailbox), usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), mailbox, syncing: isSyncing(env) });
if (id === 'sanitize' && method === 'POST') { const b = await request.json().catch(() => ({})); return json({ ok: true, ...sanitizeHtml(b.html) }); }
if (id === 'bulk' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.ids)) return bad('bad_request');
let done = 0;
for (const mid of body.ids.slice(0, 200)) { const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(String(mid)).first(); if (!m || !canSee(m)) continue; try { if (await applyAction(env, m, body, user)) done++; } catch { /* 개별 실패는 건너뜁니다 */ } }
return json({ ok: true, done });
}
if (method === 'GET' && !id) {
const folder = url.searchParams.get('folder') || 'inbox', q = clean(url.searchParams.get('q'), 200), unreadOnly = url.searchParams.get('unread') === '1', flaggedOnly = url.searchParams.get('flagged') === '1', attachOnly = url.searchParams.get('attach') === '1';
if (!FOLDERS.includes(folder)) return bad('validation', 422);
const page = Math.max(0, Math.min(100000, Number(url.searchParams.get('page')) || 0)), size = Math.min(100, Math.max(10, Number(url.searchParams.get('size')) || 50));
// 검색어의 % _ \ 는 LIKE 와일드카드가 아니라 글자 그대로 찾습니다(0.9.0 수정, 예전에는 "_" 하나로 모든 메일이 나왔음).
const like = Array(4).fill('%' + q.replace(/[\\%_]/g, '\\$&') + '%');
const where = `mailbox=? AND folder=? AND (subject LIKE ? ESCAPE '\\' OR text LIKE ? ESCAPE '\\' OR sender LIKE ? ESCAPE '\\' OR recipients_json LIKE ? ESCAPE '\\')${unreadOnly ? ' AND read_at IS NULL' : ''}${flaggedOnly ? " AND EXISTS(SELECT 1 FROM imap_flags f WHERE f.message_id=messages.id AND f.flags LIKE '%\\\\Flagged%')" : ''}${attachOnly ? " AND EXISTS(SELECT 1 FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL))" : ''}`;
// attachOnly: "첨부" 보기 필터. 예전에는 화면이 받아 온 한 페이지(50통) 안에서만 걸러, 페이지 수·합계는 전체 기준인데
// 목록은 비어 보이는 문제가 있었습니다. 서버 조건으로 옮겨 합계·페이지가 필터와 일치합니다.
const rows = await env.DB.prepare(`SELECT id,direction,folder,sender,recipients_json,subject,substr(text,1,160) AS preview,state,source,raw_status,read_at,received_at,created_at,updated_at,mailbox,
(SELECT COUNT(*) FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL)) AS attachment_count,(SELECT COUNT(*) FROM pop_deliveries d WHERE d.message_id=messages.id) AS pop_delivered,
(SELECT flags FROM imap_flags f WHERE f.message_id=messages.id) AS flags
FROM messages WHERE ${where} ORDER BY received_at DESC LIMIT ? OFFSET ?`).bind(mailbox, folder, ...like, size, page * size).all();
const total = await env.DB.prepare(`SELECT COUNT(*) n FROM messages WHERE ${where}`).bind(mailbox, folder, ...like).first();
return json({ ok: true, messages: rows.results.map((m) => ({ ...m, flagged: String(m.flags || '').includes('\\Flagged'), flags: undefined })), total: total.n, page, size, counts: await folderCounts(env, mailbox), mailbox, usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), syncing: isSyncing(env) });
}
if (method === 'POST' && !id) { const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); if (body.html) body.html = stripPrefix(body.html, prefix, env); return json({ ok: true, id: await saveDraft(env, body, undefined, scope) }, 201); }
if (!id) return bad('not_found', 404);
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!mail || !canSee(mail)) return bad('not_found', 404);
if (method === 'GET') {
const attachments = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,(bytes IS NOT NULL) AS available FROM attachments WHERE message_id=? ORDER BY rowid').bind(id).all()).results;
if (action === 'original') {
const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first(); if (!raw) return bad('not_found', 404);
return new Response(Buffer.from(raw.eml), { headers: { 'Content-Type': 'message/rfc822', 'Content-Disposition': `attachment; filename="${id}.eml"`, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
}
if (action === 'attachments' && !sub) return json({ ok: true, attachments });
if (action === 'attachments' && sub) {
const row = await env.DB.prepare('SELECT filename,content_type,bytes FROM attachments WHERE id=? AND message_id=?').bind(sub, id).first(); if (!row?.bytes) return bad('not_found', 404);
const inline = url.searchParams.get('inline') === '1' && /^image\//.test(row.content_type);
return new Response(Buffer.from(row.bytes), { headers: { 'Content-Type': inline ? row.content_type : 'application/octet-stream', 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.filename)}`, 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } });
}
if (action === 'files' && !sub) return json({ ok: true, files: await listLargeFiles(env, id) }); // 대용량 파일 목록(0.9.3)
if (action) return bad('not_found', 404);
if (!mail.read_at && mail.direction === 'inbound' && url.searchParams.get('peek') !== '1') await env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(now(), id).run();
const popDeliveries = (await env.DB.prepare('SELECT user_id,delivered_at,via FROM pop_deliveries WHERE message_id=?').bind(id).all()).results;
const flags = JSON.parse((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first())?.flags || '[]');
// autoActions: 이 메일에 한 자동 처리(전달·자동 응답) 기록. 상세 정보 상자에 "전달됨 → 주소 / 자동 응답 보냄" 으로 보입니다.
// largeFiles(0.9.3): 보내는 메일(초안·보낸 메일)에 딸린 대용량 파일과 링크 상태(남은 횟수·기한·만료).
return json({ ok: true, message: { ...mail, original_html: undefined, previewHtml: previewHtml(mail.html, id, attachments, prefix), attachments, popDeliveries, autoActions: await autoActionsOf(env, id), flagged: flags.includes('\\Flagged'), answered: flags.includes('\\Answered'), largeFiles: mail.direction === 'outbound' ? await listLargeFiles(env, id) : [] } });
}
// 대용량 파일 떼기(0.9.3): 초안이면 파일 삭제, 보낸 메일이면 링크 끊기(파일 삭제·만료 처리). 업로드는 index.mjs 의 스트리밍 경로(POST /api/files)가 맡습니다.
if (method === 'DELETE' && action === 'files' && sub) { await removeLargeFile(env, sub, mailbox, id); await audit(env, user.login, 'large_file_remove', sub, { message: id }); return json({ ok: true }); }
if (method === 'POST' && action === 'send') {
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
const b = await request.json().catch(() => ({}));
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~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
if ((request.headers.get('content-type') || '').includes('application/json')) {
const body = await request.json().catch(() => null); if (!body || typeof body.from !== 'string' || !Array.isArray(body.ids)) return bad('bad_request');
const origin = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(body.from).first(); if (!origin || !canSee(origin)) return bad('not_found', 404);
const ids = await copyAttachments(env, id, origin.id, body.ids.map(String).slice(0, 50));
return json({ ok: true, ids }, 201);
}
const form = await request.formData().catch(() => null); if (!form) return bad('bad_request');
const ids = []; const inline = url.searchParams.get('inline') === '1';
for (const file of form.getAll('files')) { if (typeof file === 'string') continue; ids.push(await addAttachment(env, id, { filename: file.name, contentType: file.type, bytes: Buffer.from(await file.arrayBuffer()), inline })); }
return json({ ok: true, ids, urls: ids.map((aid) => `${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(aid)}?inline=1`) }, 201);
}
if (method === 'DELETE' && action === 'attachments' && sub) { await removeAttachment(env, id, sub); return json({ ok: true }); }
if (method === 'PATCH' && !action) {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
if (!(await applyAction(env, mail, body, user))) {
// 동작도 아니고 초안 내용도 없는 본문(예: {})은 거부합니다. 예전에는 saveDraft 로 흘러가 받는 사람·제목·본문이 빈 값으로 덮였습니다(0.8.16 수정).
if (!['to', 'cc', 'bcc', 'subject', 'html', 'text'].some((k) => body[k] !== undefined)) return bad('bad_request');
if (body.html) body.html = stripPrefix(body.html, prefix, env);
await saveDraft(env, body, id, scope);
}
return json({ ok: true, id });
}
if (method === 'DELETE' && !action) {
if (mail.folder !== 'trash') return bad('mail_trash_first', 409);
await purge(env, mail); await audit(env, user.login, 'mail_delete', id); return json({ ok: true });
}
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;
// 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 } : {}) });
}
}
/**
* 회원 가입(0.9.0~). 관리자가 켜 두었을 때만 받습니다(아니면 404 signup_disabled). IP 당 1시간 5회로 제한합니다.
* 승인 옵션이 켜져 있으면 승인 대기 계정으로 만들고(로그인 불가, 관리자 페이지에 "승인 대기" 로 보임), 아니면 바로 쓸 수 있는 일반 계정을 만듭니다.
* 역할은 언제나 '사용자' 이고 비밀번호 정책·계정 수 제한은 관리자가 만드는 계정과 같습니다.
*/
async function signup(env, request, ip) {
const cfg = await readSignup(env); if (!cfg.enabled) return bad('signup_disabled', 404);
if (env.RATE.blocked('signup:' + ip, { limit: 5 })) return bad('rate_limited', 429);
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const password = String(body.password ?? '');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
env.RATE.hit('signup:' + ip, { limit: 5, windowSec: 3600 });
const id = await createUser(env, { login: body.login, name: body.name, password, role: 'user' }, 'signup', { pending: cfg.approval });
await audit(env, null, 'signup', id, { login: clean(body.login, 64), ip, pending: cfg.approval });
return json({ ok: true, id, pending: cfg.approval }, 201);
}
/** 관리자 설정 응답: 회원 가입·관리자 제한·서비스 스위치 + 시스템 제한(읽기 전용)·계정 수. */
async function adminConfig(env) {
const sys = systemLimits(env);
// largeFiles / systemLargeFiles / largeFileUsage(0.9.3): 대용량 첨부파일의 관리자 설정, 시스템 상한(읽기 전용), 디스크 사용량.
return { signup: await readSignup(env), limits: await readAdminLimits(env), services: await readServices(env), system: sys, accounts: { used: await accountCount(env), limit: sys.accounts }, largeFiles: await readLargeFileSettings(env), systemLargeFiles: systemLargeFiles(env), largeFileUsage: await largeFileStats(env) };
}
/**
* 내장 Haraka → 게이트웨이 수신 전달(1.0.0). Haraka 플러그인(haraka/plugins/arimail.js)이 같은 서버 안에서 로컬 HTTP 로 부릅니다.
* GET /api/inbound/haraka/rcpt?addr=… RCPT 단계: 이 주소가 우리 메일함인지(아니면 Haraka 가 "No such user" 로 바로 거부해 역산란을 막습니다)
* POST /api/inbound/haraka DATA 가 끝난 메일 원본(본문) + X-AriMail-Id(트랜잭션 id) / X-AriMail-From / X-AriMail-Rcpt(JSON 배열)
* 인증: X-AriMail-Token 이 설정 파일의 HARAKA_TOKEN(자동 생성)과 같아야 하고, 수신 방식이 haraka 일 때만 열립니다(그 밖에는 404).
* 응답: 저장하면 200 { imported }, 건너뛰면 200 { skipped }(플러그인이 quota_exceeded/storage_full 은 452 로 되돌려 보낸 쪽이 나중에 다시 시도), 실패는 500(플러그인이 451).
* 기록: webhook_events 에 type 'haraka.received' 로 남겨 상태 화면의 수신 기록과 함께 보입니다.
*/
async function harakaInbound(env, request, path, method) {
// 1.0.7: Haraka 는 서버에 하나이므로 이 엔드포인트도 하나입니다. 토큰은 기본 도메인의 HARAKA_TOKEN(haraka/run.mjs 가 그 설정 파일을 읽음)으로 확인하고,
// 받는 사람 주소의 @ 뒤로 어느 도메인(테넌트)의 메일함인지 고릅니다. 수신 방식이 haraka 인 도메인이 하나도 없으면 예전처럼 404 입니다.
const tenants = env.TENANTS, primary = tenants?.primary?.env || env;
const receivers = tenants ? tenants.envs().filter((e) => inboundMode(e) === 'haraka') : (inboundMode(env) === 'haraka' ? [env] : []);
if (!receivers.length) return bad('not_found', 404);
const token = request.headers.get('x-arimail-token') || '';
if (!primary.HARAKA_TOKEN || !timingEqual(String(token), String(primary.HARAKA_TOKEN))) return bad('unauthenticated', 401);
/** 주소가 속한 도메인의 env(수신 방식이 haraka 인 것만). 테넌트가 없으면(시험 등) 이 env. */
const pick = (addr) => { if (!tenants) return receivers[0]; const t = tenants.byAddress(addr); return t && t.entry.enabled !== false && inboundMode(t.env) === 'haraka' ? t.env : null; };
if (path[2] === 'rcpt' && method === 'GET') { const addr = bareAddress(new URL(request.url).searchParams.get('addr') || ''); const target = pick(addr); return json({ ok: true, local: !!target && (await mailboxList(target)).includes(addr) }); }
if (!path[2] && method === 'POST') {
const raw = Buffer.from(await request.arrayBuffer()); if (!raw.length) return bad('bad_request');
let rcpt = []; try { rcpt = JSON.parse(request.headers.get('x-arimail-rcpt') || '[]'); } catch { rcpt = []; }
if (!Array.isArray(rcpt)) rcpt = [];
const id = cleanStr(request.headers.get('x-arimail-id'), 120) || newId(), stamp = nowIso(), from = request.headers.get('x-arimail-from') || '';
// 받는 사람을 도메인별로 묶어 각 도메인의 DB 에 저장합니다(한 트랜잭션이 두 도메인의 주소를 담을 수 있음). 도메인을 못 찾는 주소는 첫 수신 도메인으로(예전 동작).
const groups = new Map();
for (const a of rcpt) { const target = pick(bareAddress(a)) || receivers[0]; if (!groups.has(target)) groups.set(target, []); groups.get(target).push(a); }
if (!groups.size) groups.set(receivers[0], []);
const record = (target, status, detail) => target.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind('haraka:' + id, 'haraka.received', 'haraka:' + id, stamp, status, detail || null).run();
const results = []; let failed = null;
for (const [target, list] of groups) {
try {
const r = await ingestRawMail(target, raw, { id, rcpt: list, from, known: await mailboxList(target) });
// 같은 트랜잭션이 다시 오면(Haraka 재시도) 이미 'stored' 로 남은 기록을 'skipped' 로 덮어쓰지 않습니다.
if (r.skipped !== 'exists') await record(target, r.imported ? 'stored' : 'skipped', r.skipped || null);
results.push(r);
} catch (e) { console.error('Haraka 수신 저장 실패', target.DOMAIN || '', id, e.message); await record(target, 'failed', String(e.message || 'ingest_failed').slice(0, 300)); failed = failed || e; }
}
const imported = results.reduce((n, r) => n + Number(r.imported || 0), 0);
if (failed && !imported) return bad(failed.message || 'ingest_failed', 500);
// 도메인이 하나면 예전과 같은 모양({ ok, imported, id, mailbox, … }), 여럿이면 합계와 도메인별 결과.
if (results.length === 1) return json({ ok: true, ...results[0] });
const skipped = imported ? undefined : results.find((r) => r.skipped)?.skipped;
return json({ ok: true, imported, ...(skipped ? { skipped } : {}), results });
}
return bad('not_found', 404);
}
async function webhook(env, request) {
const raw = Buffer.from(await request.arrayBuffer());
const headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') };
if (!env.RESEND_WEBHOOK_SECRET) { console.error('웹훅 비밀키 없음 — RESEND_WEBHOOK_SECRET 를 설정하세요'); return bad('webhook_not_configured', 503); }
const check = verifySvix(env.RESEND_WEBHOOK_SECRET, headers, raw);
if (!check.ok) return bad(check.error, 401);
let event; try { event = JSON.parse(raw.toString('utf8')); } catch { return bad('bad_request'); }
const eventId = headers['svix-id'] || uid(), stamp = now();
const type = String(event?.type || ''), data = event?.data || {};
// 같은 svix-id 는 한 번만 처리합니다. 단, 저장에 실패해 500 을 돌려준 이벤트('failed')는 Resend 가 같은 id 로
// 다시 보내므로 중복으로 보지 않고 다시 처리합니다(예전에는 재시도가 duplicate 로 끝나 메일이 저장되지 않았습니다).
if (await env.DB.prepare("SELECT id FROM webhook_events WHERE id=? AND status<>'failed'").bind(eventId).first()) return json({ ok: true, duplicate: true });
const record = (status, detail) => env.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind(eventId, type, data.email_id || null, stamp, status, detail || null).run();
if (type !== 'email.received') { await record('ignored', 'type'); return json({ ok: true, ignored: type }); }
const known = await knownMailboxes(env);
if (!addresses([...(data.to || []), ...(data.cc || []), ...(data.bcc || []), ...(data.received_for || [])]).some((a) => known.includes(a))) { await record('ignored', 'other_mailbox'); return json({ ok: true, ignored: 'other_mailbox' }); }
try {
const result = await ingestReceived(env, data.email_id, { source: 'webhook', known, summary: { from: data.from, to: data.to, subject: data.subject, created_at: data.created_at, attachments: data.attachments } });
await record(result.imported ? 'stored' : 'skipped', result.skipped || result.raw);
return json({ ok: true, ...result });
} catch (e) {
// 5xx 를 돌려주면 Resend 가 재시도합니다. 실패 이유는 기록만 남깁니다.
await record('failed', (e.message + ' ' + (e.providerMessage || '')).slice(0, 300));
return bad(e.message || 'ingest_failed', 500);
}
}
/**
* 웹 메일 설정 화면용 정보: 이 계정으로 메일 프로그램을 설정할 때 필요한 값(IMAP·SMTP·POP 호스트·포트·TLS 상태)과 사용자 설정.
* 서버 운영 정보(웹훅·동기화·저장 통계)는 0.8.4 부터 관리자 페이지의 adminStatus 로 옮겼습니다.
* 0.9.0: services(관리자가 POP/IMAP 서비스 자체를 껐는지), limits(첨부 한 개 최대 MIME 크기·받는 사람 수), quota(메일함 최대 크기·사용량),
* canChangePassword(게이트웨이 계정이면 설정 화면에서 비밀번호를 바꿀 수 있음)를 더했습니다.
*/
async function settings(env, user) {
// host: 웹 메일 주소. serverHost(1.0.7): 메일 프로그램이 접속할 IMAP·POP·SMTP 서버 이름 — 서버에 하나뿐인 메일 포트·인증서는 기본 도메인의 호스트 이름이므로
// 추가 도메인의 사용자도 그 이름으로 접속합니다(자기 도메인 이름으로 접속하면 인증서 이름이 맞지 않음).
const host = env.PUBLIC_HOST || 'mail.arihakdang.com', serverHost = env.MAIL_SERVER_HOST || host;
const plainOk = await plainLoginAllowed(env);
return json({
ok: true, mailbox: user.mailbox, user: { login: user.login, name: user.name, role: user.role, source: user.source }, host, prefs: await readPrefs(env, user),
// 보관 정책: 0.8.0 부터 기한 없음. 화면은 이 값으로 "삭제할 때까지 보관" 문구를 보여 줍니다.
retention: 'unlimited',
services: await readServices(env), limits: { attachmentBytes: await attachmentLimit(env), recipients: await recipientLimit(env) },
// managedBy(1.0.8): 비밀번호를 어디서 바꾸는지(local 이면 여기, www / webmaker 면 그 서비스). 화면은 이 값으로 안내 문구를 고릅니다.
quota: { usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) }, canChangePassword: user.source === 'local', managedBy: user.source, policy: await readPasswordPolicy(env), largeFiles: await readLargeFileSettings(env),
// SMTP(보내는 메일 서버): 이 게이트웨이의 제출 서버(smtp.mjs). 계정·비밀번호는 IMAP·POP 과 같은 로그인 정보이고,
// 발송 서비스의 API 키는 서버 내부에만 있으므로 화면에 키 관련 정보(설정 여부 포함)를 내보내지 않습니다.
smtp: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { smtps: Number(env.SMTPS_PORT || 465), starttls: Number(env.SMTP_PORT || 587) }, from: `${user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang'} <${user.mailbox}>` },
// plaintext(0.9.7): 110/143 에서 TLS 없이 로그인할 수 있는지(관리자 설정 "평문 로그인 허용" 또는 POP_ALLOW_PLAINTEXT=1). 화면이 110 포트 줄에 표시합니다.
pop: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { pop3s: Number(env.POPS_PORT || 995), starttls: Number(env.POP_PORT || 110) }, plaintext: plainOk },
imap: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { imaps: Number(env.IMAPS_PORT || 993), starttls: Number(env.IMAP_PORT || 143) }, plaintext: plainOk, mailboxes: ['INBOX', 'Sent', 'Drafts', 'Trash'] },
});
}
/**
* 관리자 페이지 "서버 상태": 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록·서비스 포트 상태. 관리자 세션으로만 볼 수 있습니다.
* 0.9.0: stats(메일 현황 — 일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)와 services(POP/IMAP 스위치)를 더했습니다.
*/
async function adminStatus(env) {
const last = (await env.DB.prepare('SELECT * FROM sync_runs ORDER BY id DESC LIMIT 5').all()).results;
const webhooks = (await env.DB.prepare("SELECT status,COUNT(*) n,MAX(received_at) last FROM webhook_events GROUP BY status").all()).results;
const counts = await env.DB.prepare("SELECT COUNT(*) total,SUM(CASE WHEN raw_status='stored' THEN 1 ELSE 0 END) raw_stored,SUM(CASE WHEN raw_status='built' THEN 1 ELSE 0 END) raw_built,SUM(CASE WHEN source='webhook' THEN 1 ELSE 0 END) via_webhook,SUM(CASE WHEN source='sync' THEN 1 ELSE 0 END) via_sync,SUM(CASE WHEN provider_id LIKE 'haraka:%' THEN 1 ELSE 0 END) via_haraka FROM messages WHERE direction='inbound'").first();
const perMailbox = (await env.DB.prepare('SELECT mailbox,COUNT(*) n,COALESCE(SUM(raw_size),0) bytes FROM messages GROUP BY mailbox ORDER BY mailbox').all()).results;
const pop = await env.DB.prepare('SELECT COUNT(*) n,COUNT(DISTINCT user_id) accounts,MAX(delivered_at) last FROM pop_deliveries').first();
const host = env.PUBLIC_HOST || 'mail.arihakdang.com';
return json({
ok: true, host, version: env.APP_VERSION || null, mailbox: mailboxOf(env), tls: !!env.POP_TLS_ACTIVE, retention: 'unlimited',
ports: { pop3s: Number(env.POPS_PORT || 995), pop3: Number(env.POP_PORT || 110), imaps: Number(env.IMAPS_PORT || 993), imap: Number(env.IMAP_PORT || 143), smtps: Number(env.SMTPS_PORT || 465), submission: Number(env.SMTP_PORT || 587) },
services: await readServices(env), plainLogin: await plainLoginAllowed(env),
// 1.0.0: 수·발신 방식과 발신 서비스 상태(60초 캐시), Haraka 상태(haraka/run.mjs 가 쓰는 상태 파일), 설정 파일 경로.
mail: { inbound: inboundMode(env), outbound: outboundMode(env), status: await outboundStatus(env).catch((e) => ({ provider: e.message })), haraka: usesHaraka(env) ? harakaStatus(env) : null, configFile: configFile(env) },
webhook: { url: `https://${host}/api/webhooks/resend`, secretConfigured: !!env.RESEND_WEBHOOK_SECRET, events: webhooks },
sync: { intervalMinutes: Math.round(Number(env.SYNC_INTERVAL_MS || 3600000) / 60000), running: isSyncing(env), runs: last },
storage: counts, mailboxes: perMailbox, pop: { deliveries: pop },
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
});
}