Files
AriWeb-Suite/modules/arimail/server/domains.mjs
T

357 lines
34 KiB
JavaScript

/**
* 다중 도메인 등록부와 "테넌트"(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);
const stamp = new Date().toISOString();
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, createdAt: current?.createdAt || stamp, updatedAt: stamp };
}
/**
* 등록부 항목의 로그인 방식을 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 || '') : '' };
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
}
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(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 }; } }
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 || '', 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 });
}
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;
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, 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);
// 기본 도메인의 첫 호스트는 환경 파일의 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>`;
}