fix(docker): mail setup 받는 메일 크기 한도 50MiB · 전환 준비 문서 (mailcow 확인 · 서버 B 배포 · 백업 · 큰 메일) #12

Merged
ttipo merged 14 commits from feat/mail-migration-password into main 2026-11-04 07:59:25 +09:00
13 changed files with 534 additions and 78 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다
13단계부터 운영은 `DB_IMAGE` 를 예시의 고정값(판 · digest)으로 두고, 첫 setup 은 복구 관리자와 함께(`--recovery` — 관리자 허용 IP 를 처음 걸 때 되돌릴 길) 돌린다.
이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버" 차례를 따른다
- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + 그 버킷만 쓰는 하위 계정 키, restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다),
- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + Object Storage 만 쓰는 하위 계정 키(버킷에는 따로 키가 없고 NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다), restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다),
`/etc/arasystem/ops.env`, 타이머 셋(백업 03:30 · 점검 15분 · 부팅 되살리기) 켜기, 첫 백업 뒤 복원 연습(`drill`) 한 번. Healthchecks 체크 둘 · UptimeRobot 감시 셋 — 알림은 우리 메일 서버가 아닌 주소로
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
+3 -1
View File
@@ -13,7 +13,9 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 지금 스레드의 user CPU 시간(`backend/test/cpu-time.ts`)이나 비용 수치.
프로세스 전체 · system 시간은 쓰지 않는다 — V8 의 GC · JIT 도우미 스레드와 페이지 폴트가 기계 사정을 따라 흔들려, 2026-10-31 문서만 바꾼 PR 의 CI 에서 할당이 많은 단언 셋이 넘쳤다
(같은 컨테이너 16회: 프로세스 user + system 으로 4회 넘침 · 스레드 user 로 0회).
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
+3
View File
@@ -391,6 +391,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
- 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 은 SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB 로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어
메일 컨테이너 1g 로는 바깥 100MB 한 통이 한도에 닿았다(2026-11-03 개발 984/1024MiB · 운영은 106MB IMAP APPEND 로 OOM). IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는
이전(imapsync) 때 잠시 올릴 수 있어 바꾸지 않고, 기본보다 크면 `[주의]` 만 낸다 (mail/README.md "엔진 한도" · "큰 메일").
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소와 리졸버 네트워크(서브넷 · 구성원 · 리졸버 주소 · mail 의 `dns`)를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
- **메일 전용 DNS 리졸버** `mail-resolver`(unbound — 설정 `mail/resolver/unbound.conf`): 엔진의 DNS 조회(스팸 차단 목록 · SPF · DKIM · DMARC · MX · PTR · TLSA)를
+1 -1
View File
@@ -384,7 +384,7 @@ dc exec backend npm uninstall <패키지>
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(지금 스레드의 user CPU 시간 — GC · JIT 도우미 스레드와 system 시간은 뺀다)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
@@ -80,10 +80,40 @@ function decodeBasic(header: string): string {
return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8');
}
/** Node fetch 의 연결 실패와 같은 모양 — 엔진에 닿지 못했다 */
function connectionRefused(): TypeError {
return new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
});
}
describe('JmapClient', () => {
let fetchMock: jest.SpyInstance;
let cache: Cache;
let client: JmapClient;
/** 가짜 응답 없이 나간 fetch 의 주소 — 테스트가 끝날 때 비어 있어야 한다 (afterEach) */
let unansweredFetches: string[];
/**
* 가짜 응답을 넣지 않은 fetch 의 기본 — 진짜 네트워크로 나가지 않고 곧바로 연결 실패로 거절하며 주소를 남긴다.
* spy 의 기본(원래 fetch)이면 spec 의 엔진 주소 http://mail:8080 으로 진짜 요청이 나간다 — 개발 컨테이너에서는 개발 엔진이 받고,
* CI 에서는 그 이름을 늦게 풀면 테스트 제한 시간(5초)을 넘긴다 (2026-11-03 CI — 메일 계정이 없는 세션의 계정 정보 조회)
*/
const refuseUnanswered = (url: string): Promise<Response> => {
unansweredFetches.push(url);
return Promise.reject(connectionRefused());
};
/**
* 넣어 둔 가짜 응답 · 기록을 비우고 기본(연결 실패)을 다시 건다 — `fetchMock.mockReset()` 을 바로 부르지 않는다.
* jest-mock 30 의 mockReset 은 spy 의 구현까지 지워, 그 뒤로는 기본도 원래 fetch 도 아닌 undefined 를 돌려준다
*/
const resetFetchMock = (): void => {
fetchMock.mockReset();
fetchMock.mockImplementation(refuseUnanswered);
};
/** n 번째 fetch 호출의 URL·헤더 */
const requestAt = (index: number) => {
@@ -96,7 +126,9 @@ describe('JmapClient', () => {
};
beforeEach(() => {
unansweredFetches = [];
fetchMock = jest.spyOn(global, 'fetch');
fetchMock.mockImplementation(refuseUnanswered);
cache = fakeCache();
client = new JmapClient(settings, cache);
});
@@ -104,6 +136,8 @@ describe('JmapClient', () => {
afterEach(() => {
fetchMock.mockRestore();
jest.useRealTimers();
// 엔진에 묻는 일마다 응답을 직접 넣는다 — 빠진 응답이 있으면 연결 실패로 지나가 버리지 않게 여기서 그 테스트를 실패시킨다
expect(unansweredFetches).toEqual([]);
});
describe('직원 자격 확인 (verifyCredentials)', () => {
@@ -157,13 +191,7 @@ describe('JmapClient', () => {
'MAIL_001',
);
fetchMock.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
}),
);
fetchMock.mockRejectedValueOnce(connectionRefused());
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
@@ -288,7 +316,7 @@ describe('JmapClient', () => {
it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => {
for (const status of [401, 403]) {
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json({}, status));
await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe(
@@ -465,7 +493,7 @@ describe('JmapClient', () => {
'REQ_001',
);
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json(sessionBody));
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
'MAIL_001',
@@ -1038,12 +1066,32 @@ describe('JmapClient', () => {
expect(cache.set).not.toHaveBeenCalled();
});
it('메일 계정이 없는 세션은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, primaryAccounts: {} }),
);
it('메일 계정이 없는 세션은 MAIL_001 — 먼저 대리 접근으로 계정 정보(권한)를 한 번 더 묻고, 비밀번호 만료의 모양이 아닐 때 (13단계)', async () => {
fetchMock
.mockResolvedValueOnce(json({ ...sessionBody, primaryAccounts: {} }))
// 보통 직원의 권한 — 메일 권한이 있어 비밀번호 만료가 아니다
.mockResolvedValueOnce(
json({
permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'],
}),
);
expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001');
const failure = await client
.getSession(ARA)
.catch((error: unknown) => error);
expect(failure).not.toBeInstanceOf(PasswordExpiredException);
expect((failure as BusinessException).code).toBe('MAIL_001');
// 만료가 아니니 세션이 모자란 것으로 — 계정 정보를 읽지 못해서가 아니다
expect((failure as BusinessException).context).toMatchObject({
reason: 'session-incomplete',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(requestAt(1).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(1).authorization)).toBe(
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(cache.set).not.toHaveBeenCalled();
});
});
@@ -1291,7 +1339,7 @@ describe('JmapClient', () => {
it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((resolve, reject) => {
@@ -1326,7 +1374,7 @@ describe('JmapClient', () => {
it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
@@ -1672,7 +1720,7 @@ describe('JmapClient', () => {
expect(requestAt(0).url).toBe('http://mail:8080/jmap/session');
expect(requestAt(1).url).toContain('/jmap/eventsource/');
fetchMock.mockReset();
resetFetchMock();
// 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다
fetchMock.mockImplementation(() =>
Promise.resolve(json(withoutEventSource)),
@@ -1705,13 +1753,21 @@ describe('JmapClient', () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 403));
.mockResolvedValueOnce(json({}, 403))
// 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제
.mockResolvedValueOnce(json({ permissions: ['authenticate'] }));
const code = await runWithRequestContext({ requestId: 'trace-es' }, () =>
codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
);
expect(code).toBe('MAIL_001');
// 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다)
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(requestAt(5).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(5).authorization)).toBe(
'mail-gateway@arasystem.test:gateway-secret',
);
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(
/^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/,
@@ -140,8 +140,10 @@ describe('받는 사람 맞추기 (recipient-match — 쓰기 화면과 같은
expect(recipientNameMatcher(`${'가'.repeat(99)}나`)(name)).toBe(true);
const matches = recipientNameMatcher(`${'가'.repeat(99)}다`);
expect(names.filter(matches)).toEqual([]);
// 지금 0.06~0.14초 — 한도는 10배쯤 (testing 규칙). 옛 방법(1억 번 비교)은 확실히 넘는다
expect(cpuMs()).toBeLessThan(1_500);
// 지금 스레드 user 0.13초 안팎(호스트 Windows · Node 22), CI 와 같은 Linux 컨테이너(Node 24)에서는 혼자 0.17~0.25초 —
// 한도는 그 10배쯤 (testing 규칙 — 1.5초였을 때 2026-10-31 CI 에서 프로세스 시간 3.29초로 넘쳤다, test/cpu-time.ts).
// 옛 방법(1억 번 비교)은 그 컨테이너에서 51~70초로 확실히 넘는다 (2026-10-31 되살려 잼)
expect(cpuMs()).toBeLessThan(2_500);
});
it('추천 줄 거르기 — 같은 이름은 한 번만 견준다 (연락처 하나의 주소 여러 줄)', () => {
+43 -11
View File
@@ -3,14 +3,28 @@
*
* 왜 벽시계(performance.now)가 아닌가: 다른 프로세스가 CPU 를 나눠 쓰면 벽시계 시간은 그만큼 늘어난다.
* 2026-10-27 바쁜 PC(병렬 테스트·개발 서버와 함께)에서 혼자 0.7~0.8초인 정화가 5.9초·9.1초로 재져
* 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 이 프로세스가 실제로 계산한 시간만 세므로 다른 작업이 많아도 거의 늘지 않는다 —
* 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 실제로 계산한 시간만 세므로 다른 작업이 많아도 덜 는다 —
* "계산량이 입력 길이에 비례하는가(예전의 제곱 경로가 돌아오지 않았는가)"를 보는 단언에 쓴다.
*
* - 프로세스 전체(GC·JIT 스레드 포함)의 user + system 시간이다 — 한도는 지금 값의 10배쯤 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다.
* CPU 시간도 늘지 않는 것은 아니다 — 2026-10-28 CI 와 같은 Linux 컨테이너(Docker Desktop)에서 메모리가 모자라고 테스트가 함께 돌 때
* 평소의 4~7배로 재져, 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다(이미지 많은 정화 1.6초 → 5.75초, DTO 검사 0.08초 → 0.59초).
* - **지금 스레드**(테스트 코드가 도는 JS 스레드)의 **user 시간만** 센다 — `process.threadCpuUsage`.
* 함수가 없거나 플랫폼이 스레드 시간을 주지 못하면(부르면 던진다) 프로세스 전체(`process.cpuUsage`)의 user 시간으로 물러선다.
* 빼는 둘은 잰 코드가 아니라 그때 기계가 얼마나 바쁜지를 따라 흔들린다:
* - 다른 스레드 — V8 은 GC(병렬 스캐빈지 · 동시 마킹)와 JIT 컴파일을 도우미 스레드에 나눠 맡긴다.
* 할당이 많은 단언에서 프로세스 시간이 이 스레드 시간의 많게는 2~3배였다.
* - system 시간 — 새 메모리를 처음 만질 때의 페이지 폴트 등. CI 와 같은 Linux 컨테이너(Docker Desktop VM)에서 JSON.parse 를 포함한 모양 확인이
* 혼자는 user 17~45ms · system 16~28ms 인데, 전체 테스트와 함께 돌 때는 같은 폴트 수(4~8천)에 system 이 366ms 까지 갔다.
* 2026-10-31 CI(문서만 바꾼 PR)에서 할당이 많은 단언 세 곳이 그때의 프로세스 user + system 시간으로 넘쳤다 — 받는 사람 맞추기 3.29초(한도 1.5초) ·
* JSON.parse 를 포함한 모양 확인 1.35초(0.5초) · DTO 검사 2.94초(2초). 같은 컨테이너에서 전체 테스트를 16번 돌리니 한도를 넘은 회가
* 프로세스 user + system 으로 4번 · 스레드 user + system 으로 2번 · 스레드 user 로 0번이었다.
* 막으려는 옛 경로(제곱 등)는 거의 다 user 시간이라 그대로 넘는다 — 받는 사람 맞추기의 옛 방법은 스레드 51.2초 가운데 user 51.0초.
* - user 시간도 부하에서 는다 — 이 스레드가 직접 하는 GC 몫, 코어를 나눠 쓰는 느려짐(하이퍼스레딩 · 효율 코어), VM 이 vCPU 를 쉬게 한 시간
* (Docker Desktop VM 은 steal 로 따로 세지 않는다)이 그대로 들어간다 — 위 16번에서 혼자일 때의 3~8배, 많게는 16배까지 재졌다.
* 2026-10-28 에도 (그때는 프로세스 user + system 으로) 메모리가 모자라고 테스트가 함께 돌 때 평소의 4~7배로 재져, 여유가 3~6배뿐이던 한도 두 곳이
* CI 를 실패시켰다(이미지 많은 정화 1.6초 → 5.75초, DTO 검사 0.08초 → 0.59초). 그래서 한도는 지금 값의 10배쯤 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다.
* 지금 값은 CI 와 같은 Linux 컨테이너(Node 24)에서도 잰다 — 호스트(Windows · Node 22)보다 2배쯤 크게 재지는 단언이 있다.
* - 기다리는 시간(I/O·타이머)은 CPU 시간에 들어가지 않는다 — 그런 단언(예: 파일이 지워질 때까지 기다리기)에는 쓰지 않는다.
* - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다 — 수십 ms 아래를 비교하는 데 쓰지 않는다.
* system 시간을 세지 않으므로 시스템 호출이 많은 코드의 비용을 재는 데도 쓰지 않는다.
* - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다(스레드 시간도) — 수십 ms 아래를 비교하는 데 쓰지 않는다.
*
* 위치: src 밖(test/)이라 빌드(nest build 는 sourceRoot 인 src 만 컴파일한다)와 단위 테스트 커버리지(rootDir 이 src)에 들어가지 않고,
* 이름이 `*.spec.ts` 가 아니라 테스트로 돌지도 않는다. 단위 테스트는 `@/../test/cpu-time` 으로 부른다 —
@@ -19,17 +33,35 @@
const MICROSECONDS_PER_MS = 1_000;
/** CPU 시간 읽기 — 앞서 읽은 값을 주면 그때부터 쓴 만큼 (process.cpuUsage 와 같은 모양) */
type CpuUsageReader = (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage;
/**
* 지금부터 재기 시작한다. 돌려준 함수를 부르면 그때까지 이 프로세스가 쓴 CPU 시간(ms)을 준다.
* 지금 스레드의 CPU 시간 읽기를 고른다 — 쓸 수 없으면 프로세스 전체.
* 함수가 있어도 플랫폼이 스레드 시간을 주지 못하면(libuv 의 uv_getrusage_thread 가 ENOTSUP) 부르는 순간 던지므로 한 번 불러 본다.
*/
function pickCpuUsageReader(): CpuUsageReader {
if (typeof process.threadCpuUsage === 'function') {
try {
process.threadCpuUsage();
return (previous) => process.threadCpuUsage(previous);
} catch {
// 스레드 시간을 주지 못하는 플랫폼 — 아래 프로세스 전체로
}
}
return (previous) => process.cpuUsage(previous);
}
const readCpuUsage = pickCpuUsageReader();
/**
* 지금부터 재기 시작한다. 돌려준 함수를 부르면 그때까지 이 스레드가 쓴 user CPU 시간(ms)을 준다.
*
* const cpuMs = startCpuTimer();
* sanitizeMailHtml(html);
* expect(cpuMs()).toBeLessThan(5_000);
*/
export function startCpuTimer(): () => number {
const started = process.cpuUsage();
return () => {
const { user, system } = process.cpuUsage(started);
return (user + system) / MICROSECONDS_PER_MS;
};
const started = readCpuUsage();
return () => readCpuUsage(started).user / MICROSECONDS_PER_MS;
}
@@ -21,6 +21,7 @@ import {
} from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
// 쓰기 화면의 대용량 첨부 (11단계 — 디자인 원본 ComposeLargeFiles · 계약 4절 "발송 · 임시 저장과 함께" · "인용 속 링크의 상태")
@@ -367,7 +368,14 @@ describe('ComposePage — 대용량 첨부', () => {
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.uploading('1'))
releaseChunk()
await flushPromises()
// 조각을 받으면 끝내기까지 가 ready 가 된다 — 조각을 보내기 전의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라
// 부하가 크면 양보 두 번으로는 끝나지 않는다 (ComposePage.spec.ts 의 첨부 합계 테스트와 같은 까닭) — ready 가 될 때까지 본다
await vi.waitFor(
() => {
if (phases(wrapper)[0] !== 'ready') throw new Error('대용량 파일을 올리는 중')
},
{ timeout: WAIT_FOR_TIMEOUT_MS },
)
await flushPromises()
expect(phases(wrapper)).toEqual(['ready'])
+14 -2
View File
@@ -36,7 +36,7 @@ import {
} from '@/test-utils/mock-api'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import { LONG_DRAFT_TIMEOUT_MS, WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
@@ -599,7 +599,17 @@ describe('ComposePage', () => {
configurable: true,
})
await picker.trigger('change')
await flushPromises()
// 도면은 다 올려야(끝내기 응답 뒤 ready) [보내기]가 풀린다 — 그 전에 누른 보내기는 막혀 보내기 요청이 끝내 나가지 않는다.
// 조각의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라 부하가 크면 양보 두 번으로는 끝나지 않았다 (2026-11-03 CI) — ready 가 될 때까지 본다
await vi.waitFor(
() => {
const row = wrapper.find('[data-large-upload]')
if (!row.exists() || row.attributes('data-phase') !== 'ready') {
throw new Error('대용량 파일을 올리는 중')
}
},
{ timeout: WAIT_FOR_TIMEOUT_MS },
)
await flushPromises()
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
@@ -612,6 +622,8 @@ describe('ComposePage', () => {
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
// 다 올렸다 — [보내기]를 막는 까닭이 없다 (아래 보내기는 마이크로태스크로만 이어져 양보 한 번이면 요청이 나간다)
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
+8
View File
@@ -9,3 +9,11 @@
* 전체 한도에 가까워 이 테스트들에만 30초를 준다.
*/
export const LONG_DRAFT_TIMEOUT_MS = 30_000
/**
* 조건이 될 때까지 다시 보는 기다림(vi.waitFor)의 기한 — 몇 번 양보(flushPromises)해서는 끝난다고 믿을 수 없는 일을 기다린다.
* 예: 대용량 파일 조각의 SHA-256 — jsdom 이 아니라 Node 의 Web Crypto 라 스레드 풀에서 돌아, 부하가 크면 양보 두 번 안에 끝나지 않았다
* (2026-11-03 CI — 다 올리기 전에 누른 [보내기]는 막혀 보내기 요청이 끝내 나가지 않는다).
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬, 끝내 되지 않으면 테스트 한도보다 이 기다림이 먼저 까닭(던진 글)을 알린다.
*/
export const WAIT_FOR_TIMEOUT_MS = 15_000
+165 -35
View File
@@ -138,6 +138,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)).
계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 —
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도").
SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB(52,428,800바이트)로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어 1g 로는 100MB 한 통이 한도에 닿는다.
더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 바꾸지 않고 `[주의]`. 리로드 뒤 메일 컨테이너 안에서 EHLO 의 `SIZE` 로 걸렸는지 본다(`[확인] 받는 메일 크기 한도 — EHLO SIZE …`).
IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는 이전 때 잠시 올릴 수 있어 바꾸지 않고, 엔진 기본보다 크면 `[주의]` 만 낸다 ([엔진 한도](#엔진-한도) "받는 메일 크기").
**엔진 DNS · Spamhaus** — 엔진의 DNS(`x:DnsResolver`)를 메일 전용 리졸버로(운영은 리졸버에 바로 `Custom`, 개발은 도커 DNS 를 거쳐 `System` — Mailpit 이름 때문),
스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
**엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는
@@ -192,7 +195,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, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) |
| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)). 30 넘게 올리려면 받는 메일 크기 한도(50MiB — 같은 절 "받는 메일 크기")도 본다 |
| `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) |
| `MAIL_WEBHOOK_SECRET` | 엔진 웹훅의 서명 키(12단계 — 영문 · 숫자 32자 이상, 운영 필수) — setup 이 같은 값으로 엔진 웹훅을 만든다. 바꾸면 setup 과 backend 를 함께 ([엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)) |
| `APP_NETWORK_SUBNET` | 앱 네트워크 대역 (기본 `172.16.71.0/24`) — 웹훅 받는 쪽이 접속 주소(소켓)가 이 대역인지 본다 |
@@ -288,7 +291,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다).
운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다).
직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라))
- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 버킷 · 그 버킷만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관),
- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 비공개 버킷 · 백업 전용 API 인증키(아래 "백업 › 설치" — 메인 계정 키를 고른 까닭과 위험) · restic 비밀번호(비밀번호 관리자에 따로 보관),
Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)).
`.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다).
frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다
@@ -629,6 +632,40 @@ sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC'
- 확인 (2026-10-29, 0.16.27): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] … 524,288,000바이트 … 전 50,000,000`, 두 번째는 `[유지]`.
setup 뒤에는 이미 60MiB 를 올린 계정이 20MiB 를 더 올릴 수 있었다(바꾸기 전에는 429). `maxUploadCount` · `uploadTtl` · `maxUploadSize` 는 그대로였다.
**받는 메일 크기** (`x:MtaStageData` · `x:Imap` · `x:Email` 싱글톤) — 큰 메일 한 통을 받으면 엔진 메모리가 그 크기의 5.5배쯤 는다.
그래서 바깥에서 받는 한 통의 크기를 메일 컨테이너의 메모리 한도(1g — [메모리 예산](#메모리-예산))에 맞춰 둔다.
| 설정 | 걸리는 곳 | 기본값 | setup |
| --- | --- | --- | --- |
| `x:MtaStageData` 의 `maxMessageSize` | SMTP 로 받는 메일 한 통(25 · 465 — EHLO 의 `SIZE`. 465 는 시험하지 않았다) | `{"match":{},"else":"104857600"}` (100MiB) | 50MiB(52,428,800)보다 크면 50MiB 로 낮춘다. 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 바꾸지 않고 `[주의]`. 리로드 뒤 EHLO 의 `SIZE` 로 확인한다(`[확인]`) |
| `x:Imap` 의 `maxRequestSize` | IMAP 요청 하나(APPEND 의 리터럴) | 52,428,800 (50MiB) | 바꾸지 않는다 — 기본보다 크면 `[주의]` |
| `x:Email` 의 `maxMessageSize` · `maxAttachmentSize` | JMAP · IMAP 으로 넣는 메일 · 첨부(엔진 설명 기준 — 따로 시험하지 않았다). SMTP 배달에는 걸리지 않는다 | 75,000,000 · 50,000,000 | 바꾸지 않는다 — 기본보다 크면 `[주의]` |
- **넘으면** — SMTP 는 `552 5.3.4 Message too big for system.` 이다(보낸 쪽이 `SIZE` 를 알리면 MAIL FROM 에서, 알리지 않아도 넘는 순간에 — 받아 두지 않는다). 바깥 보낸 사람은 반송을 받는다.
IMAP 은 리터럴을 받기 전에 `BAD [PARSE] Literal exceeds the maximum request size of 52428800 bytes.` 다. 웹메일은 큰 파일을 [대용량 첨부](#대용량-첨부-11단계--인프라)(링크)로 보내므로 걸리지 않는다.
- **IMAP · 메일 크기 한도를 바꾸지 않는 까닭** — imapsync 로 옮기는 동안 큰 메일 때문에 잠시 올린다([큰 메일](#큰-메일-운영-2026-11-03)). 그 도중에 setup 을 돌려도 이전이 깨지지 않게 알리기만 한다 — 이전을 마치면 관리 화면에서 기본으로 되돌린다.
- 잰 값 (2026-11-03 개발 엔진 0.16.27 — 메일 컨테이너 cgroup 의 anon 을 0.1초마다, 무작위 첨부 하나인 메일, 스왑 없음):
| 넣은 길 | 25MB | 50MB | 75MB | 100MB 남짓 | 115MB |
| --- | --- | --- | --- | --- | --- |
| IMAP APPEND | +115MiB | +256MiB | +396MiB | +540MiB (100MB) | +614MiB |
| SMTP 받기 (인증 없음 · 스팸 필터) | +100MiB | +224~270MiB | +409MiB | +557MiB (104MB) | — |
- 큰 메일을 넣은 뒤 엔진의 평소 사용량(기준선)이 127MiB 에서 408MiB 까지 쌓였고 몇 분 안에는 내려오지 않았다 — 가장 클 때 = 그때의 기준선 + 위 값.
- 기본값 그대로 · 1g 에서 바깥 104MB 한 통이 **984 / 1024MiB** 였다(겨우 버팀). 운영은 1g 에서 106.4MB IMAP APPEND 로 OOM 이 났다([큰 메일](#큰-메일-운영-2026-11-03)).
- 받는 한도 50MiB 에서는 50MB 한 통이 가장 클 때 581MiB, 60MB 는 거절되고 메모리가 그대로였다(`SIZE` 없이 보내도 +31MiB).
- `x:Email` 의 `maxMessageSize`(75MB)는 SMTP 배달에 걸리지 않았다 — 104MB 가 그대로 배달됐다.
- 확인 (2026-11-03, 0.16.27 개발): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] 받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize) 52,428,800바이트 (50MiB) · 전 104,857,600`,
두 번째는 `[유지]` 이고, 둘 다 `[확인] IMAP 요청 · 메일 크기 한도 — 엔진 기본 이하 (…)` 가 이어진다.
리로드 뒤의 확인은 두 번 다 `[확인] 받는 메일 크기 한도 — EHLO SIZE 52,428,800 (메일 컨테이너 <앱 네트워크 주소>:25 · 리로드 뒤 엔진이 알리는 값)` 이다.
setup 이 맞췄는데 엔진이 104,857,600 을 알리면 다섯 번 다시 본 뒤 종료 1, SIZE 줄이 없으면 `[주의]` 다(가짜 클라이언트로 본 갈래 — 실제 엔진에서 리로드 실패를 만들지는 않았다).
`x:Imap` 의 `maxRequestSize` 를 115,343,360 으로 올려 두고 돌리면 `[주의] IMAP 요청 · 메일 크기 한도가 엔진 기본보다 큽니다 — …` 이고 값은 그대로 남았다.
- **확인하지 않은 것**
- 메일 프로그램이 465 로 보내는 메일도 같은 식이라 50MiB 를 넘으면 552 일 것이다.
- 웹메일 발송(JMAP 제출)은 엔진 안의 SMTP 세션이라 같은 한도에 걸릴 수 있다. 메일 한 통 ≈ 첨부 합계 × 1.37(base64 · 줄바꿈) + 본문 4MB(인코딩까지 5.5MiB 남짓) + 서명 그림이라
첨부 합계 기본 25MB 면 41MiB 남짓으로 아래지만 32MB 남짓부터 넘는다 — `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` 를 30 넘게 올리려면 이 한도와 [메모리 예산](#메모리-예산)을 함께 본다.
- 큰 메일 두 통 이상을 함께 받는 경우는 재지 않았다 — 쌓인 기준선 408MiB 에 50MB 두 통(270MiB 씩)이면 950MiB 남짓이다. 메일 프로그램은 465 제출과 보낸편지함 APPEND 를 거의 함께 한다.
운영 전에 정할 것:
- **메일을 많이 받는 주소** — 같은 바깥 도메인에서 한 사람이 시간당 25통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다).
@@ -787,7 +824,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
| --- | --- | --- | --- |
| clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) |
| clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 |
| mail | 1g | 0.1~0.3GB | |
| mail | 1g | 0.1~0.3GB (큰 메일 뒤 0.4GB 남짓까지 쌓인다 — 2026-11-03) | 메일 한 통을 받을 때 그 크기 × 5.5 쯤 더 — 받는 한도 50MiB(setup)면 +0.3GB 남짓. 운영에서 106.4MB 한 통이 1g 를 넘었다(2026-11-03 — 개발 104MB 는 984/1024MiB · 서버 B 는 이전하는 동안만 2g, [큰 메일](#큰-메일-운영-2026-11-03) · [받는 메일 크기](#엔진-한도)) |
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
| db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | |
| **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
@@ -959,13 +996,17 @@ unset OTP_ADMIN_PW OTP_TARGET
### 13단계 전부터 운영 중인 서버
이미 떠 있는 운영 서버(서버 B)에 13단계를 거는 차례다. **2026-11-04 뒤에** 한다 — 고정한 DB 이미지(digest)가 그날 2주가 찬다(`.env.production.example` 의 `DB_IMAGE` 주석).
서버의 DB 가 이미 그 digest 로 돌고 있으면(아래 0) 새로 들이는 이미지가 없어 날짜를 기다리지 않아도 된다 — 서버 B 는 10-31 에 받은 `postgres:15-alpine` 이 같은 digest 라 2026-11-03 에 올렸다.
**12단계 전부터 운영 중인 서버**(웹훅 키가 없다)는 3) 전에 [위 12단계 차례](#운영-배포)의 1) — `MAIL_WEBHOOK_SECRET` 넣기 — 를 먼저 한다(키 없이 3)을 하면 백엔드가 env 검사에서 멈춰 웹메일 전체가 선다). 웹훅은 5)의 setup 이 만든다 — 서버 B 는 11단계에서 이렇게 12 · 13단계를 한 번에 올렸다.
모든 컨테이너가 다시 만들어진다(로그 한도 · DB 이미지) — 메일 · 웹메일이 1~2분 끊기므로 사용이 적은 시간에 한다. 그 전에 손으로 백업을 받아 두기를 권한다([손으로 받을 때](#손으로-받을-때-타이머-없이--예전-방법)).
```bash
cd ~/arasystem && git pull # 13단계 코드 (저장소 사용자로)
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다
mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done
# 0) 지금 DB 가 쓰는 이미지 — 예시의 DB_IMAGE 와 같은 sha256 이면 날짜를 기다리지 않아도 된다(위)
sudo docker image inspect "$(sudo docker inspect -f '{{.Image}}' "${PROJECT_NAME}-db")" --format '{{json .RepoDigests}}'
# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다(메일 주소 · IP 가 들어 있다 — 이 사용자만 읽게)
umask 077 && mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done
# 2) .env.production 의 DB_IMAGE 를 예시와 같은 고정값으로 (옛 값 postgres:15-alpine) — 편집기로 그 한 줄만
grep -n '^DB_IMAGE=' .env.production .env.production.example
# 3) 복구 관리자를 일회성으로 준 채 전부 다시 만든다 — 관리자 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 setup 이 건다(막히면 되돌릴 길)
@@ -984,6 +1025,10 @@ node --env-file=.env.production mail/scripts/setup.mjs
- 5)의 출력에 `[변경] 관리자 역할("System Administrator") — 대리 접근(impersonate) 뺌`, `[변경] 관리자 … — 허용 IP <APP_NETWORK_SUBNET>`, 휴지통 · 정리 시각 줄,
그리고 `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP … 안에서 로그인됨` 이 있어야 한다. 6)은 모두 `[유지]` 에 같은 `[확인]` 줄.
- 복구 관리자 없이 처음 돌리면 setup 이 허용 IP 를 걸지 않고 종료 1 로 이 절을 가리킨다(아무것도 막히지 않는다 — 다시 3)부터).
- 5)가 종료 1 이고 `[실패]` 가 **Spamhaus ZEN 시험 항목의 시간 초과**뿐이면 — 리졸버를 막 다시 띄운 직후에 난다(2026-10-31 · 11-03 두 번, 같은 키의 DBL 은 통과).
setup 이 ZEN 을 꺼 두지만 6)의 보통 setup 이 다시 시험해 켠다(`[변경] … ZEN(IP) — DQS 로 묻는다`). 관리자 허용 IP 는 이미 걸리고 확인됐으니(`[확인] 관리자 … 안에서 로그인됨`) 그대로 6)으로 간다 — 이때는 "복구 관리자 끄기" 안내가 나오지 않는다.
- 6)의 끝에 `[확인] 복구 관리자 꺼짐 (mail 컨테이너에 STALWART_RECOVERY_ADMIN 없음)` · `[완료]` · 종료 0 이면 끝이다(2026-11-03 서버 B — 백엔드는 마이그레이션 `AddAccountSecurity` · `AddCutoverPrep` 를 적용하며 떴다. 운영은 마이그레이션 로그를 남기지 않으니 `migrations` 표로 본다:
`sudo docker exec "${PROJECT_NAME}-db" sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select name from migrations order by id desc limit 3"'`).
- 7) **백업 · 감시 설치** — [백업](#백업) › 설치 1~4(부팅 때 엔진 되살리기 유닛 포함), 첫 백업 뒤 `restore.sh drill` 한 번, [감시](#감시-13단계)의 Healthchecks · UptimeRobot.
- 8) 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서 `openssl s_client -quiet -connect mx1.arasystem.it.kr:993` 뒤 `a LOGIN <관리자 주소> <비밀번호>` → `NO`(또는 끊김).
관리 화면은 이제 [SSH 터널](#관리-화면--ssh-터널)로만 들어간다.
@@ -1318,6 +1363,7 @@ alias dcx='docker compose exec postfix-mailcow'
1. **postscreen 예외** — `data/conf/postfix/custom_postscreen_whitelist.cidr` 에 `<MX1_IP>/32 permit` (재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다.
Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다.
2. **Fail2ban 예외** — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에 `<MX1_IP>` (imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다).
mailcow 가 mx1 을 사설 주소로 보면 막히지 않아 필요 없다 — IMAP(993)은 앞단 터널 주소 `10.9.0.1` 로 보였고(2026-11-03), 25 는 보지 않았다. 넣어도 해는 없다([imapsync › 원본](#원본--mailcow-마스터-계정)의 접속 주소).
3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다).
4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다:
```bash
@@ -1455,7 +1501,7 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
| 5. 메일 이전 1차 | [imapsync](#imapsync) — 한 사람으로 먼저(`--justlogin` · 폴더 맞춤), 그다음 주소 목록 전부 | 사람마다 `Detected 0 errors` · 폴더별 수 · 「이전 현황」의 메일함 사용량 |
| 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다). **이때부터 9 까지 직원은 새 서버에 들어오지 못한다** — 짧게(예: 금요일 저녁 ~ 월요일 아침) | 외부에서 보낸 메일이 새 엔진 로그에 수신 |
| 7. 마지막 imapsync | TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령을 한 번 더(차이분 — 이미 옮긴 메일은 건너뛴다). 그 전에 직원에게 mailcow 를 그만 쓰게 알린다 | mailcow 로그에 새 수신 없음 · 사람마다 `Detected 0 errors` |
| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 **한 번** 보인다. mailcow 메일함은 `Disallow login`, 마스터 계정 두 줄 · 서버 B 의 비밀번호 파일 지우기([imapsync](#imapsync) 단계 8) | 「이전 현황」이 비었다 |
| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 **한 번** 보인다. mailcow 메일함은 `Disallow login`, 마스터 계정 두 값 비우기 · 서버 B 의 비밀번호 파일 지우기([imapsync](#imapsync) 단계 8) | 「이전 현황」이 비었다 |
| 9. 임시 비밀번호 나눠 주기 | 직원에게 직접(말 · 쪽지). 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다 | 직원이 웹메일에 들어온다 |
| 10. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — |
@@ -1493,18 +1539,18 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
이전 중인 계정은 운영 도구 메뉴 `11. 메일 이전` 이 다룬다 — `1. 이전 현황` · `2. 이전용 비밀번호 다시 만들기` · `3. 이전 마치기`(2 · 3 은 `주소 (빈 값이면 이전 중인 계정 모두): `).
"이전 중"은 엔진 비밀번호 만료가 미래(30일 안)이면서 운영 도구의 실행 기록에서 마지막으로 건 비밀번호가 이전용인 계정이다(계약 6절 "메일 이전").
엔진의 **비밀번호 기본 만료**(`x:Authentication` 의 `passwordDefaultExpiry` — 관리 화면의 인증 설정)는 켜지 않는다 — 켜면 직원 비밀번호가 주기적으로 만료되고, 끈 뒤에도 그동안 바꾼 비밀번호의 만료가 남는다. 운영 도구는 켜져 있으면 첫 화면에 알린다.
- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. **운영 mailcow · 서버 B 에서는 아직 돌려 보지 않았다** — 단계 5 의 첫 사람으로 확인한다.
- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. 운영(mailcow → 서버 B)은 2026-11-03 에 원본 로그인만 · 1차 · 2차를 15계정으로 돌렸다 — 운영에서만 나온 것은 아래 [큰 메일](#큰-메일-운영-2026-11-03)(엔진의 크기 한도 · 메모리).
#### 차례 (단계 번호는 [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 것)
| 단계 | 할 일 | 비고 |
| --- | --- | --- |
| 준비 | mailcow 마스터 계정 고정(아래 "원본") · mailcow Fail2ban 예외에 새 서버 IP · 서버 B 에 마스터 비밀번호 파일 · 주소 목록(아래 "돌리는 곳") | Fail2ban 예외는 [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 mailcow 절차 2 와 같다 — imapsync 로그인 실패가 쌓여 그 IP 가 막히면 새 서버 → mailcow 의 SMTP 도 끊긴다 |
| 준비 | 단계 2 며칠 전 — mailcow 마스터 계정 고정(아래 "원본") · 서버 B 에 마스터 비밀번호 파일 · **원본 로그인만 미리**(아래 "돌리는 곳"의 준비 줄 — 대상 계정 없이) · 주소 목록 | 원본 쪽 문제(마스터 · 인증서 · 앞단 길)를 계정을 만들기 전에 고친다 — 단계 2 부터는 짧게 이어야 한다(아래). mailcow 의 Fail2ban 예외는 필요 없다 — mailcow 는 바깥 접속을 모두 앞단 터널 주소(사설 `10.9.0.1`)로 보고, 사설 주소는 막지 않는다(아래 "원본"의 접속 주소) |
| 2 | 계정 만들기 — 운영 도구 「입사」를 한 사람씩(모두를 한 실행에서), 옮겨 올 메일 y. 처음 보이는 이전용 비밀번호를 **그 자리에서** 서버 B 의 비밀번호 파일로(아래 — 다시 볼 수 없다) | 「메일 이전 › 이전 현황」에 이전 중인 계정 · 끝나는 때 · 메일함 사용량이 보인다 |
| 5 | imapsync 1차 — **한 사람으로 먼저**(`--justlogin` · 엔진이 본 접속 주소 · 1차 · 아래 "사람마다 확인할 것"), 그다음 주소 목록 전부 | 메일이 많으면 MX 전날에 — 단계 7 의 차이분이 짧아진다 |
| 6 | MX 바꾸기 | 이때부터 단계 9 까지 직원은 새 서버에 들어오지 못한다(비밀번호를 아직 받지 않았다) |
| 7 | **마지막 imapsync** — TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령 | 그사이 mailcow 에 온 메일 · 바뀐 읽음 표시만 옮긴다. 직원에게는 그 전에 mailcow 를 그만 쓰게 알린다 — 이 뒤에 mailcow 에서 한 일은 옮겨지지 않는다 |
| 8 | **마지막 imapsync 가 끝나고 5분쯤 지난 뒤** 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 `Disallow login (incoming mail is still accepted)` 로, 마스터 계정 두 줄 지우기, 서버 B 의 비밀번호 파일 지우기 | 사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 **한 번** 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) |
| 8 | **마지막 imapsync 가 끝나고 5분쯤 지난 뒤** 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 `Disallow login (incoming mail is still accepted)` 로, 마스터 계정 두 값 비우기(아래 "원본"), 서버 B 의 비밀번호 파일 지우기 | 사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 **한 번** 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) |
| 9 | 임시 비밀번호 나눠 주기 — 직원에게 직접(말 · 쪽지) | 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다(계약 6절 "제한된 세션") |
- **단계 6 ~ 9 는 짧게 잡는다** — 예: 금요일 저녁 MX → 토요일 마지막 imapsync · 이전 마치기 → 월요일 아침 나눠 주기. 그동안 바깥 메일은 새 서버에 쌓이고, 직원은 단계 7 전까지 mailcow 를 그대로 쓸 수 있다.
@@ -1520,27 +1566,50 @@ mailcow 는 dovecot-mailcow 를 다시 띄울 때마다 **무작위** 마스터
mailcow 문서 "Static master user"(<https://docs.mailcow.email/manual-guides/Dovecot/u_e-dovecot-static_master/>, 몇 해 된 글):
```bash
# mailcow 호스트 — 설치 폴더(/opt/mailcow-dockerized)의 mailcow.conf 에 두 줄. 둘 다 값이 있어야 한다(하나라도 비면 무작위로 만든다)
# mailcow 호스트(사무실 PC 의 WSL 셸) — mailcow.conf 는 root 소유라 sudo 로 연다: sudo nano /opt/mailcow-dockerized/mailcow.conf
# 두 줄은 mailcow 기본 설정에 빈 값으로 이미 있다(DOVECOT_MASTER_USER= · DOVECOT_MASTER_PASS=). 둘 다 값을 넣는다 — 하나라도 비면 무작위로 만든다
DOVECOT_MASTER_USER=<이름> # 로그인 이름에는 @mailcow.local 이 저절로 붙는다
DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자로 길게
docker compose up -d # dovecot-mailcow 가 다시 만들어진다
DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자만(특수 문자 금지 — mailcow 설정의 주석) · 길게 — 그 PC 에서 openssl rand -hex 24 로 만들어 비밀번호 관리자에 둔다
# 그리고 설치 폴더에서 다시 띄운다 — 두 값을 받는 dovecot-mailcow 만 다시 만들어진다
cd /opt/mailcow-dockerized && sudo docker compose up -d
```
- IMAP 로그인 이름은 `<직원 주소>*<이름>@mailcow.local`(예 `hong@arasystem.it.kr*migrator@mailcow.local` — `@` 가 둘), 비밀번호는 `DOVECOT_MASTER_PASS` 다.
**IMAP 전용** — SOGo(mailcow 웹메일) 로그인에는 쓰지 못한다.
- 끝나면(단계 8) 두 줄을 지우고 `docker compose up -d` — 무작위로 돌아간다. 마스터 비밀번호는 모든 메일함을 연다.
- `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. 운영자가 그 PC 에 들어가 고친다
(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다).
설치 폴더는 compose 가 기록한 작업 폴더로 찾는다 — 값(비밀번호)은 출력하지 않고 이름만 본다:
- 다시 띄우는 동안 직원의 IMAP · 웹메일(SOGo)이 잠깐 끊긴다 — 메일 프로그램은 다시 잇고, 들어오는 메일은 postfix 가 붙잡았다가 넘긴다. 한가한 때에 한다.
- 마스터 비밀번호는 모든 메일함을 연다 — 고정은 단계 2 며칠 전(원본 로그인을 미리 볼 수 있게)부터 단계 8 까지만 둔다.
끝나면(단계 8) 두 값을 비우고(줄은 둔다) 같은 명령으로 다시 띄운다 — 무작위로 돌아간다.
- `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(Windows 의 WSL — 설치 폴더 `/opt/mailcow-dockerized`, 바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다.
운영자가 그 PC 의 WSL 셸에서 고친다(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다).
이전하는 동안 그 PC 가 절전 · 재부팅되면 mailcow 가 멈춰 imapsync 가 끊긴다 — 전원 연결 시 절전 안 함으로 둔다.
`mailcow.conf` 는 root 만 읽고 저장소도 root 소유라 확인 명령은 sudo 로 한다 — 값(비밀번호)은 출력하지 않는다(`git config safe.directory` 는 건드리지 않는다):
```bash
# mailcow PC 에서 (리눅스 셸 · docker 를 쓸 수 있는 사용자)
sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
cd <위 폴더> && grep -o '^#\?DOVECOT_MASTER_[A-Z]*' mailcow.conf # 줄이 있는지 · 주석인지만
git log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜)
# mailcow PC 의 WSL 셸에서 (sudo 를 쓸 수 있는 사용자)
sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' # → /opt/mailcow-dockerized
sudo awk -F= '/^DOVECOT_MASTER_(USER|PASS)=/ { print $1, ($2 == "" ? "비어 있음" : "값 있음") }' /opt/mailcow-dockerized/mailcow.conf # 값 대신 비었는지만. 아무것도 안 나오면 줄이 없거나 주석
sudo git -C /opt/mailcow-dockerized log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜)
```
- **확인하지 않은 것** — imapsync 가 붙는 `mail.arasystem.it.kr:993` 이 Contabo 앞단을 어떻게 지나 사무실 PC 의 mailcow 에 닿는지 —
mailcow 가 보는 접속 주소가 서버 B 의 IP 가 아니라 앞단의 주소라면 Fail2ban 예외도 그 주소여야 하고, 그 주소가 막히면 모든 직원의 메일 프로그램이 함께 막힌다(첫 사람의 `--justlogin` 뒤 mailcow 관리 화면의 로그인 기록에서 그 주소를 본다).
운영 mailcow 판에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 한 사람의 `--justlogin` 으로 먼저 본다),
운영(2026-11-03 확인): 설치 폴더 `/opt/mailcow-dockerized`, 판 2026-05-12(`32156a33`), 두 줄은 주석 없이 **빈 값**이다(아직 아무도 고정하지 않았다).
"값 있음"이면 누군가 이미 고정해 둔 것이다 — 바꾸기 전에 그 값을 쓰는 곳(백업 스크립트 등)이 있는지 알아본다.
- **mailcow 가 보는 접속 주소 — 바깥 접속은 모두 `10.9.0.1`**(2026-11-03 운영 — 최근 7일 로그인 줄 · 지금 연결 모두): `mail.arasystem.it.kr:993` 은 Contabo 앞단에서 터널을 지나
이 PC 의 mailcow 에 닿고, mailcow 는 그 터널 주소 하나로 본다(공인 주소는 0 — 서버 B 도 같은 길이다). 안쪽 `172.22.1.248` 은 SOGo(웹메일), `172.22.1.8` 은 mailcow 안의 다른 컨테이너(7일에 3만 8천 줄 — 상태 점검으로 보인다)다.
- mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 **사설 · 루프백 주소는 막지 않는다**(Python `ipaddress` 의 `is_private` · `is_loopback` — mailcow 원본 `data/Dockerfiles/netfilter/main.py`).
그래서 **서버 B 를 Fail2ban 예외에 넣을 필요가 없다**. 거꾸로 지금 mailcow 는 바깥에서 오는 IMAP 무차별 대입을 IP 로 막지 못한다(전환 뒤 새 엔진은 실제 주소로 막는다).
- 남는 것은 Dovecot 의 늦추기다 — 실패가 있었던 주소의 다음 로그인을 늦춘다(최대 15초). 바깥 접속이 모두 한 주소라 imapsync 의 실패가 쌓이면 바깥 직원의 로그인도 함께 늦어진다 — 실패를 되풀이하지 않는다.
- 이 mailcow 의 docker 로그(json-file · 회전 없음 · Dovecot 만 428MB)는 `--since` 만 주면 **0줄**이다 — 처음부터 읽다가 중간의 읽지 못하는 줄(10-24 재시작 무렵으로 보인다)에서 docker 가 말없이 멈춘다.
`--tail` 은 끝에서부터 읽어 된다 — 아래처럼 늘 `--tail` 을 준다. 앞단을 바꾸는 등 다시 볼 때:
```bash
# 최근 10만 줄(끝에서부터 — 본 범위의 처음 · 끝 시각도 나온다)의 로그인 줄이 어떤 주소였는지. 공인 주소는 세기만 하고 보이지 않는다
sudo docker logs -t --tail 100000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk -v front=46.250.248.43 'NR == 1 { a = substr($1, 1, 19) } { z = substr($1, 1, 19) } /-login: / && match($0, /rip=[0-9A-Fa-f:.]+/) { ip = substr($0, RSTART + 4, RLENGTH - 4); if (ip == front) f++; else if (ip ~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/) p[ip]++; else { if (!(ip in g)) d++; if (++g[ip] > m) m = g[ip]; n++ } } END { print "본 줄", NR, "· 처음", a, "· 끝", z; for (ip in p) print "내부", ip, p[ip] "줄"; print "앞단", front, f + 0, "줄"; print "그 밖의 공인", d + 0, "곳", n + 0, "줄 · 가장 많은 한 곳", m + 0, "줄" }'
# 지금 붙어 있는 IMAP · POP3 연결의 주소(로그 없이 — Dovecot 이 아는 연결)
sudo docker exec "$(sudo docker ps -qf name=dovecot-mailcow)" doveadm -f flow who -1 2>&1 | grep -oE 'ip=[0-9A-Fa-f:.]+' | cut -d= -f2 | awk -v front=46.250.248.43 '$0 == front { f++; next } /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/ { p[$0]++; next } { if (!($0 in g)) d++; if (++g[$0] > m) m = g[$0]; n++ } END { for (ip in p) print "내부", ip, p[ip] "개"; print "앞단", front, f + 0, "개"; print "그 밖의 공인", d + 0, "곳", n + 0, "개 · 가장 많은 한 곳", m + 0, "개" }'
# 준비의 "원본 로그인만"(서버 B — 아래 "돌리는 곳") 뒤 15분 안에 — 마스터 로그인만 골라 그 주소(10.9.0.1 이면 예상대로). 직원의 평소 로그인은 나오지 않는다
sudo docker logs --since 15m --tail 20000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk '/Master user logging in as / { m = $NF; next } m != "" && index($0, "Login: user=<" m ">") { if (match($0, /rip=[0-9A-Fa-f:.]+/)) print m, substr($0, RSTART + 4, RLENGTH - 4); m = "" }'
```
100.64–100.127.x(Tailscale 같은 공유 주소)는 사설로 치지 않아 Fail2ban 이 막을 수 있다 — 앞단을 바꿔 바깥 접속이 그런 주소나 공인 주소 하나로 보이게 되면 그 주소가 예외에 있는지 본다(막히면 바깥 직원 모두가 함께 막힌다).
세 명령은 Dovecot 2.3 의 로그 · doveadm 꼴(`imap-login: Login: user=<…>, …, rip=…` · `Master user logging in as …` · `username=… proto=… pid=… ip=…`)로 맞췄다 — 개발 흉내(Dovecot 2.3.21.1)의 실제 출력과 mailcow 꼴 표본으로,
우분투 · 데비안의 기본 awk 인 mawk 1.3.4 에서 시험했다(2026-11-03). 앞의 둘은 운영 mailcow 에서도 돌렸다. mailcow 는 지금 원본 compose 에서도 `dovecot:2.3.21.1-2` 다.
- **확인하지 않은 것** — 운영 mailcow 판(2026-05-12)에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 준비의 "원본 로그인만"으로 먼저 본다),
메일함 편집의 **Allowed protocols** 에서 IMAP 이 꺼진 메일함이 마스터로도 막히는지(그래서 mailcow 접근 줄이기는 마지막 imapsync 뒤 — 단계 8).
- 개발 흉내(Dovecot 2.3 에 같은 꼴 — 구분자 `*` · 마스터 이름 `migrator@mailcow.local`)에서는 imapsync 가 그 이름을 그대로 넘겨 Dovecot 이 `Master user logging in as kim@legacy.test` 로 받았다.
mailcow 에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED] Authorization failed` 다.
@@ -1575,6 +1644,17 @@ imapsync_one() { # $1 = 직원 주소, 그 뒤는 덧붙일 옵션
--automap --syncinternaldates --addheader --nolog --noreleasecheck "${@:2}" </dev/null
}
# 준비 — 원본 로그인만 미리: 대상 계정 없이 두 쪽 모두 mailcow 로 --justlogin(이전용 비밀번호 파일이 아직 없어도 된다).
# 끝 줄이 `Exiting with return value 0` 이면 된다 — 아니면 grep 없이 다시 보고 아래 "실패할 때"로(되풀이하지 않는다 — 한 번에 실패가 둘이고, Dovecot 이 바깥 직원 모두와 같은 주소의 로그인을 늦춘다).
# 된 뒤 15분 안에 mailcow PC 에서 서버 B 가 어떤 주소로 보였는지 본다(예상 10.9.0.1 — 위 "원본"의 접속 주소)
A=hong@arasystem.it.kr
sudo docker run --rm --network host --user 65534:65534 --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp --tmpfs /var/tmp \
--mount type=bind,src=/root/imapsync/mailcow-master.pass,dst=/run/secrets/master,readonly \
"$IMAPSYNC_IMAGE" imapsync \
--host1 mail.arasystem.it.kr --port1 993 --ssl1 --sslargs1 SSL_verify_mode=1 --user1 "$A*$MASTER" --passfile1 /run/secrets/master \
--host2 mail.arasystem.it.kr --port2 993 --ssl2 --sslargs2 SSL_verify_mode=1 --user2 "$A*$MASTER" --passfile2 /run/secrets/master \
--justlogin --nolog --noreleasecheck </dev/null | grep -E '^(Host[12]: success login|Host[12] failure|Exiting with)'
# 단계 5 — 한 사람으로 먼저: 두 서버에 로그인만 → 엔진이 본 접속 주소(remoteIp)가 B_IP 인지 → 1차
imapsync_one hong@arasystem.it.kr --justlogin
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep auth.success | grep imaps | tail -1
@@ -1622,9 +1702,28 @@ sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass
| `… SSL connect attempt failed … certificate verify failed` · `hostname verification failed` (10) | 인증서 — 자체 서명이거나 이름이 다르다(`--add-host` 없이 주소로 붙음) | 위 `--host2` · 인증서 |
- 같은 실패를 모두에게 되풀이하지 않는다 — 엔진은 IP 마다 인증 실패 하루 100회면 그 IP 를 막는다(자동 차단 — 위 "setup 스크립트가 하는 일" 4. 서버 B 의 사설 IP 는 신뢰 대역 밖이다).
한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이다(준비의 예외).
한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이 막지 않지만(서버 B 도 사설 `10.9.0.1` 로 보인다) Dovecot 이 그 주소 — 바깥 직원 모두 — 의 로그인을 늦춘다(위 "원본"의 접속 주소).
- 한쪽 로그인이 실패해도 imapsync 는 다른 쪽 로그인을 해 본 뒤 끝난다(`--justlogin` 에서 봤다) — 출력의 `Err 1/2` 처럼 실패가 둘로 보이면 먼저 나온 것부터 본다.
#### 큰 메일 (운영 2026-11-03)
엔진의 크기 한도보다 큰 메일은 APPEND 에서 거절되고, 한도 안이어도 100MB 남짓이면 엔진이 메모리 한도 1g 를 넘는다 — 운영 1 · 2차에서 둘 다 겪었다.
| imapsync 출력 (종료 코드) | 까닭 | 한 일 |
| --- | --- | --- |
| 그 메일에서 `literal exceeds …`, 끝에 `Detected N errors` (114 `EXIT_ERR_APPEND`) | 엔진 한도(IMAP 요청 · 메일 · 첨부 크기)보다 크다 — 그 메일만 빠지고 나머지는 옮긴다 | 관리 화면에서 세 값을 110 MiB(`115343360`)로 — Email 의 E-mail Size · Attachment Size, IMAP 의 `maxRequestSize`. setup 은 이 셋을 바꾸지 않고 기본보다 크면 `[주의]` 만 낸다 — 이전을 마치면 되돌린다(아래) |
| 100MB 남짓 한 통에서 `socket closed`, 그 뒤 몇 사람이 `Connection refused` (`EXIT_CONNECTION_FAILURE_HOST2`) | 엔진이 1g 를 넘어 커널에 죽고(OOM — `dmesg` 에 stalwart) 다시 뜨는 동안이다 | `sudo docker update --memory 2g --memory-swap 2g <PROJECT_NAME>-mail` 뒤 그 사람들만 다시 — 모두 통과 |
- `docker update` 는 재시작 · 멈춤에도 남지만 **컨테이너를 다시 만들면 compose 의 1g 로 돌아간다** — 다시 만들었으면 다시 건다. 확인 `sudo docker inspect -f '{{.HostConfig.Memory}} {{.RestartCount}}' <PROJECT_NAME>-mail`(2147483648 이면 2g).
- 110 MiB 보다 큰 메일은 옮기지 못한다 — 운영에서는 보내지 못한 초안 두 통(244 · 135 MiB)이었고, 사용자가 따로 보관하기로 했다(그 사람만 끝까지 `Detected 2 errors` · 114).
- **이전을 마치면 되돌린다**(2026-11-03 사용자 결정) — 「이전 마치기」 뒤 차례대로:
1. 관리 화면에서 세 값을 기본으로 — E-mail Size `75000000` · Attachment Size `50000000` · IMAP `maxRequestSize` `52428800`.
2. setup 을 돌려 `[완료]`(종료 0)로 끝나고 `[확인] 받는 메일 크기 한도 — EHLO SIZE 52,428,800` 줄이 있는지, 크기 한도 `[주의]` 가 없는지 본다 —
`[변경]` · `[유지]` 줄은 저장한 값이고, 엔진에 걸린 값은 리로드 뒤의 이 `[확인]` 줄이다(리로드가 실패하면 엔진은 옛 한도로 받는다).
3. 메모리를 compose 의 값으로 — `sudo docker update --memory 1g --memory-swap 2g <PROJECT_NAME>-mail`.
받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다([엔진 한도](#엔진-한도) "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB, 백업이 도는 동안은 8.15GB 로 8GB 를 넘는다.
#### 개발에서 확인한 것 (imapsync — 2026-11-02)
mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a1d2f6dc164880cd7bfa74009106dfa46b36a6a2ee636d921` — mailcow 와 같은 2.3 줄기, 비-root 로 띄움, 시험 CA 의 인증서)에
@@ -1634,6 +1733,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
| 무엇 | 결과 |
| --- | --- |
| 원본 로그인 | `<주소>*migrator@mailcow.local` 을 imapsync 가 그대로 넘겼다. 원본에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED]` |
| 원본 로그인만(준비) | 두 쪽을 같은 원본 · 같은 마스터 로그인으로 준 `--justlogin` 을 imapsync 가 받는다 — `Host1: success login` · `Host2: success login` · `Exiting with return value 0`(2026-11-03). Dovecot 로그는 로그인마다 `Master user logging in as <주소>` 다음 `Login: user=<주소>, …, rip=<imapsync 주소>` |
| 폴더 맞춤 | INBOX → Inbox(`inbox`) · Sent → Sent Items(`sent`) · Drafts → Drafts(`drafts`) · Trash → Deleted Items(`trash`) · Junk → Junk Mail(`junk`) — **따로 생긴 폴더 없음, `--f1f2` 가 필요 없었다**. Archive 는 보통 폴더 "Archive"(role 없음) |
| 폴더마다 메일 수 | 원본과 같다 (메일 14 · 5통, 폴더 8 · 7개) |
| 받은 때 | 모두 같다 — 2025년 내부 날짜가 초까지 그대로(JMAP `receivedAt`) |
@@ -1651,7 +1751,8 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
- **개발과 운영이 다른 점** — 대상 인증서가 자체 서명이라 `--sslargs2 SSL_verify_mode=0` 으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여 `mail:993` 으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다).
원본은 시험 CA 로 `SSL_verify_mode=1` 이었다.
- **확인하지 않은 것** — 운영 mailcow 의 마스터 계정 · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름.
- **개발에서 확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정 · Allowed protocols · 서버 B 의 길(`--network host` + `--add-host`)은 2026-11-03 운영에서 됐다(원본 로그인만 → 1차 · 2차 15계정). 아직 보지 않은 것은 엔진이 보는 서버 B 의 주소다.
운영의 양 · 시간 · 직원이 만든 폴더 이름은 사람마다의 imapsync 로그(서버 B `/root/imapsync/log/` — 주소 · 폴더 이름이 들어 있어 한 주쯤 뒤 지운다)에 있다.
---
@@ -1709,12 +1810,16 @@ sudo install -m 0644 ops/systemd/arasystem-*.service ops/systemd/arasystem-*.tim
# 2) 설정 · 비밀 — 저장소 밖, root 만 (값은 ops/ops.env.example 의 설명대로)
sudo install -d -m 0700 -o root -g root /etc/arasystem
sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env && sudoedit /etc/arasystem/ops.env
sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password'
# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다
# restic 비밀번호 — 백업 저장소를 암호화하는 열쇠. 비밀번호 관리자의 생성기로 먼저 만들어 저장하고(40자 이상 · 영문 · 숫자) 화면 · 셸 기록에 남기지 않게 붙여 넣는다
read -rsp 'restic 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0600 -o root -g root /dev/stdin /etc/arasystem/restic-password; unset P
# (서버에서 만들려면 sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' — 그때는 sudo cat 으로 한 번 보고 비밀번호 관리자로 옮긴다)
# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다(재설정이 없다).
# 운영자 한 사람만 아는 곳이 아니라 회사가 꺼낼 수 있는 곳에. 저장소를 만든(3 의 init) 뒤에는 같은 값을 계속 쓴다
# 3) 저장소를 만들고 첫 백업을 손으로 (메일 엔진이 몇 초 멈춘다 — 사용이 적은 시간에)
sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh init
sudo systemctl start arasystem-backup.service; journalctl -u arasystem-backup -n 30 --no-pager
sudo systemctl start arasystem-backup.service; sudo journalctl -u arasystem-backup -n 30 --no-pager # 기록은 sudo 로 — 일반 사용자는 "-- No entries --"
sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh snapshots # final 스냅숏 하나
# 4) 타이머 켜기 — 백업 03:30 · 15분 점검, 그리고 부팅 때 백업이 멈춘 엔진 되살리기(표시 파일이 있을 때만 — 평소에는 아무 일도 하지 않는다)
sudo systemctl enable --now arasystem-backup.timer arasystem-check.timer
@@ -1722,10 +1827,25 @@ sudo systemctl enable arasystem-mail-recover.service
systemctl list-timers 'arasystem-*'
```
- **NCP Object Storage** — 콘솔에서 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로.
- **NCP Object Storage** — 콘솔에서 한국 리전에 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다).
**버킷에는 따로 키가 없다** — 계정의 API 인증키(Access Key ID · Secret Key)를 쓴다. **서버 B 는 메인 계정의 API 인증키를 백업 전용으로 새로 만들어 쓴다**(2026-11-03 사용자 결정 — 서브 계정은 만들지 않는다):
콘솔 오른쪽 위 My Account › 계정 및 보안 관리(다시 로그인) › 보안 관리 › 접근 관리 › API 인증키 관리 › [신규 API 인증키 생성] — 계정마다 2개까지라, 처음부터 있던 기본 키는 쓰지 않는다
(그 키를 쓰는 다른 곳과 섞이지 않게 — 문제가 생기면 이 키만 사용 중지할 수 있다) → 두 값을 비밀번호 관리자(restic 비밀번호와 같은 항목)에 → ops.env 의 `AWS_ACCESS_KEY_ID` · `AWS_SECRET_ACCESS_KEY`.
⚠️ **메인 계정 키는 Object Storage 만이 아니라 그 NCP 계정의 모든 API 를 연다**(서버 A · B, 네트워크 · ACG, 스토리지 …) — 이 키가 놓이는 서버 B 의 root 가 뚫리면 계정 전체가 넘어간다. 받아들이고 고른 길이다.
범위를 줄이려면 서브 계정으로 바꾼다 — Sub Account › Sub Accounts › 서브 계정 생성(접근 유형 **API Gateway 접근만**) → 정책 탭에 관리형 `NCP_OBJECT_STORAGE_MANAGER` 하나(`…_VIEWER` 는 읽기만이라 백업이 안 된다)
→ Access Key 탭에서 추가(**Secret Key 는 만들 때만 보인다**). 그래도 버킷 하나로는 좁히지 못한다(NCP 문서에 그 방법이 없다 — 2026-11-03) — 같은 계정에 다른 버킷을 두지 않는다(시험 버킷은 쓸모가 끝나면 지운다).
키를 바꿀 때는 새 키를 ops.env 에 넣고 백업이 한 번 된 것을 본 뒤 옛 키를 사용 중지 → 삭제(사용 중지한 키만 지울 수 있다).
만들 때의 다른 칸은 기본값 그대로 — **잠금 해제**(보존 기간을 걸면 restic 이 지난 백업을 지우지 못해 prune 이 실패한다) · **암호화(KMS) 해제**(restic 이 이미 암호화한다. 한 번 켜면 끌 수 없다) · 타 계정 권한 없음.
"공개"는 버킷의 폴더 · 파일 목록을 누구에게나 연다 — restic 은 키로 서명해 들어가므로 필요 없다(NCP 가이드 "버킷 관리", 2026-11-03 확인).
`RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` —
지역 `kr-standard`(서명 V4) · 경로 방식(NCP 는 경로 · 가상 호스트 방식 둘 다 받는다 — [NCP API 가이드 Object Storage](https://api.ncloud-docs.com/docs/storage-objectstorage)).
⚠️ **NCP 로는 아직 돌려 보지 않았다**(MinIO 로만) — 3)의 `init` · 첫 백업 · 아래 `drill` 로 처음 확인한다.
**NCP 로 확인했다**(2026-11-03 서버 B — `init` · 첫 백업 14초(메일 엔진 멈춤 6초 · 새로 올린 양 363KB · DB 덤프 30KB) · 아래 `drill` 이 계정 · 메일 · DB 표마다 행 수 · env 를 운영과 같게 되살렸다).
- **`init` 이 `Stat: Access Denied` 로 멈추면** — restic 의 첫 요청은 본문 없는 HEAD 라 S3 가 거절한 까닭이 보이지 않는다. 같은 설정으로 목록 조회를 해 오류 코드를 본다(키는 출력하지 않는다):
```bash
sudo bash -c '. /opt/arasystem-ops/lib/common.sh && load_config && for u in "/" "/<버킷>?list-type=2&max-keys=1"; do echo "== $u"; printf "user = \"%s:%s\"\n" "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" | curl -sS --max-time 20 -K - --aws-sigv4 "aws:amz:kr-standard:s3" "https://kr.object.ncloudstorage.com$u" -w "\nHTTP %{http_code}\n" | grep -oE "<(Code|Message|Name|KeyCount)>[^<]*|HTTP [0-9]+"; done'
```
`SignatureDoesNotMatch` — Secret Key 가 그 Access Key ID 와 짝이 아니다(2026-11-03 서버 B 에서 겪음 — 콘솔의 복사 버튼으로 같은 줄의 두 값을 다시 넣어 풀렸다. 화면에서 끌어 복사하면 줄여 보인 값 · 다른 줄의 값이 들어간다).
`InvalidAccessKeyId` — Access Key ID 가 틀렸다. `AccessDenied` — 키는 맞고 권한 · 버킷의 접근 제어(VPC) 문제다. 두 번 모두 `HTTP 200`(`<Name>` 에 버킷 · `<KeyCount>0`)이면 `init` 을 다시 한다.
- 설정 파일 `/etc/arasystem/ops.env` 는 셸로 실행하지 않고 줄마다 읽는다. `ARASYSTEM_DIR`(저장소 폴더)의 `.env.production` 에서 `PROJECT_NAME` 등을 읽는다 — 값을 따로 복사해 두지 않는다.
`PROJECT_NAME` 은 ops.env 에도 적어 두기를 권한다(새 서버 복원의 첫 단계에는 `.env.production` 이 없다 — 아래 "서버를 통째로 잃었을 때"). 둘 다 있고 다르면 스크립트가 멈춘다.
- 백업은 root 의 systemd 가 돈다(`ProtectSystem=full` · `ProtectHome=read-only` · `PrivateTmp` · 최대 6시간 · 멈출 때 엔진을 되살릴 시간 5분). 로그는 `journalctl -u arasystem-backup`.
@@ -1771,14 +1891,20 @@ systemctl list-timers 'arasystem-*'
### 손으로 받을 때 (타이머 없이 — 예전 방법)
배포 전 등에 한 번 받을 때 — DB 는 멈추지 않고, 메일 엔진만 30초쯤 멈춘다(2026-11-03 서버 B 13단계 배포 전에 이대로 받았다 — DB 표 20개 · 메일 1MB).
```bash
sudo docker compose --env-file .env.production stop mail
sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" -v "$PWD/backup:/backup" \
alpine tar czf "/backup/mail-$(date +%Y%m%d-%H%M).tar.gz" -C / config data
sudo docker compose --env-file .env.production start mail
cd ~/arasystem && export PROJECT_NAME=arasystem && umask 077 && D=~/backup-$(date +%Y%m%d-%H%M) && mkdir -p "$D"
# DB — 백업 스크립트와 같은 방법(pg_dump -Fc 뒤 pg_restore -l 로 읽히는지 — 표 수가 나온다)
sudo docker exec "${PROJECT_NAME}-db" sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$D/db.dump" && sudo docker exec -i "${PROJECT_NAME}-db" pg_restore -l < "$D/db.dump" | grep -c ' TABLE '
# 메일 볼륨 — 엔진을 멈추고 묶는다. 이미지는 지금 도는 DB 이미지(새로 받지 않는다) · 네트워크 없음 · 읽기 권한만, 파일은 표준 출력으로 받아 이 사용자 것(0600)
IMG="$(sudo docker inspect -f '{{.Image}}' "${PROJECT_NAME}-db")" && sudo docker compose --env-file .env.production stop mail && sudo docker run --rm --network none --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" --entrypoint tar "$IMG" czf - -C / config data > "$D/mail.tar.gz"; echo "tar 종료 코드 $?"; sudo docker compose --env-file .env.production start mail
ls -lh "$D"
```
- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체가 들어 있다 — 접근을 제한하고 서버 밖에도 둔다.
- 메일 볼륨 줄은 한 줄이다 — 앞 단계가 실패하면 엔진을 멈추지 않게 `&&` 로 묶었다(나눠 붙이다 `IMG=` 를 빠뜨리면 `invalid reference format` 에 빈 파일이다 — 2026-11-03 한 번 겪음). `start mail` 은 어떻게 끝나도 돈다.
- 이미지를 따로 받지 않는 까닭 — 떠 있는 태그(`alpine` 등)를 쓰면 그날의 새 이미지가 들어온다([docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정").
- 복원은 DB 는 빈 DB 에 `pg_restore`, 메일은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체 · DKIM 키 · DB 가 들어 있다 — 접근을 제한하고 서버 밖에도 두며, 쓸모가 끝나면 지운다.
---
@@ -2167,6 +2293,10 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
| 웹메일 발송의 받는 사람이 `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 이 제한 없음인지는 확인하지 않았다) |
| `[주의] IMAP 요청 · 메일 크기 한도가 엔진 기본보다 큽니다 — …` | imapsync 이전 때 올린 값이 남았다 — 이전을 마쳤으면 관리 화면에서 기본(`maxRequestSize` 52,428,800 · E-mail Size 75,000,000 · Attachment Size 50,000,000)으로 되돌린다([큰 메일](#큰-메일-운영-2026-11-03)). 이전 중이면 그대로 둔다 — setup 은 바꾸지 않는다 |
| `받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize)를 읽지 못해 맞추지 않았습니다` · `[주의] 받는 메일 크기 한도(…)가 운영자의 식이라 …` · `…가 0 입니다` | 엔진이 식이 아닌 꼴(판을 올려 속성이 바뀌었는지 본다) · 갈래가 있는 식 · 0 을 돌려줬다 — 관리 API `x:MtaStageData/get` 으로 `maxMessageSize` 를 보고 갈래와 else 를 모두 1 이상 52,428,800 이하로 둔 뒤 `ReloadSettings`(EHLO 의 `SIZE` 로 확인). setup 은 갈래가 없는 숫자 식만 고친다 |
| `받는 메일 크기 한도가 엔진에 걸리지 않았습니다 — 리로드 뒤 EHLO SIZE … 가 52,428,800바이트 (50MiB) 보다 크다` (종료 1) | 저장은 했는데 리로드가 걸리지 않았다 — 같은 실행의 리로드 `[실패]`(바깥 DNS 가 안 되면 엔진이 리로드를 통째로 거절한다 — [리졸버 장애 때](#리졸버-장애-때)) · `[주의]` 를 먼저 고치고 setup 을 다시 돌린다. 걸리기 전에는 엔진이 옛 한도로 받으니 메일 메모리를 1g 로 내리지 않는다([큰 메일](#큰-메일-운영-2026-11-03)) |
| `[주의] 받는 메일 크기 한도가 엔진에 걸렸는지 확인하지 못했습니다 — …` | 메일 컨테이너 안의 curl 로 25 에 EHLO 를 보내지 못했거나, 응답에 SIZE 가 없거나 숫자가 아니다 — 관리 API `x:MtaStageData/get` 으로 값을 보고 리로드한 뒤 다시 실행한다. `[확인]` 줄을 보기 전에는 메모리를 1g 로 내리지 않는다 |
| `[주의] 사내 목록 계정이 없습니다 (…)` | 복구 관리자 없이 돌렸다 — 실패가 아니다. 운영: "운영 배포" 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 재실행 |
+201 -2
View File
@@ -51,6 +51,9 @@
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
* SMTP 로 받는 메일 한 통의 크기(x:MtaStageData 의 maxMessageSize)를 50MiB 이하로 낮춘다 (설정 리로드 뒤 반영 · SMTP_MESSAGE_SIZE_LIMIT 머리말)
* — 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 [주의]. 리로드 뒤 메일 컨테이너 안에서 EHLO 의 SIZE 로
* 걸렸는지 본다. IMAP 요청 · 메일 크기 한도(x:Imap · x:Email)는 바꾸지 않고 엔진 기본보다 크면 [주의] 만
* 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30
* (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말)
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
@@ -206,6 +209,30 @@ 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*$/;
/**
* SMTP 로 받는 메일 한 통의 상한 — x:MtaStageData 싱글톤의 maxMessageSize(25 · 465 — 465 는 시험하지 않았다 · EHLO 의 SIZE 로 알린다 ·
* 엔진 기본 {"match":{},"else":"104857600"} = 100MiB)를 이 값 이하로 둔다 (mail/README.md "엔진 한도" 「받는 메일 크기」 · ensureMessageSizeLimits ·
* 리로드 뒤 확인 checkMessageSizeLimit). 2026-11-03 개발 엔진(0.16.27)에서 잰 것:
* - 큰 메일 한 통을 받으면 엔진 메모리(cgroup anon)가 그 크기의 5.5배쯤 는다 — IMAP APPEND 와 SMTP 받기가 같다(100MB IMAP +540MiB · 104MB SMTP +557MiB · 50MB SMTP +224~270MiB).
* 받은 뒤 기준선이 127MiB 에서 408MiB 까지 쌓이고 몇 분 안에는 내려오지 않았다. 기본값 · 메모리 1g(스왑 없음)에서 바깥 104MB 한 통이 984/1024MiB 였고,
* 운영은 1g 에서 106.4MB IMAP APPEND 로 OOM 이 났다 — 메일 컨테이너 메모리 1g 는 그대로 두고 받는 한 통을 줄인다(2026-11-03 사용자 결정).
* - 넘는 메일은 552 5.3.4 "Message too big for system." — 보낸 쪽이 SIZE 를 알리면 MAIL FROM 에서, 알리지 않으면 넘는 순간에(받아 두지 않고) 거절한다.
* 값은 저장한 뒤 ReloadSettings 로 걸린다(EHLO 의 SIZE 가 52428800 으로 바뀐다).
* - x:Email 의 maxMessageSize · maxAttachmentSize 와 x:Imap 의 maxRequestSize 는 SMTP 배달에 걸리지 않는다(Email.maxMessageSize 75MB 에서 104MB SMTP 메일이 배달됐다) —
* SMTP 로 받는 크기는 이 값 하나로만 막는다
*/
const SMTP_MESSAGE_SIZE_LIMIT = 50 * MEGABYTE;
/**
* IMAP 요청 · 메일 · 첨부 크기의 엔진 기본값 (0.16.27 — 2026-11-03 개발 엔진에서 관리 API 로 읽은 값). setup 은 이 셋을 바꾸지 않는다 —
* imapsync 이전 동안 운영자가 큰 메일 때문에 잠시 올리므로(mail/README.md "큰 메일") 이전 도중에 setup 을 돌려도 이전이 깨지지 않게.
* 기본보다 크면 [주의] 만 낸다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다(SMTP_MESSAGE_SIZE_LIMIT 머리말).
* IMAP 은 maxRequestSize 를 넘는 APPEND 를 리터럴을 받기 전에 BAD [PARSE] "Literal exceeds the maximum request size of …" 로 거절한다.
* 0.16.27 의 값이다 — 엔진 판을 올리면 다시 읽어 맞춘다
*/
const ENGINE_DEFAULT_SIZE_LIMITS = {
Imap: { maxRequestSize: 52_428_800 },
Email: { maxMessageSize: 75_000_000, maxAttachmentSize: 50_000_000 },
};
/**
* 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시 (소문자로 맞춰 검사).
* ⚠️ 백엔드 backend/src/modules/auth/employee-address.ts 의 LOCAL_PART_PATTERN(isLocalPart)과 같은 규칙이다 —
@@ -3143,6 +3170,95 @@ async function ensureUploadQuota(client, cfg) {
log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`);
}
/** x:<Type> 싱글톤의 칸들 — 읽지 못하면(메서드 오류 · 요청 실패 · 싱글톤 없음) 멈추지 않고 { error: 까닭 } */
async function readSingleton(client, type, properties) {
try {
const item = (await call(client, `x:${type}/get`, { ids: ['singleton'], properties })).list?.[0];
return item ? { value: item } : { error: `x:${type}/get 에 singleton 이 없다` };
} catch (error) {
if (!(error instanceof SetupError)) throw error;
return { error: error.message };
}
}
/**
* 받는 메일 크기 한도 (SMTP_MESSAGE_SIZE_LIMIT · ENGINE_DEFAULT_SIZE_LIMITS 머리말 — mail/README.md "엔진 한도" 「받는 메일 크기」).
* 1. SMTP 로 받는 한 통(x:MtaStageData 의 maxMessageSize)을 SMTP_MESSAGE_SIZE_LIMIT 이하로 — 저장만 한다(main 의 리로드로 반영).
* 더 작게 둔 값은 올리지 않는다. 0 은 뜻(제한 없음인지)을 확인하지 않았고, 갈래가 있거나 else 가 숫자 하나가 아닌 식은 운영자의 것이라 바꾸지 않고 [주의].
* 2. IMAP 요청 · 메일 · 첨부 크기(x:Imap · x:Email)는 읽기만 한다 — 엔진 기본보다 크면 [주의], 읽지 못해도 멈추지 않는다.
* 돌려주는 값: 1 의 상태 — 리로드 뒤 확인(checkMessageSizeLimit)에 넘긴다. 'saved' 이번에 낮춰 저장 · 'kept' 단순한 꼴(갈래 없음 · 숫자 else)로 이미 한도 이하 ·
* 'operator' 운영자의 식 · 'zero' 0 · 'unread' 읽지 못함(problems)
* ⚠️ 출력의 "받는 메일 크기 한도" 는 운영 차례 문서(mail/README.md "큰 메일")가 이 글자로 확인한다 — 바꾸지 않는다
*/
async function ensureMessageSizeLimits(client) {
const label = '받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize)';
const mib = `${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB`;
const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${mib})`;
const memory = '큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다';
const smtp = await readSingleton(client, 'MtaStageData', ['maxMessageSize']);
const expression = smtp.value?.maxMessageSize;
let state;
if (smtp.error || !isExpression(expression)) {
state = 'unread';
problems.push(
`${label}를 읽지 못해 맞추지 않았습니다 (${smtp.error ?? JSON.stringify(expression) ?? '칸 없음'}) — ${limit} 이하여야 합니다 (mail/README.md "엔진 한도").`,
);
} else if (branchesOf(expression).length > 0 || !/^\d+$/.test(String(expression.else))) {
state = 'operator';
warn(
`${label}가 운영자의 식이라(갈래가 있거나 else 가 숫자 하나가 아니다) 바꾸지 않았습니다 (${JSON.stringify(expression)}) — ` +
`모든 갈래와 else 가 ${limit} 이하인지 관리 화면에서 확인하세요. ${memory} (mail/README.md "엔진 한도").`,
);
} else {
const size = Number(expression.else);
if (size === 0) {
state = 'zero';
warn(`${label}가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. 관리 화면에서 1 이상 ${limit} 이하로 두세요 — ${memory} (mail/README.md "엔진 한도").`);
} else if (size > SMTP_MESSAGE_SIZE_LIMIT) {
state = 'saved';
await setObjects(client, 'MtaStageData', { update: { singleton: { maxMessageSize: { match: {}, else: String(SMTP_MESSAGE_SIZE_LIMIT) } } } });
log(`[변경] ${label} ${limit} · 전 ${formatCount(size)}`);
} else {
state = 'kept';
log(`[유지] ${label} ${formatCount(size)}바이트 (${mib} 이하)`);
}
}
// IMAP 요청 · 메일 · 첨부 크기 — 이전(imapsync) 동안 운영자가 올리므로 바꾸지 않는다. 0 · null 은 뜻을 확인하지 않은 값이라 "확인하지 못함"
const scope = 'IMAP 요청 · 메일 크기 한도';
const large = [];
const unread = [];
const within = [];
for (const [type, defaults] of Object.entries(ENGINE_DEFAULT_SIZE_LIMITS)) {
const read = await readSingleton(client, type, Object.keys(defaults));
if (read.error) {
unread.push(`x:${type} — ${read.error}`);
continue;
}
for (const [field, engineDefault] of Object.entries(defaults)) {
const value = read.value[field];
const name = `x:${type} 의 ${field}`;
if (!Number.isSafeInteger(value) || value <= 0) unread.push(`${name} = ${JSON.stringify(value) ?? '칸 없음'}`);
else if (value > engineDefault) large.push(`${name} ${formatCount(value)}(기본 ${formatCount(engineDefault)})`);
else within.push(`${name} ${formatCount(value)}`);
}
}
if (large.length > 0) {
warn(
`${scope}가 엔진 기본보다 큽니다 — ${large.join(' · ')}. ${memory} — 메모리 1g 로는 100MB 남짓(운영 106.4MB) 한 통에서 죽을 수 있다. ` +
'이전(imapsync)하는 동안 올렸다면 마친 뒤 관리 화면에서 기본으로 되돌린다 — setup 은 바꾸지 않는다 (mail/README.md "큰 메일").',
);
}
if (unread.length > 0) {
warn(
`${scope}를 읽지 못했거나 뜻을 확인하지 않은 값(0 · null 등)이라 엔진 기본보다 큰지 확인하지 못했습니다 (${unread.join(' · ')}) — ` +
'setup 은 바꾸지 않는다, 관리 화면에서 본다 (mail/README.md "엔진 한도").',
);
}
if (large.length === 0 && unread.length === 0) log(`[확인] ${scope} — 엔진 기본 이하 (${within.join(' · ')})`);
return state;
}
/** 시 · 분 → "HH:MM" */
function formatClock(hour, minute) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
@@ -3426,8 +3542,10 @@ async function checkHttpEndpointPolicy(cfg) {
}
/**
* 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄 }. curl 을 부르지 못하면 null.
* 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄,
* ehlo: { sent: EHLO 를 보냈는가, size: EHLO 응답의 SIZE 값 — 숫자 글 · 숫자가 없으면 '' · SIZE 줄이 없으면 null }, status: curl 종료 코드 }. curl 을 부르지 못하면 null.
* MAIL FROM 이 거절되거나 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
* 받는 주소를 하나도 주지 않으면 curl 은 EHLO 뒤 HELP 만 하고 끝낸다(MAIL FROM · RCPT · DATA 없음 — curl 8.14.1, 2026-11-03 개발 엔진) — EHLO 만 볼 때 쓴다.
* sender 가 '<>' 면 빈 봉투(MAIL FROM:<>)다 (RCPT_CHECK_SENDER).
*/
function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) {
@@ -3454,9 +3572,26 @@ function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) {
{ input: '', encoding: 'utf8' },
);
if (result.error) return null;
const replies = { mailFrom: null, rcpt: new Map() };
const replies = { mailFrom: null, rcpt: new Map(), ehlo: { sent: false, size: null }, status: result.status };
let asked = null;
let inEhlo = false;
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
if (/^> EHLO /.test(line)) {
replies.ehlo.sent = true;
inEhlo = true;
continue;
}
if (inEhlo) {
// EHLO 응답은 여러 줄이다 — "250-…" 이 이어지고 "250 …" 이 끝이다 (SIZE 는 RFC 1870 — 숫자가 없을 수 있다)
const extension = /^< 250([ -])(.*)$/.exec(line);
if (extension) {
const size = /^SIZE(?:\s+(\d+))?\s*$/i.exec(extension[2].trim());
if (size) replies.ehlo.size = size[1] ?? '';
if (extension[1] === ' ') inEhlo = false;
continue;
}
if (/^[<>] /.test(line)) inEhlo = false;
}
if (/^> MAIL FROM:/.test(line)) {
asked = { mailFrom: true };
continue;
@@ -3516,6 +3651,66 @@ async function checkSystemRecipientsRejected(cfg, addresses) {
);
}
/**
* 받는 메일 크기 한도가 리로드 뒤 엔진에 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션 — 바깥 메일이 들어오는 길)로
* EHLO 를 보내 SIZE 를 읽는다(smtpRepliesInsideMail 에 받는 주소 없이 — EHLO · HELP 뿐, 메일은 보내지 않는다). 루프백이 아니라 네트워크 주소인 까닭은
* 시스템 계정 RCPT 확인과 같다. ensureMessageSizeLimits 의 [변경] · [유지] 는 저장한 값이라, 리로드가 걸리지 않았으면 엔진은 옛 한도(기본 100MiB)로 받는다 —
* 운영 차례(mail/README.md "큰 메일")는 이 [확인] 줄을 보고 메일 컨테이너 메모리를 1g 로 되돌린다.
* - SIZE 가 1 이상 SMTP_MESSAGE_SIZE_LIMIT 이하 → [확인]
* - 더 큰데 setup 이 단순한 꼴로 저장 · 유지했다(state 'saved' · 'kept') → problems(종료 1). 운영자의 식 · 0 · 읽지 못함이면 [주의]
* - SIZE 줄이 없거나 숫자가 아니거나 0, 확인 자체를 못 함 → [주의]
* ⚠️ "[확인] 받는 메일 크기 한도 — EHLO SIZE" 는 운영 차례 문서가 이 글자로 확인한다 — 바꾸지 않는다
*/
async function checkMessageSizeLimit(cfg, state) {
const label = '받는 메일 크기 한도';
const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB)`;
const ours = state === 'saved' || state === 'kept';
const mailIp = mailContainerIp(cfg);
if (!mailIp) {
warn(`docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 ${label}가 엔진에 걸렸는지(EHLO 의 SIZE) 확인하지 못했습니다.`);
return;
}
const where = `메일 컨테이너 ${mailIp}:25`;
const sizeOf = (replies) => (/^\d+$/.test(replies?.ehlo.size ?? '') ? Number(replies.ehlo.size) : null);
const within = (size) => size !== null && size >= 1 && size <= SMTP_MESSAGE_SIZE_LIMIT;
let replies = null;
// setup 이 맞춘 값이면 리로드가 퍼질 때까지 몇 번 더 본다 — 운영자의 식 · 0 은 기다려도 바뀌지 않는다
for (let attempt = 0; attempt < (ours ? SETTLE_ATTEMPTS : 1); attempt += 1) {
if (attempt > 0) await sleep(POLL_INTERVAL_MS / 2);
replies = smtpRepliesInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, []);
if (within(sizeOf(replies))) break;
}
const size = sizeOf(replies);
if (within(size)) {
const note = state === 'operator' ? ' · 운영자의 식이라 다른 세션(465 · 인증한 세션 등)은 다를 수 있다 — 위 [주의]' : '';
log(`[확인] ${label} — EHLO SIZE ${formatCount(size)} (${where} · 리로드 뒤 엔진이 알리는 값${note})`);
return;
}
if (size !== null && size > SMTP_MESSAGE_SIZE_LIMIT) {
const head = `${label}가 엔진에 걸리지 않았습니다 — 리로드 뒤 EHLO SIZE ${formatCount(size)} (${where}) 가 ${limit} 보다 크다`;
if (ours) {
problems.push(
`${head} — 엔진에 저장된 값은 ${limit} 이하다(setup 이 이번에 저장했거나 그대로 두었다). 리로드가 실패했거나 반영되지 않았으니 위 [실패] · [주의] 를 보고 ` +
'setup 을 다시 돌린다 — 걸리기 전에는 엔진이 옛 한도로 받아 큰 메일 한 통에 메모리 1g 를 넘을 수 있다 (mail/README.md "큰 메일").',
);
} else {
const untouched = {
operator: '운영자의 식이라 setup 이 바꾸지 않았다 — 위 [주의]',
zero: '0 이라 setup 이 바꾸지 않았다 — 위 [주의]',
unread: 'setup 이 값을 읽지 못해 맞추지 않았다 — 끝의 [실패] 항목',
};
warn(`${head} — ${untouched[state] ?? 'setup 이 맞추지 않은 값이다'}. 관리 화면에서 ${limit} 이하로 두고 다시 실행하세요 (mail/README.md "엔진 한도").`);
}
return;
}
let why;
if (!replies) why = 'docker exec 로 curl 을 부르지 못했다';
else if (!replies.ehlo.sent) why = `EHLO 를 보내지 못했다 (curl 종료 코드 ${replies.status})`;
else if (replies.ehlo.size === null) why = 'EHLO 응답에 SIZE 줄이 없다';
else why = `EHLO 의 SIZE 가 한도를 알리지 않는다 (SIZE ${replies.ehlo.size || '숫자 없음'})`;
warn(`${label}가 엔진에 걸렸는지 확인하지 못했습니다 — ${why} (${where}). 관리 API 로 x:MtaStageData 의 maxMessageSize 를 보고, 리로드한 뒤 다시 실행하세요 (mail/README.md "엔진 한도").`);
}
/**
* 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서).
* 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면
@@ -5270,10 +5465,12 @@ async function main() {
}
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다 · 시범 공존이면 기존 서버 IP 도 뺀다),
// 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영)
// SMTP 로 받는 메일 한 통은 50MiB 이하로 (아래 리로드로 반영) — IMAP 요청 · 메일 크기 한도는 이전 동안 운영자가 올리므로 바꾸지 않고, 엔진 기본보다 크면 [주의] 만
await ensureRecipientThrottle(admin, pilot);
// 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다)
await ensurePilotInboundTrust(admin, pilot);
await ensureUploadQuota(admin, cfg);
const messageSize = await ensureMessageSizeLimits(admin);
// 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영)
const retention = await ensureDataRetention(admin, cfg);
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
@@ -5297,6 +5494,8 @@ async function main() {
}
await checkHttpEndpointPolicy(cfg);
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
// 받는 메일 크기 한도가 위 리로드로 실제로 걸렸는지 — 메일 컨테이너 안에서 EHLO 의 SIZE (저장 때의 [변경] · [유지] 만으로는 모른다)
await checkMessageSizeLimit(cfg, messageSize);
if (pilot) await checkPilotSenderGuard(cfg);
checkEngineDnssec(cfg, engineDnsChanged);
await checkEngineWebhook(admin, cfg, webhook);
+8 -4
View File
@@ -4,15 +4,18 @@
# 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — root 만 읽게:
# sudo install -d -m 0700 -o root -g root /etc/arasystem
# sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env # 그다음 값을 채운다 (sudoedit /etc/arasystem/ops.env)
# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로:
# sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password'
# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다.
# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로. 비밀번호 관리자의 생성기로 먼저 만들어 저장한 뒤(40자 이상 · 영문 · 숫자)
# 화면 · 셸 기록에 남기지 않고 붙여 넣는다(restic 은 끝 줄바꿈을 떼고 읽는다):
# read -rsp 'restic 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0600 -o root -g root /dev/stdin /etc/arasystem/restic-password; unset P
# (서버에서 만들려면 sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' — 그때는 sudo cat 으로 한 번 보고 비밀번호 관리자로 옮긴다)
# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자 — 운영자 한 사람만이 아니라 회사가 꺼낼 수 있는 곳)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다.
#
# 형식: KEY=값 한 줄씩. 스크립트는 이 파일을 셸로 실행(source)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다.
# ==========================================
# 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다
# (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다)
# 아래 예시 값(/home/ubuntu/…)을 그대로 두지 않는다 — 클론한 사용자의 홈으로(서버 B 는 /home/ara/arasystem). 틀리면 .env.production 을 찾지 못해 멈춘다
ARASYSTEM_DIR=/home/ubuntu/arasystem
# 프로젝트 이름 — 볼륨 · 컨테이너 이름(<PROJECT_NAME>_mail-data · <PROJECT_NAME>-mail)과 백업 스냅숏의 host 이름이 이 값이다.
# 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이
@@ -29,7 +32,8 @@ RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasys
# NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다)
# (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects)
RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'
# NCP 서브 계정의 API 인증키 — 이 버킷만 쓰게 좁힌 계정으로 (콘솔 › Sub Account)
# NCP API 인증키 — 버킷에는 따로 키가 없다. 서버 B 는 메인 계정의 백업 전용 키(My Account › 계정 및 보안 관리 › 보안 관리 › 접근 관리 › API 인증키 관리 — 2026-11-03 결정).
# ⚠️ 메인 계정 키는 그 계정의 모든 API 를 연다 — 범위를 줄이려면 Object Storage 만 쓰는 서브 계정의 키로(mail/README.md "백업 › 설치"). 어느 쪽이든 버킷 하나로는 좁히지 못한다
AWS_ACCESS_KEY_ID=change-me
AWS_SECRET_ACCESS_KEY=change-me
# 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다)