# 메일 엔진 (Stalwart) 사내 메일은 **Stalwart(JMAP) 를 자체 호스팅**한다. 직원 로그인 = 메일 계정이다. 웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다. - 이미지: `stalwartlabs/stalwart:v0.16.27` — 패치까지 고정 (비-root `stalwart`, UID 2000 · healthcheck 내장. 올릴 때는 [docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정") - 설정값 SSOT: 루트 `.env.{APP_ENV}` 의 메일 섹션 ([env-structure](../.claude/rules/env-structure.md)) - 초기화·계정 구성: [`scripts/setup.mjs`](./scripts/setup.mjs) — 의존성 없음, 여러 번 실행해도 안전 - 이름은 루트 env 의 `PROJECT_NAME` 을 따른다 — 컨테이너 `${PROJECT_NAME}-mail`, 볼륨 `${PROJECT_NAME}_mail-config`·`_mail-data`, 네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다: ```bash export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production ``` --- ## 구조 ``` 브라우저 ─▶ frontend nginx ─(edge)─▶ backend ──HTTP JMAP (app-network, http://mail:8080)──▶ mail (Stalwart) │ 로그인: <직원 주소>:<비밀번호> 로 세션 조회 (200 / 401) │ 25 SMTP 수신 │ 메일함: <직원 주소>%<게이트웨이 주소> 로 대리 접근 │ 465 SMTPS 제출 · 993 IMAPS │ 443 HTTPS — ACME·MTA-STS 만 (JMAP·관리 API·포털은 403) ▼ 사내 도메인 밖 (개발 환경만) mailpit — 외부 수신자 메일 수집 ``` 메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만 붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다. | 구성 요소 | 위치 | 비고 | | --- | --- | --- | | `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `_mail-config`(config.json) · `_mail-data`(RocksDB) | | dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 | | `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` | | setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" | | 포트 | 운영 | 개발 | | --- | --- | --- | | 25 · 465 · 993 · 443 | 호스트에 공개 (`0.0.0.0`, IPv4 만 — [보안](#보안)). 443 의 HTTP 는 MTA-STS 정책 파일만 연다 | 공개하지 않음 | | 8080 (HTTP JMAP·관리 API) | **공개하지 않음** (backend 전용 내부망) | `127.0.0.1:${MAIL_ADMIN_PORT}` (관리 화면·setup 스크립트) | | 995 pop3s · 4190 sieve | 리스너는 있으나 공개하지 않음 | — | | 계정 | 용도 | 비밀번호 | | --- | --- | --- | | `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` | | 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` | | `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` | | `@` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 | | `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` | --- ## 개발 시작 ```bash # 0) env 파일 (처음 한 번) — change-me-* 를 무작위 영문·숫자로 교체한다 cp .env.development.example .env.development cp backend/.env.development.example backend/.env.development # 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mailpit 이 함께 뜬다 docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d # 2) 메일 엔진 설정 (처음 한 번 — 이후에도 env 를 바꾸면 다시 실행한다) node --env-file=.env.development mail/scripts/setup.mjs ``` | 확인 | 주소 | | --- | --- | | 관리 화면 (웹 포털) | — `admin@arasystem.test` / `MAIL_ADMIN_PASSWORD` | | 직원 셀프서비스 | | | Mailpit (외부 수신자 메일) | | - 첫 실행은 bootstrap → **mail 컨테이너를 스스로 재시작**(`docker restart -mail`)한 뒤 이어서 진행한다. - 출력의 `[생성]`·`[변경]`·`[유지]` 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 `[유지]` 여야 정상이다. - ⚠️ **외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다.** 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다. 그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(`example.net` 등)만 쓴다. - 개발 메일 데이터를 버리고 처음부터: ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" # 이후 1) 2) 를 다시 ``` (`docker compose down -v` 도 이 볼륨을 지운다 — 의도한 게 아니라면 쓰지 않는다) ### setup 스크립트가 하는 일 0. **사전 확인 (아무것도 바꾸기 전)** — app-network 의 실제 서브넷이 `APP_NETWORK_SUBNET` 인지, 앱 네트워크에 정해진 컨테이너 (backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 **엔진 설정을 바꾸지 않고 중단**한다 (신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고. 1. **모드 감지** — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상 2. **bootstrap** — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작. 응답의 무작위 관리자 비밀번호는 한 번만 보이므로 `data/mail/bootstrap-admin.json`(git 무시)에 잠시 보관했다가 교체 후 지운다. ⚠️ `Bootstrap/set` 은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서 - 부르기 **전에** `data/mail/` 에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다 - 재실행 때는 mail 컨테이너의 `/etc/stalwart/config.json` 으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다 (Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단) 3. **관리자** — `@도메인` 비밀번호를 `MAIL_ADMIN_PASSWORD` 로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은 `--recovery`). 갓 bootstrap 한 엔진이면 bootstrap 이 만든 `admin@` 의 아이디를 `MAIL_ADMIN_LOCAL_PART` 로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다). 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) **중단**한다 4. **자동 차단 예외** — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다). 신뢰 대역 = `MAIL_TRUSTED_NETWORKS`, 비우면 `APP_NETWORK_SUBNET`(compose 가 고정한 app-network 서브넷). **서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다. 5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만 6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`). 공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안) 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 (`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정") 7. **사내 목록 계정** — 계정 목록 읽기 전용 역할(`authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery`) + 비밀번호 없는 계정 `@도메인`. 엔진 관리자는 이 권한을 내줄 수 없어 **복구 관리자가 있을 때만**(개발 · 운영 `--recovery`) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고 역할·계정이 없거나 다르면 `[주의]` — 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로 `Principal/query`·`get` 을 해 본다 ([사내 목록 계정](#사내-목록-계정)) 8. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)). 계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 9 의 리로드로 반영된다 9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)). 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다). 보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다 10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다) 11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인 12. **시범 공존** (`MAIL_PILOT_LEGACY_HOST` 가 있을 때 — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)) — 바꾸기 전에 도메인 · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 보고, 막는 것(맨 앞에 주인 표시인 자동 차단 예외 · RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외)을 6 · 8 과 함께 저장해 9 의 리로드로 먼저 걸고, RCPT 가드가 답하는지 본 뒤 → 발송 경로 `legacy` · 발송 전략 → 리로드 → 도메인 `allowRelaying`(저장 즉시) → 다시 RCPT · MAIL FROM 을 실제로 보내 확인한다 — 켜기 전에 가드가 2초 안에 답하지 않으면 켜지 않고, 켠 뒤에 답하지 않으면 `allowRelaying` 을 바로 다시 끄고 멈춘다(끄지 못하면 그렇게 알린다). 값이 비면 거꾸로(`allowRelaying` 먼저, 꺼진 것을 다시 읽어 확인한 뒤에만 나머지) 지운다 — 운영은 `--end-pilot` 이 있어야 한다 그 밖의 안전장치: - **운영은 docker 를 쓸 수 있어야 한다** — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작). - **셸에 `APP_ENV`·`APP_NETWORK_SUBNET` 등이 이미 있으면 중단한다.** Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않아, 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다. (`MAIL_RECOVERY_ADMIN` 은 예외 — 원래 셸에서 일회성으로 주는 값이다. 시범 공존 값 `MAIL_PILOT_LEGACY_*` 는 **개발에서만** 예외 — 시험할 때 셸에서 한 번 켜 본다. 운영은 env 파일에 없는 시범 값이 셸에 있어도 중단한다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다) - `APP_NETWORK_SUBNET` 은 사설 대역의 네트워크 주소, 접두 길이 `/24`~`/28` 만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다 (넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다). - `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다. 로그인 확인은 2xx 만 "맞음", 401 만 "틀림"으로 보고 그 밖(429·5xx)은 중단한다 — 비밀번호를 잘못 덮어쓰지 않게. - 운영에서는 `change-me` 예시 값을 거부하고, `MAIL_PUBLIC_URL` 이 경로 없는 `https://` 가 아니면 실패, 발송 전략이 Mailpit 을 가리키면 실패로 보고한다. - 운영의 복구 관리자 규칙 — env 파일에 있으면 실패, 셸에 있는데 `--recovery` 가 없으면 실패, `--recovery` 실행은 끝에 "복구 관리자 끄기"를 안내하고 종료 코드 3, 그 뒤 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패. - 관리자 역할 계정이 백엔드 차단 목록(`MAIL_ADMIN_LOCAL_PART` 관리자 + `MAIL_BLOCKED_ACCOUNTS`)에서 빠져 있으면 `[주의]`. - 종료 코드 `0` 성공 · `1` 실패 · `3` 후속 조치 필요(개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 `--recovery` 뒤 복구 관리자 끄기 — 안내대로 한 뒤 다시 실행). ### 백엔드 연결 정보 | env (backend 에 compose 가 주입) | 값 | | --- | --- | | `MAIL_JMAP_URL` | `http://mail:8080` (내부 JMAP) | | `MAIL_DOMAIN` | `arasystem.test` / 운영 `arasystem.it.kr` | | `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 | | `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" | | `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) | | `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) | - 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401 - 메일함 접근: Basic `<직원 주소>%` : `MAIL_GATEWAY_PASSWORD` (게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403) - 사내 목록: Basic `@%` : `MAIL_GATEWAY_PASSWORD` → 세션의 `urn:ietf:params:jmap:principals` 계정으로 `Principal/query`·`Principal/get` ([사내 목록 계정](#사내-목록-계정) — 계정이 없으면 401, 역할에 `authenticate` 가 없으면 403) - ⚠️ 세션 응답의 `apiUrl`·`downloadUrl`·`uploadUrl`·`eventSourceUrl` 은 **`MAIL_PUBLIC_URL` 기준 절대 주소**다 (개발 `http://localhost:8180/jmap/`). 백엔드는 이 주소를 따르지 말고 `MAIL_JMAP_URL` 에 경로만 붙여 쓴다. - `Email/query` 의 `subject` 필터는 단어 단위 검색이고 제목 앞의 `[태그]` 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다. - 대리 접근은 메일 엔진 로그 `auth.success` 에 `accountName`(대상 직원)·`details`(게이트웨이)·`remoteIp` 로 남는다. - 신뢰 대역은 자동 차단에서 빠지므로 **로그인 무차별 대입 방어는 백엔드가 맡아야 한다** (로그인 엔드포인트 엄격 한도 — [security](../.claude/rules/security.md)). ### 사내 목록 계정 백엔드가 사내 목록(계약 [3절 "사내 목록"](../docs/mail/api-contract.md) — `GET /api/directory-entries` 와 받는 사람 추천의 "사내")을 채우려고 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는 전용 계정이다. 직원 역할에서는 이 권한을 뺐으므로([보안](#보안)) 계정 목록을 읽는 길은 이 계정 하나뿐이다. | 항목 | 값 | | --- | --- | | 주소 | `@` (기본 `mail-directory@`) — 관리자·게이트웨이 아이디와 같으면 setup 이 아무것도 바꾸기 전에 멈춘다 | | 역할 | 설명 `사내 목록 — 계정 목록 읽기 전용 [setup.mjs]` · 권한 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐 (빼는 권한·물려받는 역할 없음). `authenticate` 가 없으면 대리 접근이 403 이다 | | 계정 | 표시 이름 `사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]` · **비밀번호 없음** · 저장 한도 `quotas.maxDiskQuota` 1,048,576바이트 | | 쓰는 법 | 백엔드만 — 게이트웨이 대리 접근 `<사내 목록 주소>%` 로 세션의 `urn:ietf:params:jmap:principals` 계정에 `Principal/query`·`Principal/get` | | 엔진이 주는 칸 | `id`·`type`(`individual` 개인 · `group` 공용 주소)·`name`(로그인 이름)·`description`(표시 이름)·`email` 뿐 — 비밀번호 같은 칸은 없다. 관리자·게이트웨이·이 계정도 목록에 나오므로 빼는 것은 백엔드 몫이다 (계약 3절 "빼는 계정") | | 메일 | 받지 않는다 — 이 주소(+태그 꼴 포함)로 오는 메일은 RCPT 에서 없는 주소처럼 550 이다([보안](#보안) "메일을 받지 않는 시스템 계정"). 그 뒤의 막으로 역할에 `emailReceive` 가 없고(배달하지 않는다) 저장 한도도 작다 | - **왜 복구 관리자인가** — 엔진 관리자 역할(System Administrator)에는 `jmapPrincipal*` 이 없고, 엔진은 역할·계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를 바꾸는 쪽이 가졌는지 본다. 그래서 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 (`You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet`). 관리자로 되는 것은 계정의 표시 이름·저장 한도 고치기, 비밀번호 빼기, 계정 지우기다 (2026-10-29, 0.16.27 에서 확인). - **언제 만들어지나** — 개발은 setup 을 돌릴 때마다(env 파일에 복구 관리자가 있다). 운영은 [운영 배포](#운영-배포)의 `--recovery` 실행(첫 배포)에서 만들어진다. **이미 운영 중인 서버**는 같은 절차(1 복구 관리자 주기 → `up -d mail` → 3 `--recovery` → 4·5 끄기)를 한 번 더 한다 — 이미 맞는 나머지는 `[유지]` 다. - **복구 관리자 없이 돌리면**(운영의 보통 실행) — 읽어서 확인하고, 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기·옛 계정 지우기)만 맞춘다. 역할·계정이 없거나 역할이 다르면 `[주의]` 만 내고 **실패로 끝내지 않는다** — 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록(최근 90일)을 쓴다 (계약 3절). `--recovery` 로 돌렸는데 복구 관리자로 로그인하지 못해 맞추지 못했으면 실패로 끝난다(mail 컨테이너를 그 값으로 다시 만들었는지 본다). - **비밀번호가 없다** — 이 계정으로는 직접 로그인할 수 없고, 게이트웨이 비밀번호를 가진 백엔드만 대리 접근으로 쓴다. 누가 비밀번호를 넣으면 그것으로 계정 목록(관리자·게이트웨이 주소 포함)을 읽을 수 있으므로 setup 이 다음 실행에서 뺀다(관리자로도 된다). - 이 역할은 기본 직원 역할(`User`)과 따로다 — 직원 역할에서 `jmapPrincipal*` 을 빼는 단계([setup 스크립트가 하는 일](#setup-스크립트가-하는-일) 6)는 이 역할을 건드리지 않는다. 지우거나 바꾸면: | 한 일 | 생기는 일 | 되돌리기 | | --- | --- | --- | | 계정을 지움 | 대리 접근이 401 → 사내 목록 `503 MAIL_001`, 받는 사람 추천의 "사내"는 로그인한 직원 기록으로 | 복구 관리자로 setup (운영 `--recovery`) | | 역할을 지움 | 계정이 쓰는 동안은 엔진이 거절한다(`objectIsLinked`) — 계정을 먼저 지워야 지워진다 | 위와 같다 | | 역할에 권한을 더함 · 물려받는 역할을 붙임 | 그 권한이 이 계정(= 게이트웨이를 가진 백엔드)에 열린다 — setup 이 `[변경]`(복구 관리자) 또는 `[주의]` | 복구 관리자로 setup — 관리자로는 뺄 수 없다 | | 역할에서 `authenticate`·`jmapPrincipal*` 을 뺌 (빼는 권한에 넣음 포함) | 계정 목록을 읽지 못한다 → 사내 목록 `503 MAIL_001` | 복구 관리자로 setup | | 계정의 역할을 바꿈 | 위의 둘 중 하나 | 복구 관리자로 setup | | 계정에 비밀번호를 넣음 | 그 비밀번호로 계정 목록을 읽을 수 있다 | setup (관리자로도 뺀다) | | 다른 계정에 이 역할을 줌 | 그 계정이 계정 목록 전체를 읽는다 — setup 이 `[주의]` | 관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) | | `MAIL_DIRECTORY_LOCAL_PART` 를 바꿈 | 복구 관리자로 돌린 setup 이 새 계정을 만들고, 그 뒤에 옛 계정(setup 표시 + 이 역할 하나)을 지운다 — backend 도 다시 띄운다(`up -d backend`) | 값을 되돌리고 같은 절차 | - 확인 (2026-10-29, 개발 엔진 0.16.27): 처음 실행은 `[생성]` 둘(역할·계정)과 `[확인] 사내 목록 — 대리 접근 … (Principal/query · get)`, 다시 돌리면 `[유지]`. 역할에 권한·빼는 권한·물려받는 역할을 붙이고 계정에 비밀번호를 넣은 뒤 돌리면 복구 관리자로 모두 `[변경]`, 복구 관리자 없이 돌리면 계정만 `[변경]`(관리자로)이고 역할은 `[주의]` · 종료 코드 0. 계정을 지우고 복구 관리자 없이 돌리면 `[주의] 사내 목록 계정이 없습니다 (계정 없음)` · 종료 코드 0. 아이디를 바꿨다 되돌리면 `[생성]` 새 계정 · `[삭제]` 옛 계정. --- ## 운영 배포 사전 조건: - 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조 · 프록시가 다른 서버에 있고 사설망으로 붙으면 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)) - `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr)) - 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등)) - 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다 - **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)) - **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절) - **엔진 한도** — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 ([엔진 한도](#엔진-한도)) - **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한). 다른 서버의 Nginx Proxy Manager 라면 설정 · `TRUST_PROXY` 는 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망) - **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다) - **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 — rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여 자동 차단·게이트웨이 IP 제한이 무력화된다. 배포 뒤 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 확인한다. - **setup 을 돌릴 사용자** — docker 를 쓸 수 있어야 한다(docker 그룹, 또는 매번 `sudo -E`). 저장소는 그 사용자로 클론한다(`sudo git clone` 금지) — `data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다). - **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다). - **`MAIL_DIRECTORY_LOCAL_PART`**(사내 목록 계정, 기본 `mail-directory`)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 ([사내 목록 계정](#사내-목록-계정)). 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다. ```bash export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" # 1) 복구 관리자를 일회성으로 준다 — 셸 기록에 남지 않게 read 로 받는다 (compose 는 셸 값이 env 파일보다 우선한다) read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P" # 2) 기동 (mail 은 bootstrap 모드로 뜬다 — 8080 만 열림). sudo 를 쓰면 -E 로 셸 값을 넘긴다 sudo -E docker compose --env-file .env.production up -d --build # 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다). # 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다). export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" # (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로: node --env-file=.env.production mail/scripts/setup.mjs --recovery # docker 그룹이 아니면 sudo 로 (셸 값과 PATH 를 넘긴다). ⚠️ 그러면 이후 실행도 모두 같은 형태로 — data/mail/ 이 root 소유가 된다 sudo -E env "PATH=$PATH" node --env-file=.env.production mail/scripts/setup.mjs --recovery # (B) 호스트에 Node 가 없으면 — 공식 배포 파일을 임시 폴더에 받아 그대로 쓴다 (설치하지 않는다, 체크섬 확인. ARM 서버는 linux-arm64) NODE_DIST=node-v24.22.1-linux-x64; NODE_TMP="$(mktemp -d)" curl -fsSL -o "$NODE_TMP/$NODE_DIST.tar.xz" "https://nodejs.org/dist/v24.22.1/$NODE_DIST.tar.xz" curl -fsSL "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt" | grep " $NODE_DIST.tar.xz\$" | (cd "$NODE_TMP" && sha256sum -c -) tar -xJf "$NODE_TMP/$NODE_DIST.tar.xz" -C "$NODE_TMP" && export PATH="$NODE_TMP/$NODE_DIST/bin:$PATH" # → 이후는 (A) 와 같다. 끝나면 rm -rf "$NODE_TMP" # --recovery 실행은 설정을 마친 뒤 "복구 관리자 끄기"를 안내하고 종료 코드 3 으로 끝난다 → 4) 로 # 4) 복구 관리자 끄기 — 셸 값을 지우고 mail 컨테이너를 다시 만든다 (빈 값이면 복구 자격 증명은 401 — 검증함) unset MAIL_RECOVERY_ADMIN P sudo docker compose --env-file .env.production up -d mail # 5) --recovery 없이 다시 실행 (3 과 같은 형태 — 컨테이너를 다시 만들었으므로 MAIL_ADMIN_URL 도 다시 구한다) # 전부 [유지] 이고, 컨테이너에 복구 관리자가 남아 있지 않은지 확인한다 (남아 있으면 실패) export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" node --env-file=.env.production mail/scripts/setup.mjs ``` - 3)의 `--recovery` 실행은 **사내 목록 계정**도 만든다 — 엔진 관리자는 그 권한을 내줄 수 없어 복구 관리자로만 된다 ([사내 목록 계정](#사내-목록-계정)). 이 기능(8단계) 전부터 운영 중인 서버는 1)~5) 를 한 번 더 한다. 5)의 보통 실행은 그 계정이 있고 맞는지 읽어서 확인하고, 없으면 `[주의]` 만 낸다. - 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다. - 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면 경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https:///jmap/session` → `403`. #### bootstrap 직후에 멈췄다면 (다시 실행하기 전에) setup 이 bootstrap 과 재시작 사이(또는 직후)에 멈췄으면 — 연결 끊김, 권한 오류, 종료 코드 1 — **볼륨을 지우거나 `Bootstrap/set` 을 손으로 부르지 않는다.** 1. 엔진이 어디까지 갔는지 본다: `sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남"` 2. 실패 원인을 고친다 — 출력의 `[실패]` 줄. `data/mail` 에 쓸 수 없다고 나왔다면 소유자를 바꾼다: `sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail` (sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로) 3. 셸에 `MAIL_RECOVERY_ADMIN` 이 남아 있는 채로 **같은 명령(`--recovery`)을 다시** 실행한다. bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다. 4. `bootstrap 이 거부되었습니다 (primaryKeyViolation)` 가 나오면 [문제 해결](#문제-해결) 의 해당 줄을 본다. #### 원래 접속 주소 확인 (배포 직후) 자동 차단·게이트웨이 IP 제한은 접속 주소를 믿고 동작한다. 바깥(다른 네트워크의 PC)에서 **실제 계정(본인 메일함)으로 IMAP 에 한 번 로그인**한 뒤, 메일 엔진 로그의 `auth.success` 줄의 `remoteIp` 가 그 PC 의 공인 주소인지 본다. 도커 내부 주소(`172.16.71.1` 같은 게이트웨이 등)로 보이면 rootless·iptables 설정을 고친다. - 틀린 비밀번호로는 확인할 수 없다 — setup 이 정한 로그 수준(info)에서는 **실패한 로그인이 로그에 남지 않는다** (2026-10-27 Stalwart 0.16.27 에서 IMAP·HTTP 모두 확인. 성공은 `auth.success … listenerId = "imaps", remoteIp = …` 로 남는다). ```bash # 바깥 PC 에서 — 연결되면 아래 두 줄을 직접 입력한다 (명령에 비밀번호를 적지 않아 셸 기록에 남지 않는다) openssl s_client -quiet -connect mx1.arasystem.it.kr:993 # a LOGIN <본인 주소> <비밀번호> # a LOGOUT # 메일 호스트에서 sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3 ``` - 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180::8080 <메일 호스트>` → . setup 은 운영에서 직원 계정을 만들지 않는다. ⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어 `You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). - `MAIL_HOSTNAME` 은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다. - 관리자 비밀번호를 바꾸려면: env 의 `MAIL_ADMIN_PASSWORD` 변경 → 위 1)·`up -d mail` → `--recovery` 실행 → 4)·5). - 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은 `sudo docker compose --env-file .env.production down` 후 `up -d` 로 네트워크와 컨테이너를 함께 다시 만든다. 그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (개발 환경에서 확인 — `up -d --force-recreate` 로도 풀린다). ### PTR 메일 호스트 IP 의 역방향 DNS 를 `MAIL_HOSTNAME`(전환 기간에는 `mx1.arasystem.it.kr`)으로 설정한다 (가비아가 아니라 **IP 를 준 호스팅사** 콘솔 또는 지원 요청). `A(MAIL_HOSTNAME) → IP` 와 `PTR(IP) → MAIL_HOSTNAME` 이 서로 맞고(FCrDNS), SMTP 인사말(EHLO = `MAIL_HOSTNAME`)과도 같아야 Gmail·네이버 등이 거부하거나 스팸 처리하지 않는다. ### 외부 릴레이 (SES 등) 아웃바운드 25 가 막혀 있거나 IP 평판이 나쁠 때 외부 메일을 릴레이로 보낸다. 개발의 Mailpit 경로와 같은 방식이고 인증만 다르다. 관리자 Basic 인증으로 `POST {관리 API}/jmap/` (`using: ["urn:ietf:params:jmap:core","urn:stalwart:jmap"]`): ```jsonc [ ["x:MtaRoute/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "create": { "ses": { "@type": "Relay", "name": "ses", "description": "외부 발송 — Amazon SES", "address": "email-smtp.ap-northeast-2.amazonaws.com", "port": 587, "authUsername": "", // 비밀번호를 DB 에 두지 않고 mail 컨테이너의 환경변수에서 읽는다 → compose 의 mail.environment 에 추가 "authSecret": { "@type": "EnvironmentVariable", "variableName": "MAIL_RELAY_PASSWORD" } } } }, "0"], ["x:MtaOutboundStrategy/set", { "accountId": "<…>", "update": { "singleton": { "route": { "match": { "0": { "if": "is_local_domain(rcpt_domain)", "then": "'local'" } }, "else": "'ses'" } } } }, "1"], // 리로드 전에는 반영되지 않는다 ["x:Action/set", { "accountId": "<…>", "create": { "r": { "@type": "ReloadSettings" } } }, "2"] ] ``` - SPF 에 릴레이 쪽을 넣는다 (예: SES 는 `include:amazonses.com`). DKIM 은 Stalwart 가 보내기 전에 서명한다. - 포트별 TLS 방식(587 STARTTLS / 465 암묵적 TLS) 속성은 관리 화면에서 확인한다. **이 예시는 실제 SES 로 검증하지 않았다.** ### DNS 리졸버와 스팸 차단 목록 (DNSBL) 스팸 필터는 기본으로 외부 차단 목록(DNSBL) 18개를 DNS 로 조회한다 — Spamhaus(`zen` IP · `dbl` 도메인), SURBL, URIBL, Barracuda, SpamCop, DNSWL, Mailspike, SenderScore 등 (목록: 관리 화면 Settings › Spam Filter › DNSBL, 또는 `x:SpamDnsblServer/query`). 이 목록들은 Stalwart 와 **별개 서비스라 각자 이용 조건이 있다.** 1. **메일 서버 전용 재귀 DNS 를 둔다.** - 공용·ISP DNS(KT `168.126.63.1`, `8.8.8.8`, `1.1.1.1` 등)로 조회하면 Spamhaus 가 거부하고 오류 코드 `127.255.255.254` 를 돌려준다. 2026-10-26 개발 환경(KT DNS)에서 항상 등록돼 있는 시험 주소를 조회해 이 오류 코드가 오는 것을 확인했다. - 전용 재귀 리졸버(unbound 등)를 메일 호스트나 같은 compose 네트워크에 두고, Stalwart 의 DNS 리졸버 설정을 그쪽으로 바꾼다 (관리 화면 Settings › Network › DNS › DNS Resolver. 관리 API 로는 `x:DnsResolver` — 지금은 `@type: System`, 다른 유형의 필드 이름은 관리 화면에서 확인). - DNSSEC 검증도 되어 기동 로그의 "DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다. 2. **Spamhaus 이용 조건을 맞춘다.** - 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 **무료 DQS 키**(사용량 한도 안)를 발급받아 Spamhaus 두 항목(`STWT_RBL_SPAMHAUS_IP`, `STWT_DBL_SPAMHAUS_DOMAIN`)의 조회 주소를 DQS 주소로 바꾸거나, 조건이 맞지 않으면 두 항목을 끈다 (`x:SpamDnsblServer/set` 으로 `enable: false` → `ReloadSettings`). - 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다. 3. **조회 거부 응답의 처리를 시험한다 (미검증).** `127.255.255.254` 같은 "조회 거부" 응답을 "목록에 있음"으로 처리하면 정상 메일까지 스팸 점수를 받는다는 사용자 보고가 있다(Stalwart 공식 확인 아님). 운영 전에 외부에서 시험 메일을 받아 스팸 판정 기록에 Spamhaus 항목이 잘못 붙지 않는지 확인한다. ```bash # 리졸버가 Spamhaus 에 막혀 있는지 확인 — 127.0.0.2 는 항상 등록된 시험 주소다. # 정상이면 127.0.0.x 가 돌아오고, 127.255.255.254 면 그 리졸버는 막혀 있다. sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <리졸버 IP> ``` 참고: [Spamhaus — 공용 DNS 로 무료 목록 쓰기](https://www.spamhaus.com/resource-center/successfully-accessing-spamhauss-free-block-lists-using-a-public-dns/), [Spamhaus — KT 경유 조회 안내](https://submit.spamhaus.org/resources/query-the-legacy-dnsbls-via-korea-telecom/), [Stalwart DNSBL 문서](https://www.stalw.art/docs/spamfilter/dnsbl) ### 엔진 한도 받는 쪽 한도(`x:MtaInboundThrottle` — 관리 화면 Settings › MTA › Rates & Quotas › Inbound Rate Limits)는 엔진을 처음 설치하면 아래 두 개다. 한도를 넘은 바깥 메일 서버는 거절이 아니라 미루기(`452 4.4.5 Rate limit exceeded` 또는 연결 끊김)를 받고 나중에 다시 보낸다 — 늦게 받을 뿐 잃지는 않는다. | 한도 (엔진의 설명 · key) | 세는 것 | 기본값 | 넘으면 | setup | | --- | --- | --- | --- | --- | | `Sender address to recipient throttle` · `senderDomain`·`rcpt` | 같은 보낸 도메인 → 같은 받는 사람마다 받는 사람 수 (RCPT TO 때) | 시간당 25 | 그 받는 사람만 `452 4.4.5` | **인증 없는 세션에만** 건다 | | `Sender IP throttle` · `remoteIp` | 접속 IP 마다 연결 수 (연결할 때) | 초당 5 | 인사 없이 연결을 끊는다 | 그대로 둔다 | - **바꾸는 까닭** — 기본 조건(`match`)은 모든 세션(`{"else":"true"}`)이라 로그인한 직원의 발송에도 걸린다 (엔진 문서 [Rate limits](https://stalw.art/docs/mta/inbound/rate-limit/) "Default throttles"). 사내 메일은 보낸 도메인이 모두 같아서, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤의 웹메일 발송(JMAP 제출)은 그 받는 사람이 `452 4.4.5` 로 거절됐다 (2026-10-28 개발 엔진 — 로그 `smtp.rate-limit-exceeded … id = "MtaInboundThrottle with id …"`). 바깥 서버와 달리 **엔진은 그 메일을 다시 보내지도 반송하지도 않는다** — 제출 기록의 `deliveryStatus` 에만 남는다 (백엔드가 읽어 알린다 — [계약](../docs/mail/api-contract.md) 2절 "받는 사람별 결과"). - **setup 이 하는 일** — 설명과 key 로 찾아(id 는 설치마다 다르다) `match` 를 `{"match":{},"else":"is_empty(authenticated_as)"}` 로 맞추고 설정 리로드로 반영한다. 엔진 기본값이 스팸 필터를 인증 없는 세션에만 거는 식(`x:MtaStageData` 의 `enableSpamFilter`)과 같다. - `authenticated_as` 는 인증한 계정 이름이고, 인증 없는 세션(AUTH 없이 들어온 SMTP — 바깥 서버가 25 로 넣는 메일)이면 빈 값이다. 이 식(RCPT TO 단계)에서 쓸 수 있는 변수다 ([MtaInboundThrottle](https://stalw.art/docs/ref/object/mta-inbound-throttle/) · [MtaRcptToVariable](https://stalw.art/docs/ref/expression/variable/mta-rcpt-to-variable/) · [is_empty](https://stalw.art/docs/configuration/expressions/functions/)). 엔진은 저장할 때 식을 검사한다 — 모르는 변수·이 맥락에 없는 변수(HTTP 의 `path` 등)는 거부한다. - 웹메일 발송(JMAP 제출)은 엔진 안에서 그 직원 계정으로 인증된 SMTP 세션으로 돌아 빠진다. 메일 프로그램의 SMTP 제출(465, AUTH 뒤)도 같은 까닭으로 빠진다 (465 는 시험하지 않았다). - 찾지 못하면 만들지 않고 `[주의]` 만 낸다 — 지웠다면 그대로 두고, 이름을 바꿨다면 그 한도의 `match` 를 직접 본다. - 한도 값(`rate`)·켜짐(`enable`)은 건드리지 않는다. `match` 는 실행할 때마다 맞추므로, 받는 사람별 예외처럼 조건을 따로 둬야 하면 이 한도를 끄고(`enable: false`) 다른 설명으로 새 한도를 만든다 (setup 은 그 한도를 건드리지 않는다). - **바깥에서 들어오는 메일(인증 없는 SMTP)에는 그대로 건다** — 같은 바깥 도메인 → 한 사람 시간당 25통. - **보낸 IP 한도는 그대로 둔다** — 연결할 때 세는 한도라 웹메일 발송에는 처음부터 걸리지 않는다 (JMAP 제출은 엔진 안의 세션이라 연결 단계를 거치지 않는다 — 0.16.27 소스 [`smtp/src/inbound/spawn.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/smtp/src/inbound/spawn.rs)). 조건에 `authenticated_as` 를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(한도는 key 와 식이 쓰는 변수를 모두 아는 첫 단계에서 센다 — 같은 문서 "Evaluation stages", 소스 [`common/src/config/smtp/queue.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/common/src/config/smtp/queue.rs) 의 `parse_inbound`) 연결이 아니라 MAIL FROM 을 세게 되고, 바깥에서 몰려오는 연결은 더는 막지 못한다. - 확인 (2026-10-28, 0.16.27): 바꾼 뒤 웹메일과 같은 방식(대리 접근 JMAP 제출)으로 ara.kim → doyun.lee 30통(초당 40통 남짓)이 모두 `250 2.1.5 Queued` (엔진 로그에 한도 기록 없음), 인증 없는 SMTP 는 같은 바깥 도메인(`example.net`) → doyun.lee 26번째부터 `452 4.4.5` (로그 `id = "MtaInboundThrottle with id …"`, `limit = [25, 3600000ms]`). setup 을 이어서 두 번 돌리면 첫 번째는 `[변경]`, 두 번째는 `[유지]` 다(엔진이 돌려주는 `match` · `key` 모양이 그대로 비교된다 — 바뀌면 매번 `[변경]` 이 나온다). **임시 업로드 한도** (`x:Jmap` 싱글톤) — 엔진은 계정마다 1시간 동안 올린 것을 크기 합계와 개수로 센다. 첨부 올리기만이 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과 서명 그림이 든 발송(그림마다 하나)도 같은 몫을 쓴다. | 설정 | 뜻 | 기본값 | setup | | --- | --- | --- | --- | | `uploadQuota` | 계정마다 `uploadTtl` 동안 올린 크기 합계 | 50,000,000바이트 | 첨부 합계 한도의 20배보다 작으면 그 값으로 올린다 (기본 524,288,000바이트 = 25MB × 20) | | `maxUploadCount` | 같은 동안 올린 개수 | 1,000 | 그대로 둔다 | | `uploadTtl` | 올린 것이 남는 시간 = 위 둘을 세는 창 | 3,600,000ms (1시간) | 그대로 둔다 | | `maxUploadSize` | 한 번에 올리는 크기 | 50,000,000바이트 | 그대로 둔다 | - **넘으면** — 그 계정의 업로드가 모두 HTTP 429(`Quota exceeded` — "You have exceeded the blob upload quota of 1000 files or 50000000 bytes.")로, 올린 것이 만료될 때까지 막힌다. 백엔드는 크기 · 개수 어느 쪽이든 429 `REQ_001` 로 내리고 로그에 `{"reason":"mail-engine-rate-limit","call":"upload"}` 를 남긴다. 엔진 로그(info)에는 남지 않는다(크기 합계로 확인). - **바꾸는 까닭** (2026-10-29 개발 엔진, 0.16.27) — 한 계정으로 1,000개를 넘기자 그 계정의 필터 저장이 막혔다. 크기 합계도 그대로 걸려서 20MiB 두 개 뒤 세 번째가 429 였다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 · 필터 · 부재중 저장이 1시간까지 막힌다 ([계약](../docs/mail/api-contract.md) 2절 "첨부 업로드 한도"). - **setup 이 하는 일** — `uploadQuota` 가 첨부 합계 한도의 20배(한 시간에 합계 한도를 꽉 채운 메일 20통)보다 작으면 그 값으로 올리고 설정 리로드로 반영한다. - 첨부 합계 한도는 백엔드(`mail.settings.ts`)와 같게 센다 — `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(비우면 25)이되 파일 하나 한도 `MAIL_ATTACHMENT_MAX_SIZE_MB`(비우면 20)보다 작아지지 않고, MB = 1,048,576바이트. 둘 중 하나라도 양의 정수 표기(앞자리 0·부호·소수점 없이)가 아니면 아무것도 바꾸지 않고 멈춘다 — 백엔드 env 검증과 같은 규칙. - 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 0 이면 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고 `[주의]` 를 낸다. - **리로드가 있어야 반영된다** — 저장만 한 뒤에는 엔진이 옛 한도로 계속 429 를 냈고(메시지의 바이트 수도 옛 값), `ReloadSettings` 뒤에 올라갔다 (같은 날 확인). - 첨부 한도 env 를 바꾸면 backend 를 다시 띄우는 것과 함께 setup 도 다시 실행한다. - **개수 · 기간 · 한 번 크기는 그대로 둔다** - 개수 1,000 — 사람이 쓰는 양으로는 닿기 어렵다(한 시간에 첨부 · 필터 저장 · 서명 그림을 합쳐 1,000번). 계정이 털리거나 스크립트가 돌 때 엔진을 지키는 몫으로 남긴다. - 기간 1시간 — 늘리면 같은 한도를 더 긴 동안 세어 오히려 빡빡해지고, 줄이면 올린 첨부가 메일(임시 저장본)에 붙기 전에 만료될 수 있다(시험하지 않았다). - 한 번 크기 50,000,000바이트 — 파일당 첨부 한도(기본 20MB)보다 크다. `MAIL_ATTACHMENT_MAX_SIZE_MB` 를 48 이상(48MB = 50,331,648바이트)으로 올리면 엔진이 그 파일을 400 `urn:ietf:params:jmap:error:limit`(`maxSizeUpload`)로 거절하고 사용자에게는 `FILE_002` 로 보인다 — 그때는 이 값도 함께 올린다(관리 API `x:Jmap/set` → `ReloadSettings`). - 확인 (2026-10-29, 0.16.27): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] … 524,288,000바이트 … 전 50,000,000`, 두 번째는 `[유지]`. setup 뒤에는 이미 60MiB 를 올린 계정이 20MiB 를 더 올릴 수 있었다(바꾸기 전에는 429). `maxUploadCount` · `uploadTtl` · `maxUploadSize` 는 그대로였다. 운영 전에 정할 것: - **메일을 많이 받는 주소** — 같은 바깥 도메인에서 한 사람이 시간당 25통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다). 알림 메일(모니터링·결제·배포 알림 등)처럼 한 곳에서 같은 주소로 많이 오는 주소를 먼저 꼽고, 한도를 올리거나(관리 화면 — setup 은 `rate` 를 건드리지 않는다) 위처럼 따로 한도를 둔다. - **한 IP 초당 연결 5개** — 큰 메일 서비스가 여러 연결로 한꺼번에 배달하면 넘을 수 있다(인사 없이 끊기고 바깥 서버가 다시 시도한다 — 늦게 받는다). 메일 프로그램(465)으로 보내는 직원이 사무실 NAT 뒤에 많아도 한 IP 로 센다(연결은 인증 전에 센다 — 시험하지 않았다). 두 한도 모두 넘으면 로그 `smtp.rate-limit-exceeded` 가 남으니 운영을 시작한 뒤 이 로그로 정한다. - **직원마다 내보내는 양의 한도(`x:MtaOutboundThrottle`)는 비어 있다** — 이제 웹메일 발송에는 엔진의 받는 쪽 한도도 걸리지 않으므로, 남는 것은 백엔드의 요청 수 한도(루트 env `THROTTLE_*` — IP 당)뿐이다. 계정이 털리거나 잘못 쓰여 한꺼번에 많이 보내면 바깥 서버들의 한도에 걸려 서버 평판이 떨어진다. 보낸 사람마다 한도(key `sender` — 넘으면 거절이 아니라 대기열에서 미뤄진다, [Outbound rate limits](https://stalw.art/docs/mta/outbound/rate-limit/))를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다. - **직원마다 1시간 업로드 한도(크기 · 개수)** — setup 은 크기 합계만 첨부 합계 한도의 20배로 올리고 개수(1,000)는 그대로 둔다. 운영을 시작한 뒤 백엔드 로그 `"reason":"mail-engine-rate-limit","call":"upload"` 로 막힌 직원이 있는지 보고 크기 · 개수를 정한다(엔진 로그에는 남지 않는다). 올린 값은 setup 이 내리지 않는다. --- ## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저) 전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다. **MX 는 mailcow 그대로**이고, mailcow 의 Transport Map 이 시범 사용자 주소 하나만 새 서버로 넘긴다. 새 서버 쪽은 루트 env 두 개로 setup 이 맞춘다. - 예시 값: 시범 사용자 `owner@arasystem.it.kr`, 동료(mailcow) `colleague@arasystem.it.kr`, mailcow `mail.arasystem.it.kr`(`46.250.248.43`), 새 서버 IP `` - 근거: 2026-10-29 개발 엔진(0.16.27) 조사 · 시험과 mailcow(2026-10 판) 설정 파일 · 문서 조사. **mailcow 쪽 절차와 운영의 실제 흐름은 아직 해 보지 않았다** — "개발 확인"이라고 적은 것만 개발 엔진에서 봤다. ### 메일 흐름 | 메일 | 길 | 새 서버가 하는 일 | | --- | --- | --- | | 바깥 → 시범 사용자 | 바깥 → mailcow(MX · 스팸 검사) → Transport Map → mx1:25 | mailcow IP 에서 인증 없이 받는다 — 그 IP 는 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다(연결 IP 가 mailcow 라 늘 실패한다). DKIM 검증 · 스팸 판정은 그대로 | | 동료 → 시범 사용자 | SOGo · 메일 프로그램 → mailcow → Transport Map → mx1:25 | 사내 도메인 MAIL FROM 을 **mailcow IP 에서만** 받는다. 받는 쪽 한도 · 자동 차단에서 mailcow IP 를 뺀다 | | 시범 사용자 → 동료 | 웹메일 · 465 → mx1 → 발송 경로 `legacy` → mailcow:25 → 동료 사서함 | 새 서버에 없는 사내 주소도 받아서(`allowRelaying`) mailcow 로 넘긴다. DKIM 은 새 selector 로 서명 | | 새 서버가 만든 메일 → 동료 | 반송 · 필터 전달 · 부재중 → `legacy` → mailcow | 봉투 보낸 사람은 빈 봉투 `<>`(반송)이거나 시범 사용자 주소(전달 · 부재중) — mailcow 의 `reject_unlisted_sender` 를 통과한다 | | 시범 사용자 → 바깥 | mx1 → 받는 쪽 MX 직접 | 시범 앞과 같다 — mailcow 를 거치지 않는다(SPF 에 새 IP) | | 시범 사용자 → 시범 사용자 | mx1 안 | 로컬 | | 인증 없이 mx1:25 로 온, **새 서버에 없는** 사내 주소 (mailcow 포함 어디서든) | — | RCPT `550 5.1.2` — 받아 놓고 되돌려 보내지 않는다 (루프 1차 막기) | | mailcow 가 아닌 IP 에서 온 사내 도메인 MAIL FROM | — | `550 5.7.1` (위조 막기 — 빈 봉투 `<>` 는 그대로 받는다) | 루프는 두 겹으로 막는다 — ① RCPT 가드: 인증 없는 세션의 새 서버에 없는 사내 주소는 받지 않는다 ② 발송 전략: `legacy` 로는 **인증한 제출(웹메일 · 465)과 엔진이 만든 메일(필터 전달 · 부재중 · 반송 · 보고)만** 보낸다 — 인증 없이 들어온 메일은 로컬(배달 실패 → 보낸 사람에게 반송, 그 반송은 동료 앞이면 `legacy` 로). 엔진의 홉 한도(`x:MtaStageData` 의 `maxReceivedHeaders` 50 → `450 4.4.6`)가 마지막 막이다. **RCPT 가드 없이 `allowRelaying` 만 켜진 상태는 setup 이 만들지 않는다** — 켤 때는 가드가 걸린 뒤에 켜고, 켠 뒤 가드가 답하지 않으면 바로 다시 끄고, 끌 때는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 가드를 뺀다(2026-10-30 리뷰 H1 · M1). ### 사전 조건 - **운영 전환 체크리스트의 "1 결정 · 준비" · "2 서버 배포"를 마쳤다** — 새 서버가 mx1 로 떠 있고([운영 배포](#운영-배포)), setup 을 다시 돌리면 전부 `[유지]` 다. - **DNS 는 더하기만** (가비아) — [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 단계 1 · 3 · 4: `mx1` A → `` · PTR → `mx1.arasystem.it.kr`, setup 이 출력한 새 DKIM selector TXT 두 개(`v1-ed25519-…` · `v1-rsa-…`), SPF 에 `ip4:`. **DKIM TXT 는 첫 시범 실행 전에 퍼져 있어야 한다** — 운영 setup 이 이 호스트의 리졸버로 읽어, 없거나 키가 다르면 켜지 않고 멈춘다. **MX · DMARC · autodiscover 는 바꾸지 않는다.** mx1 에 AAAA 는 두지 않는다(아래 "주의" 의 IPv6). - 새 서버의 계정은 **시범 사용자 하나만**. 다른 직원 · 별칭 · 공용 주소 · `dmarc@` · `postmaster@` 는 만들지 않는다(아래 "주의"). - 새 서버 → mailcow **25** 가 열려 있다(setup 이 확인한다 — 운영은 25 만 받는다. 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다). mailcow 가 새 서버에 붙을 때의 IP 를 안다 — 보통 `mail.arasystem.it.kr` 의 A(`46.250.248.43`). - `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) · `MAIL_DIRECTORY_LOCAL_PART`(기본 `mail-directory`) · 게이트웨이 `MAIL_GATEWAY_USER` 의 @ 앞(예 `mail-gateway`)이 mailcow 의 실제 주소와 겹치지 않는다 — 셋 다 새 서버의 로컬 주소(메일을 받지 않는 시스템 계정)라, 시범 사용자가 mailcow 의 같은 이름 주소로 보내면 새 서버가 `not-found` 로 거절한다. - 기존 서버 IP(`MAIL_PILOT_LEGACY_IPS`)에 **사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다** — setup 이 같은 주소로 시범 표시 항목을 만들어야 한다(같은 주소의 항목은 둘일 수 없다). 있으면 setup 이 멈춘다 — 지운 뒤 다시 실행한다. - 시범 사용자의 mailcow 사서함은 **그대로 둔다** — Transport Map 은 mailcow 에 있는 받는 사람에게만 먹고, 새 서버가 owner@ 명의로 동료에게 보낼 때도 mailcow 의 `reject_unlisted_sender` 를 이 사서함으로 통과한다. ### env 와 실행 (새 서버) `.env.production` 에 두 값을 적는다 — **셸이 아니라 파일에** (운영은 env 파일에 없는 시범 값이 셸에 있으면 setup 이 멈춘다): ```bash MAIL_PILOT_LEGACY_HOST=mail.arasystem.it.kr # host · host:port · [host] · [host]:port (포트 기본 25 — 운영은 25 만) MAIL_PILOT_LEGACY_IPS=46.250.248.43 # mailcow 가 이 서버에 붙을 때의 공인 IPv4 · CIDR (콤마) ``` 그리고 [운영 배포](#운영-배포) 5) 와 같은 형태로(`MAIL_ADMIN_URL` · sudo 여부도 같게) setup 을 실행한다: ```bash node --env-file=.env.production mail/scripts/setup.mjs ``` | 값 | 받는 꼴 | 운영에서 막는 것 | | --- | --- | --- | | `MAIL_PILOT_LEGACY_HOST` | `host` · `host:port` · `[host]` · `[host]:port` — 포트는 1~65535, 앞자리 0 없이(기본 25). IPv6 는 받지 않는다 | 25 가 아닌 포트 · `mailpit` · 점 없는 이름(도커 내부 이름으로 풀릴 수 있다) · 사설 · 특수 IPv4(문서 · 시험 대역 `192.0.2.0/24` · `198.51.100.0/24` · `203.0.113.0/24` · `198.18.0.0/15` · `192.0.0.0/24` 포함) · `MAIL_HOSTNAME`(모든 환경). 이름이 사설 · 특수 주소로 풀려도 멈춘다(실행할 때 메일 컨테이너 안에서 푼다) | | `MAIL_PILOT_LEGACY_IPS` | IPv4 · CIDR(네트워크 주소로)을 콤마로 | 비어 있음(HOST 가 있으면 필수) · 사설 · 특수 대역(위와 같은 문서 · 시험 대역 포함) · `APP_NETWORK_SUBNET` 과 겹침 · `/24` 보다 넓음 · 이 서버 자신의 주소 | - 기존 서버 주소가 IPv4(예: `46.250.248.43`)면 발송 경로의 인증서 이름 확인을 끈다(`allowInvalidCerts: true` — 출력에 알린다). 이름(`mail.arasystem.it.kr`)이면 mailcow 의 공인 인증서로 확인한다 — **이름을 권한다**. - 개발은 셸에서 일회성으로 켜 본다(개발만 셸 값을 받는다) — 기존 서버 역은 Mailpit, 기존 서버 IP 는 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳 — 앱 네트워크 전체는 쓰지 않는다): `MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` → 값 없이 다시 돌리면 바로 끈다. ### setup 이 하는 일 먼저 확인한다 — 하나라도 어긋나면 **아무것도 바꾸지 않고** 멈춘다. 다만 **RCPT 가드 없이 `allowRelaying` 이 열려 있으면 그것부터 닫는다**(`[변경]`, 다시 읽어 확인) — 그다음 확인이 모두 맞으면 아래 차례(A → B → C)로 다시 켜고, 어긋나면 닫힌 채 멈춘다(머리글 "allowRelaying 만 닫고 중단합니다"). **닫지 못하면**(저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면) 다른 확인 · 설정(경로 · 전략 · C) 없이 멈추되(종료 1), 멈추기 전에 **RCPT 가드만 다시 건다** — RCPT 단계 식이 setup 의 것이거나 비었을 때만 가드 갈래를 저장하고 시스템 Sieve 스크립트를 켠 뒤 리로드하고 가드를 확인한다(2026-10-30 3차 리뷰 R1). 멈춤 글이 그 결과를 싣는다 — "대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(550 · Nms). relay 는 여전히 켜져 있습니다" 또는 "RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다" (운영자의 식이면 건드리지 않고 그렇다고 적는다). 어느 쪽이든 관리 화면에서 그 도메인의 relay 허용(`allowRelaying`)을 끈 뒤 다시 실행한다(N1). 멈출 때는 그 전에 쌓인 확인 실패도 함께 찍는다. | 확인 | 까닭 | | --- | --- | | `x:MtaStageRcpt` 의 `waitOnFail` 이 고정 값이고 4초 이상(기본 5초) · (`allowRelaying` 이 이미 켜져 있으면) 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 이다 | 가드(Sieve)의 거절은 곧바로(개발 0.2~1.1ms), 엔진 자체의 없는 주소 거절은 `waitOnFail` 뒤(개발 4,999~5,003ms)다 — 응답 시간으로 둘을 가른다. 가드가 답하지 않으면 relay 를 먼저 닫는다. `waitOnFail` 이 바뀌어 있어도 확인은 보낸다 — 250 이면(시간과 상관없이 가드가 없다) 닫고, 550 이면 relay 를 그대로 두고 `waitOnFail` 문제로 멈춘다(N3) | | 도메인에 catch-all 이 없다 | catch-all 은 `allowRelaying` 보다 먼저라 동료 앞 메일이 catch-all 함으로 간다 | | 하위 주소(`subAddressing`)가 기본(`Enabled` — `+`), 바깥 디렉터리(`directoryId`)가 없다 | RCPT 가드가 `+` 를 가정한다 · 엔진의 계정만 로컬로 본다 | | 기존 서버 주소가 엔진의 서버 이름(`x:SystemSettings` 의 `defaultHostname` · `MAIL_HOSTNAME`)과 다르고, 메일 컨테이너 안에서 풀어도 엔진 자신(메일 컨테이너 주소 · 이 호스트의 IPv4 · 서버 이름의 주소 · 루프백)이 아니며, 기존 서버의 인사(220)가 엔진의 서버 이름이 아니다 · (운영) 이름이 사설 · 특수 주소로 풀리지 않는다 | 자기 자신으로 보내면 메일이 돈다 — 인사 이름으로 보는 것은 DNS · NAT 와 상관없이 잡힌다 | | 메일 컨테이너 안에서 `curl -X NOOP smtp://<기존 서버>:<포트>/<서버 이름>` 이 220 · 250 — 운영은 `--ssl-reqd`(STARTTLS 와 인증서까지, IPv4 주소면 인증서는 보지 않는다) | 닿지 않으면 동료 앞 메일이 새 서버 대기열에 쌓인다. 메일은 보내지 않는다. **이미 켜진 시범의 같은 기존 서버**가 잠깐 닿지 않거나 이름이 풀리지 않으면 `[주의]` 만 하고 이어 간다(다른 정비를 막지 않는다) — 기존 서버를 바꾸는 실행이면 멈춘다 | | 발송 경로 이름 `legacy` · RCPT 단계 스크립트 · 운영의 발송 전략("사내 도메인은 로컬, 나머지는 경로 하나" 꼴)을 다른 설정이 쓰지 않는다 | setup 은 남의 설정을 덮어쓰지 않는다 — 외부 릴레이(SES 등)를 쓰고 있으면 그 경로가 바깥 발송으로 남는다 | | 기존 서버 IP 에 메일 컨테이너 주소가 없다 · (운영) 이 서버 자신의 주소가 없다 · (운영) 기존 서버 이름의 주소가 목록에 있는지(없으면 `[주의]`) | 위조 막기를 메일 컨테이너에서 확인한다 | | 기존 서버 IP 에 사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다 | setup 의 시범 표시 항목이 그 주소를 써야 한다(아래 "주인 표시") — 지운 뒤 다시 실행하면 setup 이 같은 주소로 만든다 | | 기존 서버 IP 로만 된 시범 꼴의 갈래(위조 막기 · 검사 예외 · "Sender IP throttle")가 표시 없이 있지 않다 · 한 칸에 setup 꼴의 갈래가 둘 이상이 아니다 | 같은 원칙(N4) — 운영자가 넣은 같은 IP 의 갈래는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고, 시범을 끝낼 때 함께 지워진다. 그 갈래를 지운 뒤(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다. 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 setup 의 갈래도 이렇게 보인다 — 그때는 같은 설명으로 표시를 다시 만들어도 된다. 둘 이상이면 하나만 남기고 지운다. 다른 IP 의 같은 꼴 갈래는 막지 않고 건드리지도 않는다 | | (운영) 활성 DKIM selector 의 TXT 가 DNS 에 있고 공개 키가 엔진의 키와 같다 — 이 호스트의 리졸버로 읽는다 | 다르면 mailcow 가 owner 의 메일을 위조로 거절한다(`R_DKIM_REJECT` → `SPOOFED_UNAUTH` — 아래 mailcow 절차 4). 없으면 DMARC 가 SPF 정렬에만 기대게 되어(`R_DKIM_PERMFAIL` 은 0점), 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일이 거절된다. **처음 켤 때 다르거나 TXT 가 확실히 없으면**(ENOTFOUND · ENODATA) 멈춘다 — relay 를 켜지 않는다, TXT 를 넣고 퍼진 뒤 다시(N8). 이미 켜져 있으면 실패로 보고만(닫지 않는다). 리졸버 오류 · 엔진이 공개 키를 주지 않으면 `[주의]`(견줄 수 없음 — 운영에서는 아직 돌려 보지 않았다) | 그다음 순서대로 맞춘다 — **막는 것부터**: | 차례 | 엔진 설정 | 값 | | --- | --- | --- | | A | `x:AllowedIp` | 기존 서버 IP — 설명 `[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP` (→ `ReloadBlockedIps`). **시범 설정의 주인 표시**라 A 의 맨 앞에서 만든다 — 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다(N5). 바뀐 IP 의 옛 항목은 식을 모두 고친 뒤에 지운다 | | A | `x:MtaStageRcpt` 의 `script` | 시스템 계정 갈래 + **RCPT 가드** — 인증 없는 세션의 새 서버에 없는 사내 주소(+태그 꼴 포함)는 시스템 계정과 같은 스크립트로 `550 5.1.2` | | A | `x:MtaInboundThrottle` "Sender address to recipient throttle" | 인증 없는 세션 중 기존 서버 IP 가 아닌 것만 센다 | | A | `x:MtaStageMail` 의 `isSenderAllowed` | 맨 앞 갈래 — 인증 없는 세션의 사내 도메인 MAIL FROM 이 기존 서버 IP 가 아니면 false → `550 5.7.1` | | A | `x:SenderAuth` 의 `spfEhloVerify` · `spfFromVerify` · `reverseIpVerify` · `dmarcVerify` | 맨 앞 갈래 — 기존 서버 IP 면 `disable` (나머지 갈래는 그대로) | | A | `x:MtaInboundThrottle` "Sender IP throttle" | 기존 서버 IP 를 세지 않는다(찾지 못하거나 엔진 기본 · setup 의 값이 아니면 `[주의]`) | | | `ReloadSettings` | A 가 걸린다 | | | **켜기 전 확인** | 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 — relay 가 꺼져 있어도 같은 판별이다(RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돈다 · 개발 실측 0.3~0.7ms). 아니면 B · C 없이 종료 1(N2) | | B | `x:MtaRoute` Relay `legacy` | `:<포트>`, STARTTLS 가 있으면 쓴다 — 설명 `시범 공존 — … [setup.mjs]` | | B | `x:MtaOutboundStrategy` 의 `route` | 0 새 서버의 주소 → 로컬 · 1 그 밖의 사내 주소 + `source` 가 `authenticated` · `autogenerated` · `dsn` · `report` → `legacy` · 2 그 밖의 사내 주소 → 로컬 · 나머지 → 시범 앞의 값(운영 `mx`, 개발 `mailpit`) | | | `ReloadSettings` | B 가 걸린다 | | C | `x:Domain` 의 `allowRelaying` | `true` — **저장 즉시 먹는다**(리로드가 필요 없다). 그래서 A · B 가 걸리고 가드를 확인한 뒤에 둔다 | - 갈래 1 의 `source` 는 **허용 목록**이다(2026-10-30 리뷰 L1). 0.16.27 의 값은 `authenticated`(웹메일 · SMTP 인증 제출) · `unauthenticated` · `dmarc_pass`(인증 없는 수신) · `dsn`(반송) · `report`(보고) · `autogenerated`(필터 전달 · 부재중 등 Sieve) · `unknown`(표시가 없는 대기열 메일)이다(소스 `smtp/src/queue/mod.rs`). 빠진 셋은 legacy 로 가지 않는다 — 인증 없는 수신은 RCPT 가드가 먼저 막고, `unknown` 은 0.16.27 이 새로 만드는 메일에는 붙지 않는다(옛 판에서 남은 대기열 메일뿐). **엔진 마이너를 올리면 이 목록을 다시 본다.** - **주인 표시** — 식(위조 막기 · 검사 예외 · "Sender IP throttle")에는 설명 칸이 없어, setup 은 **시범 표시가 있는 자동 차단 예외의 주소와 같은 IP 의 갈래만** 자기 것으로 본다. 표시가 없으면 같은 꼴이어도 운영자 것으로 보고 켤 때도 끌 때도 건드리지 않는다(시범 설정이 있을 때만 `[주의]` 로 알린다). 뺄 때는 맨 앞만이 아니라 모든 갈래에서 찾는다. 기존 서버 IP 로만 된 운영자 갈래는 켜기 전에 멈추고(위 확인 표), 한 칸에 setup 꼴의 갈래가 둘 이상이면(setup 은 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것) 켤 때는 멈추고 끌 때는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의]`) — 켤 때도 끌 때도 운영자 갈래를 합치거나 지우지 않는다(N4). - **받는 사람 한도**("Sender address to recipient throttle")는 표시로 가리지 않는다 — 8단계부터 setup 이 match 를 통째로 정하는 칸이라, 켤 때는 기존 서버 IP 를 뺀 식으로, 끌 때는 "인증 없는 세션에만"으로 되돌린다. 표시를 보는 것은 끄는 실행이 relay 가 켜져 있어 시범 설정을 남길 때 그 예외가 setup 의 것인지 가르는 데뿐이다(N5). 확인 — 메일 컨테이너 안에서 자기 네트워크 주소의 25 로, RCPT 까지만 (메일은 보내지 않는다): - **켜기 전**(A 의 리로드 뒤 · B 앞): 인증 없는 세션 `RCPT TO:@도메인>` → **550 이고 2초 안** (RCPT 가드, 보낸 사람은 빈 봉투). 아니면 relay 를 켜지 않고 종료 코드 1(2차 리뷰 N2). 이미 켜진 시범이면 relay 가 켜진 채로 보고, 가드가 답하지 않으면 곧바로 끈다. - **켠 뒤**(C 바로 뒤): 같은 확인. 아니면 **`allowRelaying` 을 곧바로 다시 끄고**(다시 읽어 확인) 종료 코드 1 — 가드 · 경로는 남겨 두어 원인을 고친 뒤 같은 명령으로 다시 켠다(2026-10-30 리뷰 M1). 끄지 못하면 "끄려 했지만 끄지 못했습니다 — 지금 가드 없이 열려 있을 수 있습니다"로 알린다(N1). - 인증 없는 세션 `MAIL FROM:@도메인>` → `550 5.7.1` (메일 컨테이너는 기존 서버 IP 가 아니다) - [개발] 인증한 세션(465, 테스트 직원) `RCPT TO:` → 250 뒤 RSET — 운영은 직원 비밀번호가 없어 하지 않는다(아래 mailcow 절차 4 의 실제 발송으로 본다). 비밀번호는 `docker exec` 의 환경변수가 아니라 표준 입력으로 넘긴다 다시 돌리면 전부 `[유지]`, 값이 바뀌면 그 칸만 `[변경]` 이다. 끌 때는 거꾸로 — `allowRelaying` 을 먼저 끄고(저장 즉시) **엔진에서 다시 읽어 꺼진 것을 확인한 뒤에만** 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 → 리로드 → `legacy` 경로(발송 전략을 되돌린 리로드 뒤 — 걸려 있는 전략이 지워진 경로를 고르는 틈이 없게, L5). **운영은 값을 비우는 것만으로는 끄지 않는다** — env 파일에서 값이 빠진 채 돌리면 "값을 다시 넣거나 `--end-pilot`" 으로 멈춘다(아무것도 바꾸지 않는다). 개발은 바로 끈다. `allowRelaying` 은 켜져 있는데 setup 의 `legacy` 경로가 없으면(관리 화면에서 경로만 지웠을 수도 있다) 누가 켠 것인지 알 수 없어 끄지 않고 실패로 보고하며, **그동안 RCPT 가드 · 위조 막기 · 예외 · 발송 전략도 남긴다**(가드 없는 relay 를 setup 이 만들지 않는다). `allowRelaying` 을 끈 뒤 다시 돌리면 그때 뺀다. **다른 도메인**이 `allowRelaying` 을 켜 두었으면 setup 은 그 도메인을 끄지 않고 시범 설정을 남긴다(RCPT 가드는 모든 사내 도메인을 덮는다) — "다른 도메인 … 이 켜 두어"로 알린다. 시범 설정이 하나도 없는데 `allowRelaying` 만 켜져 있으면 바꾸지 않고 실패로 알린다(요약 `시범 설정 없음 · allowRelaying 켜짐` — 가드 없이 열린 relay 다). 개발 확인 (2026-10-29, 0.16.27 — 기존 서버 역 Mailpit, 기존 서버 IP 역 백엔드 컨테이너): - 처음 실행은 A · B · C 가 `[생성]` · `[변경]`, 다시 돌리면 전부 `[유지]` · 종료 코드 0. 기존 서버 IP 를 둘로 늘렸다 줄이면 그 칸만 `[변경]` · `[삭제]`(여러 IP 의 식도 엔진이 받았다). - 웹메일 → 새 서버에 없는 사내 주소는 `legacy` 경로로 한 번 배달(엔진 로그 `delivery.delivered … hostname = ""`, Mailpit 의 id 가 같다), 있는 계정은 로컬, 바깥 주소는 개발 외부 경로로, 시스템 계정은 여전히 `not-found`. - 인증 없는 25 의 없는 사내 주소(+태그 포함) 550 · 있는 계정(+태그 · 대문자 포함) 250, 메일 컨테이너의 사내 도메인 MAIL FROM `550 5.7.1`, 기존 서버 IP 의 사내 도메인 MAIL FROM 은 받아서 배달(`Authentication-Results: … none`). - 기존 서버 IP 가 새 서버에 없는 주소를 되돌려 보내면 RCPT 550 — Mailpit 에 되돌아 배달된 것이 없고 대기열이 빈다. - 기존 서버 주소가 엔진 자신(`mail:25`) · `MAIL_HOSTNAME` · 닿지 않는 주소면 아무것도 바꾸지 않고 멈췄다. 값 없이 다시 돌리면 엔진 설정이 시범 앞과 같아졌다(스냅숏 비교) — 웹메일의 없는 사내 주소는 다시 `not-found`. 개발 확인 (2026-10-30 리뷰 수정 뒤, 기존 서버 역 `arasystem-mailpit:1025` — 엔진 로그의 `hostname` 으로 개발 외부 경로 `mailpit` 과 가른다): - **허용 목록 경로** — 465 인증 제출(`queue.authenticated-message-queued`) · doyun 의 필터 전달 · 부재중 응답(`queue.autogenerated-queued`)이 새 서버에 없는 사내 주소로 `legacy` 에 한 번씩. 부재중은 개발의 `.test` 도메인 탓에 스팸 점수가 높아(7.5) 시험 동안만 기존 서버 IP 를 스팸 필터에서 빼고 되돌렸다. RCPT 가드 갈래만 잠시 뺀 엔진에서 기존 서버 IP 가 보낸 인증 없는 메일은 `legacy` 로 가지 않고 로컬 실패(`delivery.dsn-perm-fail`) → 그 반송은 동료(새 서버에 없는 사내 주소) 앞이라 `legacy` 로 한 통. - **닫힌 쪽(M1)** — 위처럼 가드 갈래가 빠진 채 relay 가 열린 엔진 · 시스템 Sieve 스크립트를 끈 엔진(가드 식은 있지만 RCPT 가 250)에서 시범 값으로 돌리자, setup 이 먼저 `allowRelaying` 을 끄고(`[변경] … RCPT 가드 없이 allowRelaying 이 켜져 있어 …` · `… RCPT 가드가 답하지 않아(… 250 2.1.5 OK) …`) 가드 · 스크립트를 되살린 뒤 C 로 다시 켰다 — 켠 뒤 확인 1ms 안팎, 종료 코드 0. - **이미 켜진 시범(L2)** — 기존 서버 역을 멈춘(`docker pause`) 채 같은 값으로 돌리면 `[주의] … 닿지 않습니다 … 이미 켜진 시범이라 이어 간다` · 종료 코드 0. 다른 기존 서버(닿지 않는 포트)로 바꾸는 실행은 멈췄고 엔진 스냅숏이 그대로였다. - **H1** — 시범이 켜진 엔진에서 `legacy` 경로만 지우고 값 없이 돌리면 `[유지] … allowRelaying 이 켜져 있어 끝내지 않는다` · 가드 그대로(RCPT 550) · 종료 코드 1. `allowRelaying` 을 손으로 끈 뒤 다시 돌리자 나머지를 뺐고, 끝난 엔진 스냅숏이 시험 전과 같았다. - 켬 → 같은 값으로 다시(전부 `[유지]`) → 값 없이(끄기 — `[삭제] … legacy` 가 리로드 뒤) → 다시 값 없이(전부 `[유지]`) 뒤 엔진 스냅숏이 시험 전과 같았다. 개발 확인 (2026-10-30 2차 리뷰 수정 뒤): - **켜기 전 확인(N2)** — 처음 켤 때 `[생성] 자동 차단 예외`(표시)가 A 의 맨 앞에 나오고, A 의 리로드 뒤 relay 가 꺼진 채 `[확인] … 켜기 전 확인(allowRelaying 꺼짐): … 곧바로 거절 (… 0.4ms)` 다음에 C. 시범이 켜진 엔진에서 relay 만 잠시 끄고 잰 RCPT 5번 0.3~0.7ms(켠 채로는 0.4~0.7ms) — relay 와 상관없이 가드의 빠른 550 이다. - **다시 읽기(N1)** — `x:Domain/set`(allowRelaying false · true) 바로 뒤 `x:Domain/get` 100번 모두 저장한 값과 같았다. - **waitOnFail 을 바꾼 엔진(N3)** — `waitOnFail` 1s + 가드가 먹는 엔진: `[주의] RCPT 가드가 550 으로 답합니다 …` 뒤 relay 를 그대로 두고 `waitOnFail` 문제로 멈춤(종료 1, "켜진 채로 두었습니다"). 거기서 시스템 Sieve 를 끄면 확인이 250 → `[변경] … 껐다` · "allowRelaying 만 닫고 중단합니다"(종료 1). `waitOnFail` 을 되돌리고 다시 돌리자 Sieve 를 되살리고 다시 켰다(종료 0). - **운영자 갈래(N4)** — 켜기 전 엔진의 `spfFromVerify` 에 기존 서버 IP 갈래를 손으로 넣자 사전 확인에서 멈췄고(종료 1) 엔진이 그대로였다. 켜진 엔진의 `dmarcVerify` 에 setup 과 같은 글의 갈래를 하나 더 넣자 "둘 이상"으로 멈췄고(종료 1) 바뀐 것이 없었다. - 켬 → 같은 값으로 다시(바뀐 줄 없음) → 값 없이(끄기) → 다시 값 없이 뒤, 그리고 위 시험들을 되돌린 뒤 엔진 스냅숏이 시험 전과 같았다. ### mailcow 쪽 절차 (mailcow 호스트 `/opt/mailcow-dockerized` — 아직 해 보지 않았다) mailcow 2026-10(master) 의 설정 파일 · 화면 · 문서로 정리했다. 운영 중인 mailcow 가 더 오래됐으면 화면 이름이 다를 수 있다 — 0 의 명령으로 먼저 대조한다. ```bash cd /opt/mailcow-dockerized alias dcx='docker compose exec postfix-mailcow' ``` 0. **읽기만 하는 점검** — 로컬로 새는 길이 없는지: ```bash grep -n -A3 '^transport_maps' data/conf/postfix/main.cf # custom_transport.pcre, local_transport, mysql_relay_ne.cf, mysql_transport_maps.cf 차례 dcx postconf -P 'smtpd/pass/smtpd_sender_restrictions' # permit_mynetworks,reject_unlisted_sender,reject_unknown_sender_domain dcx cat /opt/postfix/conf/custom_transport.pcre # 비어 있거나 owner@ · 도메인과 무관해야 dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_virtual_mailbox_maps.cf # 사서함이 받는 사람으로 살아 있다 docker compose exec acme-mailcow dig +short A mx1.arasystem.it.kr @172.22.1.254 # docker compose exec acme-mailcow dig +short AAAA mx1.arasystem.it.kr @172.22.1.254 # 비어 있어야 ``` 도메인이 백업 MX · "Relay all recipients" 가 아닌지, owner 앞 Sync job 이 없는지(E-Mail → Configuration → Synchronizations)도 본다. 1. **postscreen 예외** — `data/conf/postfix/custom_postscreen_whitelist.cidr` 에 `/32 permit` (재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. 2. **Fail2ban 예외** — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에 `` (imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). 3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다). 4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다: ```bash docker compose logs --since 15m postfix-mailcow | grep -E 'from=|\[\]' # 좋음: client=mx1.arasystem.it.kr[] … 다음 줄에 relay=dovecot[…]:24 status=sent # 나쁨: NOQUEUE: reject … Sender address rejected (mailcow 에 없는 봉투 발신자) · milter-reject … 5.7.1 (rspamd 거절) docker compose logs --since 15m rspamd-mailcow | grep -E 'SPOOFED_UNAUTH|R_SPF_FAIL|R_DKIM_REJECT|DMARC_POLICY_SOFTFAIL' # 없어야 ``` 동료가 받은 메일 머리글이 `Authentication-Results … spf=pass dkim=pass (header.s=v1-…) dmarc=pass` 이고 `X-Spamd-Result` 에 `SPOOFED_UNAUTH` 가 없어야 한다. SPF · DKIM · DMARC 가운데 하나라도 실패하면 mailcow 가 위조로 보고(+50점) **거절**한다 — DMARC 가 `p=none` 이어도 막아 주지 않는다. DKIM 은 키가 있는데 서명이 맞지 않을 때(`R_DKIM_REJECT`)다 — selector 의 키를 찾지 못하는 것(`R_DKIM_PERMFAIL`)은 0점이라 SPF 정렬로 DMARC 가 통과하면 받는다. 없는 주소(`colleaguee@`)로 보내면 mailcow 가 550 → 새 서버가 owner 에게 반송한다(루프 없음). 5. **imapsync 1차** — mailcow → 새 서버, owner 만 ([imapsync](#imapsync)). 6. **Transport Map 추가** — System → Configuration → Routing → Transport Maps → "Add transport": | 칸 | 값 | | --- | --- | | Destination | `owner@arasystem.it.kr` (`owner+태그@` 도 맞는다) | | Next hop | `[mx1.arasystem.it.kr]:25` — 대괄호는 MX 조회를 끈다. **앞에 `smtp:` 를 쓰지 않는다**(mailcow 가 붙인다) | | Username / Password | 비움 | | "Destination is a regular expression to match against MX name" | **끔** | | Active | 켬 | 목록의 **Test** 버튼(보내는 주소 `colleague@arasystem.it.kr`, 받는 주소 `owner@arasystem.it.kr` — PHP 가 next hop 에 직접 시험 메일을 보낸다)으로 새 서버가 mailcow IP 의 사내 도메인 메일을 받는지 본다. 그다음: ```bash dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # smtp_via_transport_maps:[mx1.arasystem.it.kr]:25 dcx postmap -q colleague@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # (출력 없음) dcx postfix reload # 바로 반영 (안 해도 30초~1분) # 실제 발송 — 바깥(Gmail) · SOGo 동료 · owner 가 든 별칭 · owner+test@ 에서 각 한 통 docker compose logs --since 15m postfix-mailcow | grep -E 'to=' # relay=mx1.arasystem.it.kr[]:25 … status=sent ``` 7. **마지막 imapsync**(차이분). 8. **owner 의 메일 프로그램을 mx1 로** — IMAP `mx1.arasystem.it.kr:993` · SMTP `:465`, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다. 9. **mailcow 의 옛 사서함 접근 줄이기** — 사서함 편집의 Allowed protocols(IMAP · POP3 · SMTP · Sieve · ActiveSync · CalDAV/CardDAV)와 SOGo 접근을 줄이고 앱 비밀번호를 지운 뒤, 마지막 동기화가 끝나면 Active 를 **"Disallow login (incoming mail is still accepted)"** 로. **지우거나 "Inactive" 로 두지 않는다** — 그러면 owner 앞 메일이 모두 550 이고, 새 서버에서 owner 가 동료에게 보내는 메일도 `reject_unlisted_sender` 로 거절된다. 10. 며칠 본다 — mailcow Queue Manager(E-Mail → Queue Manager), rspamd 기록, 반송, DMARC 보고서. ### 지켜볼 것 새 서버에서 owner 가 받은 메일의 `X-Spam-Score` · `X-Spam-Result` 를 **세 통** 본다 — 바깥(Gmail) 한 통, 동료(SOGo) 한 통, DMARC 가 엄격한(`p=reject`) 바깥 도메인 한 통. mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Results: … none`) `DMARC_NA` · `AUTH_NA` 가 각 1점 붙는다. 스팸 문턱은 5점이다. - 개발에서는 점수가 늘 높다 — 기존 서버 IP 로 넘긴 동료 흉내 메일이 7.5점(`.test` 도메인 탓 `FROM_INVALID` · `ENV_FROM_INVALID` 각 2점, 시험 글의 제목 인코딩 1점). 운영 값으로 판단한다. 조사 때 p=reject 바깥 발신(서명 없음)을 넘기자 이 설정으로 3.5점(받은편지함), 검사 예외가 없을 때는 11.0점(스팸함)이었다. - mailcow 가 8~15점에 붙이는 `X-Spam-Flag: YES` 는 새 서버 규칙(`SPAM_FLAG` +5)이 받아 스팸함으로 간다. owner 의 mailcow Sieve · 부재중 · 전달 규칙은 더 돌지 않는다 — 새 서버에 다시 만든다. - 동료가 받은 owner 의 메일 — `X-Spamd-Result` 에 `SPOOFED_UNAUTH` · `R_DKIM_REJECT` 가 없어야 한다(위 4). ### 되돌리기 (시범을 멈추고 mailcow 로) 1. mailcow — Transport Maps 의 owner@ 행 → Quick actions → **Deactivate**(행을 남긴다) → `dcx postfix reload`. 30초~1분 안에 다시 mailcow 사서함으로 배달된다. `dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf` 가 비어야 한다. 2. 큐에 남은 owner 앞 메일 — `dcx postqueue -p` 로 보고 `dcx postqueue -f` (다시 경로를 받아 로컬로). 3. **시범 동안 새 서버로 들어온 메일 · 보낸 메일을 mailcow 로 되복사** — 반대 방향 imapsync(새 서버 → mailcow) 또는 mailcow 의 Synchronizations 에 mx1 IMAP 을 원본으로. 4. owner 의 mailcow 접근 되살리기 — Active 를 Active 로, Allowed protocols · SOGo · 앱 비밀번호, 메일 프로그램을 mailcow 로. 5. **새 서버 대기열에 동료 앞 메일이 남지 않았는지** 본다 — 관리 화면의 메일 대기열(Queue) 또는 관리 API `x:QueuedMessage/query` · `/get`. 시범을 끄면 발송 전략이 "사내 도메인 → 로컬"로 돌아가, 아직 mailcow 로 못 간 동료 앞 메일(mailcow 일시 장애 · 지연 — 다시 시도는 3일, 동료 앞 반송은 10번까지)은 다음 시도에서 로컬 배달 실패 → owner 에게 반송된다. 남아 있으면 mailcow 가 받을 수 있게 된 뒤 다 나갈 때까지 기다리거나(대기열에서 바로 다시 시도), 기다릴 수 없으면 그 목록(받는 사람 · 제목)을 적어 두고 끈 뒤 owner 가 mailcow 에서 다시 보낸다. 6. 새 서버 — **Transport Map 을 끈 뒤에** 시범을 끝낸다: 두 값을 비우고 `--end-pilot`(아래). 거꾸로 하면 그사이 owner 가 동료에게 보낸 메일이 `not-found` 로 거절된다. 7. 넣었던 것 정리 — postscreen 예외 줄, Fail2ban 예외. DNS 의 `ip4:` · 새 DKIM TXT 는 남겨도 해가 없다. ### 시범 끝내기 — 전면 전환 [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)대로 모두의 계정을 새 서버에 만들고(단계 2) MX 를 mx1 로 바꾼다(단계 6). 그 뒤: 1. MX 전파(TTL)가 끝나 mailcow 에 새 수신이 없어지면 mailcow 의 Transport Map 행을 지운다(Remove) — 그때까지 mailcow 로 온 owner 앞 메일은 그대로 새 서버로 넘어온다. 2. 새 서버 — `.env.production` 의 두 값을 비우고 `--end-pilot` 으로 실행한다: ```bash node --env-file=.env.production mail/scripts/setup.mjs --end-pilot ``` `allowRelaying`(꺼진 것을 다시 읽어 확인) → 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 차례로 지우고 리로드한 뒤 `legacy` 경로를 지운다. 다음 실행부터는 `--end-pilot` 없이 `[유지] 시범 공존 — 꺼짐` 이다. - 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 계정 만들기 → imapsync → MX 바꾸기를 짧게 이어서 한다. - 위조 막기(인증 없는 사내 도메인 MAIL FROM 은 mailcow IP 에서만)도 함께 빠진다. 전환 뒤에도 남길지는 정할 일이다(원래 봉투를 그대로 넘기는 바깥 전달 서비스가 막힌다). ### 주의 - **시범 기간에는 동료 계정을 새 서버에 만들지 않는다** — 새 서버에 있는 주소는 늘 로컬 배달이라, 만든 순간부터 owner 가 그 동료에게 보낸 메일이 새 서버에 쌓이고 mailcow 로 가지 않는다(동료는 mailcow 에서 읽는데). `postmaster@` · `dmarc@` · 공용 주소 · 별칭도 같다. 이주 기간에 미리 만들어야 하면 발송 전략을 "옮긴 사람 목록" 꼴로 바꿔야 한다 — setup 은 지원하지 않는다. - **DKIM** — 동료 앞 메일은 새 selector 두 개(`v1-ed25519-…` · `v1-rsa-…`)로 서명된다. 키가 다르면 mailcow 가 위조로 거절하고, TXT 가 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다(위 mailcow 절차 4). setup 이 활성 selector 를 알리고, 운영에서는 **켜기 전에** DNS 와 견준다(처음 켤 때 키가 다르거나 TXT 가 확실히 없으면 `allowRelaying` 을 켜지 않고 멈춘다 — **TXT 는 첫 시범 실행 전에 넣고 퍼진 것을 본다**. 리졸버 오류는 `[주의]`). DNS 를 손으로 관리하는 동안 엔진은 키를 스스로 바꾸지 않는다 — 다만 키를 지우거나 알고리즘을 더하면 새 키를 **DNS 에 넣기 전에** 쓴다(전환 절차 "주의할 레코드" 의 DKIM 자동 교체). - **SOGo 일정 초대** — mailcow 사용자가 SOGo 에서 owner 를 초대하면 메일을 거치지 않고 owner 의 **옛 SOGo 달력**에 바로 들어간다(알림 메일만 새 서버로 온다 — SOGo 버그 트래커의 관리자 답, 확인하지 않았다). - **autodiscover** — 그대로 mailcow 를 가리킨다. owner 의 프로그램이 자동 설정을 쓰면 mailcow(새 메일이 없는 옛 함)에 잡힌다 — 손으로 설정한다. - **크기** — mailcow 와 새 서버 모두 메일 한 통 100MB(104,857,600바이트 — 새 서버는 엔진 기본값)다. 웹메일 첨부는 따로 `MAIL_ATTACHMENT_*` 한도다. - **IPv6 · SPF** — mx1 에 AAAA 를 두지 않는다. mailcow 가 IPv6 로 붙으면 IPv4 예외가 모두 빗나가 동료 메일이 `550 5.7.1` 이다(mailcow 는 `inet_protocols = all`). 새 서버 → mailcow 는 IPv4 로만 간다(컨테이너에 IPv6 가 없고 Relay 경로는 IPv4 먼저) — SPF 는 `ip4:` 만 더한다. - **EHLO 이름** — 새 서버가 mailcow 에 붙을 때의 이름은 엔진의 서버 이름(`defaultHostname` = bootstrap 때의 `MAIL_HOSTNAME` = mx1)이다. `mail.arasystem.it.kr` 이면 mailcow 가 "loops back to myself" 로 멈춘다 — setup 은 기존 서버 주소가 서버 이름과 같으면 멈춘다. - **사내 오타 주소** — 새 서버에 없는 사내 주소는 모두 "받음"이라, 웹메일의 즉시 `not-found`(받는 사람별 결과) 대신 mailcow 의 550 → **몇 초 뒤 반송(DSN)이 받은편지함에** 온다. 제출 기록에도 남지 않는다(대기열에서 빠지면 `250 2.1.5 Queued` 로 돌아간다). - **사내 목록** — 받는 사람 추천 · 주소록의 "사내"는 새 서버의 계정 목록이라 **시범 사용자 혼자**만 보인다. 동료는 개인 주소록 · 최근 보낸 사람으로만 나온다(시범 동안 백엔드는 바꾸지 않는다). - **주소 추측 막기가 약하다** — RCPT 가드의 거절(Sieve)은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번이면 끊기 · 자동 차단 셈)을 거치지 않는다(개발: RCPT 한 번에 가드 0.2~1.1ms, 엔진 자체 거절 4,999~5,003ms — setup 은 이 차이로 가드가 걸렸는지 본다). 시범 중 MX 는 mailcow 라 mx1:25 에 바로 붙는 것은 스캐너뿐이다. 시스템 계정 거절도 같은 성질이다. - **mailcow 가 멈추면** — 동료 앞 메일은 새 서버 대기열(`local` 일정 — 3일)에서 다시 시도하다 반송되고, owner 앞 메일은 mailcow 큐(4시간 뒤 지연 안내, 5일 뒤 반송)에서 기다린다. - **DMARC 보고** — 엔진은 자기 도메인(arasystem.it.kr) 메일로는 DMARC · DKIM 실패 보고를 만들지 않는다(소스 [`smtp/src/reporting/dmarc.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/smtp/src/reporting/dmarc.rs) 의 `is_local_report_domain`) — `rua=dmarc@` 로 보고를 보내 mailcow 의 `reject_unlisted_sender` 에 걸리는 일은 없다. 다른 도메인에 대한 집계 보고(기본 하루 한 번)는 그 도메인의 `rua` 로 새 서버가 직접 보낸다(봉투 `noreply-dmarc@arasystem.it.kr`). mailcow IP 의 DMARC 검사를 끄므로 mailcow 를 거친 메일로는 보고 거리가 생기지 않는다. - mailcow 의 공용 주소 · 별칭(`all@` 등)으로 보내면 mailcow 가 펼치고 owner 몫은 새 서버로 되돌아온다(자기 사본 — 확인하지 않았다). --- ## arasystem.it.kr 전환 절차 (mailcow → Stalwart) **전면 전환 전에 한 사람이 먼저 새 서버를 쓴다** — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)(MX 는 mailcow 그대로, 아래 단계 1 · 3 · 4 의 DNS 만 더한다). 시범을 거친 뒤 아래 표대로 모두를 옮기고, MX 를 바꾼 다음 시범 설정을 끝낸다(`--end-pilot`). 현재 (DNS: 가비아): | 레코드 | 값 | | --- | --- | | `arasystem.it.kr` MX | `10 mail.arasystem.it.kr` (mailcow) | | `arasystem.it.kr` TXT (SPF) | `v=spf1 mx ip4:46.250.248.43 -all` | | `_dmarc.arasystem.it.kr` TXT | `v=DMARC1; p=none; rua=mailto:dmarc@arasystem.it.kr` | | `dkim._domainkey.arasystem.it.kr` TXT | mailcow DKIM (selector `dkim`) | 원칙: - **메일박스를 먼저 만들고 MX 를 바꾼다.** 거꾸로면 없는 주소로 들어온 메일이 반송(550)된다. - 새 엔진은 **새 호스트명(`mx1.arasystem.it.kr`)** 으로 띄우고 **MX 대상만** 바꾼다. `mail.arasystem.it.kr` 은 끝까지 mailcow 를 가리키므로, MX 만 되돌리면 롤백된다. - SPF 가 `-all` 이다. **새 서버 IP 를 SPF 에 넣기 전에** 새 서버에서 외부로 보내면 수신 측이 거부하거나 스팸 처리한다. | 단계 | 할 일 | 확인 | | --- | --- | --- | | 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL | | 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` | | 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처). **시범 공존 중이면 전면 전환 때 한다** — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "주의") | 관리 화면 계정 목록 | | 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` | | 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` | | 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 | | 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다) | 외부에서 보낸 메일이 새 엔진 로그에 수신 | | 7. 증분 동기화 | MX 전환 직후 · TTL 경과 후 · 정리 직전에 `imapsync` 를 다시 (이미 옮긴 메일은 건너뛴다) | mailcow 로그에 새 수신 없음 | | 8. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — | 롤백: MX 를 `10 mail.arasystem.it.kr` 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 `imapsync` 로 옮긴다. 주의할 레코드: - **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고, 리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다. - **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다. - **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다. - **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만, **0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는 활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 (소스 [`services/src/task_manager/dkim.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/services/src/task_manager/dkim.rs) — 일정은 DNS 게시가 있을 때만 잡는다, 엔진 시험 `tests/src/automation/dkim.rs` 의 "manual DNS" — 교체도 다시 시도도 하지 않는다. 개발 엔진의 두 키도 일정이 없다 — 2026-10-29). - 주의 — **알고리즘에 활성 키가 없으면** 엔진이 새 키를 만들어 **DNS 에 게시하기 전에 곧바로 서명에 쓴다**(DNS 수동일 때). 키를 지우거나, 알고리즘(`algorithms`)을 더하거나, `dkimManagement` 를 다른 값에서 `Automatic` 으로 바꿀 때가 그렇다 — 그런 일을 하면 새 selector 의 TXT 를 바로 넣는다. - 자동 DNS(`Automatic`)라면 새 키를 먼저 게시하고 전파를 확인한 뒤에 쓴다(실패하면 `pending` 으로 두고 옛 키로 계속 서명). - 키를 손으로 바꾸는 방법은 아직 확인하지 않았다 — 엔진은 활성(`active`) 키를 모두 서명에 쓴다. 교체가 필요해지면 개발 엔진에서 먼저 시험한다. ### imapsync ```bash imapsync \ --host1 mail.arasystem.it.kr --ssl1 --user1 '<직원 주소><구분자><마스터 계정>' --password1 '<마스터 비밀번호>' \ --host2 mx1.arasystem.it.kr --ssl2 --user2 '<직원 주소>' --password2 '<새 엔진 초기 비밀번호>' \ --automap --syncinternaldates ``` - 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과 로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다). - 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다. 게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다. - 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나, mailcow 의 비밀번호 해시를 그대로 옮길 수 있는지 검토한다 (미검증). --- ## 백업 | 볼륨 | 내용 | | --- | --- | | `_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** | | `_mail-data` | RocksDB — 메일·계정·설정 전체 | RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다. 멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다. ```bash sudo docker compose --env-file .env.production stop mail sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" -v "$PWD/backup:/backup" \ alpine tar czf "/backup/mail-$(date +%Y%m%d-%H%M).tar.gz" -C / config data sudo docker compose --env-file .env.production start mail ``` - 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다. - 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다. --- ## 보안 - **게이트웨이 비밀번호 = 관리자급 비밀.** 전 직원 메일함의 열쇠일 뿐 아니라, **관리자 계정도 대리 접근된다** — `<관리자 주소>%<게이트웨이 주소>` 로 로그인하면 관리 API(`x:Account/get` 등)가 열린다 (백엔드 확인, setup 이 매번 `[주의]` 로 보여 준다). - Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(`enabledPermissions`)은 접속하는 쪽의 권한만 정하고, 테넌트로 나누면 테넌트에 든 게이트웨이는 `impersonate` 권한 자체를 잃는다 — 테넌트 권한을 `Merge`·`Replace` 로 주거나 계정에 직접 줘도 모든 대리 접근이 403(`security.unauthorized details = "impersonate"`) 이었다. - 그래서 막는 일은 백엔드가 한다 — 관리자(`MAIL_ADMIN_LOCAL_PART`)·`MAIL_BLOCKED_ACCOUNTS` 계정은 웹메일 로그인·대리 접근 대상에서 뺀다 (+ 대리 접근한 세션에 관리 권한이 있으면 거부하는 2차 확인). 관리자 계정을 더 만들면 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다 (빠지면 setup 이 `[주의]`). - **지킬 것**: 백엔드 env(루트 `.env.production`)에만 둔다 · 다른 곳에 재사용하지 않는다(관리자·DB 비밀번호와도 다르게) · 무작위로 길게 · 정기 교체 · 게이트웨이 허용 IP(신뢰 대역 = `APP_NETWORK_SUBNET`)를 넓히지 않는다 — 이 제한이 바깥에서의 사용을 막는 유일한 장치다. - **교체 절차**: `.env.production` 의 `MAIL_GATEWAY_PASSWORD` 를 새 무작위 값으로 → `node --env-file=.env.production mail/scripts/setup.mjs` (운영 배포 3 과 같은 형태 — `[변경] 게이트웨이 … 비밀번호`) → `sudo docker compose --env-file .env.production up -d backend` (새 값으로 다시 만든다). setup 과 backend 재생성 사이에는 웹메일이 `MAIL_001` 로 잠시 멈춘다 — 사용이 적은 시간에 한다. 사용 기록은 `auth.success` 로그에 남는다. - **신뢰 대역 = 고정한 앱 네트워크 서브넷.** compose 가 app-network 서브넷을 `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`)으로 고정하고, setup 은 그 안의 대역만 신뢰 대역으로 받는다 (더 넓으면 실패). 예전처럼 도커 주소 풀 전체(172.16.0.0/12·192.168.0.0/16)를 믿지 않는다. app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — **인터넷을 향한 frontend 는 edge 네트워크로만 backend 에 닿는다** (frontend 가 붙어 있으면 그 주소가 신뢰 대역에 들고 `mail:8080` 관리 API 에 닿는다. setup 이 구성원을 확인한다). - **8080 은 절대 공개하지 않는다** — 평문 HTTP 관리 API·JMAP 이다. - **공개 443 은 ACME·MTA-STS 만** — setup 이 `x:Http` 싱글톤의 `allowedEndpoints` 를 `listener == 'https' && path != '/.well-known/mta-sts.txt'` → 403 으로 둔다. 웹메일은 백엔드 → 내부 `http`(8080)라 영향이 없다. - 2026-10-26 확인: https 의 `/jmap/session`·`/.well-known/jmap`·`/admin/`·`/account/`·`/login`·OAuth 디스커버리·`/healthz` 는 403, `/.well-known/mta-sts.txt` 는 200, 내부 8080 은 그대로. 호스트·다른 컨테이너·AllowedIp 에 든 주소에서 와도 403 이다. - 식의 변수는 `listener`·`path`(그 밖에 `url`·`method`·`remote_ip`·`local_ip`·`local_port`·`protocol`·`headers`·`is_tls`)가 통하고 `url_path`·`host` 등은 저장할 때 거부된다. 바꾼 뒤에는 `ReloadSettings` 가 있어야 반영된다. - ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다. 확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다. - ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다). - 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180::8080 <메일 호스트>` → . 직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다. - **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한 (`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영). - 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작. - ⚠️ 되돌리기는 **일반 관리자로는 안 된다** — 관리자 역할(System Administrator)에 이 권한이 없어 "You are not authorized to grant permissions" 로 거부된다. 복구 관리자(일회성)로 `x:Role/set` `update {"": {"enabledPermissions/jmapPrincipalGet": true, …}}` → `InvalidateCaches` 를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면 `ensureUserRoleWithoutPrincipals` 단계를 먼저 고친다) - 백엔드가 사내 목록을 읽는 것은 따로 만든 사내 목록 계정(권한 셋, 비밀번호 없음)으로만이다 — 직원 역할에 되돌려 주지 않는다 ([사내 목록 계정](#사내-목록-계정)). - 직원이 공용 주소(Group 계정)의 구성원이 되어도 Group 역할의 Principal 권한을 물려받지 않는다 (2026-10-29 확인 — 구성원이 된 뒤에도 `Principal/query` → forbidden). - **메일을 받지 않는 시스템 계정은 RCPT 에서 거절한다 (backscatter 막기).** 관리자 · 게이트웨이 · 사내 목록 계정의 역할에는 `emailReceive` 가 없다(관리자의 System Administrator 역할도). 그대로 두면 엔진은 그 주소로 온 메일을 RCPT 에서 250 으로 받아 놓고, 배달할 때 거절해(`security.unauthorized … details = "emailReceive"`) **보낸 사람에게 반송(DSN)한다** — 보낸 사람을 위조한 스팸이면 모르는 사람에게 반송이 나가 서버 평판이 떨어진다. 없는 주소는 처음부터 RCPT 에서 `550 5.1.2` 다. - setup 이 `x:MtaStageRcpt` 의 `script` 식을 "받는 주소가 그 계정이거나 그 +태그 꼴일 때만" 시스템 Sieve 스크립트 `arasystem-system-recipients`(`reject "550 5.1.2 Mailbox does not exist."`)를 고르게 맞춘다(ReloadSettings 로 반영). 없는 주소와 같은 응답이라 바깥에서 이 계정들이 있는지도 알 수 없다. 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다. - 대상: 게이트웨이 · 사내 목록 계정과, 관리자 역할에 `emailReceive` 가 없을 때의 **관리자 역할 계정 모두**(나중에 더 만든 관리자도 — setup 을 다시 돌리면 식에 더해진다). 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 그 주소는 넣지 않는다(실제 메일함일 수 있다). - 엔진은 식의 `rcpt` 를 소문자로 주지만 +태그(부주소)는 떼지 않는다 — 그래서 `<아이디>+…@<도메인>` 꼴을 따로 본다(구분자는 엔진 기본 `+` 로 가정한다). - 웹메일에서 이 주소로 보내면(엔진 안의 인증된 세션) 같은 RCPT 에서 끝난다 — 제출의 받는 사람별 결과가 `delivered: no` · `550 5.1.2` 로 바로 오고(계약 2절 "받는 사람별 결과") 반송은 없다. - RCPT 단계 스크립트(`x:MtaStageRcpt` 의 `script`)는 하나뿐이다. 기본값(없음)이거나 setup 이 써 둔 식일 때만 고치고, 다른 설정이 쓰고 있으면 건드리지 않고 `[주의]` 를 낸다 — 그때는 그 식에 이 계정들일 때 `arasystem-system-recipients` 를 고르는 갈래를 합친다. - 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550, 직원 · 직원 +태그 · 이름이 `admin` 으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면 `[유지]`, 관리자를 더 만들거나 지우면 `[변경]`. - **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고 일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5). - **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는 도커 게이트웨이 주소로 보인다 → 신뢰 대역으로 취급되어 자동 차단 예외와 게이트웨이 IP 제한이 무력화된다. IPv6 수신이 필요하면 Docker 데몬의 IPv6(ip6tables)를 켜 원본 주소가 보존되는 것을 확인한 뒤 바인딩을 추가한다. - `X-Forwarded-For` 는 기본 설정에서 신뢰하지 않는다 (`x:Http` 의 `useXForwarded: false` — 위조 헤더로 IP 판정이 바뀌지 않음을 검증). 프록시 뒤에서 이를 켤 때는 프록시 주소로 한정해야 한다. - 신뢰 대역은 자동 차단에서 빠진다 → 무차별 대입 방어는 백엔드 rate limit 이 담당한다. - 비밀번호는 영문·숫자로만 — `#`·따옴표·`$`·공백은 compose 와 Node(`--env-file`, `#` 부터 주석)가 다르게 읽는다. --- ## 리버스 프록시와 같은 호스트에 둘 때 (대안) 기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (1~3 은 문서로만 정리 — 검증하지 않았다) 메일 서버를 따로 둘 수 있으면 4 를 권한다 — 프록시와 메일 엔진이 443 을 나눠 쓰지 않는다. 1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로. 2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를 복호화하지 않고 mail 의 443 으로 넘긴다. Stalwart 가 TLS 와 ACME(TLS-ALPN-01)를 그대로 처리한다. compose 에서 mail 의 443 매핑을 뺀다. - ⚠️ **mail 과 L4 프록시만 붙는 전용 네트워크**를 새로 만든다 (예: `_mail-ingress`, 서브넷 고정, 다른 컨테이너를 붙이지 않는다). **함께 쓰는 `proxy` 네트워크나 app-network 에 mail 을 붙이지 않는다** — 그 네트워크의 모든 컨테이너가 `mail:8080`(평문 관리 API·JMAP)에 닿는다. - 프록시의 대상은 서비스 이름(`mail`)이 아니라 `-mail:443` 으로 적는다 (다른 프로젝트의 `mail` 과 겹치지 않게). ```nginx stream { map $ssl_preread_server_name $upstream { mx1.arasystem.it.kr -mail:443; # 전용 네트워크로만 닿는다 default 127.0.0.1:8443; # 기존 웹 TLS 종단 } server { listen 443; ssl_preread on; proxy_pass $upstream; } } ``` 3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다. 4. **메일 서버를 따로 둔다** — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다. 프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신. → [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망) ⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면 인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고, PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) 또는 그 프록시로만 한정한 `X-Forwarded-For`(3)로 원본 주소를 넘긴다. 3 은 공개 443 정책(`listener == 'https'`)이 `http` 리스너에는 걸리지 않으므로 프록시 쪽에서 `/jmap`·관리 경로를 따로 막아야 한다. --- ## 리버스 프록시가 다른 서버에 있을 때 (NCP 등 — 사설망) 포털의 TLS 를 **다른 서버**의 리버스 프록시(예: 기존 서버의 Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 쓴다)가 끝내고, **같은 VPC 사설망**으로 메일 서버의 frontend(8080)에 넘기는 구성이다 — [위 대안](#리버스-프록시와-같은-호스트에-둘-때-대안)의 4. 메일 서버에는 웹메일 스택 전체(mail · backend · db · frontend)를 그대로 올리고 메일 엔진이 그 서버의 443 을 직접 쓴다(ACME TLS-ALPN · MTA-STS). 예시 값: 서버 A(기존 — NPM) 사설 IP `10.0.1.11`, 서버 B(메일 — `mx1.arasystem.it.kr`) 사설 IP `10.0.1.12`. ``` portal.arasystem.it.kr (11단계부터 files. 도) → 서버 A 공인 IP mx1.arasystem.it.kr → 서버 B 공인 IP (PTR 도) 브라우저 ─TLS─▶ 서버 A (기존) Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 │ http · 같은 VPC 사설망 → 10.0.1.12:8080 (서버 B 의 ACG: 8080 은 10.0.1.11 에서만) ▼ 서버 B (메일 — 새로) docker-compose.yml + docker-compose.remote-proxy.yml -frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME ``` - 선택 파일 [`docker-compose.remote-proxy.yml`](../docker-compose.remote-proxy.yml) 이 바꾸는 것은 frontend 하나다 — `proxy` 네트워크에서 빼고(서버 B 에는 없다), 8080 을 `FRONTEND_PRIVATE_BIND`(사설 IPv4:포트)에만 게시하고, 그 값을 먼저 보는 `frontend-bind-check` 를 붙인다. frontend 의 환경 · nginx 설정(`X-Forwarded-For` 이어 붙이기 · `X-Request-Id` · 실시간 알림)과 다른 서비스 · 네트워크(edge · app-network)는 기본 구성과 같다. - 서버 A → B 구간은 평문 HTTP 다 — 같은 VPC 사설망 안이라 받아들이고, 그래서 8080 은 서버 A 에서만 받는다(아래 ACG). ### NCP 쪽 할 일 | 할 일 | 값 | | --- | --- | | 서버 B 만들기 | 서버 A 와 **같은 VPC** 의 Public 서브넷, 공인 IP 하나, 메모리 8GB (11단계의 ClamAV 까지 같은 서버에 올린다). 다른 VPC 면 Peering 이 있어야 한다 — 이 절은 같은 VPC 만 다룬다 | | VPC 대역 | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) · `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 두 값을 다른 사설 /24 로 바꾸고 mail setup 을 다시 돌린다 | | 서버 B ACG — 인바운드 | TCP 25 · 465 · 993 · 443 ← `0.0.0.0/0` (메일 수신 · 메일 프로그램 · ACME TLS-ALPN · MTA-STS) · TCP 8080 ← **서버 A 사설 IP `/32` 만** · TCP 22 ← 관리 IP 만. 그 밖(80 · 3000 등)은 열지 않는다 | | 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · 53(DNS)은 남긴다 | | 서버 A ACG — 아웃바운드 | 좁혀 두었다면 서버 B 사설 IP 의 TCP 8080 을 더한다 | | Network ACL | 서브넷의 NACL 을 바꿔 두었다면 위 포트를 같게 연다 | | PTR | 서버 B 공인 IP → `mx1.arasystem.it.kr` — 가비아가 아니라 **NCP 고객지원**에 요청한다. 가비아의 `mx1` A 레코드를 먼저 넣는다(정방향 · 역방향이 맞아야 한다 — [PTR](#ptr)) | | DNS (가비아) | `mx1` A → 서버 B 공인 IP · `portal` A → 서버 A 공인 IP(지금 그대로) · `files` A → 서버 A 공인 IP(11단계) | - **게시 주소로는 바깥을 막지 못한다** — NCP 의 공인 IP 는 서버 안에 없고 사설 IP 로 옮겨(NAT) 들어와, 사설 IP 에 게시한 8080 에도 공인 IP 로 닿는다. 8080 을 막는 것은 ACG 다. 서버 B 의 `ip -4 addr` 에 공인 IP 가 없으면 이 경우다 — 바깥 PC 에서 `curl -m 5 http://<서버 B 공인 IP>:8080/` 이 시간 초과여야 한다. (NCP 의 NAT · ACG 는 NCP 문서 기준이고 이 저장소에서 시험하지 않았다 — 배포 뒤 아래 "확인"으로 본다) - 메일 포트(25 · 465 · 993 · 443)는 기본 구성 그대로 `0.0.0.0` 에 게시한다. 공인 IP 로 들어온 접속이 원래 주소 그대로 보이는지는 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 본다. - 두 서버의 사설 IP 를 바꾸면(서버를 다시 만들면) `FRONTEND_PRIVATE_BIND` · `TRUST_PROXY` · NPM 의 Forward IP · ACG 를 함께 고친다. ### 서버 B — env 와 실행 `.env.production`([운영 배포](#운영-배포))에 셋을 넣는다 — 앞의 둘은 예시 env 의 "리버스 프록시가 다른 서버에 있을 때" 칸, `TRUST_PROXY` 는 바꾼다: ```bash COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml # 리눅스는 : 로 잇는다 FRONTEND_PRIVATE_BIND=10.0.1.12:8080 # 서버 B 의 사설 IPv4:포트 (ip -4 addr) TRUST_PROXY=172.16.72.0/24,10.0.1.11 # ,<서버 A 사설 IP> — 아래 "TRUST_PROXY" ``` - **`COMPOSE_FILE` 은 env 파일에 둔다** — compose 가 `--env-file` 의 이 값을 읽으므로 이 문서의 운영 명령(`sudo docker compose --env-file .env.production …`)이 `-f` 없이 그대로 두 파일을 읽는다. `-f` 두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는 `proxy` 네트워크)으로 돈다. `-f` 를 주면 `COMPOSE_FILE` 보다 그쪽이 이긴다. - 그다음은 [운영 배포](#운영-배포) 절차 그대로다. 띄우기 전 · 뒤에 본다: ```bash sudo docker compose --env-file .env.production config --networks # edge · app-network 만 — proxy 가 보이면 COMPOSE_FILE 이 먹지 않았다 sudo docker port "${PROJECT_NAME}-frontend" # 8080/tcp -> 10.0.1.12:8080 한 줄 sudo docker compose --env-file .env.production logs frontend-bind-check ``` - **게시 주소 확인** — `FRONTEND_PRIVATE_BIND` 가 비어 있으면 compose 가 바로 멈춘다(`required variable FRONTEND_PRIVATE_BIND is missing a value`). 값이 있으면 frontend 보다 먼저 `frontend-bind-check`(셸만 쓰는 한 번짜리 컨테이너 — DB 이미지를 빌린다 · 네트워크 없음 · 비-root)가 RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16):포트(1~65535, 앞자리 0 없이)인지 본다. 아니면 `up` 이 `service "frontend-bind-check" didn't complete successfully: exit 1` 로 멈추고 frontend 는 뜨지 않는다 — 포트뿐인 값(`8080` · `:8080` — compose 는 모든 주소에 연다) · `0.0.0.0` · `127.0.0.1` · 공인 IP · IPv6. 떠 있던 frontend 의 값을 잘못 바꾸면 compose 가 frontend 를 다시 만든 뒤 띄우지 않으므로 포털이 멈춘다(닫힌 쪽) — 값을 고치고 `up -d`. 끝나면 멈춰 있어 `ps` 에는 없고 `ps -a` 에 `Exited (0)` 이다. 앱 네트워크에 붙지 않으므로 mail setup 의 구성원 확인과도 상관없다. - 확인 (2026-10-30 — compose v2.40.2(윈도) · 5.4.0(리눅스, CI 잡 이미지), 예시 env + 위 값): - `config` 의 frontend 는 networks `edge` 하나, ports `host_ip: 10.0.1.12` · `published: "8080"` · `target: 8080`, depends_on 에 `frontend-bind-check`(`service_completed_successfully`) — 기본 구성과의 차이는 이 셋뿐이고 환경 · healthcheck 는 같다. 맨 위 networks 에 `proxy` 가 없다. - `COMPOSE_FILE` 을 `--env-file` 에만 두고 `-f` 없이 돌린 `config` 가 위와 같았다(윈도는 `;` 로 잇는다). `COMPOSE_PROFILES=mail` 도 그대로 먹었고, `-f docker-compose.yml` 을 주면 기본 구성이 나왔다. - `frontend-bind-check` — `10.0.1.12:8080` · `172.31.255.254:65535` · `192.168.0.10:18080` 은 0, `8080` · `:8080` · `0.0.0.0:8080` · `127.0.0.1:8080` · `203.0.113.5:8080` · `172.32.0.1:8080` · `[::]:8080` · `10.0.1.12:0` · `10.0.1.12:08080` 은 1 (`10.0.1.12` · `010.0.1.12:8080` · `10.0.1.12:65536` 은 compose 가 먼저 거절). 잘못된 값의 `up -d` 는 종료 코드 1 · frontend `Created`(시작하지 않음)이고, 떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가 `Created` 로 남았다 (시험용 프로젝트 — frontend 대신 alpine). ### TRUST_PROXY — 서버 A 의 주소 목록 - 값은 `,<서버 A 사설 IP>` (예 `172.16.72.0/24,10.0.1.11`) — 백엔드의 운영 env 검증이 받는다(2026-10-30 확인). 서버 A 사설 IP 는 frontend 가 보는 접속 주소다 — 서버 A 의 NPM 은 도커지만 서버 밖으로는 서버 A 의 사설 IP 로 나간다. 배포 뒤 `sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"` 의 첫 칸으로 확인하고, 같은 줄의 X-Forwarded-For(넷째 `"…"` 칸) 끝이 브라우저의 공인 주소인지 본다. - 홉 수 `2` 도 정상 흐름에서는 같은 IP 를 읽지만 "오른쪽에서 두 번째 주소"를 **누가 붙었든** 믿는다. 같은 VPC 의 다른 서버가 8080 에 바로 붙으면(ACG 를 잘못 열었을 때) `X-Forwarded-For` 를 꾸며 백엔드가 그 값을 클라이언트 IP 로 쓴다 — 로그인 실패 한도 · 요청 수 한도가 IP 기준이다. 주소 목록은 서버 A 에서 온 것만 믿어, 다른 곳에서 바로 붙으면 그 서버 자신의 주소로 센다. | frontend 가 받은 `X-Forwarded-For` (백엔드가 보는 접속은 edge 의 frontend) | 주소 목록 | 홉 수 `2` | | --- | --- | --- | | 정상 — `203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` | | 브라우저가 꾸밈 — `1.2.3.4, 203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` | | 같은 VPC 의 다른 서버가 8080 에 바로 — `1.2.3.4, 10.0.2.20` | `10.0.2.20` | `1.2.3.4` (꾸민 값) | | 서버 A 의 다른 컨테이너가 8080 에 바로 — `1.2.3.4, 10.0.1.11` | `1.2.3.4` (꾸민 값) | `1.2.3.4` (꾸민 값) | (2026-10-30, 개발 백엔드 컨테이너의 Express 5.2.1 · proxy-addr 2.0.8 과 백엔드의 `resolveTrustProxy` 로 확인) - **남는 것** — 서버 A 의 모든 것(다른 사이트의 컨테이너 포함)은 같은 사설 IP 로 나가 NPM 과 가를 수 없다(표의 마지막 줄). 서버 A 는 믿는 쪽에 든다 — 그 서버에 믿지 못할 것을 두지 않는다. NPM 이 사설 대역에서 온 접속의 `X-Real-IP` 를 클라이언트 주소로 믿는 것(같은 VPC 의 다른 서버가 NPM 을 거쳐 꾸밀 수 있다)은 아래 고급 칸으로 끈다. ### Nginx Proxy Manager 설정 서버 A 의 NPM 에 Proxy Host 를 하나 만든다. 기본값은 NPM **v2.16.0**(2026-09-24, 지금 최신) 소스로 확인했다 — [`nginx.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/nginx.conf) · [`proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/proxy.conf) · [`block-exploits.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/block-exploits.conf) · [`assets.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/assets.conf) · [`log-proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/log-proxy.conf) · 템플릿 [`proxy_host.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/proxy_host.conf) · [`_listen.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/_listen.conf). 서버 A 의 NPM 판이 다르면 같은 파일을 대조한다. | 탭 · 칸 | 값 | 까닭 | | --- | --- | --- | | Details › Domain Names | `portal.arasystem.it.kr` | | | Details › Scheme · Forward Hostname / IP · Forward Port | `http` · 서버 B 사설 IP(`10.0.1.12`) · `8080` | `FRONTEND_PRIVATE_BIND` 와 같은 값 | | Details › Cache Assets | **끔** | 켜면 `.js` · `.css` · `.png` 등으로 끝나는 주소를 NPM 이 30분 캐시한다 — 응답의 `Cache-Control` · `Set-Cookie` 를 무시하고 키가 호스트 + 주소뿐이라(로그인한 사람을 가르지 않는다), 그런 주소의 API 가 생기면 남의 응답이 나간다(지금 서명 그림 · 첨부는 id 로 끝난다). 우리 캐시 머리글(`index.html` no-cache · `/assets/` 30일 immutable)도 지운다 | | Details › Block Common Exploits | **끔** | PHP 사이트용 패턴이 메일 검색어에 걸린다 — 아래 표 | | Details › Websockets Support | 끔 | 웹메일은 웹소켓을 쓰지 않는다 (실시간 알림은 SSE — 보통 HTTP 응답) | | Details › Access List | Publicly Accessible | 로그인은 웹메일이 한다 | | SSL › SSL Certificate | Let's Encrypt 새 인증서 (`portal.`) | 서버 A 의 80 으로 HTTP-01 | | SSL › Force SSL | 켬 | | | SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) | | SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 | | Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) | ```nginx # 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s proxy_read_timeout 150s; # 접근 로그에 검색어 · 토큰(쿼리)이 남지 않게 — NPM 의 proxy 형식은 "$request_uri"(쿼리째)와 Referer 를 남긴다. # 접속 기록은 프론트 nginx 의 접근 로그(쿼리 없음 · 브라우저 주소는 X-Forwarded-For 칸)로 본다 access_log off; # 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header). # 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다 real_ip_header proxy_protocol; ``` - **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 — 위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다. - 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`). v2.16.0 기본값과 이 포털에 필요한 것: | 항목 | v2.16.0 기본 | 필요 | 할 일 | | --- | --- | --- | --- | | 본문 한도 | `client_max_body_size 2000m` (http) | `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m) 이상 | 그대로 — 줄이면 그 이상으로 | | 읽기 시간 | `proxy_read_timeout 90s` (http) | 150s 이상(첨부) · 실시간 알림은 25초 ping 보다 길면 된다 | 고급 칸 150s | | 응답 전체 시간 상한 | 없음 | 실시간 알림 1시간 | 그대로 | | 응답 버퍼링 | nginx 기본(켬) — `X-Accel-Buffering` 을 무시하게 한 곳이 없다 | `/api/mail-events` 만 바로 흘려보낸다 | 그대로 — 프론트 nginx 가 그 응답에 `X-Accel-Buffering: no` 를 넘긴다 | | 압축 | `gzip on` · `gzip_types` 없음 → `text/html` 만 | `text/event-stream` 은 압축하지 않는다 | 그대로 — custom `http.conf` 등으로 `gzip_types` 를 넓히지 않는다 | | `Accept-Encoding` | http 단계에 `proxy_set_header Accept-Encoding ""` 가 있지만, Proxy Host 의 `location /` 가 `proxy.conf` 로 머리글을 따로 정해 물려받지 않는다 | | 그대로 — 브라우저의 값이 frontend 까지 가서 JS · CSS · JSON 은 프론트 nginx 가 압축한다(SSE 는 하지 않는다) | | 클라이언트가 끊으면 | `proxy_ignore_client_abort off` | 바로 끊는다 | 그대로 | | `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 | | `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` | | 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` | **Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것: | 요청 | 걸리는 패턴 | 결과 | | --- | --- | --- | | 메일 검색 API `?q=viagra` (소문자 — `valium` · `xanax` · `cialis` · `cocaine` · `tramadol` · `prozac` · `libido` 등) | 스팸 낱말 | 403 | | `?q=concat(` · `?q=javascript`(`%3C…script…%3E`) | SQL · 스크립트 | 403 | | 검색 화면 새로 고침 — `/mail/search?q=http://example.com` · `?q=../etc` (화면 주소는 `/` · `:` 를 인코딩하지 않는다) | 파일 주입 | 403 | | 같은 검색어의 API(`q=http:%2F%2F…` · `q=..%2F…`) · 한글 검색어 | — | 통과 | (2026-10-30, `block-exploits.conf` 를 그대로 넣고 realip · 로그 형식 · 템플릿 차례를 흉내 낸 nginx 1.30.6 으로 확인. 같은 시험에서 사설 주소에서 온 `X-Real-IP: 1.2.3.4` 가 `$remote_addr` 를 바꾸고 고급 칸의 `real_ip_header proxy_protocol;` 이 막는 것, 템플릿의 `access_log` 뒤의 `access_log off;` 로 그 Proxy Host 의 로그가 비는 것, `location /` 가 머리글을 따로 정하면 `Accept-Encoding` 이 그대로 넘어가는 것도 봤다. 실제 NPM 에는 넣어 보지 않았다) ### 확인 (배포 뒤) ```bash # 서버 A 에서 — 사설망으로 닿는다 (200) curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.12:8080/ # 바깥 PC 에서 — 공인 IP 의 8080 은 닫혀 있다 (시간 초과) curl -m 5 http://<서버 B 공인 IP>:8080/ # 서버 B 에서 — 첫 칸이 서버 A 사설 IP, X-Forwarded-For 칸 끝이 브라우저의 공인 주소 sudo docker logs --tail 3 "${PROJECT_NAME}-frontend" ``` - 포털에 로그인해 20MB 첨부를 올려 본다(본문 한도 · 150s). 브라우저 개발자 도구 › 네트워크의 `mail-events` 가 200 으로 열린 채 25초마다 `ping` 을 받는다(버퍼링 · 압축 없음). - `https://portal.arasystem.it.kr/api/health` 는 바깥에서 404(`RES_001` JSON)다 — 기본 구성과 같다. --- ## 문제 해결 | 증상 | 조치 | | --- | --- | | `셸 환경변수가 env 파일 값을 가리고 있습니다` | 표시된 변수를 지우고 다시 (`unset APP_ENV` · PowerShell `Remove-Item Env:APP_ENV`) | | `메일 엔진에 연결할 수 없습니다` | `COMPOSE_PROFILES=mail` 로 떠 있는지, `MAIL_ADMIN_PORT` 가 맞는지. 운영은 `MAIL_ADMIN_URL` 로 컨테이너 내부 주소를 준다 ("운영 배포" 3) | | `운영에서는 docker 를 쓸 수 있어야 합니다` | docker 그룹 사용자로, 또는 `sudo -E env "PATH=$PATH" node …` 로 호스트에서 실행 ("운영 배포" 3). 아무것도 바뀌지 않았다 | | `임시 비밀번호 파일 자리(…/data/mail)에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다` | 아무것도 바뀌지 않았다. `data/`·`data/mail/` 을 실행하는 사용자 소유로 (`sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail`) 또는 늘 `sudo -E` 로 — 같은 명령 다시 | | bootstrap 직후 멈춤 (`bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다` 등) | [bootstrap 직후에 멈췄다면](#bootstrap-직후에-멈췄다면-다시-실행하기-전에) — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(`--recovery`) 다시. config.json 으로 판단해 재시작만 한다 | | `재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음)` | 설정 파일이 있는데 엔진이 bootstrap 모드로 떴다 — `docker logs "${PROJECT_NAME}-mail"` 의 기동 오류를 본다. `Bootstrap/set` 을 다시 부르지 않는다 | | `bootstrap 이 거부되었습니다` + `primaryKeyViolation` | 데이터 볼륨에는 이전 설치가 있는데 설정 볼륨에 config.json 이 없다 (예: `_mail-config` 만 지움). **새 설치**면 두 볼륨을 다 지우고 처음부터 (`docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"`), **운영 중이던 서버**면 백업에서 `_mail-config` 를 복원한다 | | `사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` | 아래 줄의 항목을 고치고 다시 — 엔진 설정은 그대로다 | | `앱 네트워크 … 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다` | `docker network disconnect "${PROJECT_NAME}_app-network" <컨테이너>`, compose 의 `networks` 확인 (frontend 는 edge 만). 개발은 경고만 | | `APP_NETWORK_SUBNET(…) — 접두 길이는 /24 ~ /28 …` · `네트워크 주소로 …` · `사설 대역 …` | 사설 대역의 네트워크 주소, `/24`~`/28` 로 (예: `172.16.71.0/24`). 바꾸면 `down` 후 `up -d` 로 네트워크를 다시 만든다 | | `메일 엔진의 관리자(admin@…)와 MAIL_ADMIN_LOCAL_PART(…) 가 다릅니다` | bootstrap 뒤에 `MAIL_ADMIN_LOCAL_PART` 만 바꿨다 — **bootstrap 전에만 바꿀 수 있다.** (1) env 를 엔진 관리자의 아이디로 되돌리고 `up -d backend` (가장 간단). (2) 꼭 바꿔야 하면: 복구 관리자로 엔진의 관리자 계정 아이디를 바꾼다(관리 화면 › 계정, 또는 `x:Account/set` `update {"": {"name": "<새 아이디>"}}` — 주소가 함께 바뀐다) → env 를 같은 값으로 → `up -d backend` → setup 재실행 | | `…(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다` · `… 는 메일 엔진의 관리자 계정이 아닙니다` | `MAIL_ADMIN_LOCAL_PART` 가 직원 계정을 가리킨다 — 엔진의 관리자 아이디로 맞춘다 (메시지에 엔진의 관리자 목록이 나온다) | | `… 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP 429·5xx)` | 비밀번호를 덮어쓰지 않으려고 멈췄다. 429 면 몇 분 뒤, 5xx 면 `docker logs` 로 엔진 상태를 본 뒤 다시 | | `[주의] 게이트웨이로 관리자 … 를 대리 접근하면 관리 API 가 열립니다` | 알려진 남은 위험이다 (Stalwart 0.16 은 막을 수 없다) — [보안](#보안)의 게이트웨이 비밀번호 수칙을 지킨다. 실패가 아니다 | | `[주의] 관리자 역할 계정 … 가 백엔드 차단 목록에 없습니다` | 그 주소를 `MAIL_BLOCKED_ACCOUNTS` 에 넣고 `up -d backend`, 쓰지 않는 관리자라면 지운다 | | `관리자로 로그인할 수 없습니다` | 개발: `MAIL_RECOVERY_ADMIN` 을 채워 `up -d mail` 후 setup 재실행. 운영: "운영 배포" 1)·`up -d mail` → `--recovery` → 4)·5) | | `[재시작 필요]` · `[후속 조치]` (종료 코드 3) | 안내대로 한 뒤 같은 명령으로 다시 (운영 `--recovery` 뒤에는 `--recovery` 없이) | | `MAIL_TRUSTED_NETWORKS 의 … 가 APP_NETWORK_SUBNET(…) 보다 넓거나 그 밖에 있습니다` | `MAIL_TRUSTED_NETWORKS` 를 비우거나(= 서브넷) 서브넷 안의 더 좁은 대역으로 | | `앱 네트워크 … 의 서브넷(…)이 APP_NETWORK_SUBNET(…) 과 다릅니다` | 서브넷을 고정하기 전에 만든 네트워크 — `down` 후 `up -d` 로 다시 만들고 setup 재실행 | | 서브넷을 바꾼 뒤 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 | 네트워크만 새로 만들어지고 다른 컨테이너가 서비스 이름 별칭을 잃었다 — `up -d --force-recreate` (또는 `down` 후 `up -d`) | | `운영 env 파일에 MAIL_RECOVERY_ADMIN 가 들어 있습니다` 등 복구 관리자 오류 | 파일에서 지우고 셸에서 일회성으로만 준다 ("운영 배포") | | `… 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다` | `unset MAIL_RECOVERY_ADMIN` 후 `docker compose --env-file .env.production up -d mail` | | `백엔드 주소 … 가 신뢰 대역(…) 밖입니다` | `MAIL_TRUSTED_NETWORKS` 를 비우고(= `APP_NETWORK_SUBNET`) setup 재실행 | | `공개 443 차단 확인 실패` | setup 이 `ReloadSettings` 까지 했는지, `x:Http` 의 `allowedEndpoints` 가 위 식인지 (관리 화면 › Settings › HTTP) | | `[주의] 받는 쪽 한도 "Sender address to recipient throttle"(…)을 찾지 못해 만들지 않았습니다` | 엔진 기본 한도를 지웠거나 이름을 바꿨다. 지웠다면 그대로 두고, 이름을 바꿨다면 그 한도의 `match` 가 `{"match":{},"else":"is_empty(authenticated_as)"}` 인지 관리 화면에서 본다 ([엔진 한도](#엔진-한도)) | | 웹메일 발송의 받는 사람이 `452 4.4.5 Rate limit exceeded` 로 거절된다 | setup 출력에 `받는 쪽 한도 … 인증 없는 세션에만` 이 있는지 본다. 받는 쪽 한도를 새로 만들었다면 그 `match` 도 — 엔진 로그 `smtp.rate-limit-exceeded` 의 `id` 가 어느 한도인지 알려 준다 ([엔진 한도](#엔진-한도)) | | 한 직원의 첨부 올리기 · 필터 · 부재중 저장이 429 `REQ_001` 로 막힌다 (백엔드 로그 `"reason":"mail-engine-rate-limit","call":"upload"`) | 엔진의 임시 업로드 한도(계정마다 1시간 — 크기 합계 · 개수)를 넘었다. 올린 것이 만료되면(최대 1시간) 풀린다. setup 출력에 `임시 업로드 한도 — 계정마다 크기 합계 …` 가 있는지 보고, 개수를 넘는 직원이 잦으면 `maxUploadCount` 를 올린다 ([엔진 한도](#엔진-한도) "임시 업로드 한도") | | `임시 업로드 한도(x:Jmap 의 uploadQuota = …)를 읽지 못해 바꾸지 않았습니다` · `[주의] 임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다` | 엔진이 숫자가 아닌 값(판을 올려 속성이 바뀌었는지 본다)이나 0 을 돌려줬다 — 관리 API `x:Jmap/get` 으로 `uploadQuota` 를 보고 첨부 합계 한도 × 20 이상으로 둔 뒤 `ReloadSettings` (0 이 제한 없음인지는 확인하지 않았다) | | `[주의] 사내 목록 계정이 없습니다 (…)` | 복구 관리자 없이 돌렸다 — 실패가 아니다. 운영: "운영 배포" 1)·`up -d mail` → 3) `--recovery` → 4)·5) 를 한 번. 개발: `MAIL_RECOVERY_ADMIN` 을 채우고 `up -d mail` 후 setup. 그동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 ([사내 목록 계정](#사내-목록-계정)) | | `[주의] 사내 목록 계정이 setup 기준과 다릅니다 (역할의 권한이 다름 … · 계정의 역할이 다름)` | 누가 역할·계정을 고쳤다. 관리자로는 되돌릴 수 없다(엔진이 거절) — 위와 같이 복구 관리자로 setup. 이어서 나오는 `[확인]`·`[주의]` 줄이 지금 백엔드가 읽을 수 있는지 알려 준다 | | `…(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 …` | 그 주소를 실제 계정이 쓴다(그 계정은 건드리지 않았다). `MAIL_DIRECTORY_LOCAL_PART` 를 아무도 쓰지 않는 아이디로 바꾸고 `up -d backend` → setup 재실행 | | `사내 목록 계정으로 계정 목록을 읽지 못했습니다 — …: 세션 HTTP 403` · `Principal/… → forbidden` | 역할에 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 가 없거나 빼는 권한에 들었다 — 복구 관리자로 setup. `세션 HTTP 401` 이면 계정이 없거나 게이트웨이 비밀번호·허용 IP 문제다 | | `[주의] 사내 목록 역할을 다른 계정도 가지고 있습니다: …` | 관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) | | `[유지] 옛 사내 목록 계정 … — 새 계정 … 가 준비된 뒤에 지운다` | `MAIL_DIRECTORY_LOCAL_PART` 를 바꿨는데 새 계정을 아직 만들지 못했다(복구 관리자 없음) — 복구 관리자로 setup 을 돌리면 새 계정을 만들고 옛 계정을 지운다 | | 사내 목록(`GET /api/directory-entries`)이 `503 MAIL_001` | setup 출력의 요약 `사내 목록` 줄을 본다 — 준비되지 않았으면 위 줄들. 준비됐는데도 그러면 backend 의 `MAIL_DIRECTORY_LOCAL_PART` 가 setup 과 같은지 보고 `up -d backend` | | `[주의] RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다` | 메일을 받지 않는 시스템 계정은 지금처럼 받아 놓고 반송한다. 그 식에 시스템 계정일 때 `arasystem-system-recipients` 를 고르는 갈래를 합치거나, 그 설정이 필요 없으면 기본값(`{"match":{},"else":"false"}`)으로 되돌리고 setup 재실행 ([보안](#보안)) | | `시스템 계정의 RCPT 거절 확인 실패 — …` | setup 이 `ReloadSettings` 까지 했는지, `x:MtaStageRcpt` 의 `script` 식과 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive: true`)를 관리 API 로 본다. 확인은 메일 컨테이너 안의 `curl` 로 RCPT 까지만 보낸다 | | `시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` · `… allowRelaying 만 닫고 중단합니다` | 아무것도 바뀌지 않았다 — 뒤의 머리글이면 가드 없이 열려 있던 relay 만 닫았다(위에 `[변경] … allowRelaying 을 껐다`, 엔진에 없는 사내 주소는 다시 550). 아래 줄의 항목을 고친다 — 기존 서버에 닿지 않으면 이 호스트의 바깥 25 · 기존 서버의 STARTTLS · 인증서 이름, 자기 자신으로 풀리면 `MAIL_PILOT_LEGACY_HOST`, 기존 서버 IP 의 사람이 만든 자동 차단 예외 · 기존 서버 IP 로 된 운영자 갈래(아래 줄)는 지운 뒤 다시, `waitOnFail` 은 기본값(5s)으로, DKIM 은 아래 줄 ([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "setup 이 하는 일"). 이미 켜진 시범이면 "시범은 켜진 채로 두었습니다" — 고친 뒤 같은 명령으로 다시 실행한다 | | `MAIL_PILOT_LEGACY_HOST 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다` | 운영의 env 파일에서 시범 값이 빠졌다. 시범을 이어 가려면 두 값을 다시 넣고, 끝내려면(전면 전환 — MX 를 옮긴 뒤) 값을 비운 채 `--end-pilot` 으로 실행한다. 아무것도 바뀌지 않았다 | | `운영의 시범 공존 값(…)은 셸이 아니라 .env.production 에 둡니다` | 셸에 `MAIL_PILOT_LEGACY_*` 가 남아 있다 — 파일에 적고 `unset` 뒤 다시 | | `도메인 … 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어 …` | 시범을 켠 적이 없는 엔진에서 누가 relay 만 켜 두었다 — 가드 없이 열린 relay 라 엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다. setup 은 남의 설정이라 끄지 않는다(요약 `시범 설정 없음 · allowRelaying 켜짐`, 종료 1). 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용을 끈다 | | `도메인 … 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(legacy)가 없습니다` | 누가 켰는지 알 수 없어 끄지 않았다 — 관리 화면에서 `legacy` 경로만 지웠을 수도 있다. RCPT 가드 · 위조 막기 · 예외 · 발송 전략은 **남겼다**(가드 없는 relay 를 만들지 않는다 — 요약은 `시범 공존 끝내지 못함`). 끝낼 것이면 관리 API `x:Domain/set` 으로 `allowRelaying: false` 뒤 다시 실행하면 나머지를 뺀다. 이어 갈 것이면 시범 값을 넣고(운영은 env 파일) 실행하면 경로를 다시 만든다 | | `RCPT 가드가 답하지 않아(…) 시범 공존의 allowRelaying 을 켜지 않고 중단합니다` | 켜기 전 확인(A 의 리로드 뒤 · relay 꺼진 채)에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — relay 는 열지 않았다(엔진에 없는 사내 주소는 그대로 550). 가드 · 표시 · 예외는 저장돼 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 | | `… allowRelaying 을 끄려 했지만 끄지 못했습니다 (…)` 뒤에 `— 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(…)` | 가드가 답하지 않아 relay 를 닫으려 했는데 저장이 거절되었거나 · 엔진에 닿지 않았거나 · 다시 읽어도 켜져 있다. setup 이 멈추기 전에 RCPT 가드만 다시 걸었고 지금은 가드가 550 으로 거절한다 — relay 는 켜진 채다. 관리 화면에서 그 도메인의 relay 허용(관리 API `x:Domain/set` 의 `allowRelaying: false`)을 끄고, 꺼진 것을 본 뒤 같은 명령으로 다시 실행한다 | | `… 끄지 못했습니다 (…)` 뒤에 `RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다` · `… 열려 있을 수 있습니다` | relay 를 닫지 못했고 가드도 다시 걸지 못했다(저장 · 리로드 실패, 다시 걸었는데 확인이 250 이거나 확인하지 못함, 또는 RCPT 단계 식을 다른 설정이 써서 건드리지 않음) — **지금 열려 있다(있을 수 있다).** 관리 화면에서 relay 허용을 바로 끄고, 꺼진 것을 본 뒤 다시 실행한다. 켜기 전 · 켠 뒤 확인에서 끄지 못한 경우(가드는 이미 걸었다)도 "열려 있을 수 있습니다"로 같다. setup 은 그 밖의 설정을 더 바꾸지 않고 멈췄다 | | `RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다` | 켠 뒤 확인에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — `allowRelaying` 을 다시 껐다(엔진에 없는 사내 주소는 다시 550, owner 가 동료에게 보내는 메일도 `not-found`). 가드 · 경로는 남아 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 | | `[변경] 시범 공존 — RCPT 가드 없이 allowRelaying 이 켜져 있어 → … allowRelaying 을 껐다` · `… RCPT 가드가 답하지 않아(…) → …` | 실행을 시작할 때 relay 가 가드 없이 열려 있었다(누가 RCPT 식을 바꿨거나 시스템 Sieve 스크립트를 껐다) — 먼저 닫았다. 같은 실행이 확인을 마치면 가드를 되살리고 다시 켠다 — 끝이 `[완료]` 면 된 것이다 | | `시범 공존 확인 실패 — …` (위조 막기 · 인증한 세션) | 설정은 저장됐다. setup 이 리로드까지 했는지, `x:MtaStageMail` 의 `isSenderAllowed` · 도메인의 `allowRelaying` 을 관리 API 로 본다. 고치기 어려우면 값을 비우고(운영은 `--end-pilot`) 끈다 | | `다른 도메인 … 이 allowRelaying 을 켜 두어 시범 설정(…)을 빼지 않았습니다` | 시범을 끄는 실행인데 시범 도메인 밖의 도메인이 relay 를 켜 두었다 — RCPT 가드가 모든 사내 도메인을 덮으므로 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다. setup 은 그 도메인을 끄지 않는다 — 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행한다 | | `도메인 … 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다` | 시범 도메인의 relay 를 끄는 저장을 엔진이 받았는데 반영되지 않았다 — 시범 설정을 그대로 두었다. 관리 화면에서 relay 허용을 끈 뒤 다시 실행한다 | | `운영의 MAIL_PILOT_LEGACY_HOST 포트는 25 만 받습니다` | 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다 — mailcow 의 25 를 쓴다(`[mail.arasystem.it.kr]:25` 또는 포트 없이) | | `[주의] 시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시 … 에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다` | 위조 막기 · 검사 예외 · IP 한도 식에 시범 꼴의 갈래가 있는데 시범 표시(자동 차단 예외)에 없는 IP 다 — 운영자가 넣은 것이면 그대로 둔다(기존 서버 IP 와 같은 IP 면 아래 줄처럼 멈춘다). setup 이 넣은 것인데 표시 항목을 지웠다면 관리 화면에서 그 갈래를 지운다 | | `… 에 기존 서버 IP(…)로 된 갈래가 setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시 … 가 지워진 것입니다` | 켜기 전에 멈췄다(아무것도 바뀌지 않았다). 운영자가 넣은 것이면 — 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 끝낼 때 지워지므로 — 그 갈래를 지우고(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다(시범 동안은 setup 의 갈래가 같은 일을 한다). 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 그 IP 로 같은 설명(`[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP`)의 자동 차단 예외를 다시 만든 뒤 실행해도 된다 | | `… 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다` | setup 은 한 칸에 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것이다. 하나만 남기고 지운 뒤 다시 실행한다. 끄는 실행에서는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의] … 맨 앞 하나만 빼고`) | | `DKIM TXT … 의 공개 키가 엔진의 키와 다릅니다` · `DKIM TXT … 가 DNS 에 없습니다` | 키가 다르면 동료(mailcow) 앞 메일이 위조로 거절되고, 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. `setup.mjs --print-dns` 가 보여 주는 selector TXT 로 DNS 를 고치거나 넣고, 바깥에서 `dig TXT ._domainkey.<도메인>` 으로 보인 뒤 다시 실행한다. 처음 켜는 실행이면 relay 를 켜지 않고 멈췄고, 이미 켜진 시범이면 켠 채로 실패만 알렸다 | | `[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 …` | 관리자 역할의 이름을 바꿨거나 `emailReceive` 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다 | | `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) | | 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` |