Files
arasystem/mail/scripts/setup.mjs
T
ttipo 4d95fc28d8 fix(docker): mail setup — 받는 사람 단위 받는 쪽 한도를 인증 없는 세션에만
- 엔진 기본 "Sender address to recipient throttle"(같은 보낸 도메인 → 한 받는 사람, 시간당 25)의 match 를 is_empty(authenticated_as) 로 —
  사내 메일이 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 452 로 거절되던 문제. 바깥에서 들어오는 메일에는 그대로
- "Sender IP throttle"(초당 연결 5개)은 그대로 — 연결 단계 한도라 웹메일 발송에는 걸리지 않고, 조건을 붙이면 MAIL FROM 단계로 옮겨진다
- mail/README.md "엔진 한도" — 기본값 · 바꾸는 까닭 · 확인 결과 · 운영 전에 정할 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:26 +09:00

1753 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 메일 엔진(Stalwart) 설정 — 여러 번 실행해도 안전하다 (멱등).
*
* 사용법 (저장소 루트에서, Node 20.6 이상 — --env-file · 권장 24 LTS · 의존성 없음):
* node --env-file=.env.development mail/scripts/setup.mjs
* node --env-file=.env.development mail/scripts/setup.mjs --print-dns # 권장 DNS 레코드도 출력
* MAIL_RECOVERY_ADMIN=… node --env-file=.env.production mail/scripts/setup.mjs --recovery
* # 운영의 최초 bootstrap·관리자 복구 — 복구 관리자는 일회성으로만 준다
*
* 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md).
* 비밀번호는 어떤 경우에도 출력하지 않는다.
*
* ⚠️ 운영에서는 docker 를 쓸 수 있어야 한다 (docker 그룹 또는 sudo — mail/README.md "운영 배포").
* 네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 하기 때문이다. 없으면 아무것도 바꾸지 않고 실패한다.
*
* 하는 일 (순서가 중요하다)
* 0. 사전 확인 앱 네트워크 서브넷 · 앱 네트워크에 붙은 컨테이너 · 백엔드 주소 — 하나라도 어긋나면
* 아무것도 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 늦다)
* 1. 모드 감지 bootstrap(첫 기동) / bootstrap 완료·재시작 대기 / 정상
* 2. bootstrap 서버 호스트명·기본 도메인·DKIM 키 → 컨테이너 재시작
* ⚠️ Bootstrap/set 은 원자적이지 않다. 두 번 호출하면 도메인 중복으로 거부되면서도
* 일부 설정(tracer 등)은 한 번 더 만들어진다. 그래서
* - 부르기 전에 임시 비밀번호 파일을 쓸 수 있는지 실제로 써 보고, 안 되면 아무것도 바꾸지 않고 중단한다
* - 응답(무작위 관리자 비밀번호)을 받자마자 파일에 보관한다
* - 재실행 때는 mail 컨테이너의 config.json(또는 보관 파일)으로 "이미 끝났음"을 알아보고 재시작만 한다
* 3. 관리자 영구 관리자(<MAIL_ADMIN_LOCAL_PART>@도메인, 기본 admin) 비밀번호를 MAIL_ADMIN_PASSWORD 로 맞춘다.
* bootstrap 은 늘 admin@ 을 만들므로, 갓 bootstrap 한 엔진이면 그 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾼다.
* 이미 쓰던 엔진의 관리자와 env 아이디가 다르면(bootstrap 뒤에 값만 바꿈) 아무것도 바꾸지 않고 중단한다
* 4. 차단 예외 AllowedIp — 모든 로그인이 백엔드 IP 를 거치므로 필수
* (기본 보안 설정은 IP 당 인증 실패가 하루 100회면 그 IP 를 차단한다)
* 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능)
* 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게
* 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다)
* 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다
* (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안")
* 7. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영)
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
* 8. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 9 보다 먼저 한다)
* 9. [개발] 테스트 직원 계정
*
* 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행)
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { request as httpsRequest } from 'node:https';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// ============================================================
// 상수
// ============================================================
const MIN_NODE_MAJOR = 20;
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
/** compose 내부망에서 실행할 때의 관리 API 주소 — 운영은 8080 을 호스트에 공개하지 않는다 */
const INTERNAL_ADMIN_URL = 'http://mail:8080';
/**
* 앱 네트워크(app-network) 서브넷 기본값 — docker-compose.yml 의 `${APP_NETWORK_SUBNET:-…}` 와 같아야 한다.
* 메일 엔진은 이 대역(또는 그 안의 더 좁은 MAIL_TRUSTED_NETWORKS)만 신뢰한다.
*/
const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24';
/**
* 앱 네트워크 서브넷의 접두 길이 범위 — /24 보다 넓지도, /28 보다 좁지도 않게.
* - 넓은 쪽(/24): 이 대역 전체가 신뢰 대역이다(자동 차단 예외 + 게이트웨이 로그인 허용).
* 게시 포트(25·465·993·443)로 들어온 접속은 원래 주소가 그대로 보이므로, 대역이 넓을수록
* 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹쳐 그 접속까지 믿게 될 위험이 커진다.
* 이 compose 의 앱 네트워크에는 컨테이너 몇 개(backend·db·redis·mail·mailpit)만 붙으므로 /24(254개)면 충분하다.
* - 좁은 쪽(/28): 게이트웨이 1 + 위 5개 + 재생성 여유. /29(사용 가능 6개)는 게이트웨이를 빼면 5개라 여유가 없다.
*/
const APP_NETWORK_PREFIX_RANGE = { widest: 24, narrowest: 28 };
/** 사설 대역(RFC 1918) — 공인 대역을 앱 네트워크로 쓰면 그 대역의 실제 인터넷 주소를 믿게 된다 */
const PRIVATE_IPV4_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
/** 운영에서는 env 파일에 두지 않고 셸에서 일회성으로만 주는 키 (--recovery 실행) */
const RECOVERY_ENV_KEY = 'MAIL_RECOVERY_ADMIN';
/**
* Bootstrap/set 이 쓰는 설정 파일 (mail 컨테이너 안, mail-config 볼륨). 재시작 전에도 이미 있다 —
* bootstrap 모드인데 이 파일이 있으면 Bootstrap/set 은 끝났고 재시작만 남은 것이다 (Stalwart 0.16 에서 확인).
*/
const MAIL_CONFIG_FILE = '/etc/stalwart/config.json';
/** 앱 네트워크(신뢰 대역)에 붙어도 되는 서비스 — 그 밖의 컨테이너(특히 인터넷을 향한 frontend)는 붙이지 않는다 */
const APP_NETWORK_SERVICES = ['backend', 'db', 'redis', 'mail', 'mailpit'];
/**
* 공개 443(https 리스너)에서 허용할 HTTP 경로 — 나머지(JMAP·관리 API·웹 포털·OAuth)는 403.
* 백엔드는 내부 http(8080) 리스너만 쓰므로 영향이 없다. 관리 화면은 SSH 터널로 8080 에 붙는다 (mail/README.md).
* 식의 변수 이름(listener·path)은 Stalwart 0.16 에서 검증했다 (url_path 등은 거부된다).
*/
const HTTP_ENDPOINT_POLICY = {
match: { 0: { if: "listener == 'https' && path != '/.well-known/mta-sts.txt'", then: '403' } },
else: '200',
};
/** 기본 직원 역할 — 계정의 roles {"@type":"User"} 가 가리키는 기본 제공 역할 */
const USER_ROLE_DESCRIPTION = 'User';
/** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */
const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/;
/**
* 엔진 기본 받는 쪽 한도 가운데 받는 사람 단위의 것 (x:MtaInboundThrottle — 같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25).
* id 는 설치마다 다르므로 설명·key 로 찾는다. 기본 조건은 모든 세션이라, 보낸 도메인이 모두 같은 사내 메일은 동료 모두 합쳐 한 사람에게
* 시간당 25통을 넘으면 웹메일 발송(JMAP 제출)의 그 받는 사람이 452 4.4.5 로 거절됐다 — 엔진은 다시 보내지도 반송하지도 않는다 (2026-10-28 확인).
*/
const RECIPIENT_THROTTLE = { description: 'Sender address to recipient throttle', key: ['senderDomain', 'rcpt'] };
/**
* 그 한도를 걸 세션 — 인증 없는 세션(바깥에서 들어오는 메일)만. 엔진 기본값이 스팸 필터에 쓰는 식(x:MtaStageData 의 enableSpamFilter)과 같다.
* authenticated_as 는 인증한 계정 이름이고 인증 없는 세션이면 빈 값이다 — 웹메일 발송은 엔진 안에서 그 직원으로 인증된 세션이라 빠진다.
* 엔진은 저장할 때 식을 검사한다 (모르는 변수·이 맥락에 없는 변수는 거부 — Stalwart 0.16.27 에서 확인).
* ⚠️ 다른 기본 한도 "Sender IP throttle"(보낸 IP 마다 초당 연결 5개)은 그대로 둔다. 연결할 때 세는 한도라 웹메일 발송에는 걸리지 않고,
* 식에 authenticated_as 를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(식의 변수를 아는 첫 단계) 바깥 접속의 연결 수를 더는 세지 않는다.
*/
const UNAUTHENTICATED_SESSIONS_ONLY = { match: {}, else: 'is_empty(authenticated_as)' };
/**
* 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시 (소문자로 맞춰 검사).
* ⚠️ 백엔드 backend/src/modules/auth/employee-address.ts 의 LOCAL_PART_PATTERN(isLocalPart)과 같은 규칙이다 —
* MAIL_ADMIN_LOCAL_PART 를 두 쪽이 함께 읽으므로, 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
*/
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
/** 영구 관리자 아이디 기본값 — 루트 env MAIL_ADMIN_LOCAL_PART (백엔드 mail.settings.ts 의 DEFAULT_MAIL_ADMIN_LOCAL_PART 와 같다) */
const DEFAULT_ADMIN_LOCAL_PART = 'admin';
/**
* bootstrap 이 만드는 영구 관리자의 아이디 — Bootstrap/set 은 아이디를 받지 않고 늘 admin@<기본 도메인> 을 만든다
* (응답 username, Stalwart 0.16.27 에서 확인). MAIL_ADMIN_LOCAL_PART 가 다르면 bootstrap 직후 이 계정의 아이디를 바꾼다.
*/
const BOOTSTRAP_ADMIN_LOCAL_PART = 'admin';
/**
* bootstrap 응답의 무작위 관리자 비밀번호를 "비밀번호 교체 때까지만" 보관하는 곳.
* 저장소 루트의 data/ 는 내용이 git 에 올라가지 않고(.gitignore), .gitkeep 이 있어 클론한 사용자 소유로 생긴다.
* 호스트 Node 로 돌리든 컨테이너에 저장소를 붙여 돌리든 같은 자리라 여기에 둔다. 교체가 끝나면 바로 지운다.
*/
const STATE_FILE = fileURLToPath(new URL('../../data/mail/bootstrap-admin.json', import.meta.url));
const REQUEST_TIMEOUT_MS = 15_000;
const STARTUP_TIMEOUT_MS = 30_000;
const RESTART_TIMEOUT_MS = 120_000;
const POLL_INTERVAL_MS = 2_000;
const QUERY_PAGE_SIZE = 200;
const GET_CHUNK_SIZE = 100;
/** bootstrap → 재시작 → 비밀번호 교체 단계의 반복 한도 (무한 재시작 방지) */
const MAX_ADMIN_STEPS = 6;
const EXIT_ERROR = 1;
/** 후속 조치가 필요하다 — 안내대로 한 뒤 같은 명령(운영 --recovery 뒤에는 --recovery 없이)으로 다시 실행 */
const EXIT_FOLLOW_UP = 3;
/** 이 스크립트가 만든 객체 표시 — 재실행 때 자기가 만든 것만 정리하기 위해 */
const MANAGED_MARK = '[setup.mjs]';
const GATEWAY_ROLE_DESCRIPTION = `메일 게이트웨이 — 대리 접근 전용 ${MANAGED_MARK}`;
/** 게이트웨이는 로그인과 대리 접근만 한다. 메일함 권한은 대리 접근 대상 직원의 것을 쓴다 */
const GATEWAY_PERMISSIONS = { authenticate: true, impersonate: true };
const GATEWAY_DESCRIPTION = `메일 게이트웨이 (백엔드 대리 접근 전용) ${MANAGED_MARK}`;
const ALLOWED_IP_REASON = `${MANAGED_MARK} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`;
/** 개발 전용 — 사내 도메인 밖 메일을 받는 Mailpit (docker-compose.dev.yml 의 mailpit 서비스) */
const DEV_RELAY = {
name: 'mailpit',
description: `개발 전용 — 사내 도메인 밖 메일을 Mailpit 으로 ${MANAGED_MARK}`,
address: 'mailpit',
port: 1025,
};
/** 개발 전용 — 사내 도메인은 로컬 배달, 나머지는 전부 Mailpit 으로 */
const DEV_OUTBOUND_ROUTE = {
match: { 0: { if: 'is_local_domain(rcpt_domain)', then: "'local'" } },
else: `'${DEV_RELAY.name}'`,
};
/** 개발 전용 테스트 직원 계정 — 비밀번호는 MAIL_DEV_USER_PASSWORD 공통 */
const DEV_USERS = [
{ name: 'ara.kim', description: '김아라' },
{ name: 'doyun.lee', description: '이도윤' },
{ name: 'seojun.park', description: '박서준' },
];
/** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */
const PLACEHOLDER_PATTERN = /change[-_]?me/i;
// ============================================================
// 오류 · 출력
// ============================================================
class SetupError extends Error {
constructor(message, { detail, hint } = {}) {
super(message);
this.detail = detail;
this.hint = hint;
}
}
class JmapMethodError extends SetupError {
constructor(method, error) {
super(`${method} 실패: ${error.type}${error.description ? ` — ${error.description}` : ''}`);
this.type = error.type;
this.description = error.description ?? '';
}
}
/** 재시작이 필요한데 이 환경에서 docker 를 쓸 수 없다 */
class RestartRequired extends Error {
constructor(reason, container) {
super(reason);
this.container = container;
}
}
/** 끝까지 진행한 뒤 실패로 보고할 문제 (예: 백엔드 주소가 신뢰 대역 밖) */
const problems = [];
const log = (message) => console.log(message);
const warn = (message) => console.warn(`[주의] ${message}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ============================================================
// 설정 (루트 .env.{APP_ENV})
// ============================================================
function readEnv(key) {
const value = process.env[key]?.trim();
return value ? value : undefined;
}
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 */
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|MAIL_\w+|MAILPIT_\w+)$/;
/** Node --env-file 과 같은 규칙으로 읽는다 (따옴표 없는 값은 # 부터 주석 — 공백이 없어도) */
function parseEnvText(text) {
const entries = new Map();
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const match = /^(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/.exec(line);
if (!match) continue;
let value = match[2].trim();
const quote = value[0];
if ((quote === '"' || quote === "'" || quote === '`') && value.length >= 2 && value.endsWith(quote)) {
value = value.slice(1, -1);
} else {
value = value.replace(/#.*$/, '').trim();
}
entries.set(match[1], value);
}
return entries;
}
/** --env-file 로 받은 파일들의 값 (뒤 파일이 이긴다) */
function readEnvFiles() {
const files = process.execArgv.flatMap((arg, index, all) => {
if (arg.startsWith('--env-file=')) return [arg.slice('--env-file='.length)];
if (arg === '--env-file' && all[index + 1]) return [all[index + 1]];
return [];
});
const fromFiles = new Map();
for (const file of files) {
try {
for (const [key, value] of parseEnvText(readFileSync(file, 'utf8'))) fromFiles.set(key, value);
} catch {
// 읽을 수 없는 파일은 Node 가 이미 오류를 냈을 것이다
}
}
return fromFiles;
}
/**
* Node 의 --env-file 은 셸에 이미 있는 환경변수를 덮어쓰지 않는다.
* 셸에 APP_ENV=development 가 남은 채 --env-file=.env.production 으로 실행하면
* 운영 서버에 개발용 설정(Mailpit 발송 경로 등)을 넣게 된다 → 값이 어긋나면 중단한다.
* 복구 관리자(MAIL_RECOVERY_ADMIN)는 원래 셸에서 일회성으로 주는 값이라 검사하지 않는다.
*/
function findShellOverrides(fromFiles) {
return [...fromFiles]
.filter(
([key, value]) =>
key !== RECOVERY_ENV_KEY && WATCHED_KEY.test(key) && process.env[key] !== undefined && process.env[key] !== value,
)
.map(([key]) => key);
}
function loadConfig(argv) {
const fromFiles = readEnvFiles();
const overridden = findShellOverrides(fromFiles);
if (overridden.length > 0) {
throw new SetupError(`셸 환경변수가 env 파일 값을 가리고 있습니다: ${overridden.join(', ')}`, {
hint:
'Node 의 --env-file 은 이미 있는 환경변수를 덮어쓰지 않아 의도와 다른 설정으로 실행됩니다. ' +
`해당 변수를 지우고 다시 실행하세요 (bash: unset ${overridden.join(' ')} · PowerShell: Remove-Item Env:${overridden[0]}).`,
});
}
const errors = [];
const warnings = [];
const need = (key) => {
const value = readEnv(key);
if (value === undefined) errors.push(`${key} 가 설정되지 않았습니다.`);
return value ?? '';
};
const appEnv = readEnv('APP_ENV');
if (appEnv !== 'development' && appEnv !== 'production') {
errors.push(
`APP_ENV 는 development 또는 production 이어야 합니다 (현재: ${appEnv ?? '없음'}). ` +
'--env-file=.env.{APP_ENV} 를 빠뜨리지 않았는지 확인하세요.',
);
}
const isProduction = appEnv === 'production';
const projectName = need('PROJECT_NAME');
const domain = need('MAIL_DOMAIN').toLowerCase();
const hostname = need('MAIL_HOSTNAME').toLowerCase();
const adminPassword = need('MAIL_ADMIN_PASSWORD');
const gatewayAddress = need('MAIL_GATEWAY_USER').toLowerCase();
const gatewayPassword = need('MAIL_GATEWAY_PASSWORD');
const devUserPassword = isProduction ? undefined : need('MAIL_DEV_USER_PASSWORD');
const at = gatewayAddress.indexOf('@');
const gatewayLocal = at > 0 ? gatewayAddress.slice(0, at) : '';
if (gatewayAddress && (!LOCAL_PART_PATTERN.test(gatewayLocal) || gatewayAddress.slice(at + 1) !== domain)) {
errors.push(`MAIL_GATEWAY_USER 는 <이름>@${domain || 'MAIL_DOMAIN'} 형식의 주소여야 합니다 (현재: ${gatewayAddress}).`);
}
// 영구 관리자 아이디 — 백엔드가 같은 값으로 웹메일 로그인·대리 접근을 막는다. bootstrap 전에만 정할 수 있다
const adminLocalRaw = readEnv('MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_ADMIN_LOCAL_PART;
const adminLocalPart = adminLocalRaw.toLowerCase();
if (!LOCAL_PART_PATTERN.test(adminLocalPart)) {
errors.push(
`MAIL_ADMIN_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${adminLocalRaw}").`,
);
} else if (adminLocalPart === gatewayLocal) {
errors.push('MAIL_ADMIN_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.');
} else if (!isProduction && DEV_USERS.some((user) => user.name === adminLocalPart)) {
errors.push(`MAIL_ADMIN_LOCAL_PART(${adminLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
}
// 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다
const blockedAccounts = (readEnv('MAIL_BLOCKED_ACCOUNTS') ?? '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
// bootstrap/recovery 전용 임시 관리자 "아이디:비밀번호".
// 값이 있는 동안은 정상 모드에서도 관리자로 로그인되므로, 운영은 env 파일에 두지 않고 --recovery 실행 때만 셸에서 준다
const recoveryFlag = argv.includes('--recovery');
let recovery = null;
const recoveryRaw = readEnv(RECOVERY_ENV_KEY);
if (recoveryRaw) {
const sep = recoveryRaw.indexOf(':');
if (sep <= 0 || sep === recoveryRaw.length - 1) {
errors.push(`${RECOVERY_ENV_KEY} 는 "아이디:비밀번호" 형식이어야 합니다.`);
} else {
recovery = { user: recoveryRaw.slice(0, sep), password: recoveryRaw.slice(sep + 1) };
}
}
if (isProduction) {
if (fromFiles.get(RECOVERY_ENV_KEY)?.trim()) {
errors.push(
`운영 env 파일에 ${RECOVERY_ENV_KEY} 가 들어 있습니다. 파일에서 지우고, 최초 설정·관리자 복구 때만 ` +
'셸에서 일회성으로 주고 --recovery 로 실행하세요 (mail/README.md "운영 배포").',
);
} else if (recoveryRaw && !recoveryFlag) {
errors.push(
`${RECOVERY_ENV_KEY} 가 설정되어 있습니다. 최초 설정·관리자 복구라면 --recovery 를 붙여 실행하고, ` +
`아니면 셸에서 지우세요 (unset ${RECOVERY_ENV_KEY}).`,
);
} else if (recoveryFlag && !recoveryRaw) {
errors.push(`--recovery 에는 ${RECOVERY_ENV_KEY}="아이디:비밀번호" 를 셸에서 일회성으로 함께 줘야 합니다.`);
}
}
// 신뢰 대역 — 고정한 앱 네트워크 서브넷, 또는 그 안의 더 좁은 대역만 허용한다 (더 넓으면 외부 접속까지 믿게 된다)
const appNetworkSubnet = readEnv('APP_NETWORK_SUBNET') ?? DEFAULT_APP_NETWORK_SUBNET;
const subnetProblem = describeSubnetProblem(appNetworkSubnet);
const subnetValid = parseCidr(appNetworkSubnet) !== null;
if (subnetProblem) errors.push(`APP_NETWORK_SUBNET(${appNetworkSubnet}) — ${subnetProblem}`);
const trustedNetworks = (readEnv('MAIL_TRUSTED_NETWORKS') ?? appNetworkSubnet)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
if (trustedNetworks.length === 0) errors.push('MAIL_TRUSTED_NETWORKS 가 비어 있습니다.');
for (const network of trustedNetworks) {
if (parseCidr(network) === null) {
errors.push(`MAIL_TRUSTED_NETWORKS 항목은 IPv4 CIDR 이어야 합니다 (앱 네트워크는 IPv4): ${network}`);
} else if (subnetValid && !cidrWithin(network, appNetworkSubnet)) {
errors.push(
`MAIL_TRUSTED_NETWORKS 의 ${network} 가 APP_NETWORK_SUBNET(${appNetworkSubnet}) 보다 넓거나 그 밖에 있습니다. ` +
'신뢰 대역은 앱 네트워크 안으로만 좁힐 수 있습니다 — 비우면 APP_NETWORK_SUBNET 과 같습니다.',
);
}
}
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
const secrets = {
MAIL_ADMIN_PASSWORD: adminPassword,
MAIL_GATEWAY_PASSWORD: gatewayPassword,
MAIL_RECOVERY_ADMIN: recovery?.password,
MAIL_DEV_USER_PASSWORD: devUserPassword,
};
for (const [key, value] of Object.entries(secrets)) {
if (!value || !PLACEHOLDER_PATTERN.test(value)) continue;
if (isProduction) errors.push(`${key} 가 예시 값 그대로입니다. 운영용 무작위 값으로 교체하세요.`);
else warnings.push(`${key} 가 예시 값 그대로입니다. 개발용이라도 무작위 값을 권장합니다.`);
}
if (adminPassword && adminPassword === gatewayPassword) {
(isProduction ? errors : warnings).push('MAIL_GATEWAY_PASSWORD 가 MAIL_ADMIN_PASSWORD 와 같습니다. 서로 다른 값을 쓰세요.');
}
// 관리 API 주소 — 명시값 > 개발 관리 포트(127.0.0.1) > compose 내부망
const adminPort = readEnv('MAIL_ADMIN_PORT');
const adminUrl = (readEnv('MAIL_ADMIN_URL') ?? (adminPort ? `http://127.0.0.1:${adminPort}` : INTERNAL_ADMIN_URL)).replace(/\/+$/, '');
// 공개 주소 — 운영은 https 여야 한다 (JMAP·OAuth 디스커버리 주소, 그리고 바깥에서 본 443 차단 확인에 쓴다)
const publicUrl = readEnv('MAIL_PUBLIC_URL')?.replace(/\/+$/, '');
if (isProduction && !/^https:\/\/[^/]+$/i.test(publicUrl ?? '')) {
errors.push(`운영의 MAIL_PUBLIC_URL 은 경로 없는 https:// 주소여야 합니다 (현재: ${publicUrl ?? '없음'}, 예: https://${hostname || 'mx1.example.kr'}).`);
}
if (errors.length > 0) {
throw new SetupError('환경변수 설정이 올바르지 않습니다.', {
detail: errors.map((message) => ` - ${message}`).join('\n'),
hint: '루트 .env.{APP_ENV} 를 확인하세요 (mail/README.md).',
});
}
return {
appEnv,
isProduction,
projectName,
container: `${projectName}-mail`,
backendContainer: `${projectName}-backend`,
domain,
hostname,
adminUrl,
publicUrl,
adminLocalPart,
adminAddress: `${adminLocalPart}@${domain}`,
bootstrapAdminAddress: `${BOOTSTRAP_ADMIN_LOCAL_PART}@${domain}`,
blockedAccounts,
adminPassword,
recovery,
recoveryFlag,
gatewayAddress,
gatewayLocal,
gatewayPassword,
appNetworkSubnet,
networkName: `${projectName}_app-network`,
trustedNetworks,
devUserPassword,
mailpitUiPort: readEnv('MAILPIT_UI_PORT'),
warnings,
printDns: argv.includes('--print-dns'),
docker: dockerAvailable(),
};
}
// ============================================================
// IPv4 대역 계산 (비트 연산 대신 산술 — 32비트 부호 문제 회피)
// ============================================================
function parseIpv4(text) {
const parts = text.split('.');
if (parts.length !== 4) return null;
let value = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return null;
value = value * 256 + Number(part);
}
return value;
}
function parseCidr(text) {
const [ip, bits = '32'] = text.split('/');
const base = parseIpv4(ip);
if (base === null || !/^\d{1,2}$/.test(bits) || Number(bits) > 32) return null;
return { base, prefix: Number(bits) };
}
function cidrContains(cidr, ipText) {
const range = parseCidr(cidr);
const ip = parseIpv4(ipText);
if (range === null || ip === null) return false;
const blockSize = 2 ** (32 - range.prefix);
return Math.floor(ip / blockSize) === Math.floor(range.base / blockSize);
}
/** CIDR 의 [첫 주소, 끝 주소] */
function cidrRange({ base, prefix }) {
const blockSize = 2 ** (32 - prefix);
const start = Math.floor(base / blockSize) * blockSize;
return [start, start + blockSize - 1];
}
/** inner 대역이 outer 대역 안에 완전히 들어가는가 (같아도 된다) */
function cidrWithin(inner, outer) {
const a = parseCidr(inner);
const b = parseCidr(outer);
if (a === null || b === null) return false;
const [aStart, aEnd] = cidrRange(a);
const [bStart, bEnd] = cidrRange(b);
return aStart >= bStart && aEnd <= bEnd;
}
/** 앱 네트워크 서브넷으로 쓸 수 없는 이유 — 쓸 수 있으면 null (기준: APP_NETWORK_PREFIX_RANGE 머리말) */
function describeSubnetProblem(text) {
const cidr = parseCidr(text);
if (cidr === null || !text.includes('/')) return 'IPv4 CIDR 이어야 합니다 (예: 172.16.71.0/24).';
const { widest, narrowest } = APP_NETWORK_PREFIX_RANGE;
if (cidr.prefix < widest || cidr.prefix > narrowest) {
return `접두 길이는 /${widest} ~ /${narrowest} 여야 합니다 — 이 대역 전체가 메일 엔진의 신뢰 대역이 되므로 넓게 잡지 않는다 (setup.mjs 의 APP_NETWORK_PREFIX_RANGE).`;
}
const [start] = cidrRange(cidr);
if (start !== cidr.base) return `네트워크 주소로 적어야 합니다 (예: ${formatIpv4(start)}/${cidr.prefix}).`;
if (!PRIVATE_IPV4_RANGES.some((range) => cidrWithin(text, range))) {
return `사설 대역(${PRIVATE_IPV4_RANGES.join(', ')}) 안이어야 합니다 — 공인 대역이면 그 대역의 실제 인터넷 주소를 믿게 된다.`;
}
return null;
}
function formatIpv4(value) {
const parts = [];
let rest = value;
for (let i = 0; i < 4; i += 1) {
parts.unshift(rest % 256);
rest = Math.floor(rest / 256);
}
return parts.join('.');
}
// ============================================================
// docker (호스트에서 실행할 때만 — 컨테이너 안에서는 없을 수 있다)
// ============================================================
function runDocker(args) {
return spawnSync('docker', args, { encoding: 'utf8' });
}
function dockerAvailable() {
return runDocker(['version', '--format', '{{.Server.Version}}']).status === 0;
}
/** 컨테이너의 { 네트워크 이름: IP } — 컨테이너가 없으면 null */
function containerNetworks(name) {
const result = runDocker(['inspect', '--format', '{{json .NetworkSettings.Networks}}', name]);
if (result.status !== 0) return null;
const networks = JSON.parse(result.stdout) ?? {};
return Object.fromEntries(Object.entries(networks).map(([network, info]) => [network, info.IPAddress]));
}
/** 도커 네트워크의 서브넷 목록 — 네트워크가 없으면 null */
function networkSubnets(name) {
const result = runDocker(['network', 'inspect', '--format', '{{json .IPAM.Config}}', name]);
if (result.status !== 0) return null;
return (JSON.parse(result.stdout) ?? []).map((config) => config.Subnet).filter(Boolean);
}
/** 도커 네트워크에 붙은 컨테이너 이름 목록 — 네트워크가 없으면 null */
function networkContainerNames(name) {
const result = runDocker(['network', 'inspect', '--format', '{{json .Containers}}', name]);
if (result.status !== 0) return null;
return Object.values(JSON.parse(result.stdout) ?? {})
.map((container) => container.Name)
.filter(Boolean)
.sort();
}
/**
* mail 컨테이너에 Bootstrap/set 이 쓴 설정 파일이 있는가 — true·false, docker 로 확인할 수 없으면 null.
* (이미지에 test 가 있다. 컨테이너가 없거나 exec 가 실패하면 null)
*/
function mailConfigWritten(cfg) {
if (!cfg.docker) return null;
const result = runDocker(['exec', cfg.container, 'test', '-s', MAIL_CONFIG_FILE]);
if (result.status === 0) return true;
if (result.status === 1) return false;
return null;
}
/** 컨테이너 환경변수 값이 비어 있지 않은가 — 값 자체는 읽기만 하고 출력하지 않는다. 컨테이너가 없으면 null */
function containerEnvIsSet(name, key) {
const result = runDocker(['inspect', '--format', '{{json .Config.Env}}', name]);
if (result.status !== 0) return null;
const entry = (JSON.parse(result.stdout) ?? []).find((line) => line.startsWith(`${key}=`));
return Boolean(entry && entry.slice(key.length + 1).trim());
}
/** 메일 컨테이너 안에서 본 HTTP 상태 코드 (이미지의 curl 사용, 자체 인증서라 -k) — 실패하면 null */
function statusInsideMail(cfg, url) {
const result = runDocker(['exec', cfg.container, 'curl', '-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10', url]);
const code = Number(result.stdout?.trim());
return result.status === 0 && Number.isInteger(code) ? code : null;
}
// ============================================================
// HTTP · JMAP
// ============================================================
function basicAuth(user, password) {
return `Basic ${Buffer.from(`${user}:${password}`, 'utf8').toString('base64')}`;
}
function unreachableHint(cfg) {
if (cfg.isProduction) {
return '운영은 8080 을 공개하지 않으므로 compose 내부망에서 실행해야 합니다 (mail/README.md "운영 배포").';
}
return (
'mail 컨테이너가 떠 있는지 확인하세요 — 루트 .env.development 에 COMPOSE_PROFILES=mail 후 ' +
'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d ' +
'(MAIL_ADMIN_PORT 가 compose 포트 매핑과 같은지도 확인)'
);
}
async function request(cfg, path, init = {}) {
const url = `${cfg.adminUrl}${path}`;
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
} catch (error) {
throw new SetupError(`메일 엔진에 연결할 수 없습니다: ${url} (${error.cause?.code ?? error.message})`, {
hint: unreachableHint(cfg),
});
}
}
/** HTTP 가 응답할 때까지 기다린다 (bootstrap 모드에서도 8080 /healthz/live 는 열린다) */
async function waitForHttp(cfg, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = '';
while (Date.now() < deadline) {
try {
const res = await fetch(`${cfg.adminUrl}/healthz/live`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (res.ok) return;
lastError = `HTTP ${res.status}`;
} catch (error) {
lastError = error.cause?.code ?? error.message;
}
await sleep(POLL_INTERVAL_MS);
}
throw new SetupError(`메일 엔진이 응답하지 않습니다: ${cfg.adminUrl} (${lastError})`, { hint: unreachableHint(cfg) });
}
/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치(또는 허용 IP 밖) */
async function loginStatus(cfg, user, password) {
const res = await request(cfg, '/jmap/session', { headers: { authorization: basicAuth(user, password) } });
await res.body?.cancel();
return res.status;
}
/**
* https 주소의 HTTP 상태 — 인증서는 검사하지 않는다(발급 전일 수 있다). authorized 는 인증서가 믿을 만한지.
* 연결하지 못하면 { error }.
*/
function httpsStatus(url) {
return new Promise((resolve) => {
const req = httpsRequest(url, { method: 'GET', rejectUnauthorized: false, timeout: REQUEST_TIMEOUT_MS }, (res) => {
const authorized = res.socket.authorized === true;
res.resume();
resolve({ status: res.statusCode, authorized });
});
req.on('timeout', () => req.destroy(Object.assign(new Error('시간 초과'), { code: 'ETIMEDOUT' })));
req.on('error', (error) => resolve({ error: error.code ?? error.message }));
req.end();
});
}
/** 관리 API 클라이언트 — 로그인에 실패하면 null */
async function openClient(cfg, user, password) {
const auth = basicAuth(user, password);
const res = await request(cfg, '/jmap/session', { headers: { authorization: auth } });
if (res.status === 401 || res.status === 403) {
await res.body?.cancel();
return null;
}
if (!res.ok) throw new SetupError(`세션 조회 실패 (HTTP ${res.status})`);
const session = await res.json();
return { cfg, auth, session, accountId: session.primaryAccounts?.[STALWART_CAPABILITY] };
}
async function jmap(client, methodCalls) {
const res = await request(client.cfg, '/jmap/', {
method: 'POST',
headers: { authorization: client.auth, 'content-type': 'application/json' },
body: JSON.stringify({ using: JMAP_USING, methodCalls }),
});
if (!res.ok) {
throw new SetupError(`JMAP 요청 실패 (HTTP ${res.status})`, { detail: (await res.text()).slice(0, 500) });
}
return (await res.json()).methodResponses;
}
async function call(client, method, args = {}) {
const [[name, result]] = await jmap(client, [[method, { accountId: client.accountId, ...args }, 'c0']]);
if (name === 'error') throw new JmapMethodError(method, result);
return result;
}
function describeSetError(error) {
const parts = [error.type];
if (error.description) parts.push(error.description);
if (error.properties?.length) parts.push(`속성: ${error.properties.join(', ')}`);
return parts.join(' — ');
}
/** x:<Type>/set — 하나라도 거부되면 예외 */
async function setObjects(client, type, { create, update, destroy }) {
const args = {};
if (create && Object.keys(create).length > 0) args.create = create;
if (update && Object.keys(update).length > 0) args.update = update;
if (destroy && destroy.length > 0) args.destroy = destroy;
const result = await call(client, `x:${type}/set`, args);
const failures = [];
for (const [field, label] of [
['notCreated', '생성'],
['notUpdated', '변경'],
['notDestroyed', '삭제'],
]) {
for (const [id, error] of Object.entries(result[field] ?? {})) {
failures.push(` - ${label} 거부 (${id}): ${describeSetError(error)}`);
}
}
if (failures.length > 0) throw new SetupError(`x:${type}/set 요청이 거부되었습니다.`, { detail: failures.join('\n') });
return result;
}
/** x:<Type>/query 전체 페이지 → x:<Type>/get */
async function listAll(client, type, properties) {
const ids = [];
for (;;) {
const page = await call(client, `x:${type}/query`, { position: ids.length, limit: QUERY_PAGE_SIZE, calculateTotal: true });
ids.push(...page.ids);
const done =
page.ids.length === 0 || (typeof page.total === 'number' ? ids.length >= page.total : page.ids.length < QUERY_PAGE_SIZE);
if (done) break;
}
const list = [];
for (let i = 0; i < ids.length; i += GET_CHUNK_SIZE) {
const page = await call(client, `x:${type}/get`, { ids: ids.slice(i, i + GET_CHUNK_SIZE), properties });
list.push(...page.list);
}
return list;
}
// ============================================================
// bootstrap · 관리자
// ============================================================
function readState() {
if (!existsSync(STATE_FILE)) return null;
try {
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
return state?.username && state?.secret ? state : null;
} catch {
return null;
}
}
/** 상태 파일을 쓸 수 없을 때의 안내 — 운영은 sudo docker compose 가 data/ 를 root 소유로 만들었을 수 있다 */
function stateDirHint() {
return (
`이 스크립트를 실행하는 사용자가 ${dirname(STATE_FILE)} 에 쓸 수 있어야 합니다. ` +
'저장소의 data/ 와 data/mail/ 이 그 사용자 소유인지 확인하고, 아니면 소유자를 바꾼 뒤 다시 실행하세요 ' +
'(예: sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail). ' +
'매번 같은 사용자(같은 sudo 여부)로 실행해야 한다 — mail/README.md "운영 배포".'
);
}
/**
* Bootstrap/set 을 부르기 전에 상태 파일 자리에 실제로 써 본다.
* bootstrap 직후에 보관하지 못하면 재실행이 Bootstrap/set 을 다시 부르게 되므로(원자적이지 않다),
* 쓸 수 없으면 아무것도 바꾸지 않은 지금 멈춘다.
*/
function ensureStateWritable() {
const probe = `${STATE_FILE}.${process.pid}.probe`;
try {
mkdirSync(dirname(STATE_FILE), { recursive: true });
writeFileSync(probe, '', { mode: 0o600 });
rmSync(probe, { force: true });
} catch (error) {
throw new SetupError(
`임시 비밀번호 파일 자리(${dirname(STATE_FILE)})에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다 — 아무것도 바꾸지 않았습니다 (${error.code ?? error.message}).`,
{ hint: stateDirHint() },
);
}
}
function writeState(issued) {
const state = { username: issued.username, secret: issued.secret, issuedAt: new Date().toISOString() };
try {
mkdirSync(dirname(STATE_FILE), { recursive: true });
writeFileSync(STATE_FILE, JSON.stringify(state), { mode: 0o600 });
} catch (error) {
// 바로 앞에서 써 봤으므로 드물다. bootstrap 은 이미 끝났다 — 재실행은 config.json 으로 이를 알아보고 재시작만 한다
throw new SetupError(`bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다 (${error.code ?? error.message}).`, {
hint:
'Bootstrap/set 을 다시 부르지 마세요. 권한을 고친 뒤 같은 명령으로 다시 실행하면 재시작 후 복구 관리자로 관리자 비밀번호를 맞춥니다. ' +
stateDirHint(),
});
}
}
/** 상태 파일 삭제 — strict 면 실패를 오류로, 아니면 경고만 (비밀번호를 이미 바꿨다면 남은 파일은 옛 값이다) */
function removeState({ strict = false } = {}) {
try {
rmSync(STATE_FILE, { force: true });
} catch (error) {
if (strict) throw new SetupError(`옛 임시 비밀번호 파일을 지우지 못했습니다: ${STATE_FILE} (${error.code ?? error.message})`, { hint: stateDirHint() });
warn(`임시 비밀번호 파일을 지우지 못했습니다 — 직접 지우세요: ${STATE_FILE} (${error.code ?? error.message})`);
}
}
function resetHint(cfg) {
return (
'개발 데이터를 버려도 된다면 메일 볼륨을 지우고 처음부터 시작할 수 있습니다: ' +
'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit && ' +
`docker volume rm ${cfg.projectName}_mail-config ${cfg.projectName}_mail-data`
);
}
function adminRecoveryHint(cfg) {
if (cfg.isProduction) {
return (
`MAIL_ADMIN_PASSWORD 가 서버에 설정된 값과 다릅니다(또는 최초 bootstrap 입니다). 복구 관리자를 셸에서 일회성으로 주고 ` +
`mail 컨테이너를 다시 만든 뒤(export ${RECOVERY_ENV_KEY}=…; docker compose --env-file .env.production up -d mail) ` +
'--recovery 로 이 스크립트를 실행하면 관리자 비밀번호를 env 값으로 맞춥니다. 끝나면 안내대로 복구 관리자를 끕니다 (mail/README.md "운영 배포").'
);
}
return (
`MAIL_ADMIN_PASSWORD 가 서버에 설정된 값과 다릅니다. ${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤` +
'(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) ' +
`이 스크립트를 다시 실행하면 관리자 비밀번호를 env 값으로 맞춥니다. ${resetHint(cfg)}`
);
}
/**
* 관리 API 를 쓸 수 있는 세션인가 — 데이터 없는 호출(x:Account/get ids:[])로 본다.
* 직원 세션은 forbidden, 관리자(또는 관리자를 대리 접근한 세션)는 성공한다.
*/
async function hasManagementAccess(client) {
const [[name, result]] = await jmap(client, [['x:Account/get', { accountId: client.accountId, ids: [] }, 'm']]);
if (name !== 'error') return true;
if (result.type === 'forbidden') return false;
throw new JmapMethodError('x:Account/get', result);
}
/** bootstrap 모드에서는 Bootstrap 외의 객체가 전부 forbidden 이다 */
async function isBootstrapMode(client) {
try {
await call(client, 'x:Account/query', { limit: 1 });
return false;
} catch (error) {
if (error instanceof JmapMethodError && error.type === 'forbidden' && /bootstrap/i.test(error.description)) return true;
throw error;
}
}
async function restartMail(cfg, reason) {
if (!cfg.docker) throw new RestartRequired(reason, cfg.container);
log(`[재시작] ${reason} — docker restart ${cfg.container}`);
const result = runDocker(['restart', cfg.container]);
if (result.status !== 0) {
throw new SetupError(`컨테이너를 재시작하지 못했습니다: ${cfg.container}`, { detail: result.stderr?.trim() });
}
await waitForHttp(cfg, RESTART_TIMEOUT_MS);
}
async function runBootstrap(cfg, recovery) {
log(
`[생성] bootstrap — 호스트명 ${cfg.hostname}, 기본 도메인 ${cfg.domain}, DKIM 키 생성` +
(cfg.isProduction ? ', TLS 인증서(ACME) 요청' : ''),
);
const result = await call(recovery, 'x:Bootstrap/set', {
update: {
singleton: {
serverHostname: cfg.hostname,
defaultDomain: cfg.domain,
// 개발 도메인(.test)은 공개 DNS 에 없으므로 인증서를 요청하지 않는다
requestTlsCertificate: cfg.isProduction,
generateDkimKeys: true,
// 로그를 컨테이너 표준 출력으로 보낸다 → docker logs (기본값은 컨테이너 안의 파일)
tracer: { '@type': 'Stdout', level: 'info', ansi: false },
},
},
});
const issued = result.updated?.singleton;
if (!issued?.username || !issued?.secret) {
const rejected = result.notUpdated?.singleton;
throw new SetupError('bootstrap 이 거부되었습니다.', {
detail: rejected ? describeSetError(rejected) : undefined,
hint:
rejected?.type === 'primaryKeyViolation'
? `데이터 볼륨(${cfg.projectName}_mail-data)에 이전 설치가 남아 있는데 설정 볼륨에 config.json 이 없는 상태입니다. ` +
(cfg.isProduction
? '새 설치라면 볼륨을 지우고 처음부터, 운영 중이던 서버라면 백업에서 mail-config 볼륨을 복원하세요 (mail/README.md "문제 해결").'
: resetHint(cfg))
: undefined,
});
}
// 무작위 관리자 비밀번호는 이 응답에서 한 번만 보인다 — 다른 일을 하기 전에 먼저 보관한다
writeState(issued);
}
/** 관리자 역할 계정인가 — bootstrap 이 만드는 관리자는 roles {"@type":"Admin"} 이다 */
function isAdminAccount(account) {
return account.roles?.['@type'] === 'Admin';
}
/** MAIL_ADMIN_LOCAL_PART 를 bootstrap 뒤에 바꿨다 — 엔진의 관리자와 이름이 다르다 */
function adminNameMismatch(cfg, engineAdmins) {
return new SetupError(
`메일 엔진의 관리자(${engineAdmins.join(', ') || '찾지 못함'})와 MAIL_ADMIN_LOCAL_PART(${cfg.adminLocalPart}) 가 다릅니다 — 아무것도 바꾸지 않고 중단합니다.`,
{
hint:
'MAIL_ADMIN_LOCAL_PART 는 bootstrap 전에만 정할 수 있다. 그대로 두면 백엔드는 새 이름만 막고 엔진의 실제 관리자는 막지 않는다. ' +
'이미 바꿨다면 (1) env 를 엔진 관리자의 아이디로 되돌리고 backend 를 다시 띄운다(가장 간단), 또는 (2) 아이디를 꼭 바꿔야 하면 ' +
'복구 관리자로 엔진의 관리자 계정 아이디를 새 값으로 바꾼 뒤 backend 를 다시 띄우고 이 스크립트를 다시 실행한다 (mail/README.md "문제 해결").',
},
);
}
/**
* 영구 관리자 계정을 env 에 맞춘다 (비밀번호 = MAIL_ADMIN_PASSWORD).
* freshBootstrap(갓 bootstrap 해 아무도 쓰지 않은 엔진)이면 bootstrap 이 만든 admin@ 의 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾼다.
* 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면 이름을 바꾼 것이므로 중단한다.
*/
async function claimAdminAccount(client, cfg, { via, freshBootstrap }) {
const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']);
const byAddress = (address) => accounts.find((account) => account.emailAddress?.toLowerCase() === address);
const engineAdmins = accounts.filter(isAdminAccount).map((account) => account.emailAddress?.toLowerCase());
const admin = byAddress(cfg.adminAddress);
if (admin) {
if (!isAdminAccount(admin)) {
throw new SetupError(`${cfg.adminAddress}(MAIL_ADMIN_LOCAL_PART)는 메일 엔진의 관리자 계정이 아닙니다 — 아무것도 바꾸지 않고 중단합니다.`, {
hint: `엔진의 관리자: ${engineAdmins.join(', ') || '없음'}. MAIL_ADMIN_LOCAL_PART 를 그 아이디로 맞추세요.`,
});
}
await setObjects(client, 'Account', {
update: { [admin.id]: { [`credentials/${passwordKey(admin)}/secret`]: cfg.adminPassword } },
});
log(`[변경] 관리자 ${cfg.adminAddress} 비밀번호를 MAIL_ADMIN_PASSWORD 로 맞췄습니다 (${via} 사용).`);
return;
}
const bootstrapAdmin = byAddress(cfg.bootstrapAdminAddress);
if (freshBootstrap && bootstrapAdmin && isAdminAccount(bootstrapAdmin)) {
// Bootstrap/set 은 아이디를 받지 않는다 — 아무도 쓰기 전에 아이디를 바꿔 admin@ 이 남지 않게 한다
await setObjects(client, 'Account', {
update: {
[bootstrapAdmin.id]: { name: cfg.adminLocalPart, [`credentials/${passwordKey(bootstrapAdmin)}/secret`]: cfg.adminPassword },
},
});
log(`[변경] bootstrap 이 만든 관리자 ${cfg.bootstrapAdminAddress} → ${cfg.adminAddress} (MAIL_ADMIN_LOCAL_PART), 비밀번호 MAIL_ADMIN_PASSWORD (${via} 사용)`);
return;
}
throw adminNameMismatch(cfg, engineAdmins);
}
/**
* 관리자로 로그인하지 못했고 복구 수단도 없을 때 — bootstrap 뒤에 MAIL_ADMIN_LOCAL_PART 만 바꾼 경우인지 본다.
* bootstrap 기본 아이디(admin@)에 MAIL_ADMIN_PASSWORD 가 통하면 그렇다 (이 스크립트의 접속 주소는 신뢰 대역이라 자동 차단에 걸리지 않는다).
*/
async function detectRenamedAdmin(cfg) {
if (cfg.adminLocalPart === BOOTSTRAP_ADMIN_LOCAL_PART) return;
const status = await loginStatus(cfg, cfg.bootstrapAdminAddress, cfg.adminPassword);
if (status >= 200 && status < 300) throw adminNameMismatch(cfg, [cfg.bootstrapAdminAddress]);
}
/**
* 관리자 로그인 상태를 확정한다.
* 정상 + env 비밀번호 일치 → 그대로 사용
* bootstrap 모드 → bootstrap(최초 1회) → 재시작
* bootstrap 완료·재시작 전 → 재시작만 (Bootstrap/set 재호출 금지)
* 판단: mail 컨테이너의 config.json (docker 를 쓸 수 없으면 보관 파일)
* 정상 + env 비밀번호 불일치 → 보관된 임시 비밀번호 또는 recovery 관리자로 env 값에 맞춘다
* 정상 + env 아이디의 관리자가 없음 → 갓 bootstrap 한 엔진이면 admin@ 의 아이디를 바꾸고, 아니면 중단 (claimAdminAccount)
*/
async function ensureAdmin(cfg) {
/** 이번 실행에서 bootstrap 뒤 재시작을 했다 — 엔진의 관리자를 아직 아무도 쓰지 않았다 */
let restarted = false;
for (let step = 0; step < MAX_ADMIN_STEPS; step += 1) {
const admin = await openClient(cfg, cfg.adminAddress, cfg.adminPassword);
if (admin?.accountId) {
if (!(await hasManagementAccess(admin))) {
throw new SetupError(`${cfg.adminAddress}(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다 — 아무것도 바꾸지 않고 중단합니다.`, {
hint: 'MAIL_ADMIN_LOCAL_PART 를 엔진의 관리자 아이디로 맞추세요 (mail/README.md "문제 해결").',
});
}
removeState(); // 비밀번호가 env 값과 같아졌으니 임시 비밀번호를 보관할 이유가 없다
log(`[확인] 관리자 ${cfg.adminAddress} 로그인 (MAIL_ADMIN_PASSWORD)`);
return admin;
}
const recovery = cfg.recovery ? await openClient(cfg, cfg.recovery.user, cfg.recovery.password) : null;
if (recovery?.accountId && (await isBootstrapMode(recovery))) {
const configWritten = mailConfigWritten(cfg); // true·false, 모르면 null
const saved = readState();
const alreadyBootstrapped = configWritten ?? Boolean(saved);
if (alreadyBootstrapped && !restarted) {
// 지난 실행이 bootstrap 직후 재시작 전에 멈췄다 — 재시작만 하면 된다
await restartMail(cfg, 'bootstrap 은 이미 끝났고 재시작만 남았습니다');
restarted = true;
continue;
}
if (configWritten === true) {
// 설정 파일이 있는데도 bootstrap 모드로 떴다 — Bootstrap/set 을 다시 부르면 일부 설정이 중복된다
throw new SetupError('재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음). Bootstrap/set 을 다시 부르지 않고 중단합니다.', {
hint: `기동 로그에서 설정 오류를 확인하세요: docker logs ${cfg.container} (mail/README.md "문제 해결").`,
});
}
if (saved) {
warn('bootstrap 모드인데 설정 파일이 없습니다. 보관 중이던 임시 비밀번호는 이전 설치의 것이므로 지웁니다.');
removeState({ strict: true });
}
ensureStateWritable();
await runBootstrap(cfg, recovery);
await restartMail(cfg, 'bootstrap 설정(config.json)을 적용합니다');
restarted = true;
continue;
}
// 정상 모드인데 MAIL_ADMIN_PASSWORD 로는 로그인되지 않는다 → env 값으로 맞춘다
const saved = readState();
const temporary = saved ? await openClient(cfg, saved.username, saved.secret) : null;
if (temporary?.accountId) {
// 보관한 임시 비밀번호가 아직 통한다 = bootstrap 이 만든 관리자를 아무도 쓰지 않았다
await claimAdminAccount(temporary, cfg, { via: 'bootstrap 이 발급한 임시 비밀번호', freshBootstrap: true });
removeState();
continue;
}
if (recovery?.accountId) {
await claimAdminAccount(recovery, cfg, { via: 'recovery 관리자', freshBootstrap: restarted });
removeState();
continue;
}
if (restarted) {
// 재시작 직후에는 디렉터리 초기화가 늦을 수 있다 — 잠시 뒤 다시 확인한다
await sleep(POLL_INTERVAL_MS);
continue;
}
await detectRenamedAdmin(cfg);
throw new SetupError(`관리자(${cfg.adminAddress})로 로그인할 수 없습니다.`, {
hint:
adminRecoveryHint(cfg) +
' (MAIL_ADMIN_LOCAL_PART 를 bootstrap 뒤에 바꿨다면 비밀번호가 아니라 아이디가 다른 것이다 — mail/README.md "문제 해결")',
});
}
throw new SetupError('관리자 계정 상태를 확정하지 못했습니다 (반복 한도 초과).', { hint: adminRecoveryHint(cfg) });
}
// ============================================================
// 구성 요소 (멱등 — 없으면 만들고, 다르면 고치고, 같으면 둔다)
// ============================================================
async function findDomain(client, cfg) {
const domains = await listAll(client, 'Domain', ['name']);
const domain = domains.find((item) => item.name?.toLowerCase() === cfg.domain);
if (!domain) {
throw new SetupError(`도메인 ${cfg.domain} 이 메일 엔진에 없습니다.`, {
hint: 'MAIL_DOMAIN 이 bootstrap 때의 값과 같은지 확인하세요. 다른 도메인은 관리 화면에서 추가합니다.',
});
}
return { id: domain.id, name: cfg.domain };
}
async function ensureAllowedIps(client, cfg) {
const entries = await listAll(client, 'AllowedIp', ['address', 'reason']);
const present = new Set(entries.map((entry) => entry.address));
const create = {};
cfg.trustedNetworks.forEach((address, index) => {
log(`${present.has(address) ? '[유지]' : '[생성]'} 자동 차단 예외 ${address}`);
if (!present.has(address)) create[`ip${index}`] = { address, reason: ALLOWED_IP_REASON };
});
// 이 스크립트가 만든 것 중 MAIL_TRUSTED_NETWORKS 에서 빠진 대역은 지운다 (사람이 만든 항목은 건드리지 않는다)
const stale = entries.filter((entry) => entry.reason?.startsWith(MANAGED_MARK) && !cfg.trustedNetworks.includes(entry.address));
for (const entry of stale) log(`[삭제] 자동 차단 예외 ${entry.address} (MAIL_TRUSTED_NETWORKS 에서 빠짐)`);
if (Object.keys(create).length === 0 && stale.length === 0) return;
await setObjects(client, 'AllowedIp', { create, destroy: stale.map((entry) => entry.id) });
// 차단·예외 목록은 캐시된다 — 바로 반영되게 다시 읽힌다
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } });
}
function sameTrueSet(actual, expectedKeys) {
const keys = Object.entries(actual ?? {})
.filter(([, value]) => value === true)
.map(([key]) => key)
.sort();
return JSON.stringify(keys) === JSON.stringify([...expectedKeys].sort());
}
function toTrueSet(keys) {
return Object.fromEntries(keys.map((key) => [key, true]));
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}
async function ensureGatewayRole(client) {
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
const role = roles.find((item) => item.description === GATEWAY_ROLE_DESCRIPTION);
if (!role) {
const result = await setObjects(client, 'Role', {
create: { role: { description: GATEWAY_ROLE_DESCRIPTION, enabledPermissions: GATEWAY_PERMISSIONS } },
});
log('[생성] 역할 — 대리 접근 전용 (authenticate, impersonate)');
return result.created.role.id;
}
if (!sameTrueSet(role.enabledPermissions, Object.keys(GATEWAY_PERMISSIONS))) {
await setObjects(client, 'Role', { update: { [role.id]: { enabledPermissions: GATEWAY_PERMISSIONS } } });
log('[변경] 역할 — 대리 접근 전용 권한을 authenticate, impersonate 로 되돌림');
} else {
log('[유지] 역할 — 대리 접근 전용 (authenticate, impersonate)');
}
return role.id;
}
function passwordKey(account) {
const entry = Object.entries(account.credentials ?? {}).find(([, credential]) => credential['@type'] === 'Password');
if (!entry) {
throw new SetupError(`${account.emailAddress ?? account.name} 에 비밀번호 자격 증명이 없습니다.`, {
hint: '관리 화면에서 비밀번호를 추가한 뒤 다시 실행하세요.',
});
}
return entry[0];
}
/**
* 로그인 확인 — 2xx 면 맞고 401 이면 틀리다. 그 밖(429 시도 과다·5xx 등)은 판단할 수 없으므로 중단한다
* (틀린 것으로 보고 비밀번호를 덮어쓰거나, 맞는 것으로 보고 넘어가면 안 된다).
*/
async function passwordMatches(cfg, address, password) {
const status = await loginStatus(cfg, address, password);
if (status >= 200 && status < 300) return true;
if (status === 401) return false;
throw new SetupError(`${address} 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP ${status}). 아무것도 바꾸지 않았습니다.`, {
hint:
status === 429
? '시도가 너무 많아 잠시 막혔습니다. 몇 분 뒤 다시 실행하세요.'
: `메일 엔진 상태와 로그를 확인한 뒤 다시 실행하세요: docker logs ${cfg.container}`,
});
}
/**
* 계정을 env 값에 맞춘다. 저장된 비밀번호는 읽을 수 없으므로 실제 로그인으로 확인하고, 다르면 env 값으로 바꾼다.
* spec: { name, description, roles, password, allowedIps?, label }
*/
async function ensureAccount(client, cfg, domain, spec) {
const address = `${spec.name}@${domain.name}`;
const accounts = await listAll(client, 'Account', ['name', 'domainId', 'description', 'roles', 'credentials']);
const existing = accounts.find((account) => account.name === spec.name && account.domainId === domain.id);
if (!existing) {
const credential = { '@type': 'Password', secret: spec.password };
if (spec.allowedIps) credential.allowedIps = toTrueSet(spec.allowedIps);
await setObjects(client, 'Account', {
create: {
account: {
'@type': 'User',
name: spec.name,
domainId: domain.id,
description: spec.description,
roles: spec.roles,
credentials: { 0: credential },
},
},
});
log(`[생성] ${spec.label} ${address} (${spec.description})`);
return address;
}
const key = passwordKey(existing);
const patch = {};
const changes = [];
if (existing.description !== spec.description) {
patch.description = spec.description;
changes.push('표시 이름');
}
if (stableJson(existing.roles) !== stableJson(spec.roles)) {
patch.roles = spec.roles;
changes.push('역할');
}
if (spec.allowedIps && !sameTrueSet(existing.credentials[key].allowedIps, spec.allowedIps)) {
patch[`credentials/${key}/allowedIps`] = toTrueSet(spec.allowedIps);
changes.push('허용 IP');
}
if (!(await passwordMatches(cfg, address, spec.password))) {
patch[`credentials/${key}/secret`] = spec.password;
changes.push('비밀번호');
}
if (changes.length === 0) {
log(`[유지] ${spec.label} ${address} (${spec.description})`);
return address;
}
await setObjects(client, 'Account', { update: { [existing.id]: patch } });
log(`[변경] ${spec.label} ${address} — ${changes.join(', ')}`);
if (changes.includes('비밀번호') && (await loginStatus(cfg, address, spec.password)) === 401) {
warn(`${address} 로그인이 여전히 거부됩니다. 이 스크립트의 접속 주소가 허용 IP 밖이면 정상일 수 있습니다.`);
}
return address;
}
// ============================================================
// 사전 확인 — 신뢰 대역을 바꾸기 전에 (하나라도 어긋나면 아무것도 바꾸지 않고 중단한다)
// ============================================================
/** 실제 앱 네트워크의 서브넷이 APP_NETWORK_SUBNET 과 같은지 — 다르면 옛 설정으로 만든 네트워크가 남아 있다 */
function checkNetworkSubnet(cfg, issues) {
const subnets = networkSubnets(cfg.networkName);
if (subnets === null) {
issues.push(
`앱 네트워크 ${cfg.networkName} 가 없습니다 — compose 로 먼저 띄우고(PROJECT_NAME 이 compose 와 같은 env 파일인지도 확인) 다시 실행하세요.`,
);
return false;
}
if (subnets.includes(cfg.appNetworkSubnet)) {
log(`[확인] 앱 네트워크 ${cfg.networkName} = ${cfg.appNetworkSubnet} (APP_NETWORK_SUBNET)`);
return true;
}
issues.push(
`앱 네트워크 ${cfg.networkName} 의 서브넷(${subnets.join(', ') || '없음'})이 APP_NETWORK_SUBNET(${cfg.appNetworkSubnet}) 과 다릅니다. ` +
'서브넷을 고정하기 전(또는 바꾸기 전)에 만든 네트워크입니다 — docker compose … down 후 up -d 로 네트워크를 다시 만들고 이 스크립트를 다시 실행하세요.',
);
return false;
}
/**
* 앱 네트워크에 붙은 컨테이너 — 이 네트워크 전체가 신뢰 대역이므로 정해진 서비스만 붙어야 한다.
* 인터넷을 향한 frontend(nginx)는 edge 네트워크로만 백엔드에 닿는다 (docker-compose.yml).
* 운영은 그 밖의 컨테이너가 있으면 중단, 개발은 경고만 한다.
*/
function checkAppNetworkMembers(cfg, issues) {
const names = networkContainerNames(cfg.networkName);
if (names === null) return; // 네트워크가 없으면 서브넷 확인이 이미 보고했다
const expected = new Set(APP_NETWORK_SERVICES.map((service) => `${cfg.projectName}-${service}`));
const unexpected = names.filter((name) => !expected.has(name));
if (unexpected.length === 0) {
log(`[확인] 앱 네트워크 구성원 — ${names.join(', ') || '없음'}`);
return;
}
const message =
`앱 네트워크 ${cfg.networkName} 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다: ${unexpected.join(', ')} — ` +
'이 네트워크의 주소는 자동 차단에서 빠지고 게이트웨이로 로그인할 수 있다. ' +
`docker network disconnect ${cfg.networkName} <컨테이너> 로 떼거나 compose 의 networks 를 고친 뒤 다시 실행하세요.`;
if (cfg.isProduction) issues.push(message);
else warn(message);
}
/**
* 백엔드 컨테이너가 메일 엔진에 붙는 주소가 신뢰 대역 안인지 — 밖이면 게이트웨이 로그인이 막힌다.
* 신뢰 대역이 앱 네트워크 전체면(MAIL_TRUSTED_NETWORKS 비움) 서브넷이 맞는 한 항상 안이다 — 백엔드가 아직 없어도 된다.
* 대역을 좁혔다면 실제 주소로 확인해야 하므로 백엔드가 떠 있어야 한다.
*/
function checkBackendNetwork(cfg, issues, subnetMatches) {
const mail = containerNetworks(cfg.container);
if (!mail) {
issues.push(`${cfg.container} 컨테이너를 찾을 수 없습니다 (PROJECT_NAME 이 compose 와 같은 env 파일인지 확인).`);
return;
}
if (!(cfg.networkName in mail)) {
issues.push(`${cfg.container} 가 앱 네트워크 ${cfg.networkName} 에 없습니다 (compose 의 mail.networks 확인).`);
return;
}
const wholeSubnet = cfg.trustedNetworks.length === 1 && cfg.trustedNetworks[0] === cfg.appNetworkSubnet;
const backend = containerNetworks(cfg.backendContainer);
const ip = backend?.[cfg.networkName];
if (!ip) {
if (wholeSubnet && subnetMatches) {
log(`[확인] 신뢰 대역 = 앱 네트워크 전체(${cfg.appNetworkSubnet}) — 백엔드가 이 네트워크에 붙으면 주소는 항상 그 안이다 (${cfg.backendContainer} 아직 없음)`);
return;
}
issues.push(
`${cfg.backendContainer} 가 없거나 앱 네트워크 ${cfg.networkName} 에 붙어 있지 않아 백엔드 주소가 신뢰 대역(${cfg.trustedNetworks.join(', ')}) 안인지 확인할 수 없습니다. ` +
'백엔드를 먼저 띄우거나, MAIL_TRUSTED_NETWORKS 를 비워(= APP_NETWORK_SUBNET) 다시 실행하세요.',
);
return;
}
if (cfg.trustedNetworks.some((cidr) => cidrContains(cidr, ip))) {
log(`[확인] 백엔드 주소 ${ip} (${cfg.networkName}) 가 신뢰 대역 안에 있습니다.`);
return;
}
issues.push(
`백엔드 주소 ${ip} (${cfg.networkName}) 가 신뢰 대역(${cfg.trustedNetworks.join(', ')}) 밖입니다 — 게이트웨이 로그인이 거부됩니다. ` +
'MAIL_TRUSTED_NETWORKS 를 비우거나(= APP_NETWORK_SUBNET) 백엔드가 있는 대역으로 고치고 다시 실행하세요.',
);
}
/**
* 메일 엔진 설정을 바꾸기 전의 확인 — 어긋나면 아무것도 바꾸지 않고 중단한다.
* (신뢰 대역을 바꾼 뒤에야 알면 전 직원의 메일이 멈추고, 백엔드 주소가 자동 차단 예외에서 빠져 차단될 수 있다)
*/
function preflight(cfg) {
if (!cfg.docker) {
// 운영은 main 이 이미 막았다 — 여기는 개발뿐이다
warn('docker 를 쓸 수 없어 앱 네트워크 확인(서브넷·구성원·백엔드 주소)을 건너뜁니다 (개발 환경만 허용).');
return;
}
const issues = [];
const subnetMatches = checkNetworkSubnet(cfg, issues);
checkAppNetworkMembers(cfg, issues);
checkBackendNetwork(cfg, issues, subnetMatches);
if (issues.length > 0) {
throw new SetupError('사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.', {
detail: issues.map((message) => ` - ${message}`).join('\n'),
});
}
}
// ============================================================
// 잠금 · 엔진 한도 · 반영 확인
// ============================================================
/**
* 직원(User) 역할에서 Principal 조회 권한을 뺀다.
* 기본값으로는 직원 계정이 JMAP Principal/get 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다.
* 웹메일은 백엔드를 거치고 백엔드는 Principal 메서드를 쓰지 않는다. 대리 접근 세션도 직원 역할의 권한을 쓴다.
* 역할 권한은 캐시되므로 ReloadSettings 가 아니라 InvalidateCaches 로 반영한다 (Stalwart 0.16 에서 확인).
*/
async function ensureUserRoleWithoutPrincipals(client) {
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
const role = roles.find((item) => item.description === USER_ROLE_DESCRIPTION);
if (!role) {
problems.push(`기본 직원 역할("${USER_ROLE_DESCRIPTION}")을 찾지 못해 Principal 권한을 정리하지 못했습니다 (관리 화면 › 역할 확인).`);
return;
}
const granted = Object.entries(role.enabledPermissions ?? {})
.filter(([key, value]) => value === true && PRINCIPAL_PERMISSION_PATTERN.test(key))
.map(([key]) => key);
if (granted.length === 0) {
log('[유지] 직원 역할 — Principal 조회 권한 없음 (계정 목록 비공개)');
return;
}
const patch = Object.fromEntries(granted.map((key) => [`enabledPermissions/${key}`, null]));
await setObjects(client, 'Role', { update: { [role.id]: patch } });
await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`);
}
/** 공개 443(https 리스너)의 HTTP 경로 정책 — 바꿨으면 true (ReloadSettings 뒤에 반영된다) */
async function ensureHttpEndpointPolicy(client) {
const current = (await call(client, 'x:Http/get', { ids: ['singleton'], properties: ['allowedEndpoints'] })).list[0];
if (stableJson(current?.allowedEndpoints) === stableJson(HTTP_ENDPOINT_POLICY)) {
log('[유지] 공개 443 — JMAP·관리 API·웹 포털 차단 (MTA-STS 정책 파일만 허용)');
return false;
}
await setObjects(client, 'Http', { update: { singleton: { allowedEndpoints: HTTP_ENDPOINT_POLICY } } });
log('[변경] 공개 443 — JMAP·관리 API·웹 포털 차단 (MTA-STS 정책 파일만 허용)');
return true;
}
/**
* 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다.
* 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다).
* 한도 값(rate)·켜짐(enable)은 건드리지 않는다 — 운영에서 정할 것 (mail/README.md "엔진 한도").
*/
async function ensureRecipientThrottle(client) {
const label = `받는 쪽 한도 "${RECIPIENT_THROTTLE.description}"(같은 보낸 도메인 → 한 받는 사람)`;
const scope = '인증 없는 세션에만 (로그인한 직원의 발송 제외)';
const throttles = (await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match', 'enable'])).filter(
(item) => item.description === RECIPIENT_THROTTLE.description && sameTrueSet(item.key, RECIPIENT_THROTTLE.key),
);
if (throttles.length === 0) {
warn(
`${label}을 찾지 못해 만들지 않았습니다. 이름을 바꿔 쓰고 있다면 그 한도의 match 가 ${JSON.stringify(UNAUTHENTICATED_SESSIONS_ONLY)} 인지 ` +
'관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 — 아니면 로그인한 직원의 발송도 그 한도에 걸린다 (mail/README.md "엔진 한도").',
);
return;
}
const stale = throttles.filter((item) => stableJson(item.match) !== stableJson(UNAUTHENTICATED_SESSIONS_ONLY));
if (stale.length > 0) {
await setObjects(client, 'MtaInboundThrottle', {
update: Object.fromEntries(stale.map((item) => [item.id, { match: UNAUTHENTICATED_SESSIONS_ONLY }])),
});
}
for (const item of throttles) {
const disabled = item.enable === false ? ' · 꺼져 있음(enable: false)' : '';
if (stale.includes(item)) log(`[변경] ${label} — ${scope}${disabled} · 전 match ${JSON.stringify(item.match)}`);
else log(`[유지] ${label} — ${scope}${disabled}`);
}
}
/** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */
async function reloadSettings(client) {
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } });
}
/** 리로드·캐시 무효화가 퍼질 때까지 확인을 몇 번 다시 해 본다 */
const SETTLE_ATTEMPTS = 5;
/**
* 공개 443 정책이 실제로 걸렸는지 — https 는 403, 내부 http 는 열려 있어야 한다.
* ⚠️ 루프백(127.0.0.1)에서 온 요청은 정책을 건너뛰므로(Stalwart 0.16 에서 확인) 컨테이너의 네트워크 주소로 부른다.
* 호스트·다른 컨테이너·자기 네트워크 주소에서 온 요청은 AllowedIp 에 있어도 403 이다.
*/
async function checkHttpEndpointPolicy(cfg) {
const networks = cfg.docker ? containerNetworks(cfg.container) : null;
const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null;
if (!mailIp) {
const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 공개 443 차단을 실제로 확인하지 못했습니다.';
if (cfg.isProduction) problems.push(message);
else log(`[건너뜀] ${message}`);
return;
}
let httpsCode = null;
let httpCode = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
httpsCode = statusInsideMail(cfg, `https://${mailIp}:443/jmap/session`);
httpCode = statusInsideMail(cfg, 'http://127.0.0.1:8080/healthz/live');
if (httpsCode === 403 && httpCode === 200) break;
await sleep(POLL_INTERVAL_MS / 2);
}
if (httpsCode === 403 && httpCode === 200) {
log(`[확인] 공개 443 의 /jmap 은 403 (https://${mailIp}:443), 내부 8080 은 열려 있습니다.`);
return;
}
problems.push(
`공개 443 차단 확인 실패 — https /jmap/session=${httpsCode ?? '응답 없음'}(403 이어야 함), ` +
`http 8080 /healthz/live=${httpCode ?? '응답 없음'}(200 이어야 함).`,
);
}
/**
* 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서).
* 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면
* https 리스너 정책이 걸리지 않아 여기서 200 이 나온다 (mail/README.md 대안 3).
* 연결 자체가 안 되면(DNS 전파 전, 호스트에서 자기 공인 IP 로 되돌아오는 경로가 막힌 환경 등) 경고와 함께 바깥에서 확인할 명령을 보여 준다.
*/
async function checkPublicEndpoint(cfg) {
const url = `${cfg.publicUrl}/jmap/session`;
const manual = `바깥(다른 네트워크)에서 확인하세요: curl -s -o /dev/null -w '%{http_code}\\n' ${url} # 403 이어야 한다`;
const result = await httpsStatus(url);
if (result.error) {
warn(`공개 주소 ${url} 에 연결하지 못해 바깥 경로의 403 은 확인하지 못했습니다 (${result.error}). ${manual}`);
return;
}
if (result.status === 403) {
const certificate = result.authorized ? '인증서 유효' : '인증서는 아직 믿을 수 없음 — ACME 발급 전이면 정상, 계속되면 컨테이너 로그 확인';
log(`[확인] 공개 주소 ${url} → 403 (${certificate})`);
return;
}
problems.push(
`공개 주소 ${url} 가 ${result.status} 를 돌려줍니다 (403 이어야 함) — 앞단 프록시가 TLS 를 끝내고 8080 으로 넘기고 있지 않은지 확인하세요 (mail/README.md "리버스 프록시와 같은 호스트에 둘 때").`,
);
}
/**
* 남은 위험을 매번 보여 준다 — 게이트웨이는 관리자 계정도 대리 접근할 수 있다.
* Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다: 역할 권한은 접속하는 쪽만 정하고, 테넌트에 넣으면
* 게이트웨이가 impersonate 권한 자체를 잃는다(2026-10-27 확인 — mail/README.md "보안"). 그래서 막는 일은 백엔드가 한다
* (관리자·MAIL_BLOCKED_ACCOUNTS 는 웹메일 로그인·대리 접근 대상에서 뺀다).
* 1. <관리자>%<게이트웨이> 로 데이터 없는 관리 호출 — 성공하면 [주의]: 게이트웨이 비밀번호 = 관리자급 비밀
* 2. 관리자 역할 계정이 백엔드 차단 목록(MAIL_ADMIN_LOCAL_PART 관리자 + MAIL_BLOCKED_ACCOUNTS)에서 빠졌으면 [주의]
*/
async function checkGatewayAdminReach(admin, cfg) {
const login = `${cfg.adminAddress}%${cfg.gatewayAddress}`;
const session = await openClient(cfg, login, cfg.gatewayPassword);
let reachable = false;
if (!session?.accountId) {
log(`[확인] 게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근할 수 없습니다 (${login} 로그인 거부).`);
} else if (await hasManagementAccess(session)) {
reachable = true;
warn(
`게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근하면 관리 API 가 열립니다 (${login} → x:Account/get 성공). ` +
'게이트웨이 비밀번호는 관리자급 비밀이다 — 백엔드 env 에만 두고, 다른 곳에 재사용하지 말고, 정기 교체하고, ' +
'게이트웨이 허용 IP(신뢰 대역)를 넓히지 않는다 (mail/README.md "보안"). 백엔드는 관리자 계정의 웹메일 로그인·대리 접근을 막는다.',
);
} else {
log(`[확인] 게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근해도 관리 API 는 닫혀 있습니다.`);
}
const blocked = new Set([cfg.adminAddress, ...cfg.blockedAccounts]);
const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles']);
const unblocked = accounts
.filter(isAdminAccount)
.map((account) => account.emailAddress?.toLowerCase())
.filter((address) => address && !blocked.has(address));
if (unblocked.length > 0) {
warn(
`관리자 역할 계정 ${unblocked.join(', ')} 가 백엔드 차단 목록에 없습니다 — 웹메일 로그인·대리 접근을 1차로 막지 못한다. ` +
'MAIL_BLOCKED_ACCOUNTS 에 넣고 backend 를 다시 띄우거나, 쓰지 않는 관리자라면 지우세요.',
);
}
return reachable;
}
/** 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지 */
async function checkPrincipalHidden(cfg, address) {
const client = await openClient(cfg, address, cfg.devUserPassword);
const accountId = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
if (!client || !accountId) {
problems.push(`${address} 로 로그인하지 못해 계정 목록 비공개를 확인하지 못했습니다.`);
return;
}
let name = '';
let result = {};
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
const res = await request(cfg, '/jmap/', {
method: 'POST',
headers: { authorization: client.auth, 'content-type': 'application/json' },
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:principals'],
methodCalls: [['Principal/get', { accountId, ids: null, properties: ['email'] }, 'p']],
}),
});
[[name, result]] = (await res.json()).methodResponses;
if (name === 'error' && result.type === 'forbidden') {
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden).`);
return;
}
await sleep(POLL_INTERVAL_MS / 2);
}
problems.push(`직원(${address})이 아직 계정 목록을 볼 수 있습니다 (Principal/get → ${name === 'error' ? result.type : `${result.list?.length}개`}).`);
}
/** 운영 — 복구 관리자가 mail 컨테이너에 남아 있지 않은지 (값은 출력하지 않는다). main 이 docker 를 이미 확인했다 */
function checkRecoveryDisabled(cfg) {
const isSet = containerEnvIsSet(cfg.container, 'STALWART_RECOVERY_ADMIN');
if (isSet === null) {
problems.push(`${cfg.container} 를 찾지 못해 복구 관리자가 꺼졌는지 확인하지 못했습니다 (PROJECT_NAME 확인).`);
} else if (isSet) {
problems.push(
`${cfg.container} 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다 — 정상 모드에서도 관리자로 로그인됩니다. ` +
`셸의 ${RECOVERY_ENV_KEY} 를 지우고 docker compose --env-file .env.production up -d mail 로 다시 만드세요.`,
);
} else {
log('[확인] 복구 관리자 꺼짐 (mail 컨테이너에 STALWART_RECOVERY_ADMIN 없음)');
}
}
async function ensureDevRelay(client) {
const routes = await listAll(client, 'MtaRoute', ['name', 'address', 'port']);
const route = routes.find((item) => item.name === DEV_RELAY.name);
if (!route) {
await setObjects(client, 'MtaRoute', {
create: {
relay: {
'@type': 'Relay',
name: DEV_RELAY.name,
description: DEV_RELAY.description,
address: DEV_RELAY.address,
port: DEV_RELAY.port,
authSecret: { '@type': 'None' },
},
},
});
log(`[생성] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
} else if (route.address !== DEV_RELAY.address || route.port !== DEV_RELAY.port) {
await setObjects(client, 'MtaRoute', { update: { [route.id]: { address: DEV_RELAY.address, port: DEV_RELAY.port } } });
log(`[변경] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
} else {
log(`[유지] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
}
const before = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
if (stableJson(before?.route) !== stableJson(DEV_OUTBOUND_ROUTE)) {
await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route: DEV_OUTBOUND_ROUTE } } });
log(`[변경] 외부 발송 전략 — 사내 도메인은 로컬, 나머지는 ${DEV_RELAY.name}`);
} else {
log(`[유지] 외부 발송 전략 — 사내 도메인은 로컬, 나머지는 ${DEV_RELAY.name}`);
}
// 저장만으로는 반영되지 않는다 — main 이 reloadSettings 뒤에 verifyDevRelay 로 확인한다
}
/** 개발 — 리로드 뒤 외부 발송 전략이 Mailpit 인지. 아니면 테스트 계정을 만들기 전에 멈춘다 */
async function verifyDevRelay(client) {
const after = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
if (stableJson(after?.route) !== stableJson(DEV_OUTBOUND_ROUTE)) {
throw new SetupError('외부 발송 전략이 Mailpit 으로 바뀌지 않았습니다. 테스트 계정을 만들지 않고 중단합니다.');
}
log('[확인] 설정 리로드 완료 — 사내 도메인 밖 메일은 Mailpit 에만 쌓입니다.');
}
/** 운영에 개발용 Mailpit 경로가 섞여 들어오지 않았는지 (개발 볼륨을 옮겨 온 경우 등) */
async function checkNoDevRelay(client) {
const strategy = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
if (stableJson(strategy?.route).includes(`'${DEV_RELAY.name}'`)) {
problems.push('운영인데 외부 발송 전략이 개발용 Mailpit 경로를 가리킵니다. 관리 화면에서 발송 전략을 확인하세요.');
}
}
/** 개발 — 게이트웨이로 테스트 직원 메일함에 대리 접근이 되는지 (백엔드가 쓰는 방식 그대로) */
async function checkImpersonation(cfg, target) {
const login = `${target}%${cfg.gatewayAddress}`;
const client = await openClient(cfg, login, cfg.gatewayPassword);
const mailAccount = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
if (client && client.session.username?.toLowerCase() === target && mailAccount) {
log(`[확인] 대리 접근 ${login} → ${target} 메일 계정 세션`);
return;
}
problems.push(`대리 접근 확인 실패 — ${login}. 게이트웨이 역할(impersonate)과 허용 IP(MAIL_TRUSTED_NETWORKS)를 확인하세요.`);
}
async function printDnsZone(client, domain) {
const { list } = await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['dnsZoneFile'] });
log(`\n----- 권장 DNS 레코드 (${domain.name}) — 그대로 붙이지 말고 mail/README.md "전환 절차" 순서를 따를 것 -----`);
log(list[0]?.dnsZoneFile?.trim() || '(없음)');
log('-----');
}
// ============================================================
// 실행
// ============================================================
function printHelp() {
log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]');
log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md');
log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).');
log(` --recovery 운영의 최초 bootstrap·관리자 복구 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`);
}
/** 후속 조치가 필요하다 (종료 코드 3) — 안내를 출력하고 끝낸다 */
class FollowUpRequired extends Error {
constructor(message, steps) {
super(message);
this.steps = steps;
}
}
async function main() {
const argv = process.argv.slice(2);
if (argv.includes('--help') || argv.includes('-h')) {
printHelp();
return;
}
const nodeMajor = Number(process.versions.node.split('.')[0]);
if (nodeMajor < MIN_NODE_MAJOR) throw new SetupError(`Node ${MIN_NODE_MAJOR} 이상이 필요합니다 (현재 ${process.versions.node}).`);
const cfg = loadConfig(argv);
log(`메일 엔진 설정 — ${cfg.appEnv} · 도메인 ${cfg.domain} · 관리 API ${cfg.adminUrl}`);
for (const message of cfg.warnings) warn(message);
// 운영은 확인을 건너뛰지 않는다 — docker 가 없으면 아무것도 바꾸기 전에 멈춘다
if (cfg.isProduction && !cfg.docker) {
throw new SetupError('운영에서는 docker 를 쓸 수 있어야 합니다 — 아무것도 바꾸지 않고 중단합니다.', {
hint:
'네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 한다. 이 스크립트를 docker 를 쓸 수 있는 사용자로 ' +
'호스트에서 실행하세요 — docker 그룹이면 그대로, 아니면 sudo -E env "PATH=$PATH" node … (mail/README.md "운영 배포").',
});
}
await waitForHttp(cfg, STARTUP_TIMEOUT_MS);
preflight(cfg);
const admin = await ensureAdmin(cfg);
const domain = await findDomain(admin, cfg);
await ensureAllowedIps(admin, cfg);
const roleId = await ensureGatewayRole(admin);
await ensureAccount(admin, cfg, domain, {
label: '게이트웨이',
name: cfg.gatewayLocal,
description: GATEWAY_DESCRIPTION,
roles: { '@type': 'Custom', roleIds: { [roleId]: true } },
password: cfg.gatewayPassword,
allowedIps: cfg.trustedNetworks,
});
// 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API
await ensureUserRoleWithoutPrincipals(admin);
await ensureHttpEndpointPolicy(admin);
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다)
await ensureRecipientThrottle(admin);
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
if (!cfg.isProduction) await ensureDevRelay(admin);
await reloadSettings(admin);
await checkHttpEndpointPolicy(cfg);
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
const devUsers = [];
if (cfg.isProduction) {
await checkPublicEndpoint(cfg);
await checkNoDevRelay(admin);
} else {
await verifyDevRelay(admin);
for (const user of DEV_USERS) {
const address = await ensureAccount(admin, cfg, domain, {
label: '테스트 직원',
name: user.name,
description: user.description,
roles: { '@type': 'User' },
password: cfg.devUserPassword,
});
devUsers.push(`${address} (${user.description})`);
}
await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`);
await checkPrincipalHidden(cfg, `${DEV_USERS[0].name}@${domain.name}`);
}
if (cfg.isProduction || cfg.printDns) await printDnsZone(admin, domain);
// 복구 관리자는 값이 남아 있는 동안 정상 모드에서도 관리자로 로그인된다
const recoveryActive = cfg.recovery ? Boolean((await openClient(cfg, cfg.recovery.user, cfg.recovery.password))?.accountId) : false;
// 운영의 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패로 본다 (--recovery 실행은 끝에 끄라고 안내한다)
if (cfg.isProduction && !cfg.recoveryFlag) checkRecoveryDisabled(cfg);
log('\n[요약]');
const adminScreen = cfg.isProduction
? '공개 443 에서는 막혀 있다 — SSH 터널로 내부 8080 (mail/README.md "운영 배포")'
: `${cfg.publicUrl ?? cfg.adminUrl}/admin/`;
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`);
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
log(' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가');
if (gatewayReachesAdmin) {
log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")');
}
if (devUsers.length > 0) {
log(` 테스트 직원 ${devUsers.join(', ')} — 비밀번호 MAIL_DEV_USER_PASSWORD`);
log(` Mailpit 사내 도메인 밖으로 보낸 메일 — http://localhost:${cfg.mailpitUiPort ?? 'MAILPIT_UI_PORT'}`);
}
if (recoveryActive && !cfg.isProduction) {
log(` 참고 ${RECOVERY_ENV_KEY} 가 설정되어 있어 복구 관리자가 정상 모드에서도 로그인됩니다 (개발만 허용 — 127.0.0.1 에만 열려 있다)`);
}
if (problems.length > 0) {
throw new SetupError('설정은 끝났지만 확인에 실패한 항목이 있습니다.', {
detail: problems.map((message) => ` - ${message}`).join('\n'),
});
}
if (cfg.isProduction && cfg.recoveryFlag && recoveryActive) {
throw new FollowUpRequired('설정은 끝났습니다. 복구 관리자는 일회성이므로 지금 끕니다 (mail/README.md "운영 배포" 4·5).', [
`unset ${RECOVERY_ENV_KEY} # 셸에 남은 값 지우기`,
'docker compose --env-file .env.production up -d mail # 복구 관리자 없이 mail 컨테이너를 다시 만든다',
'node --env-file=.env.production mail/scripts/setup.mjs # --recovery 없이 다시 실행 → 꺼졌는지 확인',
'(MAIL_ADMIN_URL·sudo 여부는 방금 실행한 것과 같은 형태로)',
]);
}
log('\n[완료] 메일 엔진이 env 설정과 일치합니다.');
}
main().catch((error) => {
if (error instanceof RestartRequired) {
console.error(`\n[재시작 필요] ${error.message}`);
console.error(' 이 환경에서는 docker 를 쓸 수 없습니다. 호스트에서 아래를 실행한 뒤 같은 명령으로 다시 실행하세요.');
console.error(` docker restart ${error.container}`);
process.exit(EXIT_FOLLOW_UP);
}
if (error instanceof FollowUpRequired) {
console.error(`\n[후속 조치] ${error.message}`);
for (const step of error.steps) console.error(` ${step}`);
process.exit(EXIT_FOLLOW_UP);
}
console.error(`\n[실패] ${error.message}`);
if (error.detail) console.error(error.detail);
if (error.hint) console.error(` → ${error.hint}`);
if (!(error instanceof SetupError)) console.error(error.stack);
process.exit(EXIT_ERROR);
});