운영 메일 서버를 NCP 에 따로 두고, 포털의 TLS 는 기존 서버의 Nginx Proxy Manager(다른 사이트들과 80 · 443 을 함께 쓴다)가
같은 VPC 사설망으로 넘기기로 했다. 메일 엔진이 그 서버의 443 을 직접 써야 해서(ACME · MTA-STS) 프록시를 같은 호스트에 둘 수 없다.
- docker-compose.remote-proxy.yml: frontend 를 proxy 네트워크에서 빼고 8080 을 FRONTEND_PRIVATE_BIND(사설 IPv4:포트)에만 게시한다.
compose 의 ${…:?} 는 빈 값만 막아 포트뿐인 값이면 모든 주소에 열리므로, 한 번짜리 frontend-bind-check 가 값을 먼저 보고
아니면 frontend 를 띄우지 않는다(네트워크 없음 · 비-root · DB 이미지의 셸).
- .env.production 의 COMPOSE_FILE 로 켠다 — 운영 명령(--env-file .env.production)을 그대로 쓰고 -f 를 빠뜨려 기본 구성으로 도는 일이 없게.
- TRUST_PROXY 는 <EDGE_NETWORK_SUBNET>,<프록시 서버 사설 IP> 주소 목록, NPM 은 v2.16.0 소스로 기본값을 확인해 고급 칸 세 줄
(읽기 150s · 접근 로그 끔 · 사설 대역의 X-Real-IP 믿지 않기)과 Block Common Exploits 끄기(메일 검색이 403)를 적었다.
- NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 게시 주소로는 바깥을 막지 못한다 — 8080 은 ACG 가 프록시 서버 사설 IP 에서만 받는다.
- CI infra 잡이 이 조합의 config 와 frontend-bind-check 의 받을 값 · 거절할 값을 본다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
메일 엔진 (Stalwart)
사내 메일은 Stalwart(JMAP) 를 자체 호스팅한다. 직원 로그인 = 메일 계정이다. 웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.
- 이미지:
stalwartlabs/stalwart:v0.16.27— 패치까지 고정 (비-rootstalwart, 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 스크립트가 하는 일
- 사전 확인 (아무것도 바꾸기 전) — app-network 의 실제 서브넷이
APP_NETWORK_SUBNET인지, 앱 네트워크에 정해진 컨테이너 (backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 엔진 설정을 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고. - 모드 감지 — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
- bootstrap — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
응답의 무작위 관리자 비밀번호는 한 번만 보이므로
data/mail/bootstrap-admin.json(git 무시)에 잠시 보관했다가 교체 후 지운다. ⚠️Bootstrap/set은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서- 부르기 전에
data/mail/에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다 - 재실행 때는 mail 컨테이너의
/etc/stalwart/config.json으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다 (Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
- 부르기 전에
- 관리자 —
<MAIL_ADMIN_LOCAL_PART>@도메인비밀번호를MAIL_ADMIN_PASSWORD로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은--recovery). 갓 bootstrap 한 엔진이면 bootstrap 이 만든admin@의 아이디를MAIL_ADMIN_LOCAL_PART로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다). 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) 중단한다 - 자동 차단 예외 — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다).
신뢰 대역 =
MAIL_TRUSTED_NETWORKS, 비우면APP_NETWORK_SUBNET(compose 가 고정한 app-network 서브넷). 서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다. 이 스크립트가 만든 예전 항목은 지운다. - 게이트웨이 — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
- 잠금 — 직원(
User) 역할에서 Principal 조회 권한 13개(jmapPrincipal*·davPrincipal*)를 뺀다(→InvalidateCaches). 공개 443(https 리스너)의 HTTP 는/.well-known/mta-sts.txt만 열고 나머지는 403 (x:Http의allowedEndpoints) — 보안 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 (x:MtaStageRcpt의script+ 시스템 Sieve 스크립트 — 보안 "메일을 받지 않는 시스템 계정") - 사내 목록 계정 — 계정 목록 읽기 전용 역할(
authenticate·jmapPrincipalGet·jmapPrincipalQuery) + 비밀번호 없는 계정<MAIL_DIRECTORY_LOCAL_PART>@도메인. 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때만(개발 · 운영--recovery) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고 역할·계정이 없거나 다르면[주의]— 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로Principal/query·get을 해 본다 (사내 목록 계정) - 엔진 한도 — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 —
로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고
[주의](엔진 한도). 계정마다 임시 업로드 크기 합계(x:Jmap의uploadQuota— 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 (엔진 한도 "임시 업로드 한도"). 둘 다 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 까지 가더라도 반송이 생기지 않는다 - [개발] 외부 발송 경로 — Relay
mailpit:1025+ 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다) - [개발] 테스트 직원 계정 → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(
Principal/get→ forbidden)까지 자체 확인 - 시범 공존 (
MAIL_PILOT_LEGACY_HOST가 있을 때 — 파일럿 공존) — 바꾸기 전에 도메인 · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 보고, 막는 것(맨 앞에 주인 표시인 자동 차단 예외 · 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 스킬 "운영 환경" |
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 · 본문 한도·시간 제한). 다른 서버의 Nginx Proxy Manager 라면 설정 ·TRUST_PROXY는 리버스 프록시가 다른 서버에 있을 때 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 을 손으로 부르지 않는다.
- 엔진이 어디까지 갔는지 본다:
sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남" - 실패 원인을 고친다 — 출력의
[실패]줄.data/mail에 쓸 수 없다고 나왔다면 소유자를 바꾼다:sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail(sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로) - 셸에
MAIL_RECOVERY_ADMIN이 남아 있는 채로 같은 명령(--recovery)을 다시 실행한다. bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다. 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 와 별개 서비스라 각자 이용 조건이 있다.
- 메일 서버 전용 재귀 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" 경고가 사라진다.
- 공용·ISP DNS(KT
- Spamhaus 이용 조건을 맞춘다.
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 무료 DQS 키(사용량 한도 안)를 발급받아
Spamhaus 두 항목(
STWT_RBL_SPAMHAUS_IP,STWT_DBL_SPAMHAUS_DOMAIN)의 조회 주소를 DQS 주소로 바꾸거나, 조건이 맞지 않으면 두 항목을 끈다 (x:SpamDnsblServer/set으로enable: false→ReloadSettings). - 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다.
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 무료 DQS 키(사용량 한도 안)를 발급받아
Spamhaus 두 항목(
- 조회 거부 응답의 처리를 시험한다 (미검증).
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.")로, 올린 것이 만료될 때까지 막힌다. 백엔드는 크기 · 개수 어느 쪽이든 429REQ_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바이트)으로 올리면 엔진이 그 파일을 400urn:ietf:params:jmap:error:limit(maxSizeUpload)로 거절하고 사용자에게는FILE_002로 보인다 — 그때는 이 값도 함께 올린다(관리 APIx: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)는 비어 있다 — 이제 웹메일 발송에는 엔진의 받는 쪽 한도도 걸리지 않으므로, 남는 것은 백엔드의 요청 수 한도(루트 envTHROTTLE_*— IP 당)뿐이다. 계정이 털리거나 잘못 쓰여 한꺼번에 많이 보내면 바깥 서버들의 한도에 걸려 서버 평판이 떨어진다. 보낸 사람마다 한도(keysender— 넘으면 거절이 아니라 대기열에서 미뤄진다, Outbound rate limits)를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다. - 직원마다 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, mailcowmail.arasystem.it.kr(46.250.248.43), 새 서버 IP<MX1_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 는 더하기만 (가비아) — 전환 절차의 단계 1 · 3 · 4:
mx1A →<MX1_IP>· PTR →mx1.arasystem.it.kr, setup 이 출력한 새 DKIM selector TXT 두 개(v1-ed25519-…·v1-rsa-…), SPF 에ip4:<MX1_IP>. 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 이 멈춘다):
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 을 실행한다:
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.2waitOnFail 뒤(개발 4,999waitOnFail 이 바뀌어 있어도 확인은 보낸다 — 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 |
<HOST>:<포트>, 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:<setup-check-<난수>@도메인>→ 550 이고 2초 안 (RCPT 가드, 보낸 사람은 빈 봉투). 아니면 relay 를 켜지 않고 종료 코드 1(2차 리뷰 N2). 이미 켜진 시범이면 relay 가 켜진 채로 보고, 가드가 답하지 않으면 곧바로 끈다. - 켠 뒤(C 바로 뒤): 같은 확인. 아니면
allowRelaying을 곧바로 다시 끄고(다시 읽어 확인) 종료 코드 1 — 가드 · 경로는 남겨 두어 원인을 고친 뒤 같은 명령으로 다시 켠다(2026-10-30 리뷰 M1). 끄지 못하면 "끄려 했지만 끄지 못했습니다 — 지금 가드 없이 열려 있을 수 있습니다"로 알린다(N1). - 인증 없는 세션
MAIL FROM:<setup-check-<난수>@도메인>→550 5.7.1(메일 컨테이너는 기존 서버 IP 가 아니다) - [개발] 인증한 세션(465, 테스트 직원)
RCPT TO:<setup-check-…@도메인>→ 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 = "<legacy 주소>", 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.30.7ms(켠 채로는 0.40.7ms) — relay 와 상관없이 가드의 빠른 550 이다. - 다시 읽기(N1) —
x:Domain/set(allowRelaying false · true) 바로 뒤x:Domain/get100번 모두 저장한 값과 같았다. - waitOnFail 을 바꾼 엔진(N3) —
waitOnFail1s + 가드가 먹는 엔진:[주의] 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 의 명령으로 먼저 대조한다.
cd /opt/mailcow-dockerized
alias dcx='docker compose exec postfix-mailcow'
-
읽기만 하는 점검 — 로컬로 새는 길이 없는지:
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 # <MX1_IP> 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)도 본다.
-
postscreen 예외 —
data/conf/postfix/custom_postscreen_whitelist.cidr에<MX1_IP>/32 permit(재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. -
Fail2ban 예외 — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에
<MX1_IP>(imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). -
새 서버 setup — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다).
-
반대 방향부터 시험 — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다:
docker compose logs --since 15m postfix-mailcow | grep -E 'from=<owner@arasystem\.it\.kr>|\[<MX1_IP>\]' # 좋음: client=mx1.arasystem.it.kr[<MX1_IP>] … 다음 줄에 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 에게 반송한다(루프 없음). -
imapsync 1차 — mailcow → 새 서버, owner 만 (imapsync).
-
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 의 사내 도메인 메일을 받는지 본다. 그다음: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=<owner(\+[^@]*)?@arasystem\.it\.kr>' # relay=mx1.arasystem.it.kr[<MX1_IP>]:25 … status=sent -
마지막 imapsync(차이분).
-
owner 의 메일 프로그램을 mx1 로 — IMAP
mx1.arasystem.it.kr:993· SMTP:465, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다. -
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로 거절된다. -
며칠 본다 — 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 로)
- 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가 비어야 한다. - 큐에 남은 owner 앞 메일 —
dcx postqueue -p로 보고dcx postqueue -f(다시 경로를 받아 로컬로). - 시범 동안 새 서버로 들어온 메일 · 보낸 메일을 mailcow 로 되복사 — 반대 방향 imapsync(새 서버 → mailcow) 또는 mailcow 의 Synchronizations 에 mx1 IMAP 을 원본으로.
- owner 의 mailcow 접근 되살리기 — Active 를 Active 로, Allowed protocols · SOGo · 앱 비밀번호, 메일 프로그램을 mailcow 로.
- 새 서버 대기열에 동료 앞 메일이 남지 않았는지 본다 — 관리 화면의 메일 대기열(Queue) 또는 관리 API
x:QueuedMessage/query·/get. 시범을 끄면 발송 전략이 "사내 도메인 → 로컬"로 돌아가, 아직 mailcow 로 못 간 동료 앞 메일(mailcow 일시 장애 · 지연 — 다시 시도는 3일, 동료 앞 반송은 10번까지)은 다음 시도에서 로컬 배달 실패 → owner 에게 반송된다. 남아 있으면 mailcow 가 받을 수 있게 된 뒤 다 나갈 때까지 기다리거나(대기열에서 바로 다시 시도), 기다릴 수 없으면 그 목록(받는 사람 · 제목)을 적어 두고 끈 뒤 owner 가 mailcow 에서 다시 보낸다. - 새 서버 — Transport Map 을 끈 뒤에 시범을 끝낸다: 두 값을 비우고
--end-pilot(아래). 거꾸로 하면 그사이 owner 가 동료에게 보낸 메일이not-found로 거절된다. - 넣었던 것 정리 — postscreen 예외 줄, Fail2ban 예외. DNS 의
ip4:<MX1_IP>· 새 DKIM TXT 는 남겨도 해가 없다.
시범 끝내기 — 전면 전환
전환 절차대로 모두의 계정을 새 서버에 만들고(단계 2) MX 를 mx1 로 바꾼다(단계 6). 그 뒤:
- MX 전파(TTL)가 끝나 mailcow 에 새 수신이 없어지면 mailcow 의 Transport Map 행을 지운다(Remove) — 그때까지 mailcow 로 온 owner 앞 메일은 그대로 새 서버로 넘어온다.
- 새 서버 —
.env.production의 두 값을 비우고--end-pilot으로 실행한다:node --env-file=.env.production mail/scripts/setup.mjs --end-pilotallowRelaying(꺼진 것을 다시 읽어 확인) → 발송 전략 → 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,9995,003ms — setup 은 이 차이로 가드가 걸렸는지 본다). 시범 중 MX 는 mailcow 라 mx1:25 에 바로 붙는 것은 스캐너뿐이다. 시스템 계정 거절도 같은 성질이다. - mailcow 가 멈추면 — 동료 앞 메일은 새 서버 대기열(
local일정 — 3일)에서 다시 시도하다 반송되고, owner 앞 메일은 mailcow 큐(4시간 뒤 지연 안내, 5일 뒤 반송)에서 기다린다. - DMARC 보고 — 엔진은 자기 도메인(arasystem.it.kr) 메일로는 DMARC · DKIM 실패 보고를 만들지 않는다(소스
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)
전면 전환 전에 한 사람이 먼저 새 서버를 쓴다 — 파일럿 공존(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 기본 리포트 수신처). 시범 공존 중이면 전면 전환 때 한다 — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다(파일럿 공존 "주의") |
관리 화면 계정 목록 |
| 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(rotateAfter90일 ·retireAfter7일 ·deleteAfter30일)이지만, 0.16.27 은 엔진이 DNS 를 직접 고칠 때(dnsManagement: Automatic+ DKIM 게시)만 교체한다. DNS 를 손으로(가비아) 관리하는 지금 설정(dnsManagement: Manual)에서는 활성 키에 교체 일정이 생기지 않아(x:DkimSignature의nextTransitionAt가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 (소스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) 키를 모두 서명에 쓴다. 교체가 필요해지면 개발 엔진에서 먼저 시험한다.
- 주의 — 알고리즘에 활성 키가 없으면 엔진이 새 키를 만들어 DNS 에 게시하기 전에 곧바로 서명에 쓴다(DNS 수동일 때). 키를 지우거나, 알고리즘(
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로그에 남는다.
- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(
- 신뢰 대역 = 고정한 앱 네트워크 서브넷. 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 로 만든다.
- 2026-10-26 확인: https 의
- 직원은 계정 목록을 볼 수 없다. 기본
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/setupdate {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}}→InvalidateCaches를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면ensureUserRoleWithoutPrincipals단계를 먼저 고친다) - 백엔드가 사내 목록을 읽는 것은 따로 만든 사내 목록 계정(권한 셋, 비밀번호 없음)으로만이다 — 직원 역할에 되돌려 주지 않는다 (사내 목록 계정).
- 직원이 공용 주소(Group 계정)의 구성원이 되어도 Group 역할의 Principal 권한을 물려받지 않는다 (2026-10-29 확인 — 구성원이 된 뒤에도
Principal/query→ forbidden).
- 2026-10-26 확인: 테스트 직원의
- 메일을 받지 않는 시스템 계정은 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 을 이어서 돌리면[유지], 관리자를 더 만들거나 지우면[변경].
- 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 을 나눠 쓰지 않는다.
- IP 를 하나 더 붙인다 (가장 단순) — 메일 포트를 그 IP 에만 바인딩:
"<메일 전용 IP>:443:443"등. 나머지는 그대로. - 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; } } - ⚠️ mail 과 L4 프록시만 붙는 전용 네트워크를 새로 만든다 (예:
- 프록시가 TLS 종단 →
mail:8080— 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다. - 메일 서버를 따로 둔다 — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다. 프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신. → 리버스 프록시가 다른 서버에 있을 때
⚠️ 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
<PROJECT_NAME>-frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail
mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME
- 선택 파일
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) |
| 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 는 바꾼다:
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 # <EDGE_NETWORK_SUBNET>,<서버 A 사설 IP> — 아래 "TRUST_PROXY"
COMPOSE_FILE은 env 파일에 둔다 — compose 가--env-file의 이 값을 읽으므로 이 문서의 운영 명령(sudo docker compose --env-file .env.production …)이-f없이 그대로 두 파일을 읽는다.-f두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는proxy네트워크)으로 돈다.-f를 주면COMPOSE_FILE보다 그쪽이 이긴다.- 그다음은 운영 배포 절차 그대로다. 띄우기 전 · 뒤에 본다:
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 는 networksedge하나, portshost_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 · frontendCreated(시작하지 않음)이고, 떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가Created로 남았다 (시험용 프로젝트 — frontend 대신 alpine).
TRUST_PROXY — 서버 A 의 주소 목록
-
값은
<EDGE_NETWORK_SUBNET>,<서버 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.11203.0.113.50203.0.113.50브라우저가 꾸밈 — 1.2.3.4, 203.0.113.50, 10.0.1.11203.0.113.50203.0.113.50같은 VPC 의 다른 서버가 8080 에 바로 — 1.2.3.4, 10.0.2.2010.0.2.201.2.3.4(꾸민 값)서버 A 의 다른 컨테이너가 8080 에 바로 — 1.2.3.4, 10.0.1.111.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 ·
proxy.conf ·
block-exploits.conf ·
assets.conf ·
log-proxy.conf ·
템플릿 proxy_host.conf ·
_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 가 백엔드를 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=<b>javascript</b>(%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 에는 넣어 보지 않았다)
확인 (배포 뒤)
# 서버 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_001JSON)다 — 기본 구성과 같다.
문제 해결
| 증상 | 조치 |
|---|---|
셸 환경변수가 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 까지만 보낸다 |
시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다 · … allowRelaying 만 닫고 중단합니다 |
아무것도 바뀌지 않았다 — 뒤의 머리글이면 가드 없이 열려 있던 relay 만 닫았다(위에 [변경] … allowRelaying 을 껐다, 엔진에 없는 사내 주소는 다시 550). 아래 줄의 항목을 고친다 — 기존 서버에 닿지 않으면 이 호스트의 바깥 25 · 기존 서버의 STARTTLS · 인증서 이름, 자기 자신으로 풀리면 MAIL_PILOT_LEGACY_HOST, 기존 서버 IP 의 사람이 만든 자동 차단 예외 · 기존 서버 IP 로 된 운영자 갈래(아래 줄)는 지운 뒤 다시, waitOnFail 은 기본값(5s)으로, DKIM 은 아래 줄 (파일럿 공존 "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 <selector>._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.* |