Compare commits

..

3 Commits

Author SHA1 Message Date
ttipo 867543c49f docs(rules): 10단계 — 파일럿 키(setup 전용 · 셸 예외)와 docker 스킬의 파일럿 공존
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 18s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m30s
CI / Infra (compose · Dockerfile · nginx) (push) Successful in 21s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m38s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 11m56s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m55s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m20s
CI / Frontend (lint · type-check · test · build) (push) Successful in 11m58s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m18s
파일럿 키 둘은 setup 만 읽는다(compose · backend 에 넘기지 않는다). 셸 값과 env 파일이 다르면 멈추는 규칙의 예외를 바로 적었다 —
복구 관리자(원래 셸 일회성)와 파일럿 키: 개발은 한 번 시험하려고 셸로 줄 수 있고, 운영은 파일에 없는 시범 값이 셸에 있으면 멈춘다
(엔진에 남는 설정이라 파일만 보고도 알 수 있게).
docker 스킬에는 켜고 끄는 차례 · 가드 없는 relay 를 만들지 않는 규칙 · "끄지 못했습니다"일 때 할 일 · 운영 확인 항목을 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:37:25 +09:00
ttipo 258ebf2180 feat(docker): mail setup — 파일럿 공존(한 사람 먼저) 켜기 · 끄기와 자체 확인
전면 전환 전에 한 사람만 같은 @arasystem.it.kr 주소로 새 서버를 쓰고 나머지 직원은 mailcow 에 둔다.
MX 는 mailcow 그대로이고 mailcow 의 Transport Map 이 시범 주소 하나만 새 서버로 넘긴다 —
새 서버는 자기에게 없는 사내 주소를 mailcow 로 중계해야 해서 루트 env 두 개(MAIL_PILOT_LEGACY_HOST ·
MAIL_PILOT_LEGACY_IPS)로 setup 이 맞추게 했다.

- 켜는 차례는 막는 것부터: 시범 표시(기존 서버 IP 의 자동 차단 예외) → RCPT 가드(인증 없는 세션의 없는 사내 주소 550) ·
  위조 막기(사내 도메인 MAIL FROM 은 mailcow IP 에서만) · 그 IP 의 SPF · 역방향 DNS · DMARC 검사와 받는 쪽 한도 예외 →
  리로드 → 켜기 전 가드 확인 → legacy 경로 · 발송 전략(인증한 제출 · 엔진이 만든 메일만 legacy) → 리로드 →
  allowRelaying → 켠 뒤 확인. allowRelaying 은 저장 즉시 먹어 가드보다 먼저 켜면 열린 중계가 된다.
- 가드 없는 relay 를 setup 이 만들지 않는다: 가드는 "2초 안에 550"으로 가린다(가드 거절은 곧바로 · 엔진 자체 거절은
  waitOnFail 5초 뒤). 아니면 켜지 않거나 바로 다시 끄고, 끄지 못하면 가드만이라도 다시 건 뒤 사실대로 알리고 멈춘다.
  끌 때는 allowRelaying 을 먼저 끄고 엔진에서 다시 읽어 꺼진 것을 본 뒤에만 나머지를 뺀다 — 주인을 모르는 relay 는
  끄지도 가드를 빼지도 않는다.
- 설명 칸이 없는 식의 주인은 시범 표시의 주소로 가린다 — 같은 꼴의 운영자 설정은 덮어쓰지 않고, 헷갈리면 멈춘다.
- 운영은 env 파일의 값으로만 켜고(포트 25 만 · 문서 대역 · 사설로 풀리는 이름 · 자기 자신 거부), 값이 빠졌다고
  저절로 끄지 않는다(끄기는 --end-pilot). 처음 켤 때 DKIM TXT 가 없거나 엔진의 키와 다르면 켜지 않는다.
  개발은 셸 값으로 한 번 시험한다.

리뷰 세 번(H1 · M1 · M2 · L1~L9 → N1~N8 → R1~R4)을 반영했다. 개발 엔진(0.16.27, 기존 서버 역 Mailpit)에서
켬 · 같은 값으로 다시([유지]) · 끔을 돌려 끈 뒤 엔진 설정이 처음과 같았다.
mailcow 쪽 절차와 운영 흐름은 아직 해 보지 않았다 — mail/README.md "파일럿 공존".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:37:25 +09:00
ttipo 4c1737f222 docs: 10단계(파일럿 공존) — 시범 동안의 받는 사람별 결과 · 사내 목록
시범 동안 새 서버에 없는 사내 주소는 엔진이 mailcow 로 중계하므로 웹메일이 곧바로 not-found 로 알리지 못하고
나중의 반송 메일로 알게 된다. 사내 목록도 새 서버의 계정(시범 사용자)뿐이다.
클라이언트 · 서버 코드는 바꾸지 않았다 — 계약과 받는 사람별 결과 주석에 그 차이를 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:37:24 +09:00
8 changed files with 2057 additions and 71 deletions
+5 -1
View File
@@ -30,6 +30,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
│ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
```
규칙:
@@ -53,6 +54,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
예외는 복구 관리자 `MAIL_RECOVERY_ADMIN`(원래 셸에서 일회성으로 주는 값이라 검사하지 않는다)과 파일럿 키 둘이다 — 파일럿 키는 개발은 한 번 시험하려고 셸로 줄 수 있고(`[참고]` 를 낸다),
운영은 반대로 파일에 없는 파일럿 값이 셸에 있으면 중단한다 (파일럿은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 한다). 운영에서 파일럿을 끄는 것은 값을 비우고 `--end-pilot` 을 줄 때뿐이다 — 값이 빠졌다고 저절로 끄지 않는다.
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
@@ -74,6 +77,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+17
View File
@@ -317,6 +317,23 @@ node --env-file=.env.development mail/scripts/setup.mjs
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
- 사내 목록 계정 `<MAIL_DIRECTORY_LOCAL_PART>@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정").
- **파일럿 공존**(10단계 — mail/README.md "파일럿 공존"): 루트 env `MAIL_PILOT_LEGACY_HOST`(옛 서버 mailcow 주소)·`MAIL_PILOT_LEGACY_IPS`(그 서버가 보내는 IP)를 넣으면
setup 이 새 서버에 없는 사내 주소를 옛 서버로 중계하게 한다. 켜는 차례는 막는 것부터 — 시범 표시(옛 서버 IP 의 자동 차단 예외 — 아래 식들의 주인 표시) →
받는 쪽 가드(인증 없는 모르는 사내 주소 550)·위조 막기·옛 서버 IP 의 검사 · 한도 예외 → 리로드 → **켜기 전 가드 확인** → 경로(리로드) →
도메인의 `allowRelaying`(저장 즉시 먹어 가드보다 먼저 켜면 열린 중계가 된다) → 켠 뒤 확인. 끌 때는 거꾸로(`allowRelaying` 먼저) — 운영은 값을 비우고 `--end-pilot` 일 때만.
**가드 없는 relay 를 setup 이 만들지 않는다** — 가드 확인은 "2초 안에 550"이다(가드 거절은 곧바로 · 엔진 자체 거절은 `waitOnFail` 5초 뒤라 응답 시간으로 가른다 — 그래서 `waitOnFail` 은 4초 이상).
켜기 전에 아니면 켜지 않고, 켠 뒤에 아니면 relay 를 바로 다시 끈다(둘 다 종료 코드 1). 끄지 못하면 "끄지 못했습니다"로 멈춘다 — 이미 켜진 relay 를 처음 확인할 때(0단계)는
멈추기 전에 RCPT 가드만 다시 걸고 그 결과를 싣는다. "대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다"면 열린 relay 는 아니고(relay 는 켜진 채),
"열려 있습니다 · 열려 있을 수 있습니다"면 관리 화면에서 그 도메인의 relay 허용을 바로 끈다.
가드는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 뺀다 — 켜져 있는데 setup 의 `legacy` 경로가 없으면
끄지 않고 가드도 남긴 채 종료 코드 1(요약 `끝내지 못함`, 시범 설정이 하나도 없으면 `시범 설정 없음 · allowRelaying 켜짐`).
개발은 셸로 한 번 시험한다(`MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` — 다음 평소 실행이 끈다.
엔진 로그에서 개발 외부 경로 `mailpit` 과 가르려면 컨테이너 이름 `<PROJECT_NAME>-mailpit:1025` 를 준다).
운영 확인 항목: 새 호스트 → mailcow **25** 가 열려 있을 것(운영은 25 만 받는다), 파일럿 동안 mx1 에 AAAA 를 두지 않을 것(SPF 는 ip4 만), mailcow 가 보내는 IP 가 목록과 같을 것(빗나가면 동료 메일이 `550 5.7.1`),
그 IP 로 사람이 만든 자동 차단 예외(`x:AllowedIp`) · 검사 예외(SPF · DMARC 등) · IP 한도 예외가 없을 것(setup 이 시범 표시를 붙여 만든다 — 있으면 멈춘다),
새 DKIM TXT 를 **켜기 전에** DNS 에 넣을 것(운영은 처음 켤 때 TXT 가 없거나 다르면 켜지 않는다),
끄기 · 되돌리기 전에 새 서버 대기열에 동료 앞 메일이 남지 않았을 것(끈 뒤에는 로컬 실패 → 반송).
개발의 인증 발송 확인(465)은 백엔드 컨테이너의 node 로 돈다(메일 이미지에는 node 가 없다 — 비밀번호는 표준 입력으로 넘기고 출력하지 않는다).
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
+7
View File
@@ -159,6 +159,13 @@ MAIL_TRUSTED_NETWORKS=
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
# 개발은 비워 두고, 시험할 때만 셸에서 일회성으로 준다 (기존 서버 역 = Mailpit — 개발에서는 셸 값을 일부러 받는다):
# MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs
# 값 없이 다시 실행하면 setup 이 시범 설정을 지운다. 기존 서버 주소 — host · host:port · [host]:port (포트 기본 25)
MAIL_PILOT_LEGACY_HOST=
# 기존 서버가 이 서버에 붙을 때의 IPv4 · CIDR (콤마). 개발은 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳)
MAIL_PILOT_LEGACY_IPS=
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
+7
View File
@@ -170,3 +170,10 @@ MAIL_TRUSTED_NETWORKS=
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
# MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소만 이 서버로 넘긴다. 값은 셸이 아니라 이 파일에 둔다.
# ⚠️ 켠 뒤에 값이 비면 setup 이 멈춘다 — 시범을 끝낼 때(전면 전환)는 둘 다 비우고 setup 에 --end-pilot 을 붙인다.
# 기존 서버 주소 — host · host:port · [host]:port (운영은 포트 25 만 — 기본값, IPv4 주소면 인증서 이름 확인을 끈다). 예: mail.arasystem.it.kr
MAIL_PILOT_LEGACY_HOST=
# 기존 서버가 이 서버에 붙을 때의 공인 IPv4 · CIDR(/24 까지, 콤마) — HOST 가 있으면 필수. 예: 46.250.248.43
MAIL_PILOT_LEGACY_IPS=
@@ -15,6 +15,8 @@ import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
* 남긴다(키는 받는 사람 주소). 거절된 사람은 `delivered: "no"` 와 SMTP 응답으로만 남고 엔진이 **다시 보내지도 반송하지도 않는다**
* (Stalwart 0.16.27 — 2026-10-28 확인):
* - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도)
* 파일럿 공존(10단계) 동안은 새 서버에 없는 사내 주소를 엔진이 옛 서버로 중계하므로 여기서 거절되지 않는다 — 그쪽의 거절은 나중에 반송 메일로 온다
* (계약 2절 "받는 사람별 결과", mail/README.md "파일럿 공존")
* - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다.
* 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다
* 판단은 deliveryStatus 로 한다 — undoStatus 는 모두 거절이면 final(붙잡아도 — 2026-10-29 확인), 하나라도 대기열에 들어가면 pending 일 뿐이다.
+8 -1
View File
@@ -361,6 +361,10 @@ interface RejectedRecipient {
엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서).
- **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다).
개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다).
- **파일럿 공존 동안**(10단계 — mail/README.md "파일럿 공존"): 새 서버에 없는 사내 주소는 거절되지 않는다 — 엔진이 옛 서버(mailcow)로 중계하고,
그쪽이 받지 않으면 몇 초 뒤 반송 메일(`MAILER-DAEMON@<도메인>`)이 받은편지함에 온다(바깥 주소와 같은 모양). 그래서 그런 주소는 `rejectedRecipients` · `MAIL_010` · `MAIL_011` 에
나오지 않고, 제출 기록도 대기열에서 빠지면 `250 2.1.5 Queued` 로 돌아가 나중의 실패를 남기지 않는다. 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 그대로 `not-found`.
setup 이 받는 쪽 가드를 확인하지 못하면 중계를 켜지 않거나 다시 닫는다(종료 코드 1 — 열린 중계를 막으려고). 그동안은 평소처럼 곧바로 `not-found` 다.
- **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`.
같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다).
@@ -1027,7 +1031,8 @@ interface PendingSend {
엔진이 그 시각을 거절하면(`forbiddenMailFrom` — 엔진 한도가 바뀐 경우 등) 400 `VAL_001` 이고 만든 보낸 사본은 지운다(구현하며 정함).
7일은 엔진 한도다(`x:MtaExtensions` 의 `futureRelease` — 로그인 세션 `7d`. JMAP 능력의 `maxDelayedSend` 는 30일로 보이지만 엔진이 7일 넘게는 거절한다 — 2026-10-29 확인).
붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이라 7일 안은 안전하다(같은 날 확인). 예약에는 보내기 취소 시간을 더하지 않는다. 응답 `pendingSend: { kind: 'scheduled', sendAt }`.
- **받는 사람 확인은 제출 순간에 한다** — 붙잡아도 엔진이 사내 받는 사람을 그 자리에서 묻는다(없는 사내 주소는 곧바로 `delivered: no` · `550 5.1.2`, 반송 없음 — 2026-10-29 확인).
- **받는 사람 확인은 제출 순간에 한다** — 붙잡아도 엔진이 사내 받는 사람을 그 자리에서 묻는다(없는 사내 주소는 곧바로 `delivered: no` · `550 5.1.2`, 반송 없음 — 2026-10-29 확인.
파일럿 공존 동안은 새 서버에 없는 사내 주소가 옛 서버로 중계된다 — 위 "받는 사람별 결과").
그래서 위 "받는 사람별 결과"(일부 거절 `rejectedRecipients` · 모두 거절 `MAIL_010` · `MAIL_011`)는 그대로다. 바깥 주소는 풀린 뒤 배달되고, 실패하면 지금처럼 반송 메일이 온다.
붙잡은 제출의 결과는 사내 · 바깥 모두 곧바로 `250 2.1.5 Queued` 다(거절된 사람만 `delivered: no`). 모두 거절인데 제출이 아직 기다리고 있으면(모두 4xx 등)
같은 요청에서 먼저 취소하고 사본을 지운다 — 풀리는 시각에 다시 보내지 않게(취소를 엔진이 받지 않아도 사본은 지우고 warn). 모두 5xx 면 엔진이 붙잡아도 곧바로 `final` 이라 지우기만 한다(같은 날 확인).
@@ -1346,6 +1351,8 @@ interface DirectoryEntry {
(비밀번호 같은 칸은 없다 — 2026-10-29 확인). 이 권한은 엔진 관리자가 내주지도 빼지도 못해 setup 이 **복구 관리자로 실행될 때** 만든다(개발은 늘, 운영은 첫 배포의 `--recovery` — mail/README.md "사내 목록 계정").
이 계정은 메일을 받지 않는다 — 그 주소(+태그 꼴 포함)로 오는 메일은 받는 쪽 RCPT 단계에서 없는 주소처럼 550 이다(setup 이 관리자 · 게이트웨이와 함께 맞춘다 —
받아 놓고 보낸 사람에게 반송하지 않게, mail/README.md "보안"). 그 뒤의 막으로 역할에 메일 받기 권한이 없고 저장 한도도 1MiB 다.
- **파일럿 공존 동안**(10단계)에는 새 서버의 계정 — 파일럿 사용자 — 만 나온다. 목록을 읽을 수는 있으므로 "로그인한 직원 기록" 대신 쓰기는 켜지지 않는다 —
동료는 내 주소록 · 최근 주고받은 주소(옛 메일을 옮기면 채워진다)로만 추천에 나온다. 파일럿 동안 새 서버에 동료 계정을 만들지 않는다(만들면 그 사람에게 가는 메일이 새 서버에 갇힌다).
- 서버는 목록을 10분 기억한다(모든 직원이 함께 — 서버 메모리). 사내 목록 계정이 없거나 읽지 못하면 503 `MAIL_001` 이고, 받는 사람 추천의 "사내"는 전처럼 로그인한 직원 기록으로 대신한다.
- 한 항목(`GET /api/directory-entries/{id}`)은 그 기억한 목록에서 찾는다 — 화면이 주소로 연 사내 항목. `excludeGroupId` 는 그 직원 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다.
+327 -6
View File
@@ -117,17 +117,24 @@ node --env-file=.env.development mail/scripts/setup.mjs
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다.
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)).
시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다)
시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다).
보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다
10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11
(7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다)
11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
12. **시범 공존** (`MAIL_PILOT_LEGACY_HOST` 가 있을 때 — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)) — 바꾸기 전에 도메인 · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 보고,
막는 것(맨 앞에 주인 표시인 자동 차단 예외 · RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외)을 6 · 8 과 함께 저장해 9 의 리로드로 먼저 걸고, RCPT 가드가 답하는지 본 뒤 →
발송 경로 `legacy` · 발송 전략 → 리로드 → 도메인 `allowRelaying`(저장 즉시) → 다시 RCPT · MAIL FROM 을 실제로 보내 확인한다 —
켜기 전에 가드가 2초 안에 답하지 않으면 켜지 않고, 켠 뒤에 답하지 않으면 `allowRelaying` 을 바로 다시 끄고 멈춘다(끄지 못하면 그렇게 알린다).
값이 비면 거꾸로(`allowRelaying` 먼저, 꺼진 것을 다시 읽어 확인한 뒤에만 나머지) 지운다 — 운영은 `--end-pilot` 이 있어야 한다
그 밖의 안전장치:
- **운영은 docker 를 쓸 수 있어야 한다** — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작).
- **셸에 `APP_ENV`·`APP_NETWORK_SUBNET` 등이 이미 있으면 중단한다.** Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않아,
셸에 `APP_ENV=development` 가 남은 채 `.env.production` 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다.
(`MAIL_RECOVERY_ADMIN` 만 예외 — 원래 셸에서 일회성으로 주는 값이다)
(`MAIL_RECOVERY_ADMIN` 은 예외 — 원래 셸에서 일회성으로 주는 값이다. 시범 공존 값 `MAIL_PILOT_LEGACY_*` 는 **개발에서만** 예외 — 시험할 때 셸에서 한 번 켜 본다.
운영은 env 파일에 없는 시범 값이 셸에 있어도 중단한다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다)
- `APP_NETWORK_SUBNET` 은 사설 대역의 네트워크 주소, 접두 길이 `/24`~`/28` 만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다
(넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다).
- `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다.
@@ -444,8 +451,298 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
**MX 는 mailcow 그대로**이고, mailcow 의 Transport Map 이 시범 사용자 주소 하나만 새 서버로 넘긴다. 새 서버 쪽은 루트 env 두 개로 setup 이 맞춘다.
- 예시 값: 시범 사용자 `owner@arasystem.it.kr`, 동료(mailcow) `colleague@arasystem.it.kr`, mailcow `mail.arasystem.it.kr`(`46.250.248.43`), 새 서버 IP `<MX1_IP>`
- 근거: 2026-10-29 개발 엔진(0.16.27) 조사 · 시험과 mailcow(2026-10 판) 설정 파일 · 문서 조사. **mailcow 쪽 절차와 운영의 실제 흐름은 아직 해 보지 않았다** — "개발 확인"이라고 적은 것만 개발 엔진에서 봤다.
### 메일 흐름
| 메일 | 길 | 새 서버가 하는 일 |
| --- | --- | --- |
| 바깥 → 시범 사용자 | 바깥 → mailcow(MX · 스팸 검사) → Transport Map → mx1:25 | mailcow IP 에서 인증 없이 받는다 — 그 IP 는 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다(연결 IP 가 mailcow 라 늘 실패한다). DKIM 검증 · 스팸 판정은 그대로 |
| 동료 → 시범 사용자 | SOGo · 메일 프로그램 → mailcow → Transport Map → mx1:25 | 사내 도메인 MAIL FROM 을 **mailcow IP 에서만** 받는다. 받는 쪽 한도 · 자동 차단에서 mailcow IP 를 뺀다 |
| 시범 사용자 → 동료 | 웹메일 · 465 → mx1 → 발송 경로 `legacy` → mailcow:25 → 동료 사서함 | 새 서버에 없는 사내 주소도 받아서(`allowRelaying`) mailcow 로 넘긴다. DKIM 은 새 selector 로 서명 |
| 새 서버가 만든 메일 → 동료 | 반송 · 필터 전달 · 부재중 → `legacy` → mailcow | 봉투 보낸 사람은 빈 봉투 `<>`(반송)이거나 시범 사용자 주소(전달 · 부재중) — mailcow 의 `reject_unlisted_sender` 를 통과한다 |
| 시범 사용자 → 바깥 | mx1 → 받는 쪽 MX 직접 | 시범 앞과 같다 — mailcow 를 거치지 않는다(SPF 에 새 IP) |
| 시범 사용자 → 시범 사용자 | mx1 안 | 로컬 |
| 인증 없이 mx1:25 로 온, **새 서버에 없는** 사내 주소 (mailcow 포함 어디서든) | — | RCPT `550 5.1.2` — 받아 놓고 되돌려 보내지 않는다 (루프 1차 막기) |
| mailcow 가 아닌 IP 에서 온 사내 도메인 MAIL FROM | — | `550 5.7.1` (위조 막기 — 빈 봉투 `<>` 는 그대로 받는다) |
루프는 두 겹으로 막는다 — ① RCPT 가드: 인증 없는 세션의 새 서버에 없는 사내 주소는 받지 않는다 ② 발송 전략: `legacy` 로는 **인증한 제출(웹메일 · 465)과 엔진이 만든 메일(필터 전달 · 부재중 · 반송 · 보고)만** 보낸다 —
인증 없이 들어온 메일은 로컬(배달 실패 → 보낸 사람에게 반송, 그 반송은 동료 앞이면 `legacy` 로). 엔진의 홉 한도(`x:MtaStageData` 의 `maxReceivedHeaders` 50 → `450 4.4.6`)가 마지막 막이다.
**RCPT 가드 없이 `allowRelaying` 만 켜진 상태는 setup 이 만들지 않는다** — 켤 때는 가드가 걸린 뒤에 켜고, 켠 뒤 가드가 답하지 않으면 바로 다시 끄고, 끌 때는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 가드를 뺀다(2026-10-30 리뷰 H1 · M1).
### 사전 조건
- **운영 전환 체크리스트의 "1 결정 · 준비" · "2 서버 배포"를 마쳤다** — 새 서버가 mx1 로 떠 있고([운영 배포](#운영-배포)), setup 을 다시 돌리면 전부 `[유지]` 다.
- **DNS 는 더하기만** (가비아) — [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 단계 1 · 3 · 4:
`mx1` A → `<MX1_IP>` · PTR → `mx1.arasystem.it.kr`, setup 이 출력한 새 DKIM selector TXT 두 개(`v1-ed25519-…` · `v1-rsa-…`), SPF 에 `ip4:<MX1_IP>`.
**DKIM TXT 는 첫 시범 실행 전에 퍼져 있어야 한다** — 운영 setup 이 이 호스트의 리졸버로 읽어, 없거나 키가 다르면 켜지 않고 멈춘다.
**MX · DMARC · autodiscover 는 바꾸지 않는다.** mx1 에 AAAA 는 두지 않는다(아래 "주의" 의 IPv6).
- 새 서버의 계정은 **시범 사용자 하나만**. 다른 직원 · 별칭 · 공용 주소 · `dmarc@` · `postmaster@` 는 만들지 않는다(아래 "주의").
- 새 서버 → mailcow **25** 가 열려 있다(setup 이 확인한다 — 운영은 25 만 받는다. 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다).
mailcow 가 새 서버에 붙을 때의 IP 를 안다 — 보통 `mail.arasystem.it.kr` 의 A(`46.250.248.43`).
- `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) · `MAIL_DIRECTORY_LOCAL_PART`(기본 `mail-directory`) · 게이트웨이 `MAIL_GATEWAY_USER` 의 @ 앞(예 `mail-gateway`)이 mailcow 의 실제 주소와 겹치지 않는다 —
셋 다 새 서버의 로컬 주소(메일을 받지 않는 시스템 계정)라, 시범 사용자가 mailcow 의 같은 이름 주소로 보내면 새 서버가 `not-found` 로 거절한다.
- 기존 서버 IP(`MAIL_PILOT_LEGACY_IPS`)에 **사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다** — setup 이 같은 주소로 시범 표시 항목을 만들어야 한다(같은 주소의 항목은 둘일 수 없다). 있으면 setup 이 멈춘다 — 지운 뒤 다시 실행한다.
- 시범 사용자의 mailcow 사서함은 **그대로 둔다** — Transport Map 은 mailcow 에 있는 받는 사람에게만 먹고, 새 서버가 owner@ 명의로 동료에게 보낼 때도 mailcow 의 `reject_unlisted_sender` 를 이 사서함으로 통과한다.
### env 와 실행 (새 서버)
`.env.production` 에 두 값을 적는다 — **셸이 아니라 파일에** (운영은 env 파일에 없는 시범 값이 셸에 있으면 setup 이 멈춘다):
```bash
MAIL_PILOT_LEGACY_HOST=mail.arasystem.it.kr # host · host:port · [host] · [host]:port (포트 기본 25 — 운영은 25 만)
MAIL_PILOT_LEGACY_IPS=46.250.248.43 # mailcow 가 이 서버에 붙을 때의 공인 IPv4 · CIDR (콤마)
```
그리고 [운영 배포](#운영-배포) 5) 와 같은 형태로(`MAIL_ADMIN_URL` · sudo 여부도 같게) setup 을 실행한다:
```bash
node --env-file=.env.production mail/scripts/setup.mjs
```
| 값 | 받는 꼴 | 운영에서 막는 것 |
| --- | --- | --- |
| `MAIL_PILOT_LEGACY_HOST` | `host` · `host:port` · `[host]` · `[host]:port` — 포트는 1~65535, 앞자리 0 없이(기본 25). IPv6 는 받지 않는다 | 25 가 아닌 포트 · `mailpit` · 점 없는 이름(도커 내부 이름으로 풀릴 수 있다) · 사설 · 특수 IPv4(문서 · 시험 대역 `192.0.2.0/24` · `198.51.100.0/24` · `203.0.113.0/24` · `198.18.0.0/15` · `192.0.0.0/24` 포함) · `MAIL_HOSTNAME`(모든 환경). 이름이 사설 · 특수 주소로 풀려도 멈춘다(실행할 때 메일 컨테이너 안에서 푼다) |
| `MAIL_PILOT_LEGACY_IPS` | IPv4 · CIDR(네트워크 주소로)을 콤마로 | 비어 있음(HOST 가 있으면 필수) · 사설 · 특수 대역(위와 같은 문서 · 시험 대역 포함) · `APP_NETWORK_SUBNET` 과 겹침 · `/24` 보다 넓음 · 이 서버 자신의 주소 |
- 기존 서버 주소가 IPv4(예: `46.250.248.43`)면 발송 경로의 인증서 이름 확인을 끈다(`allowInvalidCerts: true` — 출력에 알린다). 이름(`mail.arasystem.it.kr`)이면 mailcow 의 공인 인증서로 확인한다 — **이름을 권한다**.
- 개발은 셸에서 일회성으로 켜 본다(개발만 셸 값을 받는다) — 기존 서버 역은 Mailpit, 기존 서버 IP 는 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳 — 앱 네트워크 전체는 쓰지 않는다):
`MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` → 값 없이 다시 돌리면 바로 끈다.
### setup 이 하는 일
먼저 확인한다 — 하나라도 어긋나면 **아무것도 바꾸지 않고** 멈춘다. 다만 **RCPT 가드 없이 `allowRelaying` 이 열려 있으면 그것부터 닫는다**(`[변경]`, 다시 읽어 확인) —
그다음 확인이 모두 맞으면 아래 차례(A → B → C)로 다시 켜고, 어긋나면 닫힌 채 멈춘다(머리글 "allowRelaying 만 닫고 중단합니다").
**닫지 못하면**(저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면) 다른 확인 · 설정(경로 · 전략 · C) 없이 멈추되(종료 1), 멈추기 전에 **RCPT 가드만 다시 건다** —
RCPT 단계 식이 setup 의 것이거나 비었을 때만 가드 갈래를 저장하고 시스템 Sieve 스크립트를 켠 뒤 리로드하고 가드를 확인한다(2026-10-30 3차 리뷰 R1). 멈춤 글이 그 결과를 싣는다 —
"대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(550 · Nms). relay 는 여전히 켜져 있습니다" 또는 "RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다"
(운영자의 식이면 건드리지 않고 그렇다고 적는다). 어느 쪽이든 관리 화면에서 그 도메인의 relay 허용(`allowRelaying`)을 끈 뒤 다시 실행한다(N1). 멈출 때는 그 전에 쌓인 확인 실패도 함께 찍는다.
| 확인 | 까닭 |
| --- | --- |
| `x:MtaStageRcpt` 의 `waitOnFail` 이 고정 값이고 4초 이상(기본 5초) · (`allowRelaying` 이 이미 켜져 있으면) 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 이다 | 가드(Sieve)의 거절은 곧바로(개발 0.2~1.1ms), 엔진 자체의 없는 주소 거절은 `waitOnFail` 뒤(개발 4,999~5,003ms)다 — 응답 시간으로 둘을 가른다. 가드가 답하지 않으면 relay 를 먼저 닫는다. `waitOnFail` 이 바뀌어 있어도 확인은 보낸다 — 250 이면(시간과 상관없이 가드가 없다) 닫고, 550 이면 relay 를 그대로 두고 `waitOnFail` 문제로 멈춘다(N3) |
| 도메인에 catch-all 이 없다 | catch-all 은 `allowRelaying` 보다 먼저라 동료 앞 메일이 catch-all 함으로 간다 |
| 하위 주소(`subAddressing`)가 기본(`Enabled` — `+`), 바깥 디렉터리(`directoryId`)가 없다 | RCPT 가드가 `+` 를 가정한다 · 엔진의 계정만 로컬로 본다 |
| 기존 서버 주소가 엔진의 서버 이름(`x:SystemSettings` 의 `defaultHostname` · `MAIL_HOSTNAME`)과 다르고, 메일 컨테이너 안에서 풀어도 엔진 자신(메일 컨테이너 주소 · 이 호스트의 IPv4 · 서버 이름의 주소 · 루프백)이 아니며, 기존 서버의 인사(220)가 엔진의 서버 이름이 아니다 · (운영) 이름이 사설 · 특수 주소로 풀리지 않는다 | 자기 자신으로 보내면 메일이 돈다 — 인사 이름으로 보는 것은 DNS · NAT 와 상관없이 잡힌다 |
| 메일 컨테이너 안에서 `curl -X NOOP smtp://<기존 서버>:<포트>/<서버 이름>` 이 220 · 250 — 운영은 `--ssl-reqd`(STARTTLS 와 인증서까지, IPv4 주소면 인증서는 보지 않는다) | 닿지 않으면 동료 앞 메일이 새 서버 대기열에 쌓인다. 메일은 보내지 않는다. **이미 켜진 시범의 같은 기존 서버**가 잠깐 닿지 않거나 이름이 풀리지 않으면 `[주의]` 만 하고 이어 간다(다른 정비를 막지 않는다) — 기존 서버를 바꾸는 실행이면 멈춘다 |
| 발송 경로 이름 `legacy` · RCPT 단계 스크립트 · 운영의 발송 전략("사내 도메인은 로컬, 나머지는 경로 하나" 꼴)을 다른 설정이 쓰지 않는다 | setup 은 남의 설정을 덮어쓰지 않는다 — 외부 릴레이(SES 등)를 쓰고 있으면 그 경로가 바깥 발송으로 남는다 |
| 기존 서버 IP 에 메일 컨테이너 주소가 없다 · (운영) 이 서버 자신의 주소가 없다 · (운영) 기존 서버 이름의 주소가 목록에 있는지(없으면 `[주의]`) | 위조 막기를 메일 컨테이너에서 확인한다 |
| 기존 서버 IP 에 사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다 | setup 의 시범 표시 항목이 그 주소를 써야 한다(아래 "주인 표시") — 지운 뒤 다시 실행하면 setup 이 같은 주소로 만든다 |
| 기존 서버 IP 로만 된 시범 꼴의 갈래(위조 막기 · 검사 예외 · "Sender IP throttle")가 표시 없이 있지 않다 · 한 칸에 setup 꼴의 갈래가 둘 이상이 아니다 | 같은 원칙(N4) — 운영자가 넣은 같은 IP 의 갈래는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고, 시범을 끝낼 때 함께 지워진다. 그 갈래를 지운 뒤(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다. 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 setup 의 갈래도 이렇게 보인다 — 그때는 같은 설명으로 표시를 다시 만들어도 된다. 둘 이상이면 하나만 남기고 지운다. 다른 IP 의 같은 꼴 갈래는 막지 않고 건드리지도 않는다 |
| (운영) 활성 DKIM selector 의 TXT 가 DNS 에 있고 공개 키가 엔진의 키와 같다 — 이 호스트의 리졸버로 읽는다 | 다르면 mailcow 가 owner 의 메일을 위조로 거절한다(`R_DKIM_REJECT` → `SPOOFED_UNAUTH` — 아래 mailcow 절차 4). 없으면 DMARC 가 SPF 정렬에만 기대게 되어(`R_DKIM_PERMFAIL` 은 0점), 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일이 거절된다. **처음 켤 때 다르거나 TXT 가 확실히 없으면**(ENOTFOUND · ENODATA) 멈춘다 — relay 를 켜지 않는다, TXT 를 넣고 퍼진 뒤 다시(N8). 이미 켜져 있으면 실패로 보고만(닫지 않는다). 리졸버 오류 · 엔진이 공개 키를 주지 않으면 `[주의]`(견줄 수 없음 — 운영에서는 아직 돌려 보지 않았다) |
그다음 순서대로 맞춘다 — **막는 것부터**:
| 차례 | 엔진 설정 | 값 |
| --- | --- | --- |
| A | `x:AllowedIp` | 기존 서버 IP — 설명 `[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP` (→ `ReloadBlockedIps`). **시범 설정의 주인 표시**라 A 의 맨 앞에서 만든다 — 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다(N5). 바뀐 IP 의 옛 항목은 식을 모두 고친 뒤에 지운다 |
| A | `x:MtaStageRcpt` 의 `script` | 시스템 계정 갈래 + **RCPT 가드** — 인증 없는 세션의 새 서버에 없는 사내 주소(+태그 꼴 포함)는 시스템 계정과 같은 스크립트로 `550 5.1.2` |
| A | `x:MtaInboundThrottle` "Sender address to recipient throttle" | 인증 없는 세션 중 기존 서버 IP 가 아닌 것만 센다 |
| A | `x:MtaStageMail` 의 `isSenderAllowed` | 맨 앞 갈래 — 인증 없는 세션의 사내 도메인 MAIL FROM 이 기존 서버 IP 가 아니면 false → `550 5.7.1` |
| A | `x:SenderAuth` 의 `spfEhloVerify` · `spfFromVerify` · `reverseIpVerify` · `dmarcVerify` | 맨 앞 갈래 — 기존 서버 IP 면 `disable` (나머지 갈래는 그대로) |
| A | `x:MtaInboundThrottle` "Sender IP throttle" | 기존 서버 IP 를 세지 않는다(찾지 못하거나 엔진 기본 · setup 의 값이 아니면 `[주의]`) |
| | `ReloadSettings` | A 가 걸린다 |
| | **켜기 전 확인** | 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 — relay 가 꺼져 있어도 같은 판별이다(RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돈다 · 개발 실측 0.3~0.7ms). 아니면 B · C 없이 종료 1(N2) |
| B | `x:MtaRoute` Relay `legacy` | `<HOST>:<포트>`, STARTTLS 가 있으면 쓴다 — 설명 `시범 공존 — … [setup.mjs]` |
| B | `x:MtaOutboundStrategy` 의 `route` | 0 새 서버의 주소 → 로컬 · 1 그 밖의 사내 주소 + `source` 가 `authenticated` · `autogenerated` · `dsn` · `report` → `legacy` · 2 그 밖의 사내 주소 → 로컬 · 나머지 → 시범 앞의 값(운영 `mx`, 개발 `mailpit`) |
| | `ReloadSettings` | B 가 걸린다 |
| C | `x:Domain` 의 `allowRelaying` | `true` — **저장 즉시 먹는다**(리로드가 필요 없다). 그래서 A · B 가 걸리고 가드를 확인한 뒤에 둔다 |
- 갈래 1 의 `source` 는 **허용 목록**이다(2026-10-30 리뷰 L1). 0.16.27 의 값은 `authenticated`(웹메일 · SMTP 인증 제출) · `unauthenticated` · `dmarc_pass`(인증 없는 수신) · `dsn`(반송) ·
`report`(보고) · `autogenerated`(필터 전달 · 부재중 등 Sieve) · `unknown`(표시가 없는 대기열 메일)이다(소스 `smtp/src/queue/mod.rs`). 빠진 셋은 legacy 로 가지 않는다 —
인증 없는 수신은 RCPT 가드가 먼저 막고, `unknown` 은 0.16.27 이 새로 만드는 메일에는 붙지 않는다(옛 판에서 남은 대기열 메일뿐). **엔진 마이너를 올리면 이 목록을 다시 본다.**
- **주인 표시** — 식(위조 막기 · 검사 예외 · "Sender IP throttle")에는 설명 칸이 없어, setup 은 **시범 표시가 있는 자동 차단 예외의 주소와 같은 IP 의 갈래만** 자기 것으로 본다.
표시가 없으면 같은 꼴이어도 운영자 것으로 보고 켤 때도 끌 때도 건드리지 않는다(시범 설정이 있을 때만 `[주의]` 로 알린다). 뺄 때는 맨 앞만이 아니라 모든 갈래에서 찾는다.
기존 서버 IP 로만 된 운영자 갈래는 켜기 전에 멈추고(위 확인 표), 한 칸에 setup 꼴의 갈래가 둘 이상이면(setup 은 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것) 켤 때는 멈추고
끌 때는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의]`) — 켤 때도 끌 때도 운영자 갈래를 합치거나 지우지 않는다(N4).
- **받는 사람 한도**("Sender address to recipient throttle")는 표시로 가리지 않는다 — 8단계부터 setup 이 match 를 통째로 정하는 칸이라, 켤 때는 기존 서버 IP 를 뺀 식으로,
끌 때는 "인증 없는 세션에만"으로 되돌린다. 표시를 보는 것은 끄는 실행이 relay 가 켜져 있어 시범 설정을 남길 때 그 예외가 setup 의 것인지 가르는 데뿐이다(N5).
확인 — 메일 컨테이너 안에서 자기 네트워크 주소의 25 로, RCPT 까지만 (메일은 보내지 않는다):
- **켜기 전**(A 의 리로드 뒤 · B 앞): 인증 없는 세션 `RCPT TO:<setup-check-<난수>@도메인>` → **550 이고 2초 안** (RCPT 가드, 보낸 사람은 빈 봉투). 아니면 relay 를 켜지 않고 종료 코드 1(2차 리뷰 N2).
이미 켜진 시범이면 relay 가 켜진 채로 보고, 가드가 답하지 않으면 곧바로 끈다.
- **켠 뒤**(C 바로 뒤): 같은 확인. 아니면 **`allowRelaying` 을 곧바로 다시 끄고**(다시 읽어 확인) 종료 코드 1 — 가드 · 경로는 남겨 두어 원인을 고친 뒤 같은 명령으로 다시 켠다(2026-10-30 리뷰 M1).
끄지 못하면 "끄려 했지만 끄지 못했습니다 — 지금 가드 없이 열려 있을 수 있습니다"로 알린다(N1).
- 인증 없는 세션 `MAIL FROM:<setup-check-<난수>@도메인>` → `550 5.7.1` (메일 컨테이너는 기존 서버 IP 가 아니다)
- [개발] 인증한 세션(465, 테스트 직원) `RCPT TO:<setup-check-…@도메인>` → 250 뒤 RSET — 운영은 직원 비밀번호가 없어 하지 않는다(아래 mailcow 절차 4 의 실제 발송으로 본다).
비밀번호는 `docker exec` 의 환경변수가 아니라 표준 입력으로 넘긴다
다시 돌리면 전부 `[유지]`, 값이 바뀌면 그 칸만 `[변경]` 이다. 끌 때는 거꾸로 — `allowRelaying` 을 먼저 끄고(저장 즉시) **엔진에서 다시 읽어 꺼진 것을 확인한 뒤에만**
발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 → 리로드 → `legacy` 경로(발송 전략을 되돌린 리로드 뒤 — 걸려 있는 전략이 지워진 경로를 고르는 틈이 없게, L5).
**운영은 값을 비우는 것만으로는 끄지 않는다** — env 파일에서 값이 빠진 채 돌리면 "값을 다시 넣거나 `--end-pilot`" 으로 멈춘다(아무것도 바꾸지 않는다). 개발은 바로 끈다.
`allowRelaying` 은 켜져 있는데 setup 의 `legacy` 경로가 없으면(관리 화면에서 경로만 지웠을 수도 있다) 누가 켠 것인지 알 수 없어 끄지 않고 실패로 보고하며,
**그동안 RCPT 가드 · 위조 막기 · 예외 · 발송 전략도 남긴다**(가드 없는 relay 를 setup 이 만들지 않는다). `allowRelaying` 을 끈 뒤 다시 돌리면 그때 뺀다.
**다른 도메인**이 `allowRelaying` 을 켜 두었으면 setup 은 그 도메인을 끄지 않고 시범 설정을 남긴다(RCPT 가드는 모든 사내 도메인을 덮는다) — "다른 도메인 … 이 켜 두어"로 알린다.
시범 설정이 하나도 없는데 `allowRelaying` 만 켜져 있으면 바꾸지 않고 실패로 알린다(요약 `시범 설정 없음 · allowRelaying 켜짐` — 가드 없이 열린 relay 다).
개발 확인 (2026-10-29, 0.16.27 — 기존 서버 역 Mailpit, 기존 서버 IP 역 백엔드 컨테이너):
- 처음 실행은 A · B · C 가 `[생성]` · `[변경]`, 다시 돌리면 전부 `[유지]` · 종료 코드 0. 기존 서버 IP 를 둘로 늘렸다 줄이면 그 칸만 `[변경]` · `[삭제]`(여러 IP 의 식도 엔진이 받았다).
- 웹메일 → 새 서버에 없는 사내 주소는 `legacy` 경로로 한 번 배달(엔진 로그 `delivery.delivered … hostname = "<legacy 주소>"`, Mailpit 의 id 가 같다), 있는 계정은 로컬, 바깥 주소는 개발 외부 경로로, 시스템 계정은 여전히 `not-found`.
- 인증 없는 25 의 없는 사내 주소(+태그 포함) 550 · 있는 계정(+태그 · 대문자 포함) 250, 메일 컨테이너의 사내 도메인 MAIL FROM `550 5.7.1`, 기존 서버 IP 의 사내 도메인 MAIL FROM 은 받아서 배달(`Authentication-Results: … none`).
- 기존 서버 IP 가 새 서버에 없는 주소를 되돌려 보내면 RCPT 550 — Mailpit 에 되돌아 배달된 것이 없고 대기열이 빈다.
- 기존 서버 주소가 엔진 자신(`mail:25`) · `MAIL_HOSTNAME` · 닿지 않는 주소면 아무것도 바꾸지 않고 멈췄다. 값 없이 다시 돌리면 엔진 설정이 시범 앞과 같아졌다(스냅숏 비교) — 웹메일의 없는 사내 주소는 다시 `not-found`.
개발 확인 (2026-10-30 리뷰 수정 뒤, 기존 서버 역 `arasystem-mailpit:1025` — 엔진 로그의 `hostname` 으로 개발 외부 경로 `mailpit` 과 가른다):
- **허용 목록 경로** — 465 인증 제출(`queue.authenticated-message-queued`) · doyun 의 필터 전달 · 부재중 응답(`queue.autogenerated-queued`)이 새 서버에 없는 사내 주소로 `legacy` 에 한 번씩.
부재중은 개발의 `.test` 도메인 탓에 스팸 점수가 높아(7.5) 시험 동안만 기존 서버 IP 를 스팸 필터에서 빼고 되돌렸다. RCPT 가드 갈래만 잠시 뺀 엔진에서 기존 서버 IP 가 보낸 인증 없는 메일은
`legacy` 로 가지 않고 로컬 실패(`delivery.dsn-perm-fail`) → 그 반송은 동료(새 서버에 없는 사내 주소) 앞이라 `legacy` 로 한 통.
- **닫힌 쪽(M1)** — 위처럼 가드 갈래가 빠진 채 relay 가 열린 엔진 · 시스템 Sieve 스크립트를 끈 엔진(가드 식은 있지만 RCPT 가 250)에서 시범 값으로 돌리자,
setup 이 먼저 `allowRelaying` 을 끄고(`[변경] … RCPT 가드 없이 allowRelaying 이 켜져 있어 …` · `… RCPT 가드가 답하지 않아(… 250 2.1.5 OK) …`) 가드 · 스크립트를 되살린 뒤 C 로 다시 켰다 — 켠 뒤 확인 1ms 안팎, 종료 코드 0.
- **이미 켜진 시범(L2)** — 기존 서버 역을 멈춘(`docker pause`) 채 같은 값으로 돌리면 `[주의] … 닿지 않습니다 … 이미 켜진 시범이라 이어 간다` · 종료 코드 0.
다른 기존 서버(닿지 않는 포트)로 바꾸는 실행은 멈췄고 엔진 스냅숏이 그대로였다.
- **H1** — 시범이 켜진 엔진에서 `legacy` 경로만 지우고 값 없이 돌리면 `[유지] … allowRelaying 이 켜져 있어 끝내지 않는다` · 가드 그대로(RCPT 550) · 종료 코드 1.
`allowRelaying` 을 손으로 끈 뒤 다시 돌리자 나머지를 뺐고, 끝난 엔진 스냅숏이 시험 전과 같았다.
- 켬 → 같은 값으로 다시(전부 `[유지]`) → 값 없이(끄기 — `[삭제] … legacy` 가 리로드 뒤) → 다시 값 없이(전부 `[유지]`) 뒤 엔진 스냅숏이 시험 전과 같았다.
개발 확인 (2026-10-30 2차 리뷰 수정 뒤):
- **켜기 전 확인(N2)** — 처음 켤 때 `[생성] 자동 차단 예외`(표시)가 A 의 맨 앞에 나오고, A 의 리로드 뒤 relay 가 꺼진 채 `[확인] … 켜기 전 확인(allowRelaying 꺼짐): … 곧바로 거절 (… 0.4ms)` 다음에 C.
시범이 켜진 엔진에서 relay 만 잠시 끄고 잰 RCPT 5번 0.3~0.7ms(켠 채로는 0.4~0.7ms) — relay 와 상관없이 가드의 빠른 550 이다.
- **다시 읽기(N1)** — `x:Domain/set`(allowRelaying false · true) 바로 뒤 `x:Domain/get` 100번 모두 저장한 값과 같았다.
- **waitOnFail 을 바꾼 엔진(N3)** — `waitOnFail` 1s + 가드가 먹는 엔진: `[주의] RCPT 가드가 550 으로 답합니다 …` 뒤 relay 를 그대로 두고 `waitOnFail` 문제로 멈춤(종료 1, "켜진 채로 두었습니다").
거기서 시스템 Sieve 를 끄면 확인이 250 → `[변경] … 껐다` · "allowRelaying 만 닫고 중단합니다"(종료 1). `waitOnFail` 을 되돌리고 다시 돌리자 Sieve 를 되살리고 다시 켰다(종료 0).
- **운영자 갈래(N4)** — 켜기 전 엔진의 `spfFromVerify` 에 기존 서버 IP 갈래를 손으로 넣자 사전 확인에서 멈췄고(종료 1) 엔진이 그대로였다.
켜진 엔진의 `dmarcVerify` 에 setup 과 같은 글의 갈래를 하나 더 넣자 "둘 이상"으로 멈췄고(종료 1) 바뀐 것이 없었다.
- 켬 → 같은 값으로 다시(바뀐 줄 없음) → 값 없이(끄기) → 다시 값 없이 뒤, 그리고 위 시험들을 되돌린 뒤 엔진 스냅숏이 시험 전과 같았다.
### mailcow 쪽 절차 (mailcow 호스트 `/opt/mailcow-dockerized` — 아직 해 보지 않았다)
mailcow 2026-10(master) 의 설정 파일 · 화면 · 문서로 정리했다. 운영 중인 mailcow 가 더 오래됐으면 화면 이름이 다를 수 있다 — 0 의 명령으로 먼저 대조한다.
```bash
cd /opt/mailcow-dockerized
alias dcx='docker compose exec postfix-mailcow'
```
0. **읽기만 하는 점검** — 로컬로 새는 길이 없는지:
```bash
grep -n -A3 '^transport_maps' data/conf/postfix/main.cf # custom_transport.pcre, local_transport, mysql_relay_ne.cf, mysql_transport_maps.cf 차례
dcx postconf -P 'smtpd/pass/smtpd_sender_restrictions' # permit_mynetworks,reject_unlisted_sender,reject_unknown_sender_domain
dcx cat /opt/postfix/conf/custom_transport.pcre # 비어 있거나 owner@ · 도메인과 무관해야
dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_virtual_mailbox_maps.cf # 사서함이 받는 사람으로 살아 있다
docker compose exec acme-mailcow dig +short A mx1.arasystem.it.kr @172.22.1.254 # <MX1_IP>
docker compose exec acme-mailcow dig +short AAAA mx1.arasystem.it.kr @172.22.1.254 # 비어 있어야
```
도메인이 백업 MX · "Relay all recipients" 가 아닌지, owner 앞 Sync job 이 없는지(E-Mail → Configuration → Synchronizations)도 본다.
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 도 끊긴다).
3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다).
4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다:
```bash
docker compose logs --since 15m postfix-mailcow | grep -E 'from=<owner@arasystem\.it\.kr>|\[<MX1_IP>\]'
# 좋음: client=mx1.arasystem.it.kr[<MX1_IP>] … 다음 줄에 relay=dovecot[…]:24 status=sent
# 나쁨: NOQUEUE: reject … Sender address rejected (mailcow 에 없는 봉투 발신자) · milter-reject … 5.7.1 (rspamd 거절)
docker compose logs --since 15m rspamd-mailcow | grep -E 'SPOOFED_UNAUTH|R_SPF_FAIL|R_DKIM_REJECT|DMARC_POLICY_SOFTFAIL' # 없어야
```
동료가 받은 메일 머리글이 `Authentication-Results … spf=pass dkim=pass (header.s=v1-…) dmarc=pass` 이고 `X-Spamd-Result` 에 `SPOOFED_UNAUTH` 가 없어야 한다.
SPF · DKIM · DMARC 가운데 하나라도 실패하면 mailcow 가 위조로 보고(+50점) **거절**한다 — DMARC 가 `p=none` 이어도 막아 주지 않는다.
DKIM 은 키가 있는데 서명이 맞지 않을 때(`R_DKIM_REJECT`)다 — selector 의 키를 찾지 못하는 것(`R_DKIM_PERMFAIL`)은 0점이라 SPF 정렬로 DMARC 가 통과하면 받는다.
없는 주소(`colleaguee@`)로 보내면 mailcow 가 550 → 새 서버가 owner 에게 반송한다(루프 없음).
5. **imapsync 1차** — mailcow → 새 서버, owner 만 ([imapsync](#imapsync)).
6. **Transport Map 추가** — System → Configuration → Routing → Transport Maps → "Add transport":
| 칸 | 값 |
| --- | --- |
| Destination | `owner@arasystem.it.kr` (`owner+태그@` 도 맞는다) |
| Next hop | `[mx1.arasystem.it.kr]:25` — 대괄호는 MX 조회를 끈다. **앞에 `smtp:` 를 쓰지 않는다**(mailcow 가 붙인다) |
| Username / Password | 비움 |
| "Destination is a regular expression to match against MX name" | **끔** |
| Active | 켬 |
목록의 **Test** 버튼(보내는 주소 `colleague@arasystem.it.kr`, 받는 주소 `owner@arasystem.it.kr` — PHP 가 next hop 에 직접 시험 메일을 보낸다)으로 새 서버가 mailcow IP 의 사내 도메인 메일을 받는지 본다. 그다음:
```bash
dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # smtp_via_transport_maps:[mx1.arasystem.it.kr]:25
dcx postmap -q colleague@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # (출력 없음)
dcx postfix reload # 바로 반영 (안 해도 30초~1분)
# 실제 발송 — 바깥(Gmail) · SOGo 동료 · owner 가 든 별칭 · owner+test@ 에서 각 한 통
docker compose logs --since 15m postfix-mailcow | grep -E 'to=<owner(\+[^@]*)?@arasystem\.it\.kr>' # relay=mx1.arasystem.it.kr[<MX1_IP>]:25 … status=sent
```
7. **마지막 imapsync**(차이분).
8. **owner 의 메일 프로그램을 mx1 로** — IMAP `mx1.arasystem.it.kr:993` · SMTP `:465`, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다.
9. **mailcow 의 옛 사서함 접근 줄이기** — 사서함 편집의 Allowed protocols(IMAP · POP3 · SMTP · Sieve · ActiveSync · CalDAV/CardDAV)와 SOGo 접근을 줄이고 앱 비밀번호를 지운 뒤,
마지막 동기화가 끝나면 Active 를 **"Disallow login (incoming mail is still accepted)"** 로. **지우거나 "Inactive" 로 두지 않는다** — 그러면 owner 앞 메일이 모두 550 이고,
새 서버에서 owner 가 동료에게 보내는 메일도 `reject_unlisted_sender` 로 거절된다.
10. 며칠 본다 — mailcow Queue Manager(E-Mail → Queue Manager), rspamd 기록, 반송, DMARC 보고서.
### 지켜볼 것
새 서버에서 owner 가 받은 메일의 `X-Spam-Score` · `X-Spam-Result` 를 **세 통** 본다 — 바깥(Gmail) 한 통, 동료(SOGo) 한 통, DMARC 가 엄격한(`p=reject`) 바깥 도메인 한 통.
mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Results: … none`) `DMARC_NA` · `AUTH_NA` 가 각 1점 붙는다. 스팸 문턱은 5점이다.
- 개발에서는 점수가 늘 높다 — 기존 서버 IP 로 넘긴 동료 흉내 메일이 7.5점(`.test` 도메인 탓 `FROM_INVALID` · `ENV_FROM_INVALID` 각 2점, 시험 글의 제목 인코딩 1점). 운영 값으로 판단한다.
조사 때 p=reject 바깥 발신(서명 없음)을 넘기자 이 설정으로 3.5점(받은편지함), 검사 예외가 없을 때는 11.0점(스팸함)이었다.
- mailcow 가 8~15점에 붙이는 `X-Spam-Flag: YES` 는 새 서버 규칙(`SPAM_FLAG` +5)이 받아 스팸함으로 간다. owner 의 mailcow Sieve · 부재중 · 전달 규칙은 더 돌지 않는다 — 새 서버에 다시 만든다.
- 동료가 받은 owner 의 메일 — `X-Spamd-Result` 에 `SPOOFED_UNAUTH` · `R_DKIM_REJECT` 가 없어야 한다(위 4).
### 되돌리기 (시범을 멈추고 mailcow 로)
1. mailcow — Transport Maps 의 owner@ 행 → Quick actions → **Deactivate**(행을 남긴다) → `dcx postfix reload`. 30초~1분 안에 다시 mailcow 사서함으로 배달된다.
`dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf` 가 비어야 한다.
2. 큐에 남은 owner 앞 메일 — `dcx postqueue -p` 로 보고 `dcx postqueue -f` (다시 경로를 받아 로컬로).
3. **시범 동안 새 서버로 들어온 메일 · 보낸 메일을 mailcow 로 되복사** — 반대 방향 imapsync(새 서버 → mailcow) 또는 mailcow 의 Synchronizations 에 mx1 IMAP 을 원본으로.
4. owner 의 mailcow 접근 되살리기 — Active 를 Active 로, Allowed protocols · SOGo · 앱 비밀번호, 메일 프로그램을 mailcow 로.
5. **새 서버 대기열에 동료 앞 메일이 남지 않았는지** 본다 — 관리 화면의 메일 대기열(Queue) 또는 관리 API `x:QueuedMessage/query` · `/get`.
시범을 끄면 발송 전략이 "사내 도메인 → 로컬"로 돌아가, 아직 mailcow 로 못 간 동료 앞 메일(mailcow 일시 장애 · 지연 — 다시 시도는 3일, 동료 앞 반송은 10번까지)은
다음 시도에서 로컬 배달 실패 → owner 에게 반송된다. 남아 있으면 mailcow 가 받을 수 있게 된 뒤 다 나갈 때까지 기다리거나(대기열에서 바로 다시 시도),
기다릴 수 없으면 그 목록(받는 사람 · 제목)을 적어 두고 끈 뒤 owner 가 mailcow 에서 다시 보낸다.
6. 새 서버 — **Transport Map 을 끈 뒤에** 시범을 끝낸다: 두 값을 비우고 `--end-pilot`(아래). 거꾸로 하면 그사이 owner 가 동료에게 보낸 메일이 `not-found` 로 거절된다.
7. 넣었던 것 정리 — postscreen 예외 줄, Fail2ban 예외. DNS 의 `ip4:<MX1_IP>` · 새 DKIM TXT 는 남겨도 해가 없다.
### 시범 끝내기 — 전면 전환
[전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)대로 모두의 계정을 새 서버에 만들고(단계 2) MX 를 mx1 로 바꾼다(단계 6). 그 뒤:
1. MX 전파(TTL)가 끝나 mailcow 에 새 수신이 없어지면 mailcow 의 Transport Map 행을 지운다(Remove) — 그때까지 mailcow 로 온 owner 앞 메일은 그대로 새 서버로 넘어온다.
2. 새 서버 — `.env.production` 의 두 값을 비우고 `--end-pilot` 으로 실행한다:
```bash
node --env-file=.env.production mail/scripts/setup.mjs --end-pilot
```
`allowRelaying`(꺼진 것을 다시 읽어 확인) → 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 차례로 지우고 리로드한 뒤 `legacy` 경로를 지운다. 다음 실행부터는 `--end-pilot` 없이 `[유지] 시범 공존 — 꺼짐` 이다.
- 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 계정 만들기 → imapsync → MX 바꾸기를 짧게 이어서 한다.
- 위조 막기(인증 없는 사내 도메인 MAIL FROM 은 mailcow IP 에서만)도 함께 빠진다. 전환 뒤에도 남길지는 정할 일이다(원래 봉투를 그대로 넘기는 바깥 전달 서비스가 막힌다).
### 주의
- **시범 기간에는 동료 계정을 새 서버에 만들지 않는다** — 새 서버에 있는 주소는 늘 로컬 배달이라, 만든 순간부터 owner 가 그 동료에게 보낸 메일이 새 서버에 쌓이고 mailcow 로 가지 않는다(동료는 mailcow 에서 읽는데).
`postmaster@` · `dmarc@` · 공용 주소 · 별칭도 같다. 이주 기간에 미리 만들어야 하면 발송 전략을 "옮긴 사람 목록" 꼴로 바꿔야 한다 — setup 은 지원하지 않는다.
- **DKIM** — 동료 앞 메일은 새 selector 두 개(`v1-ed25519-…` · `v1-rsa-…`)로 서명된다. 키가 다르면 mailcow 가 위조로 거절하고, TXT 가 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다(위 mailcow 절차 4). setup 이 활성 selector 를 알리고, 운영에서는 **켜기 전에** DNS 와 견준다(처음 켤 때 키가 다르거나 TXT 가 확실히 없으면 `allowRelaying` 을 켜지 않고 멈춘다 — **TXT 는 첫 시범 실행 전에 넣고 퍼진 것을 본다**. 리졸버 오류는 `[주의]`).
DNS 를 손으로 관리하는 동안 엔진은 키를 스스로 바꾸지 않는다 — 다만 키를 지우거나 알고리즘을 더하면 새 키를 **DNS 에 넣기 전에** 쓴다(전환 절차 "주의할 레코드" 의 DKIM 자동 교체).
- **SOGo 일정 초대** — mailcow 사용자가 SOGo 에서 owner 를 초대하면 메일을 거치지 않고 owner 의 **옛 SOGo 달력**에 바로 들어간다(알림 메일만 새 서버로 온다 — SOGo 버그 트래커의 관리자 답, 확인하지 않았다).
- **autodiscover** — 그대로 mailcow 를 가리킨다. owner 의 프로그램이 자동 설정을 쓰면 mailcow(새 메일이 없는 옛 함)에 잡힌다 — 손으로 설정한다.
- **크기** — mailcow 와 새 서버 모두 메일 한 통 100MB(104,857,600바이트 — 새 서버는 엔진 기본값)다. 웹메일 첨부는 따로 `MAIL_ATTACHMENT_*` 한도다.
- **IPv6 · SPF** — mx1 에 AAAA 를 두지 않는다. mailcow 가 IPv6 로 붙으면 IPv4 예외가 모두 빗나가 동료 메일이 `550 5.7.1` 이다(mailcow 는 `inet_protocols = all`).
새 서버 → mailcow 는 IPv4 로만 간다(컨테이너에 IPv6 가 없고 Relay 경로는 IPv4 먼저) — SPF 는 `ip4:` 만 더한다.
- **EHLO 이름** — 새 서버가 mailcow 에 붙을 때의 이름은 엔진의 서버 이름(`defaultHostname` = bootstrap 때의 `MAIL_HOSTNAME` = mx1)이다.
`mail.arasystem.it.kr` 이면 mailcow 가 "loops back to myself" 로 멈춘다 — setup 은 기존 서버 주소가 서버 이름과 같으면 멈춘다.
- **사내 오타 주소** — 새 서버에 없는 사내 주소는 모두 "받음"이라, 웹메일의 즉시 `not-found`(받는 사람별 결과) 대신 mailcow 의 550 → **몇 초 뒤 반송(DSN)이 받은편지함에** 온다.
제출 기록에도 남지 않는다(대기열에서 빠지면 `250 2.1.5 Queued` 로 돌아간다).
- **사내 목록** — 받는 사람 추천 · 주소록의 "사내"는 새 서버의 계정 목록이라 **시범 사용자 혼자**만 보인다. 동료는 개인 주소록 · 최근 보낸 사람으로만 나온다(시범 동안 백엔드는 바꾸지 않는다).
- **주소 추측 막기가 약하다** — RCPT 가드의 거절(Sieve)은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번이면 끊기 · 자동 차단 셈)을 거치지 않는다(개발: RCPT 한 번에 가드 0.2~1.1ms, 엔진 자체 거절 4,999~5,003ms — setup 은 이 차이로 가드가 걸렸는지 본다).
시범 중 MX 는 mailcow 라 mx1:25 에 바로 붙는 것은 스캐너뿐이다. 시스템 계정 거절도 같은 성질이다.
- **mailcow 가 멈추면** — 동료 앞 메일은 새 서버 대기열(`local` 일정 — 3일)에서 다시 시도하다 반송되고, owner 앞 메일은 mailcow 큐(4시간 뒤 지연 안내, 5일 뒤 반송)에서 기다린다.
- **DMARC 보고** — 엔진은 자기 도메인(arasystem.it.kr) 메일로는 DMARC · DKIM 실패 보고를 만들지 않는다(소스 [`smtp/src/reporting/dmarc.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/smtp/src/reporting/dmarc.rs) 의 `is_local_report_domain`) —
`rua=dmarc@` 로 보고를 보내 mailcow 의 `reject_unlisted_sender` 에 걸리는 일은 없다. 다른 도메인에 대한 집계 보고(기본 하루 한 번)는 그 도메인의 `rua` 로 새 서버가 직접 보낸다(봉투 `noreply-dmarc@arasystem.it.kr`).
mailcow IP 의 DMARC 검사를 끄므로 mailcow 를 거친 메일로는 보고 거리가 생기지 않는다.
- mailcow 의 공용 주소 · 별칭(`all@` 등)으로 보내면 mailcow 가 펼치고 owner 몫은 새 서버로 되돌아온다(자기 사본 — 확인하지 않았다).
---
## arasystem.it.kr 전환 절차 (mailcow → Stalwart)
**전면 전환 전에 한 사람이 먼저 새 서버를 쓴다** — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)(MX 는 mailcow 그대로, 아래 단계 1 · 3 · 4 의 DNS 만 더한다).
시범을 거친 뒤 아래 표대로 모두를 옮기고, MX 를 바꾼 다음 시범 설정을 끝낸다(`--end-pilot`).
현재 (DNS: 가비아):
| 레코드 | 값 |
@@ -466,7 +763,7 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <
| --- | --- | --- |
| 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL |
| 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` |
| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처) | 관리 화면 계정 목록 |
| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처). **시범 공존 중이면 전면 전환 때 한다** — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "주의") | 관리 화면 계정 목록 |
| 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` |
| 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` |
| 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 |
@@ -482,9 +779,15 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <
리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다.
- **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다.
- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다.
- **DKIM 자동 교체** — 도메인 설정이 `dkimManagement: Automatic`(90일마다 새 selector, 7일 뒤 이전 키 은퇴)이고 DNS 는 수동(가비아)이다.
교체된 키의 TXT 가 DNS 에 없으면 서명 검증이 실패하므로, **운영 전에 관리 화면에서 교체 방식을 확인**하고
교체 때마다 새 selector 를 등록하거나 자동 교체를 끈다. (교체 시점의 동작은 확인하지 않았다)
- **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만,
**0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는
활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다
(소스 [`services/src/task_manager/dkim.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/services/src/task_manager/dkim.rs) — 일정은 DNS 게시가 있을 때만 잡는다,
엔진 시험 `tests/src/automation/dkim.rs` 의 "manual DNS" — 교체도 다시 시도도 하지 않는다. 개발 엔진의 두 키도 일정이 없다 — 2026-10-29).
- 주의 — **알고리즘에 활성 키가 없으면** 엔진이 새 키를 만들어 **DNS 에 게시하기 전에 곧바로 서명에 쓴다**(DNS 수동일 때). 키를 지우거나, 알고리즘(`algorithms`)을 더하거나,
`dkimManagement` 를 다른 값에서 `Automatic` 으로 바꿀 때가 그렇다 — 그런 일을 하면 새 selector 의 TXT 를 바로 넣는다.
- 자동 DNS(`Automatic`)라면 새 키를 먼저 게시하고 전파를 확인한 뒤에 쓴다(실패하면 `pending` 으로 두고 옛 키로 계속 서명).
- 키를 손으로 바꾸는 방법은 아직 확인하지 않았다 — 엔진은 활성(`active`) 키를 모두 서명에 쓴다. 교체가 필요해지면 개발 엔진에서 먼저 시험한다.
### imapsync
@@ -658,6 +961,24 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
| 사내 목록(`GET /api/directory-entries`)이 `503 MAIL_001` | setup 출력의 요약 `사내 목록` 줄을 본다 — 준비되지 않았으면 위 줄들. 준비됐는데도 그러면 backend 의 `MAIL_DIRECTORY_LOCAL_PART` 가 setup 과 같은지 보고 `up -d backend` |
| `[주의] RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다` | 메일을 받지 않는 시스템 계정은 지금처럼 받아 놓고 반송한다. 그 식에 시스템 계정일 때 `arasystem-system-recipients` 를 고르는 갈래를 합치거나, 그 설정이 필요 없으면 기본값(`{"match":{},"else":"false"}`)으로 되돌리고 setup 재실행 ([보안](#보안)) |
| `시스템 계정의 RCPT 거절 확인 실패 — …` | setup 이 `ReloadSettings` 까지 했는지, `x:MtaStageRcpt` 의 `script` 식과 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive: true`)를 관리 API 로 본다. 확인은 메일 컨테이너 안의 `curl` 로 RCPT 까지만 보낸다 |
| `시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` · `… allowRelaying 만 닫고 중단합니다` | 아무것도 바뀌지 않았다 — 뒤의 머리글이면 가드 없이 열려 있던 relay 만 닫았다(위에 `[변경] … allowRelaying 을 껐다`, 엔진에 없는 사내 주소는 다시 550). 아래 줄의 항목을 고친다 — 기존 서버에 닿지 않으면 이 호스트의 바깥 25 · 기존 서버의 STARTTLS · 인증서 이름, 자기 자신으로 풀리면 `MAIL_PILOT_LEGACY_HOST`, 기존 서버 IP 의 사람이 만든 자동 차단 예외 · 기존 서버 IP 로 된 운영자 갈래(아래 줄)는 지운 뒤 다시, `waitOnFail` 은 기본값(5s)으로, DKIM 은 아래 줄 ([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "setup 이 하는 일"). 이미 켜진 시범이면 "시범은 켜진 채로 두었습니다" — 고친 뒤 같은 명령으로 다시 실행한다 |
| `MAIL_PILOT_LEGACY_HOST 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다` | 운영의 env 파일에서 시범 값이 빠졌다. 시범을 이어 가려면 두 값을 다시 넣고, 끝내려면(전면 전환 — MX 를 옮긴 뒤) 값을 비운 채 `--end-pilot` 으로 실행한다. 아무것도 바뀌지 않았다 |
| `운영의 시범 공존 값(…)은 셸이 아니라 .env.production 에 둡니다` | 셸에 `MAIL_PILOT_LEGACY_*` 가 남아 있다 — 파일에 적고 `unset` 뒤 다시 |
| `도메인 … 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어 …` | 시범을 켠 적이 없는 엔진에서 누가 relay 만 켜 두었다 — 가드 없이 열린 relay 라 엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다. setup 은 남의 설정이라 끄지 않는다(요약 `시범 설정 없음 · allowRelaying 켜짐`, 종료 1). 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용을 끈다 |
| `도메인 … 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(legacy)가 없습니다` | 누가 켰는지 알 수 없어 끄지 않았다 — 관리 화면에서 `legacy` 경로만 지웠을 수도 있다. RCPT 가드 · 위조 막기 · 예외 · 발송 전략은 **남겼다**(가드 없는 relay 를 만들지 않는다 — 요약은 `시범 공존 끝내지 못함`). 끝낼 것이면 관리 API `x:Domain/set` 으로 `allowRelaying: false` 뒤 다시 실행하면 나머지를 뺀다. 이어 갈 것이면 시범 값을 넣고(운영은 env 파일) 실행하면 경로를 다시 만든다 |
| `RCPT 가드가 답하지 않아(…) 시범 공존의 allowRelaying 을 켜지 않고 중단합니다` | 켜기 전 확인(A 의 리로드 뒤 · relay 꺼진 채)에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — relay 는 열지 않았다(엔진에 없는 사내 주소는 그대로 550). 가드 · 표시 · 예외는 저장돼 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 |
| `… allowRelaying 을 끄려 했지만 끄지 못했습니다 (…)` 뒤에 `— 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(…)` | 가드가 답하지 않아 relay 를 닫으려 했는데 저장이 거절되었거나 · 엔진에 닿지 않았거나 · 다시 읽어도 켜져 있다. setup 이 멈추기 전에 RCPT 가드만 다시 걸었고 지금은 가드가 550 으로 거절한다 — relay 는 켜진 채다. 관리 화면에서 그 도메인의 relay 허용(관리 API `x:Domain/set` 의 `allowRelaying: false`)을 끄고, 꺼진 것을 본 뒤 같은 명령으로 다시 실행한다 |
| `… 끄지 못했습니다 (…)` 뒤에 `RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다` · `… 열려 있을 수 있습니다` | relay 를 닫지 못했고 가드도 다시 걸지 못했다(저장 · 리로드 실패, 다시 걸었는데 확인이 250 이거나 확인하지 못함, 또는 RCPT 단계 식을 다른 설정이 써서 건드리지 않음) — **지금 열려 있다(있을 수 있다).** 관리 화면에서 relay 허용을 바로 끄고, 꺼진 것을 본 뒤 다시 실행한다. 켜기 전 · 켠 뒤 확인에서 끄지 못한 경우(가드는 이미 걸었다)도 "열려 있을 수 있습니다"로 같다. setup 은 그 밖의 설정을 더 바꾸지 않고 멈췄다 |
| `RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다` | 켠 뒤 확인에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — `allowRelaying` 을 다시 껐다(엔진에 없는 사내 주소는 다시 550, owner 가 동료에게 보내는 메일도 `not-found`). 가드 · 경로는 남아 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 |
| `[변경] 시범 공존 — RCPT 가드 없이 allowRelaying 이 켜져 있어 → … allowRelaying 을 껐다` · `… RCPT 가드가 답하지 않아(…) → …` | 실행을 시작할 때 relay 가 가드 없이 열려 있었다(누가 RCPT 식을 바꿨거나 시스템 Sieve 스크립트를 껐다) — 먼저 닫았다. 같은 실행이 확인을 마치면 가드를 되살리고 다시 켠다 — 끝이 `[완료]` 면 된 것이다 |
| `시범 공존 확인 실패 — …` (위조 막기 · 인증한 세션) | 설정은 저장됐다. setup 이 리로드까지 했는지, `x:MtaStageMail` 의 `isSenderAllowed` · 도메인의 `allowRelaying` 을 관리 API 로 본다. 고치기 어려우면 값을 비우고(운영은 `--end-pilot`) 끈다 |
| `다른 도메인 … 이 allowRelaying 을 켜 두어 시범 설정(…)을 빼지 않았습니다` | 시범을 끄는 실행인데 시범 도메인 밖의 도메인이 relay 를 켜 두었다 — RCPT 가드가 모든 사내 도메인을 덮으므로 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다. setup 은 그 도메인을 끄지 않는다 — 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행한다 |
| `도메인 … 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다` | 시범 도메인의 relay 를 끄는 저장을 엔진이 받았는데 반영되지 않았다 — 시범 설정을 그대로 두었다. 관리 화면에서 relay 허용을 끈 뒤 다시 실행한다 |
| `운영의 MAIL_PILOT_LEGACY_HOST 포트는 25 만 받습니다` | 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다 — mailcow 의 25 를 쓴다(`[mail.arasystem.it.kr]:25` 또는 포트 없이) |
| `[주의] 시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시 … 에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다` | 위조 막기 · 검사 예외 · IP 한도 식에 시범 꼴의 갈래가 있는데 시범 표시(자동 차단 예외)에 없는 IP 다 — 운영자가 넣은 것이면 그대로 둔다(기존 서버 IP 와 같은 IP 면 아래 줄처럼 멈춘다). setup 이 넣은 것인데 표시 항목을 지웠다면 관리 화면에서 그 갈래를 지운다 |
| `… 에 기존 서버 IP(…)로 된 갈래가 setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시 … 가 지워진 것입니다` | 켜기 전에 멈췄다(아무것도 바뀌지 않았다). 운영자가 넣은 것이면 — 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 끝낼 때 지워지므로 — 그 갈래를 지우고(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다(시범 동안은 setup 의 갈래가 같은 일을 한다). 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 그 IP 로 같은 설명(`[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP`)의 자동 차단 예외를 다시 만든 뒤 실행해도 된다 |
| `… 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다` | setup 은 한 칸에 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것이다. 하나만 남기고 지운 뒤 다시 실행한다. 끄는 실행에서는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의] … 맨 앞 하나만 빼고`) |
| `DKIM TXT … 의 공개 키가 엔진의 키와 다릅니다` · `DKIM TXT … 가 DNS 에 없습니다` | 키가 다르면 동료(mailcow) 앞 메일이 위조로 거절되고, 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. `setup.mjs --print-dns` 가 보여 주는 selector TXT 로 DNS 를 고치거나 넣고, 바깥에서 `dig TXT <selector>._domainkey.<도메인>` 으로 보인 뒤 다시 실행한다. 처음 켜는 실행이면 relay 를 켜지 않고 멈췄고, 이미 켜진 시범이면 켠 채로 실패만 알렸다 |
| `[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 …` | 관리자 역할의 이름을 바꿨거나 `emailReceive` 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다 |
| `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) |
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` |
+1684 -63
View File
File diff suppressed because it is too large Load Diff