메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 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
+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);