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

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

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

메일 엔진 (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
<MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN> (기본 mail-directory@) 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 authenticate·jmapPrincipalGet·jmapPrincipalQuery 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. 복구 관리자로만 만들어진다 (사내 목록 계정) 없음
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) — 보안 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 (x:MtaStageRcpt 의 script + 시스템 Sieve 스크립트 — 보안 "메일을 받지 않는 시스템 계정")
  8. 사내 목록 계정 — 계정 목록 읽기 전용 역할(authenticate·jmapPrincipalGet·jmapPrincipalQuery) + 비밀번호 없는 계정 <MAIL_DIRECTORY_LOCAL_PART>@도메인. 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때만(개발 · 운영 --recovery) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고 역할·계정이 없거나 다르면 [주의] — 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로 Principal/query·get 을 해 본다 (사내 목록 계정)
  9. 엔진 한도 — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 [주의] (엔진 한도). 계정마다 임시 업로드 크기 합계(x:Jmap 의 uploadQuota — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 (엔진 한도 "임시 업로드 한도"). 둘 다 9 의 리로드로 반영된다
  10. 설정 리로드 → 반영 확인 — 메일 컨테이너의 네트워크 주소로 https://…:443/jmap/session 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(MAIL_PUBLIC_URL/jmap/session, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 curl 명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 [주의] (보안). 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다)
  11. [개발] 외부 발송 경로 — Relay mailpit:1025 + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다)
  12. [개발] 테스트 직원 계정 → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(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, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 (엔진 한도)
MAIL_DIRECTORY_LOCAL_PART 사내 목록 계정 아이디 (기본 mail-directory) — setup 과 같은 값을 읽는다 (사내 목록 계정)
  • 로그인 확인: GET {MAIL_JMAP_URL}/jmap/session + Basic <직원 주소>:<비밀번호> → 200 / 401
  • 메일함 접근: Basic <직원 주소>%<MAIL_GATEWAY_USER> : MAIL_GATEWAY_PASSWORD (게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403)
  • 사내 목록: Basic <MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN>%<MAIL_GATEWAY_USER> : 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).

사내 목록 계정

백엔드가 사내 목록(계약 3절 "사내 목록" — GET /api/directory-entries 와 받는 사람 추천의 "사내")을 채우려고 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는 전용 계정이다. 직원 역할에서는 이 권한을 뺐으므로(보안) 계정 목록을 읽는 길은 이 계정 하나뿐이다.

항목 값
주소 <MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN> (기본 mail-directory@) — 관리자·게이트웨이 아이디와 같으면 setup 이 아무것도 바꾸기 전에 멈춘다
역할 설명 사내 목록 — 계정 목록 읽기 전용 [setup.mjs] · 권한 authenticate·jmapPrincipalGet·jmapPrincipalQuery 뿐 (빼는 권한·물려받는 역할 없음). authenticate 가 없으면 대리 접근이 403 이다
계정 표시 이름 사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs] · 비밀번호 없음 · 저장 한도 quotas.maxDiskQuota 1,048,576바이트
쓰는 법 백엔드만 — 게이트웨이 대리 접근 <사내 목록 주소>%<MAIL_GATEWAY_USER> 로 세션의 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 스크립트가 하는 일 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 을 쓰면 대안 참조)
  • 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 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
  • 엔진 한도 — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 (엔진 한도)
  • 포털(웹메일) 앞의 외부 리버스 프록시 — 요구사항은 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_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 (사내 목록 계정).

복구 관리자(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
  • 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://<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 은 운영에서 직원 계정을 만들지 않는다. ⚠️ 공용 주소(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"]):

[
  ["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 모양이 그대로 비교된다 — 바뀌면 매번 [변경] 이 나온다).

임시 업로드 한도 (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시간까지 막힌다 (계약 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)를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다.
  • 직원마다 1시간 업로드 한도(크기 · 개수) — setup 은 크기 합계만 첨부 합계 한도의 20배로 올리고 개수(1,000)는 그대로 둔다. 운영을 시작한 뒤 백엔드 로그 "reason":"mail-engine-rate-limit","call":"upload" 로 막힌 직원이 있는지 보고 크기 · 개수를 정한다(엔진 로그에는 남지 않는다). 올린 값은 setup 이 내리지 않는다.

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 단계를 먼저 고친다)
    • 백엔드가 사내 목록을 읽는 것은 따로 만든 사내 목록 계정(권한 셋, 비밀번호 없음)으로만이다 — 직원 역할에 되돌려 주지 않는다 (사내 목록 계정).
    • 직원이 공용 주소(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. 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 가 어느 한도인지 알려 준다 (엔진 한도)
한 직원의 첨부 올리기 · 필터 · 부재중 저장이 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 까지만 보낸다
[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 … 관리자 역할의 이름을 바꿨거나 emailReceive 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다
Password is too weak 더 길고 무작위인 값으로 (예: change-me-in-local-env 는 통과, devpassword1 은 거부)
외부로 보낸 메일이 Mailpit 에 없다 setup 의 [확인] 설정 리로드 완료 가 나왔는지. 로그: docker logs "${PROJECT_NAME}-mail" 의 delivery.*