generated from ttipo/project-base
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:
@@ -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 도 다시 돌린다
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
@@ -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` 로만 쓴다
|
||||
|
||||
@@ -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 이 실패한다
|
||||
|
||||
@@ -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 이 실패한다
|
||||
|
||||
+37
-3
@@ -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.*` |
|
||||
|
||||
+85
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user