fix(docker): mail setup — 엔진 임시 업로드 한도를 첨부 합계의 20배로

엔진(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 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:35:20 +09:00
parent 5ddc849c11
commit ab4124ba94
6 changed files with 130 additions and 6 deletions
+37 -3
View File
@@ -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_USER>` : `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.*` |