From ab4124ba9448d97478b079b02c4df7131de554ff Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 08:35:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(docker):=20mail=20setup=20=E2=80=94=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=9E=84=EC=8B=9C=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=95=9C=EB=8F=84=EB=A5=BC=20=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=EC=9D=98=2020=EB=B0=B0=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진(Stalwart 0.16.27)은 계정마다 1시간 동안 올린 양을 50MB 까지만 받는다(x:Jmap 의 uploadQuota). 20MB 첨부 셋이면 그 직원의 첨부 올리기와 필터 · 부재중 저장(Sieve 스크립트도 업로드로 올린다)이 한 시간까지 429 REQ_001 로 막혔다(2026-10-29 개발 엔진에서 확인). - setup 이 uploadQuota 를 첨부 합계 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB — 백엔드와 같은 계산)의 20배 이상으로 올린다. 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로다 - 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다(env 예시 · mail/README.md · docker 스킬 · env 규칙) Co-Authored-By: Claude Opus 5.5 --- .claude/rules/env-structure.md | 1 + .claude/skills/docker/SKILL.md | 3 ++ .env.development.example | 3 +- .env.production.example | 3 +- mail/README.md | 40 ++++++++++++++-- mail/scripts/setup.mjs | 86 +++++++++++++++++++++++++++++++++- 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 32ffd9b..6b48fd7 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -28,6 +28,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ │ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포") │ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다 └─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md) + MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다 ``` 규칙: diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 5446603..0b3bbdc 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -298,6 +298,9 @@ node --env-file=.env.development mail/scripts/setup.mjs - setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도"). 신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다. +- setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의 + 20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29). + 더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도"). - setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다. **운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다. - 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다 diff --git a/.env.development.example b/.env.development.example index e88dc6a..4425fe9 100644 --- a/.env.development.example +++ b/.env.development.example @@ -145,7 +145,8 @@ MAIL_JMAP_URL=http://mail:8080 # 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다 # (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경") MAIL_ATTACHMENT_MAX_SIZE_MB=20 -# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다 +# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다. +# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도") MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25 # 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분). # 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다 diff --git a/.env.production.example b/.env.production.example index dd593db..d0470ea 100644 --- a/.env.production.example +++ b/.env.production.example @@ -155,7 +155,8 @@ MAIL_JMAP_URL=http://mail:8080 # ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 — # 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경") MAIL_ATTACHMENT_MAX_SIZE_MB=20 -# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다 +# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다. +# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도") MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25 # 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분). # 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다 diff --git a/mail/README.md b/mail/README.md index da510b4..d20c5f6 100644 --- a/mail/README.md +++ b/mail/README.md @@ -104,7 +104,9 @@ node --env-file=.env.development mail/scripts/setup.mjs 6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`). 공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안) 7. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — - 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)) + 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)). + 계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — + 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 8 의 리로드로 반영된다 8. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다. @@ -136,7 +138,7 @@ node --env-file=.env.development mail/scripts/setup.mjs | `MAIL_DOMAIN` | `arasystem.test` / 운영 `arasystem.it.kr` | | `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, 파일당 한도 이상) | +| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) | - 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401 - 메일함 접근: Basic `<직원 주소>%` : `MAIL_GATEWAY_PASSWORD` @@ -159,7 +161,7 @@ node --env-file=.env.development mail/scripts/setup.mjs - 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다 - **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)) - **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절) -- **엔진 한도** — 받는 쪽 한도 값과 직원마다 내보내는 양의 한도를 정한다 ([엔진 한도](#엔진-한도)) +- **엔진 한도** — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 ([엔진 한도](#엔진-한도)) - **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한) - **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다) - **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 — @@ -339,6 +341,34 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org < 인증 없는 SMTP 는 같은 바깥 도메인(`example.net`) → doyun.lee 26번째부터 `452 4.4.5` (로그 `id = "MtaInboundThrottle with id …"`, `limit = [25, 3600000ms]`). setup 을 이어서 두 번 돌리면 첫 번째는 `[변경]`, 두 번째는 `[유지]` 다(엔진이 돌려주는 `match` · `key` 모양이 그대로 비교된다 — 바뀌면 매번 `[변경]` 이 나온다). +**임시 업로드 한도** (`x:Jmap` 싱글톤) — 엔진은 계정마다 1시간 동안 올린 것을 크기 합계와 개수로 센다. +첨부 올리기만이 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과 서명 그림이 든 발송(그림마다 하나)도 같은 몫을 쓴다. + +| 설정 | 뜻 | 기본값 | setup | +| --- | --- | --- | --- | +| `uploadQuota` | 계정마다 `uploadTtl` 동안 올린 크기 합계 | 50,000,000바이트 | 첨부 합계 한도의 20배보다 작으면 그 값으로 올린다 (기본 524,288,000바이트 = 25MB × 20) | +| `maxUploadCount` | 같은 동안 올린 개수 | 1,000 | 그대로 둔다 | +| `uploadTtl` | 올린 것이 남는 시간 = 위 둘을 세는 창 | 3,600,000ms (1시간) | 그대로 둔다 | +| `maxUploadSize` | 한 번에 올리는 크기 | 50,000,000바이트 | 그대로 둔다 | + +- **넘으면** — 그 계정의 업로드가 모두 HTTP 429(`Quota exceeded` — "You have exceeded the blob upload quota of 1000 files or 50000000 bytes.")로, 올린 것이 만료될 때까지 막힌다. + 백엔드는 크기 · 개수 어느 쪽이든 429 `REQ_001` 로 내리고 로그에 `{"reason":"mail-engine-rate-limit","call":"upload"}` 를 남긴다. 엔진 로그(info)에는 남지 않는다(크기 합계로 확인). +- **바꾸는 까닭** (2026-10-29 개발 엔진, 0.16.27) — 한 계정으로 1,000개를 넘기자 그 계정의 필터 저장이 막혔다. 크기 합계도 그대로 걸려서 20MiB 두 개 뒤 세 번째가 429 였다 — + 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 · 필터 · 부재중 저장이 1시간까지 막힌다 ([계약](../docs/mail/api-contract.md) 2절 "첨부 업로드 한도"). +- **setup 이 하는 일** — `uploadQuota` 가 첨부 합계 한도의 20배(한 시간에 합계 한도를 꽉 채운 메일 20통)보다 작으면 그 값으로 올리고 설정 리로드로 반영한다. + - 첨부 합계 한도는 백엔드(`mail.settings.ts`)와 같게 센다 — `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(비우면 25)이되 파일 하나 한도 `MAIL_ATTACHMENT_MAX_SIZE_MB`(비우면 20)보다 작아지지 않고, MB = 1,048,576바이트. + 둘 중 하나라도 양의 정수 표기(앞자리 0·부호·소수점 없이)가 아니면 아무것도 바꾸지 않고 멈춘다 — 백엔드 env 검증과 같은 규칙. + - 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 0 이면 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고 `[주의]` 를 낸다. + - **리로드가 있어야 반영된다** — 저장만 한 뒤에는 엔진이 옛 한도로 계속 429 를 냈고(메시지의 바이트 수도 옛 값), `ReloadSettings` 뒤에 올라갔다 (같은 날 확인). + - 첨부 한도 env 를 바꾸면 backend 를 다시 띄우는 것과 함께 setup 도 다시 실행한다. +- **개수 · 기간 · 한 번 크기는 그대로 둔다** + - 개수 1,000 — 사람이 쓰는 양으로는 닿기 어렵다(한 시간에 첨부 · 필터 저장 · 서명 그림을 합쳐 1,000번). 계정이 털리거나 스크립트가 돌 때 엔진을 지키는 몫으로 남긴다. + - 기간 1시간 — 늘리면 같은 한도를 더 긴 동안 세어 오히려 빡빡해지고, 줄이면 올린 첨부가 메일(임시 저장본)에 붙기 전에 만료될 수 있다(시험하지 않았다). + - 한 번 크기 50,000,000바이트 — 파일당 첨부 한도(기본 20MB)보다 크다. `MAIL_ATTACHMENT_MAX_SIZE_MB` 를 48 이상(48MB = 50,331,648바이트)으로 올리면 + 엔진이 그 파일을 400 `urn:ietf:params:jmap:error:limit`(`maxSizeUpload`)로 거절하고 사용자에게는 `FILE_002` 로 보인다 — 그때는 이 값도 함께 올린다(관리 API `x:Jmap/set` → `ReloadSettings`). +- 확인 (2026-10-29, 0.16.27): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] … 524,288,000바이트 … 전 50,000,000`, 두 번째는 `[유지]`. + setup 뒤에는 이미 60MiB 를 올린 계정이 20MiB 를 더 올릴 수 있었다(바꾸기 전에는 429). `maxUploadCount` · `uploadTtl` · `maxUploadSize` 는 그대로였다. + 운영 전에 정할 것: - **메일을 많이 받는 주소** — 같은 바깥 도메인에서 한 사람이 시간당 25통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다). @@ -349,6 +379,8 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org < - **직원마다 내보내는 양의 한도(`x:MtaOutboundThrottle`)는 비어 있다** — 이제 웹메일 발송에는 엔진의 받는 쪽 한도도 걸리지 않으므로, 남는 것은 백엔드의 요청 수 한도(루트 env `THROTTLE_*` — IP 당)뿐이다. 계정이 털리거나 잘못 쓰여 한꺼번에 많이 보내면 바깥 서버들의 한도에 걸려 서버 평판이 떨어진다. 보낸 사람마다 한도(key `sender` — 넘으면 거절이 아니라 대기열에서 미뤄진다, [Outbound rate limits](https://stalw.art/docs/mta/outbound/rate-limit/))를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다. +- **직원마다 1시간 업로드 한도(크기 · 개수)** — setup 은 크기 합계만 첨부 합계 한도의 20배로 올리고 개수(1,000)는 그대로 둔다. 운영을 시작한 뒤 백엔드 로그 + `"reason":"mail-engine-rate-limit","call":"upload"` 로 막힌 직원이 있는지 보고 크기 · 개수를 정한다(엔진 로그에는 남지 않는다). 올린 값은 setup 이 내리지 않는다. --- @@ -540,5 +572,7 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) | `공개 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 이 제한 없음인지는 확인하지 않았다) | | `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 1bf9f04..2755084 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -36,6 +36,8 @@ * (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안") * 7. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영) * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") + * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) + * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 * 8. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인 * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 9 보다 먼저 한다) * 9. [개발] 테스트 직원 계정 @@ -113,6 +115,23 @@ const RECIPIENT_THROTTLE = { description: 'Sender address to recipient throttle' * 식에 authenticated_as 를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(식의 변수를 아는 첫 단계) 바깥 접속의 연결 수를 더는 세지 않는다. */ const UNAUTHENTICATED_SESSIONS_ONLY = { match: {}, else: 'is_empty(authenticated_as)' }; +/** + * 계정마다 임시 업로드 크기 합계(x:Jmap 싱글톤의 uploadQuota)를 첨부 합계 한도의 이만큼 배 이상으로 둔다 — 한 시간에 합계 한도를 꽉 채운 메일 20통 + * (계약 docs/mail/api-contract.md 2절 "첨부 업로드 한도"). + * 엔진은 계정마다 uploadTtl(기본 1시간) 동안 올린 것의 크기 합계(uploadQuota, 기본 50,000,000바이트)와 개수(maxUploadCount, 기본 1,000)를 세고, + * 넘으면 올린 것이 만료될 때까지 그 계정의 업로드를 모두 429 로 막는다 — 첨부 올리기뿐 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과 + * 서명 그림이 든 발송도. 2026-10-29 개발 엔진(0.16.27): 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째가 429 "Quota exceeded" 였다 + * (기본 크기 합계면 20MB 첨부 세 개). 바꾼 값은 ReloadSettings 뒤에 반영된다 — 저장만 하면 옛 한도로 계속 막는다 (같은 날 확인). + */ +const UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL = 20; +/** + * 첨부 한도 env 의 기본값 · 단위 · 숫자 표기 — 백엔드 backend/src/modules/mail/mail.settings.ts(DEFAULT_ATTACHMENT_*·MEGABYTE)와 + * backend/src/shared/utils/env.ts(10진 숫자만, 앞자리 0 없이)와 같아야 한다. 한쪽을 바꾸면 다른 쪽도 함께 바꾼다. + */ +const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20; +const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25; +const MEGABYTE = 1024 * 1024; +const POSITIVE_INT_PATTERN = /^[1-9]\d*$/; /** * 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시 (소문자로 맞춰 검사). * ⚠️ 백엔드 backend/src/modules/auth/employee-address.ts 의 LOCAL_PART_PATTERN(isLocalPart)과 같은 규칙이다 — @@ -403,6 +422,24 @@ function loadConfig(argv) { (isProduction ? errors : warnings).push('MAIL_GATEWAY_PASSWORD 가 MAIL_ADMIN_PASSWORD 와 같습니다. 서로 다른 값을 쓰세요.'); } + // 첨부 합계 한도 — 백엔드(mail.settings.ts)와 같은 값: 비우면 기본값, 합계는 파일 하나 한도보다 작아지지 않는다. + // 엔진의 임시 업로드 크기 합계를 그 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 둔다 (ensureUploadQuota) + const attachmentMb = (key, fallback) => { + const value = readEnv(key); + if (value === undefined) return fallback; + if (POSITIVE_INT_PATTERN.test(value) && Number.isSafeInteger(Number(value))) return Number(value); + errors.push(`${key} 는 양의 정수여야 합니다 — 앞자리 0·부호·소수점 없이 숫자만 씁니다 (백엔드 env 검증과 같은 규칙, 현재: "${value}").`); + return fallback; + }; + const attachmentTotalMb = Math.max( + attachmentMb('MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB', DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB), + attachmentMb('MAIL_ATTACHMENT_MAX_SIZE_MB', DEFAULT_ATTACHMENT_MAX_SIZE_MB), + ); + const uploadQuotaFloor = attachmentTotalMb * MEGABYTE * UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL; + if (!Number.isSafeInteger(uploadQuotaFloor)) { + errors.push(`첨부 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB · MAIL_ATTACHMENT_MAX_SIZE_MB)가 너무 큽니다 (${attachmentTotalMb}MB).`); + } + // 관리 API 주소 — 명시값 > 개발 관리 포트(127.0.0.1) > compose 내부망 const adminPort = readEnv('MAIL_ADMIN_PORT'); const adminUrl = (readEnv('MAIL_ADMIN_URL') ?? (adminPort ? `http://127.0.0.1:${adminPort}` : INTERNAL_ADMIN_URL)).replace(/\/+$/, ''); @@ -443,6 +480,8 @@ function loadConfig(argv) { appNetworkSubnet, networkName: `${projectName}_app-network`, trustedNetworks, + attachmentTotalMb, + uploadQuotaFloor, devUserPassword, mailpitUiPort: readEnv('MAILPIT_UI_PORT'), warnings, @@ -1384,6 +1423,49 @@ async function ensureRecipientThrottle(client) { } } +/** 세 자리마다 쉼표 — 숫자가 아니면 JSON 그대로 */ +function formatCount(value) { + return typeof value === 'number' ? value.toLocaleString('en-US') : JSON.stringify(value); +} + +/** 밀리초 → 시간·분으로 떨어지면 그 단위로 */ +function formatDuration(ms) { + if (!Number.isSafeInteger(ms) || ms <= 0) return `${formatCount(ms)}ms`; + if (ms % 3_600_000 === 0) return `${ms / 3_600_000}시간`; + if (ms % 60_000 === 0) return `${ms / 60_000}분`; + return `${formatCount(ms)}ms`; +} + +/** + * 계정마다 임시 업로드 크기 합계(x:Jmap 의 uploadQuota)를 첨부 합계 한도의 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 — ReloadSettings 뒤에 반영된다. + * 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 개수(maxUploadCount) · 기간(uploadTtl) · 한 번 크기(maxUploadSize)는 건드리지 않는다 (mail/README.md "엔진 한도"). + * 0 은 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고 알린다. + */ +async function ensureUploadQuota(client, cfg) { + const current = (await call(client, 'x:Jmap/get', { ids: ['singleton'], properties: ['uploadQuota', 'maxUploadCount', 'uploadTtl'] })).list[0]; + const quota = current?.uploadQuota; + const label = '임시 업로드 한도 — 계정마다 크기 합계'; + const floor = `첨부 합계 ${cfg.attachmentTotalMb}MB × ${UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL}`; + const untouched = `개수 ${formatCount(current?.maxUploadCount)} · 기간 ${formatDuration(current?.uploadTtl)}은 그대로`; + if (!Number.isSafeInteger(quota) || quota < 0) { + problems.push(`임시 업로드 한도(x:Jmap 의 uploadQuota = ${formatCount(quota)})를 읽지 못해 바꾸지 않았습니다 — ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상이어야 합니다.`); + return; + } + if (quota === 0) { + warn( + `임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. ` + + `제한 없음으로 둔 것이 아니라면 ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상으로 두세요 (mail/README.md "엔진 한도").`, + ); + return; + } + if (quota >= cfg.uploadQuotaFloor) { + log(`[유지] ${label} ${formatCount(quota)}바이트 (${floor} = ${formatCount(cfg.uploadQuotaFloor)} 이상) · ${untouched}`); + return; + } + await setObjects(client, 'Jmap', { update: { singleton: { uploadQuota: cfg.uploadQuotaFloor } } }); + log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`); +} + /** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */ async function reloadSettings(client) { await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } }); @@ -1662,8 +1744,10 @@ async function main() { // 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API await ensureUserRoleWithoutPrincipals(admin); await ensureHttpEndpointPolicy(admin); - // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다) + // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다), + // 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영) await ensureRecipientThrottle(admin); + await ensureUploadQuota(admin, cfg); // 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다 if (!cfg.isProduction) await ensureDevRelay(admin); await reloadSettings(admin);