Files
arasystem/mail/scripts/setup.mjs
T
ttipo 258ebf2180 feat(docker): mail setup — 파일럿 공존(한 사람 먼저) 켜기 · 끄기와 자체 확인
전면 전환 전에 한 사람만 같은 @arasystem.it.kr 주소로 새 서버를 쓰고 나머지 직원은 mailcow 에 둔다.
MX 는 mailcow 그대로이고 mailcow 의 Transport Map 이 시범 주소 하나만 새 서버로 넘긴다 —
새 서버는 자기에게 없는 사내 주소를 mailcow 로 중계해야 해서 루트 env 두 개(MAIL_PILOT_LEGACY_HOST ·
MAIL_PILOT_LEGACY_IPS)로 setup 이 맞추게 했다.

- 켜는 차례는 막는 것부터: 시범 표시(기존 서버 IP 의 자동 차단 예외) → RCPT 가드(인증 없는 세션의 없는 사내 주소 550) ·
  위조 막기(사내 도메인 MAIL FROM 은 mailcow IP 에서만) · 그 IP 의 SPF · 역방향 DNS · DMARC 검사와 받는 쪽 한도 예외 →
  리로드 → 켜기 전 가드 확인 → legacy 경로 · 발송 전략(인증한 제출 · 엔진이 만든 메일만 legacy) → 리로드 →
  allowRelaying → 켠 뒤 확인. allowRelaying 은 저장 즉시 먹어 가드보다 먼저 켜면 열린 중계가 된다.
- 가드 없는 relay 를 setup 이 만들지 않는다: 가드는 "2초 안에 550"으로 가린다(가드 거절은 곧바로 · 엔진 자체 거절은
  waitOnFail 5초 뒤). 아니면 켜지 않거나 바로 다시 끄고, 끄지 못하면 가드만이라도 다시 건 뒤 사실대로 알리고 멈춘다.
  끌 때는 allowRelaying 을 먼저 끄고 엔진에서 다시 읽어 꺼진 것을 본 뒤에만 나머지를 뺀다 — 주인을 모르는 relay 는
  끄지도 가드를 빼지도 않는다.
- 설명 칸이 없는 식의 주인은 시범 표시의 주소로 가린다 — 같은 꼴의 운영자 설정은 덮어쓰지 않고, 헷갈리면 멈춘다.
- 운영은 env 파일의 값으로만 켜고(포트 25 만 · 문서 대역 · 사설로 풀리는 이름 · 자기 자신 거부), 값이 빠졌다고
  저절로 끄지 않는다(끄기는 --end-pilot). 처음 켤 때 DKIM TXT 가 없거나 엔진의 키와 다르면 켜지 않는다.
  개발은 셸 값으로 한 번 시험한다.

리뷰 세 번(H1 · M1 · M2 · L1~L9 → N1~N8 → R1~R4)을 반영했다. 개발 엔진(0.16.27, 기존 서버 역 Mailpit)에서
켬 · 같은 값으로 다시([유지]) · 끔을 돌려 끈 뒤 엔진 설정이 처음과 같았다.
mailcow 쪽 절차와 운영 흐름은 아직 해 보지 않았다 — mail/README.md "파일럿 공존".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:37:25 +09:00

4030 lines
236 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·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다
* node --env-file=.env.production mail/scripts/setup.mjs --end-pilot
* # 운영의 시범 공존 끝내기(전면 전환) — MAIL_PILOT_LEGACY_HOST · _IPS 를 비우고 함께 준다
*
* 설정값은 전부 루트 .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. [개발] 테스트 직원 계정
* 시범 공존 (MAIL_PILOT_LEGACY_HOST 가 있을 때 — mail/README.md "파일럿 공존")
* 한 사람만 새 서버를 쓰고 나머지는 기존 서버(mailcow)에 남는 동안: 이 엔진에 없는 사내 주소는 기존 서버로 넘기고,
* 기존 서버 IP 에서 인증 없이 온 메일을 받고, 두 서버 사이에서 메일이 돌지 않게 한다. 켜는 순서가 중요하다 —
* A 막는 것(RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외 · 자동 차단 예외) → 리로드
* → B 경로(발송 경로 legacy · 발송 전략) → 리로드 → C 도메인 allowRelaying(저장 즉시 먹는다).
* 바꾸기 전에 도메인(catch-all · 하위 주소 · 바깥 디렉터리) · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 본다.
* 값을 비우면 거꾸로(allowRelaying 먼저) 지운다 — 운영은 --end-pilot 을 함께 줘야 한다(env 파일에서 값이 빠졌을 때 조용히 끝나지 않게)
*
* 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행)
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { resolveTxt } from 'node:dns/promises';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { request as httpsRequest } from 'node:https';
import { networkInterfaces } from 'node:os';
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 거절 확인의 보낸 사람 — 빈 봉투(MAIL FROM:<>, 반송과 같은 꼴). 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고
* (예전의 게이트웨이 주소는 사내 도메인이라 걸렸다 — 2026-10-29 개발 엔진), 확인이 어긋나 curl 이 DATA 까지 가더라도 빈 봉투에는 반송이 생기지 않는다.
* curl 8.14 는 빈 값('')을 받지 않아 '<>' 로 준다 — 그대로 MAIL FROM:<> 가 된다 (엔진은 250 으로 받는다, 같은 날 확인)
*/
const RCPT_CHECK_SENDER = '<>';
/** 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 으로 (= baseOutboundRoute 의 else 가 '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: '박서준' },
];
/*
* 시범 공존 (10단계 — mail/README.md "파일럿 공존")
* 한 사람(시범 사용자)만 같은 @도메인 주소로 이 엔진을 쓰고, 나머지 직원은 기존 서버(mailcow)에 남는다. MX 는 기존 서버 그대로이고,
* 기존 서버가 시범 사용자 주소만 이 엔진으로 넘긴다(mailcow Transport Map). 이 엔진이 할 일:
* - 시범 사용자가 동료(이 엔진에 없는 사내 주소)에게 보낸 메일을 기존 서버로 넘긴다 — 발송 경로 legacy + 발송 전략 + 도메인 allowRelaying
* - 기존 서버 IP 에서 인증 없이 넘어온 메일을 받는다 — 그 IP 만 SPF · 역방향 DNS · DMARC 검사 · 받는 쪽 한도 · 자동 차단에서 뺀다
* - 두 서버 사이에서 메일이 돌지 않게 한다 — RCPT 가드(1차) + 발송 전략의 source 조건(2차)
* 바깥 발송은 기존 서버를 거치지 않고 이 엔진이 직접 한다 (발송 전략의 else 는 시범 앞의 값 그대로 — 운영 'mx', 개발 'mailpit').
* 근거: 2026-10-29 개발 엔진(Stalwart 0.16.27) 조사 — 식 · 변수 · 순서를 모두 저장하고 효과까지 봤다.
*/
const PILOT_HOST_ENV_KEY = 'MAIL_PILOT_LEGACY_HOST';
const PILOT_IPS_ENV_KEY = 'MAIL_PILOT_LEGACY_IPS';
const PILOT_ENV_KEYS = [PILOT_HOST_ENV_KEY, PILOT_IPS_ENV_KEY];
/** 운영에서 시범 공존을 끝낼 때 함께 주는 플래그 — 값이 빠진 env 파일로 돌려 조용히 끝나지 않게 */
const END_PILOT_FLAG = '--end-pilot';
const DEFAULT_PILOT_PORT = 25;
/**
* 운영의 기존 서버 포트 — 25 만. 587 · 465 는 AUTH 가 있어야 중계되는데 setup 은 발송 경로에 AUTH 를 넣지 않는다
* (587 은 EHLO · STARTTLS · NOOP 확인은 통과해도 실제 중계는 인증이 없어 거절되고 반송된다 — 2026-10-30 리뷰 L4)
*/
const PILOT_PRODUCTION_PORT = 25;
const MAX_PORT = 65535;
const PORT_PATTERN = /^[1-9]\d{0,4}$/;
/** 호스트 이름 — 영문 · 숫자 · -, 점으로 나눈 칸마다 63자, 모두 253자 (소문자로 맞춰 검사) */
const HOSTNAME_PATTERN = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
/** 기존 서버로 가는 발송 경로(x:MtaRoute 의 Relay) 이름 — 발송 전략의 route 식이 이 이름을 고른다 */
const PILOT_ROUTE_NAME = 'legacy';
const PILOT_ROUTE_DESCRIPTION = `시범 공존 — 새 서버에 없는 사내 주소를 기존 서버(mailcow)로 ${MANAGED_MARK}`;
/** 기존 서버 IP 의 자동 차단 예외 — 막히면 시범 사용자가 받는 메일이 모두 기존 서버 대기열에 묶인다 */
const PILOT_ALLOWED_IP_REASON = `${MANAGED_MARK} 시범 공존 — 기존 서버(mailcow) 중계 IP`;
/**
* 운영의 기존 서버 주소로 받지 않는 대역 — 사설(RFC 1918) · 루프백 · 링크 로컬 · CGNAT · IETF 예약 · 문서 · 시험 · 멀티캐스트 · 예약.
* 기존 서버는 공인 주소로 붙는다 (IPS 항목 · HOST 의 IPv4 · HOST 이름이 풀린 주소 모두)
*/
const NON_PUBLIC_IPV4_RANGES = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.2.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
];
/** 기존 서버 IP 한 항목의 가장 넓은 접두 — 이보다 넓으면 그 대역의 다른 서버까지 기존 서버처럼 믿는다 (운영) */
const PILOT_IPS_WIDEST_PREFIX = 24;
/** 기존 서버에 닿는지 볼 때(메일 컨테이너 안의 curl NOOP) 기다리는 시간 */
const PILOT_REACH_TIMEOUT_SECONDS = 15;
/**
* RCPT 가드가 답했다고 보는 응답 시간의 문턱 — RCPT 를 보낸 뒤 550 이 이보다 빨리 오면 가드(Sieve 즉시 거절), 늦으면 엔진 자체의 거절(waitOnFail 대기)이다.
* 2026-10-30 개발 엔진(0.16.27): Sieve 거절 0.2~0.3ms, 엔진 자체 거절 4,999~5,003ms(waitOnFail 5s) — 2초면 양쪽 모두에서 넉넉히 멀다 (2026-10-30 리뷰 M1)
*/
const PILOT_GUARD_FAST_REPLY_MS = 2000;
/** 개발 — 인증한 세션 확인(465 SMTPS 제출)에 쓰는 엔진 포트 (compose 내부망, 백엔드 컨테이너에서 mail:465) */
const DEV_SUBMISSION_PORT = 465;
/**
* RCPT 가드 — 인증 없는 세션에서 이 엔진에 없는 사내 주소는 시스템 계정과 같은 스크립트(SYSTEM_RCPT_SCRIPT)로 550.
* 도메인의 allowRelaying 은 받는 사람 해석기에서 돌아 세션이 인증했는지 모른다 — 켜면 인증 없는 세션(바깥 · 기존 서버)도 모르는 사내 주소를
* 250 으로 받아 놓고 배달하지 못해 보낸 사람에게 반송한다. 이 가드가 그것을 막고, 기존 서버가 그런 주소를 되돌려 보내도 받지 않는다(루프 1차 막기).
* 식의 rcpt 는 소문자지만 +태그를 떼기 전이고 is_local_address 도 태그를 떼지 않는다 — 있는 계정의 +태그 꼴은 따로 본다(구분자는 엔진 기본 +).
* ⚠️ Sieve 거절은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번 · 자동 차단 셈)을 거치지 않는다 — 주소 추측 막기가 약해진다 (README).
*/
const PILOT_RCPT_GUARD =
"is_empty(authenticated_as) && is_local_domain(rcpt_domain) && !is_local_address(rcpt) && " +
"!(contains(rcpt, '+') && is_local_address(split_once(rcpt, '+')[0] + '@' + rcpt_domain))";
/** MAIL FROM 위조 막기의 조건 앞부분 — 뒤에 기존 서버 IP 조건이 붙는다. 맞으면 isSenderAllowed 가 false → 550 5.7.1 (빈 봉투 <> 는 걸리지 않는다) */
const PILOT_SENDER_GUARD_PREFIX = 'is_empty(authenticated_as) && is_local_domain(sender_domain) && !';
/** 기존 서버 IP 에서 온 세션만 끄는 검사 (x:SenderAuth) — SPF 는 기존 서버를 보고, DMARC 를 남기면 서명 없는 p=reject 발신이 점수를 안고 들어온다 */
const PILOT_SENDER_AUTH_FIELDS = ['spfEhloVerify', 'spfFromVerify', 'reverseIpVerify', 'dmarcVerify'];
/** 엔진 기본 받는 쪽 한도 가운데 보낸 IP 마다 연결 수 — 기존 서버 IP 만 뺀다 (식에 remote_ip 만 쓰면 연결 단계에서 센다) */
const IP_THROTTLE = { description: 'Sender IP throttle', key: ['remoteIp'] };
const IP_THROTTLE_DEFAULT_MATCH = { match: {}, else: 'true' };
/** *.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)는 원래 셸에서 일회성으로 주는 값이라 검사하지 않는다.
* exempt — 그 밖에 검사하지 않을 키 (개발의 시범 공존 값 — loadConfig)
*/
function findShellOverrides(fromFiles, exempt = []) {
return [...fromFiles]
.filter(
([key, value]) =>
key !== RECOVERY_ENV_KEY &&
!exempt.includes(key) &&
WATCHED_KEY.test(key) &&
process.env[key] !== undefined &&
process.env[key] !== value,
)
.map(([key]) => key);
}
/**
* 시범 공존 값 가운데 env 파일이 아니라 셸에서 온 것 — 비어 있지 않은 값이 env 파일 값과 다르다(파일에 없는 것 포함).
* 개발은 일부러 받는다(이번 실행에만 켜 보는 시험 — mail/README.md "파일럿 공존"). 운영은 받지 않는다 — 시범 상태는 엔진에 남는 설정이라
* env 파일(SSOT)만 보고도 알 수 있어야 하고, 그래야 값이 빠진 파일로 돌렸을 때 --end-pilot 없이는 멈춘다.
*/
function pilotKeysFromShell(fromFiles) {
return PILOT_ENV_KEYS.filter((key) => {
const value = process.env[key]?.trim();
return Boolean(value) && fromFiles.get(key)?.trim() !== value;
});
}
/**
* 기존 서버 주소 — host · host:port · [host] · [host]:port (대괄호는 떼어도 같다 — Relay 경로는 MX 를 찾지 않는다).
* 돌려주는 값: { host, port, isIpv4 } 또는 문제를 적은 글
*/
function parseLegacyHost(text) {
const match = /^\[([^\]]*)\](?::([^:]*))?$/.exec(text) ?? /^([^[\]:]*)(?::([^:]*))?$/.exec(text);
if (!match) return '형식은 host · host:port · [host]:port 입니다 (IPv6 는 쓰지 않는다 — 메일 컨테이너에 IPv6 가 없다)';
const [, host, portText] = match;
if (portText !== undefined && !(PORT_PATTERN.test(portText) && Number(portText) <= MAX_PORT)) {
return `포트는 1~${MAX_PORT} 의 10진수(앞자리 0 없이)여야 합니다`;
}
const ip = parseIpv4(host);
const isIpv4 = ip !== null;
if (isIpv4 && formatIpv4(ip) !== host) return 'IPv4 주소는 앞자리 0 없이 적습니다 (예: 46.250.248.43)';
if (!isIpv4 && (!HOSTNAME_PATTERN.test(host) || /^[\d.]+$/.test(host))) {
return '호스트는 이름(영문 · 숫자 · - · .)이나 IPv4 주소여야 합니다';
}
return { host, port: portText === undefined ? DEFAULT_PILOT_PORT : Number(portText), isIpv4 };
}
/**
* 기존 서버 IP 목록 — 콤마로 나눈 IPv4 · CIDR. 같은 값은 한 번만. 돌려주는 값: { ips, problems }
* 운영은 공인 주소만(사설 · 앱 네트워크 대역 · 특수 대역 거부), 한 항목이 /24 보다 넓으면 거부한다.
*/
function parseLegacyIps(text, { isProduction, appNetworkSubnet }) {
const ips = [];
const issues = [];
for (const item of text.split(',').map((value) => value.trim()).filter(Boolean)) {
const cidr = parseCidr(item);
const parts = item.split('/');
const [ipText, bits] = parts;
if (
cidr === null ||
parts.length > 2 ||
formatIpv4(cidr.base) !== ipText ||
(bits !== undefined && !/^(?:0|[1-9]\d?)$/.test(bits))
) {
issues.push(`${item} — IPv4 주소나 CIDR 이어야 합니다 (앞자리 0 없이, 예: 46.250.248.43 · 46.250.248.0/28)`);
continue;
}
const [start] = cidrRange(cidr);
if (start !== cidr.base) {
issues.push(`${item} — CIDR 은 네트워크 주소로 적습니다 (예: ${formatIpv4(start)}/${cidr.prefix})`);
continue;
}
if (isProduction) {
if (cidr.prefix < PILOT_IPS_WIDEST_PREFIX) {
issues.push(`${item} — /${PILOT_IPS_WIDEST_PREFIX} 보다 넓습니다. 기존 서버가 보내는 주소만 적습니다 (그 대역의 다른 서버까지 기존 서버처럼 믿게 된다)`);
continue;
}
if (parseCidr(appNetworkSubnet) !== null && cidrOverlaps(item, appNetworkSubnet)) {
issues.push(`${item} — 앱 네트워크(APP_NETWORK_SUBNET ${appNetworkSubnet}) 안입니다. 기존 서버의 공인 주소를 적습니다`);
continue;
}
if (NON_PUBLIC_IPV4_RANGES.some((range) => cidrOverlaps(item, range))) {
issues.push(`${item} — 사설 · 특수 대역입니다. 기존 서버가 이 서버에 붙을 때의 공인 주소를 적습니다`);
continue;
}
}
if (!ips.includes(item)) ips.push(item);
}
return { ips, problems: issues };
}
function loadConfig(argv) {
const fromFiles = readEnvFiles();
// 개발의 시범 공존 값은 셸에서 일회성으로 받아도 된다 (pilotKeysFromShell 머리말). APP_ENV 를 셸로 바꿨다면 아래 검사가 먼저 멈춘다
const devPilotExempt = readEnv('APP_ENV') === 'development' ? PILOT_ENV_KEYS : [];
const overridden = findShellOverrides(fromFiles, devPilotExempt);
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'}).`);
}
// 시범 공존 — 기존 서버(mailcow)와 함께 (mail/README.md "파일럿 공존"). MAIL_PILOT_LEGACY_HOST 를 비우면 꺼진다
const endPilot = argv.includes(END_PILOT_FLAG);
const pilotShellKeys = pilotKeysFromShell(fromFiles);
if (isProduction && pilotShellKeys.length > 0) {
errors.push(
`운영의 시범 공존 값(${pilotShellKeys.join(', ')})은 셸이 아니라 .env.production 에 둡니다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다. ` +
`파일에 적고 셸에서 지운 뒤 다시 실행하세요 (unset ${pilotShellKeys.join(' ')}).`,
);
}
const pilotHostRaw = readEnv(PILOT_HOST_ENV_KEY);
const pilotIpsRaw = readEnv(PILOT_IPS_ENV_KEY);
let pilot = null;
if (pilotHostRaw) {
if (endPilot) {
errors.push(`${END_PILOT_FLAG} 는 시범 공존을 끝낼 때 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비우고 함께 줍니다 (지금 ${PILOT_HOST_ENV_KEY} 가 있다).`);
}
const parsed = parseLegacyHost(pilotHostRaw.toLowerCase());
if (typeof parsed === 'string') {
errors.push(`${PILOT_HOST_ENV_KEY} — ${parsed} (현재: "${pilotHostRaw}").`);
} else {
if (parsed.host === hostname) {
errors.push(`${PILOT_HOST_ENV_KEY}(${parsed.host}) 가 이 엔진의 MAIL_HOSTNAME 과 같습니다 — 기존 서버(mailcow)의 주소를 적습니다 (자기 자신으로 보내면 메일이 돈다).`);
}
if (isProduction && parsed.host === DEV_RELAY.address) {
errors.push(`운영의 ${PILOT_HOST_ENV_KEY} 에 개발용 ${DEV_RELAY.address} 를 쓸 수 없습니다 — 기존 서버(mailcow)의 주소(예: mail.arasystem.it.kr)를 적습니다.`);
} else if (isProduction && !parsed.isIpv4 && !parsed.host.includes('.')) {
errors.push(`운영의 ${PILOT_HOST_ENV_KEY}(${parsed.host})는 점이 든 이름(예: mail.arasystem.it.kr)이나 공인 IPv4 여야 합니다 — 점 없는 이름은 도커 내부 이름으로 풀릴 수 있다.`);
} else if (isProduction && parsed.isIpv4 && NON_PUBLIC_IPV4_RANGES.some((range) => cidrContains(range, parsed.host))) {
errors.push(`운영의 ${PILOT_HOST_ENV_KEY}(${parsed.host})가 사설 · 특수 대역입니다 — 기존 서버의 공인 주소나 이름을 적습니다.`);
}
if (isProduction && parsed.port !== PILOT_PRODUCTION_PORT) {
errors.push(
`운영의 ${PILOT_HOST_ENV_KEY} 포트는 ${PILOT_PRODUCTION_PORT} 만 받습니다 (지금 ${parsed.port}) — 587 · 465 는 AUTH 가 있어야 중계되는데 ` +
'setup 은 발송 경로에 AUTH 를 넣지 않는다(587 은 확인은 통과해도 실제 중계가 인증이 없어 거절되고 반송된다).',
);
}
pilot = { ...parsed, ips: [], allowInvalidCerts: parsed.isIpv4 };
}
const legacyIps = parseLegacyIps(pilotIpsRaw ?? '', { isProduction, appNetworkSubnet });
for (const problem of legacyIps.problems) errors.push(`${PILOT_IPS_ENV_KEY} — ${problem}`);
if (isProduction && legacyIps.ips.length === 0 && legacyIps.problems.length === 0) {
errors.push(
`운영은 ${PILOT_HOST_ENV_KEY} 와 함께 ${PILOT_IPS_ENV_KEY}(기존 서버가 이 서버에 붙는 공인 IPv4, 예: 46.250.248.43)가 있어야 합니다 — ` +
'그 IP 에서 온 메일만 사내 도메인 보낸 사람 · 검사 예외로 받는다.',
);
}
if (pilot) pilot.ips = legacyIps.ips;
} else if (pilotIpsRaw) {
errors.push(`${PILOT_IPS_ENV_KEY} 만 있고 ${PILOT_HOST_ENV_KEY} 가 비어 있습니다 — 시범 공존을 켜려면 둘 다, 끄려면 둘 다 비웁니다.`);
}
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'),
// 시범 공존 — { host, port, isIpv4, ips, allowInvalidCerts } 또는 null(꺼짐). 개발에서 ips 가 비면 preparePilot 이 백엔드 주소로 채운다
pilot,
endPilot,
pilotShellKeys,
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;
}
/** 두 대역(또는 주소)이 한 주소라도 겹치는가 */
function cidrOverlaps(first, second) {
const a = parseCidr(first);
const b = parseCidr(second);
if (a === null || b === null) return false;
const [aStart, aEnd] = cidrRange(a);
const [bStart, bEnd] = cidrRange(b);
return aStart <= bEnd && bStart <= aEnd;
}
/** 앱 네트워크 서브넷으로 쓸 수 없는 이유 — 쓸 수 있으면 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;
}
/**
* 메일 컨테이너 안에서 이름을 IPv4 로 풀어 본다 (이미지의 getent — 엔진이 쓰는 것과 같은 리졸버 설정).
* IPv4 주소면 그대로. 돌려주는 값: 주소 배열(못 풀면 빈 배열), docker 로 물을 수 없으면 null
*/
function resolveIpv4InsideMail(cfg, name) {
if (parseIpv4(name) !== null) return [name];
const result = runDocker(['exec', cfg.container, 'getent', 'ahostsv4', name]);
if (result.error || result.status === null) return null;
// getent 는 못 찾으면 2 — 빈 결과로 본다
if (result.status !== 0) return [];
const found = (result.stdout ?? '')
.split(/\r?\n/)
.map((line) => line.trim().split(/\s+/)[0])
.filter((ip) => ip && parseIpv4(ip) !== null);
return [...new Set(found)];
}
// ============================================================
// 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 에서 빠진 대역은 지운다 (사람이 만든 항목은 건드리지 않는다).
// 시범 공존의 기존 서버 IP(PILOT_ALLOWED_IP_REASON)는 ensurePilotInboundTrust 가 맡는다
const stale = entries.filter(
(entry) =>
entry.reason?.startsWith(MANAGED_MARK) && entry.reason !== PILOT_ALLOWED_IP_REASON && !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 를 고른다.
* guard(시범 공존)면 갈래를 하나 더 둔다 — 인증 없는 세션의 모르는 사내 주소도 같은 스크립트로 (PILOT_RCPT_GUARD).
*/
function systemRcptScriptExpression(addresses, { guard = false } = {}) {
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(' || ');
const script = `'${SYSTEM_RCPT_SCRIPT.name}'`;
const match = { 0: { if: condition, then: script } };
if (guard) match[1] = { if: PILOT_RCPT_GUARD, then: script };
return { match, else: 'false' };
}
/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때). 시범 공존의 가드 갈래(둘째)가 있어도 된다 */
function isOwnRcptScriptExpression(value) {
const branches = Object.values(value?.match ?? {});
const script = `'${SYSTEM_RCPT_SCRIPT.name}'`;
if (value?.else !== 'false' || branches.length < 1 || branches.length > 2 || branches[0]?.then !== script) return false;
return branches.length === 1 || (branches[1]?.if === PILOT_RCPT_GUARD && branches[1]?.then === script);
}
/** RCPT 단계 식에 시범 공존의 가드 갈래가 있는가 */
function hasRcptGuard(value) {
return Object.values(value?.match ?? {}).some((branch) => branch?.if === PILOT_RCPT_GUARD);
}
/**
* setup 의 RCPT 단계 식(또는 기본값)에 가드 갈래를 더한 식 — 시스템 계정 갈래는 그대로 두고 둘째에 가드를 둔다(이미 있으면 그대로).
* 기본값(식 없음)이면 가드 갈래 하나만 — 다음 평소 실행이 시스템 계정 갈래를 채운다. setup 의 식도 기본값도 아니면 null
*/
function rcptScriptWithGuard(value) {
const script = `'${SYSTEM_RCPT_SCRIPT.name}'`;
const guardBranch = { if: PILOT_RCPT_GUARD, then: script };
if (stableJson(value) === stableJson(RCPT_SCRIPT_UNSET)) return { match: { 0: guardBranch }, else: 'false' };
if (!isOwnRcptScriptExpression(value)) return null;
if (hasRcptGuard(value)) return value;
return { match: { 0: branchesOf(value)[0], 1: guardBranch }, else: 'false' };
}
/** 시스템 계정 거절에 쓰는 시스템 Sieve 스크립트(SYSTEM_RCPT_SCRIPT)를 만들거나 맞춘다 — 켜기(isActive)까지. 리로드 뒤에 반영된다 */
async function ensureSystemRcptSieveScript(client) {
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}`);
return;
}
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}`);
}
}
/**
* 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true.
* 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고,
* 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다.
* pilot(시범 공존)이면 RCPT 가드 갈래를 함께 두고, 아니면 뺀다 — 리로드 뒤에 반영된다 (켤 때는 allowRelaying 보다 먼저, 끌 때는 나중에 — main).
* 빼기 직전에 allowRelaying 을 엔진에서 다시 읽어, 켜진 도메인이 있으면 가드를 남긴다 — 가드 없는 relay 를 setup 이 만들지 않는다 (2026-10-30 리뷰 H1).
* allowRelaying 이 꺼져 있으면 가드의 응답은 엔진 자체의 없는 주소 거절과 같은 글이라 남아 있어도 해가 없다.
*/
async function ensureSystemRecipientsRejected(client, addresses, pilot = null) {
const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address));
if (unsafe.length > 0) {
problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`);
return false;
}
await ensureSystemRcptSieveScript(client);
const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script;
let guard = Boolean(pilot);
let relaying = [];
if (!pilot && hasRcptGuard(current) && isOwnRcptScriptExpression(current)) {
relaying = await relayingDomainNames(client);
guard = relaying.length > 0;
}
const label =
`RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)` +
(pilot ? ' · 시범 공존: 인증 없는 세션에서 이 엔진에 없는 사내 주소도 550 (루프 1차 막기)' : '') +
(!pilot && guard ? ` · 시범 공존의 RCPT 가드는 남긴다 — allowRelaying 이 켜져 있다(${relaying.join(', ')} — 꺼진 것을 확인한 뒤에만 뺀다)` : '');
const expression = systemRcptScriptExpression(addresses, { guard });
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}${!guard && hasRcptGuard(current) ? ' — 시범 공존의 가드 갈래를 뺐다 (allowRelaying 이 꺼진 것을 다시 읽어 확인)' : ''}`);
return true;
}
/**
* 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다.
* 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다).
* 한도 값(rate)·켜짐(enable)은 건드리지 않는다 — 운영에서 정할 것 (mail/README.md "엔진 한도").
* pilot(시범 공존)이면 기존 서버 IP 도 뺀다 — 기존 서버가 넘기는 동료 전체의 사내 메일은 보낸 도메인이 모두 같아 한 묶음으로 세어진다
* (개발에서 같은 IP · 같은 도메인 26번째부터 452 → 뺀 뒤 30/30 통과, 2026-10-29).
*/
async function ensureRecipientThrottle(client, pilot = null) {
const label = `받는 쪽 한도 "${RECIPIENT_THROTTLE.description}"(같은 보낸 도메인 → 한 받는 사람)`;
const scope =
'인증 없는 세션에만 (로그인한 직원의 발송 제외)' + (pilot ? ` · 시범 공존: 기존 서버 IP(${pilot.ips.join(', ')}) 제외` : '');
const desired = pilot ? { match: {}, else: `is_empty(authenticated_as) && !${legacyIpCondition(pilot.ips)}` } : UNAUTHENTICATED_SESSIONS_ONLY;
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(desired)} 인지 ` +
'관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 — 아니면 로그인한 직원의 발송도 그 한도에 걸린다 (mail/README.md "엔진 한도").',
);
return;
}
// 시범이 꺼진 실행 — setup 의 기존 서버 IP 예외(표시로 가린다)는 allowRelaying 이 꺼진 것을 다시 읽어 확인한 뒤에만 뺀다 (2026-10-30 리뷰 H1)
let held = [];
if (!pilot && throttles.some((item) => recipientThrottleIps(item.match))) {
const owned = pilotMarkerSet(await listAll(client, 'AllowedIp', ['address', 'reason']));
const ours = throttles.filter((item) => isPilotRecipientThrottleMatch(item.match, owned));
if (ours.length > 0 && (await relayingDomainNames(client)).length > 0) held = ours;
}
const stale = throttles.filter((item) => !held.includes(item) && stableJson(item.match) !== stableJson(desired));
if (stale.length > 0) {
await setObjects(client, 'MtaInboundThrottle', {
update: Object.fromEntries(stale.map((item) => [item.id, { match: desired }])),
});
}
for (const item of throttles) {
const disabled = item.enable === false ? ' · 꺼져 있음(enable: false)' : '';
if (held.includes(item)) log(`[유지] ${label} — 시범 공존의 기존 서버 IP 예외를 남긴다(allowRelaying 이 켜져 있다)${disabled}`);
else 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 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄 }. curl 을 부르지 못하면 null.
* MAIL FROM 이 거절되거나 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
* sender 가 '<>' 면 빈 봉투(MAIL FROM:<>)다 (RCPT_CHECK_SENDER).
*/
function smtpRepliesInsideMail(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 = { mailFrom: null, rcpt: new Map() };
let asked = null;
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
if (/^> MAIL FROM:/.test(line)) {
asked = { mailFrom: true };
continue;
}
const sent = /^> RCPT TO:<([^>]*)>/.exec(line);
if (sent) {
asked = { rcpt: sent[1].toLowerCase() };
continue;
}
const reply = /^< (\d{3}[ -].*)$/.exec(line);
if (reply && asked) {
if (asked.mailFrom) replies.mailFrom = reply[1];
else replies.rcpt.set(asked.rcpt, reply[1]);
asked = null;
}
}
return replies;
}
/** 메일 컨테이너의 앱 네트워크 주소 — docker 를 쓸 수 없거나 모르면 null */
function mailContainerIp(cfg) {
const networks = cfg.docker ? containerNetworks(cfg.container) : null;
return networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean) ?? null) : null;
}
/**
* 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로
* 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 빈 봉투(RCPT_CHECK_SENDER) — 사내 도메인 밖이라
* 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 생기지 않는다.
* 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다).
*/
async function checkSystemRecipientsRejected(cfg, addresses) {
const mailIp = mailContainerIp(cfg);
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 = smtpRepliesInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, probes);
if (replies && probes.every((probe) => replies.rcpt.get(probe)?.startsWith('550'))) break;
await sleep(POLL_INTERVAL_MS / 2);
}
const notRejected = probes.filter((probe) => !replies?.rcpt.get(probe)?.startsWith('550'));
if (notRejected.length === 0) {
log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`);
return;
}
const mailFromRejected = replies?.mailFrom && !replies.mailFrom.startsWith('250') ? ` · MAIL FROM:<> → ${replies.mailFrom}` : '';
problems.push(
`시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.rcpt.get(probe) ?? '응답 없음'}`).join(' · ')}${mailFromRejected} (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 없음)');
}
}
/**
* 개발 — 사내 도메인 밖 메일을 Mailpit 으로 (발송 경로 mailpit + 발송 전략). 저장만 한다 — main 이 리로드한 뒤 verifyDevRelay 로 본다.
* keepPilotRoute(시범이 켜졌거나, 끄는 실행인데 allowRelaying 이 꺼지지 않아 시범 설정을 남길 때)이고 발송 전략이 이미 시범 꼴(else 'mailpit')이면 그대로 둔다
* — 시범 경로는 리로드 뒤에 ensurePilotRouting 이 맞춘다.
*/
async function ensureDevRelay(client, keepPilotRoute = false) {
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 (keepPilotRoute && stableJson(before?.route) === stableJson(pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else))) return;
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 인지(시범 공존이면 시범 꼴의 else 가 Mailpit). 아니면 테스트 계정을 만들기 전에 멈춘다.
* keepPilotRoute(시범 설정을 남기는 끄는 실행)면 시범 꼴 · 기본 꼴 어느 쪽이든 else 가 Mailpit 이면 된다
*/
async function verifyDevRelay(client, pilot = null, keepPilotRoute = false) {
const after = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
const accepted = pilot
? [pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else)]
: keepPilotRoute
? [DEV_OUTBOUND_ROUTE, pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else)]
: [DEV_OUTBOUND_ROUTE];
if (!accepted.some((route) => stableJson(after?.route) === stableJson(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('-----');
}
// ============================================================
// 시범 공존 — 기존 서버(mailcow)와 함께 (PILOT_HOST_ENV_KEY 머리말 · mail/README.md "파일럿 공존")
// ============================================================
/** 기존 서버 IP 하나의 조건 — 연결 단계 변수(remote_ip)라 받는 쪽 한도에 써도 세는 단계가 옮겨지지 않는다 */
const legacyIpTest = (ip) => `is_ip_in_cidr(remote_ip, '${ip}')`;
/** 기존 서버 IP 목록 → 식의 조건 — 하나면 그대로, 여럿이면 괄호로 묶은 || (앞에 ! 를 붙여도 뜻이 같게) */
function legacyIpCondition(ips) {
const tests = ips.map(legacyIpTest);
return tests.length === 1 ? tests[0] : `(${tests.join(' || ')})`;
}
/** legacyIpCondition 이 만든 글이면 그 IP 목록, 아니면 null — IP 가 바뀌어도 그 꼴을 알아본다 */
function parseLegacyIpCondition(text) {
if (typeof text !== 'string') return null;
const inner = text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text;
const ips = [];
for (const part of inner.split(' || ')) {
const match = /^is_ip_in_cidr\(remote_ip, '([0-9./]+)'\)$/.exec(part);
if (!match) return null;
ips.push(match[1]);
}
return legacyIpCondition(ips) === text ? ips : null;
}
/** 식의 갈래 목록 (엔진은 { match: { "0": {if, then}, … }, else } 꼴로 준다) */
function branchesOf(expression) {
return Object.values(expression?.match ?? {});
}
/** 엔진이 주는 식의 꼴({ match, else })인가 — 아니면 갈래를 붙이거나 빼지 않는다 */
function isExpression(value) {
return Boolean(value) && typeof value === 'object' && typeof value.match === 'object' && 'else' in value;
}
function withBranches(branches, elseValue) {
return { match: Object.fromEntries(branches.map((branch, index) => [String(index), branch])), else: elseValue };
}
/** isOwn 이 setup 의 것이라고 보는 갈래를 모두 뺀 식 — 자리와 상관없이(운영자가 앞에 갈래를 더했어도). 운영자의 갈래 · else 는 그대로 */
function withoutPilotBranch(expression, isOwn) {
const branches = branchesOf(expression);
const kept = branches.filter((branch) => !isOwn(branch));
return kept.length === branches.length ? expression : withBranches(kept, expression.else);
}
/**
* 끄는 실행 — setup 의 갈래를 뺀 식. setup 은 한 칸에 하나만 넣으므로, setup 꼴의 갈래가 둘 이상이면 나머지는 운영자가 같은 글로 넣은 것이다 —
* 그때는 맨 앞 하나만 빼고 나머지는 남긴다(같은 글이라 운영자의 것이 그대로 남는 것과 같다 — 2차 리뷰 N4)
*/
function withoutOwnBranchForTeardown(expression, isOwn, label) {
const own = branchesOf(expression).filter((branch) => isOwn(branch));
if (own.length <= 1) return withoutPilotBranch(expression, isOwn);
warn(`${label} 에 setup 의 시범 갈래와 같은 글의 갈래가 ${own.length}개 있습니다 — 맨 앞 하나만 빼고 나머지는 운영자 것으로 보고 남깁니다.`);
let dropped = false;
const kept = branchesOf(expression).filter((branch) => {
if (dropped || !isOwn(branch)) return true;
dropped = true;
return false;
});
return withBranches(kept, expression.else);
}
/** setup 의 갈래를 맨 앞에 하나 둔 식 — 전에 넣은 setup 의 갈래는(어디에 있든) 뺀다. 운영자의 갈래는 갈아 끼우지 않는다 */
function withPilotBranch(expression, branch, isOwn) {
const base = withoutPilotBranch(expression, isOwn);
return withBranches([branch, ...branchesOf(base)], base.else);
}
/*
* 식에는 설명 칸이 없다 — 시범 갈래의 주인은 setup 이 남긴 표시로 가린다:
* 설명이 PILOT_ALLOWED_IP_REASON 인 자동 차단 예외(x:AllowedIp)의 주소 집합. 갈래의 IP 가 모두 그 집합 안일 때만 setup 의 갈래다.
* 그 항목이 없으면 시범 갈래도 없다 — 꼴이 같아도 운영자가 넣은 갈래는 켤 때 · 끌 때 모두 건드리지 않는다(2026-10-30 리뷰 M2).
* 켤 때는 표시를 먼저 만들고 → 식을 고치고(이 실행을 시작할 때의 표시로 주인을 가린다) → 옛 표시를 지운다. 어디서 멈춰도 다음 실행이 자기 갈래를 알아본다.
*/
const NO_IPS = new Set();
/** ips 가 모두 owned(주소 집합) 안인가 — 비었거나 꼴이 아니면(null) false */
function ipsWithin(ips, owned) {
return Array.isArray(ips) && ips.length > 0 && owned instanceof Set && ips.every((ip) => owned.has(ip));
}
/** 위조 막기 갈래의 IP — 그 꼴이 아니면 null */
function senderGuardBranchIps(branch) {
if (branch?.then !== 'false' || typeof branch.if !== 'string' || !branch.if.startsWith(PILOT_SENDER_GUARD_PREFIX)) return null;
return parseLegacyIpCondition(branch.if.slice(PILOT_SENDER_GUARD_PREFIX.length));
}
/** 검사 예외 갈래의 IP — 그 꼴이 아니면 null */
function trustBranchIps(branch) {
return branch?.then === 'disable' ? parseLegacyIpCondition(branch.if) : null;
}
const RECIPIENT_THROTTLE_PILOT_PREFIX = 'is_empty(authenticated_as) && !';
/** 받는 사람 단위 한도의 시범 조건 IP — 그 꼴이 아니면 null */
function recipientThrottleIps(match) {
if (branchesOf(match).length > 0 || typeof match?.else !== 'string' || !match.else.startsWith(RECIPIENT_THROTTLE_PILOT_PREFIX)) return null;
return parseLegacyIpCondition(match.else.slice(RECIPIENT_THROTTLE_PILOT_PREFIX.length));
}
/** 보낸 IP 한도의 시범 조건 IP — 그 꼴이 아니면 null */
function ipThrottleIps(match) {
if (branchesOf(match).length > 0 || typeof match?.else !== 'string' || !match.else.startsWith('!')) return null;
return parseLegacyIpCondition(match.else.slice(1));
}
/** setup 의 시범 갈래 · 조건인가 — owned 는 표시의 주소 집합(없으면 빈 집합 = 아무것도 setup 의 것이 아니다) */
const isSenderGuardBranch = (branch, owned = NO_IPS) => ipsWithin(senderGuardBranchIps(branch), owned);
const isTrustBranch = (branch, owned = NO_IPS) => ipsWithin(trustBranchIps(branch), owned);
const isPilotRecipientThrottleMatch = (match, owned = NO_IPS) => ipsWithin(recipientThrottleIps(match), owned);
const isPilotIpThrottleMatch = (match, owned = NO_IPS) => ipsWithin(ipThrottleIps(match), owned);
const isThrottle = (item, spec) => item.description === spec.description && sameTrueSet(item.key, spec.key);
/** 자동 차단 예외 목록 가운데 setup 의 시범 표시 주소 */
function pilotMarkerSet(entries) {
return new Set(entries.filter((entry) => entry.reason === PILOT_ALLOWED_IP_REASON).map((entry) => entry.address));
}
/** allowRelaying 이 켜진 도메인 — 시범 설정을 빼기 직전에 엔진에서 다시 읽는다 (relay 가 꺼진 것을 확인한 뒤에만 뺀다 — 2026-10-30 리뷰 H1) */
async function relayingDomainNames(client) {
return (await listAll(client, 'Domain', ['name', 'allowRelaying'])).filter((item) => item.allowRelaying === true).map((item) => item.name);
}
/** 시범 앞의 발송 전략 — 사내 도메인은 로컬, 나머지는 elseRoute (엔진 기본은 'mx', 개발은 DEV_OUTBOUND_ROUTE 의 'mailpit') */
function baseOutboundRoute(elseRoute) {
return { match: { 0: { if: 'is_local_domain(rcpt_domain)', then: "'local'" } }, else: elseRoute };
}
/**
* 기존 서버(legacy)로 보낼 메일의 source — 허용 목록. 0.16.27 의 값은 authenticated(인증한 SMTP · 웹메일 제출 · 일정 알림) · unauthenticated · dmarc_pass
* (인증 없는 수신) · dsn(반송) · report(보고) · autogenerated(필터 전달 · 부재중 등 Sieve) · unknown(표시가 없는 메일) 이다 (smtp/src/queue/mod.rs).
* 인증 없이 들어온 것과 unknown 은 보내지 않는다 — 닫힌 쪽(2026-10-30 리뷰 L1). 엔진 마이너를 올리면 이 목록을 다시 본다.
*/
const PILOT_LEGACY_SOURCES = ['authenticated', 'autogenerated', 'dsn', 'report'];
/**
* 시범 공존의 발송 전략(x:MtaOutboundStrategy 의 route) — 받는 사람마다 따로 평가된다.
* 0. 이 엔진의 계정 · 별칭 · 공용 주소 → 로컬
* 1. 그 밖의 사내 주소 + PILOT_LEGACY_SOURCES 의 메일 → legacy(기존 서버)
* 2. 그 밖의 사내 주소 + 그 밖의 메일(인증 없이 들어온 것 등) → 로컬(배달 실패) — 루프 2차 막기: 기존 서버에서 온 것을 되돌려 보내지 않는다.
* 이 갈래가 없으면 else 로 가는데, 시범 중 MX 는 기존 서버라 운영의 'mx' 도 다시 기존 서버로 간다
* else 사내 도메인 밖 → elseRoute (시범 앞의 값 그대로 — 바깥 발송은 이 엔진이 직접)
*/
function pilotOutboundRoute(elseRoute) {
const sources = PILOT_LEGACY_SOURCES.map((source) => `source == '${source}'`).join(' || ');
return {
match: {
0: { if: 'is_local_address(rcpt)', then: "'local'" },
1: { if: `is_local_domain(rcpt_domain) && (${sources})`, then: `'${PILOT_ROUTE_NAME}'` },
2: { if: 'is_local_domain(rcpt_domain)', then: "'local'" },
},
else: elseRoute,
};
}
/** route 식이 builder 가 만드는 꼴이면 그 else 값, 아니면 null */
function routeElseOf(route, builder) {
return typeof route?.else === 'string' && stableJson(route) === stableJson(builder(route.else)) ? route.else : null;
}
/**
* 엔진에 있는 시범 공존 설정을 읽는다 (바꾸지 않는다). remnants — setup 이 넣는 시범 설정 가운데 지금 남아 있는 것의 이름.
* 발송 경로 · 자동 차단 예외는 설명의 [setup.mjs] 로, RCPT 가드 · 발송 전략은 setup 만 만드는 꼴로, IP 가 든 식은 표시(owned)로 알아본다.
* lookalikes — 시범 꼴이지만 표시가 없어 운영자 것으로 보는 갈래(건드리지 않는다)
*/
async function readPilotState(client, domain) {
const [domainInfo] = (
await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying', 'catchAllAddress', 'subAddressing', 'directoryId'] })
).list;
const routes = await listAll(client, 'MtaRoute', ['name', 'description', 'address', 'port']);
const legacyRoute = routes.find((route) => route.name === PILOT_ROUTE_NAME) ?? null;
const ownRoute = legacyRoute?.description?.includes(MANAGED_MARK) ? legacyRoute : null;
const route = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'], properties: ['route'] })).list[0]?.route;
const rcpt = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script', 'waitOnFail'] })).list[0] ?? {};
const senderAllowed = (await call(client, 'x:MtaStageMail/get', { ids: ['singleton'], properties: ['isSenderAllowed'] })).list[0]
?.isSenderAllowed;
const senderAuth = (await call(client, 'x:SenderAuth/get', { ids: ['singleton'], properties: PILOT_SENDER_AUTH_FIELDS })).list[0] ?? {};
const throttles = await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match']);
const allowedIps = await listAll(client, 'AllowedIp', ['address', 'reason']);
const owned = pilotMarkerSet(allowedIps);
const remnants = [];
if (domainInfo?.allowRelaying === true && ownRoute) remnants.push('도메인 allowRelaying');
if (ownRoute) remnants.push(`발송 경로 ${PILOT_ROUTE_NAME}`);
if (routeElseOf(route, pilotOutboundRoute) !== null) remnants.push('발송 전략');
if (hasRcptGuard(rcpt.script)) remnants.push('RCPT 가드');
if (branchesOf(senderAllowed).some((branch) => isSenderGuardBranch(branch, owned))) remnants.push('MAIL FROM 위조 막기');
if (PILOT_SENDER_AUTH_FIELDS.some((field) => branchesOf(senderAuth[field]).some((branch) => isTrustBranch(branch, owned)))) {
remnants.push('SPF · 역방향 DNS · DMARC 예외');
}
if (throttles.some((item) => isThrottle(item, RECIPIENT_THROTTLE) && isPilotRecipientThrottleMatch(item.match, owned))) {
remnants.push(`받는 쪽 한도 예외(${RECIPIENT_THROTTLE.description})`);
}
if (throttles.some((item) => isThrottle(item, IP_THROTTLE) && isPilotIpThrottleMatch(item.match, owned))) {
remnants.push(`받는 쪽 한도 예외(${IP_THROTTLE.description})`);
}
if (owned.size > 0) remnants.push('자동 차단 예외(기존 서버 IP)');
// lookalikeBranches — 시범 꼴이지만 표시가 없는 갈래(운영자 것)와 그 IP. ownDuplicates — 한 칸에 setup 꼴의 갈래가 둘 이상인 칸
// (setup 은 한 칸에 하나만 넣는다 — 하나는 운영자가 같은 글로 넣은 것이다, 2차 리뷰 N4)
const lookalikeBranches = [];
const ownDuplicates = [];
const scan = (field, branches, ipsOf, isOwn) => {
if (branches.filter((branch) => isOwn(branch, owned)).length > 1) ownDuplicates.push(field);
for (const branch of branches) {
const ips = ipsOf(branch);
if (ips && !isOwn(branch, owned)) lookalikeBranches.push({ field, ips });
}
};
scan('x:MtaStageMail 의 isSenderAllowed', branchesOf(senderAllowed), senderGuardBranchIps, isSenderGuardBranch);
for (const field of PILOT_SENDER_AUTH_FIELDS) scan(`x:SenderAuth 의 ${field}`, branchesOf(senderAuth[field]), trustBranchIps, isTrustBranch);
for (const item of throttles.filter((throttle) => isThrottle(throttle, IP_THROTTLE))) {
const ips = ipThrottleIps(item.match);
if (ips && !isPilotIpThrottleMatch(item.match, owned)) lookalikeBranches.push({ field: `받는 쪽 한도 "${IP_THROTTLE.description}"`, ips });
}
const lookalikes = [...new Set(lookalikeBranches.map((item) => item.field))];
return {
domainInfo: domainInfo ?? {},
legacyRoute,
ownRoute,
route,
rcptScript: rcpt.script,
waitOnFail: rcpt.waitOnFail,
allowedIps,
owned,
remnants,
lookalikes,
lookalikeBranches,
ownDuplicates,
};
}
/** 식의 기간 값(`5s` · `500ms` · `1m`)을 ms 로 — 갈래가 있거나 꼴이 다르면 null */
function constantDurationMs(expression) {
if (!isExpression(expression) || branchesOf(expression).length > 0 || typeof expression.else !== 'string') return null;
const match = /^(\d+)(ms|s|m)$/.exec(expression.else.trim());
if (!match) return null;
return Number(match[1]) * { ms: 1, s: 1000, m: 60_000 }[match[2]];
}
/**
* RCPT 가드를 응답 시간으로 가릴 수 있는가 — 엔진 자체의 없는 주소 거절은 x:MtaStageRcpt 의 waitOnFail(기본 5초)을 기다리고, 가드(Sieve)는 곧바로다.
* 그 대기가 문턱의 두 배보다 짧거나 식이 바뀌어 있으면 가를 수 없다 — 문제를 적은 글, 가를 수 있으면 null
*/
function guardTimingProblem(waitOnFail) {
const waitMs = constantDurationMs(waitOnFail);
if (waitMs !== null && waitMs >= PILOT_GUARD_FAST_REPLY_MS * 2) return null;
return (
`엔진의 RCPT 실패 대기(x:MtaStageRcpt 의 waitOnFail = ${JSON.stringify(waitOnFail)})가 ${PILOT_GUARD_FAST_REPLY_MS * 2}ms 보다 짧거나 식이 바뀌어 있어, ` +
'RCPT 가드의 즉시 거절과 엔진 자체 거절을 응답 시간으로 가를 수 없습니다 — 기본값(5s)으로 되돌린 뒤 다시 실행하세요.'
);
}
/** 시각 글(HH:MM:SS.ffffff) → 초 */
function traceSeconds(stamp) {
const [hours, minutes, seconds] = stamp.split(':');
return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}
/**
* 인증 없는 RCPT 하나 — 메일 컨테이너 안의 curl(--trace-time)로 자기 네트워크 주소의 25 에. { reply, ms }(RCPT 를 보낸 뒤 응답까지),
* 응답을 읽지 못하면 { reply: null, error }. 받는 사람이 거절되면 DATA 전에 끝난다 — 받아 버리면 빈 봉투라 반송이 생기지 않는다.
*/
function timedRcptInsideMail(cfg, mailIp, sender, recipient) {
const result = spawnSync(
'docker',
[
'exec',
'-i',
cfg.container,
'curl',
'-sS',
'-v',
'--trace-time',
'--max-time',
'20',
'--url',
`smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`,
'--mail-from',
sender,
'--mail-rcpt',
recipient,
'-T',
'-',
],
{ input: '', encoding: 'utf8' },
);
const lines = (result.stderr ?? '').split(/\r?\n/);
const sentAt = lines.findIndex((line) => /^[\d:.]+ > RCPT TO:</.test(line));
const replyLine = sentAt >= 0 ? lines.slice(sentAt + 1).find((line) => /^[\d:.]+ < \d{3}[ -]/.test(line)) : undefined;
if (!replyLine) {
return { reply: null, error: lines.find((line) => line.includes('curl: ('))?.trim() ?? result.error?.message ?? `종료 코드 ${result.status}` };
}
let ms = (traceSeconds(replyLine.split(' ')[0]) - traceSeconds(lines[sentAt].split(' ')[0])) * 1000;
if (ms < 0) ms += 86_400_000; // 자정을 넘김
return { reply: replyLine.slice(replyLine.indexOf(' < ') + 3), ms: Math.round(ms * 10) / 10 };
}
/** 확인용 주소 — 이 엔진에 없는 사내 주소 (난수라 겹치지 않는다) */
function pilotProbeAddress(cfg) {
return `setup-check-${randomBytes(4).toString('hex')}@${cfg.domain}`;
}
/**
* RCPT 가드가 실제로 답하는가 (2026-10-30 리뷰 M1) — 인증 없는 세션에서 엔진에 없는 사내 주소(난수)로 RCPT 를 보내 응답과 걸린 시간을 본다.
* 가드(시스템 Sieve 의 reject)는 곧바로 550 이고, 엔진 자체의 없는 주소 거절은 waitOnFail 을 기다린 뒤 같은 글의 550 이다 —
* 2026-10-30 개발 엔진: Sieve 거절 0.2~0.3ms, 엔진 자체 거절 4,999~5,003ms. 문턱 PILOT_GUARD_FAST_REPLY_MS 로 넉넉히 가른다.
* RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돌아 allowRelaying 과 상관없이 같은 답이다(켜기 전 확인도 같은 판별 — 2차 리뷰 N2).
* timing 이 false 면(waitOnFail 이 바뀌어 시간으로 가를 수 없다) 550 은 가르지 않고 'unclear' — 250 은 시간과 상관없이 가드가 없다는 뜻이다(N3).
* 돌려주는 값: { status, ok, detail } — status 는 'guard'(빠른 550 — ok) · 'accepted'(250 — 가드 없이 받음) · 'slow'(느린 550 — 엔진 자체의 거절) ·
* 'unclear'(550 이지만 가를 수 없음) · 'other'(그 밖의 응답) · 'none'(응답 없음)
*/
async function probePilotGuard(cfg, { timing = true } = {}) {
const mailIp = mailContainerIp(cfg);
if (!mailIp) return { status: 'none', ok: false, detail: 'mail 컨테이너 주소를 몰라 확인하지 못함' };
let last = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
last = timedRcptInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, pilotProbeAddress(cfg));
if (last.reply?.startsWith('550') && !timing) {
return { status: 'unclear', ok: false, detail: `${last.reply.trim()} · ${last.ms}ms — waitOnFail 이 바뀌어 가드의 거절인지 시간으로 가를 수 없다` };
}
if (last.reply?.startsWith('550') && last.ms < PILOT_GUARD_FAST_REPLY_MS) {
return { status: 'guard', ok: true, detail: `${last.reply.trim()} · ${last.ms}ms (메일 컨테이너 ${mailIp}:25)` };
}
// 받아 버렸으면 기다려도 소용없다 — 곧바로 알린다
if (last.reply?.startsWith('250')) break;
await sleep(POLL_INTERVAL_MS / 2);
}
if (!last?.reply) return { status: 'none', ok: false, detail: `응답 없음 — ${last?.error}` };
if (last.reply.startsWith('250')) {
return { status: 'accepted', ok: false, detail: `인증 없는 세션이 엔진에 없는 사내 주소를 받았다(${last.reply.trim()})` };
}
if (last.reply.startsWith('550')) {
return { status: 'slow', ok: false, detail: `${last.reply.trim()} 이지만 ${last.ms}ms — 가드가 아니라 엔진 자체의 거절이다(문턱 ${PILOT_GUARD_FAST_REPLY_MS}ms)` };
}
return { status: 'other', ok: false, detail: `${last.reply.trim()} · ${last.ms}ms — 가드의 550 이 아니다` };
}
/**
* allowRelaying 을 끄고 엔진에서 다시 읽어 확인한다 (안전한 쪽으로 바꾸는 것이라 [변경] 으로 알린다).
* 돌려주는 값: { closed: true } 또는 { closed: false, detail } — 저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면 false.
* 끄지 못했으면 부르는 쪽이 relayOpenError 로 "지금 가드 없이 열려 있을 수 있다"고 알리고 멈춘다 (2026-10-30 2차 리뷰 N1)
*/
async function closePilotRelay(client, domain, why) {
let after;
try {
await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: false } } });
after = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying;
} catch (error) {
return { closed: false, detail: error.message };
}
if (after === false) {
log(`[변경] 시범 공존 — ${why} → 도메인 ${domain.name} 의 allowRelaying 을 껐다(다시 읽어 확인) — 엔진에 없는 사내 주소는 다시 550`);
return { closed: true };
}
return { closed: false, detail: `저장은 받았지만 다시 읽어도 allowRelaying 이 ${JSON.stringify(after)} 입니다` };
}
/**
* 0단계에서 relay 를 끄지 못했을 때 — 멈추기 전에 RCPT 가드만 다시 건다 (2026-10-30 3차 리뷰 R1).
* RCPT 식이 setup 의 것이거나 기본값(식 없음)일 때만 저장하고(운영자의 식이면 건드리지 않는다), 시스템 Sieve 스크립트를 맞춰 켠 뒤 ReloadSettings → probePilotGuard.
* 경로 · 발송 전략 · allowRelaying 등 그 밖의 설정은 바꾸지 않는다. 가드는 relay 가 꺼진 엔진에서도 엔진 자체의 없는 주소 거절과 같은 글이라 걸어 두어 해가 없다.
* timing 이 false 면(waitOnFail 이 바뀜) 시간으로 가르지 않는다 — relay 가 켜진 엔진의 550 은 가드가 거절한 것이다(엔진 자체는 relay 가 켜져 있으면 받는다).
* 돌려주는 값: { state, detail } — state 는 'armed'(지금 550 으로 거절) · 'open'(그래도 받음 — 250) · 'unknown'(확인하지 못함) · 'failed'(저장 · 리로드 실패) · 'foreign'(운영자의 식)
*/
async function rearmPilotGuard(client, cfg, rcptScript, { timing = true } = {}) {
const expression = rcptScriptWithGuard(rcptScript);
if (expression === null) return { state: 'foreign', detail: JSON.stringify(rcptScript) };
try {
await ensureSystemRcptSieveScript(client);
if (stableJson(expression) !== stableJson(rcptScript)) {
await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } });
log('[변경] RCPT 단계 — 시범 공존의 RCPT 가드를 다시 걸었다 (allowRelaying 을 끄지 못해 멈추기 전에 — 경로 · 전략 · allowRelaying 은 그대로)');
}
await reloadSettings(client);
} catch (error) {
return { state: 'failed', detail: error.message };
}
const guard = await probePilotGuard(cfg, { timing });
if (guard.ok || guard.status === 'unclear') return { state: 'armed', detail: guard.detail };
if (guard.status === 'accepted') return { state: 'open', detail: guard.detail };
return { state: 'unknown', detail: guard.detail };
}
/**
* 가드가 답하지 않는데 relay 를 끄지 못했을 때의 멈춤 (2차 리뷰 N1). 그 밖의 설정은 더 바꾸지 않는다.
* rearm(0단계 — 멈추기 전에 가드만 다시 건 결과, 3차 R1)이 있으면 그 결과를 싣는다 — 가드가 답하면 relay 는 켜져 있어도 지금 열린 relay 는 아니다.
*/
function relayOpenError(domain, why, detail, rearm = null) {
const head = `${why} allowRelaying 을 끄려 했지만 끄지 못했습니다 (${detail})`;
const closeIt = `관리 화면에서 도메인 ${domain.name} 의 relay 허용(관리 API x:Domain/set 의 allowRelaying: false)을`;
const after = '꺼진 것을 확인한 뒤 같은 명령으로 다시 실행하세요 (mail/README.md "파일럿 공존").';
const urgent = { hint: `${closeIt} 바로 끄고, ${after}` };
const backscatter = '엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다';
switch (rearm?.state) {
case 'armed':
return new SetupError(
`${head} — 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(${rearm.detail}). relay 는 여전히 켜져 있습니다.`,
{ hint: `${closeIt} 끄고, ${after} 그동안 인증 없는 세션의 엔진에 없는 사내 주소는 가드가 550 으로 거절한다.` },
);
case 'open':
return new SetupError(`${head} — RCPT 가드도 다시 걸지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있습니다(${backscatter}).`, urgent);
case 'foreign':
return new SetupError(
`${head} — RCPT 단계 식(x:MtaStageRcpt 의 script)을 다른 설정이 쓰고 있어(${rearm.detail}) 가드를 다시 걸지 않았습니다 — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`,
urgent,
);
case 'failed':
return new SetupError(`${head} — RCPT 가드도 다시 걸지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, urgent);
case 'unknown':
return new SetupError(
`${head} — RCPT 가드는 다시 걸었지만 답하는지 확인하지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`,
urgent,
);
default:
return new SetupError(`${head} — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, urgent);
}
}
/**
* 기존 서버에 닿는가 — 메일 컨테이너 안의 curl 로 EHLO · NOOP 까지만 (메일은 보내지 않는다). EHLO 이름은 이 엔진이 쓰는 서버 이름.
* 운영은 STARTTLS 를 요구하고(--ssl-reqd) 인증서도 본다 — 주소가 IPv4 면 이름을 맞출 수 없어 인증서는 보지 않는다(발송 경로의 allowInvalidCerts 와 같다).
* 돌려주는 값: { ok: true, summary, greetingName } 또는 { ok: false, detail }
*/
function smtpNoopInsideMail(cfg, pilot, ehloName) {
const args = ['exec', cfg.container, 'curl', '-sS', '-v', '--max-time', String(PILOT_REACH_TIMEOUT_SECONDS)];
if (cfg.isProduction) {
args.push('--ssl-reqd');
if (pilot.allowInvalidCerts) args.push('--insecure');
}
args.push('-X', 'NOOP', `smtp://${pilot.host}:${pilot.port}/${ehloName}`);
const result = runDocker(args);
const lines = (result.stderr ?? '').split(/\r?\n/);
const greeting = lines.find((line) => /^< 220[ -]/.test(line))?.slice(2);
const noopAt = lines.indexOf('> NOOP');
const noopReply = noopAt >= 0 ? lines.slice(noopAt + 1).find((line) => /^< \d{3}[ -]/.test(line))?.slice(2) : undefined;
const startTls = lines.some((line) => line.startsWith('> STARTTLS'));
if (result.status === 0 && greeting && noopReply?.startsWith('250')) {
const tls = startTls ? ` · STARTTLS${pilot.allowInvalidCerts ? '(인증서 이름 확인 안 함)' : cfg.isProduction ? ' · 인증서 확인' : ''}` : '';
return {
ok: true,
summary: `인사 "${greeting.slice(0, 80)}" · NOOP ${noopReply.slice(0, 3)}${tls} · EHLO ${ehloName}`,
greetingName: /^220[ -](\S+)/.exec(greeting)?.[1]?.toLowerCase() ?? '',
};
}
const curlError = lines.find((line) => line.startsWith('curl: ('))?.trim() ?? (result.error ? result.error.message : `종료 코드 ${result.status}`);
return { ok: false, detail: `${curlError}${greeting ? ` (인사 "${greeting.slice(0, 80)}")` : ''}` };
}
/** 이 호스트(setup 을 돌리는 곳)의 IPv4 주소 — 운영의 메일 호스트면 공인 주소가 들 수 있다 (자기 자신 판정을 DNS 밖에서도 본다) */
function hostIpv4Addresses() {
return Object.values(networkInterfaces())
.flat()
.filter((entry) => entry && entry.family === 'IPv4' && !entry.internal)
.map((entry) => entry.address);
}
/**
* 시범 공존 — 동료(기존 서버) 앞 메일을 서명하는 DKIM 키를 알리고, 운영은 DNS 와 견준다 (C 전에 — 어긋나면 relay 를 켜지 않는다, 2026-10-30 리뷰 L3).
* 기존 서버(rspamd)는 서명이 DNS 의 키와 맞지 않으면(키는 있는데 다름) 위조로 보고 거절한다.
* 0.16.27 의 자동 교체(x:Domain 의 dkimManagement — rotateAfter 90일 · retireAfter 7일 · deleteAfter 30일)는 엔진이 DNS 를 직접 고칠 때
* (dnsManagement Automatic + DKIM 게시)만 돈다 — 그때는 새 키를 게시하고 전파를 확인한 뒤에 쓴다. DNS 를 손으로 관리하면(Manual) 활성 키에 교체 일정이
* 생기지 않고(nextTransitionAt 없음), 키가 없는 알고리즘만 새로 만들어 곧바로 쓴다 — 게시하기 전에 (소스 services/src/task_manager/dkim.rs).
* 운영은 이 호스트의 리졸버로 selector 의 TXT 를 읽어 엔진의 공개 키(publicKey)와 견준다. 개발 도메인(.test)은 DNS 가 없어 하지 않는다.
* TXT 가 확실히 없으면(ENOTFOUND · ENODATA) 처음 켤 때 멈추는 것은 키가 다를 때와 같다 (2026-10-30 2차 리뷰 N8) — 까닭은 다르다(3차 R2 · mailcow 조사 3.2):
* 키가 다르면 서명이 맞지 않아 R_DKIM_REJECT → SPOOFED_UNAUTH 로 거절되고, 키가 없으면 R_DKIM_PERMFAIL(0점)이라 DMARC 가 SPF 정렬에만 기댄다 —
* 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일(IPv6 · DNS 캐시)이 거절된다.
* 리졸버 오류 · 엔진이 공개 키를 주지 않음은 견줄 수 없어 [주의] 만.
* 돌려주는 값: { mismatched, missing } — 키가 다른(TXT 는 있지만 p= 가 다르거나 없음) · TXT 가 없는 selector 의 TXT 이름 목록
*/
async function checkPilotDkim(client, cfg, domain) {
const [info] = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['dkimManagement', 'dnsManagement'] })).list;
const signatures = (await listAll(client, 'DkimSignature', ['selector', 'stage', 'nextTransitionAt', 'domainId', 'publicKey'])).filter(
(signature) => signature.domainId === domain.id,
);
const active = signatures.filter((signature) => signature.stage === 'active');
if (active.length === 0) {
warn(`도메인 ${domain.name} 에 활성 DKIM 서명 키가 없습니다 — 동료(기존 서버) 앞 메일이 서명 없이 나가 기존 서버의 DMARC 가 SPF 정렬에만 기댄다.`);
return { mismatched: [], missing: [] };
}
const moving = signatures.filter((signature) => signature.nextTransitionAt || signature.stage !== 'active');
log(
`[확인] 시범 공존 — DKIM 서명 키(활성) ${active.map((signature) => signature.selector).join(' · ')} — 동료(기존 서버) 앞 메일은 이 selector 로 서명된다, DNS 에 TXT 가 있어야 한다 ` +
`(dkimManagement ${info?.dkimManagement?.['@type'] ?? '?'} · dnsManagement ${info?.dnsManagement?.['@type'] ?? '?'}${moving.length === 0 ? ' · 교체 일정 없음' : ''})`,
);
if (moving.length > 0) {
warn(
`DKIM 키 교체가 예정되었거나 진행 중입니다: ${moving.map((signature) => `${signature.selector}(${signature.stage}${signature.nextTransitionAt ? ` → ${signature.nextTransitionAt}` : ''})`).join(', ')} — ` +
'새 selector 의 TXT 가 DNS 에 있는지 확인하세요 (mail/README.md "파일럿 공존").',
);
}
if (!cfg.isProduction) return { mismatched: [], missing: [] };
const mismatched = [];
const missing = [];
for (const signature of active) {
const name = `${signature.selector}._domainkey.${domain.name}`;
if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') {
warn(`엔진이 ${signature.selector} 의 공개 키를 주지 않아 DNS 의 ${name} 와 견줄 수 없습니다 — setup --print-dns 의 값과 DNS 를 손으로 견주세요.`);
continue;
}
// published: 문자열(TXT 의 p= — 비었거나 p= 가 없으면 '') · null(TXT 가 확실히 없음) · { error }(리졸버 오류 — 견줄 수 없음)
let published;
try {
published =
(await resolveTxt(name))
.map((chunks) => chunks.join(''))
.map((text) => /(?:^|;)\s*p=([^;]*)/.exec(text)?.[1]?.replace(/\s+/g, ''))
.find(Boolean) ?? '';
} catch (error) {
published = error.code === 'ENOTFOUND' || error.code === 'ENODATA' ? null : { error: error.code ?? error.message };
}
if (published === null) {
missing.push(name);
} else if (typeof published === 'object') {
warn(`DKIM TXT ${name} 를 읽지 못했습니다 (${published.error}) — 바깥에서 dig TXT ${name} 로 확인하세요.`);
} else if (published !== signature.publicKey.replace(/\s+/g, '')) {
mismatched.push(name);
} else {
log(`[확인] 시범 공존 — DKIM TXT ${name} 가 엔진의 키와 같다`);
}
}
return { mismatched, missing };
}
/**
* 시범 공존을 켜기 전의 확인 — 하나라도 어긋나면 그 밖에는 아무것도 바꾸지 않고 중단한다.
* 다만 가드 없이 relay 가 열려 있으면(엔진에 없는 주소 앞 메일을 받아 놓고 반송하는 상태) 멈추기 전에 먼저 닫는다 (2026-10-30 리뷰 M1) —
* 그때 멈추면 머리글이 "allowRelaying 만 닫고 중단합니다"이고, 닫지 못했으면 다른 확인 없이 "끄지 못했다 — 지금 열려 있을 수 있다"로 멈춘다 (2차 리뷰 N1 · N6).
* waitOnFail 이 바뀌어 시간으로 가를 수 없어도 확인은 보낸다 — 250 이면 닫는다 (N3).
* 이미 켜진 시범(setup 의 경로 + allowRelaying)이 같은 기존 서버에 잠깐 닿지 않거나 이름을 풀지 못하면 [주의] 만 하고 이어 간다 — 다른 정비까지 막지 않는다 (L2).
* 돌려주는 값: 맞출 때 쓰는 pilot { host, port, ips, allowInvalidCerts, condition, elseRoute }
*/
async function preparePilot(client, cfg, domain, state) {
if (!cfg.docker) {
throw new SetupError(
'시범 공존은 docker 를 쓸 수 있어야 합니다 — 기존 서버에 닿는지 · 자기 자신이 아닌지 · 반영됐는지를 메일 컨테이너 안에서 본다. 아무것도 바꾸지 않았습니다.',
);
}
const { host, port } = cfg.pilot;
const issues = [];
const info = state.domainInfo;
const relayAtStart = info.allowRelaying === true;
const alreadyOn = Boolean(state.ownRoute) && relayAtStart;
const sameTarget = alreadyOn && state.ownRoute.address === host && state.ownRoute.port === port;
// 0) 가드를 응답 시간으로 가릴 수 있는가 · 가드 없이 relay 가 열려 있지 않은가 — 열려 있으면 무엇보다 먼저 닫는다
const timingProblem = guardTimingProblem(state.waitOnFail);
if (timingProblem) issues.push(timingProblem);
let closedRelay = false;
if (info.allowRelaying === true) {
let why = null;
if (!hasRcptGuard(state.rcptScript)) {
why = 'RCPT 가드 없이 allowRelaying 이 켜져 있어';
} else {
// 시간으로 가를 수 없어도(waitOnFail 이 바뀜) 보낸다 — 250 은 시간과 상관없이 가드가 없다는 뜻이다 (2차 리뷰 N3)
const guard = await probePilotGuard(cfg, { timing: !timingProblem });
if (guard.ok) {
log(`[확인] 시범 공존 — RCPT 가드가 답한다 (${guard.detail})`);
} else if (guard.status === 'unclear') {
warn(`RCPT 가드가 550 으로 답합니다(${guard.detail}) — 지금 열려 있지는 않아 allowRelaying 은 그대로 두고, 아래 waitOnFail 문제로 멈춥니다.`);
} else {
why = `RCPT 가드가 답하지 않아(${guard.detail})`;
}
}
if (why) {
const closed = await closePilotRelay(client, domain, why);
if (!closed.closed) {
// 끄지 못했으면 멈추기 전에 RCPT 가드만 다시 건다 — 경로 · 전략 · C 는 하지 않는다 (3차 리뷰 R1)
throw relayOpenError(domain, why, closed.detail, await rearmPilotGuard(client, cfg, state.rcptScript, { timing: !timingProblem }));
}
info.allowRelaying = false;
closedRelay = true;
}
}
// 1) 도메인 — catch-all 은 allowRelaying 보다 먼저라 동료 앞 메일이 catch-all 함으로 가고, 가드는 + 하위 주소를 가정하고, 바깥 디렉터리는 쓰지 않는다
const domainIssues = [];
if (info.catchAllAddress != null) {
domainIssues.push(`도메인 ${domain.name} 에 catch-all(${info.catchAllAddress})이 있습니다 — 동료 앞 메일이 기존 서버가 아니라 catch-all 함으로 간다. 지운 뒤 다시 실행하세요.`);
}
if (stableJson(info.subAddressing) !== stableJson({ '@type': 'Enabled' })) {
domainIssues.push(`도메인 ${domain.name} 의 하위 주소(subAddressing)가 기본(Enabled — 구분자 +)이 아닙니다 (${JSON.stringify(info.subAddressing)}) — RCPT 가드가 + 를 가정한다.`);
}
if (info.directoryId != null) domainIssues.push(`도메인 ${domain.name} 이 바깥 디렉터리(directoryId)를 씁니다 — 시범 공존은 엔진의 계정만 로컬로 본다.`);
if (domainIssues.length === 0) log(`[확인] 시범 공존 — 도메인 ${domain.name}: catch-all 없음 · 하위 주소(+태그) 기본 · 바깥 디렉터리 없음`);
issues.push(...domainIssues);
// 2) 서버 이름 — 기존 서버로 갈 때의 EHLO 이름이고, 자기 자신으로 보내지 않는지 견준다
const system = (await call(client, 'x:SystemSettings/get', { ids: ['singleton'], properties: ['defaultHostname'] })).list[0];
const defaultHostname = (system?.defaultHostname ?? cfg.hostname).toLowerCase();
const selfNames = [...new Set([defaultHostname, cfg.hostname])];
if (selfNames.includes(host)) {
issues.push(`기존 서버 주소(${host})가 이 엔진의 서버 이름(${selfNames.join(' · ')})과 같습니다 — 자기 자신으로 보내면 메일이 돈다.`);
}
// 3) setup 이 고칠 수 있는 꼴인가 — 다른 설정을 덮어쓰지 않는다
if (state.legacyRoute && !state.ownRoute) {
issues.push(
`발송 경로 이름 ${PILOT_ROUTE_NAME} 을 setup 이 만들지 않은 경로가 쓰고 있습니다 (${state.legacyRoute.address}:${state.legacyRoute.port}) — ` +
'그 경로를 지우거나 이름을 바꾼 뒤 다시 실행하세요.',
);
}
if (stableJson(state.rcptScript) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(state.rcptScript)) {
issues.push(`RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 RCPT 가드를 넣을 수 없습니다 (${JSON.stringify(state.rcptScript)}).`);
}
let elseRoute = DEV_OUTBOUND_ROUTE.else;
if (cfg.isProduction) {
elseRoute = routeElseOf(state.route, baseOutboundRoute) ?? routeElseOf(state.route, pilotOutboundRoute);
if (elseRoute === null) {
issues.push(
`발송 전략(x:MtaOutboundStrategy 의 route)이 setup 이 아는 꼴(사내 도메인은 로컬, 나머지는 경로 하나)이 아닙니다 (${JSON.stringify(state.route)}) — ` +
'시범 경로를 합치지 않았습니다. 엔진 기본(나머지는 mx)이나 외부 릴레이 하나로 되돌린 뒤 다시 실행하세요.',
);
} else if (elseRoute === `'${DEV_RELAY.name}'` || elseRoute === `'${PILOT_ROUTE_NAME}'`) {
issues.push(`발송 전략의 바깥 경로가 ${elseRoute} 입니다 — 운영의 바깥 발송은 'mx'(또는 외부 릴레이)여야 한다.`);
}
}
// 4) 기존 서버 IP — 개발은 비우면 백엔드 컨테이너 주소 (개발의 시험 SMTP 는 그 컨테이너에서 나간다 — 앱 네트워크 전체는 쓰지 않는다)
let { ips } = cfg.pilot;
let ipsFromBackend = false;
if (ips.length === 0) {
const backendIp = containerNetworks(cfg.backendContainer)?.[cfg.networkName];
if (backendIp) {
ips = [backendIp];
ipsFromBackend = true;
} else {
issues.push(`${PILOT_IPS_ENV_KEY} 가 비어 백엔드 컨테이너 주소를 쓰려 했지만 ${cfg.backendContainer} 가 앱 네트워크에 없습니다 — 백엔드를 띄우거나 값을 주세요.`);
}
}
const mailIps = Object.values(containerNetworks(cfg.container) ?? {}).filter(Boolean);
const mailInside = mailIps.filter((ip) => ips.some((cidr) => cidrContains(cidr, ip)));
if (mailInside.length > 0) {
issues.push(`기존 서버 IP(${ips.join(', ')})에 메일 컨테이너 주소(${mailInside.join(', ')})가 들어 있습니다 — MAIL FROM 위조 막기를 메일 컨테이너에서 확인할 수 없다.`);
}
// 시범 설정의 주인 표시는 setup 이 만든 자동 차단 예외다 — 같은 주소를 사람이 만든 항목이 쓰고 있으면 표시를 만들 수 없다(주소가 겹치면 엔진이 거절한다)
const foreignAllowed = state.allowedIps.filter((entry) => ips.includes(entry.address) && entry.reason !== PILOT_ALLOWED_IP_REASON);
if (foreignAllowed.length > 0) {
issues.push(
`기존 서버 IP ${foreignAllowed.map((entry) => entry.address).join(', ')} 에 setup 이 만들지 않은 자동 차단 예외가 이미 있습니다 — ` +
'시범 설정의 주인 표시로 setup 의 항목이 있어야 한다(같은 주소의 항목은 둘일 수 없다). 그 항목을 지운 뒤 다시 실행하면 setup 이 같은 주소로 다시 만든다.',
);
}
// 같은 원칙 — 기존 서버 IP 로만 된 시범 꼴의 운영자 갈래(표시 없음)는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없다(합쳐지고, 끝낼 때 지워진다).
// 한 칸에 setup 꼴의 갈래가 둘 이상이면 하나는 운영자 것이다. 둘 다 켜기 전에 멈춘다 — 켤 때도 끝낼 때도 운영자 갈래를 합치거나 지우지 않는다 (2차 리뷰 N4)
const ownedAfter = new Set([...state.owned, ...ips]);
const clashing = state.lookalikeBranches.filter((item) => ipsWithin(item.ips, ownedAfter));
if (clashing.length > 0) {
issues.push(
`${[...new Set(clashing.map((item) => item.field))].join(', ')} 에 기존 서버 IP(${[...new Set(clashing.flatMap((item) => item.ips))].join(', ')})로 된 갈래가 ` +
`setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시(자동 차단 예외 — 설명 "${PILOT_ALLOWED_IP_REASON}")가 지워진 것입니다. ` +
'운영자가 넣은 것이면: 그대로 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 시범을 끝낼 때 함께 지워지므로, 그 갈래를 지운 뒤' +
'(IP 한도는 엔진 기본 — 모든 IP — 으로 되돌린 뒤) 다시 실행하세요(시범 동안은 setup 의 갈래가 같은 일을 하고, 끝낼 때 뺀다). ' +
'표시가 지워진 것이면: 그 IP 로 같은 설명의 자동 차단 예외를 다시 만든 뒤 실행해도 된다.',
);
}
if (state.ownDuplicates.length > 0) {
issues.push(
`${state.ownDuplicates.join(', ')} 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다 — setup 은 한 칸에 하나만 넣으므로 나머지는 운영자가 넣은 것이다. ` +
'하나만 남기고 지운 뒤 다시 실행하세요.',
);
}
// 5) 기존 서버가 이 엔진 자신이 아닌가 — 메일 컨테이너 안에서 이름을 풀고(엔진이 쓰는 리졸버), DNS 밖의 주소(이 호스트 · 메일 컨테이너)와도 견준다
const selfAddresses = new Set([...mailIps, ...hostIpv4Addresses()]);
for (const name of selfNames) for (const ip of resolveIpv4InsideMail(cfg, name) ?? []) selfAddresses.add(ip);
const legacyAddresses = resolveIpv4InsideMail(cfg, host);
let resolvable = false;
if (legacyAddresses === null) {
issues.push('메일 컨테이너 안에서 기존 서버 이름을 풀지 못했습니다 (docker exec … getent 실패).');
} else if (legacyAddresses.length === 0) {
const message = `기존 서버 이름 ${host} 를 메일 컨테이너 안에서 풀 수 없습니다 (getent ahostsv4) — DNS 를 확인하세요.`;
if (sameTarget) warn(`${message} 이미 켜진 시범이라 이어 간다 — 그동안 동료 앞 메일은 새 서버 대기열에서 기다린다. 풀리면 다시 실행하세요.`);
else issues.push(message);
} else {
resolvable = true;
const loops = legacyAddresses.filter((ip) => selfAddresses.has(ip) || cidrContains('127.0.0.0/8', ip));
const nonPublic = legacyAddresses.filter((ip) => NON_PUBLIC_IPV4_RANGES.some((range) => cidrContains(range, ip)));
if (loops.length > 0) {
issues.push(`기존 서버 ${host} 가 이 엔진 자신(${loops.join(', ')} — 메일 컨테이너 · 이 호스트 · ${selfNames.join(' · ')})으로 풀립니다 — 자기 자신으로 보내면 메일이 돈다.`);
} else if (cfg.isProduction && nonPublic.length > 0) {
issues.push(`운영의 기존 서버 ${host} 가 사설 · 특수 주소(${nonPublic.join(', ')})로 풀립니다 — 도커 내부 이름이나 잘못된 DNS 일 수 있다. 기존 서버의 공인 이름을 적습니다.`);
} else {
log(`[확인] 시범 공존 — 기존 서버 ${host} → ${legacyAddresses.join(', ')} (메일 컨테이너에서 풀림 — 이 엔진 자신이 아니다)`);
}
if (cfg.isProduction && !legacyAddresses.some((ip) => ips.some((cidr) => cidrContains(cidr, ip)))) {
warn(
`기존 서버 ${host} 의 주소(${legacyAddresses.join(', ')})가 ${PILOT_IPS_ENV_KEY}(${ips.join(', ')})에 없습니다 — 기존 서버가 이 서버로 보낼 때 ` +
'다른 주소를 쓰는 것이 아니라면 넣는다 (빠지면 기존 서버가 넘기는 동료 메일이 550 5.7.1).',
);
}
}
if (cfg.isProduction) {
const own = [...selfAddresses].filter((ip) => ips.some((cidr) => cidrContains(cidr, ip)));
if (own.length > 0) issues.push(`${PILOT_IPS_ENV_KEY} 에 이 서버 자신의 주소(${own.join(', ')} — 이 호스트 · ${selfNames.join(' · ')})가 들어 있습니다.`);
}
// 6) 기존 서버에 닿는가 — 앞의 확인이 틀렸으면 기다리지 않는다. 인사의 이름이 이 엔진의 이름이면 DNS 와 상관없이 자기 자신이다
if (issues.length === 0 && resolvable) {
const reach = smtpNoopInsideMail(cfg, cfg.pilot, defaultHostname);
if (reach.ok && selfNames.includes(reach.greetingName)) {
issues.push(`기존 서버 ${host}:${port} 가 이 엔진의 이름(${reach.greetingName})으로 인사합니다 — 자기 자신으로 보내면 메일이 돈다.`);
} else if (reach.ok) {
log(`[확인] 시범 공존 — 기존 서버 ${host}:${port} 에 닿음 (${reach.summary})`);
} else {
const message =
`기존 서버 ${host}:${port} 에 닿지 않습니다 — ${reach.detail}. 이 호스트에서 바깥 ${port} 로 나가는 연결(클라우드는 25 를 막는 곳이 많다)` +
(cfg.isProduction ? ' · 기존 서버의 STARTTLS · 인증서 이름' : '') +
'을 확인하세요.';
if (sameTarget) warn(`${message} 이미 켜진 시범이라 이어 간다 — 그동안 동료 앞 메일은 새 서버 대기열에서 기다린다(3일). 고친 뒤 다시 실행하세요.`);
else issues.push(message);
}
}
// 7) DKIM — 운영은 DNS 의 키와 견준다. 키가 다르거나 TXT 가 확실히 없으면(2차 리뷰 N8) 처음 켤 때는 relay 를 켜지 않는다(이미 켜져 있으면 실패로 보고만)
if (issues.length === 0) {
const dkim = await checkPilotDkim(client, cfg, domain);
const messages = [];
if (dkim.mismatched.length > 0) {
messages.push(
`DKIM TXT ${dkim.mismatched.join(', ')} 의 공개 키가 엔진의 키와 다릅니다 — 기존 서버(rspamd)가 시범 사용자의 메일을 위조로 거절한다(R_DKIM_REJECT → SPOOFED_UNAUTH). ` +
'setup --print-dns 가 보여 주는 값으로 TXT 를 고치세요.',
);
}
if (dkim.missing.length > 0) {
messages.push(
`DKIM TXT ${dkim.missing.join(', ')} 가 DNS 에 없습니다 — DKIM 이 검증되지 않아 기존 서버(rspamd)의 DMARC 가 SPF 정렬에만 기대게 되어, ` +
'반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. ' +
'setup --print-dns 가 보여 주는 값으로 TXT 를 넣고, 퍼진 뒤(바깥에서 dig TXT 로 보이면) 다시 실행하세요.',
);
}
for (const message of messages) (alreadyOn ? problems : issues).push(message);
}
if (issues.length > 0) {
let hint;
if (closedRelay) {
hint =
'가드 없이 열려 있던 relay 를 닫았습니다 — 엔진에 없는 사내 주소는 다시 550 입니다(시범 사용자가 동료에게 보내는 메일도 not-found). ' +
'원인을 고친 뒤 같은 명령으로 다시 실행하면 RCPT 가드를 건 뒤에 다시 켠다 (mail/README.md "파일럿 공존").';
} else if (alreadyOn) {
hint = '시범은 켜진 채로 두었습니다. 원인을 고친 뒤 같은 명령으로 다시 실행하세요 (mail/README.md "파일럿 공존").';
} else {
hint = `시범을 켜지 않으려면 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비웁니다 (mail/README.md "파일럿 공존").`;
}
throw new SetupError(
closedRelay
? '시범 공존 사전 확인에 실패해 allowRelaying 만 닫고 중단합니다.'
: '시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.',
{ detail: issues.map((message) => ` - ${message}`).join('\n'), hint },
);
}
log(
`[확인] 시범 공존 — 기존 서버 IP ${ips.join(', ')}` +
(ipsFromBackend ? ` (${PILOT_IPS_ENV_KEY} 비움 → 개발 기본값: 백엔드 컨테이너 주소 — 개발의 시험 SMTP 는 그 컨테이너에서 나간다)` : ''),
);
if (cfg.pilot.allowInvalidCerts) {
log(`[확인] 시범 공존 — 기존 서버 주소가 IPv4(${host})라 발송 경로의 인증서 이름 확인을 끈다 (allowInvalidCerts: true — TLS 는 있으면 그대로 쓴다)`);
}
if (relayAtStart && !state.ownRoute) {
warn(
`도메인 ${domain.name} 의 allowRelaying 이 시범 설정 전부터 켜져 있었습니다` +
(closedRelay ? ' — RCPT 가드 없이 열려 있어 위에서 닫았고, 이제 setup 이 맡아 가드를 건 뒤에 다시 켜며 시범을 끝낼 때 끈다.' : ' — 이제 setup 이 맡고, 시범을 끝낼 때 끈다.'),
);
}
return { host, port, ips, allowInvalidCerts: cfg.pilot.allowInvalidCerts, condition: legacyIpCondition(ips), elseRoute };
}
/**
* 시범 공존 값이 비었을 때 — 엔진에 시범 설정이 남았으면 끝낸다 (운영은 --end-pilot 이 있어야 한다 — 없으면 아무것도 바꾸지 않고 멈춘다).
* 끄는 순서: 도메인 allowRelaying 먼저(저장 즉시 먹는다) → **다시 읽어 꺼진 것을 확인** → 발송 전략 → (main: 나머지 · 리로드 → 발송 경로 legacy 지우기).
* relay 가 꺼진 것을 확인하지 못하면(누가 켰는지 모르는 allowRelaying 포함) 아무것도 빼지 않는다 — RCPT 가드 · 위조 막기 · 예외를 남긴다 (2026-10-30 리뷰 H1).
* 돌려주는 값: { relayOff, routeToDestroy, nothingLeft? } — routeToDestroy 는 리로드 뒤에 지울 legacy 경로 (발송 전략을 되돌린 것이 먼저 걸린 다음에, L5),
* nothingLeft 는 시범 설정이 하나도 없을 때(남의 allowRelaying 만 켜져 있으면 relayOff 가 false — 요약이 "시범 설정 없음"이라고 말한다)
*/
async function endPilotIfLeft(client, cfg, domain, state) {
const relayingAtStart = state.domainInfo.allowRelaying === true;
const foreignRelay = relayingAtStart && !state.ownRoute;
if (foreignRelay) {
problems.push(
state.remnants.length === 0
? `도메인 ${domain.name} 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어, 엔진에 없는 사내 주소 앞 메일을 ` +
'받아 놓고 반송할 수 있습니다. setup 은 남의 설정이라 끄지 않았습니다 — 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용' +
'(관리 API x:Domain/set 의 allowRelaying: false)을 끄세요 (mail/README.md "파일럿 공존").'
: `도메인 ${domain.name} 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(${PILOT_ROUTE_NAME})가 없습니다 — setup 이 켠 것인지 알 수 없어 끄지 않았고, ` +
'그래서 RCPT 가드 · 위조 막기 · 예외도 빼지 않았습니다(있으면 남긴다). 의도한 것이 아니면 관리 API x:Domain/set 으로 allowRelaying 을 false 로 한 뒤 다시 실행하세요 (mail/README.md "파일럿 공존").',
);
}
if (state.remnants.length === 0) {
log(`[유지] 시범 공존 — 꺼짐 (${PILOT_HOST_ENV_KEY} 비어 있음)`);
// nothingLeft — 시범 설정이 하나도 없다(남의 allowRelaying 만 켜져 있을 수 있다 — 요약이 그렇게 말한다, 2차 리뷰 N6)
return { relayOff: !relayingAtStart, nothingLeft: true, routeToDestroy: null };
}
if (cfg.isProduction && !cfg.endPilot) {
throw new SetupError(
`${PILOT_HOST_ENV_KEY} 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다 (${state.remnants.join(' · ')}) — 아무것도 바꾸지 않고 중단합니다.`,
{
hint:
`시범을 이어 가려면 .env.production 에 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 다시 넣고 실행하세요. ` +
`끝내려면(전면 전환 — MX 를 새 서버로 옮긴 뒤) 값을 비운 채 ${END_PILOT_FLAG} 를 붙여 실행하세요 (mail/README.md "파일럿 공존").`,
},
);
}
if (relayingAtStart && state.ownRoute) {
await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: false } } });
}
const relaying = await relayingDomainNames(client);
if (relaying.length > 0) {
// 이 도메인을 끄지 못했는가 · 다른 도메인이 켜 두었는가를 가른다 — setup 은 다른 도메인을 끄려 하지 않는다 (2차 리뷰 N6)
const others = relaying.filter((name) => name !== domain.name);
if (relaying.includes(domain.name) && !foreignRelay) {
problems.push(
`도메인 ${domain.name} 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다 — 시범 설정(RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로)을 그대로 두었습니다. ` +
'관리 화면에서 그 도메인의 relay 허용(관리 API x:Domain/set 의 allowRelaying: false)을 끈 뒤 다시 실행하세요.',
);
}
if (others.length > 0) {
problems.push(
`다른 도메인 ${others.join(', ')} 이 allowRelaying 을 켜 두어 시범 설정(RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로)을 빼지 않았습니다 — ` +
'RCPT 가드는 모든 사내 도메인을 덮고, setup 은 그 도메인을 끄지 않는다. 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행하세요.',
);
}
log(`[유지] 시범 공존 — allowRelaying 이 켜져 있어(${relaying.join(', ')}) 끝내지 않는다: RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로를 남긴다 (꺼진 것을 확인한 뒤에만 뺀다)`);
return { relayOff: false, routeToDestroy: null };
}
log(`[변경] 시범 공존 끝 (${cfg.isProduction ? END_PILOT_FLAG : '개발 — 값이 비면 바로 끈다'}) — 남은 것: ${state.remnants.join(' · ')}`);
if (relayingAtStart) log(`[변경] 시범 공존 끝 — 도메인 ${domain.name}: 엔진에 없는 사내 주소는 다시 550 (allowRelaying false — 저장 즉시, 다시 읽어 확인)`);
const pilotElse = routeElseOf(state.route, pilotOutboundRoute);
if (pilotElse !== null) {
await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route: baseOutboundRoute(pilotElse) } } });
log(
`[변경] 시범 공존 끝 — 발송 전략: 사내 도메인은 로컬, 나머지는 ${pilotElse} (리로드 뒤 반영${state.ownRoute ? ` — 발송 경로 ${PILOT_ROUTE_NAME} 는 그 뒤에 지운다` : ''})`,
);
}
let routeToDestroy = null;
if (state.ownRoute) {
if (pilotElse === null && JSON.stringify(state.route ?? null).includes(`'${PILOT_ROUTE_NAME}'`)) {
problems.push(
`발송 전략이 setup 의 시범 꼴이 아닌데 ${PILOT_ROUTE_NAME} 경로를 고릅니다 (${JSON.stringify(state.route)}) — 발송 경로 ${PILOT_ROUTE_NAME} 을 지우지 않았습니다. ` +
'발송 전략을 고친 뒤 다시 실행하세요.',
);
} else {
routeToDestroy = state.ownRoute;
}
}
return { relayOff: true, routeToDestroy };
}
/** 끄는 실행 — 리로드로 발송 전략이 걸린 뒤 setup 의 legacy 경로를 지운다 (2026-10-30 리뷰 L5) */
async function destroyPilotRoute(client, route) {
await setObjects(client, 'MtaRoute', { destroy: [route.id] });
log(`[삭제] 시범 공존 끝 — 발송 경로 ${PILOT_ROUTE_NAME} (${route.address}:${route.port}, 발송 전략을 되돌린 리로드 뒤)`);
}
/**
* A 의 맨 앞 — 시범 설정의 주인 표시(설명이 PILOT_ALLOWED_IP_REASON 인 자동 차단 예외)를 기존 서버 IP 마다 만든다 (2차 리뷰 N5).
* RCPT 가드 · 받는 사람 한도 · 위조 막기 · 검사 예외 · IP 한도보다 먼저 두어, 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다
* (끄는 실행이 relay 가 켜져 있어 남기는 판정도 이 표시로 한다). 옛 IP 의 표시는 식을 모두 고친 뒤 ensurePilotInboundTrust 가 지운다.
* 차단 · 예외 목록은 캐시되므로 만들었으면 ReloadBlockedIps 로 바로 반영한다.
*/
async function ensurePilotMarkers(client, pilot) {
const entries = await listAll(client, 'AllowedIp', ['address', 'reason']);
const create = {};
pilot.ips.forEach((address, index) => {
if (entries.some((entry) => entry.address === address)) log(`[유지] 자동 차단 예외 ${address} (시범 공존 — 기존 서버)`);
else create[`pilot${index}`] = { address, reason: PILOT_ALLOWED_IP_REASON };
});
if (Object.keys(create).length === 0) return;
await setObjects(client, 'AllowedIp', { create });
for (const entry of Object.values(create)) log(`[생성] 자동 차단 예외 ${entry.address} (시범 공존 — 기존 서버)`);
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } });
}
/**
* A — 기존 서버 IP 에서 온 세션을 믿는 설정. pilot 이면 맞추고, null 이면 setup 의 것만 뺀다 (리로드 뒤 반영 — 켤 때는 allowRelaying 보다 먼저).
* - MAIL FROM 위조 막기: 인증 없는 세션의 사내 도메인 MAIL FROM 은 기존 서버 IP 에서만 (x:MtaStageMail 의 isSenderAllowed 맨 앞 갈래 → 550 5.7.1)
* - SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사: 기존 서버 IP 면 끈다 (x:SenderAuth 네 칸의 맨 앞 갈래). DKIM 검증은 그대로 —
* 기존 서버를 거친 바깥 메일은 이 엔진에서 SPF 가 늘 실패하고(연결 IP = 기존 서버), DMARC 를 남기면 서명 없는 p=reject 발신이 스팸함으로 간다
* (개발: 11.0점 → 3.5점). DMARC 를 끄면 기존 서버 경유 메일로 실패 · 집계 보고를 만들지도 않는다
* - 받는 쪽 한도 "Sender IP throttle": 기존 서버 IP 는 세지 않는다 (엔진 기본값이거나 setup 의 것일 때만 — 다른 값이면 [주의])
* - 자동 차단 예외(x:AllowedIp) 의 옛 표시: 식을 모두 고친 뒤에 지운다 (새 표시는 A 의 맨 앞 ensurePilotMarkers 가 먼저 만든다)
* 주인은 표시(owned)로 가린다 — 표시가 없으면 setup 의 것도 없다(운영자 갈래는 그대로). 기존 서버 IP 로만 된 운영자 갈래가 있으면 켜기 전에 멈추므로(preparePilot — N4)
* 켤 때 이 실행이 만든 표시가 섞여도 가르는 결과가 같다. 끌 때는 relay 가 꺼진 것을 다시 읽어 확인한 뒤에만 뺀다.
*/
async function ensurePilotInboundTrust(client, pilot) {
const entries = await listAll(client, 'AllowedIp', ['address', 'reason']);
const owned = pilotMarkerSet(entries);
if (!pilot) {
// 표시가 없으면 setup 의 것도 없다 — 꼴이 같은 운영자 갈래는 건드리지 않는다
if (owned.size === 0) return;
const relaying = await relayingDomainNames(client);
if (relaying.length > 0) {
log(`[유지] 시범 공존 — allowRelaying 이 켜져 있어(${relaying.join(', ')}) 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외를 남긴다`);
return;
}
}
const isOwnSender = (branch) => isSenderGuardBranch(branch, owned);
const isOwnTrust = (branch) => isTrustBranch(branch, owned);
const ipsLabel = pilot ? `기존 서버 IP(${pilot.ips.join(', ')})` : '';
let blockedIpsChanged = false;
// 1) MAIL FROM 위조 막기
const senderAllowed = (await call(client, 'x:MtaStageMail/get', { ids: ['singleton'], properties: ['isSenderAllowed'] })).list[0]
?.isSenderAllowed;
const senderLabel = `시범 공존 — 인증 없는 세션의 사내 도메인 MAIL FROM 은 ${ipsLabel}에서만 (다른 IP 는 550 5.7.1 · 빈 봉투 <> 는 그대로)`;
if (!isExpression(senderAllowed)) {
problems.push(`x:MtaStageMail 의 isSenderAllowed 를 식으로 읽지 못해 시범 공존의 MAIL FROM 위조 막기를 맞추지 않았습니다 (${JSON.stringify(senderAllowed)}).`);
} else {
const senderDesired = pilot
? withPilotBranch(senderAllowed, { if: `${PILOT_SENDER_GUARD_PREFIX}${pilot.condition}`, then: 'false' }, isOwnSender)
: withoutOwnBranchForTeardown(senderAllowed, isOwnSender, 'x:MtaStageMail 의 isSenderAllowed');
if (stableJson(senderDesired) !== stableJson(senderAllowed)) {
await setObjects(client, 'MtaStageMail', { update: { singleton: { isSenderAllowed: senderDesired } } });
log(pilot ? `[변경] ${senderLabel}` : '[변경] 시범 공존 끝 — MAIL FROM 위조 막기 갈래를 뺐다 (x:MtaStageMail 의 isSenderAllowed)');
} else if (pilot) {
log(`[유지] ${senderLabel}`);
}
}
// 2) 기존 서버 IP 의 SPF · 역방향 DNS · DMARC 검사
const senderAuth = (await call(client, 'x:SenderAuth/get', { ids: ['singleton'], properties: PILOT_SENDER_AUTH_FIELDS })).list[0] ?? {};
const authPatch = {};
for (const field of PILOT_SENDER_AUTH_FIELDS) {
if (!isExpression(senderAuth[field])) {
problems.push(`x:SenderAuth 의 ${field} 를 식으로 읽지 못해 시범 공존의 검사 예외를 맞추지 않았습니다 (${JSON.stringify(senderAuth[field])}).`);
continue;
}
const desired = pilot
? withPilotBranch(senderAuth[field], { if: pilot.condition, then: 'disable' }, isOwnTrust)
: withoutOwnBranchForTeardown(senderAuth[field], isOwnTrust, `x:SenderAuth 의 ${field}`);
if (stableJson(desired) !== stableJson(senderAuth[field])) authPatch[field] = desired;
}
const authLabel = `시범 공존 — ${ipsLabel}에서 온 메일은 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다 (DKIM 검증 · 스팸 판정은 그대로)`;
if (Object.keys(authPatch).length > 0) {
await setObjects(client, 'SenderAuth', { update: { singleton: authPatch } });
log(pilot ? `[변경] ${authLabel} (${Object.keys(authPatch).join(', ')})` : `[변경] 시범 공존 끝 — 기존 서버 IP 의 검사 예외를 뺐다 (${Object.keys(authPatch).join(', ')})`);
} else if (pilot) {
log(`[유지] ${authLabel}`);
}
// 3) 받는 쪽 한도 — 보낸 IP 마다 연결 수
const ipThrottleLabel = `받는 쪽 한도 "${IP_THROTTLE.description}"(보낸 IP 마다 연결 수)`;
const ipThrottles = (await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match'])).filter((item) => isThrottle(item, IP_THROTTLE));
if (pilot && ipThrottles.length === 0) {
warn(
`${ipThrottleLabel}을 찾지 못해 기존 서버 IP 예외를 넣지 않았습니다 — 이름을 바꿔 쓰고 있다면 그 한도가 기존 서버 IP 를 세지 않는지 ` +
'관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 (mail/README.md "파일럿 공존").',
);
}
for (const item of ipThrottles) {
const ours = isPilotIpThrottleMatch(item.match, owned);
if (pilot) {
const desired = { match: {}, else: `!${pilot.condition}` };
if (stableJson(item.match) === stableJson(desired)) {
log(`[유지] ${ipThrottleLabel} — 시범 공존: ${ipsLabel} 는 세지 않는다`);
} else if (ours || stableJson(item.match) === stableJson(IP_THROTTLE_DEFAULT_MATCH)) {
await setObjects(client, 'MtaInboundThrottle', { update: { [item.id]: { match: desired } } });
log(`[변경] ${ipThrottleLabel} — 시범 공존: ${ipsLabel} 는 세지 않는다 · 전 match ${JSON.stringify(item.match)}`);
} else {
warn(
`${ipThrottleLabel}의 조건을 따로 바꿔 두어(${JSON.stringify(item.match)}) 기존 서버 IP 예외를 넣지 않았습니다 — ` +
'기존 서버가 한꺼번에 많이 넘기면 연결이 끊기고 기존 서버가 나중에 다시 보낸다(늦게 받는다).',
);
}
} else if (ours) {
await setObjects(client, 'MtaInboundThrottle', { update: { [item.id]: { match: IP_THROTTLE_DEFAULT_MATCH } } });
log(`[변경] 시범 공존 끝 — ${ipThrottleLabel}: 엔진 기본(모든 IP)으로`);
}
}
// 4) 옛 표시 지우기 — 식을 모두 고친 뒤에 (켤 때는 IP 가 빠진 것만, 끌 때는 모두)
const wanted = pilot ? pilot.ips : [];
const stale = entries.filter((entry) => entry.reason === PILOT_ALLOWED_IP_REASON && !wanted.includes(entry.address));
for (const entry of stale) log(`[삭제] 자동 차단 예외 ${entry.address} (시범 공존 — ${pilot ? `${PILOT_IPS_ENV_KEY} 에서 빠짐` : '끝'})`);
if (stale.length > 0) {
await setObjects(client, 'AllowedIp', { destroy: stale.map((entry) => entry.id) });
blockedIpsChanged = true;
}
// 차단 · 예외 목록은 캐시된다 — 바로 반영되게 다시 읽힌다
if (blockedIpsChanged) await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } });
}
/** B — 기존 서버로 가는 발송 경로(Relay legacy)와 시범 공존의 발송 전략 (A 의 리로드 뒤). 바꿨으면 true — main 이 다시 리로드한다 */
async function ensurePilotRouting(client, pilot) {
let changed = false;
const routes = await listAll(client, 'MtaRoute', [
'name',
'description',
'address',
'port',
'protocol',
'implicitTls',
'allowInvalidCerts',
'authSecret',
'authUsername',
]);
const existing = routes.find((route) => route.name === PILOT_ROUTE_NAME);
const desired = {
description: PILOT_ROUTE_DESCRIPTION,
address: pilot.host,
port: pilot.port,
protocol: 'smtp',
implicitTls: false,
allowInvalidCerts: pilot.allowInvalidCerts,
authSecret: { '@type': 'None' },
};
const tls = pilot.allowInvalidCerts ? 'STARTTLS 가 있으면 쓴다 · 인증서 이름 확인 안 함' : 'STARTTLS 가 있으면 쓴다';
const label = `시범 공존 — 발송 경로 ${PILOT_ROUTE_NAME} → ${pilot.host}:${pilot.port} (${tls})`;
if (!existing) {
await setObjects(client, 'MtaRoute', { create: { legacy: { '@type': 'Relay', name: PILOT_ROUTE_NAME, ...desired } } });
changed = true;
log(`[생성] ${label}`);
} else if (!existing.description?.includes(MANAGED_MARK)) {
// 사전 확인(preparePilot)이 이미 막는다 — 그사이 누가 바꿨다
throw new SetupError(`발송 경로 ${PILOT_ROUTE_NAME} 을 setup 이 만들지 않은 경로가 쓰고 있습니다 — 시범 경로를 넣지 않고 중단합니다.`);
} else {
const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => stableJson(existing[key]) !== stableJson(value)));
if (existing.authUsername != null) patch.authUsername = null;
if (Object.keys(patch).length > 0) {
await setObjects(client, 'MtaRoute', { update: { [existing.id]: patch } });
changed = true;
log(`[변경] ${label} (${Object.keys(patch).join(', ')})`);
} else {
log(`[유지] ${label}`);
}
}
const route = pilotOutboundRoute(pilot.elseRoute);
const current = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'], properties: ['route'] })).list[0]?.route;
const routeLabel =
`시범 공존 — 발송 전략: 이 엔진의 주소는 로컬 · 그 밖의 사내 주소는 ${PILOT_ROUTE_NAME}(source ${PILOT_LEGACY_SOURCES.join(' · ')} 만) · ` +
`그 밖(인증 없이 들어온 메일 등)은 로컬(루프 2차 막기) · 사내 도메인 밖은 ${pilot.elseRoute}`;
if (stableJson(current) !== stableJson(route)) {
await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route } } });
changed = true;
log(`[변경] ${routeLabel}`);
} else {
log(`[유지] ${routeLabel}`);
}
return changed;
}
/** C — 도메인의 allowRelaying (엔진에 없는 사내 주소도 받는다). 저장 즉시 먹으므로 A · B 의 리로드 뒤에 둔다 */
async function ensurePilotRelaying(client, domain) {
const current = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying === true;
const label = `시범 공존 — 도메인 ${domain.name}: 이 엔진에 없는 사내 주소도 받는다 (allowRelaying — 인증 없는 세션은 RCPT 가드가 550)`;
if (current) {
log(`[유지] ${label}`);
return;
}
await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: true } } });
log(`[변경] ${label} — 저장 즉시 반영`);
}
/**
* RCPT 가드가 실제로 답하는지 (probePilotGuard) — 두 번 본다: stage 'before' 는 A 의 리로드 뒤 · B · C 앞(2차 리뷰 N2), 'after' 는 C 바로 뒤(M1).
* RCPT 단계 Sieve 는 받는 사람 해석보다 먼저라 relay 가 꺼져 있어도 같은 판별이다(가드면 빠른 550).
* 빠른 550 이 아니면 종료 1 — relay 가 꺼져 있으면 켜지 않고, 켜져 있으면 곧바로 끄고(다시 읽어 확인) 멈춘다. 끄지 못했으면 그렇게 알린다(N1).
*/
async function verifyPilotGuardOrClose(client, cfg, domain, { stage = 'after' } = {}) {
const guard = await probePilotGuard(cfg);
// relay 상태를 읽지 못하면 켜진 것으로 본다(닫는 쪽으로)
let relayOn = true;
try {
relayOn = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying !== false;
} catch {
relayOn = true;
}
const when = stage === 'before' ? `켜기 전 확인(allowRelaying ${relayOn ? '이미 켜짐' : '꺼짐'})` : '켠 뒤 확인';
if (guard.ok) {
log(`[확인] 시범 공존 — ${when}: 인증 없는 세션의 엔진에 없는 사내 주소 → RCPT 가드가 곧바로 거절 (${guard.detail})`);
return;
}
const why = `RCPT 가드가 답하지 않아(${guard.detail})`;
const hint =
'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트 arasystem-system-recipients(isActive)를 관리 API 로 본 뒤 다시 실행하세요 ' +
'(mail/README.md "파일럿 공존").';
if (!relayOn) {
throw new SetupError(
`${why} 시범 공존의 allowRelaying 을 켜지 않고 중단합니다 — 가드가 걸리지 않은 채 켜면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.`,
{ hint },
);
}
const closed = await closePilotRelay(client, domain, why);
if (!closed.closed) throw relayOpenError(domain, why, closed.detail);
throw new SetupError('RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다 — 그대로 두면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.', { hint });
}
/**
* 위조 막기가 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션)로 사내 도메인 MAIL FROM 을 보내 550 5.7.1 인지 본다
* (메일 컨테이너는 기존 서버 IP 가 아니다 — 받는 사람은 거절될 게이트웨이 주소라 DATA 로 가지 않는다)
*/
async function checkPilotSenderGuard(cfg) {
const mailIp = mailContainerIp(cfg);
if (!mailIp) {
problems.push('mail 컨테이너 주소를 몰라 시범 공존의 MAIL FROM 위조 막기를 실제로 확인하지 못했습니다.');
return;
}
const spoof = pilotProbeAddress(cfg);
let replies = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
replies = smtpRepliesInsideMail(cfg, mailIp, spoof, [cfg.gatewayAddress]);
if (replies?.mailFrom?.startsWith('550 5.7.1')) break;
await sleep(POLL_INTERVAL_MS / 2);
}
if (replies?.mailFrom?.startsWith('550 5.7.1')) {
log(`[확인] 시범 공존 — 기존 서버 IP 가 아닌 곳(메일 컨테이너 ${mailIp})의 사내 도메인 MAIL FROM <${spoof}> → ${replies.mailFrom}`);
return;
}
problems.push(
`시범 공존 확인 실패 — 메일 컨테이너 ${mailIp} 의 MAIL FROM <${spoof}>: ${replies?.mailFrom ?? '응답 없음'} (550 5.7.1 이어야 함 — 위조 막기). ` +
'x:MtaStageMail 의 isSenderAllowed 와 리로드를 확인하세요.',
);
}
/**
* 개발 — 인증한 세션의 받는 사람 확인에 쓰는 작은 SMTP 클라이언트 (백엔드 컨테이너의 node 로 돈다 — 메일 컨테이너에는 node 가 없고,
* curl 은 RCPT 가 받아지면 DATA 를 보낸다). 465(암묵적 TLS) · AUTH PLAIN · MAIL FROM · RCPT 뒤 RSET — 아무것도 보내지 않는다.
* 계정 · 비밀번호는 이 글 앞에 붙는 한 줄(PROBE_CREDENTIALS)로 표준 입력에만 실린다 — 명령줄 · 환경변수(docker exec 요청)에 두지 않는다 (2026-10-30 리뷰 L8).
* 결과에는 담지 않는다.
*/
const SMTP_RCPT_PROBE_SCRIPT = String.raw`
const tls = require('node:tls');
const opts = JSON.parse(process.argv[process.argv.length - 1]);
const out = {};
let buffer = '';
let waiting = null;
let done = false;
const lines = [];
const finish = () => { if (done) return; done = true; console.log(JSON.stringify(out)); socket.destroy(); };
const socket = tls.connect({ host: opts.host, port: opts.port, rejectUnauthorized: false });
socket.setTimeout(20000, () => { out.error = 'timeout'; finish(); });
socket.on('error', (error) => { out.error = error.message; finish(); });
socket.on('data', (data) => {
buffer += data.toString('latin1');
let index;
while ((index = buffer.indexOf('\r\n')) >= 0) { lines.push(buffer.slice(0, index)); buffer = buffer.slice(index + 2); }
flush();
});
function flush() {
if (!waiting) return;
const end = lines.findIndex((line) => /^\d{3}( |$)/.test(line));
if (end < 0) return;
const reply = lines.splice(0, end + 1).pop();
const resolve = waiting;
waiting = null;
resolve(reply);
}
const read = () => new Promise((resolve) => { waiting = resolve; flush(); });
const send = (text) => { socket.write(text + '\r\n'); return read(); };
(async () => {
out.greeting = (await read()).slice(0, 3);
out.ehlo = (await send('EHLO ' + opts.ehlo)).slice(0, 3);
const token = Buffer.from('\0' + PROBE_CREDENTIALS.user + '\0' + PROBE_CREDENTIALS.password).toString('base64');
out.auth = await send('AUTH PLAIN ' + token);
if (!out.auth.startsWith('235')) return finish();
out.mail = await send('MAIL FROM:<' + PROBE_CREDENTIALS.user + '>');
out.rcpt = await send('RCPT TO:<' + opts.rcpt + '>');
out.rset = await send('RSET');
await send('QUIT');
finish();
})().catch((error) => { out.error = error.message; finish(); });
`;
/** 백엔드 컨테이너에서 인증한 세션(465)으로 RCPT 까지 — { greeting, ehlo, auth, mail, rcpt, rset } 또는 { error } */
function authenticatedRcptFromBackend(cfg, user, recipient) {
const credentials = `const PROBE_CREDENTIALS = ${JSON.stringify({ user, password: cfg.devUserPassword })};\n`;
const result = spawnSync(
'docker',
['exec', '-i', cfg.backendContainer, 'node', '-', JSON.stringify({ host: 'mail', port: DEV_SUBMISSION_PORT, ehlo: RCPT_CHECK_EHLO, rcpt: recipient })],
{ input: credentials + SMTP_RCPT_PROBE_SCRIPT, encoding: 'utf8', timeout: 30_000 },
);
try {
return JSON.parse((result.stdout ?? '').trim().split(/\r?\n/).pop());
} catch {
// 표준 오류는 옮기지 않는다 — 스크립트의 첫 줄(계정)이 오류 글에 섞일 수 있다
return { error: result.error?.message ?? `종료 코드 ${result.status}` };
}
}
/**
* 개발 — 인증한 세션(465, 테스트 직원)은 이 엔진에 없는 사내 주소를 받는가 (RCPT 250 뒤 RSET — 보내지 않는다).
* 웹메일(JMAP 제출)도 같은 RCPT 를 지나 기존 서버로 간다. 운영은 직원 비밀번호가 없어 하지 않는다 (mail/README.md "파일럿 공존" 의 확인 명령으로 본다).
*/
async function checkPilotAuthenticatedRelay(cfg, user) {
if (!containerNetworks(cfg.backendContainer)) {
warn(`${cfg.backendContainer} 가 없어 인증한 세션의 확인(465 RCPT)을 건너뜁니다 — 백엔드를 띄운 뒤 다시 실행하면 확인한다.`);
return;
}
const probe = pilotProbeAddress(cfg);
let reply = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
reply = authenticatedRcptFromBackend(cfg, user, probe);
if (reply?.rcpt?.startsWith('250')) break;
await sleep(POLL_INTERVAL_MS / 2);
}
if (reply?.rcpt?.startsWith('250')) {
log(`[확인] 시범 공존 — 인증한 세션(${DEV_SUBMISSION_PORT}, ${user})의 엔진에 없는 사내 주소 ${probe} → ${reply.rcpt} (RSET — 보내지 않았다)`);
return;
}
problems.push(
`시범 공존 확인 실패 — 인증한 세션(${DEV_SUBMISSION_PORT}, ${user})의 ${probe}: ${reply?.rcpt ?? reply?.auth ?? reply?.error ?? '응답 없음'} ` +
'(250 이어야 함 — 도메인 allowRelaying). 이대로면 웹메일에서 동료에게 보낸 메일이 not-found 로 거절된다.',
);
}
// ============================================================
// 실행
// ============================================================
function printHelp() {
log(`사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery] [${END_PILOT_FLAG}]`);
log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md');
log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).');
log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`);
log(` ${END_PILOT_FLAG} 운영의 시범 공존 끝내기(전면 전환) — ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비우고 함께 준다 (mail/README.md "파일럿 공존")`);
}
/** 후속 조치가 필요하다 (종료 코드 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);
if (cfg.pilotShellKeys.length > 0) {
// 운영은 loadConfig 가 이미 막았다 — 여기는 개발뿐이다
log(`[참고] 시범 공존 값(${cfg.pilotShellKeys.join(', ')})을 셸에서 받았습니다 — 개발에서만 받는다, env 파일에 없으면 이번 실행에만 쓰인다`);
}
// 운영은 확인을 건너뛰지 않는다 — 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);
// 시범 공존 — 엔진의 시범 설정을 먼저 읽는다. 켤 때는 아무것도 바꾸기 전에 확인하고(preparePilot — 가드 없이 열린 relay 만은 먼저 닫는다),
// 값이 비었는데 남아 있으면 allowRelaying 부터 끄고 다시 읽어 확인한다(운영은 --end-pilot 이 있어야 — 없으면 여기서 멈춘다).
// relay 가 꺼진 것을 확인하지 못하면 가드 · 위조 막기 · 예외 · 경로를 남긴다(teardown.relayOff false)
const pilotState = await readPilotState(admin, domain);
if ((cfg.pilot || pilotState.remnants.length > 0) && pilotState.lookalikes.length > 0) {
warn(
`시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시(자동 차단 예외 "${PILOT_ALLOWED_IP_REASON}")에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다: ` +
`${pilotState.lookalikes.join(', ')} — setup 이 넣은 것이라면 관리 화면에서 지우세요 (mail/README.md "파일럿 공존").`,
);
}
const pilot = cfg.pilot ? await preparePilot(admin, cfg, domain, pilotState) : null;
const teardown = pilot ? null : await endPilotIfLeft(admin, cfg, domain, pilotState);
const keepPilotRoute = Boolean(pilot) || teardown?.relayOff === false;
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 에서 없는 주소처럼 거절한다 (아래 리로드로 반영).
// 시범 공존이면 같은 식에 RCPT 가드(인증 없는 세션의 엔진에 없는 사내 주소)를 함께 둔다 — 시범 A 의 첫째
const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory);
// 시범 공존 A 의 맨 앞 — 주인 표시(자동 차단 예외)부터. 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다 (2차 리뷰 N5)
if (pilot) await ensurePilotMarkers(admin, pilot);
const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients, pilot);
if (pilot && !systemRecipientsRejected) {
throw new SetupError('RCPT 가드를 넣지 못해 시범 공존을 켜지 않고 중단합니다 — 가드 없이 allowRelaying 을 켜면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.', {
hint: '위 [주의] · [실패] 의 RCPT 단계 스크립트를 고친 뒤 다시 실행하세요 (mail/README.md "파일럿 공존").',
});
}
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다 · 시범 공존이면 기존 서버 IP 도 뺀다),
// 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영)
await ensureRecipientThrottle(admin, pilot);
// 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다)
await ensurePilotInboundTrust(admin, pilot);
await ensureUploadQuota(admin, cfg);
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
if (!cfg.isProduction) await ensureDevRelay(admin, keepPilotRoute);
await reloadSettings(admin);
// 시범 공존 끝 — 발송 전략을 되돌린 것이 위 리로드로 걸린 뒤에 legacy 경로를 지운다
if (teardown?.routeToDestroy) await destroyPilotRoute(admin, teardown.routeToDestroy);
if (pilot) {
// 막는 것(A)이 위 리로드로 걸렸는지 — 열기 전에 RCPT 가드가 곧바로 답하는지 본다. 빠른 550 이 아니면 B · C 없이 종료 1 (2차 리뷰 N2)
await verifyPilotGuardOrClose(admin, cfg, domain, { stage: 'before' });
// 시범 공존 B(경로 → 리로드) → C(allowRelaying — 저장 즉시).
// 연 바로 뒤에도 RCPT 가드가 실제로 답하는지 본다 — 답하지 않으면 곧바로 닫고 종료 1 (닫힌 쪽으로 실패한다 — M1)
if (await ensurePilotRouting(admin, pilot)) await reloadSettings(admin);
await ensurePilotRelaying(admin, domain);
await verifyPilotGuardOrClose(admin, cfg, domain, { stage: 'after' });
}
await checkHttpEndpointPolicy(cfg);
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
if (pilot) await checkPilotSenderGuard(cfg);
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
const devUsers = [];
if (cfg.isProduction) {
await checkPublicEndpoint(cfg);
await checkNoDevRelay(admin);
} else {
await verifyDevRelay(admin, pilot, keepPilotRoute);
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 (pilot) await checkPilotAuthenticatedRelay(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 (pilot) {
log(
` 시범 공존 켜짐 — 엔진에 없는 사내 주소는 기존 서버 ${pilot.host}:${pilot.port} 로(인증한 · 엔진이 만든 메일만) · ` +
`기존 서버 IP ${pilot.ips.join(', ')} 에서 인증 없이 온 메일은 SPF · 역방향 DNS · DMARC 검사 없이 받는다 · ` +
`사내 도메인 MAIL FROM 은 그 IP 에서만 · 바깥 발송은 ${pilot.elseRoute} (mail/README.md "파일럿 공존")`,
);
} else if (teardown?.relayOff === false && teardown.nothingLeft) {
log(' 시범 공존 시범 설정 없음 · allowRelaying 켜짐(setup 이 켠 것이 아님 — 위 [실패], mail/README.md "파일럿 공존")');
} else if (teardown?.relayOff === false) {
log(
` 시범 공존 끝내지 못함 — ${PILOT_HOST_ENV_KEY} 는 비었지만 allowRelaying 이 켜져 있어 RCPT 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다 ` +
'(위 [실패] — allowRelaying 을 끈 뒤 다시 실행, mail/README.md "파일럿 공존")',
);
} else {
log(` 시범 공존 꺼짐 (${PILOT_HOST_ENV_KEY} 비어 있음)`);
}
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) {
const error = new SetupError('설정은 끝났지만 확인에 실패한 항목이 있습니다.', {
detail: problems.map((message) => ` - ${message}`).join('\n'),
});
error.problemsIncluded = true;
throw error;
}
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}`);
// 멈추기 전에 쌓인 확인 실패도 함께 — 끝까지 가지 못해도 앞의 [실패] 가 사라지지 않게 (2차 리뷰 N1)
if (!error.problemsIncluded && problems.length > 0) {
console.error(' 그 전에 이 실행에서 확인에 실패한 항목:');
for (const message of problems) console.error(` - ${message}`);
}
if (!(error instanceof SetupError)) console.error(error.stack);
process.exit(EXIT_ERROR);
});