Files
arasystem/mail/scripts/setup.mjs
T
ttipo 59420df1b9 feat(docker): mail setup — 사내 목록 계정 · 메일을 받지 않는 시스템 계정의 RCPT 거절
사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다.
그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 —
백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다.

관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter —
위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고,
setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다.
일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:06:27 +09:00

2409 lines
130 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 정책 파일만 연다)
* 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 —
* 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다)
* 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다
* (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안")
* 7. 사내 목록 계정 목록(Principal) 읽기 전용 역할 + 비밀번호 없는 계정(<MAIL_DIRECTORY_LOCAL_PART>@도메인) — 백엔드가 게이트웨이
* 대리 접근으로 직원 이름 · 주소만 읽는다 (계약 docs/mail/api-contract.md 3절 "사내 목록").
* 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때(개발 · 운영 --recovery)만 만들고 맞춘다.
* 없으면 관리자로 되는 것(설명 · 저장 한도 · 비밀번호 빼기)만 맞추고 [주의] — 실패로 끝내지 않는다
* 8. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영)
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
* 10. [개발] 테스트 직원 계정
*
* 종료 코드: 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';
/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */
const ADMIN_ROLE_DESCRIPTION = 'System Administrator';
/** 직원 역할에서 뺄 권한 — 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)' };
/**
* 계정마다 임시 업로드 크기 합계(x:Jmap 싱글톤의 uploadQuota)를 첨부 합계 한도의 이만큼 배 이상으로 둔다 — 한 시간에 합계 한도를 꽉 채운 메일 20통
* (계약 docs/mail/api-contract.md 2절 "첨부 업로드 한도").
* 엔진은 계정마다 uploadTtl(기본 1시간) 동안 올린 것의 크기 합계(uploadQuota, 기본 50,000,000바이트)와 개수(maxUploadCount, 기본 1,000)를 세고,
* 넘으면 올린 것이 만료될 때까지 그 계정의 업로드를 모두 429 로 막는다 — 첨부 올리기뿐 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과
* 서명 그림이 든 발송도. 2026-10-29 개발 엔진(0.16.27): 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째가 429 "Quota exceeded" 였다
* (기본 크기 합계면 20MB 첨부 세 개). 바꾼 값은 ReloadSettings 뒤에 반영된다 — 저장만 하면 옛 한도로 계속 막는다 (같은 날 확인).
*/
const UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL = 20;
/**
* 첨부 한도 env 의 기본값 · 단위 · 숫자 표기 — 백엔드 backend/src/modules/mail/mail.settings.ts(DEFAULT_ATTACHMENT_*·MEGABYTE)와
* backend/src/shared/utils/env.ts(10진 숫자만, 앞자리 0 없이)와 같아야 한다. 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
*/
const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20;
const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25;
const MEGABYTE = 1024 * 1024;
const POSITIVE_INT_PATTERN = /^[1-9]\d*$/;
/**
* 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 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} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`;
/**
* 사내 목록 계정 아이디 기본값 — 루트 env MAIL_DIRECTORY_LOCAL_PART. 백엔드도 이 값 하나를 읽으므로 기본값이 같아야 한다
* (형식은 위 LOCAL_PART_PATTERN — MAIL_ADMIN_LOCAL_PART 와 같은 규칙).
*/
const DEFAULT_DIRECTORY_LOCAL_PART = 'mail-directory';
/**
* 사내 목록 계정 — 백엔드가 엔진의 계정 목록(JMAP Principal)에서 직원 이름 · 주소만 읽는 전용 계정
* (계약 docs/mail/api-contract.md 3절 "사내 목록"). 역할은 로그인 + 계정 목록 읽기뿐이고 비밀번호가 없다 —
* 백엔드가 게이트웨이 대리 접근(<사내 목록 주소>%<게이트웨이 주소>)으로만 쓴다. authenticate 가 없으면 대리 접근이 403 이다.
* ⚠️ 엔진 관리자 역할(System Administrator)에는 jmapPrincipal* 이 없고, 엔진은 역할 · 계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를
* 바꾸는 쪽이 가졌는지 본다 — 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다
* ("You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet"). 그래서 복구 관리자로만 만들고 맞춘다.
* 관리자로 되는 것: 계정의 설명 · 저장 한도 고치기, 비밀번호 빼기, 계정 지우기 (2026-10-29 Stalwart 0.16.27 에서 확인 — ensureDirectoryAccount).
*/
const DIRECTORY_ROLE_DESCRIPTION = `사내 목록 — 계정 목록 읽기 전용 ${MANAGED_MARK}`;
const DIRECTORY_PERMISSIONS = { authenticate: true, jmapPrincipalGet: true, jmapPrincipalQuery: true };
const DIRECTORY_DESCRIPTION = `사내 목록 (백엔드 계정 목록 읽기 전용) ${MANAGED_MARK}`;
/**
* 사내 목록 계정의 저장 한도(x:Account 의 quotas — maxDiskQuota, 바이트). 주소가 있으니 메일이 쌓이지 않게 작게 둔다.
* 역할에 emailReceive 가 없어 엔진은 이 주소로 온 메일을 배달하지 않고 보낸 사람에게 반송한다(security.unauthorized "emailReceive",
* 2026-10-29 확인) — 한도는 그 위의 두 번째 막이다. 0 은 받기는 하지만 뜻(제한 없음인지)을 확인하지 않아 쓰지 않는다.
*/
const DIRECTORY_QUOTAS = { maxDiskQuota: 1024 * 1024 };
/** 계정의 권한 설정 기본값 — 역할에서만 물려받는다 (계정에 직접 권한을 더하지 않았다) */
const INHERIT_PERMISSIONS = { '@type': 'Inherit' };
/** JMAP 계정 목록(Principal) — 메일 쪽 세션의 이 능력 계정으로 Principal/query · get 을 부른다 */
const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals';
/**
* 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 받는 쪽 RCPT 단계에서 없는 주소처럼 거절한다.
* 세 계정의 역할에는 emailReceive 가 없어(관리자의 System Administrator 역할도) 엔진은 RCPT 를 250 으로 받아 놓고, 배달할 때
* security.unauthorized "emailReceive" 로 거절해 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 메일이면 모르는 사람에게 반송이 나가
* 서버 평판이 떨어진다(backscatter). 없는 주소는 처음부터 RCPT 에서 550 이다 (2026-10-29 Stalwart 0.16.27 에서 확인).
* 방법: x:MtaStageRcpt 의 script 식이 받는 주소가 이 계정들일 때만 아래 시스템 Sieve 스크립트를 고르고, 스크립트는 reject 한다
* (reject 의 글이 그대로 SMTP 응답이 된다). 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다.
* 엔진은 식의 rcpt 를 소문자로 넘기지만 +태그(부주소)는 떼지 않는다 — 그래서 '<아이디>+…@<도메인>' 꼴도 함께 본다.
* 반영은 ReloadSettings 뒤다 (main 의 reloadSettings — 그 전에는 옛 규칙으로 받는다).
*/
/** 엔진이 없는 주소에 주는 응답과 같은 글 — 바깥에서 이 계정들이 있는지 알아볼 수 없게 */
const SYSTEM_RCPT_REPLY = '550 5.1.2 Mailbox does not exist.';
const SYSTEM_RCPT_SCRIPT = {
name: 'arasystem-system-recipients',
description: `메일을 받지 않는 시스템 계정 — RCPT 에서 거절 ${MANAGED_MARK}`,
contents: ['require ["reject"];', `reject "${SYSTEM_RCPT_REPLY}";`].join('\r\n'),
};
/** x:MtaStageRcpt 의 script 기본값 — RCPT 단계 스크립트 없음 */
const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' };
/** 식에 그대로 넣는 주소의 꼴 — 따옴표 · 공백 · 식 문법이 들어갈 수 없게 (아이디는 loadConfig 가 이미 LOCAL_PART_PATTERN 으로 봤다) */
const PLAIN_ADDRESS_PATTERN = /^[a-z0-9._-]+@[a-z0-9.-]+$/;
/** RCPT 거절 확인에 쓰는 EHLO 이름 — 예약 도메인 (엔진은 FQDN 이 아닌 EHLO 를 거절한다) */
const RCPT_CHECK_EHLO = 'setup-check.example.net';
/** RCPT 거절 확인에서 한 세션에 보내는 RCPT 수 상한 — 엔진은 한 세션의 RCPT 실패를 5번까지만 받는다 (x:MtaStageRcpt 의 maxFailures) */
const RCPT_CHECK_MAX_PROBES = 4;
/** 개발 전용 — 사내 도메인 밖 메일을 받는 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}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
}
// 사내 목록 계정 아이디 — 백엔드가 같은 값으로 그 계정을 대리 접근해 계정 목록을 읽는다 (ensureDirectoryAccount)
const directoryLocalRaw = readEnv('MAIL_DIRECTORY_LOCAL_PART') ?? DEFAULT_DIRECTORY_LOCAL_PART;
const directoryLocalPart = directoryLocalRaw.toLowerCase();
if (!LOCAL_PART_PATTERN.test(directoryLocalPart)) {
errors.push(
`MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${directoryLocalRaw}").`,
);
} else if (directoryLocalPart === adminLocalPart) {
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.');
} else if (directoryLocalPart === gatewayLocal) {
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.');
} else if (!isProduction && DEV_USERS.some((user) => user.name === directoryLocalPart)) {
errors.push(`MAIL_DIRECTORY_LOCAL_PART(${directoryLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
}
// 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다
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 와 같습니다. 서로 다른 값을 쓰세요.');
}
// 첨부 합계 한도 — 백엔드(mail.settings.ts)와 같은 값: 비우면 기본값, 합계는 파일 하나 한도보다 작아지지 않는다.
// 엔진의 임시 업로드 크기 합계를 그 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 둔다 (ensureUploadQuota)
const attachmentMb = (key, fallback) => {
const value = readEnv(key);
if (value === undefined) return fallback;
if (POSITIVE_INT_PATTERN.test(value) && Number.isSafeInteger(Number(value))) return Number(value);
errors.push(`${key} 는 양의 정수여야 합니다 — 앞자리 0·부호·소수점 없이 숫자만 씁니다 (백엔드 env 검증과 같은 규칙, 현재: "${value}").`);
return fallback;
};
const attachmentTotalMb = Math.max(
attachmentMb('MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB', DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB),
attachmentMb('MAIL_ATTACHMENT_MAX_SIZE_MB', DEFAULT_ATTACHMENT_MAX_SIZE_MB),
);
const uploadQuotaFloor = attachmentTotalMb * MEGABYTE * UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL;
if (!Number.isSafeInteger(uploadQuotaFloor)) {
errors.push(`첨부 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB · MAIL_ATTACHMENT_MAX_SIZE_MB)가 너무 큽니다 (${attachmentTotalMb}MB).`);
}
// 관리 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,
directoryLocalPart,
appNetworkSubnet,
networkName: `${projectName}_app-network`,
trustedNetworks,
attachmentTotalMb,
uploadQuotaFloor,
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 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다.
* 웹메일은 백엔드를 거치고, 대리 접근 세션도 직원 역할의 권한을 쓴다. 백엔드가 계정 목록을 읽는 것은 사내 목록 계정
* (따로 만든 역할 — ensureDirectoryAccount)으로만이고, 이 함수는 기본 직원 역할(설명 "User")만 건드린다.
* 역할 권한은 캐시되므로 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(', ')})`);
}
// ============================================================
// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만)
// ============================================================
/** 복구 관리자의 관리 API 클라이언트 — 값이 없거나, 로그인되지 않거나, 관리 권한이 없으면 null */
async function openRecoveryClient(cfg) {
if (!cfg.recovery) return null;
const client = await openClient(cfg, cfg.recovery.user, cfg.recovery.password);
if (!client?.accountId) return null;
return (await hasManagementAccess(client)) ? client : null;
}
/** 값이 true 인 키 (정렬) */
function trueKeys(value) {
return Object.entries(value ?? {})
.filter(([, flag]) => flag === true)
.map(([key]) => key)
.sort();
}
/** 사내 목록 역할이 setup 기준(DIRECTORY_PERMISSIONS 만 · 빼는 권한 없음 · 물려받는 역할 없음)과 다른 점 — 같으면 빈 배열 */
function directoryRoleDrift(role) {
const expected = Object.keys(DIRECTORY_PERMISSIONS);
const enabled = trueKeys(role.enabledPermissions);
const drift = [];
const missing = expected.filter((key) => !enabled.includes(key));
const extra = enabled.filter((key) => !expected.includes(key));
const disabled = trueKeys(role.disabledPermissions);
const inherited = trueKeys(role.roleIds);
if (missing.length > 0) drift.push(`모자란 권한 ${missing.join(', ')}`);
if (extra.length > 0) drift.push(`더 붙은 권한 ${extra.join(', ')}`);
if (disabled.length > 0) drift.push(`빼는 권한 ${disabled.join(', ')}`);
if (inherited.length > 0) drift.push(`물려받는 역할 ${inherited.join(', ')}`);
return drift;
}
/** 계정이 그 역할 하나만 가졌는가 (roleId 가 없으면 false) */
function holdsOnlyRole(account, roleId) {
return Boolean(roleId) && account.roles?.['@type'] === 'Custom' && sameTrueSet(account.roles.roleIds, [roleId]);
}
/** 계정 하나를 출력할 이름 — 주소, 없으면 아이디 */
function accountLabel(account) {
return account.emailAddress ?? account.name ?? account.id;
}
/**
* 사내 목록 계정을 맞춘다 — <MAIL_DIRECTORY_LOCAL_PART>@<도메인>, 역할 DIRECTORY_PERMISSIONS 하나 · 비밀번호 없음 · 저장 한도 DIRECTORY_QUOTAS
* (계약 docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md "사내 목록 계정").
* - 복구 관리자를 쓸 수 있으면(개발 · 운영 --recovery) 역할 · 계정을 만들거나 맞춘다.
* - 없으면(운영의 일반 실행) 관리자로 되는 것(계정의 설명 · 저장 한도 · 비밀번호 빼기 · 옛 계정 지우기)만 맞추고, 역할 · 계정이 없거나
* 권한이 다르면 [주의] 만 낸다 — 실패로 끝내지 않는다 (그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다 — 계약 3절).
* 관리자로는 역할의 권한을 고칠 수 없다(빼기도 — DIRECTORY_ROLE_DESCRIPTION 머리말).
* - 그 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 건드리지 않고 실패로 보고한다 (백엔드는 그 주소를 사내 목록 · 웹메일 로그인에서 뺀다).
* - MAIL_DIRECTORY_LOCAL_PART 를 바꿨으면 옛 계정(setup 표시 + 이 역할 하나)은 새 계정이 준비된 뒤에 지운다.
* 이 계정은 메일을 보내지도 받지도 못한다(역할에 emailSend · emailReceive 가 없다) — 그래서 개발의 Mailpit 경로 리로드 전에 만들어도 된다.
* 준비되면 백엔드가 쓰는 방식 그대로 읽어 본다 (checkDirectoryAccess).
* 돌려주는 값: { address, state } — state 는 ready(준비됨) · unreadable(맞췄지만 읽지 못함) · pending(복구 관리자가 있어야 맞춘다) · conflict(다른 계정의 주소)
*/
async function ensureDirectoryAccount(admin, cfg, domain) {
const address = `${cfg.directoryLocalPart}@${domain.name}`;
const label = `사내 목록 계정 ${address}`;
const roleLabel = `역할 — 사내 목록 계정 목록 읽기 (${Object.keys(DIRECTORY_PERMISSIONS).join(', ')})`;
const shape = `비밀번호 없음 · 저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트 · 역할은 위 하나`;
const recovery = await openRecoveryClient(cfg);
const via = recovery ? ' (복구 관리자 사용)' : '';
// 계정의 설명 · 저장 한도 · 비밀번호 빼기 · 지우기는 관리자로도 된다
const writer = recovery ?? admin;
/** 복구 관리자가 있어야 맞출 수 있는 것 */
const pending = [];
let changed = false;
// 1) 역할 — 설명으로 찾는다
const roles = await listAll(admin, 'Role', ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds']);
const role = roles.find((item) => item.description === DIRECTORY_ROLE_DESCRIPTION);
let roleId = role?.id ?? null;
if (!role) {
if (recovery) {
const result = await setObjects(recovery, 'Role', {
create: { role: { description: DIRECTORY_ROLE_DESCRIPTION, enabledPermissions: DIRECTORY_PERMISSIONS } },
});
roleId = result.created.role.id;
changed = true;
log(`[생성] ${roleLabel}${via}`);
} else {
pending.push('역할 없음');
}
} else {
const drift = directoryRoleDrift(role);
if (drift.length === 0) {
log(`[유지] ${roleLabel}`);
} else if (recovery) {
await setObjects(recovery, 'Role', {
update: { [role.id]: { enabledPermissions: DIRECTORY_PERMISSIONS, disabledPermissions: {}, roleIds: {} } },
});
changed = true;
log(`[변경] ${roleLabel} 로 되돌림 — 전: ${drift.join(' · ')}${via}`);
} else {
pending.push(`역할의 권한이 다름(${drift.join(' · ')})`);
}
}
// 2) 계정 — 아이디 + 도메인으로 찾는다 (비밀번호 값은 읽지도 출력하지도 않는다 — 있는지만 본다)
const accounts = await listAll(admin, 'Account', [
'name',
'domainId',
'emailAddress',
'description',
'roles',
'credentials',
'quotas',
'permissions',
'memberGroupIds',
]);
const existing = accounts.find((account) => account.name === cfg.directoryLocalPart && account.domainId === domain.id);
if (existing && !existing.description?.includes(MANAGED_MARK) && !holdsOnlyRole(existing, roleId)) {
problems.push(
`${address}(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 사내 목록 계정으로 쓰지 않았습니다 — 그 계정은 건드리지 않았습니다. ` +
'MAIL_DIRECTORY_LOCAL_PART 를 아무도 쓰지 않는 아이디로 바꾸고 backend 를 다시 띄운 뒤 이 스크립트를 다시 실행하세요 ' +
'(백엔드는 이 주소를 사내 목록 · 웹메일 로그인에서 뺀다 — mail/README.md "사내 목록 계정").',
);
return { address, state: 'conflict' };
}
const directoryRoles = roleId ? { '@type': 'Custom', roleIds: { [roleId]: true } } : null;
if (!existing) {
if (recovery && roleId) {
await setObjects(recovery, 'Account', {
create: {
account: {
'@type': 'User',
name: cfg.directoryLocalPart,
domainId: domain.id,
description: DIRECTORY_DESCRIPTION,
roles: directoryRoles,
quotas: DIRECTORY_QUOTAS,
},
},
});
changed = true;
log(`[생성] ${label} — ${shape}${via}`);
} else {
pending.push('계정 없음');
}
} else {
const patch = {};
const changes = [];
if (existing.description !== DIRECTORY_DESCRIPTION) {
patch.description = DIRECTORY_DESCRIPTION;
changes.push('표시 이름');
}
if (stableJson(existing.quotas ?? {}) !== stableJson(DIRECTORY_QUOTAS)) {
patch.quotas = DIRECTORY_QUOTAS;
changes.push(`저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트`);
}
if (Object.keys(existing.credentials ?? {}).length > 0) {
// 비밀번호가 있으면 그것으로 직접 로그인해 계정 목록(관리자 · 게이트웨이 주소 포함)을 읽을 수 있다
patch.credentials = {};
changes.push('비밀번호 뺌');
}
let rolePending = false;
if (roleId && !holdsOnlyRole(existing, roleId)) {
if (recovery) {
patch.roles = directoryRoles;
changes.push('역할');
} else {
pending.push('계정의 역할이 다름');
rolePending = true;
}
}
if (changes.length > 0) {
await setObjects(writer, 'Account', { update: { [existing.id]: patch } });
changed = true;
log(`[변경] ${label} — ${changes.join(', ')}${via}`);
} else if (roleId && !rolePending) {
log(`[유지] ${label} — ${shape}`);
}
if (
stableJson(existing.permissions ?? INHERIT_PERMISSIONS) !== stableJson(INHERIT_PERMISSIONS) ||
Object.keys(existing.memberGroupIds ?? {}).length > 0
) {
warn(
`${label} 에 역할 밖의 권한 설정(permissions)이나 그룹(memberGroupIds)이 있습니다 — setup 은 이것을 맞추지 않는다. ` +
'관리 화면 › 계정에서 지우세요 (mail/README.md "사내 목록 계정").',
);
}
}
const ready = Boolean(roleId) && pending.length === 0;
// 3) 이 역할을 가진 다른 계정 — setup 이 만든 옛 계정(아이디를 바꿨다)은 새 계정이 준비된 뒤 지우고, 그 밖은 알린다
if (roleId) {
const others = accounts.filter(
(account) => account !== existing && account.roles?.['@type'] === 'Custom' && account.roles.roleIds?.[roleId] === true,
);
const stale = others.filter((account) => account.description?.includes(MANAGED_MARK) && holdsOnlyRole(account, roleId));
const foreign = others.filter((account) => !stale.includes(account));
if (stale.length > 0 && ready) {
await setObjects(writer, 'Account', { destroy: stale.map((account) => account.id) });
changed = true;
for (const account of stale) log(`[삭제] 옛 사내 목록 계정 ${accountLabel(account)} — MAIL_DIRECTORY_LOCAL_PART 에서 빠짐${via}`);
} else if (stale.length > 0) {
log(`[유지] 옛 사내 목록 계정 ${stale.map(accountLabel).join(', ')} — 새 계정 ${address} 가 준비된 뒤에 지운다`);
}
if (foreign.length > 0) {
warn(
`사내 목록 역할을 다른 계정도 가지고 있습니다: ${foreign.map(accountLabel).join(', ')} — 그 계정은 계정 목록 전체(관리자 · 게이트웨이 주소 포함)를 읽을 수 있다. ` +
'의도한 것이 아니면 관리 화면 › 계정에서 그 계정의 역할을 바꾸세요 (mail/README.md "사내 목록 계정").',
);
}
}
// 역할 권한 · 계정은 캐시된다 — 바로 반영되게 비운다
if (changed) await setObjects(writer, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
if (ready) {
const readable = await checkDirectoryAccess(cfg, address, { strict: Boolean(recovery) });
return { address, state: readable === false ? 'unreadable' : 'ready' };
}
// 복구 관리자가 있어야 맞출 수 있는 것이 남았다
const absent = pending.includes('역할 없음') || pending.includes('계정 없음');
const how = cfg.isProduction
? '--recovery 로 한 번 실행하세요'
: `${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) 다시 실행하세요`;
const message = absent
? `사내 목록 계정이 없습니다 (${pending.join(' · ')}) — ${how} (mail/README.md "사내 목록 계정"). 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다.`
: `사내 목록 계정이 setup 기준과 다릅니다 (${pending.join(' · ')}) — 관리자로는 맞출 수 없다(엔진이 거절한다). ${how} (mail/README.md "사내 목록 계정").`;
if (cfg.recovery && !recovery) {
// 복구 관리자를 줬는데 쓰지 못했다 — 운영은 --recovery 실행이므로 실패로 본다
const reason =
`복구 관리자(${RECOVERY_ENV_KEY})로 관리 API 에 로그인하지 못해 사내 목록 계정을 맞추지 못했습니다 — ` +
`mail 컨테이너가 지금 값으로 만들어졌는지(up -d mail) 확인하세요. `;
if (cfg.isProduction) problems.push(reason + message);
else warn(reason + message);
} else {
warn(message);
}
// 계정은 있는데 기준과 다르면 — 지금 백엔드가 읽을 수 있는지만 알려 준다 (더 붙은 권한만 있으면 읽힌다)
if (!absent && existing) await checkDirectoryAccess(cfg, address, { strict: false });
return { address, state: 'pending' };
}
/**
* 사내 목록 계정으로 계정 목록을 읽을 수 있는지 — 백엔드가 쓰는 방식 그대로
* (게이트웨이 대리 접근 <사내 목록 주소>%<게이트웨이 주소> → 세션의 principals 계정 → Principal/query · get).
* 역할을 바꾼 직후에는 캐시가 퍼질 때까지 몇 번 다시 본다. 이 스크립트의 접속 주소가 게이트웨이 허용 IP 밖이면
* (MAIL_TRUSTED_NETWORKS 를 좁힌 운영) 게이트웨이 자신도 로그인되지 않으므로 건너뛴다.
* 읽지 못하면 strict(복구 관리자로 맞춘 실행)는 실패로, 아니면 [주의] 로 남긴다. 돌려주는 값: true · false · null(건너뜀)
*/
async function checkDirectoryAccess(cfg, address, { strict }) {
const login = `${address}%${cfg.gatewayAddress}`;
const authorization = basicAuth(login, cfg.gatewayPassword);
let outcome = '';
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
const sessionRes = await request(cfg, '/jmap/session', { headers: { authorization } });
if (!sessionRes.ok) {
await sessionRes.body?.cancel();
if (sessionRes.status === 401 && (await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) === 401) {
log(`[건너뜀] 이 스크립트의 접속 주소가 게이트웨이 허용 IP(신뢰 대역) 밖이라 사내 목록 계정의 대리 접근(${login})을 확인하지 못했습니다.`);
return null;
}
outcome = `세션 HTTP ${sessionRes.status}${sessionRes.status === 403 ? ' — 역할에 authenticate 가 없거나 빼는 권한에 들어 있다' : ''}`;
} else {
const session = await sessionRes.json();
const accountId = session.primaryAccounts?.[PRINCIPALS_CAPABILITY];
if (!accountId) {
outcome = `세션에 ${PRINCIPALS_CAPABILITY} 계정이 없음`;
} else {
// 백엔드처럼 찾고(query) 읽는다(get) — 둘 중 하나만 막혀도 사내 목록을 채우지 못한다
const res = await request(cfg, '/jmap/', {
method: 'POST',
headers: { authorization, 'content-type': 'application/json' },
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', PRINCIPALS_CAPABILITY],
methodCalls: [
['Principal/query', { accountId, limit: 1 }, 'q'],
['Principal/get', { accountId, '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, properties: ['email'] }, 'g'],
],
}),
});
if (res.ok) {
const responses = (await res.json()).methodResponses;
const failed = responses.find(([name]) => name === 'error');
if (!failed) {
log(`[확인] 사내 목록 — 대리 접근 ${login} 로 계정 목록을 읽을 수 있습니다 (Principal/query · get).`);
return true;
}
outcome = `Principal/${failed[2] === 'q' ? 'query' : 'get'} → ${failed[1].type}`;
} else {
await res.body?.cancel();
outcome = `JMAP 요청 HTTP ${res.status}`;
}
}
}
await sleep(POLL_INTERVAL_MS / 2);
}
const message =
`사내 목록 계정으로 계정 목록을 읽지 못했습니다 — ${login}: ${outcome}. ` +
'역할의 권한(authenticate · jmapPrincipalGet · jmapPrincipalQuery)과 게이트웨이 허용 IP 를 확인하세요 (mail/README.md "사내 목록 계정").';
if (strict) problems.push(message);
else warn(message);
return false;
}
/** 공개 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;
}
/**
* 메일을 받지 않는 시스템 계정의 주소 — 게이트웨이 · 사내 목록(역할을 setup 이 정한다 — emailReceive 없음)과,
* 관리자 역할(ADMIN_ROLE_DESCRIPTION)에 emailReceive 가 없을 때의 관리자 역할 계정 모두(MAIL_ADMIN_LOCAL_PART 관리자 + 더 만든 관리자).
* 관리자 역할을 찾지 못하거나 그 역할이 메일을 받게 바뀌었으면 관리자 주소는 넣지 않는다 — 받는 계정을 막지 않게.
* 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면(conflict — 실제 메일함일 수 있다) 그 주소도 넣지 않는다.
*/
async function nonReceivingSystemAddresses(client, cfg, directory) {
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
const adminRole = roles.find((role) => role.description === ADMIN_ROLE_DESCRIPTION);
const adminsReceive = !adminRole || adminRole.enabledPermissions?.emailReceive === true;
const admins = adminsReceive
? []
: (await listAll(client, 'Account', ['emailAddress', 'roles']))
.filter(isAdminAccount)
.map((account) => account.emailAddress?.toLowerCase())
.filter(Boolean)
.sort((a, b) => (a === cfg.adminAddress ? -1 : b === cfg.adminAddress ? 1 : a.localeCompare(b)));
if (adminsReceive) {
warn(
`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 관리자 주소는 RCPT 거절에 넣지 않았습니다 — ` +
'관리자 계정이 메일을 받지 못한다면 그 주소로 온 메일은 받아 놓고 반송한다 (mail/README.md "보안").',
);
}
return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])];
}
/** 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다 */
function systemRcptScriptExpression(addresses) {
const condition = addresses
.map((address) => {
const at = address.lastIndexOf('@');
return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`;
})
.join(' || ');
return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' };
}
/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */
function isOwnRcptScriptExpression(value) {
const branches = Object.values(value?.match ?? {});
return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false';
}
/**
* 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true.
* 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고,
* 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다.
*/
async function ensureSystemRecipientsRejected(client, addresses) {
const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address));
if (unsafe.length > 0) {
problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`);
return false;
}
const scriptLabel = `시스템 Sieve 스크립트 ${SYSTEM_RCPT_SCRIPT.name} — reject "${SYSTEM_RCPT_REPLY}"`;
const scripts = await listAll(client, 'SieveSystemScript', ['name', 'description', 'isActive', 'contents']);
const script = scripts.find((item) => item.name === SYSTEM_RCPT_SCRIPT.name);
const desired = { description: SYSTEM_RCPT_SCRIPT.description, isActive: true, contents: SYSTEM_RCPT_SCRIPT.contents };
if (!script) {
await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } });
log(`[생성] ${scriptLabel}`);
} else {
const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value));
if (Object.keys(patch).length > 0) {
await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } });
log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`);
} else {
log(`[유지] ${scriptLabel}`);
}
}
const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`;
const expression = systemRcptScriptExpression(addresses);
const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script;
if (stableJson(current) === stableJson(expression)) {
log(`[유지] ${label}`);
return true;
}
if (stableJson(current) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(current)) {
warn(
`RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 (${JSON.stringify(current)}) — ` +
`메일을 받지 않는 시스템 계정(${addresses.join(', ')})은 지금처럼 받아 놓고 보낸 사람에게 반송한다. ` +
'그 스크립트를 고르는 식에 이 계정들일 때 거절하는 갈래를 합치세요 (mail/README.md "보안").',
);
return false;
}
await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } });
log(`[변경] ${label}`);
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}`);
}
}
/** 세 자리마다 쉼표 — 숫자가 아니면 JSON 그대로 */
function formatCount(value) {
return typeof value === 'number' ? value.toLocaleString('en-US') : JSON.stringify(value);
}
/** 밀리초 → 시간·분으로 떨어지면 그 단위로 */
function formatDuration(ms) {
if (!Number.isSafeInteger(ms) || ms <= 0) return `${formatCount(ms)}ms`;
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}시간`;
if (ms % 60_000 === 0) return `${ms / 60_000}분`;
return `${formatCount(ms)}ms`;
}
/**
* 계정마다 임시 업로드 크기 합계(x:Jmap 의 uploadQuota)를 첨부 합계 한도의 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 — ReloadSettings 뒤에 반영된다.
* 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 개수(maxUploadCount) · 기간(uploadTtl) · 한 번 크기(maxUploadSize)는 건드리지 않는다 (mail/README.md "엔진 한도").
* 0 은 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고 알린다.
*/
async function ensureUploadQuota(client, cfg) {
const current = (await call(client, 'x:Jmap/get', { ids: ['singleton'], properties: ['uploadQuota', 'maxUploadCount', 'uploadTtl'] })).list[0];
const quota = current?.uploadQuota;
const label = '임시 업로드 한도 — 계정마다 크기 합계';
const floor = `첨부 합계 ${cfg.attachmentTotalMb}MB × ${UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL}`;
const untouched = `개수 ${formatCount(current?.maxUploadCount)} · 기간 ${formatDuration(current?.uploadTtl)}은 그대로`;
if (!Number.isSafeInteger(quota) || quota < 0) {
problems.push(`임시 업로드 한도(x:Jmap 의 uploadQuota = ${formatCount(quota)})를 읽지 못해 바꾸지 않았습니다 — ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상이어야 합니다.`);
return;
}
if (quota === 0) {
warn(
`임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. ` +
`제한 없음으로 둔 것이 아니라면 ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상으로 두세요 (mail/README.md "엔진 한도").`,
);
return;
}
if (quota >= cfg.uploadQuotaFloor) {
log(`[유지] ${label} ${formatCount(quota)}바이트 (${floor} = ${formatCount(cfg.uploadQuotaFloor)} 이상) · ${untouched}`);
return;
}
await setObjects(client, 'Jmap', { update: { singleton: { uploadQuota: cfg.uploadQuotaFloor } } });
log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`);
}
/** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */
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 이어야 함).`,
);
}
/**
* 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null.
* 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
*/
function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) {
const result = spawnSync(
'docker',
[
'exec',
'-i',
cfg.container,
'curl',
'-sS',
'-v',
'--max-time',
'20',
'--url',
`smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`,
'--mail-from',
sender,
'--mail-rcpt-allowfails',
...recipients.flatMap((recipient) => ['--mail-rcpt', recipient]),
'-T',
'-',
],
{ input: '', encoding: 'utf8' },
);
if (result.error) return null;
const replies = new Map();
let asked = null;
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
const sent = /^> RCPT TO:<([^>]*)>/.exec(line);
if (sent) {
asked = sent[1].toLowerCase();
continue;
}
const reply = /^< (\d{3}[ -].*)$/.exec(line);
if (reply && asked) {
replies.set(asked, reply[1]);
asked = null;
}
}
return replies;
}
/**
* 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로
* 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라,
* 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다.
* 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다).
*/
async function checkSystemRecipientsRejected(cfg, addresses) {
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 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.';
if (cfg.isProduction) problems.push(message);
else log(`[건너뜀] ${message}`);
return;
}
const last = addresses[addresses.length - 1];
const probes = [...addresses, last.replace('@', '+setup-check@')].slice(0, RCPT_CHECK_MAX_PROBES);
let replies = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
replies = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes);
if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break;
await sleep(POLL_INTERVAL_MS / 2);
}
const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550'));
if (notRejected.length === 0) {
log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`);
return;
}
problems.push(
`시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` +
'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").',
);
}
/**
* 운영 — 바깥에서 쓰는 주소(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);
// 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의])
const directory = await ensureDirectoryAccount(admin, cfg, domain);
// 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영)
const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory);
const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients);
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다),
// 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영)
await ensureRecipientThrottle(admin);
await ensureUploadQuota(admin, cfg);
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
if (!cfg.isProduction) await ensureDevRelay(admin);
await reloadSettings(admin);
await checkHttpEndpointPolicy(cfg);
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
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) 조회 불가' +
(systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''),
);
const directoryFallback = '읽지 못하는 동안 백엔드는 로그인한 직원 기록으로 대신한다';
if (directory.state === 'ready') {
log(` 사내 목록 ${directory.address} — 계정 목록(Principal) 읽기 전용 · 비밀번호 없음 · 대리 접근: ${directory.address}%${cfg.gatewayAddress}`);
} else if (directory.state === 'unreadable') {
log(` 사내 목록 ${directory.address} — 맞췄지만 계정 목록을 읽지 못함(위 [실패] · [주의]) — ${directoryFallback}`);
} else if (directory.state === 'conflict') {
log(` 사내 목록 ${directory.address} — 다른 계정의 주소라 쓰지 않음(위 [실패]) · MAIL_DIRECTORY_LOCAL_PART 를 바꾼다 — ${directoryFallback}`);
} else {
const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`;
log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`);
}
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);
});