2026-10-26 11:02:58 +00:00
/**
* 시스템 제한·관리자 제한·사용량·현황 (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' ;
2026-10-26 11:47:45 +00:00
// [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·관리자 화면용).
2026-10-26 11:02:58 +00:00
// ------------------------------------------------------------------ 상수
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(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 ) {
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 도메인별 보장량: 설정 파일 값이 비어 있지 않으면 그 값을, 아니면 환경 파일 값을 씁니다.
const cfg = env . CONFIG || {}; const pick = ( k ) => ( cfg [ k ] !== undefined && cfg [ k ] !== null && String ( cfg [ k ]) !== '' ? cfg [ k ] : env [ k ]);
2026-10-26 11:02:58 +00:00
return {
2026-10-26 11:47:45 +00:00
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' )),
2026-10-26 11:02:58 +00:00
};
}
2026-10-26 11:47:45 +00:00
/** [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' ; }
2026-10-26 11:02:58 +00:00
// ------------------------------------------------------------------ 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 ();
2026-10-26 11:47:45 +00:00
// [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 );
2026-10-26 11:02:58 +00:00
}
2026-10-26 11:47:45 +00:00
/** 모든 메일함을 합친 사용량(바이트). 대용량 첨부 포함([Ariweb Suite 변경]). */
2026-10-26 11:02:58 +00:00
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 ();
2026-10-26 11:47:45 +00:00
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 );
2026-10-26 11:02:58 +00:00
}
/** 게이트웨이 계정 수(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 ] : [];
2026-10-26 11:47:45 +00:00
// [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 ();
2026-10-26 11:02:58 +00:00
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 ();
2026-10-26 11:47:45 +00:00
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 } };
2026-10-26 11:02:58 +00:00
}
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
/**
* 발송 전 검사. 어긋나면 오류 코드를 던지며, 오류 객체에 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 ; }
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 도메인이 이전 중·동결이면 새 발신을 막습니다(두 서버가 같은 도메인을 동시에 처리하지 않게).
if ( operatingState ( env ) !== 'active' ) { const e = new Error ( 'mail_domain_frozen' ); e . code = 'mail_domain_frozen' ; e . operating = operatingState ( env ); throw e ; }
2026-10-26 11:02:58 +00:00
const sys = systemLimits ( env );
2026-10-26 11:47:45 +00:00
// 월간 통수 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 ; }
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 운영 상태가 active 가 아니면 임시 거부(보낸 서버가 재시도). 월간 통수 100% 도 새 수신을 임시 거부합니다(§13.4).
if ( operatingState ( env ) !== 'active' ) return 'domain_not_active' ;
2026-10-26 11:02:58 +00:00
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' ;
2026-10-26 11:47:45 +00:00
// 저장 공간 사유가 우선이고(정리하면 바로 풀림), 그다음 월간 통수(다음 KST 월에 풀림).
if ( sys . monthlyMessages > 0 && ( await monthlyCount ( env )) >= sys . monthlyMessages ) return 'monthly_limit' ;
2026-10-26 11:02:58 +00:00
return null ;
}
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
2026-10-26 11:47:45 +00:00
export async function monthlyBlocked ( env ) { const sys = systemLimits ( env ); return operatingState ( env ) !== 'active' || ( sys . monthlyMessages > 0 && ( await monthlyCount ( env )) >= sys . monthlyMessages ); }
2026-10-26 11:02:58 +00:00
// ------------------------------------------------------------------ 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 = {};
2026-10-26 11:47:45 +00:00
for ( const kind of [ 'day' , 'week' , 'month' ]) periods [ kind ] = await countMail ( env , periodBounds ( kind , tz , statsNow ( env )));
2026-10-26 11:02:58 +00:00
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 {
2026-10-26 11:47:45 +00:00
timezone : tz , periods , mailboxes : boxes , system : sys , admin , limitState : await limitState ( env ),
2026-10-26 11:02:58 +00:00
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 ) },
},
};
}