2026-10-26 11:02:58 +00:00
/**
* 다중 도메인 등록부와 "테넌트"(1.0.7, 요구사항 1·2).
*
* 생각의 틀
* AriMail 의 모든 모듈은 `env` 객체 하나로 자기 세계(DB·설정 파일·메일 도메인·공개 호스트·로그인 방식)를 봅니다. 그래서 도메인을 하나 더 운영한다는 것은
* "env 를 하나 더 만든다" 는 뜻입니다. 이 모듈은 도메인마다 그런 env(= 테넌트)를 만들어 보관하고, 들어온 요청(HTTP Host, 로그인 ID 의 @ 뒤,
* 받는 사람 주소)으로 어느 테넌트의 일인지 고릅니다. 각 도메인은 자기 DB(계정·세션·메일), 자기 설정 파일(Resend 키·DNS·수·발신 방식), 자기 파일 저장소를
* 가지므로 로그인과 계정 관리가 도메인 단위로 완전히 나뉩니다.
*
* 기본 도메인(primary)
* 환경 파일(gateway.env)의 MAILBOX / PUBLIC_HOST / LOGIN_MODE / WWW_DB_FILE / DB_FILE 로 정해지는 기존 운영 그대로의 도메인입니다. 1.0.7 로 올린 서버는
* 첫 시작에 등록부(domains.json)가 없으므로 이 도메인 하나로 등록부를 만들고(데이터는 전혀 옮기지 않음) 예전처럼 동작합니다.
* 기본 도메인의 DB·설정 파일 위치는 계속 환경 파일이 정하고, 등록부에는 추가 호스트 이름 같은 보조 정보만 둡니다.
*
* 추가 도메인
* 데이터 디렉터리 아래 domains/<도메인>/ 에 mail.sqlite(같은 마이그레이션) · config.json · files/ 를 둡니다. 전체 설정 화면(전용 포트)에서 추가하면
* 재시작 없이 바로 만들어지고, 첫 관리자(admin / 지정한 비밀번호)를 자동으로 만들어 바로 로그인할 수 있습니다. 로그인 방식은 도메인마다
* local(자체 계정) · www(연결된 웹 서비스 DB 의 관리자 계정으로 인증) · webmaker(1.0.8: 웹메이커의 사용자 파일을 읽기 전용으로 참조, server/webmaker.mjs)입니다.
*
* 등록부 파일(domains.json, 기본 도메인의 데이터 디렉터리)
* { _meta:{format}, primary:'arihakdang.com', domains:{ 'example.com': { domain, enabled, hosts:[…], mailbox, auth:{mode, wwwDbFile, ownerLogin, webmakerDir, webmakerUser, siteId}, note, createdAt, updatedAt } } }
*
* 호스트 이름과 경로(1.0.8)
* hosts 의 항목은 "호스트" 또는 "호스트/경로"(예: site.ariweb.net/mail)입니다. 경로가 있으면 그 호스트의 그 경로 아래가 이 도메인의 웹 메일입니다 — 서브 도메인 사이트처럼
* 메일용 호스트 이름을 따로 만들기 어려울 때 nginx 가 /mail/ 을 게이트웨이로 넘기고(X-Forwarded-Prefix: /mail, deploy/site-mail-path.sh) 화면은 그 경로를 기준(<base>)으로 동작합니다.
*
* 요청 → 테넌트 고르기(resolveHost)
* 1) 호스트가 없거나 localhost / IP 주소이면 기본 도메인(nginx 뒤의 상태 확인, 로컬 도구, 서버 IP 로 직접 접속).
* 2) 어떤 도메인의 hosts 에 정확히 있으면 그 도메인. 3) 호스트가 도메인과 같거나 그 하위(….example.com)면 그 도메인(가장 긴 도메인 우선).
* 4) 아무 데도 없으면 null → 화면은 "메일 서비스가 존재하지 않습니다" 를 보여 줍니다. 꺼 둔(enabled=false) 도메인은 disabled 로 알려 "활성화되지 않았습니다" 를 보여 줍니다.
*/
import { readFileSync , writeFileSync , mkdirSync , renameSync , chmodSync , existsSync , accessSync , constants } from 'node:fs' ;
import { dirname , join } from 'node:path' ;
import { openDb , d1 , migrate } from './db.mjs' ;
import { loadConfig , configFile , configDir , harakaDir , CONFIG_SCHEMA } from './config.mjs' ;
import { ensureInitialAdmin , externalUsers , INITIAL_ADMIN_PASSWORD , PASSWORD_MIN_FLOOR } from './auth.mjs' ;
import { memoryRateStore } from './util.mjs' ;
export const REGISTRY_FORMAT = 1 ;
export const AUTH_MODES = [ 'local' , 'www' , 'webmaker' ];
/** 웹메이커 사이트 id(32자리 16진수)와 sudo 로 파일을 읽을 계정 이름의 형식. */
const SITE_ID_RE = /^[a-f0-9]{32}$/ , USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/ ;
/** 도메인 이름 형식: 소문자 영숫자·하이픈 이름표를 점으로 이은 것(국제화 도메인은 퓨니코드로). */
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ ;
const HOST_RE = DOMAIN_RE ;
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/ ;
/**
* 새 도메인을 만들 때 기본 도메인의 설정에서 복사해 오는 키(요구사항 2: 전체 설정에서 API 키·DNS 설정을 함께 관리).
* 도메인마다 다른 값(수·발신 방식, Resend 도메인/웹훅 id, DKIM 키, 사이트 이름, 완료 표시)은 복사하지 않습니다. DNS 존 id(DNS_GOOGLE_ZONE·DNS_AWS_HOSTED_ZONE_ID)도
* 복사하지 않습니다 — 기본 도메인의 존에 새 도메인의 레코드를 넣으려다 실패하므로, 새 도메인은 도메인 이름으로 존을 자동 검색합니다.
*/
export const COPY_KEYS = [ 'RESEND_API_KEY' , 'RESEND_REGION' , 'DNS_PROVIDER' , 'DNS_GOOGLE_CREDENTIALS' , 'DNS_GOOGLE_PROJECT' , 'DNS_AWS_ACCESS_KEY_ID' , 'DNS_AWS_SECRET_ACCESS_KEY' , 'SMTP_RELAY_HOST' , 'SMTP_RELAY_PORT' , 'SMTP_RELAY_SECURITY' , 'SMTP_RELAY_USER' , 'SMTP_RELAY_PASS' , 'HARAKA_PORT' , 'HARAKA_RELAY_PORT' , 'HARAKA_HOSTNAME' , 'STATS_TIMEZONE' ];
/** 호스트 이름 정규화: 소문자, 끝 점·포트 제거. "호스트/경로" 가 들어와도 호스트 부분만 돌려줍니다. */
export const normalizeHost = ( host ) => String ( host || '' ). trim (). toLowerCase (). split ( '/' )[ 0 ]. replace ( /^\[([^\]]+)\](:\d+)?$/ , '$1' ). replace ( /:\d+$/ , '' ). replace ( /\.$/ , '' );
/**
* 경로 접두사 정규화(1.0.8): "/mail", "mail/", "/a/b/" → "/mail", "/mail", "/a/b". 비어 있거나 "/" 면 ''(접두사 없음). 허용 글자 밖이거나 ".." 가 있으면 null.
* X-Forwarded-Prefix 헤더(nginx)와 등록부의 "호스트/경로" 둘 다 이 함수로 다듬습니다.
*/
export function normalizePrefix ( prefix ) {
const raw = String ( prefix ?? '' ). trim (); if ( ! raw || raw === '/' ) return '' ;
const parts = raw . split ( '/' ). filter ( Boolean );
if ( ! parts . length ) return '' ;
for ( const p of parts ) if ( p === '.' || p === '..' || ! /^[A-Za-z0-9._~-]{1,64}$/ . test ( p )) return null ;
return '/' + parts . join ( '/' );
}
/** 등록부의 호스트 항목("호스트" 또는 "호스트/경로")을 { host, prefix } 로 나눕니다(둘 다 소문자; nginx 설정과 맞추기 쉽게 경로도 소문자로 통일). */
export function splitHostEntry ( entry ) {
const raw = String ( entry || '' ). trim (). toLowerCase (); const i = raw . indexOf ( '/' );
return { host : normalizeHost ( i < 0 ? raw : raw . slice ( 0 , i )), prefix : i < 0 ? '' : ( normalizePrefix ( raw . slice ( i )) ?? null ) };
}
/** 호스트 항목의 호스트 이름 부분. */
export const hostNameOf = ( entry ) => splitHostEntry ( entry ). host ;
/** 도메인 이름 정규화(소문자, 앞뒤 공백·점 제거). */
export const normalizeDomain = ( domain ) => String ( domain || '' ). trim (). toLowerCase (). replace ( /^\.+|\.+$/g , '' );
export const isDomainName = ( domain ) => DOMAIN_RE . test ( normalizeDomain ( domain ));
/** 로컬 접속으로 보는 호스트: 비어 있음, localhost(.localhost 포함), IPv4/IPv6 주소. 이런 요청은 기본 도메인이 받습니다. */
export const isLocalHost = ( host ) => { const h = normalizeHost ( host ); return ! h || h === 'localhost' || h . endsWith ( '.localhost' ) || IPV4_RE . test ( h ) || h . includes ( ':' ); };
/** 등록부 파일 경로: 기본 도메인의 설정 파일(config.json) 옆 domains.json. */
export const registryFile = ( baseEnv ) => join ( configDir ( baseEnv ), 'domains.json' );
function readJson ( file ) { try { return JSON . parse ( readFileSync ( file , 'utf8' )); } catch ( e ) { if ( e . code === 'ENOENT' ) return null ; throw e ; } }
/** 임시 파일 → 이름 바꾸기(반쪽 파일 방지). 비밀값은 없지만 운영 정보이므로 0600. */
function writeJson ( file , data ) {
mkdirSync ( dirname ( file ), { recursive : true });
const tmp = file + '.tmp' ; writeFileSync ( tmp , JSON . stringify ( data , null , 2 ) + '\n' , { mode : 0o600 });
try { chmodSync ( tmp , 0o600 ); } catch { /* 권한을 못 바꾸는 파일 시스템 */ }
renameSync ( tmp , file );
}
/** 검증 오류: Error('validation') + field. API 가 422 로 돌려줍니다. */
const invalid = ( field ) => { const e = new Error ( 'validation' ); e . field = field ; return e ; };
/**
* 등록부 항목 하나를 입력(화면 폼)에서 만듭니다. 호스트 목록은 쉼표·공백으로 나눈 문자열이나 배열을 받고, 비어 있으면 mail.<도메인>.
* @param {object} body { domain, hosts?, mailbox?, auth?:{mode, wwwDbFile, ownerLogin}, note?, enabled? }
* @param {object|null} [current] 수정할 때의 기존 항목(없는 필드는 그대로 둠)
*/
export function normalizeEntry ( body , current = null , { keepAuth = false } = {}) {
const domain = normalizeDomain ( current ? . domain || body . domain );
if ( ! isDomainName ( domain )) throw invalid ( 'domain' );
const rawHosts = body . hosts === undefined ? ( current ? . hosts || []) : Array . isArray ( body . hosts ) ? body . hosts : String ( body . hosts || '' ). split ( /[\s,]+/ );
// 호스트 항목은 "호스트" 또는 "호스트/경로"(1.0.8). 호스트 이름을 정규화하고 경로는 /a/b 꼴로 다듬어 다시 "호스트/경로" 로 저장합니다.
const hosts = [];
for ( const raw of rawHosts ) {
if ( ! String ( raw || '' ). trim ()) continue ;
const { host , prefix } = splitHostEntry ( raw );
if ( ! host || ! HOST_RE . test ( host ) || prefix === null ) throw invalid ( 'hosts' );
const entry = prefix ? host + prefix : host ; if ( ! hosts . includes ( entry )) hosts . push ( entry );
}
if ( ! hosts . length ) hosts . push ( 'mail.' + domain );
// 같은 호스트 이름이 경로만 다르게 두 번 들어오면(mail.x.com 과 mail.x.com/mail) 어느 경로가 그 호스트의 웹 메일인지 애매하므로 거부합니다.
if ( new Set ( hosts . map ( hostNameOf )). size !== hosts . length ) throw invalid ( 'hosts' );
const mailboxLocal = String ( body . mailbox === undefined ? ( current ? . mailbox || 'admin@' + domain ) : body . mailbox ). trim (). toLowerCase (). split ( '@' )[ 0 ] || 'admin' ;
if ( ! /^[a-z0-9][a-z0-9._-]{0,63}$/ . test ( mailboxLocal )) throw invalid ( 'mailbox' );
// keepAuth(기본 도메인): 로그인 방식은 환경 파일이 정하므로 입력을 무시하고 검사도 하지 않습니다(시험·www 모드에서 파일 경로가 없을 수 있음).
const authIn = keepAuth ? {} : body . auth && typeof body . auth === 'object' ? body . auth : {};
const mode = authIn . mode === undefined ? ( current ? . auth ? . mode || 'local' ) : String ( authIn . mode );
if ( ! AUTH_MODES . includes ( mode )) throw invalid ( 'auth.mode' );
const wwwDbFile = authIn . wwwDbFile === undefined ? ( current ? . auth ? . wwwDbFile || '' ) : String ( authIn . wwwDbFile || '' ). trim ();
const ownerLogin = authIn . ownerLogin === undefined ? ( current ? . auth ? . ownerLogin || '' ) : String ( authIn . ownerLogin || '' ). trim (). slice ( 0 , 200 );
if ( mode === 'www' && ! keepAuth ) { if ( ! wwwDbFile ) throw invalid ( 'auth.wwwDbFile' ); try { accessSync ( wwwDbFile , constants . R_OK ); } catch { const e = new Error ( 'www_db_unreadable' ); e . field = 'auth.wwwDbFile' ; throw e ; } }
// webmaker 연동(1.0.8): 데이터 디렉터리(DATA_ROOT, 절대 경로)는 필수. webmakerUser 가 있으면 그 계정의 sudo 도우미로 읽으므로 이 프로세스의 읽기 권한을 검사하지 않고,
// 없으면 users.json 을 지금 읽을 수 있어야 합니다(webmaker_dir_unreadable). siteId 는 도메인 ↔ 사이트 대응을 손으로 고정할 때만 적습니다(비우면 도메인 이름으로 찾음).
const webmakerDir = authIn . webmakerDir === undefined ? ( current ? . auth ? . webmakerDir || '' ) : String ( authIn . webmakerDir || '' ). trim (). replace ( /\/+$/ , '' );
const webmakerUser = authIn . webmakerUser === undefined ? ( current ? . auth ? . webmakerUser || '' ) : String ( authIn . webmakerUser || '' ). trim ();
const siteId = authIn . siteId === undefined ? ( current ? . auth ? . siteId || '' ) : String ( authIn . siteId || '' ). trim (). toLowerCase ();
if ( mode === 'webmaker' && ! keepAuth ) {
if ( ! webmakerDir || ! webmakerDir . startsWith ( '/' ) || webmakerDir . includes ( '..' )) throw invalid ( 'auth.webmakerDir' );
if ( webmakerUser && ! USER_RE . test ( webmakerUser )) throw invalid ( 'auth.webmakerUser' );
if ( siteId && ! SITE_ID_RE . test ( siteId )) throw invalid ( 'auth.siteId' );
if ( ! webmakerUser ) { try { accessSync ( join ( webmakerDir , 'users.json' ), constants . R_OK ); } catch { const e = new Error ( 'webmaker_dir_unreadable' ); e . field = 'auth.webmakerDir' ; throw e ; } }
}
const note = String ( body . note === undefined ? ( current ? . note || '' ) : body . note || '' ). trim (). slice ( 0 , 200 );
const enabled = body . enabled === undefined ? ( current ? current . enabled !== false : true ) : ! ( body . enabled === false || body . enabled === 'false' || body . enabled === '0' || body . enabled === 0 );
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] Ariweb Suite 연동 정보(§0.4): 사이트 대표 메일(site_bound) / 독립 메일 서비스(standalone) 구분과 포털 식별자. 운영 상태(operating)는 이전 중 두 서버가 함께 처리하지 않게 합니다.
const ariweb = normalizeAriweb ( body . ariweb === undefined ? current ? . ariweb : body . ariweb );
const operating = body . operating === undefined ? ( current ? . operating || 'active' ) : String ( body . operating || 'active' );
if ( ! OPERATING_STATES . includes ( operating )) throw invalid ( 'operating' );
2026-10-26 11:02:58 +00:00
const stamp = new Date (). toISOString ();
2026-10-26 11:47:45 +00:00
return { domain , enabled , hosts , mailbox : ` ${ mailboxLocal } @ ${ domain } ` , auth : { mode , wwwDbFile : mode === 'www' ? wwwDbFile : '' , ownerLogin : mode === 'www' ? ownerLogin : '' , webmakerDir : mode === 'webmaker' ? webmakerDir : '' , webmakerUser : mode === 'webmaker' ? webmakerUser : '' , siteId : mode === 'webmaker' ? siteId : '' }, note , ariweb , operating , createdAt : current ? . createdAt || stamp , updatedAt : stamp };
}
/** [Ariweb Suite 변경] 도메인 운영 상태. active 가 아니면 수신 임시 거부·발신 차단(limits.operatingState). */
export const OPERATING_STATES = [ 'active' , 'migrating_out' , 'migrating_in' , 'frozen' ];
export const ARIWEB_KINDS = [ 'site_bound' , 'standalone' ];
/** Ariweb 연동 정보 정규화: 없으면 null. kind 는 site_bound|standalone, 나머지는 포털 식별자 문자열(64자). */
export function normalizeAriweb ( v ) {
if ( ! v || typeof v !== 'object' ) return null ;
const kind = String ( v . kind || '' ); if ( ! ARIWEB_KINDS . includes ( kind )) throw invalid ( 'ariweb.kind' );
const id = ( x ) => { const s = String ( x ?? '' ). trim (). slice ( 0 , 64 ); if ( s && ! /^[A-Za-z0-9_.:-]+$/ . test ( s )) throw invalid ( 'ariweb' ); return s ; };
return { kind , siteId : id ( v . siteId ), groupId : id ( v . groupId ), mailServiceId : id ( v . mailServiceId ) };
2026-10-26 11:02:58 +00:00
}
/**
* 등록부 항목의 로그인 방식을 env 에 반영합니다(createEnv 와 update 가 공유). LOGIN_MODE 와 그 방식의 값만 두고 다른 방식의 값은 지웁니다.
* www : WWW_DB_FILE, MAILBOX_OWNER_LOGIN webmaker : WEBMAKER_DIR, WEBMAKER_USER(선택), WEBMAKER_SITE(선택) local : standalone(자체 계정만)
*/
export function applyAuth ( env , auth ) {
for ( const k of [ 'WWW_DB_FILE' , 'MAILBOX_OWNER_LOGIN' , 'WEBMAKER_DIR' , 'WEBMAKER_USER' , 'WEBMAKER_SITE' ]) delete env [ k ];
if ( auth . mode === 'www' ) { env . LOGIN_MODE = 'www' ; env . WWW_DB_FILE = auth . wwwDbFile ; env . MAILBOX_OWNER_LOGIN = auth . ownerLogin || '' ; }
else if ( auth . mode === 'webmaker' ) { env . LOGIN_MODE = 'webmaker' ; env . WEBMAKER_DIR = auth . webmakerDir ; if ( auth . webmakerUser ) env . WEBMAKER_USER = auth . webmakerUser ; if ( auth . siteId ) env . WEBMAKER_SITE = auth . siteId ; }
else env . LOGIN_MODE = 'standalone' ;
}
/** 공개용 auth 요약(목록·API). 비밀값은 없지만 모양을 한곳에서 정합니다. */
const publicAuth = ( a ) => ({ mode : a . mode , wwwDbFile : a . wwwDbFile || '' , ownerLogin : a . ownerLogin || '' , webmakerDir : a . webmakerDir || '' , webmakerUser : a . webmakerUser || '' , siteId : a . siteId || '' });
/** 기본 도메인 항목: 환경 파일 값으로 만듭니다(등록부에 보조 정보가 있으면 hosts·note 만 더합니다). */
function primaryEntry ( baseEnv , stored = null ) {
const mailbox = String ( baseEnv . MAILBOX || 'admin@arihakdang.com' ). trim (). toLowerCase (), domain = mailbox . split ( '@' )[ 1 ] || '' ;
const host = normalizeHost ( baseEnv . PUBLIC_HOST || 'mail.' + domain );
// 기본 호스트의 경로 접두사는 환경 파일의 PUBLIC_PATH(선택, 1.0.8). 등록부에 적힌 나머지 호스트는 "호스트/경로" 그대로 둡니다(이름이 겹치면 앞의 것).
const first = host + ( normalizePrefix ( baseEnv . PUBLIC_PATH ) || '' );
const hosts = []; for ( const h of [ first , ...( Array . isArray ( stored ? . hosts ) ? stored . hosts : [])]) { const { host : n , prefix } = splitHostEntry ( h ); if ( ! n || prefix === null || hosts . some (( x ) => hostNameOf ( x ) === n )) continue ; hosts . push ( prefix ? n + prefix : n ); }
// 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local.
const mode = String ( baseEnv . LOGIN_MODE || '' ) === 'webmaker' && baseEnv . WEBMAKER_DIR ? 'webmaker' : baseEnv . WWW_DB_FILE || baseEnv . WWW_DB ? 'www' : 'local' ;
const auth = { mode , wwwDbFile : mode === 'www' ? String ( baseEnv . WWW_DB_FILE || '' ) : '' , ownerLogin : mode === 'www' ? String ( baseEnv . MAILBOX_OWNER_LOGIN || '' ) : '' , webmakerDir : mode === 'webmaker' ? String ( baseEnv . WEBMAKER_DIR ) : '' , webmakerUser : mode === 'webmaker' ? String ( baseEnv . WEBMAKER_USER || '' ) : '' , siteId : mode === 'webmaker' ? String ( baseEnv . WEBMAKER_SITE || '' ) : '' };
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 기본 도메인도 ariweb·operating 은 등록부 값을 그대로 둡니다(환경 파일에는 없는 정보).
return { domain , enabled : true , primary : true , hosts , mailbox , auth , note : stored ? . note || '' , ariweb : stored ? . ariweb || null , operating : OPERATING_STATES . includes ( stored ? . operating ) ? stored . operating : 'active' , createdAt : stored ? . createdAt || new Date (). toISOString (), updatedAt : stored ? . updatedAt || new Date (). toISOString () };
2026-10-26 11:02:58 +00:00
}
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */
export function loadRegistry ( baseEnv ) {
const file = registryFile ( baseEnv );
let reg = readJson ( file ), created = false ;
const primary = primaryEntry ( baseEnv , reg ? . domains ? .[ String ( reg ? . primary || '' )] || null );
if ( ! reg || typeof reg !== 'object' || ! reg . domains || typeof reg . domains !== 'object' ) { reg = { _meta : { format : REGISTRY_FORMAT , createdAt : new Date (). toISOString () }, primary : primary . domain , domains : {} }; created = true ; }
// 기본 도메인은 환경 파일이 기준입니다. 환경 파일의 도메인이 바뀌었으면(드문 일) 옛 기본 항목은 일반 항목으로 남기지 않고 지웁니다(그 데이터는 기본 DB 그대로).
if ( reg . primary !== primary . domain ) { delete reg . domains [ reg . primary ]; reg . primary = primary . domain ; created = true ; }
reg . domains [ primary . domain ] = primary ;
if ( created ) { try { writeJson ( file , reg ); } catch ( e ) { console . warn ( ` ⚠️ 도메인 등록부를 쓸 수 없습니다( ${ file } ): ${ e . message } ` ); } }
return reg ;
}
/** 테넌트 env 에서 떼어 내야 하는 키: 설정 파일이 관리하는 값(기본 도메인의 비밀값이 새 도메인에 새지 않게)과 도메인마다 다른 값. */
const TENANT_OWN_KEYS = new Set ([ 'DB' , 'DB_FILE' , 'CONFIG_FILE' , 'FILES_DIR' , 'MAILBOX' , 'PUBLIC_HOST' , 'PUBLIC_PATH' , 'WWW_DB' , 'WWW_DB_FILE' , 'LOGIN_MODE' , 'MAILBOX_OWNER_LOGIN' , 'WEBMAKER_DIR' , 'WEBMAKER_USER' , 'WEBMAKER_SITE' , 'WEBMAKER_SOCKET' , 'STRINGS' , 'PAGE' , 'CONFIG' , 'CONFIG_VERSION' , 'CONFIG_CREATED' , 'ON_CONFIG_CHANGE' , 'MAIL_INBOUND' , 'MAIL_OUTBOUND' , 'PLAN_DNS_APPLIED_AT' , 'DOMAIN' , 'TENANT' , 'RATE' , 'SYNCING' , ... CONFIG_SCHEMA . filter (( i ) => i . env ). map (( i ) => i . env )]);
/**
* 테넌트 관리자. 서버 시작 때 한 번 만들고(openTenants) 모든 요청이 이 객체로 env 를 고릅니다.
* @param {object} baseEnv 기본 도메인의 env(main.mjs 가 이미 DB·설정을 연 상태)
* @param {object} o
* @param {string} o.migrationsDir 새 도메인 DB 에 적용할 마이그레이션 디렉터리
* @param {(env:object)=>void} [o.onCreate] 테넌트 env 가 생길 때(기본 도메인 포함) 부르는 훅 — main.mjs 가 버전·문구·화면 캐시를 붙입니다
*/
export function openTenants ( baseEnv , { migrationsDir , onCreate = null , log = console } = {}) {
const registry = loadRegistry ( baseEnv );
const dataDir = configDir ( baseEnv );
const tenants = new Map (); // domain → { entry, env, db }
const creating = new Set (); // add() 진행 중인 도메인
const stripped = Object . fromEntries ( Object . entries ( baseEnv ). filter (([ k ]) => ! TENANT_OWN_KEYS . has ( k )));
/** 기본 도메인: 이미 열린 env 를 그대로 테넌트로 씁니다(데이터 이동 없음). */
const attachPrimary = () => {
const entry = registry . domains [ registry . primary ];
Object . assign ( baseEnv , { DOMAIN : entry . domain , TENANT : entry , DATA_DIR : dataDir , MAIL_SERVER_HOST : normalizeHost ( baseEnv . PUBLIC_HOST || entry . hosts [ 0 ]), PUBLIC_PATH : splitHostEntry ( entry . hosts [ 0 ]). prefix || '' });
if ( ! baseEnv . RATE ) baseEnv . RATE = memoryRateStore ();
tenants . set ( entry . domain , { entry , env : baseEnv , db : null });
if ( onCreate ) onCreate ( baseEnv );
};
/** 추가 도메인의 디렉터리. 도메인 이름은 normalizeEntry 가 검사했으므로 경로에 그대로 써도 안전합니다. */
const dirOf = ( domain ) => join ( dataDir , 'domains' , domain );
/**
* 추가 도메인의 env 를 만듭니다: 자기 DB(마이그레이션 적용)·설정 파일·파일 디렉터리. 설정 파일이 없으면(처음) seed 값으로 만듭니다.
* @param {object} entry 등록부 항목 @param {object|null} seed 처음 만들 때 넣을 설정 값(COPY_KEYS)
*/
const createEnv = ( entry , { seed = null , adminPassword = null } = {}) => {
const dir = dirOf ( entry . domain ); mkdirSync ( dir , { recursive : true });
// PUBLIC_HOST 는 첫 호스트의 이름 부분, PUBLIC_PATH 는 그 경로 접두사(없으면 '')(1.0.8). 링크(대용량 파일·설정 화면 안내)는 둘을 이어 만듭니다.
const { host : publicHost , prefix : publicPath } = splitHostEntry ( entry . hosts [ 0 ]);
const env = { ... stripped , DOMAIN : entry . domain , TENANT : entry , DATA_DIR : dataDir , DB_FILE : join ( dir , 'mail.sqlite' ), CONFIG_FILE : join ( dir , 'config.json' ), FILES_DIR : join ( dir , 'files' ), MAILBOX : entry . mailbox , PUBLIC_HOST : publicHost , PUBLIC_PATH : publicPath || '' , MAIL_SERVER_HOST : baseEnv . MAIL_SERVER_HOST || normalizeHost ( baseEnv . PUBLIC_HOST || '' ), HARAKA_DIR : harakaDir ( baseEnv ), RATE : memoryRateStore (), TENANTS : api };
applyAuth ( env , entry . auth );
const db = openDb ( env . DB_FILE ); migrate ( db , migrationsDir , { quiet : true }); env . DB = d1 ( db );
if ( seed && ! existsSync ( configFile ( env ))) { const cfg = { _meta : { format : 1 , createdAt : new Date (). toISOString (), copiedFrom : registry . primary } }; for ( const k of COPY_KEYS ) if ( seed [ k ] !== undefined && seed [ k ] !== '' ) cfg [ k ] = seed [ k ]; cfg . inbound = 'resend' ; cfg . outbound = 'resend' ; writeJson ( configFile ( env ), cfg ); }
loadConfig ( env );
if ( onCreate ) onCreate ( env );
return { entry , env , db , adminPassword };
};
const api = {
get registry () { return registry ; },
get primary () { return tenants . get ( registry . primary ); },
/** 모든 테넌트(기본 도메인 먼저). */
all () { return [ this . primary , ...[... tenants . values ()]. filter (( t ) => t . entry . domain !== registry . primary )]; },
/** 켜진 테넌트의 env 목록(주기 작업용). */
envs () { return this . all (). filter (( t ) => t . entry . enabled !== false ). map (( t ) => t . env ); },
byDomain ( domain ) { return tenants . get ( normalizeDomain ( domain )) || null ; },
/** 주소(user@domain)의 도메인으로 테넌트를 찾습니다. 도메인이 없는 로그인 ID 는 null(호출자가 기본 도메인 등으로 정함). */
byAddress ( address ) { const d = String ( address || '' ). trim (). toLowerCase (). split ( '@' )[ 1 ]; return d ? this . byDomain ( d ) : null ; },
/**
* HTTP Host 로 테넌트를 고릅니다.
* @returns {{tenant:object|null, disabled:boolean, host:string}} tenant 가 null 이면 모르는 도메인, disabled 면 꺼 둔 도메인
*/
resolveHost ( host ) {
const h = normalizeHost ( host );
let found = null , prefix = '' ;
if ( isLocalHost ( h )) found = this . primary ;
else {
// 등록된 호스트 이름(경로가 붙어 있으면 그 경로가 이 호스트의 접두사, 1.0.8)
for ( const t of tenants . values ()) { const e = t . entry . hosts . find (( x ) => hostNameOf ( x ) === h ); if ( e ) { found = t ; prefix = splitHostEntry ( e ). prefix || '' ; break ; } }
if ( ! found ) { let best = '' ; for ( const t of tenants . values ()) { const d = t . entry . domain ; if (( h === d || h . endsWith ( '.' + d )) && d . length > best . length ) { best = d ; found = t ; } } }
}
return { tenant : found , disabled : !! found && found . entry . enabled === false , host : h , prefix };
},
/**
* 메일 프로그램 로그인(IMAP·POP·SMTP)에서 쓰는 env 고르기: 로그인 ID 에 @도메인이 있으면 그 도메인, 없으면 TLS SNI 호스트가 가리키는 도메인, 그래도 없으면 기본 도메인.
* 꺼 둔 도메인은 null → 로그인 실패. 등록되지 않은 @도메인은 기본 도메인(또는 SNI 도메인)의 계정으로 검사합니다(www 관리자 이메일 로그인 호환).
*/
resolveLogin ( login , servername = '' ) {
const key = String ( login || '' ). trim (). toLowerCase ();
// @ 뒤가 등록된 도메인이면 그 도메인(꺼 두었으면 null). 등록되지 않은 도메인(예: www 관리자가 자기 이메일 someone@gmail.com 으로 로그인)은
// 아래 SNI·기본 도메인 규칙으로 넘깁니다 — verifyUser 가 그 env 의 계정(www 관리자의 이메일 포함)으로 검사하므로 안전합니다.
if ( key . includes ( '@' )) { const t = this . byAddress ( key ); if ( t ) return t . entry . enabled !== false ? t . env : null ; }
if ( servername ) { const r = this . resolveHost ( servername ); if ( r . tenant && ! r . disabled ) return r . tenant . env ; }
return this . primary . env ;
},
/** 화면·API 용 요약 목록(비밀값 없음). */
async list () {
const out = [];
for ( const t of this . all ()) {
const e = t . entry , env = t . env ; let accounts = 0 , messages = 0 ;
try { accounts = Number (( await env . DB . prepare ( 'SELECT COUNT(*) AS n FROM mail_users' ). first ()) ? . n || 0 ); messages = Number (( await env . DB . prepare ( 'SELECT COUNT(*) AS n FROM messages' ). first ()) ? . n || 0 ); } catch { /* DB 오류는 0 으로 */ }
// initialPassword: 도메인을 만들 때 정한 초기 관리자 비밀번호. 관리자가 비밀번호를 바꾸면(initial_password:<id> 표시가 지워짐) 더는 보여 주지 않습니다.
let initialPassword = null ;
if ( t . adminPassword ) { try { if ( await env . DB . prepare ( "SELECT 1 AS x FROM settings WHERE key LIKE 'initial_password:%'" ). first ()) initialPassword = t . adminPassword ; else t . adminPassword = null ; } catch { /* 무시 */ } }
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다.
let external = null ;
if ( e . auth . mode === 'webmaker' ) { try { const r = await externalUsers ( env ); external = r . ok ? { count : r . accounts . length , site : r . site } : { count : 0 , error : r . error }; } catch ( err ) { external = { count : 0 , error : err . message }; } }
2026-10-26 11:47:45 +00:00
out . push ({ domain : e . domain , primary : e . domain === registry . primary , enabled : e . enabled !== false , hosts : e . hosts , mailbox : e . mailbox , auth : publicAuth ( e . auth ), note : e . note || '' , ariweb : e . ariweb || null , operating : e . operating || 'active' , createdAt : e . createdAt , updatedAt : e . updatedAt , inbound : env . MAIL_INBOUND || 'resend' , outbound : env . MAIL_OUTBOUND || 'resend' , setupDone : env . CONFIG ? . SETUP_DONE === true , accounts , messages , external , dir : e . domain === registry . primary ? configDir ( env ) : dirOf ( e . domain ), initialPassword });
2026-10-26 11:02:58 +00:00
}
return out ;
},
/**
* 도메인을 추가합니다(전체 설정 화면). 등록부에 쓰고 바로 env 를 만들어 요청을 받기 시작합니다.
* @param {object} body normalizeEntry 입력 + { adminPassword?, copyFromPrimary? (기본 true) }
* @returns {Promise<{entry:object, initialAdmin:{login:string,password:string}|null}>}
*/
async add ( body ) {
const entry = normalizeEntry ( body );
// creating: 같은 도메인을 동시에 두 번 추가하는 요청이 중복 검사를 함께 통과하지 못하게 합니다(DB 핸들 누수 방지).
if ( tenants . has ( entry . domain ) || creating . has ( entry . domain )) throw new Error ( 'domain_exists' );
if ( entry . domain === registry . primary ) throw new Error ( 'domain_exists' );
creating . add ( entry . domain );
try { return await this . _add ( entry , body ); } finally { creating . delete ( entry . domain ); }
},
async _add ( entry , body ) {
for ( const t of tenants . values ()) for ( const h of entry . hosts ) if ( t . entry . hosts . some (( x ) => hostNameOf ( x ) === hostNameOf ( h ))) { const e = new Error ( 'host_taken' ); e . field = 'hosts' ; e . detail = h ; throw e ; }
const adminPassword = String ( body . adminPassword || INITIAL_ADMIN_PASSWORD );
if ([... adminPassword ]. length < PASSWORD_MIN_FLOOR ) throw invalid ( 'adminPassword' );
const seed = body . copyFromPrimary === false ? null : ( this . primary . env . CONFIG || {});
const t = createEnv ( entry , { seed , adminPassword });
// 첫 관리자: 로그인 방식이 local 이면 admin(메일함 앞부분) 계정을 지정한 비밀번호로 만듭니다(www 연동이면 그쪽 관리자가 로그인).
let initialAdmin = null ;
if ( entry . auth . mode === 'local' ) { const made = await ensureInitialAdmin ( t . env , { password : adminPassword }); if ( made ) initialAdmin = made ; }
registry . domains [ entry . domain ] = entry ; writeJson ( registryFile ( baseEnv ), registry );
tenants . set ( entry . domain , t );
log . log ( ` 도메인 추가: ${ entry . domain } ( ${ entry . hosts . join ( ', ' ) } , 로그인 ${ entry . auth . mode }${ initialAdmin ? `, 초기 관리자 ${ initialAdmin . login } ` : '' } )` );
return { entry , initialAdmin };
},
/** 도메인 정보를 바꿉니다(켜기·끄기, 호스트, 로그인 방식, 메모). 기본 도메인은 hosts·note 만 바꿀 수 있습니다(나머지는 환경 파일). */
update ( domain , body ) {
const t = this . byDomain ( domain ); if ( ! t ) throw new Error ( 'not_found' );
const isPrimary = t . entry . domain === registry . primary ;
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 기본 도메인도 ariweb(연동 정보)·operating(운영 상태)은 바꿀 수 있습니다.
const next = isPrimary ? { ... t . entry , ... normalizeEntry ({ hosts : body . hosts , note : body . note , ariweb : body . ariweb , operating : body . operating }, t . entry , { keepAuth : true }), enabled : true , primary : true , mailbox : t . entry . mailbox , auth : t . entry . auth } : normalizeEntry ({ ... body , domain : t . entry . domain , mailbox : undefined }, t . entry );
2026-10-26 11:02:58 +00:00
// 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림).
if ( isPrimary ) { const ph = normalizeHost ( baseEnv . PUBLIC_HOST || '' ), first = ph + ( normalizePrefix ( baseEnv . PUBLIC_PATH ) || '' ); next . hosts = [ first , ... next . hosts . filter (( h ) => hostNameOf ( h ) !== ph )]; }
for ( const other of tenants . values ()) if ( other !== t ) for ( const h of next . hosts ) if ( other . entry . hosts . some (( x ) => hostNameOf ( x ) === hostNameOf ( h ))) { const e = new Error ( 'host_taken' ); e . field = 'hosts' ; e . detail = h ; throw e ; }
if ( ! isPrimary ) {
// 로그인 방식(www DB·웹메이커 디렉터리)은 env 에 바로 반영합니다(세션·계정 조회가 다음 요청부터 새 방식을 씀).
applyAuth ( t . env , next . auth );
const { host : publicHost , prefix } = splitHostEntry ( next . hosts [ 0 ]); t . env . PUBLIC_HOST = publicHost ; t . env . PUBLIC_PATH = prefix || '' ;
if ( typeof t . env . ON_CONFIG_CHANGE === 'function' ) t . env . ON_CONFIG_CHANGE ( t . env . CONFIG || {});
}
t . entry = next ; t . env . TENANT = next ; registry . domains [ next . domain ] = next ; writeJson ( registryFile ( baseEnv ), registry );
log . log ( ` 도메인 변경: ${ next . domain } ( ${ next . enabled ? '사용' : '중지' } , ${ next . hosts . join ( ', ' ) } , 로그인 ${ next . auth . mode } )` );
return next ;
},
/** 도메인을 등록부에서 지웁니다. 데이터 디렉터리(domains/<도메인>/)는 남겨 두어 되돌릴 수 있습니다(지우려면 서버에서 직접). 기본 도메인은 지울 수 없습니다. */
remove ( domain ) {
const t = this . byDomain ( domain ); if ( ! t ) throw new Error ( 'not_found' );
if ( t . entry . domain === registry . primary ) throw new Error ( 'primary_domain' );
tenants . delete ( t . entry . domain ); delete registry . domains [ t . entry . domain ]; writeJson ( registryFile ( baseEnv ), registry );
try { t . db ? . close (); } catch { /* 이미 닫힘 */ }
log . log ( ` 도메인 삭제: ${ t . entry . domain } (데이터 디렉터리는 남김: ${ dirOf ( t . entry . domain ) } )` );
return { domain : t . entry . domain , dir : dirOf ( t . entry . domain ) };
},
/** 종료: 추가 도메인 DB 를 닫습니다(기본 DB 는 main.mjs 가 닫음). */
close () { for ( const t of tenants . values ()) if ( t . db ) { try { t . db . close (); } catch { /* 무시 */ } } },
};
attachPrimary ();
baseEnv . TENANTS = api ;
// 등록부의 추가 도메인을 모두 엽니다. 하나가 깨져도(파일 권한 등) 나머지와 기본 도메인은 뜨도록 오류는 기록만 합니다.
for ( const [ domain , entry ] of Object . entries ( registry . domains )) {
if ( domain === registry . primary ) continue ;
try { tenants . set ( domain , createEnv ( entry )); log . log ( ` 도메인: ${ domain } ( ${ entry . hosts . join ( ', ' ) } , ${ entry . enabled === false ? '중지' : '사용' } , 로그인 ${ entry . auth . mode } )` ); }
catch ( e ) { log . error ( ` ✖ 도메인 ${ domain } 을 열지 못했습니다: ${ e . message } ` ); }
}
return api ;
}
/**
* "메일 서비스가 없습니다" 안내 화면(요구사항 1). 모르는 호스트로 들어온 웹 요청과 꺼 둔 도메인의 요청에 줍니다. 스크립트 없이 HTML 만.
* @param {object} o host: 요청 호스트, disabled: 꺼 둔 도메인인지, strings: lang/ko.json 묶음(없으면 내장 문구), version
*/
export function noServicePage ({ host = '' , disabled = false , strings = {}, version = '' } = {}) {
const t = ( k , d ) => strings [ k ] || d ;
const title = disabled ? t ( 'domainDisabledTitle' , '메일 서비스가 활성화되지 않았습니다' ) : t ( 'domainMissingTitle' , '메일 서비스가 존재하지 않습니다' );
const body = disabled ? t ( 'domainDisabled' , '이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.' ) : t ( 'domainMissing' , '이 주소에는 등록된 메일 서비스가 없습니다.' );
const ask = t ( 'domainAskAdmin' , '서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.' );
const esc = ( v ) => String ( v ?? '' ). replace ( /[&<>"']/g , ( c ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title> ${ esc ( title ) } </title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:64px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 12px}p{margin:0 0 10px}.host{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef2f7;padding:2px 8px;border-radius:6px;overflow-wrap:anywhere}.meta{color:#6b7a8c;font-size:12px;margin-top:16px}</style></head><body><div class="card"><h1> ${ disabled ? '⏸' : '✉' } ${ esc ( title ) } </h1><p> ${ esc ( body ) } </p> ${ host ? `<p>요청한 주소: <span class="host"> ${ esc ( host ) } </span></p>` : '' } <p> ${ esc ( ask ) } </p><p class="meta">AriMail ${ version ? ' v' + esc ( version ) : '' } </p></div></body></html>` ;
}