2026-10-26 11:02:58 +00:00
/**
* 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' ;
2026-10-26 11:47:45 +00:00
import { submit as submitRate , rateSnapshot , queueList , abuseList , reviewAbuse , recoverQueue } from './sendqueue.mjs' ; // [Ariweb Suite 변경]
import { mailboxUser } from './auth.mjs' ;
import { createHmac , timingSafeEqual } from 'node:crypto' ;
2026-10-26 11:02:58 +00:00
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 ( /&/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 );
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 포털 SSO(§16.3): GET /api/auth/sso?ticket=<payload>.<sig> — 포털이 ARIWEB_SSO_SECRET 으로 서명한 티켓(메일함·만료·nonce)을 검증해
// 그 메일함 주인의 웹 메일 세션을 만들고 웹 메일 첫 화면으로 보냅니다. 비밀번호를 포털에 알려 주지 않아도 "현재 로그인 상태로 안전하게 연결" 됩니다.
if ( path [ 0 ] === 'auth' && path [ 1 ] === 'sso' && method === 'GET' ) {
const v = verifySsoTicket ( env , new URL ( request . url ). searchParams . get ( 'ticket' ) || '' );
if ( ! v . ok ) { await audit ( env , null , 'sso_denied' , null , { ip , reason : v . reason }); return bad ( v . reason , 401 ); }
const user = await mailboxUser ( env , v . mailbox );
if ( ! user || user . status === 'suspended' ) { await audit ( env , null , 'sso_denied' , v . mailbox , { ip , reason : 'no_mailbox' }); return bad ( 'mailbox_not_found' , 404 ); }
const token = await createSession ( env , user , request , { scope : 'mail' }); await audit ( env , user . login , 'login' , null , { ip , source : user . source , via : 'ariweb-sso' });
return new Response ( null , { status : 302 , headers : { Location : ( prefix || '' ) + '/' , 'Set-Cookie' : sessionCookie ( token , { scope : 'mail' , path : prefix }), 'Cache-Control' : 'no-store' } });
}
2026-10-26 11:02:58 +00:00
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 );
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 발송 속도·제출 창·대기열·악용 의심 표(§17.2, 운영자 확인용). 메일 내용은 없습니다.
if ( path [ 1 ] === 'rates' && method === 'GET' ) return json ({ ok : true , rates : rateSnapshot (), queue : await queueList ( env ), abuse : await abuseList ( env ) });
if ( path [ 1 ] === 'abuse' && path [ 2 ] && method === 'PATCH' ) { await reviewAbuse ( env , path [ 2 ], user . login ); await audit ( env , user . login , 'abuse_reviewed' , path [ 2 ]); return json ({ ok : true }); }
2026-10-26 11:02:58 +00:00
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 (() => ({}));
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 웹 발송도 제출 속도 제한(계정·IP 초당 10건)을 받습니다.
{ const th = await submitRate ( env , [ 'account:' + mailbox , ...( ip ? [ 'ip:' + ip ] : [])]); if ( th . throttled ) return bad ( 'mail_submission_throttled' , 429 , { until : th . until , rate : th . rate }); }
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-26 11:47:45 +00:00
const status = e . message === 'mail_domain_frozen' ? 423 : 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 ;
2026-10-26 11:02:58 +00:00
// 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)을 붙입니다.
2026-10-26 11:47:45 +00:00
return bad ( e . message || 'server_error' , status , { ...( e . code && e . code !== e . message ? { code : e . code } : {}), ...( e . resetAt ? { resetAt : e . resetAt } : {}), ...( 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 } : {}) });
2026-10-26 11:02:58 +00:00
}
}
/**
* 회원 가입(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 ),
});
}
2026-10-26 11:47:45 +00:00
/**
* [Ariweb Suite 변경] 포털 SSO 티켓 검증. 티켓 = base64url(JSON{ mailbox, exp(ms), nonce }) + '.' + base64url(HMAC-SHA256(ARIWEB_SSO_SECRET, payload)).
* 만료(exp)는 5분 이내, nonce 는 한 번만(메모리, 만료까지 보관). 비밀이 설정되지 않은 도메인은 거부합니다.
*/
const usedNonces = new Map ();
export function verifySsoTicket ( env , ticket ) {
const secret = String ( env . ARIWEB_SSO_SECRET || env . CONFIG ? . ARIWEB_SSO_SECRET || '' ); if ( ! secret ) return { ok : false , reason : 'sso_not_configured' };
const [ payload , sig ] = String ( ticket || '' ). split ( '.' ); if ( ! payload || ! sig ) return { ok : false , reason : 'sso_bad_ticket' };
const expect = createHmac ( 'sha256' , secret ). update ( payload ). digest ( 'base64url' );
const a = Buffer . from ( expect ), b = Buffer . from ( sig ); if ( a . length !== b . length || ! timingSafeEqual ( a , b )) return { ok : false , reason : 'sso_bad_signature' };
let d ; try { d = JSON . parse ( Buffer . from ( payload , 'base64url' ). toString ( 'utf8' )); } catch { return { ok : false , reason : 'sso_bad_ticket' }; }
const t = Date . now (); if ( ! d || typeof d . mailbox !== 'string' || ! Number . isFinite ( d . exp ) || d . exp < t || d . exp > t + 10 * 60 * 1000 || typeof d . nonce !== 'string' ) return { ok : false , reason : 'sso_expired' };
for ( const [ k , exp ] of usedNonces ) if ( exp < t ) usedNonces . delete ( k );
if ( usedNonces . has ( d . nonce )) return { ok : false , reason : 'sso_replayed' };
usedNonces . set ( d . nonce , d . exp );
const mailbox = d . mailbox . trim (). toLowerCase (); if ( mailbox . split ( '@' )[ 1 ] !== mailDomain ( env )) return { ok : false , reason : 'sso_wrong_domain' };
return { ok : true , mailbox };
}