diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 6b48fd7..b4fe872 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -24,9 +24,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ ├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨 ├─▶ uploads : 운영은 named volume _uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트 ├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*· - │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS) + │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART) │ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포") │ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다 + │ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery) └─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md) MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다 ``` @@ -71,7 +72,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ > 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, > `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`), > 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)· -> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`· +> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)· > `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)· > `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다) > (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조. diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 0b3bbdc..8a4b88c 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -122,6 +122,8 @@ sudo docker compose --env-file .env.production logs -f backend **다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`). 홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 `X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등). + - **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다. + "직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다. - **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고 사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다). - **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"): @@ -294,7 +296,8 @@ node --env-file=.env.development mail/scripts/setup.mjs - 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)· `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다. - 데이터는 named volume `_mail-config`·`_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다). -- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회. +- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회, + 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일(→ RCPT 에서 550 — 받아 놓고 위조된 보낸 사람에게 반송하지 않게, mail/README.md "보안"). - setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도"). 신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다. @@ -307,6 +310,7 @@ node --env-file=.env.development mail/scripts/setup.mjs (파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포"). - 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다). **bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다. +- 사내 목록 계정 `@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정"). - ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도 대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안". diff --git a/.env.development.example b/.env.development.example index 4425fe9..fcfca61 100644 --- a/.env.development.example +++ b/.env.development.example @@ -140,6 +140,11 @@ MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env # ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 MAIL_GATEWAY_USER=mail-gateway@arasystem.test MAIL_GATEWAY_PASSWORD=change-me-in-local-env +# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때 +# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없고, setup 스크립트가 복구 관리자(위 MAIL_RECOVERY_ADMIN)로 만든다. +# 관리자·게이트웨이 아이디와 달라야 한다. 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "사내 목록 계정"). +# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙) +MAIL_DIRECTORY_LOCAL_PART=mail-directory # 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다 MAIL_JMAP_URL=http://mail:8080 # 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다 diff --git a/.env.production.example b/.env.production.example index d0470ea..58f8d9d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -149,6 +149,12 @@ MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY # ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY +# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때 +# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없다. 관리자·게이트웨이 아이디와 달라야 하고, 옮겨 올 메일함과 겹치지 않게 정한다. +# ⚠️ 엔진 관리자는 이 계정의 권한을 내줄 수 없어 setup 의 --recovery 실행(위 복구 관리자 절차)에서만 만들어진다 — 첫 배포 때 만들어지고, +# 이미 운영 중인 서버라면 --recovery 를 한 번 더 한다. 없는 동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 (mail/README.md "사내 목록 계정"). +# 바꾸면 --recovery 로 setup 을 다시 실행하고 backend 를 다시 띄운다. 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙) +MAIL_DIRECTORY_LOCAL_PART=mail-directory # 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망) MAIL_JMAP_URL=http://mail:8080 # 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다. diff --git a/docker-compose.yml b/docker-compose.yml index 7439cf2..a3f86b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,9 @@ services: - MAIL_ADMIN_LOCAL_PART=${MAIL_ADMIN_LOCAL_PART:-admin} # 관리자 외에 웹메일 로그인·대리 접근을 막을 계정 (주소 전체, 콤마). 비어 있어도 위 관리자는 항상 막는다 - MAIL_BLOCKED_ACCOUNTS=${MAIL_BLOCKED_ACCOUNTS:-} + # 사내 목록 계정 아이디 — mail setup 이 만든 비밀번호 없는 계정(<이 값>@MAIL_DOMAIN). 백엔드가 게이트웨이 대리 접근으로 + # 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는다 (docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md) + - MAIL_DIRECTORY_LOCAL_PART=${MAIL_DIRECTORY_LOCAL_PART:-mail-directory} # 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다 # (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md) - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} diff --git a/mail/README.md b/mail/README.md index d20c5f6..375c127 100644 --- a/mail/README.md +++ b/mail/README.md @@ -46,6 +46,7 @@ | `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` | | 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` | | `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` | +| `@` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 | | `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` | --- @@ -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`) + 비밀번호 없는 계정 `@도메인`. + 엔진 관리자는 이 권한을 내줄 수 없어 **복구 관리자가 있을 때만**(개발 · 운영 `--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_PASSWORD` (게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403) +- 사내 목록: Basic `@%` : `MAIL_GATEWAY_PASSWORD` → 세션의 `urn:ietf:params:jmap:principals` 계정으로 + `Principal/query`·`Principal/get` ([사내 목록 계정](#사내-목록-계정) — 계정이 없으면 401, 역할에 `authenticate` 가 없으면 403) - ⚠️ 세션 응답의 `apiUrl`·`downloadUrl`·`uploadUrl`·`eventSourceUrl` 은 **`MAIL_PUBLIC_URL` 기준 절대 주소**다 (개발 `http://localhost:8180/jmap/`). 백엔드는 이 주소를 따르지 말고 `MAIL_JMAP_URL` 에 경로만 붙여 쓴다. - `Email/query` 의 `subject` 필터는 단어 단위 검색이고 제목 앞의 `[태그]` 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다. - 대리 접근은 메일 엔진 로그 `auth.success` 에 `accountName`(대상 직원)·`details`(게이트웨이)·`remoteIp` 로 남는다. - 신뢰 대역은 자동 차단에서 빠지므로 **로그인 무차별 대입 방어는 백엔드가 맡아야 한다** (로그인 엔드포인트 엄격 한도 — [security](../.claude/rules/security.md)). +### 사내 목록 계정 + +백엔드가 사내 목록(계약 [3절 "사내 목록"](../docs/mail/api-contract.md) — `GET /api/directory-entries` 와 받는 사람 추천의 "사내")을 채우려고 +엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는 전용 계정이다. 직원 역할에서는 이 권한을 뺐으므로([보안](#보안)) 계정 목록을 읽는 길은 이 계정 하나뿐이다. + +| 항목 | 값 | +| --- | --- | +| 주소 | `@` (기본 `mail-directory@`) — 관리자·게이트웨이 아이디와 같으면 setup 이 아무것도 바꾸기 전에 멈춘다 | +| 역할 | 설명 `사내 목록 — 계정 목록 읽기 전용 [setup.mjs]` · 권한 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐 (빼는 권한·물려받는 역할 없음). `authenticate` 가 없으면 대리 접근이 403 이다 | +| 계정 | 표시 이름 `사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]` · **비밀번호 없음** · 저장 한도 `quotas.maxDiskQuota` 1,048,576바이트 | +| 쓰는 법 | 백엔드만 — 게이트웨이 대리 접근 `<사내 목록 주소>%` 로 세션의 `urn:ietf:params:jmap:principals` 계정에 `Principal/query`·`Principal/get` | +| 엔진이 주는 칸 | `id`·`type`(`individual` 개인 · `group` 공용 주소)·`name`(로그인 이름)·`description`(표시 이름)·`email` 뿐 — 비밀번호 같은 칸은 없다. 관리자·게이트웨이·이 계정도 목록에 나오므로 빼는 것은 백엔드 몫이다 (계약 3절 "빼는 계정") | +| 메일 | 받지 않는다 — 이 주소(+태그 꼴 포함)로 오는 메일은 RCPT 에서 없는 주소처럼 550 이다([보안](#보안) "메일을 받지 않는 시스템 계정"). 그 뒤의 막으로 역할에 `emailReceive` 가 없고(배달하지 않는다) 저장 한도도 작다 | + +- **왜 복구 관리자인가** — 엔진 관리자 역할(System Administrator)에는 `jmapPrincipal*` 이 없고, 엔진은 역할·계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를 바꾸는 쪽이 가졌는지 본다. + 그래서 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 (`You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet`). + 관리자로 되는 것은 계정의 표시 이름·저장 한도 고치기, 비밀번호 빼기, 계정 지우기다 (2026-10-29, 0.16.27 에서 확인). +- **언제 만들어지나** — 개발은 setup 을 돌릴 때마다(env 파일에 복구 관리자가 있다). 운영은 [운영 배포](#운영-배포)의 `--recovery` 실행(첫 배포)에서 만들어진다. + **이미 운영 중인 서버**는 같은 절차(1 복구 관리자 주기 → `up -d mail` → 3 `--recovery` → 4·5 끄기)를 한 번 더 한다 — 이미 맞는 나머지는 `[유지]` 다. +- **복구 관리자 없이 돌리면**(운영의 보통 실행) — 읽어서 확인하고, 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기·옛 계정 지우기)만 맞춘다. + 역할·계정이 없거나 역할이 다르면 `[주의]` 만 내고 **실패로 끝내지 않는다** — 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록(최근 90일)을 쓴다 (계약 3절). + `--recovery` 로 돌렸는데 복구 관리자로 로그인하지 못해 맞추지 못했으면 실패로 끝난다(mail 컨테이너를 그 값으로 다시 만들었는지 본다). +- **비밀번호가 없다** — 이 계정으로는 직접 로그인할 수 없고, 게이트웨이 비밀번호를 가진 백엔드만 대리 접근으로 쓴다. + 누가 비밀번호를 넣으면 그것으로 계정 목록(관리자·게이트웨이 주소 포함)을 읽을 수 있으므로 setup 이 다음 실행에서 뺀다(관리자로도 된다). +- 이 역할은 기본 직원 역할(`User`)과 따로다 — 직원 역할에서 `jmapPrincipal*` 을 빼는 단계([setup 스크립트가 하는 일](#setup-스크립트가-하는-일) 6)는 이 역할을 건드리지 않는다. + +지우거나 바꾸면: + +| 한 일 | 생기는 일 | 되돌리기 | +| --- | --- | --- | +| 계정을 지움 | 대리 접근이 401 → 사내 목록 `503 MAIL_001`, 받는 사람 추천의 "사내"는 로그인한 직원 기록으로 | 복구 관리자로 setup (운영 `--recovery`) | +| 역할을 지움 | 계정이 쓰는 동안은 엔진이 거절한다(`objectIsLinked`) — 계정을 먼저 지워야 지워진다 | 위와 같다 | +| 역할에 권한을 더함 · 물려받는 역할을 붙임 | 그 권한이 이 계정(= 게이트웨이를 가진 백엔드)에 열린다 — setup 이 `[변경]`(복구 관리자) 또는 `[주의]` | 복구 관리자로 setup — 관리자로는 뺄 수 없다 | +| 역할에서 `authenticate`·`jmapPrincipal*` 을 뺌 (빼는 권한에 넣음 포함) | 계정 목록을 읽지 못한다 → 사내 목록 `503 MAIL_001` | 복구 관리자로 setup | +| 계정의 역할을 바꿈 | 위의 둘 중 하나 | 복구 관리자로 setup | +| 계정에 비밀번호를 넣음 | 그 비밀번호로 계정 목록을 읽을 수 있다 | setup (관리자로도 뺀다) | +| 다른 계정에 이 역할을 줌 | 그 계정이 계정 목록 전체를 읽는다 — setup 이 `[주의]` | 관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) | +| `MAIL_DIRECTORY_LOCAL_PART` 를 바꿈 | 복구 관리자로 돌린 setup 이 새 계정을 만들고, 그 뒤에 옛 계정(setup 표시 + 이 역할 하나)을 지운다 — backend 도 다시 띄운다(`up -d backend`) | 값을 되돌리고 같은 절차 | + +- 확인 (2026-10-29, 개발 엔진 0.16.27): 처음 실행은 `[생성]` 둘(역할·계정)과 `[확인] 사내 목록 — 대리 접근 … (Principal/query · get)`, 다시 돌리면 `[유지]`. + 역할에 권한·빼는 권한·물려받는 역할을 붙이고 계정에 비밀번호를 넣은 뒤 돌리면 복구 관리자로 모두 `[변경]`, 복구 관리자 없이 돌리면 계정만 `[변경]`(관리자로)이고 역할은 `[주의]` · 종료 코드 0. + 계정을 지우고 복구 관리자 없이 돌리면 `[주의] 사내 목록 계정이 없습니다 (계정 없음)` · 종료 코드 0. 아이디를 바꿨다 되돌리면 `[생성]` 새 계정 · `[삭제]` 옛 계정. + --- ## 운영 배포 @@ -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:///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::8080 <메일 호스트>` → . setup 은 운영에서 직원 계정을 만들지 않는다. + ⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어 + `You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). + 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). - `MAIL_HOSTNAME` 은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다. - 관리자 비밀번호를 바꾸려면: env 의 `MAIL_ADMIN_PASSWORD` 변경 → 위 1)·`up -d mail` → `--recovery` 실행 → 4)·5). - 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은 `sudo docker compose --env-file .env.production down` 후 `up -d` 로 네트워크와 @@ -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 {"": {"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.*` | diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index 2755084..73a5bd1 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -6,7 +6,7 @@ * node --env-file=.env.development mail/scripts/setup.mjs * node --env-file=.env.development mail/scripts/setup.mjs --print-dns # 권장 DNS 레코드도 출력 * MAIL_RECOVERY_ADMIN=… node --env-file=.env.production mail/scripts/setup.mjs --recovery - * # 운영의 최초 bootstrap·관리자 복구 — 복구 관리자는 일회성으로만 준다 + * # 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다 * * 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md). * 비밀번호는 어떤 경우에도 출력하지 않는다. @@ -32,15 +32,22 @@ * 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능) * 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게 * 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다) + * 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — + * 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다) * 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다 * (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안") - * 7. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영) + * 7. 사내 목록 계정 목록(Principal) 읽기 전용 역할 + 비밀번호 없는 계정(@도메인) — 백엔드가 게이트웨이 + * 대리 접근으로 직원 이름 · 주소만 읽는다 (계약 docs/mail/api-contract.md 3절 "사내 목록"). + * 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때(개발 · 운영 --recovery)만 만들고 맞춘다. + * 없으면 관리자로 되는 것(설명 · 저장 한도 · 비밀번호 빼기)만 맞추고 [주의] — 실패로 끝내지 않는다 + * 8. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영) * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 - * 8. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인 - * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 9 보다 먼저 한다) - * 9. [개발] 테스트 직원 계정 + * 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인 + * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다. + * 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다) + * 10. [개발] 테스트 직원 계정 * * 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행) * — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기 @@ -99,6 +106,8 @@ const HTTP_ENDPOINT_POLICY = { }; /** 기본 직원 역할 — 계정의 roles {"@type":"User"} 가 가리키는 기본 제공 역할 */ const USER_ROLE_DESCRIPTION = 'User'; +/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */ +const ADMIN_ROLE_DESCRIPTION = 'System Administrator'; /** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */ const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/; /** @@ -173,6 +182,60 @@ const GATEWAY_PERMISSIONS = { authenticate: true, impersonate: true }; const GATEWAY_DESCRIPTION = `메일 게이트웨이 (백엔드 대리 접근 전용) ${MANAGED_MARK}`; const ALLOWED_IP_REASON = `${MANAGED_MARK} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`; +/** + * 사내 목록 계정 아이디 기본값 — 루트 env MAIL_DIRECTORY_LOCAL_PART. 백엔드도 이 값 하나를 읽으므로 기본값이 같아야 한다 + * (형식은 위 LOCAL_PART_PATTERN — MAIL_ADMIN_LOCAL_PART 와 같은 규칙). + */ +const DEFAULT_DIRECTORY_LOCAL_PART = 'mail-directory'; +/** + * 사내 목록 계정 — 백엔드가 엔진의 계정 목록(JMAP Principal)에서 직원 이름 · 주소만 읽는 전용 계정 + * (계약 docs/mail/api-contract.md 3절 "사내 목록"). 역할은 로그인 + 계정 목록 읽기뿐이고 비밀번호가 없다 — + * 백엔드가 게이트웨이 대리 접근(<사내 목록 주소>%<게이트웨이 주소>)으로만 쓴다. authenticate 가 없으면 대리 접근이 403 이다. + * ⚠️ 엔진 관리자 역할(System Administrator)에는 jmapPrincipal* 이 없고, 엔진은 역할 · 계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를 + * 바꾸는 쪽이 가졌는지 본다 — 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 + * ("You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet"). 그래서 복구 관리자로만 만들고 맞춘다. + * 관리자로 되는 것: 계정의 설명 · 저장 한도 고치기, 비밀번호 빼기, 계정 지우기 (2026-10-29 Stalwart 0.16.27 에서 확인 — ensureDirectoryAccount). + */ +const DIRECTORY_ROLE_DESCRIPTION = `사내 목록 — 계정 목록 읽기 전용 ${MANAGED_MARK}`; +const DIRECTORY_PERMISSIONS = { authenticate: true, jmapPrincipalGet: true, jmapPrincipalQuery: true }; +const DIRECTORY_DESCRIPTION = `사내 목록 (백엔드 계정 목록 읽기 전용) ${MANAGED_MARK}`; +/** + * 사내 목록 계정의 저장 한도(x:Account 의 quotas — maxDiskQuota, 바이트). 주소가 있으니 메일이 쌓이지 않게 작게 둔다. + * 역할에 emailReceive 가 없어 엔진은 이 주소로 온 메일을 배달하지 않고 보낸 사람에게 반송한다(security.unauthorized "emailReceive", + * 2026-10-29 확인) — 한도는 그 위의 두 번째 막이다. 0 은 받기는 하지만 뜻(제한 없음인지)을 확인하지 않아 쓰지 않는다. + */ +const DIRECTORY_QUOTAS = { maxDiskQuota: 1024 * 1024 }; +/** 계정의 권한 설정 기본값 — 역할에서만 물려받는다 (계정에 직접 권한을 더하지 않았다) */ +const INHERIT_PERMISSIONS = { '@type': 'Inherit' }; +/** JMAP 계정 목록(Principal) — 메일 쪽 세션의 이 능력 계정으로 Principal/query · get 을 부른다 */ +const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals'; + +/** + * 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 받는 쪽 RCPT 단계에서 없는 주소처럼 거절한다. + * 세 계정의 역할에는 emailReceive 가 없어(관리자의 System Administrator 역할도) 엔진은 RCPT 를 250 으로 받아 놓고, 배달할 때 + * security.unauthorized "emailReceive" 로 거절해 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 메일이면 모르는 사람에게 반송이 나가 + * 서버 평판이 떨어진다(backscatter). 없는 주소는 처음부터 RCPT 에서 550 이다 (2026-10-29 Stalwart 0.16.27 에서 확인). + * 방법: x:MtaStageRcpt 의 script 식이 받는 주소가 이 계정들일 때만 아래 시스템 Sieve 스크립트를 고르고, 스크립트는 reject 한다 + * (reject 의 글이 그대로 SMTP 응답이 된다). 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다. + * 엔진은 식의 rcpt 를 소문자로 넘기지만 +태그(부주소)는 떼지 않는다 — 그래서 '<아이디>+…@<도메인>' 꼴도 함께 본다. + * 반영은 ReloadSettings 뒤다 (main 의 reloadSettings — 그 전에는 옛 규칙으로 받는다). + */ +/** 엔진이 없는 주소에 주는 응답과 같은 글 — 바깥에서 이 계정들이 있는지 알아볼 수 없게 */ +const SYSTEM_RCPT_REPLY = '550 5.1.2 Mailbox does not exist.'; +const SYSTEM_RCPT_SCRIPT = { + name: 'arasystem-system-recipients', + description: `메일을 받지 않는 시스템 계정 — RCPT 에서 거절 ${MANAGED_MARK}`, + contents: ['require ["reject"];', `reject "${SYSTEM_RCPT_REPLY}";`].join('\r\n'), +}; +/** x:MtaStageRcpt 의 script 기본값 — RCPT 단계 스크립트 없음 */ +const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' }; +/** 식에 그대로 넣는 주소의 꼴 — 따옴표 · 공백 · 식 문법이 들어갈 수 없게 (아이디는 loadConfig 가 이미 LOCAL_PART_PATTERN 으로 봤다) */ +const PLAIN_ADDRESS_PATTERN = /^[a-z0-9._-]+@[a-z0-9.-]+$/; +/** RCPT 거절 확인에 쓰는 EHLO 이름 — 예약 도메인 (엔진은 FQDN 이 아닌 EHLO 를 거절한다) */ +const RCPT_CHECK_EHLO = 'setup-check.example.net'; +/** RCPT 거절 확인에서 한 세션에 보내는 RCPT 수 상한 — 엔진은 한 세션의 RCPT 실패를 5번까지만 받는다 (x:MtaStageRcpt 의 maxFailures) */ +const RCPT_CHECK_MAX_PROBES = 4; + /** 개발 전용 — 사내 도메인 밖 메일을 받는 Mailpit (docker-compose.dev.yml 의 mailpit 서비스) */ const DEV_RELAY = { name: 'mailpit', @@ -350,6 +413,21 @@ function loadConfig(argv) { errors.push(`MAIL_ADMIN_LOCAL_PART(${adminLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`); } + // 사내 목록 계정 아이디 — 백엔드가 같은 값으로 그 계정을 대리 접근해 계정 목록을 읽는다 (ensureDirectoryAccount) + const directoryLocalRaw = readEnv('MAIL_DIRECTORY_LOCAL_PART') ?? DEFAULT_DIRECTORY_LOCAL_PART; + const directoryLocalPart = directoryLocalRaw.toLowerCase(); + if (!LOCAL_PART_PATTERN.test(directoryLocalPart)) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${directoryLocalRaw}").`, + ); + } else if (directoryLocalPart === adminLocalPart) { + errors.push('MAIL_DIRECTORY_LOCAL_PART 가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.'); + } else if (directoryLocalPart === gatewayLocal) { + errors.push('MAIL_DIRECTORY_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.'); + } else if (!isProduction && DEV_USERS.some((user) => user.name === directoryLocalPart)) { + errors.push(`MAIL_DIRECTORY_LOCAL_PART(${directoryLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`); + } + // 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다 const blockedAccounts = (readEnv('MAIL_BLOCKED_ACCOUNTS') ?? '') .split(',') @@ -477,6 +555,7 @@ function loadConfig(argv) { gatewayAddress, gatewayLocal, gatewayPassword, + directoryLocalPart, appNetworkSubnet, networkName: `${projectName}_app-network`, trustedNetworks, @@ -1357,7 +1436,8 @@ function preflight(cfg) { /** * 직원(User) 역할에서 Principal 조회 권한을 뺀다. * 기본값으로는 직원 계정이 JMAP Principal/get 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다. - * 웹메일은 백엔드를 거치고 백엔드는 Principal 메서드를 쓰지 않는다. 대리 접근 세션도 직원 역할의 권한을 쓴다. + * 웹메일은 백엔드를 거치고, 대리 접근 세션도 직원 역할의 권한을 쓴다. 백엔드가 계정 목록을 읽는 것은 사내 목록 계정 + * (따로 만든 역할 — ensureDirectoryAccount)으로만이고, 이 함수는 기본 직원 역할(설명 "User")만 건드린다. * 역할 권한은 캐시되므로 ReloadSettings 가 아니라 InvalidateCaches 로 반영한다 (Stalwart 0.16 에서 확인). */ async function ensureUserRoleWithoutPrincipals(client) { @@ -1380,6 +1460,308 @@ async function ensureUserRoleWithoutPrincipals(client) { log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`); } +// ============================================================ +// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만) +// ============================================================ + +/** 복구 관리자의 관리 API 클라이언트 — 값이 없거나, 로그인되지 않거나, 관리 권한이 없으면 null */ +async function openRecoveryClient(cfg) { + if (!cfg.recovery) return null; + const client = await openClient(cfg, cfg.recovery.user, cfg.recovery.password); + if (!client?.accountId) return null; + return (await hasManagementAccess(client)) ? client : null; +} + +/** 값이 true 인 키 (정렬) */ +function trueKeys(value) { + return Object.entries(value ?? {}) + .filter(([, flag]) => flag === true) + .map(([key]) => key) + .sort(); +} + +/** 사내 목록 역할이 setup 기준(DIRECTORY_PERMISSIONS 만 · 빼는 권한 없음 · 물려받는 역할 없음)과 다른 점 — 같으면 빈 배열 */ +function directoryRoleDrift(role) { + const expected = Object.keys(DIRECTORY_PERMISSIONS); + const enabled = trueKeys(role.enabledPermissions); + const drift = []; + const missing = expected.filter((key) => !enabled.includes(key)); + const extra = enabled.filter((key) => !expected.includes(key)); + const disabled = trueKeys(role.disabledPermissions); + const inherited = trueKeys(role.roleIds); + if (missing.length > 0) drift.push(`모자란 권한 ${missing.join(', ')}`); + if (extra.length > 0) drift.push(`더 붙은 권한 ${extra.join(', ')}`); + if (disabled.length > 0) drift.push(`빼는 권한 ${disabled.join(', ')}`); + if (inherited.length > 0) drift.push(`물려받는 역할 ${inherited.join(', ')}`); + return drift; +} + +/** 계정이 그 역할 하나만 가졌는가 (roleId 가 없으면 false) */ +function holdsOnlyRole(account, roleId) { + return Boolean(roleId) && account.roles?.['@type'] === 'Custom' && sameTrueSet(account.roles.roleIds, [roleId]); +} + +/** 계정 하나를 출력할 이름 — 주소, 없으면 아이디 */ +function accountLabel(account) { + return account.emailAddress ?? account.name ?? account.id; +} + +/** + * 사내 목록 계정을 맞춘다 — @<도메인>, 역할 DIRECTORY_PERMISSIONS 하나 · 비밀번호 없음 · 저장 한도 DIRECTORY_QUOTAS + * (계약 docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md "사내 목록 계정"). + * - 복구 관리자를 쓸 수 있으면(개발 · 운영 --recovery) 역할 · 계정을 만들거나 맞춘다. + * - 없으면(운영의 일반 실행) 관리자로 되는 것(계정의 설명 · 저장 한도 · 비밀번호 빼기 · 옛 계정 지우기)만 맞추고, 역할 · 계정이 없거나 + * 권한이 다르면 [주의] 만 낸다 — 실패로 끝내지 않는다 (그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다 — 계약 3절). + * 관리자로는 역할의 권한을 고칠 수 없다(빼기도 — DIRECTORY_ROLE_DESCRIPTION 머리말). + * - 그 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 건드리지 않고 실패로 보고한다 (백엔드는 그 주소를 사내 목록 · 웹메일 로그인에서 뺀다). + * - MAIL_DIRECTORY_LOCAL_PART 를 바꿨으면 옛 계정(setup 표시 + 이 역할 하나)은 새 계정이 준비된 뒤에 지운다. + * 이 계정은 메일을 보내지도 받지도 못한다(역할에 emailSend · emailReceive 가 없다) — 그래서 개발의 Mailpit 경로 리로드 전에 만들어도 된다. + * 준비되면 백엔드가 쓰는 방식 그대로 읽어 본다 (checkDirectoryAccess). + * 돌려주는 값: { address, state } — state 는 ready(준비됨) · unreadable(맞췄지만 읽지 못함) · pending(복구 관리자가 있어야 맞춘다) · conflict(다른 계정의 주소) + */ +async function ensureDirectoryAccount(admin, cfg, domain) { + const address = `${cfg.directoryLocalPart}@${domain.name}`; + const label = `사내 목록 계정 ${address}`; + const roleLabel = `역할 — 사내 목록 계정 목록 읽기 (${Object.keys(DIRECTORY_PERMISSIONS).join(', ')})`; + const shape = `비밀번호 없음 · 저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트 · 역할은 위 하나`; + const recovery = await openRecoveryClient(cfg); + const via = recovery ? ' (복구 관리자 사용)' : ''; + // 계정의 설명 · 저장 한도 · 비밀번호 빼기 · 지우기는 관리자로도 된다 + const writer = recovery ?? admin; + /** 복구 관리자가 있어야 맞출 수 있는 것 */ + const pending = []; + let changed = false; + + // 1) 역할 — 설명으로 찾는다 + const roles = await listAll(admin, 'Role', ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds']); + const role = roles.find((item) => item.description === DIRECTORY_ROLE_DESCRIPTION); + let roleId = role?.id ?? null; + if (!role) { + if (recovery) { + const result = await setObjects(recovery, 'Role', { + create: { role: { description: DIRECTORY_ROLE_DESCRIPTION, enabledPermissions: DIRECTORY_PERMISSIONS } }, + }); + roleId = result.created.role.id; + changed = true; + log(`[생성] ${roleLabel}${via}`); + } else { + pending.push('역할 없음'); + } + } else { + const drift = directoryRoleDrift(role); + if (drift.length === 0) { + log(`[유지] ${roleLabel}`); + } else if (recovery) { + await setObjects(recovery, 'Role', { + update: { [role.id]: { enabledPermissions: DIRECTORY_PERMISSIONS, disabledPermissions: {}, roleIds: {} } }, + }); + changed = true; + log(`[변경] ${roleLabel} 로 되돌림 — 전: ${drift.join(' · ')}${via}`); + } else { + pending.push(`역할의 권한이 다름(${drift.join(' · ')})`); + } + } + + // 2) 계정 — 아이디 + 도메인으로 찾는다 (비밀번호 값은 읽지도 출력하지도 않는다 — 있는지만 본다) + const accounts = await listAll(admin, 'Account', [ + 'name', + 'domainId', + 'emailAddress', + 'description', + 'roles', + 'credentials', + 'quotas', + 'permissions', + 'memberGroupIds', + ]); + const existing = accounts.find((account) => account.name === cfg.directoryLocalPart && account.domainId === domain.id); + if (existing && !existing.description?.includes(MANAGED_MARK) && !holdsOnlyRole(existing, roleId)) { + problems.push( + `${address}(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 사내 목록 계정으로 쓰지 않았습니다 — 그 계정은 건드리지 않았습니다. ` + + 'MAIL_DIRECTORY_LOCAL_PART 를 아무도 쓰지 않는 아이디로 바꾸고 backend 를 다시 띄운 뒤 이 스크립트를 다시 실행하세요 ' + + '(백엔드는 이 주소를 사내 목록 · 웹메일 로그인에서 뺀다 — mail/README.md "사내 목록 계정").', + ); + return { address, state: 'conflict' }; + } + const directoryRoles = roleId ? { '@type': 'Custom', roleIds: { [roleId]: true } } : null; + if (!existing) { + if (recovery && roleId) { + await setObjects(recovery, 'Account', { + create: { + account: { + '@type': 'User', + name: cfg.directoryLocalPart, + domainId: domain.id, + description: DIRECTORY_DESCRIPTION, + roles: directoryRoles, + quotas: DIRECTORY_QUOTAS, + }, + }, + }); + changed = true; + log(`[생성] ${label} — ${shape}${via}`); + } else { + pending.push('계정 없음'); + } + } else { + const patch = {}; + const changes = []; + if (existing.description !== DIRECTORY_DESCRIPTION) { + patch.description = DIRECTORY_DESCRIPTION; + changes.push('표시 이름'); + } + if (stableJson(existing.quotas ?? {}) !== stableJson(DIRECTORY_QUOTAS)) { + patch.quotas = DIRECTORY_QUOTAS; + changes.push(`저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트`); + } + if (Object.keys(existing.credentials ?? {}).length > 0) { + // 비밀번호가 있으면 그것으로 직접 로그인해 계정 목록(관리자 · 게이트웨이 주소 포함)을 읽을 수 있다 + patch.credentials = {}; + changes.push('비밀번호 뺌'); + } + let rolePending = false; + if (roleId && !holdsOnlyRole(existing, roleId)) { + if (recovery) { + patch.roles = directoryRoles; + changes.push('역할'); + } else { + pending.push('계정의 역할이 다름'); + rolePending = true; + } + } + if (changes.length > 0) { + await setObjects(writer, 'Account', { update: { [existing.id]: patch } }); + changed = true; + log(`[변경] ${label} — ${changes.join(', ')}${via}`); + } else if (roleId && !rolePending) { + log(`[유지] ${label} — ${shape}`); + } + if ( + stableJson(existing.permissions ?? INHERIT_PERMISSIONS) !== stableJson(INHERIT_PERMISSIONS) || + Object.keys(existing.memberGroupIds ?? {}).length > 0 + ) { + warn( + `${label} 에 역할 밖의 권한 설정(permissions)이나 그룹(memberGroupIds)이 있습니다 — setup 은 이것을 맞추지 않는다. ` + + '관리 화면 › 계정에서 지우세요 (mail/README.md "사내 목록 계정").', + ); + } + } + const ready = Boolean(roleId) && pending.length === 0; + + // 3) 이 역할을 가진 다른 계정 — setup 이 만든 옛 계정(아이디를 바꿨다)은 새 계정이 준비된 뒤 지우고, 그 밖은 알린다 + if (roleId) { + const others = accounts.filter( + (account) => account !== existing && account.roles?.['@type'] === 'Custom' && account.roles.roleIds?.[roleId] === true, + ); + const stale = others.filter((account) => account.description?.includes(MANAGED_MARK) && holdsOnlyRole(account, roleId)); + const foreign = others.filter((account) => !stale.includes(account)); + if (stale.length > 0 && ready) { + await setObjects(writer, 'Account', { destroy: stale.map((account) => account.id) }); + changed = true; + for (const account of stale) log(`[삭제] 옛 사내 목록 계정 ${accountLabel(account)} — MAIL_DIRECTORY_LOCAL_PART 에서 빠짐${via}`); + } else if (stale.length > 0) { + log(`[유지] 옛 사내 목록 계정 ${stale.map(accountLabel).join(', ')} — 새 계정 ${address} 가 준비된 뒤에 지운다`); + } + if (foreign.length > 0) { + warn( + `사내 목록 역할을 다른 계정도 가지고 있습니다: ${foreign.map(accountLabel).join(', ')} — 그 계정은 계정 목록 전체(관리자 · 게이트웨이 주소 포함)를 읽을 수 있다. ` + + '의도한 것이 아니면 관리 화면 › 계정에서 그 계정의 역할을 바꾸세요 (mail/README.md "사내 목록 계정").', + ); + } + } + + // 역할 권한 · 계정은 캐시된다 — 바로 반영되게 비운다 + if (changed) await setObjects(writer, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } }); + if (ready) { + const readable = await checkDirectoryAccess(cfg, address, { strict: Boolean(recovery) }); + return { address, state: readable === false ? 'unreadable' : 'ready' }; + } + + // 복구 관리자가 있어야 맞출 수 있는 것이 남았다 + const absent = pending.includes('역할 없음') || pending.includes('계정 없음'); + const how = cfg.isProduction + ? '--recovery 로 한 번 실행하세요' + : `${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) 다시 실행하세요`; + const message = absent + ? `사내 목록 계정이 없습니다 (${pending.join(' · ')}) — ${how} (mail/README.md "사내 목록 계정"). 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다.` + : `사내 목록 계정이 setup 기준과 다릅니다 (${pending.join(' · ')}) — 관리자로는 맞출 수 없다(엔진이 거절한다). ${how} (mail/README.md "사내 목록 계정").`; + if (cfg.recovery && !recovery) { + // 복구 관리자를 줬는데 쓰지 못했다 — 운영은 --recovery 실행이므로 실패로 본다 + const reason = + `복구 관리자(${RECOVERY_ENV_KEY})로 관리 API 에 로그인하지 못해 사내 목록 계정을 맞추지 못했습니다 — ` + + `mail 컨테이너가 지금 값으로 만들어졌는지(up -d mail) 확인하세요. `; + if (cfg.isProduction) problems.push(reason + message); + else warn(reason + message); + } else { + warn(message); + } + // 계정은 있는데 기준과 다르면 — 지금 백엔드가 읽을 수 있는지만 알려 준다 (더 붙은 권한만 있으면 읽힌다) + if (!absent && existing) await checkDirectoryAccess(cfg, address, { strict: false }); + return { address, state: 'pending' }; +} + +/** + * 사내 목록 계정으로 계정 목록을 읽을 수 있는지 — 백엔드가 쓰는 방식 그대로 + * (게이트웨이 대리 접근 <사내 목록 주소>%<게이트웨이 주소> → 세션의 principals 계정 → Principal/query · get). + * 역할을 바꾼 직후에는 캐시가 퍼질 때까지 몇 번 다시 본다. 이 스크립트의 접속 주소가 게이트웨이 허용 IP 밖이면 + * (MAIL_TRUSTED_NETWORKS 를 좁힌 운영) 게이트웨이 자신도 로그인되지 않으므로 건너뛴다. + * 읽지 못하면 strict(복구 관리자로 맞춘 실행)는 실패로, 아니면 [주의] 로 남긴다. 돌려주는 값: true · false · null(건너뜀) + */ +async function checkDirectoryAccess(cfg, address, { strict }) { + const login = `${address}%${cfg.gatewayAddress}`; + const authorization = basicAuth(login, cfg.gatewayPassword); + let outcome = ''; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + const sessionRes = await request(cfg, '/jmap/session', { headers: { authorization } }); + if (!sessionRes.ok) { + await sessionRes.body?.cancel(); + if (sessionRes.status === 401 && (await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) === 401) { + log(`[건너뜀] 이 스크립트의 접속 주소가 게이트웨이 허용 IP(신뢰 대역) 밖이라 사내 목록 계정의 대리 접근(${login})을 확인하지 못했습니다.`); + return null; + } + outcome = `세션 HTTP ${sessionRes.status}${sessionRes.status === 403 ? ' — 역할에 authenticate 가 없거나 빼는 권한에 들어 있다' : ''}`; + } else { + const session = await sessionRes.json(); + const accountId = session.primaryAccounts?.[PRINCIPALS_CAPABILITY]; + if (!accountId) { + outcome = `세션에 ${PRINCIPALS_CAPABILITY} 계정이 없음`; + } else { + // 백엔드처럼 찾고(query) 읽는다(get) — 둘 중 하나만 막혀도 사내 목록을 채우지 못한다 + const res = await request(cfg, '/jmap/', { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ + using: ['urn:ietf:params:jmap:core', PRINCIPALS_CAPABILITY], + methodCalls: [ + ['Principal/query', { accountId, limit: 1 }, 'q'], + ['Principal/get', { accountId, '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, properties: ['email'] }, 'g'], + ], + }), + }); + if (res.ok) { + const responses = (await res.json()).methodResponses; + const failed = responses.find(([name]) => name === 'error'); + if (!failed) { + log(`[확인] 사내 목록 — 대리 접근 ${login} 로 계정 목록을 읽을 수 있습니다 (Principal/query · get).`); + return true; + } + outcome = `Principal/${failed[2] === 'q' ? 'query' : 'get'} → ${failed[1].type}`; + } else { + await res.body?.cancel(); + outcome = `JMAP 요청 HTTP ${res.status}`; + } + } + } + await sleep(POLL_INTERVAL_MS / 2); + } + const message = + `사내 목록 계정으로 계정 목록을 읽지 못했습니다 — ${login}: ${outcome}. ` + + '역할의 권한(authenticate · jmapPrincipalGet · jmapPrincipalQuery)과 게이트웨이 허용 IP 를 확인하세요 (mail/README.md "사내 목록 계정").'; + if (strict) problems.push(message); + else warn(message); + return false; +} + /** 공개 443(https 리스너)의 HTTP 경로 정책 — 바꿨으면 true (ReloadSettings 뒤에 반영된다) */ async function ensureHttpEndpointPolicy(client) { const current = (await call(client, 'x:Http/get', { ids: ['singleton'], properties: ['allowedEndpoints'] })).list[0]; @@ -1392,6 +1774,97 @@ async function ensureHttpEndpointPolicy(client) { return true; } +/** + * 메일을 받지 않는 시스템 계정의 주소 — 게이트웨이 · 사내 목록(역할을 setup 이 정한다 — emailReceive 없음)과, + * 관리자 역할(ADMIN_ROLE_DESCRIPTION)에 emailReceive 가 없을 때의 관리자 역할 계정 모두(MAIL_ADMIN_LOCAL_PART 관리자 + 더 만든 관리자). + * 관리자 역할을 찾지 못하거나 그 역할이 메일을 받게 바뀌었으면 관리자 주소는 넣지 않는다 — 받는 계정을 막지 않게. + * 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면(conflict — 실제 메일함일 수 있다) 그 주소도 넣지 않는다. + */ +async function nonReceivingSystemAddresses(client, cfg, directory) { + const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']); + const adminRole = roles.find((role) => role.description === ADMIN_ROLE_DESCRIPTION); + const adminsReceive = !adminRole || adminRole.enabledPermissions?.emailReceive === true; + const admins = adminsReceive + ? [] + : (await listAll(client, 'Account', ['emailAddress', 'roles'])) + .filter(isAdminAccount) + .map((account) => account.emailAddress?.toLowerCase()) + .filter(Boolean) + .sort((a, b) => (a === cfg.adminAddress ? -1 : b === cfg.adminAddress ? 1 : a.localeCompare(b))); + if (adminsReceive) { + warn( + `관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 관리자 주소는 RCPT 거절에 넣지 않았습니다 — ` + + '관리자 계정이 메일을 받지 못한다면 그 주소로 온 메일은 받아 놓고 반송한다 (mail/README.md "보안").', + ); + } + return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])]; +} + +/** 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다 */ +function systemRcptScriptExpression(addresses) { + const condition = addresses + .map((address) => { + const at = address.lastIndexOf('@'); + return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`; + }) + .join(' || '); + return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' }; +} + +/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */ +function isOwnRcptScriptExpression(value) { + const branches = Object.values(value?.match ?? {}); + return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false'; +} + +/** + * 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true. + * 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고, + * 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다. + */ +async function ensureSystemRecipientsRejected(client, addresses) { + const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address)); + if (unsafe.length > 0) { + problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`); + return false; + } + const scriptLabel = `시스템 Sieve 스크립트 ${SYSTEM_RCPT_SCRIPT.name} — reject "${SYSTEM_RCPT_REPLY}"`; + const scripts = await listAll(client, 'SieveSystemScript', ['name', 'description', 'isActive', 'contents']); + const script = scripts.find((item) => item.name === SYSTEM_RCPT_SCRIPT.name); + const desired = { description: SYSTEM_RCPT_SCRIPT.description, isActive: true, contents: SYSTEM_RCPT_SCRIPT.contents }; + if (!script) { + await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } }); + log(`[생성] ${scriptLabel}`); + } else { + const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value)); + if (Object.keys(patch).length > 0) { + await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } }); + log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`); + } else { + log(`[유지] ${scriptLabel}`); + } + } + + const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`; + const expression = systemRcptScriptExpression(addresses); + const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script; + if (stableJson(current) === stableJson(expression)) { + log(`[유지] ${label}`); + return true; + } + if (stableJson(current) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(current)) { + warn( + `RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 (${JSON.stringify(current)}) — ` + + `메일을 받지 않는 시스템 계정(${addresses.join(', ')})은 지금처럼 받아 놓고 보낸 사람에게 반송한다. ` + + '그 스크립트를 고르는 식에 이 계정들일 때 거절하는 갈래를 합치세요 (mail/README.md "보안").', + ); + return false; + } + await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } }); + log(`[변경] ${label}`); + return true; +} + /** * 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다. * 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다). @@ -1506,6 +1979,85 @@ async function checkHttpEndpointPolicy(cfg) { ); } +/** + * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null. + * 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다. + */ +function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) { + const result = spawnSync( + 'docker', + [ + 'exec', + '-i', + cfg.container, + 'curl', + '-sS', + '-v', + '--max-time', + '20', + '--url', + `smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`, + '--mail-from', + sender, + '--mail-rcpt-allowfails', + ...recipients.flatMap((recipient) => ['--mail-rcpt', recipient]), + '-T', + '-', + ], + { input: '', encoding: 'utf8' }, + ); + if (result.error) return null; + const replies = new Map(); + let asked = null; + for (const line of (result.stderr ?? '').split(/\r?\n/)) { + const sent = /^> RCPT TO:<([^>]*)>/.exec(line); + if (sent) { + asked = sent[1].toLowerCase(); + continue; + } + const reply = /^< (\d{3}[ -].*)$/.exec(line); + if (reply && asked) { + replies.set(asked, reply[1]); + asked = null; + } + } + return replies; +} + +/** + * 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로 + * 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라, + * 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다. + * 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다). + */ +async function checkSystemRecipientsRejected(cfg, addresses) { + const networks = cfg.docker ? containerNetworks(cfg.container) : null; + const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null; + if (!mailIp) { + const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.'; + if (cfg.isProduction) problems.push(message); + else log(`[건너뜀] ${message}`); + return; + } + const last = addresses[addresses.length - 1]; + const probes = [...addresses, last.replace('@', '+setup-check@')].slice(0, RCPT_CHECK_MAX_PROBES); + let replies = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + replies = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes); + if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break; + await sleep(POLL_INTERVAL_MS / 2); + } + const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550')); + if (notRejected.length === 0) { + log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`); + return; + } + problems.push( + `시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` + + 'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").', + ); +} + /** * 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서). * 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면 @@ -1692,7 +2244,7 @@ function printHelp() { log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]'); log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md'); log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).'); - log(` --recovery 운영의 최초 bootstrap·관리자 복구 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`); + log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`); } /** 후속 조치가 필요하다 (종료 코드 3) — 안내를 출력하고 끝낸다 */ @@ -1744,6 +2296,11 @@ async function main() { // 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API await ensureUserRoleWithoutPrincipals(admin); await ensureHttpEndpointPolicy(admin); + // 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의]) + const directory = await ensureDirectoryAccount(admin, cfg, domain); + // 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영) + const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory); + const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients); // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다), // 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영) await ensureRecipientThrottle(admin); @@ -1752,6 +2309,7 @@ async function main() { if (!cfg.isProduction) await ensureDevRelay(admin); await reloadSettings(admin); await checkHttpEndpointPolicy(cfg); + if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients); const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg); const devUsers = []; @@ -1788,7 +2346,21 @@ async function main() { log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`); log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`); log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`); - log(' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가'); + log( + ' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가' + + (systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''), + ); + const directoryFallback = '읽지 못하는 동안 백엔드는 로그인한 직원 기록으로 대신한다'; + if (directory.state === 'ready') { + log(` 사내 목록 ${directory.address} — 계정 목록(Principal) 읽기 전용 · 비밀번호 없음 · 대리 접근: ${directory.address}%${cfg.gatewayAddress}`); + } else if (directory.state === 'unreadable') { + log(` 사내 목록 ${directory.address} — 맞췄지만 계정 목록을 읽지 못함(위 [실패] · [주의]) — ${directoryFallback}`); + } else if (directory.state === 'conflict') { + log(` 사내 목록 ${directory.address} — 다른 계정의 주소라 쓰지 않음(위 [실패]) · MAIL_DIRECTORY_LOCAL_PART 를 바꾼다 — ${directoryFallback}`); + } else { + const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`; + log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`); + } if (gatewayReachesAdmin) { log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")'); }