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

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

메일 엔진 (Stalwart)

사내 메일은 Stalwart(JMAP) 를 자체 호스팅한다. 직원 로그인 = 메일 계정이다. 웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.

  • 이미지: stalwartlabs/stalwart:v0.16.27 — 패치까지 고정 (비-root stalwart, UID 2000 · healthcheck 내장. 올릴 때는 docker 스킬 "이미지 버전 고정")
  • 설정값 SSOT: 루트 .env.{APP_ENV} 의 메일 섹션 (env-structure)
  • 초기화·계정 구성: scripts/setup.mjs — 의존성 없음, 여러 번 실행해도 안전
  • 이름은 루트 env 의 PROJECT_NAME 을 따른다 — 컨테이너 ${PROJECT_NAME}-mail, 볼륨 ${PROJECT_NAME}_mail-config·_mail-data, 네트워크 ${PROJECT_NAME}_app-network(신뢰 대역)·${PROJECT_NAME}_edge(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
    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 <PROJECT_NAME>_mail-config(config.json) · <PROJECT_NAME>_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 리스너는 있으나 공개하지 않음 —
계정 용도 비밀번호
<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN> (기본 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
ara.kim@ 김아라 · doyun.lee@ 이도윤 · seojun.park@ 박서준 개발 전용 테스트 직원 MAIL_DEV_USER_PASSWORD

개발 시작

# 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
확인 주소
관리 화면 (웹 포털) http://localhost:8180/admin/ — admin@arasystem.test / MAIL_ADMIN_PASSWORD
직원 셀프서비스 http://localhost:8180/account/
Mailpit (외부 수신자 메일) http://localhost:8025
  • 첫 실행은 bootstrap → mail 컨테이너를 스스로 재시작(docker restart <PROJECT_NAME>-mail)한 뒤 이어서 진행한다.
  • 출력의 [생성]·[변경]·[유지] 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 [유지] 여야 정상이다.
  • ⚠️ 외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다. 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다. 그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(example.net 등)만 쓴다.
  • 개발 메일 데이터를 버리고 처음부터:
    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 스크립트가 하는 일

  1. 사전 확인 (아무것도 바꾸기 전) — app-network 의 실제 서브넷이 APP_NETWORK_SUBNET 인지, 앱 네트워크에 정해진 컨테이너 (backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 엔진 설정을 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고.
  2. 모드 감지 — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
  3. bootstrap — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작. 응답의 무작위 관리자 비밀번호는 한 번만 보이므로 data/mail/bootstrap-admin.json(git 무시)에 잠시 보관했다가 교체 후 지운다. ⚠️ Bootstrap/set 은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서
    • 부르기 전에 data/mail/ 에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다
    • 재실행 때는 mail 컨테이너의 /etc/stalwart/config.json 으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다 (Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
  4. 관리자 — <MAIL_ADMIN_LOCAL_PART>@도메인 비밀번호를 MAIL_ADMIN_PASSWORD 로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은 --recovery). 갓 bootstrap 한 엔진이면 bootstrap 이 만든 admin@ 의 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다). 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) 중단한다
  5. 자동 차단 예외 — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다). 신뢰 대역 = MAIL_TRUSTED_NETWORKS, 비우면 APP_NETWORK_SUBNET(compose 가 고정한 app-network 서브넷). 서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다. 이 스크립트가 만든 예전 항목은 지운다.
  6. 게이트웨이 — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
  7. 잠금 — 직원(User) 역할에서 Principal 조회 권한 13개(jmapPrincipal*·davPrincipal*)를 뺀다(→ InvalidateCaches). 공개 443(https 리스너)의 HTTP 는 /.well-known/mta-sts.txt 만 열고 나머지는 403 (x:Http 의 allowedEndpoints) — 보안
  8. 엔진 한도 — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 [주의] (엔진 한도)
  9. 설정 리로드 → 반영 확인 — 메일 컨테이너의 네트워크 주소로 https://…:443/jmap/session 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(MAIL_PUBLIC_URL/jmap/session, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 curl 명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 [주의] (보안)
  10. [개발] 외부 발송 경로 — Relay mailpit:1025 + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 8 의 리로드로 반영된 것을 확인한 뒤에만 10
  11. [개발] 테스트 직원 계정 → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(Principal/get → forbidden)까지 자체 확인

그 밖의 안전장치:

  • 운영은 docker 를 쓸 수 있어야 한다 — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작).
  • 셸에 APP_ENV·APP_NETWORK_SUBNET 등이 이미 있으면 중단한다. Node 의 --env-file 은 기존 환경변수를 덮어쓰지 않아, 셸에 APP_ENV=development 가 남은 채 .env.production 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다. (MAIL_RECOVERY_ADMIN 만 예외 — 원래 셸에서 일회성으로 주는 값이다)
  • 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 스킬 "운영 환경"
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상)
  • 로그인 확인: GET {MAIL_JMAP_URL}/jmap/session + Basic <직원 주소>:<비밀번호> → 200 / 401
  • 메일함 접근: Basic <직원 주소>%<MAIL_GATEWAY_USER> : MAIL_GATEWAY_PASSWORD (게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403)
  • ⚠️ 세션 응답의 apiUrl·downloadUrl·uploadUrl·eventSourceUrl 은 MAIL_PUBLIC_URL 기준 절대 주소다 (개발 http://localhost:8180/jmap/). 백엔드는 이 주소를 따르지 말고 MAIL_JMAP_URL 에 경로만 붙여 쓴다.
  • Email/query 의 subject 필터는 단어 단위 검색이고 제목 앞의 [태그] 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다.
  • 대리 접근은 메일 엔진 로그 auth.success 에 accountName(대상 직원)·details(게이트웨이)·remoteIp 로 남는다.
  • 신뢰 대역은 자동 차단에서 빠지므로 로그인 무차별 대입 방어는 백엔드가 맡아야 한다 (로그인 엔드포인트 엄격 한도 — security).

운영 배포

사전 조건:

  • 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 대안 참조)
  • MAIL_HOSTNAME 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → MAIL_HOSTNAME (PTR)
  • 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 외부 릴레이)
  • 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
  • 메일 서버 전용 재귀 DNS — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 (DNS 리졸버와 스팸 차단 목록)
  • 스팸 차단 목록 이용 조건 — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
  • 엔진 한도 — 받는 쪽 한도 값과 직원마다 내보내는 양의 한도를 정한다 (엔진 한도)
  • 포털(웹메일) 앞의 외부 리버스 프록시 — 요구사항은 docker 스킬 "운영 환경" (같은 주소 배포 · X-Forwarded-For · IPv6 · 본문 한도·시간 제한)
  • 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_RECOVERY_ADMIN)는 .env.production 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.

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
  • 운영 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://<MAIL_HOSTNAME>/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 = … 로 남는다).
# 바깥 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:<mail 컨테이너 IP>:8080 <메일 호스트> → http://localhost:8180/admin/. setup 은 운영에서 직원 계정을 만들지 않는다.
  • 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"]):

[
  ["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": "<SMTP 사용자>",
    // 비밀번호를 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 항목이 잘못 붙지 않는지 확인한다.
# 리졸버가 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 로 무료 목록 쓰기, Spamhaus — KT 경유 조회 안내, Stalwart 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 "Default throttles"). 사내 메일은 보낸 도메인이 모두 같아서, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤의 웹메일 발송(JMAP 제출)은 그 받는 사람이 452 4.4.5 로 거절됐다 (2026-10-28 개발 엔진 — 로그 smtp.rate-limit-exceeded … id = "MtaInboundThrottle with id …"). 바깥 서버와 달리 엔진은 그 메일을 다시 보내지도 반송하지도 않는다 — 제출 기록의 deliveryStatus 에만 남는다 (백엔드가 읽어 알린다 — 계약 2절 "받는 사람별 결과").
  • setup 이 하는 일 — 설명과 key 로 찾아(id 는 설치마다 다르다) match 를 {"match":{},"else":"is_empty(authenticated_as)"} 로 맞추고 설정 리로드로 반영한다. 엔진 기본값이 스팸 필터를 인증 없는 세션에만 거는 식(x:MtaStageData 의 enableSpamFilter)과 같다.
    • authenticated_as 는 인증한 계정 이름이고, 인증 없는 세션(AUTH 없이 들어온 SMTP — 바깥 서버가 25 로 넣는 메일)이면 빈 값이다. 이 식(RCPT TO 단계)에서 쓸 수 있는 변수다 (MtaInboundThrottle · MtaRcptToVariable · is_empty). 엔진은 저장할 때 식을 검사한다 — 모르는 변수·이 맥락에 없는 변수(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). 조건에 authenticated_as 를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(한도는 key 와 식이 쓰는 변수를 모두 아는 첫 단계에서 센다 — 같은 문서 "Evaluation stages", 소스 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 모양이 그대로 비교된다 — 바뀌면 매번 [변경] 이 나온다).

운영 전에 정할 것:

  • 메일을 많이 받는 주소 — 같은 바깥 도메인에서 한 사람이 시간당 25통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다). 알림 메일(모니터링·결제·배포 알림 등)처럼 한 곳에서 같은 주소로 많이 오는 주소를 먼저 꼽고, 한도를 올리거나(관리 화면 — setup 은 rate 를 건드리지 않는다) 위처럼 따로 한도를 둔다.
  • 한 IP 초당 연결 5개 — 큰 메일 서비스가 여러 연결로 한꺼번에 배달하면 넘을 수 있다(인사 없이 끊기고 바깥 서버가 다시 시도한다 — 늦게 받는다). 메일 프로그램(465)으로 보내는 직원이 사무실 NAT 뒤에 많아도 한 IP 로 센다(연결은 인증 전에 센다 — 시험하지 않았다). 두 한도 모두 넘으면 로그 smtp.rate-limit-exceeded 가 남으니 운영을 시작한 뒤 이 로그로 정한다.
  • 직원마다 내보내는 양의 한도(x:MtaOutboundThrottle)는 비어 있다 — 이제 웹메일 발송에는 엔진의 받는 쪽 한도도 걸리지 않으므로, 남는 것은 백엔드의 요청 수 한도(루트 env THROTTLE_* — IP 당)뿐이다. 계정이 털리거나 잘못 쓰여 한꺼번에 많이 보내면 바깥 서버들의 한도에 걸려 서버 평판이 떨어진다. 보낸 사람마다 한도(key sender — 넘으면 거절이 아니라 대기열에서 미뤄진다, Outbound rate limits)를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다.

arasystem.it.kr 전환 절차 (mailcow → Stalwart)

현재 (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 기본 리포트 수신처) 관리 화면 계정 목록
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(90일마다 새 selector, 7일 뒤 이전 키 은퇴)이고 DNS 는 수동(가비아)이다. 교체된 키의 TXT 가 DNS 에 없으면 서명 검증이 실패하므로, 운영 전에 관리 화면에서 교체 방식을 확인하고 교체 때마다 새 selector 를 등록하거나 자동 교체를 끈다. (교체 시점의 동작은 확인하지 않았다)

imapsync

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 의 비밀번호 해시를 그대로 옮길 수 있는지 검토한다 (미검증).

백업

볼륨 내용
<PROJECT_NAME>_mail-config config.json (작다). 없으면 bootstrap 모드로 뜬다
<PROJECT_NAME>_mail-data RocksDB — 메일·계정·설정 전체

RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 잠시 멈추고 받는다. 멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.

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:<mail 컨테이너 IP>:8080 <메일 호스트> → http://localhost:8180/admin/. 직원 셀프서비스(/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 {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}} → InvalidateCaches 를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면 ensureUserRoleWithoutPrincipals 단계를 먼저 고친다)
  • 복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다 (검증함). 운영은 .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. 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 프록시만 붙는 전용 네트워크를 새로 만든다 (예: <PROJECT_NAME>_mail-ingress, 서브넷 고정, 다른 컨테이너를 붙이지 않는다). 함께 쓰는 proxy 네트워크나 app-network 에 mail 을 붙이지 않는다 — 그 네트워크의 모든 컨테이너가 mail:8080(평문 관리 API·JMAP)에 닿는다.
    • 프록시의 대상은 서비스 이름(mail)이 아니라 <PROJECT_NAME>-mail:443 으로 적는다 (다른 프로젝트의 mail 과 겹치지 않게).
    stream {
      map $ssl_preread_server_name $upstream {
        mx1.arasystem.it.kr  <PROJECT_NAME>-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 등으로 바꿔야 한다.

⚠️ 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·관리 경로를 따로 막아야 한다.


문제 해결

증상 조치
셸 환경변수가 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 직후에 멈췄다면 — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(--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 {"<id>": {"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 가 어느 한도인지 알려 준다 (엔진 한도)
Password is too weak 더 길고 무작위인 값으로 (예: change-me-in-local-env 는 통과, devpassword1 은 거부)
외부로 보낸 메일이 Mailpit 에 없다 setup 의 [확인] 설정 리로드 완료 가 나왔는지. 로그: docker logs "${PROJECT_NAME}-mail" 의 delivery.*