From 4c1737f2223dd82845138b2bbc927ac6395deeff Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 30 Oct 2026 09:37:24 +0900 Subject: [PATCH] =?UTF-8?q?docs:=2010=EB=8B=A8=EA=B3=84(=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=9F=BF=20=EA=B3=B5=EC=A1=B4)=20=E2=80=94=20=EC=8B=9C?= =?UTF-8?q?=EB=B2=94=20=EB=8F=99=EC=95=88=EC=9D=98=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=EB=B3=84=20=EA=B2=B0=EA=B3=BC=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EB=82=B4=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시범 동안 새 서버에 없는 사내 주소는 엔진이 mailcow 로 중계하므로 웹메일이 곧바로 not-found 로 알리지 못하고 나중의 반송 메일로 알게 된다. 사내 목록도 새 서버의 계정(시범 사용자)뿐이다. 클라이언트 · 서버 코드는 바꾸지 않았다 — 계약과 받는 사람별 결과 주석에 그 차이를 적었다. Co-Authored-By: Claude Opus 5.5 --- backend/src/modules/mail/jmap/delivery-status.ts | 2 ++ docs/mail/api-contract.md | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/mail/jmap/delivery-status.ts b/backend/src/modules/mail/jmap/delivery-status.ts index 3332789..9b4e64b 100644 --- a/backend/src/modules/mail/jmap/delivery-status.ts +++ b/backend/src/modules/mail/jmap/delivery-status.ts @@ -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 일 뿐이다. diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index d3abaa5..b51007a 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -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` 는 그 직원 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다.