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

297 lines
24 KiB
JavaScript
Raw Normal View History

/**
* 시스템 제한·관리자 제한·사용량·현황 (0.9.0~).
*
* 세 층의 제한이 있습니다. 위 층이 아래 층의 상한이며, 어느 층이든 0 은 "그 층에서는 제한하지 않음" 입니다.
* 1) 시스템 설정(systemLimits): 설치 시 환경 파일(gateway.env)에 적는 값. 메일 시스템 전체의 상위 설정이라 관리자 화면에서 바꿀 수 없습니다.
* LIMIT_ACCOUNTS 허용 가능한 메일 계정 수(mail_users 행 수)
* LIMIT_TOTAL_BYTES 모든 메일함을 합친 최대 저장 용량(MIME 기준)
* LIMIT_MAILBOX_BYTES 메일함 하나의 최대 용량(MIME 기준)
* LIMIT_ATTACHMENT_BYTES 첨부 파일 한 개의 최대 크기(MIME 기준, 아래 mimeSize 참고)
* LIMIT_RECIPIENTS 한 메일의 최대 받는 사람 수(받는 사람 + 참조 + 숨은 참조)
* LIMIT_MONTHLY_MESSAGES 한 달(매월 1일~말일)의 최대 수발신 통수(보낸 메일 + 받은 메일)
* 크기 값은 바이트 수 또는 K / M / G 접미사(예: 500M, 2G)로 적습니다.
* 2) 관리자 설정(readAdminLimits): 관리자 페이지에서 정하는 값(settings 표의 limits 키). 시스템 제한이 있으면 그 값 이내로만 저장됩니다.
* mailboxBytes(계정별 기본 메일함 크기) · attachmentBytes(첨부 최대 크기) · recipients(동시 수신자 수)
* 3) 계정별 설정(readUserQuota): 관리자가 계정마다 정하는 메일함 최대 크기(settings 표의 quota:<계정 id> 키). 역시 시스템 제한 이내.
*
* 왜 "MIME 기준" 인가: 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 4/3 배(37%)로 늘어납니다. 발송 서비스(Resend)의 한도와 저장 공간은
* 이 늘어난 크기에 맞춰 소진되므로, 모든 크기 제한과 사용량을 원본(.eml)의 바이트, 즉 MIME 변환 뒤의 크기로 셉니다.
* 화면은 이 점을 안내하고, 첨부 파일을 올릴 때는 파일 크기를 mimeSize() 로 바꿔 비교합니다.
*
* 그 밖에 이 모듈이 맡는 것: POP/IMAP 서비스 전체 켜기·끄기(services 키), 회원 가입 설정(signup 키), 서버 상태 화면의 메일 현황(stats).
* auth.mjs 가 이 모듈을 들여오므로 여기서는 auth.mjs 를 들여오지 않습니다(순환 방지). 메일함 주소·계정 id 는 인자로 받습니다.
*/
import { now } from './util.mjs';
import { isWebmaker, webmakerByMailbox } from './webmaker.mjs';
// ------------------------------------------------------------------ 상수
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */
export const HARD_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 첨부 한 개(MIME 기준). Resend 는 메일 전체 40MB 까지 받습니다.
export const HARD_RECIPIENTS = 50; // Resend 의 받는 사람 한도
/** base64 로 바뀐 뒤의 크기(줄바꿈 제외). 첨부 파일의 원본 바이트를 MIME 기준 크기로 바꿀 때 씁니다. */
export const mimeSize = (bytes) => Math.ceil(Number(bytes || 0) / 3) * 4;
/** 0 이 아닌 값들 중 가장 작은 값. 모두 0(제한 없음)이면 0. 여러 층의 제한을 합칠 때 씁니다. */
export const minNonZero = (...values) => { const positive = values.map((v) => Number(v) || 0).filter((v) => v > 0); return positive.length ? Math.min(...positive) : 0; };
/** 사용 비율(%). 제한이 0(없음)이면 null. 화면이 "8개 사용 중 / 최대 10개 (80%)" 를 그리는 데 씁니다. */
export const percentOf = (used, limit) => (limit > 0 ? Math.min(999, Math.round((Number(used || 0) / limit) * 100)) : null);
/**
* 크기 문자열을 바이트 수로. "500M", "2G", "1024K", "123456" 을 받습니다(소수 허용: 1.5G). 비어 있거나 해석할 수 없으면 0(제한 없음).
* 환경 파일에 사람이 적는 값이므로 대소문자·공백·"B" 접미사(MB, GiB)는 너그럽게 받습니다.
*/
export function parseSize(value) {
const s = String(value ?? '').trim().toUpperCase().replace(/I?B$/, '');
const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?)$/); if (!m) return 0;
const unit = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[m[2]];
return Math.floor(Number(m[1]) * unit);
}
const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; };
/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */
export function systemLimits(env) {
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),
};
}
// ------------------------------------------------------------------ settings 표 읽기·쓰기
async function readJson(env, key, defaults) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(key).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
return { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) };
}
async function writeJson(env, key, value) {
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, JSON.stringify(value), now()).run();
}
/** 제한 값 하나를 검사합니다: 0 이상의 정수여야 하고(아니면 limit_invalid), 시스템 제한이 있으면 그 이내여야 합니다(limit_exceeds_system). 오류에 field 를 붙입니다. */
function checkAgainstSystem(value, systemValue, field) {
// 숫자(또는 숫자 문자열)만 받습니다. 예전에는 true 가 1 로 저장돼 "받는 사람 1명" 같은 뜻밖의 제한이 생겼습니다(0.9.3).
const n = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN;
if (!Number.isInteger(n) || n < 0) { const e = new Error('limit_invalid'); e.field = field; throw e; }
if (systemValue > 0 && n > systemValue) { const e = new Error('limit_exceeds_system'); e.field = field; e.limit = systemValue; throw e; }
return n;
}
export const DEFAULT_ADMIN_LIMITS = { mailboxBytes: 0, attachmentBytes: 0, recipients: 0 };
/** 관리자 제한(settings 의 limits). 저장된 값이 손상됐거나 시스템 제한을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎아 돌려줍니다. */
export async function readAdminLimits(env) {
const stored = await readJson(env, 'limits', DEFAULT_ADMIN_LIMITS), sys = systemLimits(env), out = {};
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) { const v = nonNegInt(stored[k]); const cap = sys[k]; out[k] = cap > 0 && v > cap ? cap : v; }
return out;
}
/** 관리자 제한을 저장합니다(관리자 페이지). 각 값은 시스템 제한 이내여야 하며 0 은 "시스템 값을 따름" 입니다. */
export async function writeAdminLimits(env, patch) {
const current = await readAdminLimits(env), sys = systemLimits(env), next = { ...current };
// 내장 상한(첨부 25MB, 받는 사람 50)도 시스템 값처럼 넘길 수 없습니다. 예전에는 그보다 큰 값이 저장돼 관리자 화면과 실제 적용값이 달랐습니다(0.9.3).
const hard = { attachmentBytes: HARD_ATTACHMENT_BYTES, recipients: HARD_RECIPIENTS };
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) if (patch[k] !== undefined) next[k] = checkAgainstSystem(patch[k], minNonZero(sys[k], hard[k] || 0), k);
await writeJson(env, 'limits', next);
return next;
}
/** 계정별 메일함 최대 크기(바이트, 0 = 관리자 기본값·시스템 값을 따름). */
export async function readUserQuota(env, userId) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('quota:' + userId).first();
return row ? nonNegInt(row.value) : 0;
}
/** 계정별 메일함 크기 값만 검사합니다(저장 전 검사용). 돌려주는 값은 정수 바이트. */
export function checkUserQuota(env, bytes) { return checkAgainstSystem(bytes, systemLimits(env).mailboxBytes, 'mailboxBytes'); }
/** 계정별 메일함 최대 크기를 저장합니다. 시스템의 메일함 제한을 넘을 수 없습니다. 0 이면 설정을 지웁니다. */
export async function writeUserQuota(env, userId, bytes) {
const n = checkUserQuota(env, bytes);
if (n === 0) await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run();
else await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('quota:' + userId, String(n), now()).run();
return n;
}
/** deleteUser 가 부릅니다: 계정의 제한 설정을 지웁니다. */
export async function clearUserQuota(env, userId) { await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run(); }
// ------------------------------------------------------------------ 실제로 적용되는 값(세 층 합산)
/**
* 메일함 주소에 적용되는 최대 크기(바이트, 0 = 제한 없음): 계정별 설정 → 관리자 기본값 → 시스템 제한 중 가장 작은 0 아닌 값.
* 기본 메일함(www 관리자가 쓰는 경우)은 계정 행이 없으므로 관리자 기본값·시스템 제한만 적용됩니다.
*/
export async function mailboxQuota(env, mailbox) {
const row = await env.DB.prepare('SELECT id FROM mail_users WHERE mailbox=? ORDER BY created_at LIMIT 1').bind(String(mailbox || '').toLowerCase()).first();
// 웹메이커 계정(1.0.8)은 mail_users 행이 없으므로 그 주체 id('wm:…')로 계정별 설정을 찾습니다.
const ownerId = row ? row.id : isWebmaker(env) ? (await webmakerByMailbox(env, mailbox))?.id : null;
const user = ownerId ? await readUserQuota(env, ownerId) : 0, admin = await readAdminLimits(env), sys = systemLimits(env);
return minNonZero(user, admin.mailboxBytes, sys.mailboxBytes);
}
/** 첨부 한 개의 최대 크기(MIME 기준 바이트). 관리자·시스템 값이 없어도 내장 상한(25MB)은 항상 적용됩니다. */
export async function attachmentLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.attachmentBytes, systemLimits(env).attachmentBytes, HARD_ATTACHMENT_BYTES); }
/** 한 메일의 최대 받는 사람 수. 내장 상한(50)은 항상 적용됩니다. */
export async function recipientLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.recipients, systemLimits(env).recipients, HARD_RECIPIENTS); }
// ------------------------------------------------------------------ 사용량
/**
* 메일함 사용량(바이트). 저장된 원본(.eml, raw_size)이 곧 MIME 기준 크기입니다. 원본이 아직 없는 메일(작성 중인 초안)은 첨부 크기를 대신 더합니다.
* 왜 raw_size 인가: 첨부가 base64 로 늘어난 뒤의 크기가 실제로 저장·전송되는 크기이고, 요구사항도 "MIME 변환시 늘어나는 크기를 감안" 하라고 합니다.
*/
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);
}
/** 모든 메일함을 합친 사용량(바이트). */
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);
}
/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */
export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); }
// ------------------------------------------------------------------ 기간(일·주·월) — 통계 시간대 기준
/** 통계 시간대. 서버의 시간대가 UTC 여도 "오늘" 은 한국 기준이어야 하므로 기본값은 Asia/Seoul 이고 STATS_TIMEZONE 으로 바꿀 수 있습니다. */
export function statsTimezone(env) {
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul').trim();
try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); return tz; } catch { return 'Asia/Seoul'; }
}
/** 시각을 주어진 시간대의 연·월·일·시·분·초로 풉니다. */
function tzParts(date, tz) {
const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
const p = Object.fromEntries(f.formatToParts(date).map((x) => [x.type, x.value]));
return { y: +p.year, m: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute, s: +p.second };
}
/** 그 시각에 적용되는 시간대 오프셋(ms, UTC 보다 앞서면 양수). */
function tzOffset(date, tz) { const p = tzParts(date, tz); return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - Math.floor(date.getTime() / 1000) * 1000; }
/** 시간대 기준 어느 날 0시의 순간(ms). 월·일이 범위를 넘어도(13월, 32일) Date.UTC 가 넘겨 계산합니다. 서머타임 경계는 두 번째 보정으로 맞춥니다. */
function localMidnight(y, m, d, tz) { const base = Date.UTC(y, m - 1, d); let t = base - tzOffset(new Date(base), tz); const off = tzOffset(new Date(t), tz); if (base - off !== t) t = base - off; return t; }
/**
* 통계 기간의 경계(ISO 문자열): 'day' 오늘 0시~내일 0시, 'week' 이번 주 월요일 0시~다음 월요일 0시, 'month' 이달 1일 0시~다음 달 1일 0시
* (요구사항: 월간은 매월 첫째 날부터 마지막 날까지). start 이상 end 미만이 그 기간입니다.
* @param {'day'|'week'|'month'} kind
* @param {Date} [at] 기준 시각(테스트용). 기본은 지금.
*/
export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) {
const p = tzParts(at, tz); let start, end;
if (kind === 'day') { start = localMidnight(p.y, p.m, p.d, tz); end = localMidnight(p.y, p.m, p.d + 1, tz); }
else if (kind === 'week') { const dow = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay(); const monday = p.d - ((dow + 6) % 7); start = localMidnight(p.y, p.m, monday, tz); end = localMidnight(p.y, p.m, monday + 7, tz); }
else { start = localMidnight(p.y, p.m, 1, tz); end = localMidnight(p.y, p.m + 1, 1, tz); }
return { start: new Date(start).toISOString(), end: new Date(end).toISOString() };
}
/**
* 기간 안의 보낸 메일·받은 메일 수. 보낸 메일은 발송이 끝난 메일(state='sent')의 sent_at, 받은 메일은 received_at 기준이며 폴더(휴지통 포함)는 가리지 않습니다.
* 영구 삭제한 메일은 세지 않습니다(행이 없음). mailbox 를 주면 그 메일함만.
*/
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();
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 };
}
/** 이달의 수발신 통수(월간 제한과 비교). */
export async function monthlyCount(env) { return (await countMail(env, periodBounds('month', statsTimezone(env)))).total; }
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
/**
* 발송 전 검사. 어긋나면 오류 코드를 던지며, 오류 객체에 limit(적용된 상한)을 붙여 화면·SMTP 응답이 구체적으로 안내합니다.
* too_many_recipients 받는 사람(참조·숨은 참조 포함)이 상한을 넘음
* attachment_too_large 첨부 한 개가 상한(MIME 기준)을 넘음
* quota_exceeded 보낸 메일을 저장하면 메일함 최대 크기를 넘음
* storage_full 전체 저장 용량(시스템)을 넘음
* monthly_limit_exceeded 이달 수발신 통수가 시스템 제한에 이름
* @param {object} p
* @param {string} p.mailbox 보내는 메일함(보낸 메일이 저장될 곳)
* @param {number} p.recipients 받는 사람 수
* @param {number[]} [p.attachmentSizes] 첨부별 원본 바이트(여기서 MIME 크기로 바꿔 비교)
* @param {number} [p.bytes] 저장될 원본 크기 추정(바이트, 메일함·전체 용량 검사용)
*/
export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSizes = [], bytes = 0 }) {
const maxRcpt = await recipientLimit(env);
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; }
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; }
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; }
}
/**
* 수신 저장 전 검사. 받은 메일은 크기를 미리 알 수 없으므로 "이미 가득 찼는지" 만 봅니다.
* @returns {Promise<null|'quota_exceeded'|'storage_full'>} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다.
*/
export async function checkInbound(env, mailbox) {
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';
return null;
}
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; }
// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정
/**
* 서비스 전체 스위치(settings 의 services).
* pop / imap : 끄면 계정 설정과 무관하게 모든 POP(또는 IMAP) 로그인을 거부합니다(0.9.0).
* plainLogin : 평문 포트(POP3 110, IMAP 143)에서 STLS/STARTTLS 없이 비밀번호 로그인을 받을지(0.9.7). 기본 허용.
* Gmail 의 "다른 계정에서 메일 확인" 은 110 포트에서 STLS 를 쓰지 않고 바로 USER/PASS 를 보내므로,
* 이 값이 꺼져 있으면 Gmail 은 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다. 비밀번호가 암호화되지 않고
* 지나가므로 보안을 우선하면 끄고 995/993(또는 STARTTLS 를 지원하는 메일 프로그램)만 쓰게 합니다.
*/
export const DEFAULT_SERVICES = { pop: true, imap: true, plainLogin: true };
export async function readServices(env) { const s = await readJson(env, 'services', DEFAULT_SERVICES); return { pop: s.pop !== false, imap: s.imap !== false, plainLogin: s.plainLogin !== false }; }
export async function writeServices(env, patch) {
const next = await readServices(env);
for (const k of ['pop', 'imap', 'plainLogin']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'services', next); return next;
}
/** 메일 서버가 로그인 직후 부릅니다: 관리자가 그 서비스를 꺼 두었으면 false. */
export async function serviceEnabled(env, service) { return (await readServices(env))[service] !== false; }
/**
* 평문 포트(110/143)에서 TLS 없이 로그인을 받을지(0.9.7). POP3·IMAP 서버가 접속마다 한 번 읽어 CAPA 광고와 USER/PASS 허용에 씁니다.
* 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정과 무관하게 항상 허용(진단용, 예전 의미 유지)이고, 그 밖의 값(0, 빈 값)은 관리자 설정을 따릅니다.
* 0.9.5 까지는 이 변수가 0 이면 110 로그인을 항상 거부했는데, 설치 템플릿의 기본값이 0 이라 Gmail 110 접속이 모두 실패했습니다.
* 설정을 읽지 못하면(DB 오류) 안전한 쪽인 거부로 둡니다.
*/
export async function plainLoginAllowed(env) {
if (env.POP_ALLOW_PLAINTEXT === '1') return true;
try { return (await readServices(env)).plainLogin !== false; } catch { return false; }
}
export const DEFAULT_SIGNUP = { enabled: false, approval: true };
/**
* 회원 가입 설정(settings 의 signup).
* enabled : 로그인 화면에 "회원 가입" 을 보여 주고 POST /api/signup 을 받을지
* approval : true 면 가입한 계정을 관리자가 승인해야 쓸 수 있음(승인 대기), false 면 즉시 사용
*/
export async function readSignup(env) { const s = await readJson(env, 'signup', DEFAULT_SIGNUP); return { enabled: !!s.enabled, approval: s.approval !== false }; }
export async function writeSignup(env, patch) {
const next = await readSignup(env);
for (const k of ['enabled', 'approval']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'signup', next); return next;
}
// ------------------------------------------------------------------ 서버 상태 화면의 메일 현황
/**
* 관리자 "서버 상태" 의 메일 현황(요구사항 8).
* periods.day/week/month : 기간별 발신·수신·수발신 통수와 기간 경계
* mailboxes : 메일함별 사용량(MIME 기준)과 적용 중인 최대 크기·비율
* usage.total/accounts/monthly : 전체 사용량·계정 수·이달 통수를 시스템 제한과 견준 값({ used, limit, percent }, limit 0 = 제한 없음)
* system / admin : 적용 중인 시스템 제한·관리자 제한 값(화면 안내용)
* @param {string[]} mailboxes 알려진 메일함 주소 목록(기본 메일함 + 계정 메일함). auth.knownMailboxes 결과를 넘깁니다.
* @param {object[]} users listUsers 결과(login·name·mailbox 로 메일함에 이름을 붙임)
*/
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));
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);
const counts = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(mailbox).first();
boxes.push({ mailbox, login: owner?.login || null, name: owner?.name || '', messages: Number(counts?.n || 0), bytes: used, limit: quota, percent: percentOf(used, quota) });
}
const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total;
return {
timezone: tz, periods, mailboxes: boxes, system: sys, admin,
usage: {
total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) },
accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) },
monthly: { used: monthly, limit: sys.monthlyMessages, percent: percentOf(monthly, sys.monthlyMessages) },
},
};
}