메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 AriMail 도입 수정(KST 월 경계, 발신 키 오류, 도메인별 보장량, 100% 제한, 발송 큐·속도, 운영 상태, SSO) (§15~§18·§0.4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a014e3cd05
commit
a1cde81332
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user