2026-10-26 11:47:45 +00:00
/**
* portal/mail/arimail.mjs — AriMail 연동 어댑터(docs/ARCHITECTURE.md §7).
*
* 포털은 AriMail 의 기존 기능(웹메일·IMAP·POP3·SMTP·다중 도메인·사용량 API·전체 설정 API·관리자 API)을 다시 만들지 않고 이 어댑터로 부른다(§0.3·§38).
* - 전체 설정 API(/api/setup/*, 설정 토큰): 도메인(테넌트) 추가·변경·삭제, 도메인별 설정(보장량·SSO 비밀·DNS 레코드·자동 설정 계획), 발신 상태 확인.
* - 관리자 API(/api/admin/*, 관리자 세션): 메일함(계정) 목록·생성·수정·삭제, POP/IMAP 스위치, 서버 상태, 발송 속도·대기열.
* - 사용량 API(/api/usage, 비밀 키): 도메인·계정별 저장 공간·통수·경고 구간·제한 사유.
* - SSO(/api/auth/sso): 포털이 서명한 티켓으로 웹메일 세션(§16.3).
* - 송수신 확인: SMTP 제출(modules/arimail/server/providers/smtp-client.mjs 재사용)과 웹 API 수신함 조회로 실제 외부 왕복을 확인.
* 서버 등록은 mail_servers 표, 비밀은 보관소: arimail:<서버id>:setup-token · :usage-key · :sso-secret · :admin:<도메인>(관리자 로그인 JSON). 응답에 비밀값을 넣지 않는다.
* 네트워크 오류는 code='service_unreachable' 로 던져 화면이 '정보 확인 지연' 을 보이게 한다. createFake() 는 같은 인터페이스의 메모리 구현(다른 영역의 테스트용).
*/
import { randomBytes , createHmac } from 'node:crypto' ;
import { newId } from '../../lib/crypto.mjs' ;
import { nowIso , parseJson , toJson } from '../../lib/db.mjs' ;
/** 공통 fetch: JSON 응답, 네트워크 오류는 service_unreachable, AriMail 의 { ok:false, error } 는 code=error 로 던진다. */
async function call ( fetchImpl , url , { method = 'GET' , headers = {}, body , host = null , timeoutMs = 15000 } = {}) {
let r ;
try { r = await fetchImpl ( url , { method , headers : { 'Content-Type' : 'application/json' , ...( host ? { Host : host , 'X-Forwarded-Host' : host } : {}), ... headers }, body : body === undefined ? undefined : JSON . stringify ( body ), signal : AbortSignal . timeout ( timeoutMs ), redirect : 'manual' }); }
catch ( e ) { const err = new Error ( `AriMail 에 연결할 수 없습니다 ( ${ new URL ( url ). host } )` ); err . code = 'service_unreachable' ; err . cause = e ; throw err ; }
let json = null ; try { json = await r . json (); } catch ( _ ) {}
if ( ! r . ok || ( json && json . ok === false )) { const err = new Error (( json && json . error ) || `AriMail 오류 ${ r . status } ` ); err . code = ( json && json . error ) || 'arimail_' + r . status ; err . status = r . status ; err . detail = json ; throw err ; }
return { json , headers : r . headers , status : r . status };
}
export function create ( deps ) {
const { store , vault } = deps ; const fetchImpl = deps . fetchImpl || fetch ;
const secretName = ( serverId , kind ) => `arimail: ${ serverId } : ${ kind } ` ;
const serverRow = ( id ) => { const r = id ? store . get ( 'SELECT * FROM mail_servers WHERE id=?' , id ) : store . get ( 'SELECT * FROM mail_servers WHERE is_default=1' ) || store . get ( 'SELECT * FROM mail_servers ORDER BY created_at LIMIT 1' ); if ( ! r ) { const e = new Error ( '등록된 AriMail 서버가 없습니다. 운영자 콘솔 › 메일 서비스 › 서버 등록에서 먼저 등록하세요' ); e . code = 'mail_server_missing' ; throw e ; } return r ; };
const internalOf = ( s ) => ( s . internal_url || s . base_url ). replace ( /\/$/ , '' );
const setupOf = ( s ) => ( s . setup_url || s . base_url . replace ( /\/$/ , '' ) + ':8791' ). replace ( /\/$/ , '' );
const hostOf = ( s , domain ) => ( domain ? ( domain . host || `mail. ${ domain } ` ) : new URL ( s . base_url ). host );
/** 도메인 → 그 도메인의 웹 메일 호스트(등록부 hosts[0]). 캐시(60초). */
const hostCache = new Map ();
async function domainHost ( s , domain ) {
const c = hostCache . get ( s . id + '|' + domain ); if ( c && Date . now () - c . at < 60000 ) return c . host ;
const list = await api . domains ( s . id ); const d = list . find (( x ) => x . domain === domain ); const host = d ? String ( d . hosts [ 0 ]). split ( '/' )[ 0 ] : `mail. ${ domain } ` ;
hostCache . set ( s . id + '|' + domain , { host , at : Date . now () }); return host ;
}
2026-10-26 14:19:09 +00:00
/** 전체 설정 API 호출. AriMail 은 설정 API 를 기본 도메인 호스트(req.headers.host)에서만 응답하고 Node fetch 는 Host 헤더를 바꿀 수 없으므로 setup_url 자체가 기본 도메인 이름이어야 한다(등록 화면 안내, 실제 환경 확인). */
const setupCall = ( s , path , opts = {}) => call ( fetchImpl , setupOf ( s ) + path , { ... opts , headers : { ... setupHeaders ( s ), ...( opts . headers || {}) } });
2026-10-26 11:47:45 +00:00
const setupHeaders = ( s ) => { const tok = vault . get ( secretName ( s . id , 'setup-token' )); if ( ! tok ) { const e = new Error ( 'AriMail 전체 설정 토큰이 보관소에 없습니다' ); e . code = 'mail_server_token_missing' ; throw e ; } return { 'X-Setup-Token' : tok }; };
/** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */
const adminCache = new Map ();
async function adminCookie ( s , domain ) {
const k = s . id + '|' + domain ; const c = adminCache . get ( k ); if ( c && Date . now () - c . at < 10 * 60 * 1000 ) return c . cookie ;
const cred = parseJson ( vault . get ( secretName ( s . id , 'admin:' + domain )), null ); if ( ! cred || ! cred . login ) { const e = new Error ( `AriMail 관리자 로그인 정보가 없습니다( ${ domain } )` ); e . code = 'mail_admin_missing' ; throw e ; }
const host = await domainHost ( s , domain );
const r = await call ( fetchImpl , internalOf ( s ) + '/api/login' , { method : 'POST' , host , headers : { 'X-Mail-Scope' : 'admin' }, body : { login : cred . login , password : cred . password } });
const cookie = String ( r . headers . get ( 'set-cookie' ) || '' ). split ( ';' )[ 0 ]; adminCache . set ( k , { cookie , at : Date . now () }); return cookie ;
}
const admin = async ( s , domain , path , opts = {}) => call ( fetchImpl , internalOf ( s ) + '/api/' + path , { ... opts , host : await domainHost ( s , domain ), headers : { 'X-Mail-Scope' : 'admin' , Cookie : await adminCookie ( s , domain ), ...( opts . headers || {}) } });
const api = {
kind : 'arimail' ,
// ---- 서버 등록부(운영자)
listServers () { return store . all ( 'SELECT * FROM mail_servers ORDER BY is_default DESC, created_at' ). map (( s ) => ({ ... s , last_health : parseJson ( s . last_health_json , null ), secrets : { setupToken : vault . has ( secretName ( s . id , 'setup-token' )), usageKey : vault . has ( secretName ( s . id , 'usage-key' )), ssoSecret : vault . has ( secretName ( s . id , 'sso-secret' )) } })); },
defaultServer () { return serverRow ( null ); },
getServer : ( id ) => serverRow ( id ),
/** 서버 등록·수정. secrets 는 보관소에만 저장하고 응답에 돌려주지 않는다. */
2026-10-26 14:19:09 +00:00
saveServer ({ id = null , name , baseUrl , internalUrl = null , setupUrl = null , mailHost = null , isDefault = false , secrets = {} }) {
2026-10-26 11:47:45 +00:00
const now = nowIso (); const sid = id || newId ( 'ms_' );
if ( ! /^https?:\/\// . test ( String ( baseUrl || '' ))) throw Object . assign ( new Error ( '공개 주소는 http(s):// 로 시작해야 합니다' ), { code : 'validation' , status : 422 });
if ( isDefault ) store . run ( 'UPDATE mail_servers SET is_default=0' );
2026-10-26 14:19:09 +00:00
if ( id ) store . run ( 'UPDATE mail_servers SET name=?, base_url=?, internal_url=?, setup_url=?, mail_host=?, is_default=?, updated_at=? WHERE id=?' , name , baseUrl , internalUrl , setupUrl , mailHost , isDefault ? 1 : 0 , now , id );
else store . run ( 'INSERT INTO mail_servers(id, name, base_url, internal_url, setup_url, mail_host, is_default, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)' , sid , name , baseUrl , internalUrl , setupUrl , mailHost , isDefault || ! store . get ( 'SELECT 1 FROM mail_servers' ) ? 1 : 0 , now , now );
2026-10-26 11:47:45 +00:00
for ( const [ k , v ] of Object . entries ( secrets )) if ( v ) vault . set ( secretName ( sid , k ), typeof v === 'string' ? v : JSON . stringify ( v ), { kind : 'arimail' });
return api . listServers (). find (( s ) => s . id === sid );
},
/** 도메인 관리자 로그인 보관(도메인 추가 때 초기 관리자 비밀번호를 받아 저장) */
storeAdmin ( serverId , domain , login , password ) { vault . set ( secretName ( serverId , 'admin:' + domain ), JSON . stringify ({ login , password }), { kind : 'arimail-admin' }); adminCache . delete ( serverId + '|' + domain ); },
async health ( serverId ) {
const s = serverRow ( serverId ); const t0 = Date . now ();
try { const r = await call ( fetchImpl , internalOf ( s ) + '/api/health' ); const h = { ok : true , version : r . json . version , domains : r . json . domains , ms : Date . now () - t0 , at : nowIso () }; store . run ( 'UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?' , h . at , toJson ( h ), s . id ); return h ; }
catch ( e ) { const h = { ok : false , error : e . code || e . message , at : nowIso () }; store . run ( 'UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?' , h . at , toJson ( h ), s . id ); return h ; }
},
// ---- 사용량 API
async usage ( serverId , domain = '' , { days = 30 } = {}) {
const s = serverRow ( serverId ); const key = vault . get ( secretName ( s . id , 'usage-key' )); if ( ! key ) { const e = new Error ( 'AriMail 사용량 API 키가 보관소에 없습니다' ); e . code = 'mail_usage_key_missing' ; throw e ; }
const r = await call ( fetchImpl , ` ${ internalOf ( s ) } /api/usage ${ domain ? '/' + encodeURIComponent ( domain ) : '' } ?days= ${ days } ` , { headers : { Authorization : 'Bearer ' + key } });
return r . json ;
},
// ---- 전체 설정 API(도메인 등록부)
2026-10-26 14:12:28 +00:00
async domains ( serverId ) { const s = serverRow ( serverId ); return ( await setupCall ( s , '/api/setup/domains' , { headers : setupHeaders ( s ) })). json . domains ; },
2026-10-26 11:47:45 +00:00
async addDomain ( serverId , { domain , hosts = null , mailbox = 'admin' , adminPassword , ariweb = null , operating = 'active' , note = '' }) {
const s = serverRow ( serverId ); const pw = adminPassword || randomBytes ( 12 ). toString ( 'base64url' );
2026-10-26 14:12:28 +00:00
const r = await setupCall ( s , '/api/setup/domains' , { method : 'POST' , headers : setupHeaders ( s ), body : { domain , hosts : hosts || [ `mail. ${ domain } ` ], mailbox , adminPassword : pw , ariweb , operating , note , copyFromPrimary : true } });
2026-10-26 11:47:45 +00:00
hostCache . delete ( s . id + '|' + domain );
if ( r . json . initialAdmin ) api . storeAdmin ( s . id , domain , r . json . initialAdmin . login , pw );
return { entry : r . json . domain , initialAdmin : r . json . initialAdmin ? { login : r . json . initialAdmin . login } : null };
},
2026-10-26 14:12:28 +00:00
async updateDomain ( serverId , domain , patch ) { const s = serverRow ( serverId ); hostCache . delete ( s . id + '|' + domain ); return ( await setupCall ( s , '/api/setup/domains/' + encodeURIComponent ( domain ), { method : 'PATCH' , headers : setupHeaders ( s ), body : patch })). json . domain ; },
async removeDomain ( serverId , domain ) { const s = serverRow ( serverId ); hostCache . delete ( s . id + '|' + domain ); return ( await setupCall ( s , '/api/setup/domains/' + encodeURIComponent ( domain ), { method : 'DELETE' , headers : setupHeaders ( s ) })). json . removed ; },
2026-10-26 11:47:45 +00:00
/** 도메인 설정(비밀 가림)·메타(DNS 레코드·발신 상태·호스트). */
2026-10-26 14:12:28 +00:00
async domainConfig ( serverId , domain ) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/config?domain= ${ encodeURIComponent ( domain ) } ` , { headers : setupHeaders ( s ) })). json ; },
async setDomainConfig ( serverId , domain , values ) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/config?domain= ${ encodeURIComponent ( domain ) } ` , { method : 'PUT' , headers : setupHeaders ( s ), body : { values } })). json ; },
2026-10-26 11:47:45 +00:00
/** 도메인별 보장량(§13.3): 메일함 수·저장 공간·월간 통수를 AriMail 설정으로 */
setDomainLimits ( serverId , domain , { mailboxes , storageBytes , monthlyMessages }) { return api . setDomainConfig ( serverId , domain , { LIMIT_ACCOUNTS : mailboxes ?? '' , LIMIT_TOTAL_BYTES : storageBytes != null ? String ( storageBytes ) : '' , LIMIT_MONTHLY_MESSAGES : monthlyMessages ?? '' }); },
2026-10-26 14:12:28 +00:00
async plan ( serverId , domain ) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/plan?domain= ${ encodeURIComponent ( domain ) } ` , { headers : setupHeaders ( s ) })). json . plan ; },
async runPlan ( serverId , domain , body = {}) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/plan/run?domain= ${ encodeURIComponent ( domain ) } ` , { method : 'POST' , headers : setupHeaders ( s ), body })). json ; },
async dnsProbe ( serverId , domain ) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/dns/probe?domain= ${ encodeURIComponent ( domain ) } ` , { method : 'POST' , headers : setupHeaders ( s ), body : {} })). json . probe ; },
async outboundCheck ( serverId , domain ) { const s = serverRow ( serverId ); return ( await setupCall ( s , `/api/setup/check?domain= ${ encodeURIComponent ( domain ) } ` , { method : 'POST' , headers : setupHeaders ( s ), body : { what : 'outbound' } })). json . outboundStatus ; },
2026-10-26 11:47:45 +00:00
// ---- 관리자 API(메일함)
async listUsers ( serverId , domain ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/users' )). json ; },
async createUser ( serverId , domain , { login , name = '' , password , quotaBytes = undefined , services = undefined , role = 'user' }) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/users' , { method : 'POST' , body : { login , name , password , role , ...( quotaBytes !== undefined ? { quotaBytes } : {}), ...( services ? { services } : {}) } })). json ; },
async updateUser ( serverId , domain , id , patch ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/users/' + encodeURIComponent ( id ), { method : 'PATCH' , body : patch })). json ; },
async deleteUser ( serverId , domain , id ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/users/' + encodeURIComponent ( id ), { method : 'DELETE' })). json ; },
async setServices ( serverId , domain , services ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/config' , { method : 'PATCH' , body : { services } })). json ; },
async adminConfig ( serverId , domain ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/config' )). json ; },
async setLargeFiles ( serverId , domain , largeFiles ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/config' , { method : 'PATCH' , body : { largeFiles } })). json ; },
async adminStatus ( serverId , domain ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/status' )). json ; },
async rates ( serverId , domain ) { const s = serverRow ( serverId ); return ( await admin ( s , domain , 'admin/rates' )). json ; },
/** 메일함의 모든 세션 종료: AriMail 은 비밀번호 재설정이 세션을 무효화하므로 임시 비밀번호 재설정으로 구현(사용자는 다시 정해야 함). */
async endSessions ( serverId , domain , id ) { const pw = randomBytes ( 12 ). toString ( 'base64url' ); await api . updateUser ( serverId , domain , id , { password : pw }); return { ok : true , passwordReset : true }; },
// ---- SSO·접속 정보
/** 웹메일 SSO 주소(§16.3). AriMail 도메인 설정의 ARIWEB_SSO_SECRET 과 같은 비밀로 서명. 5분 유효, 1회용. */
async ssoUrl ( serverId , domain , mailbox ) {
const s = serverRow ( serverId ); const secret = vault . get ( secretName ( s . id , 'sso-secret' )); if ( ! secret ) return null ;
const payload = Buffer . from ( JSON . stringify ({ mailbox , exp : Date . now () + 5 * 60 * 1000 , nonce : randomBytes ( 12 ). toString ( 'base64url' ) })). toString ( 'base64url' );
const host = await domainHost ( s , domain );
return `https:// ${ host } /api/auth/sso?ticket= ${ encodeURIComponent ( payload + '.' + createHmac ( 'sha256' , secret ). update ( payload ). digest ( 'base64url' )) } ` ;
},
/** SSO 비밀을 만들어 보관소와 AriMail 도메인 설정 양쪽에 둔다(값은 돌려주지 않음). */
async ensureSso ( serverId , domain ) { const s = serverRow ( serverId ); let secret = vault . get ( secretName ( s . id , 'sso-secret' )); if ( ! secret ) { secret = randomBytes ( 32 ). toString ( 'base64url' ); vault . set ( secretName ( s . id , 'sso-secret' ), secret , { kind : 'arimail' }); } await api . setDomainConfig ( s . id , domain , { ARIWEB_SSO_SECRET : secret }); return true ; },
/**
* 실제 외부 왕복 확인(§15.3 8단계): 검증 메일함(ariweb-check@도메인, 비밀번호는 보관소)이 SMTP 제출(587 STARTTLS, AriMail smtp-client 재사용)로 외부 검증 주소에 보내고,
* 그 주소의 자동 회신이 검증 메일함 수신함에 들어오는지 웹 API 로 기다린다. 메일 내용은 표식(marker)뿐이며 결과에 본문을 넣지 않는다.
*/
async roundTrip ( serverId , domain , { to , timeoutMs = 90000 } = {}) {
const s = serverRow ( serverId ); const host = await domainHost ( s , domain ); const login = 'ariweb-check' ; const from = ` ${ login } @ ${ domain } ` ;
let pw = vault . get ( secretName ( s . id , 'check:' + domain ));
if ( ! pw ) { pw = randomBytes ( 12 ). toString ( 'base64url' ); try { await api . createUser ( s . id , domain , { login , name : 'Ariweb 검증' , password : pw }); } catch ( e ) { if ( e . code !== 'login_taken' ) throw e ; const list = await api . listUsers ( s . id , domain ); const u = ( list . users || []). find (( x ) => x . login === login ); if ( u ) await api . updateUser ( s . id , domain , u . id , { password : pw }); } vault . set ( secretName ( s . id , 'check:' + domain ), pw , { kind : 'arimail-check' }); }
const marker = 'AWCHK-' + randomBytes ( 6 ). toString ( 'hex' ); const mailHost = s . mail_host || new URL ( s . base_url ). host ;
const eml = Buffer . from ( `From: ${ from } \r\nTo: ${ to } \r\nSubject: ${ marker } \r\nDate: ${ new Date (). toUTCString () } \r\nMessage-ID: < ${ marker } @ ${ domain } >\r\n\r\nAriweb Suite mail verification ${ marker } . Please reply to this message.\r\n` );
let sent = false , sendDetail = '' ;
try { const { smtpSend } = await import ( '../../modules/arimail/server/providers/smtp-client.mjs' ); await smtpSend ({ host : mailHost , port : Number ( deps . config ? . values ? . mailSubmitPort || 587 ), security : deps . config ? . values ? . mailSubmitSecurity || 'starttls' , user : from , pass : pw , hostname : 'ariweb-portal' , from , to : [ to ], eml , rejectUnauthorized : deps . config ? . values ? . mailVerifierInsecureTls ? false : true }); sent = true ; }
catch ( e ) { sendDetail = `SMTP 제출 실패: ${ e . message }${ e . providerMessage ? ' ' + e . providerMessage : '' } ` ; }
let received = false , replied = false ; const deadline = Date . now () + timeoutMs ;
if ( sent ) {
const lg = await call ( fetchImpl , internalOf ( s ) + '/api/login' , { method : 'POST' , host , body : { login , password : pw } }); const cookie = String ( lg . headers . get ( 'set-cookie' ) || '' ). split ( ';' )[ 0 ];
while ( Date . now () < deadline ) { const r = await call ( fetchImpl , internalOf ( s ) + '/api/mail?folder=inbox' , { host , headers : { Cookie : cookie } }). catch (() => null ); const msgs = r ? . json ? . messages || []; if ( msgs . some (( m ) => String ( m . subject || '' ). includes ( marker ))) { received = true ; replied = true ; break ; } await new Promise (( res ) => setTimeout ( res , deps . verifyPollMs || 5000 )); }
}
return { sent , sendDetail , received , replied , marker , to , receiveDetail : sent && ! received ? '제한 시간 안에 회신이 수신함에 들어오지 않았습니다(외부 검증 주소의 자동 회신·DNS·MX 확인)' : '' , replyDetail : '' };
},
/** 메일 프로그램 설정값(비밀번호 없음) */
programSettings ( serverId , domain , address ) { const s = serverRow ( serverId ); const host = s . mail_host || new URL ( s . base_url ). host ; return { username : address , imap : { host , ports : { tls : 993 , starttls : 143 } }, pop3 : { host , ports : { tls : 995 , starttls : 110 } }, smtp : { host , ports : { tls : 465 , starttls : 587 } }, webmail : `https:// ${ host } ` }; },
/** 서버 상태 화면용: 등록부·건강·버전(비밀 없음) */
async overview () { const out = []; for ( const s of api . listServers ()) { let domains = null , health = await api . health ( s . id ); try { domains = await api . domains ( s . id ); } catch ( e ) { domains = { error : e . code || e . message }; } out . push ({ ... s , health , domains }); } return out ; },
secretName ,
};
return api ;
}
/**
* 메모리 구현(다른 영역의 테스트용). 같은 메서드 이름·모양을 가지며 상태를 this.state 로 들여다볼 수 있다.
* failWith 를 두면 모든 호출이 그 code 로 실패한다(연결 불가 시나리오).
*/
export function createFake ( opts = {}) {
const state = { servers : [{ id : 'fake' , name : '가짜 AriMail' , base_url : 'https://mail.example.test' , internal_url : 'http://127.0.0.1:1' , setup_url : 'http://127.0.0.1:1' , mail_host : 'mail.example.test' , is_default : 1 , status : 'active' }], domains : new Map (), users : new Map (), configs : new Map (), usage : new Map (), services : new Map (), failWith : opts . failWith || null , calls : [] };
const guard = ( name ) => { state . calls . push ( name ); if ( state . failWith ) { const e = new Error ( 'fake failure' ); e . code = state . failWith ; throw e ; } };
const usersOf = ( d ) => { if ( ! state . users . has ( d )) state . users . set ( d , []); return state . users . get ( d ); };
const fake = {
kind : 'arimail-fake' , state ,
listServers : () => state . servers . map (( s ) => ({ ... s , secrets : { setupToken : true , usageKey : true , ssoSecret : true } })), defaultServer : () => state . servers [ 0 ], getServer : () => state . servers [ 0 ], saveServer : ( s ) => ({ ... state . servers [ 0 ], ... s }), storeAdmin () {},
async health () { guard ( 'health' ); return { ok : true , version : '1.0.11-fake' , domains : state . domains . size , ms : 1 , at : nowIso () }; },
async usage ( _s , domain = '' , o = {}) {
guard ( 'usage' ); const one = ( d ) => { const u = state . usage . get ( d ) || {}; const users = usersOf ( d ); const cfg = state . configs . get ( d ) || {}; const lim = { accounts : Number ( cfg . LIMIT_ACCOUNTS || 0 ), totalBytes : Number ( cfg . LIMIT_TOTAL_BYTES || 0 ), monthlyMessages : Number ( cfg . LIMIT_MONTHLY_MESSAGES || 0 ) };
const storage = u . storageBytes || 0 , month = u . monthly || 0 ; const lvl = ( x , l ) => ( ! l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0 );
const levels = { storage : lvl ( storage , lim . totalBytes ), monthly : lvl ( month , lim . monthlyMessages ), accounts : lvl ( users . length , lim . accounts ) }; const reasons = []; if ( levels . storage === 100 ) reasons . push ( 'storage_full' ); if ( levels . monthly === 100 ) reasons . push ( 'monthly_limit' ); if ( levels . accounts === 100 ) reasons . push ( 'accounts_full' );
return { domain : d , enabled : true , hosts : [ `mail. ${ d } ` ], authMode : 'local' , inbound : 'haraka' , outbound : 'haraka' , timezone : 'Asia/Seoul' , accounts : { local : users . length , external : 0 , total : users . length , limit : lim . accounts }, storage : { bytes : storage , limit : lim . totalBytes , largeFiles : { count : 0 , bytes : u . largeBytes || 0 , sent : 0 } }, messages : { total : u . messages || 0 }, traffic : { month : { sent : u . sent || 0 , received : u . received || 0 , total : month }, monthlyLimit : lim . monthlyMessages , countingRule : 'per_final_recipient' , monthReset : u . monthReset || '2026-10-31T15:00:00.000Z' }, ariweb : ( state . domains . get ( d ) || {}). ariweb || null , operating : ( state . domains . get ( d ) || {}). operating || 'active' , warnLevel : Math . max ( levels . storage , levels . monthly , levels . accounts ), warnLevels : levels , limitedReasons : reasons , measuredAt : nowIso (), accountList : users . map (( x ) => ({ login : x . login , name : x . name , mailbox : x . mailbox , role : x . role , source : 'local' , status : x . status , messages : x . messages || 0 , bytes : x . bytes || 0 , limit : x . quotaBytes || 0 , lastLoginAt : x . lastLoginAt || null , sent : x . sent || 0 , received : x . received || 0 })), limits : { system : lim , admin : {} } }; };
if ( domain ) { if ( ! state . domains . has ( domain )) { const e = new Error ( 'domain_not_found' ); e . code = 'domain_not_found' ; e . status = 404 ; throw e ; } return { ok : true , version : '1.0.11-fake' , ... one ( domain ) }; }
return { ok : true , version : '1.0.11-fake' , primary : [... state . domains . keys ()][ 0 ] || null , totals : {}, domains : [... state . domains . keys ()]. map ( one ) };
},
async domains () { guard ( 'domains' ); return [... state . domains . values ()]; },
async addDomain ( _s , { domain , hosts , mailbox = 'admin' , ariweb = null , operating = 'active' }) { guard ( 'addDomain' ); if ( state . domains . has ( domain )) { const e = new Error ( 'domain_exists' ); e . code = 'domain_exists' ; e . status = 409 ; throw e ; } const entry = { domain , enabled : true , hosts : hosts || [ `mail. ${ domain } ` ], mailbox : ` ${ mailbox } @ ${ domain } ` , auth : { mode : 'local' }, ariweb , operating , accounts : 0 , messages : 0 }; state . domains . set ( domain , entry ); usersOf ( domain ). push ({ id : 'u_admin_' + domain , login : mailbox , name : '관리자' , mailbox : ` ${ mailbox } @ ${ domain } ` , role : 'admin' , status : 'active' }); return { entry , initialAdmin : { login : mailbox } }; },
async updateDomain ( _s , domain , patch ) { guard ( 'updateDomain' ); const e = state . domains . get ( domain ); if ( ! e ) { const x = new Error ( 'not_found' ); x . code = 'not_found' ; x . status = 404 ; throw x ; } Object . assign ( e , patch ); return e ; },
async removeDomain ( _s , domain ) { guard ( 'removeDomain' ); state . domains . delete ( domain ); return { domain }; },
async domainConfig ( _s , domain ) { guard ( 'domainConfig' ); const cfg = state . configs . get ( domain ) || {}; return { ok : true , values : { ... cfg , ARIWEB_SSO_SECRET : undefined }, meta : { host : `mail. ${ domain } ` , domain , dns : { records : [{ type : 'MX' , name : domain , value : `10 mail. ${ domain } ` }, { type : 'TXT' , name : domain , value : 'v=spf1 a mx -all' }, { type : 'TXT' , name : `arimail._domainkey. ${ domain } ` , value : 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type : 'TXT' , name : `_dmarc. ${ domain } ` , value : 'v=DMARC1; p=quarantine' }] }, outboundStatus : { mode : 'haraka' , provider : 'ok' }, outbound : 'haraka' , inbound : 'haraka' } }; },
async setDomainConfig ( _s , domain , values ) { guard ( 'setDomainConfig' ); state . configs . set ( domain , { ...( state . configs . get ( domain ) || {}), ... values }); return { ok : true }; },
setDomainLimits ( s , d , { mailboxes , storageBytes , monthlyMessages }) { return fake . setDomainConfig ( s , d , { LIMIT_ACCOUNTS : mailboxes ?? '' , LIMIT_TOTAL_BYTES : storageBytes != null ? String ( storageBytes ) : '' , LIMIT_MONTHLY_MESSAGES : monthlyMessages ?? '' }); },
async plan () { guard ( 'plan' ); return { steps : [] }; }, async runPlan () { guard ( 'runPlan' ); return { ok : true , log : [] }; },
async dnsProbe ( _s , domain ) { guard ( 'dnsProbe' ); return state . dnsProbe || { mx : [ `mail. ${ domain } ` ], spf : true , dkim : true , dmarc : true }; },
async outboundCheck () { guard ( 'outboundCheck' ); return { mode : 'haraka' , provider : 'ok' }; },
async listUsers ( _s , domain ) { guard ( 'listUsers' ); const cfg = state . configs . get ( domain ) || {}; return { ok : true , users : usersOf ( domain ). filter (( u ) => u . role !== 'admin' || true ), domain , accounts : { used : usersOf ( domain ). length , limit : Number ( cfg . LIMIT_ACCOUNTS || 0 ) }, services : state . services . get ( domain ) || { pop : true , imap : true } }; },
async createUser ( _s , domain , { login , name , password , quotaBytes , services }) { guard ( 'createUser' ); const list = usersOf ( domain ); if ( list . some (( u ) => u . login === login )) { const e = new Error ( 'login_taken' ); e . code = 'login_taken' ; e . status = 409 ; throw e ; } const cfg = state . configs . get ( domain ) || {}; if ( Number ( cfg . LIMIT_ACCOUNTS || 0 ) && list . length >= Number ( cfg . LIMIT_ACCOUNTS )) { const e = new Error ( 'account_limit_reached' ); e . code = 'account_limit_reached' ; e . status = 409 ; throw e ; } if ( ! password || password . length < 6 ) { const e = new Error ( 'weak_password' ); e . code = 'weak_password' ; e . status = 422 ; throw e ; } const id = 'u_' + randomBytes ( 4 ). toString ( 'hex' ); list . push ({ id , login , name , mailbox : ` ${ login } @ ${ domain } ` , role : 'user' , status : 'active' , quotaBytes : quotaBytes || 0 , services : services || { pop : true , imap : true } }); return { ok : true , id }; },
async updateUser ( _s , domain , id , patch ) { guard ( 'updateUser' ); const u = usersOf ( domain ). find (( x ) => x . id === id ); if ( ! u ) { const e = new Error ( 'not_found' ); e . code = 'not_found' ; e . status = 404 ; throw e ; } const { password , ... rest } = patch ; Object . assign ( u , rest ); if ( password ) u . passwordChanged = ( u . passwordChanged || 0 ) + 1 ; return { ok : true }; },
async deleteUser ( _s , domain , id ) { guard ( 'deleteUser' ); const list = usersOf ( domain ); const i = list . findIndex (( x ) => x . id === id ); if ( i >= 0 ) list . splice ( i , 1 ); return { ok : true }; },
async setServices ( _s , domain , services ) { guard ( 'setServices' ); state . services . set ( domain , { ...( state . services . get ( domain ) || { pop : true , imap : true }), ... services }); return { ok : true , services : state . services . get ( domain ) }; },
async adminConfig ( _s , domain ) { guard ( 'adminConfig' ); return { ok : true , services : state . services . get ( domain ) || { pop : true , imap : true }, largeFiles : state . largeFiles || { enabled : false , maxBytes : 0 , maxDays : 0 , maxDownloads : 0 }, systemLargeFiles : { bytes : 1024 ** 3 , days : 30 , downloads : 20 } }; },
async setLargeFiles ( _s , _d , lf ) { guard ( 'setLargeFiles' ); state . largeFiles = { ...( state . largeFiles || {}), ... lf }; return { ok : true }; },
async adminStatus ( _s , domain ) { guard ( 'adminStatus' ); return { ok : true , host : `mail. ${ domain } ` , tls : true , ports : { pop3s : 995 , pop3 : 110 , imaps : 993 , imap : 143 , smtps : 465 , submission : 587 }, services : state . services . get ( domain ) || { pop : true , imap : true }, mail : { inbound : 'haraka' , outbound : 'haraka' , status : { provider : 'ok' } } }; },
async rates () { guard ( 'rates' ); return { ok : true , rates : { deliveryRate : 5 , submissionRate : 10 , buckets : [], submissions : [] }, queue : [], abuse : [] }; },
async endSessions () { guard ( 'endSessions' ); return { ok : true , passwordReset : true }; },
async roundTrip ( _s , domain , { to }) { guard ( 'roundTrip' ); return state . roundTrip || { sent : true , received : true , replied : true , marker : 'AWCHK-fake' , to }; },
async ssoUrl ( _s , domain , mailbox ) { guard ( 'ssoUrl' ); return `https://mail. ${ domain } /api/auth/sso?ticket=fake- ${ encodeURIComponent ( mailbox ) } ` ; },
async ensureSso () { guard ( 'ensureSso' ); return true ; },
programSettings ( _s , domain , address ) { return { username : address , imap : { host : `mail. ${ domain } ` , ports : { tls : 993 , starttls : 143 } }, pop3 : { host : `mail. ${ domain } ` , ports : { tls : 995 , starttls : 110 } }, smtp : { host : `mail. ${ domain } ` , ports : { tls : 465 , starttls : 587 } }, webmail : `https://mail. ${ domain } ` }; },
async overview () { return state . servers . map (( s ) => ({ ... s , health : { ok : true }, domains : [... state . domains . values ()] })); },
secretName : ( id , k ) => `arimail: ${ id } : ${ k } ` ,
};
return fake ;
}