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>
This commit is contained in:
2026-10-29 14:06:27 +09:00
parent 7615329c32
commit 59420df1b9
7 changed files with 693 additions and 17 deletions
+91 -6
View File
@@ -46,6 +46,7 @@
| `<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` |
---
@@ -103,16 +104,23 @@ node --env-file=.env.development mail/scripts/setup.mjs
5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`).
공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안)
7. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 —
메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게
(`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정")
7. **사내 목록 계정** — 계정 목록 읽기 전용 역할(`authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery`) + 비밀번호 없는 계정 `<MAIL_DIRECTORY_LOCAL_PART>@도메인`.
엔진 관리자는 이 권한을 내줄 수 없어 **복구 관리자가 있을 때만**(개발 · 운영 `--recovery`) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고
역할·계정이 없거나 다르면 `[주의]` — 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로 `Principal/query`·`get` 을 해 본다 ([사내 목록 계정](#사내-목록-계정))
8. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 —
로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)).
계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 —
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 8 의 리로드로 반영된다
8. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 9 의 리로드로 반영된다
9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다.
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안))
9. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 8 의 리로드로 반영된 것을 확인한 뒤에만 10
10. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)).
시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다)
10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11
(7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다)
11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
그 밖의 안전장치:
@@ -139,16 +147,62 @@ node --env-file=.env.development mail/scripts/setup.mjs
| `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 |
| `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" |
| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) |
| `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) |
- 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401
- 메일함 접근: Basic `<직원 주소>%<MAIL_GATEWAY_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](../.claude/rules/security.md)).
### 사내 목록 계정
백엔드가 사내 목록(계약 [3절 "사내 목록"](../docs/mail/api-contract.md) — `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 스크립트가 하는 일](#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. 아이디를 바꿨다 되돌리면 `[생성]` 새 계정 · `[삭제]` 옛 계정.
---
## 운영 배포
@@ -170,6 +224,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **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(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
@@ -206,6 +261,8 @@ export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings
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`.
@@ -240,6 +297,9 @@ sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' |
```
- 실제 직원 계정은 관리 화면이나 관리 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` 로 네트워크와
@@ -502,6 +562,21 @@ sudo docker compose --env-file .env.production start mail
- ⚠️ 되돌리기는 **일반 관리자로는 안 된다** — 관리자 역할(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 가 중계해 컨테이너에는
@@ -574,5 +649,15 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
| 웹메일 발송의 받는 사람이 `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.*` |