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
+85 -1
View File
@@ -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);