From fa6799d9f72ef3e9d272156f8dc73e2c17967f04 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 22:07:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?docs:=209=EB=8B=A8=EA=B3=84(=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=B0=9C=EC=86=A1=20=C2=B7=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=C2=B7=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B4=EB=82=B8=20=EC=82=AC=EB=9E=8C=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8)=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보내기 취소 · 예약 발송(엔진이 제출을 붙잡는다 — FUTURERELEASE), 외부 이미지 중계, 인쇄, 보낸 사람 차단, 보내기 · 읽기 설정의 계약(2절)을 정하고, 확정한 캔버스 보드 넷(MailReadTools · MailPrint · MailSendLater · SettingsSendRead)을 둔다. 디자인 README 에 구현하며 정한 보완을 적었다. 리뷰(화면 한 번 · 서버 두 번)로 정한 것도 함께 적었다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(MAIL_015 — 모든 옮기기 · 지우기 길, 확인하지 못하면 닫힌 쪽), 발송은 늘 envelope 을 직접 준다(엔진이 받지 않는 주소를 not-found 로 빼고 다시 제출 — 전에는 말없이 빠졌다), 다시 취소해도 200, 이미지 중계의 동시 수(서버 16 · 직원 6 — 받는 수가 적은 직원부터), 차단 주소는 dot-atom 만 · 다시 POST 하면 꺼진 규칙을 켠다. 엔진 사실(2026-10-29): envelope 의 거절은 제출 전체 · 첫 주소 하나, 내보내는 사본에서 Bcc 를 뗀다(개발의 Mailpit 이 보이는 Bcc 줄은 Mailpit 이 붙인다), 제출 조회의 정렬 이름은 sentAt. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/MailPrint.dc.html | 96 ++++++++ docs/design/webmail/MailReadTools.dc.html | 172 ++++++++++++++ docs/design/webmail/MailSendLater.dc.html | 203 +++++++++++++++++ docs/design/webmail/README.md | 109 +++++++++ docs/design/webmail/SettingsSendRead.dc.html | 140 ++++++++++++ docs/design/webmail/canvas.json | 11 +- docs/mail/api-contract.md | 227 +++++++++++++++++-- 7 files changed, 940 insertions(+), 18 deletions(-) create mode 100644 docs/design/webmail/MailPrint.dc.html create mode 100644 docs/design/webmail/MailReadTools.dc.html create mode 100644 docs/design/webmail/MailSendLater.dc.html create mode 100644 docs/design/webmail/SettingsSendRead.dc.html diff --git a/docs/design/webmail/MailPrint.dc.html b/docs/design/webmail/MailPrint.dc.html new file mode 100644 index 0000000..f21f73e --- /dev/null +++ b/docs/design/webmail/MailPrint.dc.html @@ -0,0 +1,96 @@ + + + + +인쇄 화면 + + + + + + + +
+ +
+

인쇄 화면

+

⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 새 탭에 메일 한 통만 담은 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄웁니다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않습니다. 긴 메일은 여러 쪽으로 이어집니다.

+
+ +
+ +
+
+ 인쇄 화면 — 화면에만 보이는 줄 + + +
+ +
+
+
+ ARA SYSTEM 메일 + 김아라 <ara.kim@arasystem.it.kr> +
+

Re: 메일 서버 이전 일정 공유

+
+
보낸 사람
이도윤 <doyun.lee@arasystem.it.kr>
+
받는 사람
김아라 <ara.kim@arasystem.it.kr>
+
참조
인프라팀 <infra@arasystem.it.kr>
+
날짜
2026년 10월 26일 (월) 오전 10:42
+
첨부
메일서버_전환_체크리스트.xlsx (48 KB)
+
+
+
+

김아라님, 안녕하세요.

+

말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다. 전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.

+

전환 체크리스트를 첨부했으니 확인 부탁드립니다.

+

이도윤 드림

+
+
+
+
+ +
+
+

인쇄되는 것

+
    +
  • 머리 줄 — 「ARA SYSTEM 메일」과 인쇄한 직원의 이름 · 주소
  • +
  • 제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것과 같습니다 — 숨은참조는 넣지 않습니다)
  • +
  • 첨부 — 파일 이름과 크기만(내용은 인쇄하지 않습니다)
  • +
  • 본문 — 읽기 화면과 같은 정화를 거친 그대로. 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따릅니다
  • +
+
+
+

이미지 · 긴 메일

+
    +
  • 외부 이미지는 읽기 화면에서 보이고 있던 대로 인쇄합니다(막혀 있었으면 인쇄에도 없습니다)
  • +
  • 본문은 인쇄할 폭(A4)에 맞춰 다시 잰 뒤 인쇄합니다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않습니다
  • +
  • 인쇄 창을 닫아도 인쇄 화면 탭은 남습니다 — [인쇄]로 다시, [닫기]로 탭을 닫습니다
  • +
+
+
+

못 열 때

+

메일을 찾을 수 없으면 인쇄 화면에 "메일을 찾을 수 없습니다"와 [닫기], 받지 못하면 [다시 시도]가 보이고 인쇄 창은 띄우지 않습니다. 세션이 끝났으면 로그인 화면으로 갑니다.

+
+
+
+
+
+ + + diff --git a/docs/design/webmail/MailReadTools.dc.html b/docs/design/webmail/MailReadTools.dc.html new file mode 100644 index 0000000..1bbba02 --- /dev/null +++ b/docs/design/webmail/MailReadTools.dc.html @@ -0,0 +1,172 @@ + + + + +읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단 + + + + + + + +
+ +
+

읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단

+

외부 이미지는 지금처럼 막아 두고, 원하면 그 메일만 보이게 합니다. 이미지는 사내 서버가 대신 받아 보여 주므로 보낸 사람에게 이 PC 의 주소가 드러나지 않습니다. 인쇄 · 차단은 ⋯ 메뉴에서 합니다.

+
+ +
+ +
+
+ + + + + + + +
+ +
+

[클라우드 브리프] 10월 넷째 주 — 서버 비용 줄이는 다섯 가지

+
+ +
+
+ 클라우드 브리프 + news@cloudbrief.example + 2026. 10. 29. (목) 오전 8:00 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+ +
+ + 외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다. + +
+ +
+

안녕하세요, 이번 주 클라우드 브리프입니다. 서버 비용을 줄이는 다섯 가지 방법을 정리했습니다.

+

1. 쓰지 않는 디스크 정리하기 — 스냅숏만 남기고 붙어 있지 않은 볼륨을 지우면 …

+

2. 예약 인스턴스로 바꾸기 — 1년 약정이면 …

+

(이미지가 있던 자리는 비어 있습니다 — 글은 그대로 읽힙니다.)

+
+
+
+ +
+
+ + + + + + +
+

「인쇄」는 새 탭에 인쇄 화면을 열고 바로 인쇄 창을 띄웁니다(다음 보드). 읽기 화면에서 Ctrl+P 를 눌러도 같습니다. 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」입니다. 사내 직원 · 내 주소는 차단 항목이 없습니다.

+ +
+

보낸 사람 차단

+

앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.

+
+ + +
+ +

차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.

+
+ + +
+
+
+
+ +
+ +
+
+

[이미지 보기]를 누른 뒤

+

그 메일만 · 다시 열면 설정대로

+
+
+
(받아 온 이미지)
+

안녕하세요, 이번 주 클라우드 브리프입니다. …

+
+

안내 줄은 사라집니다. 이미지를 받지 못한 자리(없어진 주소 · 너무 큰 파일 · 사내망 주소)는 비어 있습니다. 메일 안의 배경 그림은 보이지 않습니다.

+
+ +
+
+

설정에 따라 처음부터 보일 때

+

설정 › 메일 › 보내기 · 읽기 › 외부 이미지

+
+
    +
  • 늘 묻기(처음 값) — 메일마다 [이미지 보기]
  • +
  • 주소록 · 사내 직원의 메일은 늘 보기 — 보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다. 나머지는 [이미지 보기]
  • +
  • 늘 보기 — 모든 메일에서 바로 보입니다
  • +
+

스팸메일함의 메일은 설정과 상관없이 늘 묻습니다. 답장 · 전달에 인용한 본문에는 이미지가 실리지 않습니다(지금과 같음).

+
+ +
+
+

차단한 뒤

+

알림 · 스팸메일함에서 읽을 때

+
+
+ news@cloudbrief.example 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다. + +
+
+ + 차단한 보낸 사람의 메일입니다. + +
+

[되돌리기]는 차단을 풀고 메일을 받은편지함으로 돌려놓습니다. 「이 도메인 전체」는 gmail.com · naver.com 처럼 여러 사람이 쓰는 메일 서비스에서는 고를 수 없습니다(까닭을 칸 아래에).

+
+
+
+
+ + + diff --git a/docs/design/webmail/MailSendLater.dc.html b/docs/design/webmail/MailSendLater.dc.html new file mode 100644 index 0000000..e0219ae --- /dev/null +++ b/docs/design/webmail/MailSendLater.dc.html @@ -0,0 +1,203 @@ + + + + +보내기 취소 · 예약 발송 + + + + + + + +
+ +
+

보내기 취소 · 예약 발송

+

[보내기]를 누르면 메일 서버가 몇 초(설정 — 처음 10초) 기다렸다가 보냅니다. 그동안 [보내기 취소]로 쓰기 화면에 돌아옵니다. 기다리는 곳이 서버라 창을 닫아도 그대로 보내집니다. 예약 발송은 같은 방식으로 7일 안의 시각까지 기다립니다.

+
+ +
+ +
+
+

쓰기 화면 — 보내기 ▾

+

단추를 둘로 나눕니다 — 오른쪽 ▾ 는 메뉴

+
+
+
+ + + +
+ + 2분 전에 저장함 +
+
+ +
+

메뉴는 「예약 발송…」 하나로 시작합니다. 단축키 Ctrl+Enter 는 그대로 [보내기]입니다. 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추가 함께 꺼집니다(지금의 [보내기]와 같은 규칙).

+
+ +
+

예약 발송

+
+ + + + +
+

이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다.

+
+ + +
+
+ +
+

예약 시각 규칙

+
    +
  • 골라 둔 시각이 이미 지났거나 5분 안이면 칸 아래 "지금부터 5분 뒤 이후의 시각을 고르세요."
  • +
  • 7일을 넘으면 "7일 안의 시각만 고를 수 있습니다." — 메일 서버의 한도입니다
  • +
  • 시각은 30분 단위로 고릅니다. 오늘 오후가 남았으면 「오늘 오후 6:00」도 첫 줄에 보입니다
  • +
  • 예약한 메일도 받는 사람 확인은 지금 합니다 — 없는 주소면 지금과 같이 칸 아래에서 막습니다
  • +
+
+
+ +
+
+
+

보낸 뒤 알림

+

[보내기 취소]는 기다리는 시간 동안만 — 그 뒤 알림은 저절로 닫힙니다

+
+
+
+ 메일을 보냈습니다. + +
+ +
+
+ 보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소) + +
+
+ + 보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다. +
+
+ 이미 보낸 메일이라 취소할 수 없습니다. +
+
+ + 10월 30일 (금) 오전 9:00 에 보냅니다. + +
+
+ +
+

보내기 취소의 흐름

+
    +
  1. [보내기] → 쓰기 화면을 닫고 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(설정한 초 — 마우스를 올려도 시간은 멈추지 않습니다)
  2. +
  3. [보내기 취소] → 서버가 보내지 않고 그 메일을 임시보관함으로 돌려놓습니다 → 쓰기 화면이 그 메일로 다시 열립니다(받는 사람 · 제목 · 본문 · 첨부 그대로)
  4. +
  5. 시간이 지나면 알림이 닫히고 메일은 보낸 것입니다 — 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."
  6. +
  7. 받는 사람 확인은 [보내기]를 누른 순간에 합니다 — 일부가 받지 못하면 그 알림에도 [보내기 취소]가 있어 주소를 고쳐 다시 보낼 수 있습니다
  8. +
  9. 설정 › 메일 › 보내기 · 읽기에서 끔 · 5 · 10 · 20 · 30초 — 끄면 지금처럼 바로 보냅니다
  10. +
+

기다리는 동안에도 보낸편지함에 보입니다(날짜 칸에 "보내는 중"). 다른 화면으로 옮겨 다녀도 알림은 그대로입니다. 로그아웃 · 다른 계정 로그인 때는 알림을 닫습니다 — 메일은 그대로 보내집니다.

+
+
+ +
+ +
+ 받은편지함5 + 보낸편지함 + 예약됨2 + 임시보관함 +

「예약됨」은 예약한 메일이 있을 때만 보낸편지함 아래에 보입니다. 숫자는 예약한 통 수입니다.

+
+ +
+
+

예약됨

+ 2통 · 보낼 시각 차례 +
+ + 받는 사람: 이도윤 + 11월 첫 주 배포 일정 확인 부탁드립니다 + 10월 30일 (금) 오전 9:00 + + + 받는 사람: 인프라팀 외 2명 + [공지] 메일 서버 전환 완료 안내 + 11월 2일 (월) 오전 9:00 + +

고르는 칸 · 여러 통 처리는 없습니다(한 통씩 엽니다). 보낸 메일은 저절로 이 목록에서 빠지고 보낸편지함에 남습니다.

+
+ +
+
+

예약한 메일을 열면

+

읽기 화면 머리 아래 안내 줄

+
+
+ + 10월 30일 (금) 오전 9:00 에 보낼 메일입니다. + + +
+
    +
  • [예약 취소] → 보내지 않고 임시보관함으로 옮깁니다 — 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]
  • +
  • [고치기] → 예약을 취소하고 쓰기 화면으로 엽니다 — 다시 [보내기] 또는 「예약 발송…」
  • +
  • 답장 · 전달 · 스팸 신고 단추는 이 메일에는 없습니다. [삭제]는 예약을 취소한 뒤 휴지통으로 옮깁니다(확인 창)
  • +
+
+ 이도윤 · 11월 첫 주 배포 일정 확인 부탁드립니다 + 예약 · 10월 30일 오전 9:00 +
+

보낸편지함에서도 보입니다 — 날짜 칸이 "예약 · 보낼 시각"(보내기 취소를 기다리는 동안은 "보내는 중")입니다.

+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 3a4384e..7ff5bfe 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -466,6 +466,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록). 사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄. +## 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 (2026-10-29 확정 — `MailReadTools` · `MailPrint` · `MailSendLater` · `SettingsSendRead`) + +작고 자주 쓰는 다섯 가지를 한 번에 더한다. 계약은 `docs/mail/api-contract.md` 2절의 9단계 절들. 새 아이콘: 인쇄(printer) · 시계(clock) · 차단(원 + 빗금). + +### 읽기 화면 — 외부 이미지 · ⋯ 메뉴 · 차단 (`MailReadTools`) + +- **외부 이미지 줄**: 지금의 노란 안내 줄("외부 이미지를 차단했습니다. …") 오른쪽에 [이미지 보기](36 — 밀도 예외 `--size-notice-action`). 누르면 그 메일만 이미지를 보이고 안내 줄은 사라진다. + 다시 열면 설정대로다. 받지 못한 이미지 자리는 비어 있다. 메일 안의 배경 그림은 보이지 않는다(알려진 한계). +- **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.** +- **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글). + 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭. +- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다." + 고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭. + 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소]. +- **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면 + 회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기]. + +### 인쇄 화면 (`MailPrint`) + +- ⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 **새 탭**에 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄운다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않는다. +- 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다. +- 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것 — 숨은참조는 넣지 않는다) → + 첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다. +- 외부 이미지는 읽기 화면에서 보이던 대로. 본문은 인쇄 폭(A4)에 맞춰 높이를 다시 잰 뒤 인쇄한다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않는다 + (2026-10-29 확인: 지금 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다). +- 못 열 때: 메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 [다시 시도] — 인쇄 창은 띄우지 않는다. 세션이 끝났으면 로그인 화면으로. + +### 보내기 취소 · 예약 발송 (`MailSendLater`) + +- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). Ctrl+Enter 는 그대로 [보내기]. + 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙). +- **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각. + 오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]. + 칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠). +- **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다. + 일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게). + [보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다." + 예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다. +- **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례, + 고르는 칸 · 여러 통 처리는 없다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. +- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 — + 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]). 답장 · 전달 · 스팸 신고 단추는 없다. [삭제]는 예약을 취소한 뒤 휴지통으로(확인 창). +- **보낸편지함**: 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00"(청록) — 보내기 취소를 기다리는 동안은 "보내는 중". + +### 설정 · 보내기 · 읽기 · 차단한 주소 (`SettingsSendRead`) + +- **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림. +- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다. + - 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다." + - 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자 + "외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다." +- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다." + - 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." · + "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다." + - 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기]. + 이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -692,3 +749,55 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다). - **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구. 다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다). + +### 9단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다) + +- **외부 이미지**: [이미지 보기]는 받는 동안 꺼진다(`aria-disabled` · `aria-busy`). 받지 못하면 인터셉터가 알리고 줄은 그대로다. 그 메일의 본문 칸만 바꾸고(받는 사이 다른 메일을 열었으면 버린다), + 줄이 사라져 초점을 잃으면 본문 틀로 옮긴다. 본문 틀의 CSP 는 보일 때만 그림에 우리 출처의 중계 경로(`/api/mail-images/`)를 연다 — 경로까지 좁힌다(2026-10-29 리뷰). + 중계 주소로 인정하는 모양은 `/api/mail-images/` 뿐이다(짝: backend `TOKEN_PATTERN`). 답장 · 전달 인용에는 중계 그림도 실리지 않는다. +- **인쇄**: 라우트 `mail-print`(앱 틀 없음 · 로그인 필요), 탭 제목은 메일 제목(인쇄 머리글 · PDF 이름으로 쓰인다). `window.open` 은 누름 처리 안에서 바로 부른다(팝업 차단을 피하려고). + - Ctrl+P(⌘P)는 메일이 다 열려 있을 때만 가로챈다 — 본문 틀 안에서 눌러도 같고, 한글 조합 중 · 다른 수식키 · 불러오는 중이면 브라우저 인쇄 그대로다. 라틴 글자가 아닌 배열은 `code === 'KeyP'` 로도 잡는다. + - 종이 210mm · 여백 16mm 15mm 는 @page 와 같은 값이다(둘을 함께 고친다). 본문 틀의 load(그림까지) · 글꼴 준비 · 한 프레임 뒤에 인쇄 창을 한 번만 띄운다 — + 그사이 다시 받기 시작했으면 띄우지 않고, [인쇄]는 다 잰 뒤에만 눌린다. [닫기]로 닫히지 않는 탭(주소로 바로 들어온 탭)은 메일 화면으로 간다. + - 첨부는 목록에 보이는 것만 "이름 (크기)", 날짜는 "2026년 10월 26일 (월) 오전 10:42". 받기 실패는 화면이 그린다(알림 없음). 문구: "(제목 없음)" · "(보낸 사람 없음)" · 받는 중 "메일을 불러오는 중". +- **보내기 취소 알림**: 마감은 설정한 초를 알면 "지금 + 그 초 − 1초", 모르면 min(sendAt, 지금 + 30초)이다(설정은 메일 화면에 들어올 때 받아 둔다 — 아는 값이 0 인데 서버가 붙잡았으면 sendAt). + 이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 ``(aria-hidden)다. + - 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다. + - 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]. + - 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기]. +- **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로). +- **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다. + 직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.". + 창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 — + 이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로. +- **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다. + 예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다. + - 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다. + 빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.". + - [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." — + 쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로. +- **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고, + 시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다. +- **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`): + - 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고 + 여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글. + - 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) → + 발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게). + - 서버도 막는다(`MAIL_015` — 화면이 막지 못한 때만 보인다): 여러 통 처리는 그 메일만 남기고(`failedIds` · `skippedIds` — 지금처럼), 메일함 지우기 · 비우기는 붙잡힌 메일만 남으면 + 진행 알림이 멈추고 인터셉터 알림 "보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요."(IMAP 으로 옮긴 메일에서만 생긴다 — 4단계 처리의 실패와 같은 모양). +- **기다린 뒤 다음 메일로**: 삭제 · 스팸 · 차단 · 예약 취소처럼 응답을 기다린 뒤 옮기는 일은 그사이에도 같은 목록 · 같은 메일을 보고 있을 때만 한다(3 · 4단계의 단추도 함께 고쳤다 — 2026-10-29 리뷰). +- **차단**: + - 항목이 없는 때: 보낸 사람이 없을 때 · 내 주소 · 사내 도메인(그 하위 포함). 차단 목록은 메일 화면에 들어올 때 알림 없이 받고, 못 받았으면 ⋯ 를 열 때 다시 받는다(모르는 동안은 「보낸 사람 차단」). + 500개면 항목을 끄고(`aria-disabled`) "차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.". + - 창: 고르기 묶음 이름 "차단할 범위", 도메인을 고를 수 없는 까닭 "여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.". 「이 메일도 스팸메일함으로 옮기기」는 이미 스팸메일함이면 숨긴다. + - 옮겼으면 삭제처럼 다음으로 간다. [되돌리기]는 차단을 풀고 **원래 메일함**으로 되돌린다(보드의 "받은편지함" 대신 — 받은편지함 밖에서 막았을 수 있다). + 도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다. + - 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." · + 되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.". +- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯). + 칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다. +- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음). + 성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸. + 엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." — + 차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절). +- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금). diff --git a/docs/design/webmail/SettingsSendRead.dc.html b/docs/design/webmail/SettingsSendRead.dc.html new file mode 100644 index 0000000..b689be1 --- /dev/null +++ b/docs/design/webmail/SettingsSendRead.dc.html @@ -0,0 +1,140 @@ + + + + +설정 · 보내기 · 읽기 · 차단한 주소 + + + + + + + +
+ +
+

설정 · 보내기 · 읽기 · 차단한 주소

+

메일 묶음에 두 화면을 더합니다. 「보내기 · 읽기」는 고르는 대로 저장합니다(저장 단추 없음 — 알림 설정과 같은 결). 이 설정은 서버에 저장되어 어느 PC 에서나 같습니다.

+
+ +
+ + + +
+
+

보내기 · 읽기

+ 저장했습니다 +
+ +
+

보내기 취소

+
+ + + + + +
+

[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.

+
+ +
+ +
+ 외부 이미지 + + + +

외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.

+
+
+ +
+
+
+

차단한 주소

+ 3 / 500 +
+

이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.

+
+ +
+ +
+ + +
+

메일 주소나 @도메인 형식으로 써 주세요.

+
+ +
    +
  • + news@cloudbrief.example10월 29일 차단 + +
  • +
  • + @promo-mail.example도메인 전체 · 10월 21일 차단 + +
  • +
  • + sales@unknown-shop.example10월 18일 차단 + +
  • +
+ +
+ 칸 아래 안내(보드에 없던 경우): "이미 차단한 주소입니다." · "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다." + [차단 풀기]는 바로 풀고 알림 "‘news@cloudbrief.example’ 차단을 풀었습니다." + [되돌리기]. 이미 받은 메일은 옮기지 않습니다. + 차단한 주소가 없으면 목록 자리에 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." +
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 4666f6e..46f295e 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -29,9 +29,13 @@ "MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" }, "Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true }, "ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" }, - "ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" } + "ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" }, + "MailReadTools.dc.html": { "x": 0, "y": 16820, "w": 1440, "h": 1220, "title": "읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단" }, + "MailPrint.dc.html": { "x": 1520, "y": 16820, "w": 1440, "h": 1060, "title": "인쇄 화면" }, + "MailSendLater.dc.html": { "x": 3040, "y": 16820, "w": 1440, "h": 1500, "title": "보내기 취소 · 예약 발송" }, + "SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "title": "설정 · 보내기 · 읽기 · 차단한 주소" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -40,7 +44,8 @@ "phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }, - "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 } + "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }, + "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 4f18f30..d3abaa5 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -8,7 +8,7 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` -- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `SYS_001` +- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, @@ -152,6 +152,7 @@ interface MessageSummary { isFlagged: boolean hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다 size: number // 바이트 + pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null } interface MessageDetail extends MessageSummary { @@ -163,7 +164,8 @@ interface MessageDetail extends MessageSummary { references: string[] htmlBody: string | null // 서버가 정화한 HTML (아래 참고) textBody: string | null - hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 + hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 (보이게 했으면 false) + imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정) isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도") isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true attachments: MessageAttachment[] @@ -180,7 +182,7 @@ interface MessageAttachment extends Attachment { **`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`), -외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`. +외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true` (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계). 본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다. 넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다 (인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다. @@ -205,24 +207,24 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` | | `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` | -| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | +| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | -| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | +| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` | | `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` | | `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` | | `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` | -| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` | +| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` | | `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` | | `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` | | `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` | | `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | -| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` | -| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` | -| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | -| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | -| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | -| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과") | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | +| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` | +| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) | +| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | +| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | +| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | +| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | | `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` | | `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` | | `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` | @@ -234,6 +236,14 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) | | `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` | | `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` | +| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` | +| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` | +| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) | +| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — | +| `PATCH /api/mail-settings` | `Partial` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` | +| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` | +| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` | +| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` | 표에 없는 실패 (구현에서 확정): @@ -303,7 +313,8 @@ interface MailQuota { "bodyHtml": "

도윤님, 확인했습니다.

…
", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문") "attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006) "inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다 - "draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") + "draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") + "sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송") } ``` @@ -313,6 +324,7 @@ interface MailQuota { interface SentMessage { id: string // 보낸편지함의 메일 id rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열 + pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null } interface RejectedRecipient { email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번) @@ -332,6 +344,23 @@ interface RejectedRecipient { - 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다. 결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다. - 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로. +- **엔진이 받지 않는 주소**(9단계 리뷰 뒤 — 2026-10-29 실엔진): 발송은 늘 envelope(받는 사람 · 참조 · 숨은참조, 같은 주소는 하나)을 직접 준다. + 엔진은 envelope 에 받지 않는 주소(모르는 최상위 도메인 `x@example.cmo` · 따옴표 앞부분 `"a b"@…` 등)가 있으면 제출 **전체**를 거절하고 그 가운데 첫 주소 하나만 알려 준다. + 서버는 그 주소를 `not-found` 로 빼고 같은 메일을 나머지에게 다시 제출한다 — 붙잡기(9단계)는 그대로이고, 차례마다 **하나씩** 뺀다. + 거절 글이 따옴표로 싼 주소와 정확히 같은 받는 사람부터 찾는다 — 따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다(2026-10-29 리뷰 2). + - 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400 `VAL_001`(21번째를 뺀 뒤 남은 사람이 없으면 `MAIL_010`). + - 남는 사람이 없으면 만든 사본을 지우고 400 `MAIL_010`(아래 "모두 거절"과 같다). + - 다시 낼 시간(발송 55초 안에서 10초 이상)이 모자라면 사본을 지우고 503 `MAIL_001` — 아직 아무것도 제출하지 않았으니 같은 키로 다시 보내면 된다. + 발송 응답은 사본 정리까지 56초 안이다(정리는 남은 시간 안에서, 남은 시간이 없어도 1초까지는 기다린다). + - 다시 낸 제출이 요청째 거절되면(메서드 오류 · HTTP 4xx — 엔진이 실행하지 않았다) 사본을 지우고 첫 제출과 같은 오류로 끝난다. + 결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx)는 지우지 않는다 — 나갔을 수 있어 같은 키의 재시도가 맡는다(첫 제출과 같다). + - 거절 글에 우리 주소가 없으면 지금처럼 400 `VAL_001`. + - 전에는 envelope 을 주지 않아 엔진이 그런 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 오타 도메인으로 간 사람이 응답에 없었다. + - 결과(`deliveryStatus`)를 읽지 못해도 envelope 에서 뺀 사람은 `rejectedRecipients` 에 싣는다. 같은 키의 재시도는 제출 기록의 envelope 에 없는 받는 사람을 `not-found` 로 다시 만든다. + - 주소는 대소문자를 무시하고 국제화 도메인은 `xn--` 꼴로 맞춘다 — 엔진은 유니코드 · `xn--` 모두 받고 제출 기록에는 `xn--` 로 둔다. + 엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서). + - **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다). + 개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다). - **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다), 이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`. 같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다). @@ -977,11 +1006,179 @@ interface MailFilterRun { "창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기]. 탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다. +### 보내기 취소 · 예약 발송 (`POST /api/messages` 의 기다리기 · `/api/scheduled-messages`) — 9단계 + +메일 서버(엔진)가 제출을 잠시 붙잡았다가 보낸다 — `EmailSubmission` 의 FUTURERELEASE(`envelope.mailFrom.parameters` 의 `HOLDFOR` 초 · `HOLDUNTIL` 시각). +붙잡힌 동안은 `undoStatus: pending` 이고, 취소하면(`canceled`) 배달되지 않고, 시간이 되면 보낸다(`final`) — Stalwart 0.16.27, 2026-10-29 확인. +기다리는 곳이 서버라 창을 닫아도 보내진다. 화면은 디자인 README "9단계"(디자인 원본 `MailSendLater`). + +```ts +interface PendingSend { + sendAt: string // 엔진이 보낼 시각(UTC) + kind: 'undo' | 'scheduled' // 보내기 취소를 기다리는 중 · 예약 발송 +} +``` + +- **보내기 취소**: `sendAt` 없이 보내면 서버가 그 직원의 설정(`undoSendSeconds` — 아래 "보내기 · 읽기 설정", 처음 10초)만큼 붙잡는다(`HOLDFOR`). 0 이면 지금처럼 바로 보낸다. + 응답 `pendingSend: { kind: 'undo', sendAt }` — 클라이언트는 남은 시간을 이 시각으로 센다. 다만 PC 시계가 어긋나면 알림이 설정보다 오래 남으므로, + 설정한 초를 알면 "지금 + 그 초 − 조금"을 넘기지 않는다(구현하며 정함 — 2026-10-29 리뷰). +- **예약 발송**: 발송 본문의 `sendAt`(UTC) 까지 붙잡는다(`HOLDUNTIL`) — 지금부터 5분 뒤 이상 · 7일 안. 밖이면 400 `VAL_001`. + `sendAt` 은 `Z` 로 끝나는 UTC 만 받고 소수 초는 버린다. `null` 은 없는 것과 같다(바로 보내거나 보내기 취소 설정대로). + 엔진이 그 시각을 거절하면(`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 확인). + 그래서 위 "받는 사람별 결과"(일부 거절 `rejectedRecipients` · 모두 거절 `MAIL_010` · `MAIL_011`)는 그대로다. 바깥 주소는 풀린 뒤 배달되고, 실패하면 지금처럼 반송 메일이 온다. + 붙잡은 제출의 결과는 사내 · 바깥 모두 곧바로 `250 2.1.5 Queued` 다(거절된 사람만 `delivered: no`). 모두 거절인데 제출이 아직 기다리고 있으면(모두 4xx 등) + 같은 요청에서 먼저 취소하고 사본을 지운다 — 풀리는 시각에 다시 보내지 않게(취소를 엔진이 받지 않아도 사본은 지우고 warn). 모두 5xx 면 엔진이 붙잡아도 곧바로 `final` 이라 지우기만 한다(같은 날 확인). +- **보낸 사본의 자리**: 제출하면서 지금처럼 보낸편지함으로 옮긴다 — 보내기 전에도 보낸편지함에 보이고 `pendingSend` 로 "보내는 중" · "예약 · 시각"을 보인다. + 예약은 사본에 내부 표시(키워드 `$ara-scheduled` — 목록 · 화면에는 표시로 내보내지 않는다. 보낸 뒤에도 남는다 — 해가 없다)를 붙여 보내기 취소를 기다리는 것과 가른다. + `pendingSend` 는 엔진의 기다리는 제출(`undoStatus: pending`)이면서 **보낼 시각이 아직 오지 않은 것**에만 붙는다(구현하며 정함) — 붙잡지 않은 발송도 대기열에서 배달하는 동안 잠깐 pending 이고, + 보낼 시각이 지나 배달 중인 메일은 `null` 이다(그때 취소하면 `MAIL_014`). 기다리는 제출은 보낼 시각으로 찾는다(`EmailSubmission/query` 의 `filter.after: 지금` — 엔진의 색인을 탄다. + `undoStatus` 조건은 모든 제출을 훑어 계정마다 65~140ms 라 쓰지 않는다). 차례는 보낼 시각 오름차순 — 엔진의 정렬 이름은 `sentAt` 이다(`sendAt` 은 `unsupportedSort` — 2026-10-29 확인). + 한 번에 500개까지 읽고, 닿으면 warn 을 남긴다. 목록 · 상세 · 한 통 바꾸기 응답에 엔진 호출(찾기 · 읽기)이 붙고, 읽지 못하면 `pendingSend: null` 로 준다(표시만 — 옮기기 · 지우기를 막는 확인은 닫힌 쪽으로, 아래). +- **붙잡힌 메일은 취소로만 보낸편지함을 떠난다**(구현하며 정함 — 2026-10-29 리뷰): 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다(지운 메일도 나간다) — 우리 API 로는 그렇게 되지 않게 막는다. + - 한 통 `PATCH /api/messages/{id}` 의 `mailboxId` · `isJunk` 와 `DELETE /api/messages/{id}` 는 409 `MAIL_015`(아무것도 바꾸지 않는다), 여러 통 `PATCH /api/messages` 는 그 id 를 `failedIds` 로, + `POST /api/message-deletions` 는 `skippedIds` 로. 읽음 · 중요 표시는 바꿀 수 있다. + - 메일함 지우기 · 비우기(4단계 "메일함 관리")는 붙잡힌 메일을 그대로 두고 나머지를 처리한다 — 붙잡힌 메일은 `remaining` 에 들고, + 한 요청에서 처리한 것이 없으면(남은 것이 붙잡힌 메일뿐) 409 `MAIL_015` 로 끝난다(지우기는 메일함을 지우지 않는다). 우리 API 로는 붙잡힌 메일이 보낸편지함 밖에 있을 수 없어 IMAP 으로 옮긴 메일에서만 생긴다. + - 이미 받은 메일에 필터 적용(7단계)은 붙잡힌 메일을 옮기지 않고 표시만 한다(`matched` 에는 센다). + - 막을지 볼 때 엔진에 묻지 못하면(오류 · 시간) 바꾸지 않고 503 `MAIL_001` 이다 — 위의 모든 길이 닫힌 쪽으로 실패한다. + 다 받았는지는 응답의 `total`(`calculateTotal`)로 정하고, 모르면 다 보지 못한 것으로 본다(2026-10-29 리뷰 2 — 요청한 limit 으로 정하지 않는다). + 기다리는 제출을 찾는 조회가 500개에 닿으면 그 메일 id 로 다시 묻고(같은 "지금 뒤" 조건), 그것도 넘치면 `MAIL_001`. + 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다(화면 표시 `pendingSend` 는 그때 null). + - **알려진 한계**: IMAP · 다른 JMAP 프로그램이 옮기거나 지우는 것은 막지 못한다 — 엔진은 그래도 시간이 되면 보낸다. + - 클라이언트는 붙잡힌 메일을 목록에서 고르지 못하게 하고(고르기 칸 `aria-disabled` + 까닭 — 모두 고르기 · 범위도 건너뛴다), + 읽기 화면에는 옮기기 · 스팸 단추를 두지 않는다 — [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 옮긴다(보내기 취소를 기다리는 메일도 같다). + `MAIL_015` 는 인터셉터 알림(메일함 처리는 진행 알림도 거기서 멈춘다 — 4단계와 같은 결). +- `GET /api/scheduled-messages` — 예약한 메일(`kind: 'scheduled'` 이고 아직 보내지 않은 것)을 보낼 시각 차례로. 화면은 `pageInfo.total` 로 메일함 패널의 「예약됨」(있을 때만)을 보인다 — + 메일 화면에 들어올 때 · 보내거나 취소한 뒤 · 새 메일 확인(3단계) · 실시간 알림(5단계)이 바뀜을 알릴 때 다시 묻는다. 보낸 메일은 저절로 빠진다(엔진의 제출이 `final`). + 엔진이 풀어도 새 메일 확인 · 실시간 알림의 상태(메일함 · 메일)는 바뀌지 않는다 — 그래서 화면은 보이는 목록 · 열린 메일 · 「예약됨」 첫 항목 가운데 가장 이른 `sendAt` + 2초에 + 보이는 목록 · 열린 메일 · 「예약됨」 수를 다시 맞추고, 지난 시각이 아직 보이면 5초마다 몇 번 더 본다. 취소 · 고치기가 `MAIL_014` 를 받았을 때도 같다(구현하며 정함 — 2026-10-29 리뷰). +- `DELETE /api/scheduled-messages/{id}`(`id` 는 보낸 사본의 메일 id) — 붙잡은 발송을 취소한다(보내기 취소 · 예약 취소 모두). 엔진에서 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨 + 초안으로 되돌린다(`$draft` 와 취소 표시 `$ara-canceled` 를 붙이고 예약 표시를 뗀다 — 발송 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자 초안이다) → + 200 `{ draftId }`(같은 메일 id — 쓰기 화면이 이 id 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다. + 이어 쓸 때(구현하며 정함): 서명 그림 부분(Content-ID `sig-…`)은 첨부에서 빼고 본문의 `cid:sig-…` 는 서명 그림 주소로 되돌린다(보낼 때 다시 `cid:` 로 — "서명"), + 답장 대상은 같은 스레드에서 `In-Reply-To` 로 찾아 초안의 `inReplyTo` 로 준다. + - 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409 `MAIL_014` — 아무것도 바꾸지 않는다. 없는 메일은 404 `RES_001`. + - **다시 취소해도 된다**(2026-10-29 리뷰 — 응답이 끊겨 다시 누른 경우): 그 메일의 제출이 모두 `canceled` 이고 취소 표시(`$ara-canceled`)가 있으면 **어느 메일함에 있든** 그대로 200 `{ draftId }`(같은 id — + 옮기지 않는다: 사용자가 그 초안을 휴지통에 버린 뒤 늦게 온 취소가 되살리지 않게), 표시가 없으면(앞의 옮기기가 실패) 되돌리기를 다시 한다 — 그래도 못 하면 503 `MAIL_001`. + - 엔진이 취소를 확인하지 않으면(응답의 `updated` 에 그 제출이 없음 — 거절도 아님) 옮기지 않고 503 `MAIL_001` — 옮기면 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다. + - 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다). + - 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` — + 그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그). +- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다. + 서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다. + 붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다. + 같은 키의 메일을 찾으면 그 제출을 늘 읽는다(엔진 호출 1회 더 — 제출이 모두 `canceled` 면 위의 `MAIL_005`). +- 보낸 뒤 이어 쓴 저장본(`draftId`) 지우기는 지금처럼 한다 — 취소하면 붙잡혀 있던 사본이 새 초안이 된다. +- 붙잡은 메일은 엔진을 다시 띄워도 남는다(엔진의 대기열은 저장소에 있다). 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진에 묻는다. +- **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다. +- **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다). + 일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.". + `MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다. + +### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계 + +받은 메일의 외부 이미지는 지금처럼 지운 채 준다(`hasBlockedImages`). 보이게 할 때는 서버가 그 메일의 이미지 주소를 **중계 주소**로 바꿔 넣고, 그 주소로 오는 요청에 +서버가 바깥 이미지를 대신 받아 준다 — 보낸 쪽 서버에는 직원 PC 의 주소 · 브라우저가 드러나지 않고(우리 서버의 주소만), 화면의 CSP(`img-src 'self' data:`)도 그대로다. +보낸 쪽은 이미지를 받은 시각으로 메일을 열어 본 것을 알 수 있다 — 그래서 처음 값은 "늘 묻기"다. 화면은 디자인 README "9단계"(디자인 원본 `MailReadTools` · `SettingsSendRead`). + +- `GET /api/messages/{id}?images=show` — 외부 이미지를 보이는 본문(`hasBlockedImages: false` — srcset · 배경 그림 · 한도 밖처럼 지운 것이 있어도). + `imagesShown` 은 실제로 바꿔 넣은 그림이 있을 때만 true 다 — 외부 이미지가 없는 메일은 `images=show` 여도 false(구현하며 정함). + 쿼리 없이 물으면 설정(아래 `externalImages`)을 따른다: `ask` 는 지금처럼 지우고, `trusted` 는 보낸 사람이 내 주소록(3절)이나 사내 목록(직원 · 공용 주소 — 3절 "사내 목록")에 있으면 보이고, + `always` 는 늘 보인다. **스팸메일함의 메일은 설정과 상관없이 지운다**(`images=show` 로만 보인다). `MessageDetail.imagesShown` 이 그 결과다. + `trusted` 에서 주소록 · 사내 목록을 읽지 못하면 보이지 않는 쪽으로 다룬다. 머리글 From 은 꾸밀 수 있다(알려진 한계 — 꾸며도 중계라 직원 PC 의 주소는 드러나지 않는다). +- 바꾸는 것은 `` 의 `src`(http · https · `//`)뿐이다 — `srcset` 은 지금처럼 지우고, style · 속성의 배경 그림(`url()`)도 지금처럼 지운다(알려진 한계). + 주소 2,048자 이하만, 한 통에 100개까지(그 뒤는 지운다). 같은 주소는 한 통 안에서 같은 중계 주소를 쓴다(브라우저가 한 번만 받는다). 중계 주소는 `/api/mail-images/<표>` — 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 **암호화한**(인증 암호 AES-GCM — + 고친 표는 풀리지 않는다) 불투명 글이다. 서명만 하면 표를 풀어 원래 주소(추적 번호가 든다)를 읽을 수 있어 접근 로그 · 주소창에 남는다 — 그래서 암호화한다. + 키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 404 이고, 메일을 다시 열면 새 표를 받는다. ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다. +- `GET /api/mail-images/{token}` — 로그인 필요(쿠키), 표의 직원과 같아야 한다. 서버가 바깥 이미지를 받아 그대로 준다(표준 래퍼 없음): + - 주소: http · https, 기본 포트(80 · 443)만. 이름을 풀어 **모든 주소가 공인 주소일 때만**, 푼 그 주소로 연결한다(다시 풀지 않는다 — DNS 바꿔치기를 막는다). + 막는 곳: 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) · 멀티캐스트 · 예약 · 문서용 대역, IPv6 의 같은 것(::1 · fc00::/7 · fe80::/10 · ff00::/8 · IPv4 대응 · NAT64) — + 우리 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)도 사설이라 막힌다(메일 엔진 관리 API · DB 를 부르게 만드는 공격). + 점 없는 이름(도커 서비스 이름 등)은 묻지 않고 막는다. 이름은 c-ares(`dns.Resolver`)로 푼다 — libuv 스레드 풀을 막지 않게(구현하며 정함). + - 다시 가기(redirect)는 3번까지, 매번 위 검사. 전체 10초, 받는 크기 5MB(넘으면 끊는다). + - 형식은 받은 바이트의 앞부분으로 정한다 — PNG · JPEG · GIF · WebP 만(SVG · 그 밖은 거절). 바깥이 보낸 `Content-Type` 은 믿지 않는다. + - 바깥으로는 쿠키 · Referer 없이, 고정한 User-Agent 로 묻는다. + - 응답: 받은 바이트 + 판정한 `Content-Type` · `Cache-Control: private, max-age=3600` · `X-Content-Type-Options: nosniff` · `Content-Disposition: inline` · `Content-Security-Policy: default-src 'none'; sandbox`. + - 실패는 모두 404 `RES_001` — 까닭을 주지 않는다(표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다. + 이 404 는 전역 필터를 거치지 않고 컨트롤러가 직접 쓴다 — 형식 · `traceId` 는 같고, 로그는 debug 만(바깥 탓의 실패가 잦아 warn 이면 알림이 쌓인다 — 구현하며 정함). + - 동시에 받는 수는 서버 전체 16개 · 한 직원 6개까지(넘으면 기다린다 — 30초 · 200개까지, 넘으면 404). 기다리는 줄은 직원마다이고, 서버 자리가 나면 + 자기 6개에 막히지 않은 직원 가운데 지금 받는 수가 가장 적은 직원의 맨 앞 요청부터 든다(같으면 먼저 기다린 쪽 — 몇 사람이 서버 자리를 다 차지하지 못하게, 2026-10-29 리뷰 2). + 자리는 응답을 다 보낼(또는 브라우저가 끊을) 때까지 쥔다 — 메모리에 쥐는 그림은 많아야 16 × 5MB(구현하며 정함 — 2026-10-29 리뷰). + 요청 수 한도는 이 경로만 넉넉하게 — IP 마다 1분 600회(뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT). + - 로그: 주소는 남기지 않는다(추적 번호 · 개인 정보가 들어 있다) — 실패 까닭과 바깥 호스트 이름만 debug. 요청 주소를 남기는 로그의 `/api/mail-images/<표>` 는 글자 수만 남긴다(`maskUrlForLog`). +- 화면: 본문 틀의 내부 CSP 를 보일 때만 `img-src data: <화면 주소>/api/mail-images/` 로 넓히고(중계 경로만 — `'self'` 면 정화를 빠져나간 CSS `url()` 이 같은 출처의 다른 API 를 부를 수 있다, 2026-10-29 리뷰), + 클라이언트 정화(DOMPurify)도 `/api/mail-images/` 로 시작하는 주소만 남긴다(그 밖의 바깥 주소는 지금처럼 지운다). + 답장 · 전달 인용에는 이미지가 실리지 않는다(보내는 정화가 서명 그림 밖의 `img` 를 지운다 — 중계 주소가 바깥으로 나가지 않는다). +- **운영 확인**: 백엔드 컨테이너가 바깥 80 · 443 으로 나갈 수 있어야 한다(호스트 방화벽 · 프록시) — 막혀 있으면 이미지 자리가 비어 보인다. + 막기는 사설 대역에 기대므로 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)은 사설 대역으로 둔다. 서버 자신의 공인 주소로 풀리는 이름은 공인 주소라 막지 않는다 — + 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다(2026-10-29 리뷰, 운영 절차는 docker 스킬). + +### 인쇄 — 9단계 + +API 는 새로 없다. 인쇄 화면(새 탭 — 메일 한 통, 디자인 원본 `MailPrint`)은 `GET /api/messages/{id}` 로 그린다 — 읽기 화면에서 외부 이미지를 보이고 있었으면 `images=show`. + +### 보낸 사람 차단 (`/api/mail-blocks`) — 9단계 + +```ts +interface BlockedSender { + address: string // 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로) + blockedAt: string // 차단한 시각(UTC) +} +interface MailBlockList { + senders: BlockedSender[] // 최근에 차단한 것부터, 500개까지 + isActive: boolean // 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다 +} +``` + +- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. + 이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`). +- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰), + 도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다). + 이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`). + 내 주소와 사내 도메인(`MAIL_DOMAIN` 과 그 하위)은 막을 수 없다. + 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com · daum.net · hanmail.net · kakao.com · nate.com · outlook.com · hotmail.com · live.com · yahoo.com · icloud.com 등 — 목록은 서버 상수)는 + 도메인으로 막을 수 없다(주소로는 된다). 클라이언트도 같은 목록을 먼저 본다(짝 — 한쪽을 고치면 둘 다). 500개를 넘으면 400 `VAL_001`. +- **저장 자리**: 필터 · 부재중과 같은 Sieve 스크립트(`arasystem` — 6단계)의 맨 앞. 머리 주석의 원본(JSON)에 `blocked`(주소 · 차단한 시각)를 더하고, 원본 판은 **차단한 주소가 있을 때만 2** 로 쓴다(없으면 지금처럼 1). + 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. 판 2 를 모르는 옛 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 로 막힌다 + (옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 되돌리기 계획: 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다. + 만든 Sieve(스팸 표시 감싸기 밖 · 필터 · 부재중 앞): `if anyof (address :all :is "from" [주소들], address :domain :is "from" [도메인들]) { fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>"; stop; }`. + 비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이다 — 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없어 `:comparator` 를 뺐다(구현하며 정함 — + 주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다, 한계). `fileinto` 의 대체 이름은 실제 스팸메일함 이름이다(비었거나 `${` 가 들어 있으면 "Junk"). 스팸메일함이 없으면 503 `MAIL_001`. + 실제 배달로 확인했다(2026-10-29, 38가지 — 대소문자를 섞은 주소 · 도메인은 스팸메일함으로, 하위 도메인 · 이름만 같은 다른 도메인 · 차단하지 않은 주소는 손대지 않음, + 차단이 있어도 사내 직원의 메일은 받은편지함. `SieveScript/validate` 는 모르는 test 이름을 걸러 주지 않아 배달로 본다). +- 스크립트 크기 한도(90,000바이트)를 필터와 함께 쓴다 — 넘으면 400 `MAIL_013`(사전 문구가 필터 · 차단 목록을 함께 말한다). +- 쓰기는 필터처럼 읽은 상태 그대로일 때만(`ifInState`) · 어긋나면 다시 읽어 세 번까지. 차단은 필터의 `version` 을 묻지 않는다(주소 하나를 더하고 빼는 일이라 그사이 바뀐 필터를 덮지 않는다). +- 로그: 주소는 가린다(`maskEmail` — 도메인 차단은 도메인만). +- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체, + 「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다. + 스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다. + +### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계 + +직원마다의 설정을 서버 DB 에 둔다(표 `mail_settings` — 직원 주소마다 한 줄, 없으면 처음 값). 어느 PC 에서나 같다. + +```ts +interface MailSettings { + undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10 + externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask' +} +``` + +- 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다). + 서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함). +- 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음). + 칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰). + "저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다. + ### 이 단계에서 하지 않는 것 -탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), +탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로), 여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조, -끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄. +끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, +예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만). --- -- 2.52.0 From b0412ac453a134a923717d51052bd4b2774505c5 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 22:07:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(backend):=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A4=91=EA=B3=84=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8=20=EC=82=AC=EB=9E=8C=20=EC=B0=A8=EB=8B=A8=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다. 외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화. 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings). 리뷰로 고친 것: - 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds). 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다. - 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다 (한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다. - 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게. - 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다. - 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시. - 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정). 에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다. Co-Authored-By: Claude Opus 5.5 --- backend/src/common/errors/error-codes.spec.ts | 13 + backend/src/common/errors/error-codes.ts | 19 +- .../1793255372738-AddMailSettings.ts | 21 + .../modules/contacts/contacts.service.spec.ts | 18 + .../src/modules/contacts/contacts.service.ts | 10 + .../contacts/directory.service.spec.ts | 18 + .../src/modules/contacts/directory.service.ts | 11 + .../dto/mail-user-settings.dto.spec.ts | 49 + .../dto/mail-user-settings.dto.ts | 66 + .../mail-user-settings.controller.ts | 62 + .../mail-user-settings.entity.ts | 37 + .../mail-user-settings.module.ts | 19 + .../mail-user-settings.repository.ts | 80 + .../mail-user-settings.service.spec.ts | 218 ++ .../mail-user-settings.service.ts | 125 + .../mail-user-settings.types.ts | 40 + .../modules/mail/dto/mail-block.dto.spec.ts | 62 + .../src/modules/mail/dto/mail-block.dto.ts | 98 + .../src/modules/mail/dto/mail-response.dto.ts | 73 +- .../src/modules/mail/dto/mail-vacation.dto.ts | 3 +- .../mail/dto/message-detail-query.dto.spec.ts | 27 + .../mail/dto/message-detail-query.dto.ts | 21 + .../modules/mail/dto/send-message.dto.spec.ts | 31 + .../src/modules/mail/dto/send-message.dto.ts | 13 + .../mail/html/sanitize-mail-html.spec.ts | 109 + .../modules/mail/html/sanitize-mail-html.ts | 83 +- .../mail/html/sanitize-outgoing-html.spec.ts | 1 + .../mail/html/signature-images.spec.ts | 45 + .../src/modules/mail/html/signature-images.ts | 44 + .../mail/images/mail-image-fetcher.spec.ts | 425 +++ .../modules/mail/images/mail-image-fetcher.ts | 304 +++ .../mail/images/mail-image-format.spec.ts | 49 + .../modules/mail/images/mail-image-format.ts | 36 + .../images/mail-image-proxy.service.spec.ts | 456 ++++ .../mail/images/mail-image-proxy.service.ts | 237 ++ .../mail/images/mail-image-token.spec.ts | 93 + .../modules/mail/images/mail-image-token.ts | 127 + .../mail/images/mail-image-url.spec.ts | 80 + .../src/modules/mail/images/mail-image-url.ts | 46 + .../mail/images/mail-image.constants.ts | 58 + .../mail/images/mail-image.controller.spec.ts | 168 ++ .../mail/images/mail-image.controller.ts | 165 ++ .../mail/images/node-image-transport.spec.ts | 241 ++ .../mail/images/node-image-transport.ts | 167 ++ .../modules/mail/jmap/delivery-status.spec.ts | 116 +- .../src/modules/mail/jmap/delivery-status.ts | 69 +- .../src/modules/mail/jmap/envelope.spec.ts | 208 ++ backend/src/modules/mail/jmap/envelope.ts | 187 ++ .../modules/mail/jmap/filter-source.spec.ts | 141 +- .../src/modules/mail/jmap/filter-source.ts | 101 +- .../modules/mail/jmap/jmap-filter-run.spec.ts | 226 +- .../src/modules/mail/jmap/jmap-filter-run.ts | 105 +- .../mail/jmap/jmap-filter-script.spec.ts | 448 +++- .../modules/mail/jmap/jmap-filter-script.ts | 140 +- .../mail/jmap/jmap-mail.provider.spec.ts | 2357 ++++++++++++++++- .../modules/mail/jmap/jmap-mail.provider.ts | 1122 +++++++- .../src/modules/mail/jmap/jmap.errors.spec.ts | 46 + backend/src/modules/mail/jmap/jmap.errors.ts | 22 + backend/src/modules/mail/jmap/jmap.mapper.ts | 24 +- backend/src/modules/mail/jmap/jmap.types.ts | 33 +- .../modules/mail/jmap/pending-send.spec.ts | 357 +++ backend/src/modules/mail/jmap/pending-send.ts | 344 +++ .../modules/mail/jmap/sieve-script.spec.ts | 125 +- backend/src/modules/mail/jmap/sieve-script.ts | 87 +- .../src/modules/mail/mail-block.controller.ts | 110 + .../modules/mail/mail-block.service.spec.ts | 161 ++ .../src/modules/mail/mail-block.service.ts | 91 + backend/src/modules/mail/mail-blocks.spec.ts | 172 ++ backend/src/modules/mail/mail-blocks.ts | 121 + backend/src/modules/mail/mail.constants.ts | 70 + backend/src/modules/mail/mail.module.ts | 32 + backend/src/modules/mail/mail.provider.ts | 59 +- backend/src/modules/mail/mail.service.spec.ts | 459 +++- backend/src/modules/mail/mail.service.ts | 208 +- backend/src/modules/mail/mail.types.ts | 73 +- .../src/modules/mail/mailbox.controller.ts | 8 + .../mail/message-deletion.controller.ts | 4 +- .../src/modules/mail/message.controller.ts | 39 +- .../mail/scheduled-message.controller.ts | 80 + .../mail/scheduled-messages.service.spec.ts | 118 + .../mail/scheduled-messages.service.ts | 70 + backend/src/shared/utils/mask.spec.ts | 26 + backend/src/shared/utils/mask.ts | 17 +- .../src/shared/utils/public-address.spec.ts | 89 + backend/src/shared/utils/public-address.ts | 68 + backend/test/app.e2e-spec.ts | 783 +++++- backend/test/image-fixtures.ts | 10 + flutter/lib/core/api/error_lexicon.g.dart | 4 +- .../shared/constants/error-codes.generated.ts | 4 +- 89 files changed, 12851 insertions(+), 351 deletions(-) create mode 100644 backend/src/migrations/1793255372738-AddMailSettings.ts create mode 100644 backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts create mode 100644 backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.controller.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.entity.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.module.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.repository.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.service.ts create mode 100644 backend/src/modules/mail-user-settings/mail-user-settings.types.ts create mode 100644 backend/src/modules/mail/dto/mail-block.dto.spec.ts create mode 100644 backend/src/modules/mail/dto/mail-block.dto.ts create mode 100644 backend/src/modules/mail/dto/message-detail-query.dto.spec.ts create mode 100644 backend/src/modules/mail/dto/message-detail-query.dto.ts create mode 100644 backend/src/modules/mail/images/mail-image-fetcher.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image-fetcher.ts create mode 100644 backend/src/modules/mail/images/mail-image-format.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image-format.ts create mode 100644 backend/src/modules/mail/images/mail-image-proxy.service.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image-proxy.service.ts create mode 100644 backend/src/modules/mail/images/mail-image-token.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image-token.ts create mode 100644 backend/src/modules/mail/images/mail-image-url.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image-url.ts create mode 100644 backend/src/modules/mail/images/mail-image.constants.ts create mode 100644 backend/src/modules/mail/images/mail-image.controller.spec.ts create mode 100644 backend/src/modules/mail/images/mail-image.controller.ts create mode 100644 backend/src/modules/mail/images/node-image-transport.spec.ts create mode 100644 backend/src/modules/mail/images/node-image-transport.ts create mode 100644 backend/src/modules/mail/jmap/envelope.spec.ts create mode 100644 backend/src/modules/mail/jmap/envelope.ts create mode 100644 backend/src/modules/mail/jmap/pending-send.spec.ts create mode 100644 backend/src/modules/mail/jmap/pending-send.ts create mode 100644 backend/src/modules/mail/mail-block.controller.ts create mode 100644 backend/src/modules/mail/mail-block.service.spec.ts create mode 100644 backend/src/modules/mail/mail-block.service.ts create mode 100644 backend/src/modules/mail/mail-blocks.spec.ts create mode 100644 backend/src/modules/mail/mail-blocks.ts create mode 100644 backend/src/modules/mail/scheduled-message.controller.ts create mode 100644 backend/src/modules/mail/scheduled-messages.service.spec.ts create mode 100644 backend/src/modules/mail/scheduled-messages.service.ts create mode 100644 backend/src/shared/utils/public-address.spec.ts create mode 100644 backend/src/shared/utils/public-address.ts diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index e8d95b3..5f22c7a 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -64,6 +64,19 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST); }); + it('MAIL_013 의 문구는 필터와 차단 목록(9단계)을 함께 말한다 — 같은 엔진 규칙의 한도라서', () => { + expect(ERROR_CODES.MAIL_013.message).toContain('필터'); + expect(ERROR_CODES.MAIL_013.message).toContain('차단'); + }); + + it('이미 보낸 · 기다리는 발송이 아닌 메일의 취소(MAIL_014)는 409 다 (9단계)', () => { + expect(ERROR_CODES.MAIL_014.status).toBe(HttpStatus.CONFLICT); + }); + + it('붙잡힌 메일을 옮기거나 지우기(MAIL_015)는 409 다 — 취소로만 보낸편지함을 떠난다 (9단계)', () => { + expect(ERROR_CODES.MAIL_015.status).toBe(HttpStatus.CONFLICT); + }); + it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => { expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT); expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 265b83a..9331cb8 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -155,11 +155,26 @@ const DOMAIN_ERROR_CODES = { message: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', }, - // 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기) + // 필터 · 부재중 응답 · 보낸 사람 차단(9단계)으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 + // (계약 2절 "메일 필터"의 크기 · "보낸 사람 차단"). 뜻은 6단계 그대로이고 문구만 차단 목록을 함께 말한다 MAIL_013: { status: HttpStatus.BAD_REQUEST, message: - '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', + }, + // 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다. + // 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송") + MAIL_014: { + status: HttpStatus.CONFLICT, + message: '이미 보낸 메일이라 취소할 수 없습니다.', + }, + // 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)의 보낸 사본을 옮기거나 지우려 했다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 + // (옮기거나 지워도 엔진은 시간이 되면 보낸다). 아무것도 바꾸지 않았다. 여러 통 처리는 이 코드 대신 그 id 를 failedIds · skippedIds 로 돌려준다 + // (계약 2절 "보내기 취소 · 예약 발송") + MAIL_015: { + status: HttpStatus.CONFLICT, + message: + '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', }, // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) diff --git a/backend/src/migrations/1793255372738-AddMailSettings.ts b/backend/src/migrations/1793255372738-AddMailSettings.ts new file mode 100644 index 0000000..375995a --- /dev/null +++ b/backend/src/migrations/1793255372738-AddMailSettings.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 보내기 · 읽기 설정 표 (docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" — 9단계). + * - mail_settings: 직원 주소마다 한 줄 — 보내기 취소 초(undo_send_seconds) · 외부 이미지(external_images). 없으면 처음 값(10 · ask) + * 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory)는 건드리지 않는다. + * (자동 생성: npm run migration:generate — 엔티티는 modules/mail-user-settings/mail-user-settings.entity.ts) + */ +export class AddMailSettings1793255372738 implements MigrationInterface { + name = 'AddMailSettings1793255372738'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "mail_settings" ("user_email" character varying(254) NOT NULL, "undo_send_seconds" smallint NOT NULL, "external_images" character varying(16) NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_b2ca6c2963fd9eca971baa44421" PRIMARY KEY ("user_email"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "mail_settings"`); + } +} diff --git a/backend/src/modules/contacts/contacts.service.spec.ts b/backend/src/modules/contacts/contacts.service.spec.ts index 3c65f50..381ba9c 100644 --- a/backend/src/modules/contacts/contacts.service.spec.ts +++ b/backend/src/modules/contacts/contacts.service.spec.ts @@ -323,6 +323,24 @@ describe('ContactsService (주소록)', () => { book.seedGroup('g1', '거래처', ['urn:uuid:c-pa', 'urn:uuid:gone']); }); + it('hasEmail — 내 주소록의 연락처 주소인지(대소문자 · 앞뒤 공백 무시), 판을 확인한 주소록에서 (9단계 외부 이미지)', async () => { + await expect( + service.hasEmail(ARA.email, ' jihoon@HANBIT.example '), + ).resolves.toBe(true); + await expect( + service.hasEmail(ARA.email, 'someone@hanbit.example'), + ).resolves.toBe(false); + + // 다른 곳에서 더한 연락처도 판이 바뀌었으니 바로 맞는다 + book.seedContact('c-new', { + emails: [{ address: 'someone@hanbit.example', label: 'work' }], + }); + book.touch(); + await expect( + service.hasEmail(ARA.email, 'someone@hanbit.example'), + ).resolves.toBe(true); + }); + it('이름 차례 · total 포함 — 연락처의 그룹은 이 주소록의 그룹만', async () => { const page = await service.list(ARA, listQuery()); diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index b27638b..a13e2ad 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -867,6 +867,16 @@ export class ContactsService { return (await this.freshBook(userEmail)).suggestionLines; } + /** + * 그 주소(대소문자 무시)의 연락처가 내 주소록에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 "외부 이미지 보기" — 9단계). + * 판을 확인한 주소록에서 본다 (연락처를 바꾸면 바로 맞는다) + */ + async hasEmail(userEmail: string, address: string): Promise { + return (await this.freshBook(userEmail)).contactsByEmail.has( + emailKey(address), + ); + } + /** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */ private async groupMemberEmails( userEmail: string, diff --git a/backend/src/modules/contacts/directory.service.spec.ts b/backend/src/modules/contacts/directory.service.spec.ts index e5abaaf..cfabcc2 100644 --- a/backend/src/modules/contacts/directory.service.spec.ts +++ b/backend/src/modules/contacts/directory.service.spec.ts @@ -90,6 +90,24 @@ describe('DirectoryService (사내 목록)', () => { afterEach(() => jest.restoreAllMocks()); + it('hasEmail — 사내 목록(직원 · 공용 주소)의 주소인지(대소문자 · 앞뒤 공백 무시) — 빼는 계정은 아니다, 같은 기억을 쓴다 (9단계 외부 이미지)', async () => { + await expect(service.hasEmail(' Doyun.Lee@AraSystem.test ')).resolves.toBe( + true, + ); + await expect(service.hasEmail('sales@arasystem.test')).resolves.toBe(true); + await expect(service.hasEmail('admin@arasystem.test')).resolves.toBe(false); + await expect(service.hasEmail('nobody@arasystem.test')).resolves.toBe( + false, + ); + expect(listAccounts).toHaveBeenCalledTimes(1); + + listAccounts.mockRejectedValue(new BusinessException('REQ_001')); + jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS); + expect( + (await failureOf(service.hasEmail('doyun.lee@arasystem.test'))).code, + ).toBe('MAIL_001'); + }); + it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => { const page = await service.list(query()); diff --git a/backend/src/modules/contacts/directory.service.ts b/backend/src/modules/contacts/directory.service.ts index ddba1f5..56f4118 100644 --- a/backend/src/modules/contacts/directory.service.ts +++ b/backend/src/modules/contacts/directory.service.ts @@ -18,6 +18,8 @@ import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy'; interface DirectoryListing { readonly entries: readonly DirectoryEntry[]; readonly byId: ReadonlyMap; + /** 주소(소문자) — 외부 이미지의 "사내 직원" 판정 (9단계) */ + readonly emails: ReadonlySet; readonly expiresAt: number; } @@ -101,6 +103,14 @@ export class DirectoryService { return pageOf(matched, start, query.limit, scope); } + /** + * 그 주소(대소문자 무시)가 사내 목록(직원 · 공용 주소)에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 — 9단계). + * 읽지 못하면 MAIL_001 (부르는 쪽이 없는 것으로 본다) + */ + async hasEmail(address: string): Promise { + return (await this.listing()).emails.has(address.trim().toLowerCase()); + } + /** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */ async findById(id: string): Promise { const [entry] = await this.findByIds([id]); @@ -132,6 +142,7 @@ export class DirectoryService { const listing: DirectoryListing = { entries, byId: new Map(entries.map((entry) => [entry.id, entry])), + emails: new Set(entries.map((entry) => entry.email.toLowerCase())), expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS, }; this.cached = listing; diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts new file mode 100644 index 0000000..e52d968 --- /dev/null +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts @@ -0,0 +1,49 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MailUserSettingsPatchDto } from './mail-user-settings.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */ +async function constraintsOf(body: object): Promise { + const errors = await validate( + plainToInstance(MailUserSettingsPatchDto, body), + { + whitelist: true, + forbidNonWhitelisted: true, + }, + ); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', () => { + it('고를 수 있는 값이면 통과한다 — 한 칸만 보내도 된다 (빈 본문은 서비스가 VAL_001)', async () => { + for (const undoSendSeconds of [0, 5, 10, 20, 30]) { + expect(await constraintsOf({ undoSendSeconds })).toEqual([]); + } + for (const externalImages of ['ask', 'trusted', 'always']) { + expect(await constraintsOf({ externalImages })).toEqual([]); + } + expect( + await constraintsOf({ undoSendSeconds: 20, externalImages: 'trusted' }), + ).toEqual([]); + expect(await constraintsOf({})).toEqual([]); + }); + + it('고를 수 없는 초 · 글로 쓴 초 · null 은 형식 오류', async () => { + for (const undoSendSeconds of [7, -1, 31, 10.5, '10', null, true]) { + expect(await constraintsOf({ undoSendSeconds })).toEqual(['isIn']); + } + }); + + it('모르는 외부 이미지 값 · 대문자 · null 은 형식 오류', async () => { + for (const externalImages of ['never', 'ASK', '', null, 1]) { + expect(await constraintsOf({ externalImages })).toEqual(['isIn']); + } + }); + + it('모르는 칸은 받지 않는다', async () => { + expect(await constraintsOf({ theme: 'dark' })).toEqual([ + 'whitelistValidation', + ]); + }); +}); diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts new file mode 100644 index 0000000..73f5850 --- /dev/null +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, ValidateIf } from 'class-validator'; + +import { + EXTERNAL_IMAGE_MODES, + UNDO_SEND_SECONDS_CHOICES, + type ExternalImageMode, + type MailUserSettings, + type MailUserSettingsPatch, + type UndoSendSeconds, +} from '@/modules/mail-user-settings/mail-user-settings.types'; + +/** 보낸 칸만 검사한다 — 보내지 않은 칸(undefined)은 그대로 둔다. null 은 형식 오류다 (IsOptional 은 null 도 건너뛴다) */ +const whenSent = (_object: object, value: unknown) => value !== undefined; + +const UNDO_DESCRIPTION = + `보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` + + '예약 발송에는 더하지 않는다'; +const IMAGES_DESCRIPTION = + '외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' + + '스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)'; + +/** GET · PATCH /api/mail-settings 의 응답 — 계약 2절 MailSettings */ +export class MailUserSettingsDto implements MailUserSettings { + @ApiProperty({ + description: UNDO_DESCRIPTION, + enum: [...UNDO_SEND_SECONDS_CHOICES], + example: 10, + }) + undoSendSeconds!: UndoSendSeconds; + + @ApiProperty({ + description: IMAGES_DESCRIPTION, + enum: [...EXTERNAL_IMAGE_MODES], + example: 'ask', + }) + externalImages!: ExternalImageMode; +} + +/** + * PATCH /api/mail-settings 본문 — 바꿀 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다). + * 고를 수 있는 값이 아니면(문자열 "10" · null 포함) 400 VAL_001 + */ +export class MailUserSettingsPatchDto implements MailUserSettingsPatch { + @ApiPropertyOptional({ + description: UNDO_DESCRIPTION, + enum: [...UNDO_SEND_SECONDS_CHOICES], + example: 20, + }) + @ValidateIf(whenSent) + @IsIn(UNDO_SEND_SECONDS_CHOICES, { + message: `보내기 취소 시간은 ${UNDO_SEND_SECONDS_CHOICES.join(' · ')}초 가운데에서 골라 주세요.`, + }) + undoSendSeconds?: UndoSendSeconds; + + @ApiPropertyOptional({ + description: IMAGES_DESCRIPTION, + enum: [...EXTERNAL_IMAGE_MODES], + example: 'trusted', + }) + @ValidateIf(whenSent) + @IsIn(EXTERNAL_IMAGE_MODES, { + message: '외부 이미지 설정을 다시 골라 주세요.', + }) + externalImages?: ExternalImageMode; +} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts new file mode 100644 index 0000000..b6ea0fa --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + MailUserSettingsDto, + MailUserSettingsPatchDto, +} from './dto/mail-user-settings.dto'; +import { MailUserSettingsService } from './mail-user-settings.service'; + +/** 보내기 · 읽기 설정 라우트 (`/api/mail-settings`) — 계약 2절 "보내기 · 읽기 설정" (9단계) */ +export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings'; + +// 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정", 9단계) — 서버 DB 에 둔다(어느 PC 에서나 같다) +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(MAIL_USER_SETTINGS_ROUTE) +export class MailUserSettingsController { + constructor(private readonly settings: MailUserSettingsService) {} + + @Get() + @ApiOperation({ + summary: '보내기 · 읽기 설정', + description: + '저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.', + }) + @ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' }) + get(@CurrentUser() user: AuthenticatedUser) { + return this.settings.get(user.email); + } + + @Patch() + @ApiOperation({ + summary: '보내기 · 읽기 설정 바꾸기 (보낸 칸만)', + description: + '바꿀 칸만 보낸다(하나 이상). 보내지 않은 칸은 그대로다 — 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다. 바꾼 뒤의 설정 전체를 돌려준다.', + }) + @ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' }) + @ApiErrorResponse( + 'VAL_001', + '빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드', + ) + update( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailUserSettingsPatchDto, + ) { + return this.settings.update(user.email, { + undoSendSeconds: dto.undoSendSeconds, + externalImages: dto.externalImages, + }); + } +} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts new file mode 100644 index 0000000..291139d --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts @@ -0,0 +1,37 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; + +/** 외부 이미지 설정 값의 최대 길이 — 'trusted' 가 가장 길다 (여유를 둔다) */ +const EXTERNAL_IMAGES_COLUMN_LENGTH = 16; + +/** + * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원 주소마다 한 줄. 없으면 처음 값(10초 · 늘 묻기)이다. + * 메일 엔진이 아니라 우리 DB 에 둔다 — 어느 PC 에서나 같고, 엔진을 바꿔도 남는다. + * 값은 서비스가 검사한 것만 들어간다 (mail-user-settings.types.ts) + */ +@Entity({ name: 'mail_settings' }) +export class MailUserSettingsEntity { + @PrimaryColumn({ + name: 'user_email', + type: 'varchar', + length: EMAIL_COLUMN_LENGTH, + }) + userEmail!: string; + + /** 보내기 취소를 기다리는 초 — 0 · 5 · 10 · 20 · 30 */ + @Column({ name: 'undo_send_seconds', type: 'smallint' }) + undoSendSeconds!: number; + + /** 외부 이미지 — ask · trusted · always */ + @Column({ + name: 'external_images', + type: 'varchar', + length: EXTERNAL_IMAGES_COLUMN_LENGTH, + }) + externalImages!: string; + + @Column({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.module.ts b/backend/src/modules/mail-user-settings/mail-user-settings.module.ts new file mode 100644 index 0000000..e8a03e2 --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { MailUserSettingsController } from './mail-user-settings.controller'; +import { MailUserSettingsEntity } from './mail-user-settings.entity'; +import { MailUserSettingsRepository } from './mail-user-settings.repository'; +import { MailUserSettingsService } from './mail-user-settings.service'; + +/** + * 보내기 · 읽기 설정 모듈 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다의 설정을 서버 DB(`mail_settings`)에 둔다. + * 메일 모듈이 발송(보내기 취소 초) · 메일 읽기(외부 이미지)에 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다) + */ +@Module({ + imports: [TypeOrmModule.forFeature([MailUserSettingsEntity])], + controllers: [MailUserSettingsController], + providers: [MailUserSettingsRepository, MailUserSettingsService], + exports: [MailUserSettingsService], +}) +export class MailUserSettingsModule {} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts new file mode 100644 index 0000000..b0e40ec --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { MailUserSettingsEntity } from './mail-user-settings.entity'; +import type { + MailUserSettings, + MailUserSettingsPatch, +} from './mail-user-settings.types'; + +/** 바꿀 수 있는 칸 → 표의 열 이름 */ +const COLUMNS: Readonly> = { + undoSendSeconds: 'undo_send_seconds', + externalImages: 'external_images', +}; + +/** RETURNING 한 줄 (열 이름이 키다) */ +interface ReturnedRow { + readonly user_email: string; + readonly undo_send_seconds: number; + readonly external_images: string; + readonly updated_at: Date; +} + +/** + * 보내기 · 읽기 설정 표(`mail_settings`) 접근 — 값의 검사 · 처음 값은 MailUserSettingsService 가 정한다. + */ +@Injectable() +export class MailUserSettingsRepository { + constructor( + @InjectRepository(MailUserSettingsEntity) + private readonly rows: Repository, + ) {} + + find(userEmail: string): Promise { + return this.rows.findOneBy({ userEmail }); + } + + /** + * 보낸 칸만 바꾼다 — 한 문장(INSERT … ON CONFLICT DO UPDATE SET <보낸 칸>)이라 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다. + * 줄이 없으면 보내지 않은 칸은 `base`(처음 값)로 넣는다. + * @returns 저장한 뒤의 줄 + */ + async upsert( + userEmail: string, + patch: MailUserSettingsPatch, + base: MailUserSettings, + now: Date, + ): Promise { + const changed = (Object.keys(COLUMNS) as (keyof MailUserSettings)[]) + .filter((key) => patch[key] !== undefined) + .map((key) => COLUMNS[key]); + const result = await this.rows + .createQueryBuilder() + .insert() + .into(MailUserSettingsEntity) + .values({ + userEmail, + undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds, + externalImages: patch.externalImages ?? base.externalImages, + updatedAt: now, + }) + .orUpdate([...changed, 'updated_at'], ['user_email']) + // 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING 열 이름 (행의 키는 열 이름) + .returning([ + 'userEmail', + 'undoSendSeconds', + 'externalImages', + 'updatedAt', + ]) + .execute(); + const [row] = (result.raw ?? []) as ReturnedRow[]; + const saved = new MailUserSettingsEntity(); + saved.userEmail = row?.user_email ?? userEmail; + saved.undoSendSeconds = Number(row?.undo_send_seconds); + saved.externalImages = row?.external_images ?? ''; + saved.updatedAt = row?.updated_at ?? now; + return saved; + } +} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts new file mode 100644 index 0000000..463a047 --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts @@ -0,0 +1,218 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MailUserSettingsEntity } from './mail-user-settings.entity'; +import type { MailUserSettingsRepository } from './mail-user-settings.repository'; +import { + MAIL_USER_SETTINGS_CACHE_MAX_USERS, + MAIL_USER_SETTINGS_CACHE_TTL_MS, + MailUserSettingsService, +} from './mail-user-settings.service'; +import { + DEFAULT_MAIL_USER_SETTINGS, + type MailUserSettings, + type MailUserSettingsPatch, +} from './mail-user-settings.types'; + +const ARA = 'ara.kim@arasystem.test'; + +function row( + userEmail: string, + undoSendSeconds: number, + externalImages: string, +): MailUserSettingsEntity { + const entity = new MailUserSettingsEntity(); + entity.userEmail = userEmail; + entity.undoSendSeconds = undoSendSeconds; + entity.externalImages = externalImages; + entity.updatedAt = new Date('2026-10-29T00:00:00.000Z'); + return entity; +} + +/** 대역 표 — 직원마다 한 줄 */ +function fakeRepository() { + const rows = new Map(); + const find = jest.fn((userEmail: string) => + Promise.resolve(rows.get(userEmail) ?? null), + ); + const upsert = jest.fn( + ( + userEmail: string, + patch: MailUserSettingsPatch, + base: MailUserSettings, + ) => { + const current = rows.get(userEmail); + const saved = row( + userEmail, + patch.undoSendSeconds ?? + current?.undoSendSeconds ?? + base.undoSendSeconds, + patch.externalImages ?? current?.externalImages ?? base.externalImages, + ); + rows.set(userEmail, saved); + return Promise.resolve(saved); + }, + ); + return { + rows, + find, + upsert, + repository: { find, upsert } as unknown as MailUserSettingsRepository, + }; +} + +async function failureOf( + run: () => Promise, +): Promise<{ code?: string; reason?: unknown }> { + try { + await run(); + } catch (error) { + return error instanceof BusinessException + ? { code: error.code, reason: error.context?.reason } + : { code: 'NOT_BUSINESS' }; + } + return {}; +} + +describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다', async () => { + const fake = fakeRepository(); + const service = new MailUserSettingsService(fake.repository); + + await expect(service.get(ARA)).resolves.toEqual({ + undoSendSeconds: 10, + externalImages: 'ask', + }); + expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({ + undoSendSeconds: 10, + externalImages: 'ask', + }); + expect(fake.upsert).not.toHaveBeenCalled(); + }); + + it('표의 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다', async () => { + const fake = fakeRepository(); + fake.rows.set(ARA, row(ARA, 7, 'always')); + fake.rows.set('b@arasystem.test', row('b@arasystem.test', 30, 'never')); + const service = new MailUserSettingsService(fake.repository); + + await expect(service.get(ARA)).resolves.toEqual({ + undoSendSeconds: 10, + externalImages: 'always', + }); + await expect(service.get('b@arasystem.test')).resolves.toEqual({ + undoSendSeconds: 30, + externalImages: 'ask', + }); + }); + + it('1분 기억한다 — 주소의 대소문자 · 앞뒤 공백과 상관없이, 지나면 다시 읽는다', async () => { + jest.useFakeTimers({ now: Date.parse('2026-10-29T00:00:00.000Z') }); + const fake = fakeRepository(); + fake.rows.set(ARA, row(ARA, 20, 'trusted')); + const service = new MailUserSettingsService(fake.repository); + + await service.get(ARA); + await service.get(` ${ARA.toUpperCase()} `); + expect(fake.find).toHaveBeenCalledTimes(1); + + jest.setSystemTime(Date.now() + MAIL_USER_SETTINGS_CACHE_TTL_MS); + await service.get(ARA); + expect(fake.find).toHaveBeenCalledTimes(2); + }); + + it('바꾸기는 보낸 칸만 — 저장한 값을 돌려주고 바로 기억에 넣는다 (다시 읽지 않는다), 로그에는 가린 주소', async () => { + const fake = fakeRepository(); + fake.rows.set(ARA, row(ARA, 20, 'trusted')); + const service = new MailUserSettingsService(fake.repository); + await service.get(ARA); + + await expect( + service.update(ARA, { externalImages: 'always' }), + ).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' }); + expect(fake.upsert).toHaveBeenCalledWith( + ARA, + { externalImages: 'always' }, + DEFAULT_MAIL_USER_SETTINGS, + expect.any(Date), + ); + await expect(service.get(ARA)).resolves.toEqual({ + undoSendSeconds: 20, + externalImages: 'always', + }); + expect(fake.find).toHaveBeenCalledTimes(1); + + const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n'); + expect(lines).toContain('외부 이미지 always'); + expect(lines).not.toContain(ARA); + }); + + it('빈 바꾸기는 VAL_001 — 저장하지 않는다', async () => { + const fake = fakeRepository(); + const service = new MailUserSettingsService(fake.repository); + + expect(await failureOf(() => service.update(ARA, {}))).toEqual({ + code: 'VAL_001', + reason: 'empty-mail-settings-patch', + }); + expect(fake.upsert).not.toHaveBeenCalled(); + }); + + it('읽는 사이 이 서버에서 바꿨으면 늦게 끝난 옛 읽기가 바꾼 값을 덮지 않는다', async () => { + const fake = fakeRepository(); + fake.rows.set(ARA, row(ARA, 10, 'ask')); + let release!: () => void; + fake.find.mockImplementationOnce( + (userEmail: string) => + new Promise((resolve) => { + const old = fake.rows.get(userEmail) ?? null; + release = () => resolve(old); + }), + ); + const service = new MailUserSettingsService(fake.repository); + + const reading = service.get(ARA); + await service.update(ARA, { undoSendSeconds: 30 }); + release(); + + await expect(reading).resolves.toEqual({ + undoSendSeconds: 30, + externalImages: 'ask', + }); + await expect(service.get(ARA)).resolves.toEqual({ + undoSendSeconds: 30, + externalImages: 'ask', + }); + expect(fake.find).toHaveBeenCalledTimes(1); + }); + + it(`기억하는 직원은 ${MAIL_USER_SETTINGS_CACHE_MAX_USERS}명까지 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다`, async () => { + const fake = fakeRepository(); + const service = new MailUserSettingsService(fake.repository); + + await service.get('first@arasystem.test'); + for (let i = 0; i < MAIL_USER_SETTINGS_CACHE_MAX_USERS; i += 1) { + await service.get(`u${i}@arasystem.test`); + } + fake.find.mockClear(); + + await service.get( + `u${MAIL_USER_SETTINGS_CACHE_MAX_USERS - 1}@arasystem.test`, + ); + expect(fake.find).not.toHaveBeenCalled(); + await service.get('first@arasystem.test'); + expect(fake.find).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts new file mode 100644 index 0000000..bd13302 --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import type { MailUserSettingsEntity } from './mail-user-settings.entity'; +import { MailUserSettingsRepository } from './mail-user-settings.repository'; +import { + DEFAULT_MAIL_USER_SETTINGS, + isExternalImageMode, + isUndoSendSeconds, + type MailUserSettings, + type MailUserSettingsPatch, +} from './mail-user-settings.types'; + +/** 직원마다 설정을 기억하는 시간 — 발송 · 메일 읽기마다 DB 를 묻지 않게. 이 서버에서 바꾸면 바로 맞춘다 */ +export const MAIL_USER_SETTINGS_CACHE_TTL_MS = 60_000; +/** 기억하는 직원 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 (한 사람에 몇십 바이트) */ +export const MAIL_USER_SETTINGS_CACHE_MAX_USERS = 1_000; + +interface CacheEntry { + readonly value: MailUserSettings; + readonly expiresAt: number; + /** 이 값을 넣은 차례 — 그보다 앞서 시작한 읽기는 이 값을 덮지 않는다 */ + readonly sequence: number; +} + +function cacheKey(userEmail: string): string { + return userEmail.trim().toLowerCase(); +} + +/** 표의 줄 → 설정 — 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다 */ +function toSettings(row: MailUserSettingsEntity | null): MailUserSettings { + if (row === null) return DEFAULT_MAIL_USER_SETTINGS; + return { + undoSendSeconds: isUndoSendSeconds(row.undoSendSeconds) + ? row.undoSendSeconds + : DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds, + externalImages: isExternalImageMode(row.externalImages) + ? row.externalImages + : DEFAULT_MAIL_USER_SETTINGS.externalImages, + }; +} + +/** + * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다. + * - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다 + * - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다 + * + * ⚠️ 기억은 프로세스 메모리다. 인스턴스를 늘리면 다른 인스턴스는 바꾼 값을 1분까지 늦게 본다 (보내기 취소 시간 · 외부 이미지가 1분 늦게 바뀔 뿐 — 틀린 값을 저장하지는 않는다) + */ +@Injectable() +export class MailUserSettingsService { + private readonly logger = new Logger(MailUserSettingsService.name); + private readonly cache = new Map(); + /** 기억에 넣은 차례 — 읽는 사이 바뀐 값을 옛 읽기가 덮지 않게 */ + private sequence = 0; + + constructor(private readonly repository: MailUserSettingsRepository) {} + + async get(userEmail: string): Promise { + const key = cacheKey(userEmail); + const cached = this.cache.get(key); + if (cached && cached.expiresAt > Date.now()) { + // 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다 + this.cache.delete(key); + this.cache.set(key, cached); + return cached.value; + } + + const startedAt = this.sequence; + const value = toSettings(await this.repository.find(userEmail)); + const latest = this.cache.get(key); + // 읽는 사이 이 서버에서 바꿨으면 그 값이 더 새롭다 — 덮지 않고 그 값을 준다 + if (latest && latest.sequence > startedAt) return latest.value; + this.remember(key, value); + return value; + } + + async update( + userEmail: string, + patch: MailUserSettingsPatch, + ): Promise { + if ( + patch.undoSendSeconds === undefined && + patch.externalImages === undefined + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'empty-mail-settings-patch' }, + }); + } + const saved = toSettings( + await this.repository.upsert( + userEmail, + patch, + DEFAULT_MAIL_USER_SETTINGS, + new Date(), + ), + ); + this.remember(cacheKey(userEmail), saved); + this.logger.log( + `${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`, + ); + return saved; + } + + private remember(key: string, value: MailUserSettings): void { + const now = Date.now(); + for (const [other, entry] of this.cache) { + if (entry.expiresAt <= now) this.cache.delete(other); + } + this.sequence += 1; + this.cache.delete(key); + this.cache.set(key, { + value, + expiresAt: now + MAIL_USER_SETTINGS_CACHE_TTL_MS, + sequence: this.sequence, + }); + while (this.cache.size > MAIL_USER_SETTINGS_CACHE_MAX_USERS) { + const oldest = this.cache.keys().next().value; + if (oldest === undefined) break; + this.cache.delete(oldest); + } + } +} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts new file mode 100644 index 0000000..90e6fde --- /dev/null +++ b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts @@ -0,0 +1,40 @@ +/** + * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다 한 줄, 서버 DB(`mail_settings`)에 둔다. + * 메일 엔진 연결 설정(modules/mail/mail.settings.ts 의 MailSettings)과는 다르다 — 이름이 겹치지 않게 MailUserSettings 로 부른다. + */ + +/** 보내기 취소를 기다리는 초 — 0 이면 바로 보낸다 */ +export const UNDO_SEND_SECONDS_CHOICES = [0, 5, 10, 20, 30] as const; +export type UndoSendSeconds = (typeof UNDO_SEND_SECONDS_CHOICES)[number]; + +/** + * 외부 이미지 — ask 늘 묻기 · trusted 보낸 사람이 내 주소록 · 사내 목록에 있으면 늘 보기 · always 늘 보기. + * 스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다) + */ +export const EXTERNAL_IMAGE_MODES = ['ask', 'trusted', 'always'] as const; +export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number]; + +export interface MailUserSettings { + readonly undoSendSeconds: UndoSendSeconds; + readonly externalImages: ExternalImageMode; +} + +/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */ +export type MailUserSettingsPatch = Partial; + +/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */ +export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({ + undoSendSeconds: 10, + externalImages: 'ask', +}); + +/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */ +export function isUndoSendSeconds(value: unknown): value is UndoSendSeconds { + return (UNDO_SEND_SECONDS_CHOICES as readonly unknown[]).includes(value); +} + +export function isExternalImageMode( + value: unknown, +): value is ExternalImageMode { + return (EXTERNAL_IMAGE_MODES as readonly unknown[]).includes(value); +} diff --git a/backend/src/modules/mail/dto/mail-block.dto.spec.ts b/backend/src/modules/mail/dto/mail-block.dto.spec.ts new file mode 100644 index 0000000..b60e828 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-block.dto.spec.ts @@ -0,0 +1,62 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants'; +import { MailBlockCreateDto } from './mail-block.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */ +async function constraintsOf(body: object): Promise { + const errors = await validate(plainToInstance(MailBlockCreateDto, body), { + whitelist: true, + forbidNonWhitelisted: true, + }); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('보낸 사람 차단 본문 (POST /api/mail-blocks — 9단계)', () => { + it('주소 · @도메인 — 앞뒤 공백은 빼고 본다 (막을 수 없는 대상은 서비스가 본다)', async () => { + for (const address of [ + 'kim@spam.example', + ' Kim@Spam.Example ', + '@spam.example', + '@xn--vg1b002a.example', + '@arasystem.test', + ]) { + expect(await constraintsOf({ address })).toEqual([]); + } + }); + + it('검사 차례는 형식 → 길이 → 모양', async () => { + expect(await constraintsOf({ address: 42 })).toEqual([ + 'isString', + 'maxCodeUnits', + 'isBlockTarget', + ]); + expect( + await constraintsOf({ + address: `${'a'.repeat(MAX_EMAIL_LENGTH)}@spam.example`, + }), + ).toEqual(['maxCodeUnits', 'isBlockTarget']); + }); + + it.each([ + ['빈 글', ' '], + ['@ 만', '@'], + ['점 없는 도메인', '@localhost'], + ['IP 주소', '@127.0.0.1'], + ['숫자 최상위 도메인', '@spam.123'], + ['밑줄 도메인', '@spam_ads.example'], + ['한글 도메인(punycode 로)', '@스팸.example'], + ['${ 가 든 주소', '${x}@spam.example'], + ['주소 모양이 아님', 'kim at spam.example'], + ])('모양이 틀리면 형식 오류 — %s', async (_label, address) => { + expect(await constraintsOf({ address })).toEqual(['isBlockTarget']); + }); + + it('주소가 없거나 모르는 칸이 있으면 형식 오류', async () => { + expect(await constraintsOf({})).not.toEqual([]); + expect( + await constraintsOf({ address: 'kim@spam.example', note: '메모' }), + ).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/mail/dto/mail-block.dto.ts b/backend/src/modules/mail/dto/mail-block.dto.ts new file mode 100644 index 0000000..e1c001e --- /dev/null +++ b/backend/src/modules/mail/dto/mail-block.dto.ts @@ -0,0 +1,98 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, ValidateBy } from 'class-validator'; + +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { parseBlockTarget } from '@/modules/mail/mail-blocks'; +import { + MAX_EMAIL_LENGTH, + MAX_MAIL_BLOCKS, +} from '@/modules/mail/mail.constants'; +import type { BlockedSender, MailBlockList } from '@/modules/mail/mail.types'; + +/** + * 보낸 사람 차단 — POST 본문과 응답 (계약 2절 "보낸 사람 차단" — 9단계). + * 여기서는 모양만 본다(주소는 발송의 주소와 같은 규칙 · 도메인은 `@` + 호스트 이름 — mail-blocks.ts). 내 주소 · 사내 도메인 · + * 여러 사람이 쓰는 메일 서비스의 도메인 · 500개는 서비스 · 어댑터가 본다 (모두 400 VAL_001). + * 검사 차례(applyDecorators — 적은 차례대로): 형식 → 길이 → 모양 + */ + +/** 차단할 주소 · @도메인의 모양 */ +function IsBlockTarget() { + return ValidateBy( + { + name: 'isBlockTarget', + validator: { + validate: (value: unknown) => + typeof value === 'string' && parseBlockTarget(value) !== null, + }, + }, + { message: '메일 주소나 @도메인 형식으로 써 주세요.' }, + ); +} + +function BlockAddress(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }), + MaxCodeUnits(MAX_EMAIL_LENGTH, { + message: `차단할 주소는 ${MAX_EMAIL_LENGTH}자 이하로 써 주세요.`, + }), + IsBlockTarget(), + ); +} + +/** POST /api/mail-blocks 본문 */ +export class MailBlockCreateDto { + @ApiProperty({ + description: + `차단할 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두, 하위 도메인은 따로) — 앞뒤 공백을 빼고 ${MAX_EMAIL_LENGTH}자까지, 소문자로 맞춰 저장한다. ` + + '내 주소 · 사내 도메인(그 하위 포함)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인으로 막을 수 없다(주소로는 된다)', + example: '@spam.example', + maxLength: MAX_EMAIL_LENGTH, + }) + @BlockAddress() + address!: string; +} + +/** 계약 2절 BlockedSender */ +export class BlockedSenderDto implements BlockedSender { + @ApiProperty({ + description: + '소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example")', + example: 'kim@spam.example', + }) + address!: string; + + @ApiProperty({ + description: '차단한 시각 (ISO 8601 UTC)', + example: '2026-10-29T06:00:00.000Z', + }) + blockedAt!: string; +} + +/** 계약 2절 MailBlockList — GET /api/mail-blocks */ +export class MailBlockListDto implements MailBlockList { + @ApiProperty({ + type: [BlockedSenderDto], + description: `최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지`, + }) + senders!: BlockedSenderDto[]; + + @ApiProperty({ + description: + '엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다. false 면 지금은 차단도 동작하지 않는다(필터 · 부재중 · 차단 가운데 무엇이든 저장하면 다시 켜진다)', + example: true, + }) + isActive!: boolean; +} + +/** DELETE /api/mail-blocks/{address} 의 응답 */ +export class MailBlockRemovedDto { + @ApiProperty({ + description: '차단을 푼 주소 · 도메인 (소문자로 맞춘 값)', + example: 'kim@spam.example', + }) + address!: string; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 4cb8cc9..3fb2656 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -21,6 +21,8 @@ import type { MessageDetail, MessagesPatchResult, MessageSummary, + PendingSend, + PendingSendKind, RecipientRejectionReason, RecipientSuggestion, RejectedRecipient, @@ -215,6 +217,25 @@ export class MailQuotaDto implements MailQuota { attachmentTotalMaxBytes!: number; } +const PENDING_SEND_KINDS: PendingSendKind[] = ['undo', 'scheduled']; + +/** 계약 2절 "보내기 취소 · 예약 발송"의 PendingSend — 엔진이 붙잡아 둔 발송 (9단계) */ +export class PendingSendDto implements PendingSend { + @ApiProperty({ + description: + '엔진이 보낼 시각 (ISO 8601 UTC) — 클라이언트는 보내기 취소의 남은 시간을 설정한 초가 아니라 이 시각으로 센다', + example: '2026-10-30T00:00:00.000Z', + }) + sendAt!: string; + + @ApiProperty({ + description: 'undo = 보내기 취소를 기다리는 중 · scheduled = 예약 발송', + enum: PENDING_SEND_KINDS, + example: 'scheduled', + }) + kind!: PendingSendKind; +} + export class MessageSummaryDto implements MessageSummary { @ApiProperty({ description: '메일 id', example: 'eaaaaab' }) id!: string; @@ -270,6 +291,14 @@ export class MessageSummaryDto implements MessageSummary { @ApiProperty({ description: '크기 (바이트)', example: 20480 }) size!: number; + + @ApiProperty({ + description: + '아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약 — 엔진의 기다리는 제출로 정한다)이면 그 발송, 그 밖에는 null (9단계)', + type: PendingSendDto, + nullable: true, + }) + pendingSend!: PendingSendDto | null; } export class MessageDetailDto @@ -324,11 +353,19 @@ export class MessageDetailDto textBody!: string | null; @ApiProperty({ - description: '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내', + description: + '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내. 보이게 했으면(images=show 또는 설정) false', example: false, }) hasBlockedImages!: boolean; + @ApiProperty({ + description: + '외부 이미지를 중계 주소(/api/mail-images/{token})로 바꿔 넣었으면 true — images=show 또는 설정(externalImages)으로 (9단계 "외부 이미지 보기")', + example: false, + }) + imagesShown!: boolean; + @ApiProperty({ description: 'HTML 이 있었지만 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true → 서식 없이 textBody 를 보여 주고 안내한다 (textBody 도 없으면 표시할 수 없음). ' + @@ -400,6 +437,40 @@ export class SentMessageDto implements SentMessage { type: [RejectedRecipientDto], }) rejectedRecipients!: RejectedRecipientDto[]; + + @ApiProperty({ + description: + '엔진이 붙잡아 둔 발송이면 그 정보 — 보내기 취소(설정의 초만큼) · 예약(sendAt). 바로 보냈으면 null (9단계)', + type: PendingSendDto, + nullable: true, + }) + pendingSend!: PendingSendDto | null; +} + +/** GET /api/scheduled-messages 의 응답 — 예약한 메일, 보낼 시각 차례 (9단계) */ +export class ScheduledMessagePageDto { + @ApiProperty({ + type: [MessageSummaryDto], + description: + '예약한 메일(pendingSend.kind 가 scheduled 이고 아직 보내지 않은 것) — 보낼 시각 차례(같으면 id 차례). 보낸 메일은 저절로 빠진다', + }) + items!: MessageSummaryDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: 'total 포함 — 메일함 패널의 「예약됨」 통 수', + }) + pageInfo!: PageInfoDto; +} + +/** DELETE /api/scheduled-messages/{id} 의 응답 — 취소한 발송을 되돌린 임시 저장본 (9단계) */ +export class ScheduledMessageCanceledDto { + @ApiProperty({ + description: + '임시 저장본 id — 보낸 사본과 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다', + example: 'maaaaad', + }) + draftId!: string; } /** 계약 2절 "새 메일 확인"의 MailState */ diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts index 4143f2a..e220bca 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts @@ -38,7 +38,8 @@ export function isUtcDateTime(value: unknown): boolean { ); } -function IsUtcDateTime(options: ValidationOptions) { +/** ISO 8601 UTC 시각 검사 — 부재중 기간 · 예약 발송 시각(send-message.dto.ts — 9단계)이 같이 쓴다 */ +export function IsUtcDateTime(options: ValidationOptions) { return ValidateBy( { name: 'isUtcDateTime', validator: { validate: isUtcDateTime } }, options, diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts new file mode 100644 index 0000000..0ef15c5 --- /dev/null +++ b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts @@ -0,0 +1,27 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MessageDetailQueryDto } from './message-detail-query.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고) */ +async function constraintsOf(query: object): Promise { + const errors = await validate(plainToInstance(MessageDetailQueryDto, query), { + whitelist: true, + forbidNonWhitelisted: true, + }); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 images)', () => { + it('images 는 없거나 show 만', async () => { + expect(await constraintsOf({})).toEqual([]); + expect(await constraintsOf({ images: 'show' })).toEqual([]); + }); + + it('모르는 값 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => { + for (const images of ['SHOW', '1', 'true', '']) { + expect(await constraintsOf({ images })).toEqual(['isIn']); + } + expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.ts b/backend/src/modules/mail/dto/message-detail-query.dto.ts new file mode 100644 index 0000000..6c24fb1 --- /dev/null +++ b/backend/src/modules/mail/dto/message-detail-query.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */ +export const SHOW_IMAGES = 'show'; + +/** + * GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도). + * 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001 + */ +export class MessageDetailQueryDto { + @ApiPropertyOptional({ + description: + 'show — 외부 이미지를 보이는 본문(imagesShown: true · hasBlockedImages: false). 없으면 설정(ask 지우기 · trusted 주소록 · 사내 목록의 보낸 사람 · always 늘)을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다', + enum: [SHOW_IMAGES], + example: SHOW_IMAGES, + }) + @IsOptional() + @IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' }) + images?: typeof SHOW_IMAGES; +} diff --git a/backend/src/modules/mail/dto/send-message.dto.spec.ts b/backend/src/modules/mail/dto/send-message.dto.spec.ts index d85557a..2b17cd4 100644 --- a/backend/src/modules/mail/dto/send-message.dto.spec.ts +++ b/backend/src/modules/mail/dto/send-message.dto.spec.ts @@ -94,3 +94,34 @@ describe('SendMessageDto 첨부 목록', () => { ).toContain('arrayMaxSize'); }); }); + +describe('SendMessageDto 예약 시각 (sendAt — 9단계)', () => { + const message = (sendAt: unknown) => ({ + to: [{ name: null, email: 'doyun.lee@arasystem.test' }], + subject: '제목', + body: '본문', + sendAt, + }); + + it('없거나 null 이면(보내기 취소 설정대로) · ISO 8601 UTC 시각이면 형식은 맞다 — 범위(5분 뒤 ~ 7일)는 서비스가 본다', async () => { + expect(await constraintsOf(message(undefined))).toEqual([]); + expect(await constraintsOf(message(null))).toEqual([]); + expect(await constraintsOf(message('2030-01-01T09:00:00.000Z'))).toEqual( + [], + ); + expect(await constraintsOf(message('2030-01-01T09:00:00Z'))).toEqual([]); + }); + + it('시간대 오프셋 · 날짜만 · 없는 날짜 · 소문자 z · 글이 아님은 형식 오류', async () => { + for (const sendAt of [ + '2030-01-01T18:00:00+09:00', + '2030-01-01', + '2030-02-30T09:00:00.000Z', + '2030-01-01T09:00:00.000z', + '내일 아침', + 1893488400000, + ]) { + expect(await constraintsOf(message(sendAt))).not.toEqual([]); + } + }); +}); diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts index bf91134..7d25961 100644 --- a/backend/src/modules/mail/dto/send-message.dto.ts +++ b/backend/src/modules/mail/dto/send-message.dto.ts @@ -17,6 +17,7 @@ import { MAX_ATTACHMENT_LIST_LENGTH, MAX_ATTACHMENTS_PER_MESSAGE, } from '@/modules/mail/attachment.policy'; +import { IsUtcDateTime } from '@/modules/mail/dto/mail-vacation.dto'; import { MAIL_ID_PATTERN, MAX_BODY_HTML_LENGTH, @@ -188,6 +189,18 @@ export class SendMessageDto extends ComposeContentDto { @IsOptional() @Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' }) draftId?: string; + + @ApiPropertyOptional({ + description: + '예약 발송 시각 (ISO 8601 UTC — 소수 초는 버린다) — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안(엔진 한도). 밖이면 400 VAL_001. ' + + '메일 서버가 그때까지 붙잡았다가 보낸다(창을 닫아도 보내진다 — 예약에는 보내기 취소 시간을 더하지 않는다). 없거나 null 이면 그 직원 설정의 보내기 취소 초만큼 붙잡는다 (9단계)', + example: '2026-10-30T00:00:00.000Z', + nullable: true, + }) + @IsOptional() + @IsString({ message: '예약 시각 형식이 올바르지 않습니다.' }) + @IsUtcDateTime({ message: '예약 시각 형식이 올바르지 않습니다.' }) + sendAt?: string | null; } /** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */ diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts index 6e9520b..bf6f2b7 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts @@ -110,6 +110,7 @@ describe('sanitizeMailHtml', () => { html: 'y', hasBlockedImages: false, embeddedContentIds: new Set(), + proxiedImageCount: 0, }); // CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²) expect(cpuMs()).toBeLessThan(2_000); @@ -266,6 +267,114 @@ describe('sanitizeMailHtml', () => { }); }); + describe('외부 이미지 보기 (externalImageSrc — 9단계)', () => { + /** 중계 주소 — 테스트에서는 원래 주소를 그대로 보이게 둔다 (실제 표는 암호화한 글이다) */ + const proxied = (url: URL) => + `/api/mail-images/${Buffer.from(url.href).toString('base64url')}`; + const show = (html: string) => + sanitizeMailHtml(html, new Map(), { externalImageSrc: proxied }); + + it(' 의 외부 src(http · https · //)만 중계 주소로 바꾼다 — 바꾼 수를 알려 준다', () => { + const result = show( + '

안녕

그림' + + '', + ); + + expect(result.html).toBe( + '

안녕

' + + `그림` + + `` + + ``, + ); + expect(result.proxiedImageCount).toBe(3); + expect(result.hasBlockedImages).toBe(false); + }); + + it('srcset · style 의 url() · background 속성은 지금처럼 지운다 (알려진 한계) — 지운 것이 있으면 hasBlockedImages', () => { + const result = show( + '' + + '
배경
' + + '
a
', + ); + + expect(result.html).toBe( + '
배경
a
', + ); + expect(result.proxiedImageCount).toBe(0); + expect(result.hasBlockedImages).toBe(true); + }); + + it('중계할 수 없는 주소(기본 포트가 아님 · 사용자 정보 · 2,048자 초과)는 지운다', () => { + const result = show( + '' + + '' + + ``, + ); + + expect(result.html).toBe(''); + expect(result.proxiedImageCount).toBe(0); + expect(result.hasBlockedImages).toBe(true); + }); + + it('같은 주소는 같은 중계 주소를 한 번만 만든다 · 서로 다른 주소는 한 통에 100개까지(그 뒤는 지운다)', () => { + let issued = 0; + const counting = (url: URL) => { + issued += 1; + return proxied(url); + }; + const same = sanitizeMailHtml( + ''.repeat(150), + new Map(), + { externalImageSrc: counting }, + ); + expect(issued).toBe(1); + expect(same.proxiedImageCount).toBe(1); + expect(same.html.match(/ ``, + ).join(''), + new Map(), + { externalImageSrc: counting }, + ); + expect(issued).toBe(100); + expect(many.proxiedImageCount).toBe(100); + expect(many.html.match(/ { + let issued = 0; + const result = sanitizeMailHtml( + '' + + '' + + ``, + new Map([['logo@x', PNG_DATA_URI]]), + { + externalImageSrc: (url) => { + issued += 1; + return proxied(url); + }, + }, + ); + + expect(issued).toBe(0); + expect(result.html).toBe( + ``, + ); + }); + + it('보이게 하지 않으면(옵션 없음) 바꾼 수는 0 이다', () => { + expect( + sanitizeMailHtml('') + .proxiedImageCount, + ).toBe(0); + }); + }); + describe('위험한 요소', () => { it.each([ [ diff --git a/backend/src/modules/mail/html/sanitize-mail-html.ts b/backend/src/modules/mail/html/sanitize-mail-html.ts index 15818b3..d437578 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.ts @@ -1,5 +1,7 @@ import sanitizeHtml from 'sanitize-html'; +import { MAX_PROXIED_IMAGES_PER_MESSAGE } from '@/modules/mail/images/mail-image.constants'; +import { parseProxiedImageUrl } from '@/modules/mail/images/mail-image-url'; import { normalizeContentId } from './inline-images'; /** @@ -13,6 +15,9 @@ import { normalizeContentId } from './inline-images'; * - 이벤트 핸들러(on*)·id·class 는 허용 속성이 아니므로 버린다 * - 링크는 http·https·mailto 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`) * - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단) + * - 보이게 할 때(옵션 `externalImageSrc` — 9단계 "외부 이미지 보기")는 `` 의 src 만 중계 주소로 바꾼다: + * http · https · `//` 이고 기본 포트 · 2,048자 이하(images/mail-image-url.ts)인 주소를 한 통에 100개까지. 그 밖의 외부 이미지 · + * srcset · style 과 속성의 배경 그림(url())은 지금처럼 지운다(알려진 한계). 같은 주소는 같은 중계 주소를 쓴다 * - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다 * * ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 길이·중첩 비용을 본다 (mail.service.ts) — @@ -29,8 +34,10 @@ import { normalizeContentId } from './inline-images'; export interface SanitizedMailHtml { readonly html: string; - /** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */ + /** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 (보이게 할 때도 지운 것 — srcset · 배경 그림 · 한도 밖 — 이 있으면 true) */ readonly hasBlockedImages: boolean; + /** 중계 주소로 바꿔 넣은 외부 이미지 수 (보이게 할 때만 — 같은 주소는 한 번) */ + readonly proxiedImageCount: number; /** * 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값). * 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다 @@ -295,28 +302,36 @@ function secureLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Attributes { return next; } +/** 이미지 하나를 정할 때 쓰는 것 — 정화 한 번(한 통)마다 하나 */ +interface ImageContext { + /** 정규화한 Content-ID → data: URI */ + readonly inlineImages: ReadonlyMap; + readonly markBlocked: () => void; + readonly markEmbedded: (contentId: string) => void; + /** 외부 이미지 주소 → 중계 주소 — 보이게 할 때만 있고, 넣지 못하면(주소 · 개수 한도) null */ + readonly proxy: ((src: string) => string | null) | null; +} + function resolveImage( attribs: sanitizeHtml.Attributes, - inlineImages: ReadonlyMap, - markBlocked: () => void, - markEmbedded: (contentId: string) => void, + context: ImageContext, ): sanitizeHtml.Attributes { const next: sanitizeHtml.Attributes = { ...attribs }; delete next.src; delete next.srcset; if (attribs.srcset && EMBEDDED_EXTERNAL_URL.test(attribs.srcset)) { - markBlocked(); + context.markBlocked(); } const src = attribs.src?.trim() ?? ''; if (/^cid:/i.test(src)) { const contentId = normalizeContentId(src.slice(4)); - const dataUri = inlineImages.get(contentId); + const dataUri = context.inlineImages.get(contentId); if (dataUri) { next.src = dataUri; // 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다 - markEmbedded(contentId); + context.markEmbedded(contentId); } return next; } @@ -324,7 +339,15 @@ function resolveImage( next.src = src; return next; } - if (EXTERNAL_URL.test(src)) markBlocked(); + if (EXTERNAL_URL.test(src)) { + // 보이게 할 때 — 같은 서버의 중계 주소(상대 경로, 스킴 없음)로 바꾼다. 넣지 못한 것은 지운다 + const proxied = context.proxy?.(src) ?? null; + if (proxied !== null) { + next.src = proxied; + return next; + } + context.markBlocked(); + } return next; } @@ -337,6 +360,14 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean { ); } +export interface SanitizeMailHtmlOptions { + /** + * 외부 이미지를 보인다(9단계) — 중계할 수 있는 원래 주소(정규화한 것 — images/mail-image-url.ts)를 받아 중계 주소(`/api/mail-images/…`)를 돌려준다. + * 없으면 지금처럼 외부 이미지를 지운다 + */ + readonly externalImageSrc?: (url: URL) => string; +} + /** * 메일 HTML 을 정화한다. * @param inlineImages 정규화한 Content-ID → data: URI (서비스 계층이 크기·형식을 확인해 만든 것) @@ -344,6 +375,7 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean { export function sanitizeMailHtml( html: string, inlineImages: ReadonlyMap = new Map(), + options: SanitizeMailHtmlOptions = {}, ): SanitizedMailHtml { let hasBlockedImages = false; const markBlocked = () => { @@ -353,6 +385,27 @@ export function sanitizeMailHtml( const markEmbedded = (contentId: string) => { embeddedContentIds.add(contentId); }; + // 원래 주소(정규화한 것) → 중계 주소 — 같은 주소는 같은 중계 주소(브라우저가 한 번만 받는다). 한 통에 100개까지 + const proxiedBySource = new Map(); + const { externalImageSrc } = options; + const proxy = externalImageSrc + ? (src: string): string | null => { + const url = parseProxiedImageUrl(src); + if (url === null) return null; + const known = proxiedBySource.get(url.href); + if (known !== undefined) return known; + if (proxiedBySource.size >= MAX_PROXIED_IMAGES_PER_MESSAGE) return null; + const proxied = externalImageSrc(url); + proxiedBySource.set(url.href, proxied); + return proxied; + } + : null; + const imageContext: ImageContext = { + inlineImages, + markBlocked, + markEmbedded, + proxy, + }; const output = sanitizeHtml(html, { allowedTags: ALLOWED_TAGS, @@ -367,12 +420,7 @@ export function sanitizeMailHtml( transformTags: { a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }), img: (tagName, attribs) => { - const next = resolveImage( - attribs, - inlineImages, - markBlocked, - markEmbedded, - ); + const next = resolveImage(attribs, imageContext); // 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어 // sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다). // - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring) @@ -390,5 +438,10 @@ export function sanitizeMailHtml( }, }); - return { html: output, hasBlockedImages, embeddedContentIds }; + return { + html: output, + hasBlockedImages, + embeddedContentIds, + proxiedImageCount: proxiedBySource.size, + }; } diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts index 1004267..d61a2c6 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts @@ -601,6 +601,7 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => { html: '

서명

', hasBlockedImages: false, embeddedContentIds: new Set(), + proxiedImageCount: 0, }); }); diff --git a/backend/src/modules/mail/html/signature-images.spec.ts b/backend/src/modules/mail/html/signature-images.spec.ts index 6e2605e..5bf2855 100644 --- a/backend/src/modules/mail/html/signature-images.spec.ts +++ b/backend/src/modules/mail/html/signature-images.spec.ts @@ -1,7 +1,11 @@ +import { startCpuTimer } from '@/../test/cpu-time'; import { findSignatureImageIds, + parseSignatureImageContentId, parseSignatureImageId, + restoreSignatureImageSources, rewriteSignatureImages, + signatureImageContentId, signatureImagePath, } from './signature-images'; @@ -56,4 +60,45 @@ describe('서명 그림 주소 (signature-images)', () => { `

서명

로고`, ); }); + + it('보낸 메일의 서명 그림 Content-ID — 보내는 사람 도메인(소문자)으로 만들고, 그 꼴이면 그림 id 를 돌려준다', () => { + expect(signatureImageContentId(ID, 'Ara.Kim@ArASystem.test')).toBe( + `sig-${ID}@arasystem.test`, + ); + expect(parseSignatureImageContentId(`sig-${ID}@arasystem.test`)).toBe(ID); + expect( + parseSignatureImageContentId(` SIG-${ID.toUpperCase()}@x.example `), + ).toBe(ID); + for (const cid of [ + `logo@x.example`, + `sig-${ID}`, + `sig-not-a-uuid@x.example`, + `xsig-${ID}@x.example`, + `sig-${ID}@a@b`, + ]) { + expect(parseSignatureImageContentId(cid)).toBeNull(); + } + }); + + it('취소한 발송을 이어 쓸 때(9단계) — 본문의 cid:sig-… 그림 주소를 서명 그림 주소로 되돌린다 (다른 그림 · 속성은 그대로)', () => { + const sent = + `

서명

` + + `` + + ''; + + expect(restoreSignatureImageSources(sent)).toBe( + `

서명

` + + `` + + '', + ); + }); + + it('되돌리기는 입력 길이에 비례한다 — src= 뒤 공백이 아주 길어도', () => { + const cpuMs = startCpuTimer(); + const html = ``.repeat(4); + + expect(restoreSignatureImageSources(html)).toBe(html); + // CPU 시간 (test/cpu-time.ts) — 지금 수십 ms. 공백을 여러 \s* 가 나눠 가지면 공백 수의 제곱이다 + expect(cpuMs()).toBeLessThan(2_000); + }); }); diff --git a/backend/src/modules/mail/html/signature-images.ts b/backend/src/modules/mail/html/signature-images.ts index 8f502b0..5debd0d 100644 --- a/backend/src/modules/mail/html/signature-images.ts +++ b/backend/src/modules/mail/html/signature-images.ts @@ -31,11 +31,55 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** 보낸 메일의 서명 그림 Content-ID — `sig-{그림 id}@{도메인}` (signatureImageContentId 가 만든 모양) */ +const SIGNATURE_IMAGE_CONTENT_ID = new RegExp( + `^sig-(${UUID})@[^\\s@<>]+$`, + 'i', +); + +/** + * 보낸 메일 본문의 서명 그림 주소 — `src="cid:sig-{그림 id}@{도메인}"`. 따옴표는 정화가 쓰는 큰따옴표와 작은따옴표 둘 다. + * 같은 공백을 여러 `\s*` 가 나눠 갖지 않게 둔다(입력 길이에 비례) + */ +const SENT_SIGNATURE_IMAGE_SRC = new RegExp( + `\\bsrc\\s*=\\s*(["'])cid:sig-(${UUID})@[^"'\\s<>]*\\1`, + 'gi', +); + /** 그림 id 의 주소 */ export function signatureImagePath(id: string): string { return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`; } +/** + * 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다). 도메인은 보내는 사람의 것(소문자) + */ +export function signatureImageContentId( + imageId: string, + sender: string, +): string { + const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase(); + return `sig-${imageId}@${domain}`; +} + +/** 우리가 보낸 메일의 서명 그림 부분인가 — Content-ID 가 `sig-{UUID}@…` 꼴이면 그 그림 id(소문자), 아니면 null */ +export function parseSignatureImageContentId(cid: string): string | null { + const match = cid.trim().match(SIGNATURE_IMAGE_CONTENT_ID); + return match ? match[1].toLowerCase() : null; +} + +/** + * 보낸 메일 본문의 서명 그림(`cid:sig-…`)을 서명 그림 주소로 되돌린다 — 취소한 발송을 임시 저장본으로 이어 쓸 때(9단계 "보내기 취소"). + * 보내는 정화 **전에** 쓴다 — 정화가 그 사람 자신의 그림만 남긴다(남의 그림 id · 지운 그림은 정화가 지운다) + */ +export function restoreSignatureImageSources(html: string): string { + return html.replace( + SENT_SIGNATURE_IMAGE_SRC, + (_whole, _quote: string, id: string) => + `src="${signatureImagePath(id.toLowerCase())}"`, + ); +} + /** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */ export function parseSignatureImageId(src: string | undefined): string | null { const match = src?.trim().match(SIGNATURE_IMAGE_SRC); diff --git a/backend/src/modules/mail/images/mail-image-fetcher.spec.ts b/backend/src/modules/mail/images/mail-image-fetcher.spec.ts new file mode 100644 index 0000000..68b1aaf --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-fetcher.spec.ts @@ -0,0 +1,425 @@ +import { gifBytes, pngBytes } from '@/../test/image-fixtures'; +import { + MAIL_IMAGE_MAX_BYTES, + MAIL_IMAGE_USER_AGENT, +} from './mail-image.constants'; +import { + MailImageFetcher, + type MailImageRequest, + type MailImageResolver, + type MailImageResponse, + type MailImageTransport, + type ResolvedAddress, +} from './mail-image-fetcher'; + +const PUBLIC: ResolvedAddress = { address: '93.184.216.34', family: 4 }; +const PUBLIC_V6: ResolvedAddress = { + address: '2606:2800:220:1:248:1893:25c8:1946', + family: 6, +}; + +/** 가짜 응답 — 본문 조각을 차례로 준다. 닫으면 closed */ +function reply( + status: number, + options: { + body?: Buffer[]; + location?: string; + contentLength?: number | null; + failAfter?: number; + } = {}, +): MailImageResponse & { closed: boolean } { + const chunks = options.body ?? []; + const response = { + status, + location: options.location ?? null, + contentLength: + options.contentLength === undefined + ? chunks.reduce((sum, chunk) => sum + chunk.length, 0) + : options.contentLength, + closed: false, + body: (async function* () { + for (const [index, chunk] of chunks.entries()) { + if (options.failAfter !== undefined && index >= options.failAfter) { + throw new Error('socket hang up'); + } + await Promise.resolve(); + yield chunk; + } + })(), + close() { + response.closed = true; + }, + }; + return response; +} + +/** 이름 → 주소 표 · 주소 → 응답 차례로 흉내 내는 대역 — 받은 요청을 모은다 */ +function fakes( + dns: Record, + replies: ( + | MailImageResponse + | Error + | ((request: MailImageRequest) => Promise) + )[], +) { + const lookups: string[] = []; + const requests: MailImageRequest[] = []; + const resolver: MailImageResolver = { + resolve: (hostname) => { + lookups.push(hostname); + const answer = dns[hostname]; + if (answer === undefined) return Promise.reject(new Error('ENOTFOUND')); + return answer instanceof Error + ? Promise.reject(answer) + : Promise.resolve(answer); + }, + }; + const transport: MailImageTransport = { + get: (request) => { + requests.push(request); + const next = replies.shift(); + if (next === undefined) return Promise.reject(new Error('no reply')); + if (next instanceof Error) return Promise.reject(next); + if (typeof next === 'function') return next(request); + return Promise.resolve(next); + }, + }; + return { + fetcher: new MailImageFetcher(resolver, transport), + lookups, + requests, + }; +} + +const live = () => new AbortController().signal; + +describe('MailImageFetcher — 바깥 이미지 받기 (SSRF 막기)', () => { + it('이름을 풀어 공인 주소면 그 주소로만 연결해 받는다 — 형식은 받은 바이트로, 머리글은 고정한 것뿐', async () => { + const png = pngBytes(600, 200, 2_000); + const { fetcher, lookups, requests } = fakes( + { 'news.example.com': [PUBLIC, PUBLIC_V6] }, + [reply(200, { body: [png.subarray(0, 100), png.subarray(100)] })], + ); + + const result = await fetcher.fetch( + 'https://news.example.com/a.png?u=1', + live(), + ); + + expect(result).toEqual({ ok: true, type: 'image/png', data: png }); + expect(lookups).toEqual(['news.example.com']); + expect(requests).toHaveLength(1); + expect(requests[0].url.href).toBe('https://news.example.com/a.png?u=1'); + expect(requests[0].addresses).toEqual([PUBLIC, PUBLIC_V6]); + expect(requests[0].headers).toEqual({ + 'user-agent': MAIL_IMAGE_USER_AGENT, + accept: expect.stringContaining('image/png') as unknown, + 'accept-encoding': 'identity', + }); + // 쿠키 · Referer · 인증 머리글은 없다 + for (const name of Object.keys(requests[0].headers)) { + expect(name).not.toMatch(/^(?:cookie|referer|authorization)$/i); + } + }); + + it('바깥이 보낸 형식과 상관없이 그림이 아니면(HTML · SVG) 주지 않는다', async () => { + const { fetcher } = fakes({ 'news.example.com': [PUBLIC] }, [ + reply(200, { + body: [Buffer.from('')], + }), + ]); + + await expect( + fetcher.fetch('https://news.example.com/a.png', live()), + ).resolves.toEqual({ + ok: false, + reason: 'not-an-image', + host: 'news.example.com', + }); + }); + + it.each([ + ['ftp', 'ftp://news.example.com/a.png'], + ['기본 포트가 아님', 'https://news.example.com:8080/a.png'], + ['사용자 정보', 'https://u:p@news.example.com/a.png'], + ['깨진 주소', 'not a url'], + ])( + '중계할 수 없는 주소(%s)는 이름을 풀지도 연결하지도 않는다', + async (_label, url) => { + const { fetcher, lookups, requests } = fakes({}, []); + + const result = await fetcher.fetch(url, live()); + + expect(result).toMatchObject({ ok: false, reason: 'bad-url' }); + expect(lookups).toEqual([]); + expect(requests).toEqual([]); + }, + ); + + it.each([ + 'http://127.0.0.1/a.png', + 'http://2130706433/a.png', + 'http://10.0.0.5/a.png', + 'http://172.16.71.3/api', + 'http://169.254.169.254/latest/meta-data/', + 'http://[::1]/a.png', + 'http://[::ffff:127.0.0.1]/a.png', + 'http://[fd00::1]/a.png', + ])( + 'IP 로 쓴 사설 · 루프백 · 링크 로컬 주소(%s)는 연결하지 않는다', + async (url) => { + const { fetcher, lookups, requests } = fakes({}, []); + + const result = await fetcher.fetch(url, live()); + + expect(result).toMatchObject({ ok: false, reason: 'not-public-address' }); + expect(lookups).toEqual([]); + expect(requests).toEqual([]); + }, + ); + + it('IP 로 쓴 공인 주소는 이름을 풀지 않고 그 주소로 연결한다', async () => { + const { fetcher, lookups, requests } = fakes({}, [ + reply(200, { body: [gifBytes(1, 1)] }), + ]); + + const result = await fetcher.fetch('http://93.184.216.34/p.gif', live()); + + expect(result).toMatchObject({ ok: true, type: 'image/gif' }); + expect(lookups).toEqual([]); + expect(requests[0].addresses).toEqual([PUBLIC]); + }); + + it('점이 없는 이름(도커 서비스 이름 · localhost)은 풀어 보지도 않는다', async () => { + const { fetcher, lookups, requests } = fakes({ mail: [PUBLIC] }, []); + + for (const url of ['http://mail/a.png', 'http://localhost/a.png']) { + expect(await fetcher.fetch(url, live())).toMatchObject({ + ok: false, + reason: 'single-label-host', + }); + } + expect(lookups).toEqual([]); + expect(requests).toEqual([]); + }); + + it('풀어 나온 주소 가운데 하나라도 공인이 아니면(DNS 로 사내 주소를 섞음) 연결하지 않는다', async () => { + const { fetcher, requests } = fakes( + { + 'mixed.example.com': [PUBLIC, { address: '10.0.0.7', family: 4 }], + 'inside.example.com': [{ address: '127.0.0.1', family: 4 }], + 'v6.example.com': [{ address: 'fe80::1', family: 6 }], + }, + [], + ); + + for (const host of ['mixed', 'inside', 'v6']) { + expect( + await fetcher.fetch(`https://${host}.example.com/a.png`, live()), + ).toEqual({ + ok: false, + reason: 'not-public-address', + host: `${host}.example.com`, + }); + } + expect(requests).toEqual([]); + }); + + it('이름을 풀지 못하거나 주소가 없으면 dns-failed', async () => { + const { fetcher } = fakes( + { 'empty.example.com': [], 'broken.example.com': new Error('ESERVFAIL') }, + [], + ); + + for (const host of ['empty', 'broken', 'nowhere']) { + expect( + await fetcher.fetch(`https://${host}.example.com/a.png`, live()), + ).toMatchObject({ ok: false, reason: 'dns-failed' }); + } + }); + + it('다시 가기는 매번 처음과 같은 검사 — 상대 주소는 이어 풀고, 사설 주소 · 다른 스킴으로 가면 멈춘다', async () => { + const png = pngBytes(10, 10); + const ok = fakes({ 'a.example.com': [PUBLIC], 'b.example.com': [PUBLIC] }, [ + reply(302, { location: '/moved.png' }), + reply(301, { location: 'https://b.example.com/final.png' }), + reply(200, { body: [png] }), + ]); + await expect( + ok.fetcher.fetch('http://a.example.com/a.png', live()), + ).resolves.toMatchObject({ ok: true, type: 'image/png' }); + expect(ok.requests.map((request) => request.url.href)).toEqual([ + 'http://a.example.com/a.png', + 'http://a.example.com/moved.png', + 'https://b.example.com/final.png', + ]); + + const toPrivate = fakes( + { + 'a.example.com': [PUBLIC], + 'evil.example.com': [{ address: '172.16.71.5', family: 4 }], + }, + [reply(302, { location: 'http://evil.example.com/' })], + ); + await expect( + toPrivate.fetcher.fetch('http://a.example.com/a.png', live()), + ).resolves.toEqual({ + ok: false, + reason: 'not-public-address', + host: 'evil.example.com', + }); + expect(toPrivate.requests).toHaveLength(1); + + for (const location of [ + 'http://127.0.0.1:8080/admin', + 'file:///etc/passwd', + 'gopher://a.example.com/', + '', + ]) { + const bad = fakes({ 'a.example.com': [PUBLIC] }, [ + reply(307, { location }), + ]); + expect( + await bad.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'bad-redirect' }); + expect(bad.requests).toHaveLength(1); + } + }); + + it('다시 가기는 3번까지 — 네 번째는 따라가지 않는다', async () => { + const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [ + reply(302, { location: '/1' }), + reply(302, { location: '/2' }), + reply(302, { location: '/3' }), + reply(302, { location: '/4' }), + ]); + + expect(await fetcher.fetch('http://a.example.com/0', live())).toMatchObject( + { ok: false, reason: 'too-many-redirects' }, + ); + expect(requests).toHaveLength(4); + }); + + it('200 이 아닌 응답(404 · 304 · 500)은 주지 않고 본문을 닫는다', async () => { + for (const status of [404, 304, 500, 206]) { + const response = reply(status, { body: [pngBytes(1, 1)] }); + const { fetcher } = fakes({ 'a.example.com': [PUBLIC] }, [response]); + expect( + await fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'bad-status' }); + expect(response.closed).toBe(true); + } + }); + + it(`알려 준 길이가 ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB 를 넘으면 읽지 않고, 읽는 중에 넘으면 끊는다`, async () => { + const declared = reply(200, { + body: [pngBytes(1, 1)], + contentLength: MAIL_IMAGE_MAX_BYTES + 1, + }); + const first = fakes({ 'a.example.com': [PUBLIC] }, [declared]); + expect( + await first.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'too-large' }); + expect(declared.closed).toBe(true); + + const chunk = Buffer.alloc(1024 * 1024); + pngBytes(1, 1).copy(chunk); + const streamed = reply(200, { + body: Array.from({ length: 6 }, () => chunk), + contentLength: null, + }); + const second = fakes({ 'a.example.com': [PUBLIC] }, [streamed]); + expect( + await second.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'too-large' }); + expect(streamed.closed).toBe(true); + }); + + it('길이를 알려 주면 그 길이로 한 번 잡은 버퍼에 조각을 바로 쓴다 — 알려 주지 않으면(chunked) 모아 한 번 잇는다, 바이트는 같다', async () => { + const png = pngBytes(40, 30, 50_000); + const parts = [ + png.subarray(0, 7), + png.subarray(7, 30_000), + png.subarray(30_000), + ]; + const concat = jest.spyOn(Buffer, 'concat'); + try { + const declared = fakes({ 'a.example.com': [PUBLIC] }, [ + reply(200, { body: parts, contentLength: png.length }), + ]); + expect( + await declared.fetcher.fetch('http://a.example.com/a.png', live()), + ).toEqual({ ok: true, type: 'image/png', data: png }); + // 알려 준 길이는 잇기 없이 + expect(concat).not.toHaveBeenCalled(); + + const chunked = fakes({ 'a.example.com': [PUBLIC] }, [ + reply(200, { body: parts, contentLength: null }), + ]); + expect( + await chunked.fetcher.fetch('http://a.example.com/a.png', live()), + ).toEqual({ ok: true, type: 'image/png', data: png }); + // 모르는 길이는 끝에 한 번만 잇는다 + expect(concat).toHaveBeenCalledTimes(1); + } finally { + concat.mockRestore(); + } + }); + + it('알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length) — 닫는다, 덜 오고 끝나면 받은 만큼으로 판정한다', async () => { + const png = pngBytes(10, 10); + const longer = reply(200, { + body: [png, Buffer.alloc(10)], + contentLength: png.length, + }); + const over = fakes({ 'a.example.com': [PUBLIC] }, [longer]); + expect( + await over.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'bad-length' }); + expect(longer.closed).toBe(true); + + const shorter = fakes({ 'a.example.com': [PUBLIC] }, [ + reply(200, { body: [png], contentLength: png.length + 100 }), + ]); + expect( + await shorter.fetcher.fetch('http://a.example.com/a.png', live()), + ).toEqual({ ok: true, type: 'image/png', data: png }); + }); + + it('연결하지 못함 · 본문을 읽다 끊김은 connect-failed', async () => { + const refused = fakes({ 'a.example.com': [PUBLIC] }, [ + new Error('ECONNREFUSED'), + ]); + expect( + await refused.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'connect-failed' }); + + const broken = reply(200, { + body: [pngBytes(1, 1), Buffer.alloc(10)], + failAfter: 1, + }); + const cut = fakes({ 'a.example.com': [PUBLIC] }, [broken]); + expect( + await cut.fetcher.fetch('http://a.example.com/a.png', live()), + ).toMatchObject({ ok: false, reason: 'connect-failed' }); + expect(broken.closed).toBe(true); + }); + + it('클라이언트가 끊으면 받던 것을 멈추고 canceled — 전송에 같은 신호를 넘긴다', async () => { + const controller = new AbortController(); + const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [ + (request) => + new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => + reject(new Error('aborted')), + ); + controller.abort(); + }), + ]); + + await expect( + fetcher.fetch('http://a.example.com/a.png', controller.signal), + ).resolves.toMatchObject({ ok: false, reason: 'canceled' }); + expect(requests[0].signal.aborted).toBe(true); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image-fetcher.ts b/backend/src/modules/mail/images/mail-image-fetcher.ts new file mode 100644 index 0000000..4b75802 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-fetcher.ts @@ -0,0 +1,304 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { isIP } from 'net'; + +import { isPublicIpAddress } from '@/shared/utils/public-address'; +import { + MAIL_IMAGE_FETCH_TIMEOUT_MS, + MAIL_IMAGE_MAX_BYTES, + MAIL_IMAGE_MAX_REDIRECTS, + MAIL_IMAGE_USER_AGENT, +} from './mail-image.constants'; +import { + detectProxiedImageType, + type ProxiedImageType, +} from './mail-image-format'; +import { + checkProxiedImageUrl, + hostOf, + parseProxiedImageUrl, +} from './mail-image-url'; + +/** + * 바깥 이미지 받기 (계약 2절 "외부 이미지 보기" — 9단계) — 이 서버가 직원 대신 바깥 서버에 묻는 길이라 SSRF 를 막는 곳이다. + * + * - 주소: http · https · 기본 포트 · 사용자 정보 없음 (mail-image-url.ts) + * - 이름을 풀어 나온 **모든** 주소가 공인 주소일 때만, 그 주소로만 연결한다 — 연결할 때 이름을 다시 풀지 않는다(DNS 바꿔치기 막기). + * IP 로 쓴 주소는 그 주소를 그대로 본다. 점이 없는 이름(`mail` · `db` 같은 도커 서비스 이름)은 풀어 보지도 않는다 + * - 다시 가기(redirect 301 · 302 · 303 · 307 · 308)는 3번까지 — 매번 위 검사를 처음부터 다시 + * - 전체 10초(이름 풀기 · 연결 · 다시 가기 · 본문 모두), 받는 크기 5MB — 넘으면 끊는다 + * - 형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP) — 바깥의 Content-Type 은 믿지 않는다 + * - 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다 (연결 · 머리글은 전송(transport)이 만든다) + * + * 이름 풀기(MAIL_IMAGE_RESOLVER)와 전송(MAIL_IMAGE_TRANSPORT)은 주입받는다 — 단위 테스트는 가짜로, 앱은 node-image-transport.ts 의 구현으로 + */ + +/** 이름을 풀어 나온 주소 하나 */ +export interface ResolvedAddress { + readonly address: string; + readonly family: 4 | 6; +} + +/** 이름 풀기 — 나온 주소를 모두 준다(A · AAAA). 풀지 못하면 던진다. signal 이 끊기면 멈춘다 */ +export interface MailImageResolver { + resolve(hostname: string, signal: AbortSignal): Promise; +} +export const MAIL_IMAGE_RESOLVER = Symbol('MAIL_IMAGE_RESOLVER'); + +/** 한 번 묻는 것 — 확인한 주소로만 연결한다 */ +export interface MailImageRequest { + readonly url: URL; + /** 이 주소들로만 연결한다 (이름을 다시 풀지 않는다) — 모두 공인 주소로 확인한 것 */ + readonly addresses: readonly ResolvedAddress[]; + readonly headers: Readonly>; + readonly signal: AbortSignal; +} + +/** 받은 응답 — 본문은 다 읽거나 close() 로 닫는다 */ +export interface MailImageResponse { + readonly status: number; + /** Location 머리글 (없으면 null) */ + readonly location: string | null; + /** Content-Length (없거나 읽을 수 없으면 null) */ + readonly contentLength: number | null; + readonly body: AsyncIterable; + close(): void; +} + +/** 전송 — GET 한 번. 연결하지 못하면 던진다 */ +export interface MailImageTransport { + get(request: MailImageRequest): Promise; +} +export const MAIL_IMAGE_TRANSPORT = Symbol('MAIL_IMAGE_TRANSPORT'); + +/** 받지 못한 까닭 — 로그(debug)에만 쓴다. 응답은 모두 404 */ +export type MailImageFailureReason = + | 'bad-url' + | 'single-label-host' + | 'not-public-address' + | 'dns-failed' + | 'connect-failed' + | 'bad-status' + | 'bad-redirect' + | 'too-many-redirects' + | 'too-large' + | 'bad-length' + | 'not-an-image' + | 'timeout' + | 'canceled'; + +export type MailImageFetchResult = + | { + readonly ok: true; + readonly type: ProxiedImageType; + readonly data: Buffer; + } + | { + readonly ok: false; + readonly reason: MailImageFailureReason; + /** 그때 묻던 바깥 호스트 이름 (주소 전체는 남기지 않는다) — 모르면 null */ + readonly host: string | null; + }; + +/** 다시 가기 응답 */ +const REDIRECT_STATUSES: ReadonlySet = new Set([ + 301, 302, 303, 307, 308, +]); + +const HTTP_OK = 200; + +/** 바깥으로 보내는 머리글 — 이것뿐이다 (쿠키 · Referer · 브라우저의 머리글은 넘기지 않는다) */ +const REQUEST_HEADERS: Readonly> = Object.freeze({ + 'user-agent': MAIL_IMAGE_USER_AGENT, + accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1', + // 압축하지 않은 바이트로 받는다 — 형식은 받은 바이트의 앞부분으로 정한다 + 'accept-encoding': 'identity', +}); + +/** 실패를 결과로 — 던지지 않는다 */ +class FetchFailure extends Error { + constructor( + readonly reason: MailImageFailureReason, + readonly host: string | null, + ) { + super(reason); + } +} + +@Injectable() +export class MailImageFetcher { + constructor( + @Inject(MAIL_IMAGE_RESOLVER) private readonly resolver: MailImageResolver, + @Inject(MAIL_IMAGE_TRANSPORT) + private readonly transport: MailImageTransport, + ) {} + + /** + * 바깥 이미지를 받는다 — 받으면 형식 · 바이트, 못 받으면 까닭 (던지지 않는다). + * @param signal 클라이언트가 끊으면 멈춘다 (전체 10초 제한은 여기서 더한다) + */ + async fetch( + source: string, + signal: AbortSignal, + ): Promise { + const deadline = AbortSignal.timeout(MAIL_IMAGE_FETCH_TIMEOUT_MS); + const combined = AbortSignal.any([signal, deadline]); + let host: string | null = null; + try { + let url = parseProxiedImageUrl(source); + if (url === null) throw new FetchFailure('bad-url', null); + + for (let redirects = 0; ; redirects += 1) { + host = hostOf(url); + const addresses = await this.publicAddresses(url, combined); + const response = await this.request(url, addresses, combined); + + if (REDIRECT_STATUSES.has(response.status)) { + response.close(); + if (redirects >= MAIL_IMAGE_MAX_REDIRECTS) { + throw new FetchFailure('too-many-redirects', host); + } + url = this.redirectTarget(url, response.location); + continue; + } + if (response.status !== HTTP_OK) { + response.close(); + throw new FetchFailure('bad-status', host); + } + const data = await readCapped(response, host); + const type = detectProxiedImageType(data); + if (type === null) throw new FetchFailure('not-an-image', host); + return { ok: true, type, data }; + } + } catch (error) { + if (signal.aborted) return { ok: false, reason: 'canceled', host }; + if (deadline.aborted) return { ok: false, reason: 'timeout', host }; + if (error instanceof FetchFailure) { + return { ok: false, reason: error.reason, host: error.host }; + } + return { ok: false, reason: 'connect-failed', host }; + } + } + + /** 연결해도 되는 주소 — IP 로 쓴 주소는 그대로, 이름은 풀어서. 하나라도 공인 주소가 아니면 실패 */ + private async publicAddresses( + url: URL, + signal: AbortSignal, + ): Promise { + const host = hostOf(url); + const family = isIP(host); + if (family === 4 || family === 6) { + if (!isPublicIpAddress(host)) { + throw new FetchFailure('not-public-address', host); + } + return [{ address: host, family }]; + } + // 점이 없는 이름은 사내 · 도커 망의 이름이다(mail · db · localhost) — 풀어 보지 않는다 + if (!host.includes('.')) throw new FetchFailure('single-label-host', host); + + let addresses: ResolvedAddress[]; + try { + addresses = await this.resolver.resolve(host, signal); + } catch { + throw new FetchFailure('dns-failed', host); + } + if (addresses.length === 0) throw new FetchFailure('dns-failed', host); + if (!addresses.every(({ address }) => isPublicIpAddress(address))) { + throw new FetchFailure('not-public-address', host); + } + return addresses; + } + + private async request( + url: URL, + addresses: readonly ResolvedAddress[], + signal: AbortSignal, + ): Promise { + try { + return await this.transport.get({ + url, + addresses, + headers: REQUEST_HEADERS, + signal, + }); + } catch { + throw new FetchFailure('connect-failed', hostOf(url)); + } + } + + /** 다시 갈 주소 — 지금 주소에 이어 푼 뒤 처음과 같은 검사를 한다 */ + private redirectTarget(current: URL, location: string | null): URL { + if (location === null || location.trim() === '') { + throw new FetchFailure('bad-redirect', hostOf(current)); + } + let next: URL; + try { + next = new URL(location.trim(), current); + } catch { + throw new FetchFailure('bad-redirect', hostOf(current)); + } + const checked = checkProxiedImageUrl(next); + if (checked === null) { + throw new FetchFailure('bad-redirect', hostOf(current)); + } + return checked; + } +} + +/** + * 본문을 읽는다 — 5MB 를 넘으면(알려 준 길이든 읽는 중이든) 닫고 실패. 받은 조각을 두 번 베끼지 않는다(동시 16장 × 5MB — 2026-10-29 리뷰): + * - 길이를 알려 주면(Content-Length) 그만큼 한 번 잡아 조각을 바로 그 자리에 쓴다 — 알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length) + * - 모르면(chunked) 받은 조각을 그대로 모아 끝에 한 번만 잇는다 + */ +async function readCapped( + response: MailImageResponse, + host: string, +): Promise { + const declared = response.contentLength; + if (declared !== null && declared > MAIL_IMAGE_MAX_BYTES) { + response.close(); + throw new FetchFailure('too-large', host); + } + try { + return declared !== null + ? await readDeclared(response, declared, host) + : await readChunked(response, host); + } catch (error) { + response.close(); + throw error; + } +} + +/** 알려 준 길이만큼 한 번 잡은 버퍼에 바로 쓴다 — 덜 오면(끊김 없이 끝남) 받은 만큼만 */ +async function readDeclared( + response: MailImageResponse, + declared: number, + host: string, +): Promise { + const data = Buffer.allocUnsafe(declared); + let offset = 0; + for await (const chunk of response.body) { + if (offset + chunk.length > declared) { + throw new FetchFailure('bad-length', host); + } + data.set(chunk, offset); + offset += chunk.length; + } + return offset === declared ? data : data.subarray(0, offset); +} + +/** 길이를 모르는 본문 — 조각을 베끼지 않고 모아 끝에 한 번 잇는다 */ +async function readChunked( + response: MailImageResponse, + host: string, +): Promise { + const chunks: Uint8Array[] = []; + let total = 0; + for await (const chunk of response.body) { + total += chunk.length; + if (total > MAIL_IMAGE_MAX_BYTES) { + throw new FetchFailure('too-large', host); + } + chunks.push(chunk); + } + return Buffer.concat(chunks, total); +} diff --git a/backend/src/modules/mail/images/mail-image-format.spec.ts b/backend/src/modules/mail/images/mail-image-format.spec.ts new file mode 100644 index 0000000..8511c12 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-format.spec.ts @@ -0,0 +1,49 @@ +import { + gifBytes, + jpegBytes, + pngBytes, + webpBytes, +} from '@/../test/image-fixtures'; +import { detectProxiedImageType } from './mail-image-format'; + +describe('detectProxiedImageType — 받은 바이트로 정하는 형식', () => { + it('PNG · JPEG · GIF · WebP(손실 · 무손실 · 확장)를 알아본다', () => { + expect(detectProxiedImageType(pngBytes(600, 200))).toBe('image/png'); + expect(detectProxiedImageType(jpegBytes(640, 480))).toBe('image/jpeg'); + expect(detectProxiedImageType(jpegBytes(640, 480, { sof: 0xc2 }))).toBe( + 'image/jpeg', + ); + expect(detectProxiedImageType(gifBytes(1, 1))).toBe('image/gif'); + expect(detectProxiedImageType(gifBytes(1, 1, 'GIF87a'))).toBe('image/gif'); + for (const chunk of ['VP8 ', 'VP8L', 'VP8X']) { + expect(detectProxiedImageType(webpBytes(chunk))).toBe('image/webp'); + } + }); + + it.each([ + [ + 'SVG', + Buffer.from( + '', + ), + ], + ['HTML', Buffer.from('hi')], + ['빈 바이트', Buffer.alloc(0)], + [ + 'PNG 서명만', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ], + [ + '다른 RIFF(WAV)', + Buffer.concat([ + Buffer.from('RIFF\0\0\0\0WAVEfmt ', 'latin1'), + Buffer.alloc(16), + ]), + ], + ['모르는 WebP 덩어리', webpBytes('ABCD')], + ['BMP', Buffer.from('BM\0\0\0\0\0\0\0\0\0\0\0\0', 'latin1')], + ['gzip 으로 압축한 것', Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00])], + ])('%s 는 그림으로 보지 않는다', (_label, data) => { + expect(detectProxiedImageType(data)).toBeNull(); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image-format.ts b/backend/src/modules/mail/images/mail-image-format.ts new file mode 100644 index 0000000..6573183 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-format.ts @@ -0,0 +1,36 @@ +import { detectSignatureImage } from '@/modules/mail-signature/signature-image-format'; + +/** + * 바깥에서 받은 이미지의 형식 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 바이트의 앞부분으로 정한다. + * 바깥이 보낸 Content-Type 은 믿지 않는다 — PNG · JPEG · GIF · WebP 만 받고 SVG(스크립트를 담을 수 있다) · 그 밖은 받지 않는다. + * PNG · JPEG · GIF 는 서명 그림과 같은 판별(머리까지 맞아야 한다 — mail-signature/signature-image-format.ts)을 쓰고, WebP 는 RIFF 머리를 본다 + */ + +export type ProxiedImageType = + | 'image/png' + | 'image/jpeg' + | 'image/gif' + | 'image/webp'; + +const RIFF = Buffer.from('RIFF', 'ascii'); +const WEBP = Buffer.from('WEBP', 'ascii'); +/** WebP 의 첫 덩어리 — 손실 · 무손실 · 확장 */ +const WEBP_CHUNKS: ReadonlySet = new Set(['VP8 ', 'VP8L', 'VP8X']); +/** 'RIFF' 크기(4) 'WEBP' 덩어리 이름(4) */ +const WEBP_HEADER_BYTES = 16; + +function isWebp(data: Buffer): boolean { + return ( + data.length >= WEBP_HEADER_BYTES && + data.subarray(0, 4).equals(RIFF) && + data.subarray(8, 12).equals(WEBP) && + WEBP_CHUNKS.has(data.subarray(12, 16).toString('latin1')) + ); +} + +/** 받은 바이트의 형식 — 그림이 아니거나 머리가 망가졌으면 null */ +export function detectProxiedImageType(data: Buffer): ProxiedImageType | null { + const known = detectSignatureImage(data); + if (known) return known.type; + return isWebp(data) ? 'image/webp' : null; +} diff --git a/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts b/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts new file mode 100644 index 0000000..5b399c0 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts @@ -0,0 +1,456 @@ +import { Logger } from '@nestjs/common'; + +import { pngBytes } from '@/../test/image-fixtures'; +import { + MAIL_IMAGE_CONCURRENCY, + MAIL_IMAGE_CONCURRENCY_PER_USER, + MAIL_IMAGE_MAX_WAITING_PER_USER, + MAIL_IMAGE_QUEUE_WAIT_MS, +} from './mail-image.constants'; +import type { + MailImageFetcher, + MailImageFetchResult, +} from './mail-image-fetcher'; +import { MailImageProxyService } from './mail-image-proxy.service'; +import { MailImageTokens } from './mail-image-token'; + +const ARA = 'ara.kim@arasystem.test'; +const DOYUN = 'doyun.lee@arasystem.test'; +const SOURCE = 'https://news.example.com/open.gif?u=8812'; +const PNG = pngBytes(10, 10); + +/** 테스트가 열 때까지 끝나지 않는 받기 */ +interface PendingFetch { + readonly source: string; + readonly signal: AbortSignal; + finish(result: MailImageFetchResult): void; +} + +function setup() { + const tokens = new MailImageTokens(); + const pending: PendingFetch[] = []; + const fetcher = { + fetch: jest.fn( + (source: string, signal: AbortSignal) => + new Promise((resolve) => { + pending.push({ source, signal, finish: resolve }); + }), + ), + }; + const service = new MailImageProxyService( + tokens, + fetcher as unknown as MailImageFetcher, + ); + return { tokens, fetcher, pending, service }; +} + +const OK: MailImageFetchResult = { ok: true, type: 'image/png', data: PNG }; + +/** 기다리던 일(마이크로태스크)이 돌게 한다 */ +const flush = async () => { + for (let i = 0; i < 5; i += 1) await Promise.resolve(); +}; + +/** 직원 n 명의 주소 */ +const employee = (n: number) => `user${n}@arasystem.test`; + +/** + * 남은 받기를 모두 끝내고 받은 차례를 모두 돌려준다 — 돌려주면 기다리던 것이 들어와 새 받기가 생기므로 다 끝날 때까지 되풀이한다 + * @returns 끝난 수 (모두 끝났는지는 부른 쪽이 본다) + */ +async function drain( + pending: PendingFetch[], + opens: Promise<{ release(): void } | null>[], +): Promise { + const settled = new Set(); + opens.forEach((open, index) => { + void open.then((image) => { + image?.release(); + settled.add(index); + }); + }); + for (let round = 0; settled.size < opens.length && round < 100; round += 1) { + for (const fetch of pending) fetch.finish(OK); + await flush(); + } + return settled.size; +} + +describe('MailImageProxyService — 외부 이미지 중계', () => { + let debugSpy: jest.SpyInstance; + beforeEach(() => { + debugSpy = jest + .spyOn(Logger.prototype, 'debug') + .mockImplementation(() => undefined); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('표를 풀어 원래 주소로 받는다 — 받으면 형식 · 바이트와 차례(release 할 때까지 쥔다 · 두 번 불러도 한 번)', async () => { + const { tokens, pending, service } = setup(); + const token = tokens.issue(ARA, SOURCE); + + const opened = service.open(ARA, token, new AbortController().signal); + await flush(); + expect(pending.map(({ source }) => source)).toEqual([SOURCE]); + pending[0].finish(OK); + + const image = await opened; + expect(image).toEqual({ + type: 'image/png', + data: PNG, + release: expect.any(Function) as unknown, + }); + // 받은 뒤에도 응답을 다 보낼 때까지(release) 자리를 쥔다 + expect(service.load(ARA)).toMatchObject({ active: 1, totalActive: 1 }); + image?.release(); + image?.release(); + expect(service.load(ARA)).toEqual({ + active: 0, + waiting: 0, + totalActive: 0, + totalWaiting: 0, + }); + }); + + it('다른 직원의 표 · 고친 표는 바깥에 묻지 않고 null', async () => { + const { tokens, fetcher, service } = setup(); + const token = tokens.issue(ARA, SOURCE); + + await expect( + service.open(DOYUN, token, new AbortController().signal), + ).resolves.toBeNull(); + await expect( + service.open(ARA, `${token}x`, new AbortController().signal), + ).resolves.toBeNull(); + expect(fetcher.fetch).not.toHaveBeenCalled(); + }); + + it('받지 못하면 null 이고 차례를 바로 돌려준다 — 로그(debug)에는 까닭과 바깥 호스트 이름만, 원래 주소는 남기지 않는다', async () => { + const { tokens, pending, service } = setup(); + const opened = service.open( + ARA, + tokens.issue(ARA, SOURCE), + new AbortController().signal, + ); + await flush(); + expect(service.load(ARA).active).toBe(1); + pending[0].finish({ + ok: false, + reason: 'not-public-address', + host: 'news.example.com', + }); + + await expect(opened).resolves.toBeNull(); + expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 }); + const logged = debugSpy.mock.calls.map(([message]) => String(message)); + expect(logged.some((line) => line.includes('not-public-address'))).toBe( + true, + ); + expect(logged.some((line) => line.includes('news.example.com'))).toBe(true); + for (const line of logged) { + expect(line).not.toContain('open.gif'); + expect(line).not.toContain('u=8812'); + expect(line).not.toContain(ARA); + } + }); + + it('받는 중에 클라이언트가 끊으면(받기가 canceled 로 끝남) 차례를 돌려준다', async () => { + const { tokens, pending, service } = setup(); + const leaving = new AbortController(); + const opened = service.open(ARA, tokens.issue(ARA, SOURCE), leaving.signal); + await flush(); + expect(pending[0].signal).toBe(leaving.signal); + + leaving.abort(); + pending[0].finish({ ok: false, reason: 'canceled', host: null }); + await expect(opened).resolves.toBeNull(); + expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 }); + }); + + it('받기가 던져도 차례를 돌려준다', async () => { + const { tokens, fetcher, service } = setup(); + fetcher.fetch.mockRejectedValueOnce(new Error('뜻밖의 오류')); + + await expect( + service.open( + ARA, + tokens.issue(ARA, SOURCE), + new AbortController().signal, + ), + ).rejects.toThrow('뜻밖의 오류'); + expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 }); + }); + + it(`한 직원은 동시에 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지 받고 나머지는 기다린다 — 하나를 돌려주면 다음이 들어간다, 다른 직원은 따로 센다`, async () => { + const { tokens, pending, service } = setup(); + const token = tokens.issue(ARA, SOURCE); + const opens = Array.from( + { length: MAIL_IMAGE_CONCURRENCY_PER_USER + 2 }, + () => service.open(ARA, token, new AbortController().signal), + ); + await flush(); + + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER); + expect(service.load(ARA)).toMatchObject({ + active: MAIL_IMAGE_CONCURRENCY_PER_USER, + waiting: 2, + }); + + const other = service.open( + DOYUN, + tokens.issue(DOYUN, SOURCE), + new AbortController().signal, + ); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1); + + // 받기만 끝나서는 자리가 나지 않는다 — 응답을 다 보내고 돌려줘야(release) 다음이 들어간다 + pending[0].finish(OK); + const first = await opens[0]; + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1); + first?.release(); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 2); + expect(service.load(ARA)).toMatchObject({ + active: MAIL_IMAGE_CONCURRENCY_PER_USER, + waiting: 1, + }); + + // 나머지를 모두 받고 돌려준다 + const rest = [...opens.slice(1), other]; + await expect(drain(pending, rest)).resolves.toBe(rest.length); + expect(service.load(ARA)).toEqual({ + active: 0, + waiting: 0, + totalActive: 0, + totalWaiting: 0, + }); + expect(service.load(DOYUN)).toMatchObject({ active: 0, waiting: 0 }); + }); + + it(`서버 전체는 동시에 ${MAIL_IMAGE_CONCURRENCY}개까지 — ${MAIL_IMAGE_CONCURRENCY + 1}번째는 자기 직원의 자리가 남아도 기다리고, 하나를 돌려주면 들어간다`, async () => { + const { tokens, pending, service } = setup(); + // 직원 하나가 하나씩 — 직원마다의 상한에는 걸리지 않는다 + const opens = Array.from({ length: MAIL_IMAGE_CONCURRENCY }, (_, n) => + service.open( + employee(n), + tokens.issue(employee(n), SOURCE), + new AbortController().signal, + ), + ); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY); + + const late = employee(MAIL_IMAGE_CONCURRENCY); + const waiting = service.open( + late, + tokens.issue(late, SOURCE), + new AbortController().signal, + ); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY); + expect(service.load(late)).toEqual({ + active: 0, + waiting: 1, + totalActive: MAIL_IMAGE_CONCURRENCY, + totalWaiting: 1, + }); + + pending[0].finish(OK); + (await opens[0])?.release(); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY + 1); + expect(pending.at(-1)?.source).toBe(SOURCE); + expect(service.load(late)).toMatchObject({ active: 1, waiting: 0 }); + + for (const fetch of pending.slice(1)) fetch.finish(OK); + for (const image of await Promise.all([...opens.slice(1), waiting])) { + image?.release(); + } + expect(service.load(late)).toEqual({ + active: 0, + waiting: 0, + totalActive: 0, + totalWaiting: 0, + }); + }); + + it('자기 직원의 상한에 막힌 직원은 건너뛴다 — 먼저 기다린 직원이 막혀 있으면 뒤에 온 다른 직원이 먼저 들어가고, 같은 직원끼리는 온 차례다', async () => { + const { tokens, pending, service } = setup(); + const araToken = tokens.issue(ARA, SOURCE); + // 아라가 자기 상한까지 + 하나 더 기다린다 + const araOpens = Array.from( + { length: MAIL_IMAGE_CONCURRENCY_PER_USER + 1 }, + () => service.open(ARA, araToken, new AbortController().signal), + ); + // 전체 상한까지 다른 직원들로 채운다 + const fillers = Array.from( + { length: MAIL_IMAGE_CONCURRENCY - MAIL_IMAGE_CONCURRENCY_PER_USER }, + (_, n) => + service.open( + employee(n), + tokens.issue(employee(n), SOURCE), + new AbortController().signal, + ), + ); + await flush(); + // 도윤은 아라의 기다림 뒤에 선다 + const doyun = service.open( + DOYUN, + tokens.issue(DOYUN, SOURCE), + new AbortController().signal, + ); + await flush(); + expect(service.load(DOYUN)).toMatchObject({ waiting: 1, totalWaiting: 2 }); + + // 다른 직원(아라가 아닌) 하나가 돌려주면 — 앞의 아라는 자기 상한에 막혀 있으니 도윤이 들어간다 + const fillerIndex = MAIL_IMAGE_CONCURRENCY_PER_USER; + pending[fillerIndex].finish(OK); + (await fillers[0])?.release(); + await flush(); + expect(service.load(DOYUN)).toMatchObject({ active: 1, waiting: 0 }); + expect(service.load(ARA)).toMatchObject({ + active: MAIL_IMAGE_CONCURRENCY_PER_USER, + waiting: 1, + }); + + // 아라 하나가 돌려주면 기다리던 아라가 들어간다 + pending[0].finish(OK); + (await araOpens[0])?.release(); + await flush(); + expect(service.load(ARA)).toMatchObject({ + active: MAIL_IMAGE_CONCURRENCY_PER_USER, + waiting: 0, + }); + + for (const fetch of pending) fetch.finish(OK); + for (const image of await Promise.all([...araOpens, ...fillers, doyun])) { + image?.release(); + } + expect(service.load(ARA).totalActive).toBe(0); + }); + + it('전체 자리가 나면 지금 쥔 자리가 가장 적은 직원부터(같으면 먼저 기다린 쪽) — 이미지가 많은 메일을 연 직원들이 자리를 돌려 가며 차지하지 않는다 (9단계 리뷰 2)', async () => { + // 리뷰의 장면 그대로 — 전체 16 · 직원 6 + expect([MAIL_IMAGE_CONCURRENCY, MAIL_IMAGE_CONCURRENCY_PER_USER]).toEqual([ + 16, 6, + ]); + const { tokens, pending, service } = setup(); + const [a, b, c, d] = [employee(1), employee(2), employee(3), employee(4)]; + const openAs = (user: string, count: number) => + Array.from({ length: count }, () => + service.open( + user, + tokens.issue(user, SOURCE), + new AbortController().signal, + ), + ); + // A · B · C 가 이미지가 많은 메일을 연다 — 전체 16자리를 6 · 6 · 4로 채우고 각자 5장씩 기다린다 + const aOpens = openAs(a, 6 + 5); + await flush(); + const bOpens = openAs(b, 6 + 5); + await flush(); + const cOpens = openAs(c, 4 + 5); + await flush(); + expect(service.load(a)).toMatchObject({ + active: 6, + waiting: 5, + totalActive: 16, + }); + expect(service.load(b)).toMatchObject({ active: 6, waiting: 5 }); + expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 }); + // 뒤에 온 D 는 한 장 + const dOpens = openAs(d, 1); + await flush(); + expect(service.load(d)).toMatchObject({ active: 0, waiting: 1 }); + + /** 받는 중인 요청 하나를 끝내고 차례를 돌려준다 — pending 은 들어간 차례(A 0~5 · B 6~11 · C 12~15) */ + const finishAndRelease = async ( + fetchIndex: number, + open: Promise<{ release(): void } | null>, + ) => { + pending[fetchIndex].finish(OK); + (await open)?.release(); + await flush(); + }; + + // A 가 한 장을 돌려준다 — 자리를 비운 A 의 다음 요청(먼저 기다렸다)이 아니라 쥔 자리가 없는 D 가 들어간다 + await finishAndRelease(0, aOpens[0]); + expect(service.load(d)).toMatchObject({ active: 1, waiting: 0 }); + expect(service.load(a)).toMatchObject({ active: 5, waiting: 5 }); + + // A 가 또 하나 — 쥔 자리가 A 4 · C 4 로 같다: 먼저 기다린 A + await finishAndRelease(1, aOpens[1]); + expect(service.load(a)).toMatchObject({ active: 5, waiting: 4 }); + expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 }); + + // B 가 하나 — A 5 · B 5 · C 4 → C + await finishAndRelease(6, bOpens[0]); + expect(service.load(c)).toMatchObject({ active: 5, waiting: 4 }); + expect(service.load(b)).toMatchObject({ active: 5, waiting: 5 }); + + const all = [...aOpens, ...bOpens, ...cOpens, ...dOpens]; + expect(await drain(pending, all)).toBe(all.length); + expect(service.load(a)).toEqual({ + active: 0, + waiting: 0, + totalActive: 0, + totalWaiting: 0, + }); + }); + + it('기다리다 클라이언트가 끊으면 차례에서 빠지고 바깥에 묻지 않는다', async () => { + const { tokens, pending, service } = setup(); + const token = tokens.issue(ARA, SOURCE); + const holders = Array.from( + { length: MAIL_IMAGE_CONCURRENCY_PER_USER }, + () => service.open(ARA, token, new AbortController().signal), + ); + const leaving = new AbortController(); + const waiting = service.open(ARA, token, leaving.signal); + await flush(); + expect(service.load(ARA).waiting).toBe(1); + + leaving.abort(); + await expect(waiting).resolves.toBeNull(); + expect(service.load(ARA)).toMatchObject({ waiting: 0, totalWaiting: 0 }); + + for (const fetch of pending) fetch.finish(OK); + for (const image of await Promise.all(holders)) image?.release(); + await flush(); + expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER); + expect(service.load(ARA).totalActive).toBe(0); + }); + + it(`${MAIL_IMAGE_QUEUE_WAIT_MS / 1000}초를 넘게 기다리면 받지 않는다 · 기다리는 수가 ${MAIL_IMAGE_MAX_WAITING_PER_USER}을 넘으면 바로 null`, async () => { + jest.useFakeTimers(); + const { tokens, pending, service } = setup(); + const token = tokens.issue(ARA, SOURCE); + for (let i = 0; i < MAIL_IMAGE_CONCURRENCY_PER_USER; i += 1) { + void service.open(ARA, token, new AbortController().signal); + } + const waiters = Array.from( + { length: MAIL_IMAGE_MAX_WAITING_PER_USER }, + () => service.open(ARA, token, new AbortController().signal), + ); + await flush(); + await expect( + service.open(ARA, token, new AbortController().signal), + ).resolves.toBeNull(); + + jest.advanceTimersByTime(MAIL_IMAGE_QUEUE_WAIT_MS); + await expect(Promise.all(waiters)).resolves.toEqual( + waiters.map(() => null), + ); + expect(service.load(ARA)).toMatchObject({ + active: MAIL_IMAGE_CONCURRENCY_PER_USER, + waiting: 0, + totalWaiting: 0, + }); + for (const fetch of pending) fetch.finish(OK); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image-proxy.service.ts b/backend/src/modules/mail/images/mail-image-proxy.service.ts new file mode 100644 index 0000000..f810f34 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-proxy.service.ts @@ -0,0 +1,237 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + MAIL_IMAGE_CONCURRENCY, + MAIL_IMAGE_CONCURRENCY_PER_USER, + MAIL_IMAGE_MAX_WAITING_PER_USER, + MAIL_IMAGE_QUEUE_WAIT_MS, +} from './mail-image.constants'; +import { MailImageFetcher } from './mail-image-fetcher'; +import type { ProxiedImageType } from './mail-image-format'; +import { MailImageTokens } from './mail-image-token'; + +/** + * 중계해 줄 이미지 — 받은 차례를 쥐고 있다. 응답을 다 보냈거나 연결이 닫혔을 때 release() 를 부른다 + * (그때까지의 버퍼도 동시 수에 든다 — 느린 브라우저에 보내는 동안의 메모리까지 센다). 여러 번 불러도 한 번만 돌려준다 + */ +export interface ProxiedImage { + readonly type: ProxiedImageType; + readonly data: Buffer; + readonly release: () => void; +} + +/** 차례를 기다리는 요청 하나 — 그 직원의 줄(온 차례)에 선다 */ +interface Waiter { + readonly key: string; + /** 줄에 선 차례 — 쥔 자리가 같은 직원끼리는 먼저 온 쪽 */ + readonly seq: number; + readonly admit: () => void; +} + +/** + * 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — 표를 풀어 원래 주소를 얻고 바깥에서 받는다. + * - 동시 수: 프로세스 전체 MAIL_IMAGE_CONCURRENCY(16)개 · 한 직원 MAIL_IMAGE_CONCURRENCY_PER_USER(6)개 — 넘으면 **직원마다의 줄**(온 차례)에서 기다린다. + * 자리가 나면 기다리는 직원 가운데 **지금 쥔 자리가 가장 적은 직원**의 맨 앞을 들인다(같으면 먼저 온 쪽, 자기 6개에 막힌 직원은 건너뛴다) — + * 이미지가 많은 뉴스레터를 연 직원 몇이 전체 자리를 돌려 가며 차지해 다른 직원의 그림이 30초 기다림으로 비지 않게(2026-10-29 리뷰 2). + * 한 장이 5MB 까지라 전체 상한이 없으면 여러 직원이 함께 열 때 메모리가 수 GB 로 는다(2026-10-29 리뷰 — 운영 backend 한도 1g) + * - 차례는 받기부터 응답을 다 보내거나 연결이 닫힐 때까지 쥔다(ProxiedImage.release — 컨트롤러가 부른다). 받지 못하면 여기서 돌려준다 + * - 실패는 까닭을 가리지 않고 null — 컨트롤러가 모두 404 RES_001 로 답한다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님) + * - 로그: 원래 주소는 남기지 않는다(추적 번호 · 개인 정보가 든다) — 실패 까닭과 바깥 호스트 이름만 debug + * - 기다림: 30초를 넘거나 한 직원의 기다리는 수가 200 을 넘으면 받지 않는다(null). 클라이언트가 끊으면 기다리던 자리에서 빠진다 + * + * ⚠️ 동시 수는 프로세스 메모리에서 센다 — 인스턴스를 늘리면 인스턴스마다 16 · 6개다 (바깥으로 나가는 양이 그만큼 는다 — 필요하면 공유 셈) + */ +@Injectable() +export class MailImageProxyService { + private readonly logger = new Logger(MailImageProxyService.name); + /** 받는 중(차례를 쥔) 수 — 전체 · 직원마다 */ + private active = 0; + private readonly activeByUser = new Map(); + /** 기다리는 줄 — 직원마다 온 차례 (빈 줄은 두지 않는다) */ + private readonly lines = new Map(); + private waitingTotal = 0; + private nextSeq = 0; + + constructor( + private readonly tokens: MailImageTokens, + private readonly fetcher: MailImageFetcher, + ) {} + + /** + * 표의 이미지를 받는다 — 받지 못하면 null(차례는 이미 돌려줬다). 받았으면 차례를 쥔 채 돌려준다 — 부른 쪽이 release() 한다. + * @param signal 클라이언트가 끊으면 기다림 · 받기를 멈춘다 + */ + async open( + user: string, + token: string, + signal: AbortSignal, + ): Promise { + const source = this.tokens.open(token, user); + if (source === null) { + this.debug(user, 'bad-token', null); + return null; + } + + const release = await this.acquire(user, signal); + if (release === null) { + this.debug(user, signal.aborted ? 'canceled' : 'queue-full', null); + return null; + } + let handedOver = false; + try { + const result = await this.fetcher.fetch(source, signal); + if (!result.ok) { + this.debug(user, result.reason, result.host); + return null; + } + handedOver = true; + return { type: result.type, data: result.data, release }; + } finally { + if (!handedOver) release(); + } + } + + /** 지금 받는 수 · 기다리는 수 — 그 직원 · 전체 (테스트 · 진단용) */ + load(user: string): { + readonly active: number; + readonly waiting: number; + readonly totalActive: number; + readonly totalWaiting: number; + } { + const key = slotKey(user); + return { + active: this.activeByUser.get(key) ?? 0, + waiting: this.lines.get(key)?.length ?? 0, + totalActive: this.active, + totalWaiting: this.waitingTotal, + }; + } + + /** 차례를 얻는다 — 얻으면 돌려줄 함수(한 번만 돈다), 못 얻으면(기다림이 넘침 · 너무 오래 · 끊김) null */ + private acquire( + user: string, + signal: AbortSignal, + ): Promise<(() => void) | null> { + const key = slotKey(user); + if (signal.aborted) return Promise.resolve(null); + // 비어 있는 자리가 있는데도 줄에 남은 사람은 자기 직원의 상한에 막힌 사람뿐이다(자리가 날 때마다 들일 수 있는 만큼 들인다) — 앞지르지 않는다 + if (this.canStart(key)) { + this.start(key); + return Promise.resolve(this.releaser(key)); + } + const line = this.lines.get(key) ?? []; + if (line.length >= MAIL_IMAGE_MAX_WAITING_PER_USER) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const leave = () => { + clearTimeout(timer); + signal.removeEventListener('abort', giveUp); + const index = line.indexOf(waiter); + if (index < 0) return; + line.splice(index, 1); + this.waitingTotal -= 1; + if (line.length === 0 && this.lines.get(key) === line) { + this.lines.delete(key); + } + }; + const giveUp = () => { + leave(); + resolve(null); + }; + const waiter: Waiter = { + key, + seq: this.nextSeq++, + admit: () => { + leave(); + this.start(key); + resolve(this.releaser(key)); + }, + }; + const timer = setTimeout(giveUp, MAIL_IMAGE_QUEUE_WAIT_MS); + timer.unref(); + signal.addEventListener('abort', giveUp, { once: true }); + line.push(waiter); + this.waitingTotal += 1; + this.lines.set(key, line); + }); + } + + /** 전체 · 그 직원의 자리가 남았나 */ + private canStart(key: string): boolean { + return ( + this.active < MAIL_IMAGE_CONCURRENCY && + (this.activeByUser.get(key) ?? 0) < MAIL_IMAGE_CONCURRENCY_PER_USER + ); + } + + private start(key: string): void { + this.active += 1; + this.count(this.activeByUser, key, 1); + } + + /** 차례를 돌려주는 함수 — 여러 번 불러도 한 번만 돌려준다 */ + private releaser(key: string): () => void { + let released = false; + return () => { + if (released) return; + released = true; + this.active = Math.max(0, this.active - 1); + this.count(this.activeByUser, key, -1); + this.admitWaiting(); + }; + } + + /** 자리가 남는 동안 다음 사람을 들인다 — 전체 자리가 차거나 들일 수 있는 사람이 없으면 멈춘다 */ + private admitWaiting(): void { + while (this.active < MAIL_IMAGE_CONCURRENCY) { + const next = this.nextWaiter(); + if (next === null) return; + // admit 이 줄에서 빼고 자리를 잡는다 + next.admit(); + } + } + + /** + * 다음에 들일 요청 — 자기 상한(6)에 막히지 않은 직원 가운데 지금 쥔 자리가 가장 적은 직원의 맨 앞(같으면 먼저 온 쪽). + * 기다리는 직원 수에 비례한다 (직원마다 줄의 맨 앞만 본다) + */ + private nextWaiter(): Waiter | null { + let best: Waiter | null = null; + let bestHeld = Number.POSITIVE_INFINITY; + for (const [key, line] of this.lines) { + const held = this.activeByUser.get(key) ?? 0; + if (held >= MAIL_IMAGE_CONCURRENCY_PER_USER) continue; + const head = line[0]; + if ( + best === null || + held < bestHeld || + (held === bestHeld && head.seq < best.seq) + ) { + best = head; + bestHeld = held; + } + } + return best; + } + + /** 직원마다의 셈 — 0 이 되면 지운다 (줄지 않는 표를 두지 않는다) */ + private count(map: Map, key: string, delta: number): void { + const next = (map.get(key) ?? 0) + delta; + if (next > 0) map.set(key, next); + else map.delete(key); + } + + private debug(user: string, reason: string, host: string | null): void { + this.logger.debug( + `${traceTag()} 바깥 이미지를 주지 않았습니다: user=${maskEmail(user)}, 까닭=${reason}${host ? `, host=${host}` : ''}`, + ); + } +} + +function slotKey(user: string): string { + return user.trim().toLowerCase(); +} diff --git a/backend/src/modules/mail/images/mail-image-token.spec.ts b/backend/src/modules/mail/images/mail-image-token.spec.ts new file mode 100644 index 0000000..f57b516 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-token.spec.ts @@ -0,0 +1,93 @@ +import { + MAIL_IMAGE_TOKEN_TTL_MS, + MAX_MAIL_IMAGE_TOKEN_LENGTH, + MAX_PROXIED_IMAGE_URL_LENGTH, +} from './mail-image.constants'; +import { MailImageTokens } from './mail-image-token'; + +const ARA = 'ara.kim@arasystem.test'; +const DOYUN = 'doyun.lee@arasystem.test'; +const URL = 'https://news.example.com/open.gif?u=8812&c=newsletter-10'; +const NOW = Date.parse('2026-10-29T06:00:00.000Z'); + +/** base64url 표의 한 바이트를 바꾼다 */ +function tamper(token: string, index: number): string { + const raw = Buffer.from(token, 'base64url'); + raw[index] ^= 0x01; + return raw.toString('base64url'); +} + +describe('MailImageTokens — 외부 이미지 중계 표', () => { + it('만든 표를 그 직원이 1시간 안에 풀면 원래 주소다 (주소의 대소문자는 가리지 않는다)', () => { + const tokens = new MailImageTokens(); + const token = tokens.issue(ARA, URL, NOW); + + expect(tokens.open(token, ARA, NOW)).toBe(URL); + expect(tokens.open(token, 'ARA.Kim@ArASystem.test', NOW + 1000)).toBe(URL); + expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS - 1)).toBe( + URL, + ); + }); + + it('표에는 원래 주소가 보이지 않는다 — 암호화한 불투명 글(base64url)이고, 같은 주소도 표마다 다르다', () => { + const tokens = new MailImageTokens(); + const token = tokens.issue(ARA, URL, NOW); + + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + const decoded = Buffer.from(token, 'base64url').toString('latin1'); + for (const piece of ['news.example.com', 'open.gif', 'u=8812', 'ara.kim']) { + expect(token).not.toContain(piece); + expect(decoded).not.toContain(piece); + } + expect(tokens.issue(ARA, URL, NOW)).not.toBe(token); + }); + + it('지난 표 · 다른 직원 · 다른 서버(키)의 표는 풀리지 않는다', () => { + const tokens = new MailImageTokens(); + const token = tokens.issue(ARA, URL, NOW); + + expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS)).toBeNull(); + expect(tokens.open(token, DOYUN, NOW)).toBeNull(); + expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull(); + }); + + it('한 바이트라도 고친 표는 풀리지 않는다 (인증 암호 — 판 · iv · 암호문 · 태그 어디든)', () => { + const tokens = new MailImageTokens(); + const token = tokens.issue(ARA, URL, NOW); + const length = Buffer.from(token, 'base64url').length; + + for (const index of [0, 1, 12, 13, 20, length - 17, length - 1]) { + expect(tokens.open(tamper(token, index), ARA, NOW)).toBeNull(); + } + }); + + it.each([ + ['빈 글', ''], + ['base64url 이 아닌 글자', 'abc.def'], + ['슬래시', 'abc/def'], + ['너무 짧음', 'AQ'], + ['너무 긺', 'A'.repeat(MAX_MAIL_IMAGE_TOKEN_LENGTH + 1)], + ])('모양이 틀린 표(%s)는 풀어 보지 않는다', (_label, token) => { + expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull(); + }); + + it(`가장 긴 주소(${MAX_PROXIED_IMAGE_URL_LENGTH}자)의 표도 표 길이 상한 안이다 — 한글이 든 주소도 정규화하면 퍼센트 인코딩이라 같은 상한`, () => { + const tokens = new MailImageTokens(); + const longUrl = `https://img.example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH - 24)}`; + expect(longUrl).toHaveLength(MAX_PROXIED_IMAGE_URL_LENGTH); + const token = tokens.issue( + 'very.long.local.part.of.an.employee@arasystem.test', + longUrl, + NOW, + ); + + expect(token.length).toBeLessThanOrEqual(MAX_MAIL_IMAGE_TOKEN_LENGTH); + expect( + tokens.open( + token, + 'very.long.local.part.of.an.employee@arasystem.test', + NOW, + ), + ).toBe(longUrl); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image-token.ts b/backend/src/modules/mail/images/mail-image-token.ts new file mode 100644 index 0000000..54d0394 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-token.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +import { + MAIL_IMAGE_TOKEN_TTL_MS, + MAX_MAIL_IMAGE_TOKEN_LENGTH, +} from './mail-image.constants'; + +/** + * 외부 이미지 중계 표 (계약 2절 "외부 이미지 보기" — 9단계) — `/api/mail-images/<표>` 의 표. + * + * 표는 직원 · 원래 이미지 주소 · 끝나는 시각(1시간)을 **서버 비밀로 암호화한** 불투명 글이다 — 인증 암호 AES-256-GCM. + * - 서명만 하면 표를 풀어 원래 주소(추적 번호 · 개인 정보가 든다)를 읽을 수 있어, 접근 로그 · 주소창 · 브라우저 기록에 그대로 남는다 — 그래서 암호화한다 + * - 인증 암호라 고친 표는 풀리지 않는다(태그가 맞지 않음) — 다른 직원 · 다른 주소로 바꿔 쓸 수 없다 + * - 키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 풀리지 않고(404), 메일을 다시 열면 새 표를 받는다. + * ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다 (다른 인스턴스가 만든 표를 풀지 못한다) + * - 모양: base64url( 판 1바이트 | iv 12바이트 | 암호문 | 태그 16바이트 ), 추가 인증 자료(AAD)에 용도 이름을 넣어 다른 용도의 암호문과 섞이지 않게 한다 + */ + +const ALGORITHM = 'aes-256-gcm'; +const KEY_BYTES = 32; +const IV_BYTES = 12; +const TAG_BYTES = 16; +/** 표 모양의 판 — 모양을 바꾸면 올린다 (옛 판은 풀지 않는다) */ +const TOKEN_VERSION = 1; +const AAD = Buffer.from('arasystem/mail-image-token/v1', 'utf8'); +/** + * base64url 글자만(채움 `=` 없이) — 그 밖의 글자가 든 경로는 풀어 보지 않는다. + * 짝: frontend `src/shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`(정화가 남기는 중계 주소의 표 글자) — 한쪽을 고치면 둘 다 + */ +const TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** 표 안의 것 — 짧은 키로 (표 길이를 줄인다) */ +interface TokenPayload { + /** 직원 주소 (소문자) */ + readonly u: string; + /** 원래 이미지 주소 (정규화한 http · https 주소) */ + readonly s: string; + /** 끝나는 시각 (ms) */ + readonly e: number; +} + +function userKey(user: string): string { + return user.trim().toLowerCase(); +} + +function isPayload(value: unknown): value is TokenPayload { + if (typeof value !== 'object' || value === null) return false; + const { u, s, e } = value as Partial>; + return ( + typeof u === 'string' && + typeof s === 'string' && + typeof e === 'number' && + Number.isSafeInteger(e) + ); +} + +@Injectable() +export class MailImageTokens { + private readonly key = randomBytes(KEY_BYTES); + + /** 그 직원이 그 주소의 이미지를 받을 표 — 지금부터 1시간 */ + issue(user: string, url: string, nowMs: number = Date.now()): string { + const payload: TokenPayload = { + u: userKey(user), + s: url, + e: nowMs + MAIL_IMAGE_TOKEN_TTL_MS, + }; + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, this.key, iv, { + authTagLength: TAG_BYTES, + }); + cipher.setAAD(AAD); + const sealed = Buffer.concat([ + cipher.update(JSON.stringify(payload), 'utf8'), + cipher.final(), + ]); + return Buffer.concat([ + Buffer.from([TOKEN_VERSION]), + iv, + sealed, + cipher.getAuthTag(), + ]).toString('base64url'); + } + + /** + * 표를 푼다 — 그 직원의 것이고 끝나지 않았으면 원래 주소, 아니면 null. + * 까닭(모양이 틀림 · 다른 키 · 고침 · 지남 · 다른 직원)을 가리지 않는다 — 부르는 쪽은 모두 404 로 답한다 + */ + open(token: string, user: string, nowMs: number = Date.now()): string | null { + if (token.length > MAX_MAIL_IMAGE_TOKEN_LENGTH) return null; + if (!TOKEN_PATTERN.test(token)) return null; + const raw = Buffer.from(token, 'base64url'); + if (raw.length < 1 + IV_BYTES + TAG_BYTES + 1) return null; + if (raw[0] !== TOKEN_VERSION) return null; + + const iv = raw.subarray(1, 1 + IV_BYTES); + const tag = raw.subarray(raw.length - TAG_BYTES); + const sealed = raw.subarray(1 + IV_BYTES, raw.length - TAG_BYTES); + let plain: string; + try { + const decipher = createDecipheriv(ALGORITHM, this.key, iv, { + authTagLength: TAG_BYTES, + }); + decipher.setAAD(AAD); + decipher.setAuthTag(tag); + plain = Buffer.concat([ + decipher.update(sealed), + decipher.final(), + ]).toString('utf8'); + } catch { + // 다른 키 · 고친 표 — 태그가 맞지 않는다 + return null; + } + + let payload: unknown; + try { + payload = JSON.parse(plain); + } catch { + return null; + } + if (!isPayload(payload)) return null; + if (payload.u !== userKey(user)) return null; + if (payload.e <= nowMs) return null; + return payload.s; + } +} diff --git a/backend/src/modules/mail/images/mail-image-url.spec.ts b/backend/src/modules/mail/images/mail-image-url.spec.ts new file mode 100644 index 0000000..911acfa --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-url.spec.ts @@ -0,0 +1,80 @@ +import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants'; +import { + checkProxiedImageUrl, + hostOf, + parseProxiedImageUrl, +} from './mail-image-url'; + +describe('parseProxiedImageUrl — 중계할 수 있는 바깥 이미지 주소', () => { + it.each([ + ['https://news.example.com/a.png', 'https://news.example.com/a.png'], + ['http://news.example.com/a.png?x=1', 'http://news.example.com/a.png?x=1'], + [' https://news.example.com/a.png ', 'https://news.example.com/a.png'], + ['//cdn.example.com/a.gif', 'https://cdn.example.com/a.gif'], + ['HTTPS://News.Example.COM/A.png', 'https://news.example.com/A.png'], + ['http://news.example.com:80/a.png', 'http://news.example.com/a.png'], + ['https://news.example.com:443/a.png', 'https://news.example.com/a.png'], + [ + 'https://뉴스.example.com/그림.png', + 'https://xn--vg1b002a.example.com/%EA%B7%B8%EB%A6%BC.png', + ], + [ + 'https://news.example.com/a.png#frag', + 'https://news.example.com/a.png#frag', + ], + ])('%j → %s', (src, href) => { + expect(parseProxiedImageUrl(src)?.href).toBe(href); + }); + + it.each([ + ['빈 글', ''], + ['상대 주소', '/images/a.png'], + ['data:', 'data:image/png;base64,AAAA'], + ['cid:', 'cid:logo@x'], + ['javascript:', 'javascript:alert(1)'], + ['ftp:', 'ftp://example.com/a.png'], + ['file:', 'file:///etc/passwd'], + ['기본 포트가 아님', 'https://example.com:8443/a.png'], + ['http 에 443', 'http://example.com:443/a.png'], + ['메일 엔진 관리 포트', 'http://mail:8080/api'], + ['사용자 정보', 'https://user:pass@example.com/a.png'], + ['사용자 이름만', 'https://user@example.com/a.png'], + ['깨진 주소', 'https://'], + [ + '너무 긺', + `https://example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH)}`, + ], + ])('%s(%j)은 중계하지 않는다', (_label, src) => { + expect(parseProxiedImageUrl(src)).toBeNull(); + }); + + it('정규화해서 2,048자를 넘는 주소(퍼센트 인코딩으로 늘어남)도 중계하지 않는다', () => { + const src = `https://example.com/${'가'.repeat(400)}`; + expect(src.length).toBeLessThan(MAX_PROXIED_IMAGE_URL_LENGTH); + expect(parseProxiedImageUrl(src)).toBeNull(); + }); + + it('숫자로 쓴 IP 주소는 정규화한다 — 사설 · 루프백인지는 받을 때 본다 (10진 · 16진 표기도 같은 주소)', () => { + expect(parseProxiedImageUrl('http://2130706433/a.png')?.hostname).toBe( + '127.0.0.1', + ); + expect(parseProxiedImageUrl('http://0x7f.1/a.png')?.hostname).toBe( + '127.0.0.1', + ); + expect(hostOf(parseProxiedImageUrl('http://[::1]/a.png') as URL)).toBe( + '::1', + ); + }); + + it('다시 가기 대상도 같은 검사 — 다른 스킴 · 포트 · 사용자 정보는 막는다', () => { + const base = new URL('https://news.example.com/a.png'); + expect(checkProxiedImageUrl(new URL('/b.png', base))?.href).toBe( + 'https://news.example.com/b.png', + ); + expect(checkProxiedImageUrl(new URL('http://127.0.0.1:8080/'))).toBeNull(); + expect(checkProxiedImageUrl(new URL('gopher://example.com/'))).toBeNull(); + expect( + checkProxiedImageUrl(new URL('https://a:b@example.com/')), + ).toBeNull(); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image-url.ts b/backend/src/modules/mail/images/mail-image-url.ts new file mode 100644 index 0000000..9516ed1 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image-url.ts @@ -0,0 +1,46 @@ +import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants'; + +/** + * 중계할 수 있는 바깥 이미지 주소 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 메일의 `` 를 중계 주소로 바꿀 때와, + * 바깥이 다시 가라고 한 주소(redirect)를 따라갈 때 같은 검사를 한다. + * - http · https 만 — `//host/…`(프로토콜 상대)는 https 로 본다 + * - 기본 포트만(80 · 443) — WHATWG URL 은 기본 포트를 빈 글로 둔다(`http://a:80` → 포트 없음). 그 밖의 포트는 사내 서비스 포트 찌르기에 쓰인다 + * - 사용자 정보(`user:pass@`) 없음 + * - 길이: 원래 글 · 정규화한 주소 모두 2,048자 이하 + * 이름(호스트)이 공인 주소로 풀리는지는 받을 때 본다 (mail-image-fetcher.ts — 이름은 그때그때 다른 주소로 풀릴 수 있다) + */ + +const ALLOWED_PROTOCOLS: ReadonlySet = new Set(['http:', 'https:']); + +/** 이미 만든 주소(다시 가기 대상 등)가 중계할 수 있는 주소인가 — 그렇다면 그 주소, 아니면 null */ +export function checkProxiedImageUrl(url: URL): URL | null { + if (!ALLOWED_PROTOCOLS.has(url.protocol)) return null; + if (url.port !== '') return null; + if (url.username !== '' || url.password !== '') return null; + if (url.hostname === '') return null; + if (url.href.length > MAX_PROXIED_IMAGE_URL_LENGTH) return null; + return url; +} + +/** `` 의 글 → 중계할 주소 (정규화한 URL) — 중계할 수 없으면 null */ +export function parseProxiedImageUrl(src: string): URL | null { + const trimmed = src.trim(); + if (trimmed === '' || trimmed.length > MAX_PROXIED_IMAGE_URL_LENGTH) { + return null; + } + let url: URL; + try { + url = new URL(trimmed.startsWith('//') ? `https:${trimmed}` : trimmed); + } catch { + return null; + } + return checkProxiedImageUrl(url); +} + +/** 주소의 호스트 — IPv6 는 대괄호를 뗀 모양 (연결 · 주소 검사에 쓴다) */ +export function hostOf(url: URL): string { + const { hostname } = url; + return hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; +} diff --git a/backend/src/modules/mail/images/mail-image.constants.ts b/backend/src/modules/mail/images/mail-image.constants.ts new file mode 100644 index 0000000..fee867e --- /dev/null +++ b/backend/src/modules/mail/images/mail-image.constants.ts @@ -0,0 +1,58 @@ +/** + * 외부 이미지 보기의 한도 (계약 2절 "외부 이미지 보기" — 9단계) — 숫자를 한 곳에 모은다. + */ + +/** 메일 한 통에서 중계 주소로 바꾸는 이미지 수 — 그 뒤의 외부 이미지는 지운다 */ +export const MAX_PROXIED_IMAGES_PER_MESSAGE = 100; + +/** 중계할 이미지 주소의 길이 (JS 문자열 길이 — 원래 글과 정규화한 주소 모두) — 넘으면 지운다 */ +export const MAX_PROXIED_IMAGE_URL_LENGTH = 2_048; + +/** 중계 표가 유효한 시간 — 메일을 다시 열면 새 표를 받는다 */ +export const MAIL_IMAGE_TOKEN_TTL_MS = 60 * 60_000; + +/** + * 표 글의 최대 길이 — 주소 2,048자에 직원 주소 · 끝 시각 · 암호 덧붙임(iv · 인증 태그)이 base64url 로 늘어난 것보다 넉넉히. + * 넘는 경로는 풀어 보지 않고 404 다 + */ +export const MAX_MAIL_IMAGE_TOKEN_LENGTH = 4_096; + +/** 바깥 이미지를 받는 전체 시간 — 이름 풀기 · 연결 · 다시 가기 · 본문 읽기 모두 합쳐 */ +export const MAIL_IMAGE_FETCH_TIMEOUT_MS = 10_000; + +/** 받는 크기 상한 — 넘으면 끊는다 */ +export const MAIL_IMAGE_MAX_BYTES = 5 * 1024 * 1024; + +/** 다시 가기(redirect)를 따라가는 횟수 — 매번 주소 · 이름 풀기 · 공인 주소 검사를 다시 한다 */ +export const MAIL_IMAGE_MAX_REDIRECTS = 3; + +/** 한 직원이 동시에 받는 수 — 넘으면 기다린다 */ +export const MAIL_IMAGE_CONCURRENCY_PER_USER = 6; + +/** + * 프로세스 전체에서 동시에 받는 수 — 넘으면 기다리고, 자리가 나면 지금 받는 수가 가장 적은 직원부터 들인다(mail-image-proxy.service.ts). 한 장이 MAIL_IMAGE_MAX_BYTES(5MB)까지라 + * 전체로 대략 16 × 5MB 를 넘게 쥐지 않는다 (운영 backend 메모리 한도 1g — 2026-10-29 리뷰). 차례는 응답을 다 보낼 때까지 쥔다 + */ +export const MAIL_IMAGE_CONCURRENCY = 16; + +/** + * 차례를 기다리는 최대 시간 — 넘으면 받지 않고 404 (앞단 nginx 의 /api 응답 대기 60초보다 짧게). + * 바깥 서버가 느린 이미지가 많은 메일에서 기다림이 끝없이 쌓이지 않게 둔다 + */ +export const MAIL_IMAGE_QUEUE_WAIT_MS = 30_000; + +/** 한 직원이 차례를 기다릴 수 있는 수 — 넘으면 바로 404 (뉴스레터 한 통의 이미지 수보다 넉넉히) */ +export const MAIL_IMAGE_MAX_WAITING_PER_USER = 200; + +/** 이 경로의 요청 수 한도 — IP 마다 1분 600회 (뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT) */ +export const MAIL_IMAGE_THROTTLE_TTL_MS = 60_000; +export const MAIL_IMAGE_THROTTLE_LIMIT = 600; + +/** 바깥으로 묻는 고정 User-Agent — 브라우저의 것을 넘기지 않는다 */ +export const MAIL_IMAGE_USER_AGENT = 'ARA-SYSTEM-Mail-Image-Proxy/1.0'; + +/** 응답의 브라우저 캐시 — 표(주소)마다 1시간, 공유 캐시에는 두지 않는다 */ +export const MAIL_IMAGE_CACHE_CONTROL = 'private, max-age=3600'; + +/** 응답의 CSP — 그림을 문서로 열어도 아무것도 불러오거나 실행하지 않게 */ +export const MAIL_IMAGE_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox"; diff --git a/backend/src/modules/mail/images/mail-image.controller.spec.ts b/backend/src/modules/mail/images/mail-image.controller.spec.ts new file mode 100644 index 0000000..d143822 --- /dev/null +++ b/backend/src/modules/mail/images/mail-image.controller.spec.ts @@ -0,0 +1,168 @@ +import { EventEmitter } from 'events'; +import type { Request, Response } from 'express'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { pngBytes } from '@/../test/image-fixtures'; +import { MailImageController, releaseWhenDone } from './mail-image.controller'; +import type { + MailImageProxyService, + ProxiedImage, +} from './mail-image-proxy.service'; + +const ARA: AuthenticatedUser = { + email: 'ara.kim@arasystem.test', + name: '김아라', +}; +const PNG = pngBytes(4, 4); + +/** express 응답 대역 — 머리글 · 끝 · 닫힘만 흉내 낸다 (finish 는 테스트가 낸다 — 느린 브라우저) */ +class FakeResponse extends EventEmitter { + statusCode = 200; + headersSent = false; + writableEnded = false; + writableFinished = false; + destroyed = false; + readonly headers = new Map(); + body: unknown = null; + + status(code: number): this { + this.statusCode = code; + return this; + } + setHeader(name: string, value: string): this { + this.headers.set(name.toLowerCase(), value); + return this; + } + end(data?: unknown): this { + this.headersSent = true; + this.writableEnded = true; + this.body = data ?? null; + return this; + } + json(data: unknown): this { + return this.end(data); + } + /** 다 보냈다 (커널에 넘겼다) */ + finish(): void { + this.writableFinished = true; + this.emit('finish'); + this.emit('close'); + } + /** 브라우저가 끊었다 */ + hangUp(): void { + this.destroyed = true; + this.emit('close'); + } +} + +const request = { + header: () => 'trace-1', +} as unknown as Request; + +function lease(): ProxiedImage & { release: jest.Mock } { + return { type: 'image/png', data: PNG, release: jest.fn() }; +} + +function controllerWith(open: MailImageProxyService['open']) { + const proxy = { open: jest.fn(open) }; + return { + proxy, + controller: new MailImageController( + proxy as unknown as MailImageProxyService, + ), + }; +} + +describe('MailImageController — 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 (9단계 리뷰 뒤)', () => { + it('보내면 끝(finish)에서 돌려준다 — 그 전에는 돌려주지 않는다, 끝과 닫힘이 둘 다 와도 한 번', async () => { + const image = lease(); + const { controller } = controllerWith(() => Promise.resolve(image)); + const res = new FakeResponse(); + + await controller.get(ARA, 'tok', request, res as unknown as Response); + + expect(res.statusCode).toBe(200); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('content-length')).toBe(String(PNG.length)); + expect(res.body).toBe(PNG); + // 느린 브라우저 — 아직 다 보내지 못했다 + expect(image.release).not.toHaveBeenCalled(); + + res.finish(); + expect(image.release).toHaveBeenCalledTimes(1); + }); + + it('보내는 중에 브라우저가 끊으면(close) 돌려준다', async () => { + const image = lease(); + const { controller } = controllerWith(() => Promise.resolve(image)); + const res = new FakeResponse(); + + await controller.get(ARA, 'tok', request, res as unknown as Response); + expect(image.release).not.toHaveBeenCalled(); + + res.hangUp(); + expect(image.release).toHaveBeenCalledTimes(1); + }); + + it('받는 사이 브라우저가 끊었으면 아무것도 쓰지 않고 바로 돌려준다 — 받기에도 끊김을 알린다', async () => { + const image = lease(); + const res = new FakeResponse(); + let signal: AbortSignal | undefined; + const { controller } = controllerWith((_user, _token, abort) => { + signal = abort; + res.hangUp(); + return Promise.resolve(image); + }); + + await controller.get(ARA, 'tok', request, res as unknown as Response); + + expect(signal?.aborted).toBe(true); + expect(res.body).toBeNull(); + expect(image.release).toHaveBeenCalledTimes(1); + }); + + it('받지 못하면(null) 표준 실패 형식의 404 RES_001 — 돌려줄 차례가 없다(서비스가 이미 돌려줬다)', async () => { + const { controller } = controllerWith(() => Promise.resolve(null)); + const res = new FakeResponse(); + + await controller.get(ARA, 'tok', request, res as unknown as Response); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ + success: false, + error: { + code: 'RES_001', + message: expect.any(String) as unknown, + traceId: 'trace-1', + }, + }); + }); +}); + +describe('releaseWhenDone', () => { + it('이미 다 보냈거나 닫혔으면 바로 돌려준다', () => { + for (const state of [ + { writableFinished: true, destroyed: false }, + { writableFinished: false, destroyed: true }, + ]) { + const res = Object.assign(new FakeResponse(), state); + const release = jest.fn(); + releaseWhenDone(res as unknown as Response, release); + expect(release).toHaveBeenCalledTimes(1); + expect(res.listenerCount('finish')).toBe(0); + expect(res.listenerCount('close')).toBe(0); + } + }); + + it('끝이나 닫힘을 기다렸다 돌려주고 듣기를 거둔다', () => { + const res = new FakeResponse(); + const release = jest.fn(); + releaseWhenDone(res as unknown as Response, release); + expect(release).not.toHaveBeenCalled(); + + res.hangUp(); + expect(release).toHaveBeenCalledTimes(1); + expect(res.listenerCount('finish')).toBe(0); + expect(res.listenerCount('close')).toBe(0); + }); +}); diff --git a/backend/src/modules/mail/images/mail-image.controller.ts b/backend/src/modules/mail/images/mail-image.controller.ts new file mode 100644 index 0000000..ff4d83e --- /dev/null +++ b/backend/src/modules/mail/images/mail-image.controller.ts @@ -0,0 +1,165 @@ +import { + Controller, + Get, + HttpStatus, + Param, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; + +import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { getErrorDefinition } from '@/common/errors/error-codes'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware'; +import { MAIL_IMAGES_ROUTE } from '@/modules/mail/mail.constants'; +import { + MAIL_IMAGE_CACHE_CONTROL, + MAIL_IMAGE_CONCURRENCY, + MAIL_IMAGE_CONCURRENCY_PER_USER, + MAIL_IMAGE_CONTENT_SECURITY_POLICY, + MAIL_IMAGE_FETCH_TIMEOUT_MS, + MAIL_IMAGE_MAX_BYTES, + MAIL_IMAGE_MAX_REDIRECTS, + MAIL_IMAGE_THROTTLE_LIMIT, + MAIL_IMAGE_THROTTLE_TTL_MS, +} from './mail-image.constants'; +import { MailImageProxyService } from './mail-image-proxy.service'; + +/** + * 응답을 다 보냈거나(finish) 연결이 닫혔을 때(close) release 를 부른다 — 이미 그렇다면 바로. 둘 다 와도 한 번 (release 가 한 번만 돈다) + */ +export function releaseWhenDone(res: Response, release: () => void): void { + if (res.writableFinished || res.destroyed) { + release(); + return; + } + const done = () => { + res.removeListener('finish', done); + res.removeListener('close', done); + release(); + }; + res.once('finish', done); + res.once('close', done); +} + +/** + * 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — `GET /api/mail-images/{token}`. + * 받은 메일의 외부 이미지를 보일 때 서버가 그 메일의 `` 를 이 주소로 바꿔 넣는다(mail.service.ts — 표는 mail-image-token.ts). + * + * 실패는 모두 404 RES_001 — 까닭을 주지 않는다. 표준 실패 형식(JSON)은 같지만 전역 예외 필터를 거치지 않고 여기서 쓴다: + * 필터는 4xx 마다 warn 로그를 남기는데, 뉴스레터 한 통에도 받지 못하는 이미지가 여럿이라(바깥 404 · 그림이 아님) 로그가 넘친다 — + * 까닭은 서비스가 debug 로만 남긴다(주소 없이 바깥 호스트 이름만). 로그인 · 요청 수 한도의 실패(401 · 429)는 평소처럼 필터가 답한다 + */ +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(MAIL_IMAGES_ROUTE) +export class MailImageController { + constructor(private readonly proxy: MailImageProxyService) {} + + @Get(':token') + @SkipTransform() + // 이 경로만 넉넉하게 — 뉴스레터 한 통에 이미지가 수십 개다 (사무실 NAT 하나 뒤에 여러 직원) + @Throttle({ + default: { + ttl: MAIL_IMAGE_THROTTLE_TTL_MS, + limit: MAIL_IMAGE_THROTTLE_LIMIT, + }, + }) + @ApiOperation({ + summary: + '외부 이미지 (표준 래퍼 없음 — 서버가 바깥 이미지를 대신 받아 준다)', + description: + '메일 상세(images=show 또는 설정)가 외부 이미지 주소를 이 주소로 바꿔 넣는다. 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 암호화한 글이다 — 그 직원만, 1시간 안에만 쓴다. ' + + `서버는 http · https(기본 포트만)이고 이름을 풀어 나온 모든 주소가 공인 주소일 때만 그 주소로 연결한다(다시 가기 ${MAIL_IMAGE_MAX_REDIRECTS}번까지 매번 같은 검사, 전체 ${MAIL_IMAGE_FETCH_TIMEOUT_MS / 1000}초, ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB). ` + + '형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP 만 — SVG 등은 받지 않는다). 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다. ' + + `동시에 받는 수는 서버 전체 ${MAIL_IMAGE_CONCURRENCY}개 · 한 직원 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지(넘으면 기다린다 — 자리가 나면 지금 받는 수가 가장 적은 직원부터, 응답을 다 보낼 때까지 센다). 요청 수 한도는 이 경로만 IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회. ` + + `응답: 판정한 Content-Type · Cache-Control: ${MAIL_IMAGE_CACHE_CONTROL} · X-Content-Type-Options: nosniff · Content-Disposition: inline · Content-Security-Policy: ${MAIL_IMAGE_CONTENT_SECURITY_POLICY}.`, + }) + @ApiProduces('image/png', 'image/jpeg', 'image/gif', 'image/webp') + @ApiResponse({ + status: HttpStatus.OK, + description: '바깥 이미지', + schema: { type: 'string', format: 'binary' }, + }) + @ApiErrorResponse( + 'RES_001', + '받지 않음 — 까닭을 가리지 않는다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다', + ) + @ApiErrorResponse( + 'REQ_001', + `IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회 초과`, + ) + async get( + @CurrentUser() user: AuthenticatedUser, + @Param('token') token: string, + @Req() req: Request, + @Res() res: Response, + ): Promise { + // 클라이언트가 끊으면 바깥에서 받던 것도 멈춘다 (기다리던 차례에서도 빠진다) — 응답의 'close' 는 다 보내기 전에 연결이 닫혀도 온다 + // (요청의 'close' 는 본문을 다 읽으면 오므로 쓰지 않는다 — mail-events.controller.ts 와 같다) + const disconnected = new AbortController(); + const abort = () => { + if (!res.writableEnded) disconnected.abort(); + }; + res.once('close', abort); + // 처리기에 닿기 전에 끊겼으면 'close' 는 이미 지나갔다 + if (res.destroyed) disconnected.abort(); + try { + const image = await this.proxy.open( + user.email, + token, + disconnected.signal, + ); + // 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 — 느린 브라우저에 보내는 동안의 버퍼도 동시 수에 든다 + if (image !== null) releaseWhenDone(res, image.release); + if (res.headersSent || res.destroyed) return; + if (image === null) { + this.notFound(req, res); + return; + } + res.status(HttpStatus.OK); + res.setHeader('Content-Type', image.type); + res.setHeader('Content-Length', String(image.data.length)); + res.setHeader('Cache-Control', MAIL_IMAGE_CACHE_CONTROL); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader( + 'Content-Security-Policy', + MAIL_IMAGE_CONTENT_SECURITY_POLICY, + ); + res.end(image.data); + } finally { + res.removeListener('close', abort); + } + } + + /** 표준 실패 형식의 404 RES_001 — 전역 필터와 같은 모양(머리말의 까닭으로 여기서 쓴다) */ + private notFound(req: Request, res: Response): void { + const traceId = req.header(REQUEST_ID_HEADER) ?? undefined; + res.status(HttpStatus.NOT_FOUND).json({ + success: false, + error: { + code: 'RES_001', + message: getErrorDefinition('RES_001').message, + ...(traceId ? { traceId } : {}), + }, + }); + } +} diff --git a/backend/src/modules/mail/images/node-image-transport.spec.ts b/backend/src/modules/mail/images/node-image-transport.spec.ts new file mode 100644 index 0000000..e84b830 --- /dev/null +++ b/backend/src/modules/mail/images/node-image-transport.spec.ts @@ -0,0 +1,241 @@ +import { createSocket, type Socket as UdpSocket } from 'dgram'; +import { Resolver } from 'dns/promises'; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'http'; +import type { AddressInfo } from 'net'; + +import { pngBytes } from '@/../test/image-fixtures'; +import type { MailImageRequest } from './mail-image-fetcher'; +import { + DnsMailImageResolver, + NodeMailImageTransport, +} from './node-image-transport'; + +/** + * 실제 연결 고정 구현 (9단계 리뷰 뒤) — 127.0.0.1 에 띄운 HTTP 서버 · 답하지 않는 DNS 서버로 본다. + * 풀리지 않는 이름(`*.pinned.test`)으로 묻는다 — 연결이 되면 그 이름을 다시 풀지 않고 넘겨준 주소로 이었다는 뜻이다 + * (전송을 fetch(undici — lookup 을 받지 않는다)로 바꾸거나 lookup 을 빠뜨리면 여기서 실패한다) + */ + +const PINNED_HOST = 'images.pinned.test'; +const LOOPBACK = { address: '127.0.0.1', family: 4 } as const; +const PNG = pngBytes(8, 8, 1_000); + +/** 바깥으로 보내는 머리글 — fetcher 가 주는 것과 같은 모양 */ +const HEADERS = { + 'user-agent': 'ARA-SYSTEM-Mail-Image-Proxy/1.0', + accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1', + 'accept-encoding': 'identity', +}; + +/** 서버가 받은 요청 하나 — 처리기가 받은 것과 응답 */ +interface Received { + readonly request: IncomingMessage; + readonly response: ServerResponse; +} + +/** 127.0.0.1 의 빈 포트로 연 HTTP 서버 — 받은 요청을 차례로 기다릴 수 있다 */ +async function openServer(): Promise<{ + readonly server: Server; + readonly port: number; + next(): Promise; +}> { + const arrived: Received[] = []; + const waiting: ((received: Received) => void)[] = []; + const server = createServer((request, response) => { + const received = { request, response }; + const waiter = waiting.shift(); + if (waiter) waiter(received); + else arrived.push(received); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve()), + ); + return { + server, + port: (server.address() as AddressInfo).port, + next: () => { + const ready = arrived.shift(); + return ready + ? Promise.resolve(ready) + : new Promise((resolve) => waiting.push(resolve)); + }, + }; +} + +function closeServer(server: Server): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +/** 소켓이 닫힐 때 풀리는 약속 */ +function closed(request: IncomingMessage): Promise { + return new Promise((resolve) => { + if (request.socket.destroyed) resolve(); + else request.socket.once('close', () => resolve()); + }); +} + +/** 원래 주소는 기본 포트(80)다 — 실제로 잇는 포트는 전송의 테스트 전용 자리(forTesting)가 정한다 */ +function requestFor( + signal: AbortSignal, + path = '/logo.png?u=1', +): MailImageRequest { + return { + url: new URL(`http://${PINNED_HOST}${path}`), + addresses: [LOOPBACK], + headers: HEADERS, + signal, + }; +} + +async function readAll(body: AsyncIterable): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of body) chunks.push(chunk); + return Buffer.concat(chunks); +} + +describe('NodeMailImageTransport — 넘겨준 주소로만 잇는다 (127.0.0.1 의 서버)', () => { + let opened: Awaited>; + beforeEach(async () => { + opened = await openServer(); + }); + afterEach(async () => { + await closeServer(opened.server); + }); + + it('풀리지 않는 이름이어도 넘겨준 주소로 잇고, 원래 이름을 Host 로 보낸다 — 머리글은 넘겨준 것과 Host · Connection 뿐(쿠키 · Referer 없음)', async () => { + const transport = NodeMailImageTransport.forTesting({ http: opened.port }); + const response = transport.get(requestFor(new AbortController().signal)); + + const { request, response: reply } = await opened.next(); + expect(request.url).toBe('/logo.png?u=1'); + expect(request.headers.host).toBe(`${PINNED_HOST}:${opened.port}`); + expect(request.headers.cookie).toBeUndefined(); + expect(request.headers.referer).toBeUndefined(); + expect(Object.keys(request.headers).sort()).toEqual( + ['accept', 'accept-encoding', 'connection', 'host', 'user-agent'].sort(), + ); + expect(request.headers['user-agent']).toBe(HEADERS['user-agent']); + reply.writeHead(200, { + 'content-type': 'text/html', + 'content-length': String(PNG.length), + }); + reply.end(PNG); + + const got = await response; + expect(got.status).toBe(200); + expect(got.contentLength).toBe(PNG.length); + expect(got.location).toBeNull(); + await expect(readAll(got.body)).resolves.toEqual(PNG); + }); + + it('다시 가기 응답의 Location · 길이 없는 응답(chunked)을 그대로 넘긴다', async () => { + const transport = NodeMailImageTransport.forTesting({ http: opened.port }); + const redirect = transport.get(requestFor(new AbortController().signal)); + const first = await opened.next(); + first.response.writeHead(302, { location: '/elsewhere.png' }); + first.response.end(); + const moved = await redirect; + expect(moved.status).toBe(302); + expect(moved.location).toBe('/elsewhere.png'); + moved.close(); + + const chunked = transport.get(requestFor(new AbortController().signal)); + const second = await opened.next(); + second.response.writeHead(200, { 'content-type': 'image/png' }); + second.response.write(PNG.subarray(0, 10)); + second.response.end(PNG.subarray(10)); + const streamed = await chunked; + expect(streamed.contentLength).toBeNull(); + await expect(readAll(streamed.body)).resolves.toEqual(PNG); + }); + + it('응답을 기다리는 사이 끊으면(signal) 연결이 닫히고 약속은 거절된다', async () => { + const transport = NodeMailImageTransport.forTesting({ http: opened.port }); + const controller = new AbortController(); + const pending = transport.get(requestFor(controller.signal)); + const { request } = await opened.next(); + const socketClosed = closed(request); + + controller.abort(); + await expect(pending).rejects.toThrow(); + await socketClosed; + expect(request.socket.destroyed).toBe(true); + }); + + it('본문을 읽는 중에 끊으면(signal) 연결이 닫히고 읽기가 멈춘다 · close() 도 연결을 닫는다', async () => { + const transport = NodeMailImageTransport.forTesting({ http: opened.port }); + const controller = new AbortController(); + const reading = transport.get(requestFor(controller.signal)); + const first = await opened.next(); + first.response.writeHead(200, { 'content-length': '100000' }); + first.response.write(PNG); + const got = await reading; + const firstClosed = closed(first.request); + + controller.abort(); + await expect(readAll(got.body)).rejects.toThrow(); + await firstClosed; + + const closing = transport.get(requestFor(new AbortController().signal)); + const second = await opened.next(); + second.response.writeHead(200, { 'content-length': '100000' }); + second.response.write(PNG); + const secondClosed = closed(second.request); + (await closing).close(); + await secondClosed; + expect(second.request.socket.destroyed).toBe(true); + }); + + it('앱의 전송은 기본 포트(http 80) 로 잇는다 — 테스트 전용 자리가 아니면 바뀌지 않는다', () => { + const transport = new NodeMailImageTransport(); + expect( + (transport as unknown as { ports: { http: number; https: number } }) + .ports, + ).toEqual({ http: 80, https: 443 }); + }); +}); + +describe('DnsMailImageResolver — 끊으면 이름 풀기를 취소한다 (127.0.0.1 의 답하지 않는 DNS)', () => { + let dns: UdpSocket; + let port: number; + let queries: number; + let firstQuery: Promise; + beforeEach(async () => { + queries = 0; + dns = createSocket('udp4'); + firstQuery = new Promise((resolve) => + dns.on('message', () => { + queries += 1; + resolve(); + }), + ); + await new Promise((resolve) => dns.bind(0, '127.0.0.1', resolve)); + port = dns.address().port; + }); + afterEach(async () => { + jest.restoreAllMocks(); + await new Promise((resolve) => dns.close(() => resolve())); + }); + + it('묻는 사이 끊으면 Resolver 를 취소하고 곧바로 거절한다 (제한 시간을 기다리지 않는다)', async () => { + const cancel = jest.spyOn(Resolver.prototype, 'cancel'); + const resolver = DnsMailImageResolver.forTesting([`127.0.0.1:${port}`]); + const controller = new AbortController(); + + const resolving = resolver.resolve('black.hole.test', controller.signal); + await firstQuery; + expect(queries).toBeGreaterThan(0); + controller.abort(); + + // 취소하지 않으면 Resolver 는 제한 시간(3초 × 2번)까지 기다린다 — jest 의 테스트 시간(5초)을 넘겨 실패한다. + // 거절 값은 Node 의 ECANCELLED 다 (jest 의 다른 realm 에서는 instanceof Error 가 아니라 'dns-failed' 로 감싸 보인다) + await expect(resolving).rejects.toThrow(); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/modules/mail/images/node-image-transport.ts b/backend/src/modules/mail/images/node-image-transport.ts new file mode 100644 index 0000000..565d40a --- /dev/null +++ b/backend/src/modules/mail/images/node-image-transport.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@nestjs/common'; +import { Resolver } from 'dns/promises'; +import type { LookupAddress, LookupOptions } from 'dns'; +import { request as httpRequest, type IncomingMessage } from 'http'; +import { request as httpsRequest } from 'https'; + +import type { + MailImageRequest, + MailImageResolver, + MailImageResponse, + MailImageTransport, + ResolvedAddress, +} from './mail-image-fetcher'; +import { hostOf } from './mail-image-url'; + +/** + * 바깥 이미지 받기의 실제 이름 풀기 · 전송 (mail-image-fetcher.ts 가 주입받는다) — fetcher 의 단위 테스트는 가짜를 쓰고, + * 이 파일은 127.0.0.1 에 띄운 서버 · DNS 로 따로 본다(node-image-transport.spec.ts). 그래서 테스트 전용 자리(forTesting)가 둘 있다 — + * 연결 포트와 DNS 서버. 앱(Nest 의 useClass)은 쓰지 않는다: 포트는 80 · 443 고정이고 env · 설정으로 열지 않는다 + */ + +/** 이름 풀기 한 번의 제한 시간 · 다시 묻는 횟수 — 전체 10초 안에서 */ +const DNS_TIMEOUT_MS = 3_000; +const DNS_TRIES = 2; + +/** + * DNS 로 A · AAAA 를 함께 묻는다 — c-ares(Resolver)로 묻는다. `dns.lookup`(getaddrinfo)은 libuv 스레드 풀(기본 4개)을 막고 기다려, + * 느린 이름 여러 개가 메일 엔진 · DB 연결의 이름 풀기까지 붙잡을 수 있다. 요청마다 새 Resolver — 끊으면 그것만 취소한다. + * 한쪽만 풀려도 그 주소들을 쓴다 (나온 주소는 모두 공인인지 fetcher 가 본다). 둘 다 못 풀면 던진다 + */ +@Injectable() +export class DnsMailImageResolver implements MailImageResolver { + /** 물을 DNS 서버 — 앱은 null(운영체제 설정 그대로), 테스트만 준다 */ + private servers: readonly string[] | null = null; + + /** 테스트 전용 — 이 DNS 서버(127.0.0.1 의 가짜)에 묻는다 */ + static forTesting(servers: readonly string[]): DnsMailImageResolver { + const resolver = new DnsMailImageResolver(); + resolver.servers = [...servers]; + return resolver; + } + + async resolve( + hostname: string, + signal: AbortSignal, + ): Promise { + const resolver = new Resolver({ + timeout: DNS_TIMEOUT_MS, + tries: DNS_TRIES, + }); + if (this.servers !== null) resolver.setServers([...this.servers]); + const cancel = () => resolver.cancel(); + signal.addEventListener('abort', cancel, { once: true }); + try { + const [v4, v6] = await Promise.allSettled([ + resolver.resolve4(hostname), + resolver.resolve6(hostname), + ]); + const addresses: ResolvedAddress[] = [ + ...(v4.status === 'fulfilled' + ? v4.value.map((address) => ({ address, family: 4 as const })) + : []), + ...(v6.status === 'fulfilled' + ? v6.value.map((address) => ({ address, family: 6 as const })) + : []), + ]; + if (addresses.length === 0 && v4.status === 'rejected') { + throw v4.reason instanceof Error ? v4.reason : new Error('dns-failed'); + } + return addresses; + } finally { + signal.removeEventListener('abort', cancel); + } + } +} + +/** Node 의 lookup 자리 — 확인한 주소만 돌려준다 (연결할 때 이름을 다시 풀지 않는다). 주소 고르기(happy eyeballs)는 `all` 로 */ +function pinnedLookup(addresses: readonly ResolvedAddress[]) { + return ( + _hostname: string, + options: LookupOptions, + callback: ( + error: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family?: number, + ) => void, + ): void => { + if (options.all) { + callback( + null, + addresses.map(({ address, family }) => ({ address, family })), + ); + return; + } + const [first] = addresses; + callback(null, first.address, first.family); + }; +} + +function headerValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +function contentLengthOf(value: string | string[] | undefined): number | null { + const raw = headerValue(value); + if (raw === null || !/^\d{1,15}$/.test(raw.trim())) return null; + return Number(raw.trim()); +} + +/** 연결 포트 — 주소의 체계마다 */ +interface TransportPorts { + readonly http: number; + readonly https: number; +} + +/** http 80 · https 443 — 바꾸지 않는다 (mail-image-url.ts 가 기본 포트의 주소만 받는다). 테스트만 forTesting 으로 */ +const DEFAULT_PORTS: TransportPorts = Object.freeze({ http: 80, https: 443 }); + +/** + * Node 의 http · https 로 GET 한 번 — 연결은 확인한 주소로만(lookup 자리), 연결을 모아 두지 않는다(agent: false). + * TLS 는 원래 이름으로 확인한다(SNI · 인증서 — 이름은 그대로 두고 주소만 정해 준다). 머리글은 fetcher 가 준 것뿐이다(Host 는 원래 이름) + */ +@Injectable() +export class NodeMailImageTransport implements MailImageTransport { + private ports: TransportPorts = DEFAULT_PORTS; + + /** 테스트 전용 — 127.0.0.1 에 띄운 서버의 포트로 잇는다 (앱은 기본 포트 고정) */ + static forTesting(ports: Partial): NodeMailImageTransport { + const transport = new NodeMailImageTransport(); + transport.ports = { ...DEFAULT_PORTS, ...ports }; + return transport; + } + + get(request: MailImageRequest): Promise { + const { url } = request; + const secure = url.protocol === 'https:'; + const send = secure ? httpsRequest : httpRequest; + return new Promise((resolve, reject) => { + const outgoing = send( + { + protocol: url.protocol, + hostname: hostOf(url), + port: secure ? this.ports.https : this.ports.http, + path: `${url.pathname}${url.search}`, + method: 'GET', + headers: { ...request.headers }, + agent: false, + lookup: pinnedLookup(request.addresses), + signal: request.signal, + }, + (incoming: IncomingMessage) => { + resolve({ + status: incoming.statusCode ?? 0, + location: headerValue(incoming.headers.location), + contentLength: contentLengthOf(incoming.headers['content-length']), + body: incoming, + close: () => incoming.destroy(), + }); + }, + ); + // 응답을 받은 뒤의 오류(끊김 · 취소)도 받아 둔다 — 듣는 곳이 없는 'error' 는 프로세스를 멈춘다. 본문 오류는 읽는 쪽에서 난다 + outgoing.on('error', reject); + outgoing.end(); + }); + } +} diff --git a/backend/src/modules/mail/jmap/delivery-status.spec.ts b/backend/src/modules/mail/jmap/delivery-status.spec.ts index de20a9c..0359742 100644 --- a/backend/src/modules/mail/jmap/delivery-status.spec.ts +++ b/backend/src/modules/mail/jmap/delivery-status.spec.ts @@ -1,10 +1,10 @@ import type { RecipientFields } from '@/modules/mail/mail.types'; import { - DELIVERED_TO_ALL, classifySmtpReply, describeRejections, everyoneRejectedCode, readDeliveryResult, + submittedEnvelope, toRejectedRecipients, } from './delivery-status'; import type { JmapEmailSubmission } from './jmap.types'; @@ -197,13 +197,115 @@ describe('받는 사람별 발송 결과 (delivery-status)', () => { }); }); - it('받지 않은 사람이 없는 결과(DELIVERED_TO_ALL)는 여러 요청이 함께 쓰므로 얼려 둔다 — 배열까지', () => { - expect(DELIVERED_TO_ALL).toEqual({ - rejected: [], - isEveryoneRejected: false, + describe('엔진이 envelope 에서 받지 않은 주소 (9단계 리뷰 뒤 — envelope.ts)', () => { + const recipients: RecipientFields = { + to: [{ name: null, email: 'doyun.lee@arasystem.test' }], + cc: [{ name: null, email: 'typo@example.cmo' }], + bcc: [{ name: null, email: 'Idn@한국.example.com' }], + }; + + it('낸 envelope 에 없는 받는 사람은 not-found — 결과가 없어도(붙잡은 바깥 받는 사람) 정한다, 요청의 차례 그대로', () => { + expect( + readDeliveryResult( + recipients, + [], + ['doyun.lee@arasystem.test', 'idn@xn--3e0b707e.example.com'], + ), + ).toEqual({ + rejected: [ + { + email: 'typo@example.cmo', + reason: 'not-found', + smtpStatus: null, + envelopeRefused: true, + }, + ], + isEveryoneRejected: false, + }); + }); + + it('envelope 에서 빠진 사람과 SMTP 로 거절된 사람을 함께 — 모두면 모두 거절(MAIL_010)', () => { + const result = readDeliveryResult( + recipients, + [ + submission({ + 'doyun.lee@arasystem.test': REFUSED_NOT_FOUND, + 'idn@xn--3e0b707e.example.com': REFUSED_RATE_LIMIT, + }), + ], + ['doyun.lee@arasystem.test', 'Idn@한국.example.com'], + ); + expect( + result.rejected.map(({ email, reason }) => [email, reason]), + ).toEqual([ + ['doyun.lee@arasystem.test', 'not-found'], + ['typo@example.cmo', 'not-found'], + ['Idn@한국.example.com', 'temporary'], + ]); + expect(result.isEveryoneRejected).toBe(true); + expect(everyoneRejectedCode(result.rejected)).toBe('MAIL_010'); + }); + + it('국제화 도메인은 엔진의 결과 열쇠(xn--)와 맞춘다 — 요청의 유니코드 주소 그대로 돌려준다', () => { + expect( + readDeliveryResult(only('idn@한국.example.com'), [ + submission({ 'idn@xn--3e0b707e.example.com': REFUSED_NOT_FOUND }), + ]).rejected, + ).toEqual([ + { + email: 'idn@한국.example.com', + reason: 'not-found', + smtpStatus: '550 5.1.2', + }, + ]); + }); + + it('envelope 을 모르면(null) 그 규칙을 쓰지 않는다 — 지금처럼 결과만 본다', () => { + expect(readDeliveryResult(recipients, [], null)).toEqual({ + rejected: [], + isEveryoneRejected: false, + }); + }); + + it('같은 키의 재시도 — 제출들의 envelope 받는 사람을 모은다, 하나라도 envelope 을 모르면 null', () => { + expect( + submittedEnvelope([ + { + id: 's1', + envelope: { + rcptTo: [{ email: 'a@x.example' }, { email: 'b@y.example' }], + }, + }, + { id: 's2', envelope: { rcptTo: [{ email: 'c@z.example' }] } }, + ]), + ).toEqual(['a@x.example', 'b@y.example', 'c@z.example']); + expect(submittedEnvelope([{ id: 's1' }])).toBeNull(); + expect(submittedEnvelope([{ id: 's1', envelope: null }])).toBeNull(); + expect(submittedEnvelope([])).toBeNull(); + }); + + it('로그에는 envelope 에서 빠진 사람을 따로 적는다', () => { + expect( + describeRejections([ + { + email: 'typo@example.cmo', + reason: 'not-found', + smtpStatus: null, + envelopeRefused: true, + }, + ]), + ).toBe('받지 않은 사람 1명: t***@example.cmo(엔진이 주소를 받지 않음)'); + expect( + toRejectedRecipients([ + { + email: 'typo@example.cmo', + reason: 'not-found', + smtpStatus: null, + envelopeRefused: true, + }, + ]), + ).toEqual([{ email: 'typo@example.cmo', reason: 'not-found' }]); }); - expect(Object.isFrozen(DELIVERED_TO_ALL)).toBe(true); - expect(Object.isFrozen(DELIVERED_TO_ALL.rejected)).toBe(true); }); it('응답에는 주소와 까닭만 싣는다 — SMTP 상태 코드는 로그 전용', () => { diff --git a/backend/src/modules/mail/jmap/delivery-status.ts b/backend/src/modules/mail/jmap/delivery-status.ts index b19e977..3332789 100644 --- a/backend/src/modules/mail/jmap/delivery-status.ts +++ b/backend/src/modules/mail/jmap/delivery-status.ts @@ -5,6 +5,7 @@ import type { RejectedRecipient, } from '@/modules/mail/mail.types'; import { maskEmail } from '@/shared/utils/mask'; +import { envelopeKey } from './envelope'; import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types'; /** @@ -16,13 +17,20 @@ import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types'; * - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도) * - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다. * 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다 - * 판단은 deliveryStatus 로만 한다 — undoStatus 는 모두 거절이면 final, 하나라도 대기열에 들어가면 pending 일 뿐이다. + * 판단은 deliveryStatus 로 한다 — undoStatus 는 모두 거절이면 final(붙잡아도 — 2026-10-29 확인), 하나라도 대기열에 들어가면 pending 일 뿐이다. + * 붙잡은 제출도 받는 사람마다 그 자리에서 `delivered: "queued"` · `250 2.1.5 Queued` 로 남는다(사내 · 바깥 모두 — 같은 날 확인). + * + * 엔진이 envelope 에서 받지 않은 주소(9단계 리뷰 뒤 — envelope.ts)는 제출에 들지 않아 결과가 없다 — 그래서 제출의 envelope(rcptTo)을 알면 + * **그 envelope 에 없는 받는 사람은 not-found** 다. 결과가 없는 것으로 정하지 않는다: 붙잡은 바깥 받는 사람의 결과가 늘 먼저 있다고 기대하지 않는다. + * 처음 발송(보낸 envelope)과 같은 키의 재시도(제출을 다시 읽은 envelope)가 같은 답을 내게 하는 규칙이다 */ /** 받지 않은 사람 하나 — 로그에 남길 SMTP 상태 코드를 함께 둔다 (응답에는 email · reason 만 — toRejectedRecipients) */ export interface RecipientRejection extends RejectedRecipient { - /** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 null */ + /** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 · envelope 에서 빠졌으면 null */ readonly smtpStatus: string | null; + /** 엔진이 envelope 에서 받지 않아 제출에 들지 않았다 (SMTP 로 묻지도 않았다 — 로그에만 쓴다) */ + readonly envelopeRefused?: boolean; } /** 한 번 보낸 결과 */ @@ -33,12 +41,6 @@ export interface DeliveryResult { readonly isEveryoneRejected: boolean; } -/** 받지 않은 사람이 없다 — 결과를 읽지 못했을 때도 이것으로 본다 (제출은 이미 되었다). 여러 요청이 함께 쓰는 값이라 얼려 둔다 */ -export const DELIVERED_TO_ALL: DeliveryResult = Object.freeze({ - rejected: Object.freeze([]), - isEveryoneRejected: false, -}); - /** * SMTP 응답 맨 앞의 상태 — 기본 코드(`550`)와 확장 상태(`5.1.2` — RFC 3463), 둘 다 없을 수 있다. * 여러 줄 응답의 이음표(`550-5.1.1`)도 받는다. 맨 앞만 보므로 응답 글 안의 숫자(판 번호 등)는 읽지 않는다 @@ -52,11 +54,6 @@ const ADDRESS_STATUS_SUBJECT = '1'; /** 로그 한 줄에 적는 받지 않은 사람 수 — 넘으면 "외 N명" */ const LOGGED_REJECTIONS_MAX = 10; -/** 주소를 맞출 때의 열쇠 — 대소문자와 앞뒤 공백을 무시한다 */ -function addressKey(email: string): string { - return email.trim().toLowerCase(); -} - /** * 받지 않은 SMTP 응답의 까닭 — 4xx 는 temporary, 확장 상태 5.1.x 는 not-found, 그 밖(5xx · 읽을 수 없는 응답)은 refused. * 기본 코드가 없으면 확장 상태의 첫 자리로 본다 (`delivered: "no"` 라 어느 쪽이든 이 사람에게는 가지 않는다) @@ -86,13 +83,16 @@ export function classifySmtpReply(reply: string | null | undefined): { } /** - * 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다. - * `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다. - * 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다. + * 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 envelopeKey(대소문자 무시 · 국제화 도메인은 `xn--`)로 맞추고, 돌려주는 주소는 요청에 쓴 그대로다. + * - envelope(제출에 실은 받는 사람)을 알면 그 안에 없는 사람은 not-found (엔진이 envelope 에서 받지 않았다 — 머리말) + * - `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다 + * - 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다 + * @param envelope 제출에 실은 받는 사람 주소 — 모르면 null (envelope 규칙을 쓰지 않는다) */ export function readDeliveryResult( recipients: RecipientFields, submissions: readonly JmapEmailSubmission[], + envelope: readonly string[] | null = null, ): DeliveryResult { const refusals = new Map(); const accepted = new Set(); @@ -100,7 +100,7 @@ export function readDeliveryResult( for (const [address, status] of Object.entries( submission.deliveryStatus ?? {}, )) { - const key = addressKey(address); + const key = envelopeKey(address); if (status?.delivered !== 'no') { accepted.add(key); } else if (!refusals.has(key)) { @@ -108,6 +108,8 @@ export function readDeliveryResult( } } } + const submitted = + envelope === null ? null : new Set(envelope.map(envelopeKey)); const seen = new Set(); const rejected: RecipientRejection[] = []; @@ -116,10 +118,19 @@ export function readDeliveryResult( ...recipients.cc, ...recipients.bcc, ]) { - const key = addressKey(email); + const key = envelopeKey(email); if (seen.has(key)) continue; seen.add(key); + if (submitted !== null && !submitted.has(key)) { + rejected.push({ + email, + reason: 'not-found', + smtpStatus: null, + envelopeRefused: true, + }); + continue; + } const refusal = accepted.has(key) ? undefined : refusals.get(key); if (refusal) { rejected.push({ email, ...classifySmtpReply(refusal.smtpReply) }); @@ -131,6 +142,24 @@ export function readDeliveryResult( }; } +/** + * 제출들의 envelope 받는 사람 (같은 키의 재시도가 제출을 다시 읽을 때) — 하나라도 envelope 을 읽지 못하면 null (envelope 규칙을 쓰지 않는다) + */ +export function submittedEnvelope( + submissions: readonly JmapEmailSubmission[], +): string[] | null { + if (submissions.length === 0) return null; + const addresses: string[] = []; + for (const submission of submissions) { + const rcptTo = submission.envelope?.rcptTo; + if (!Array.isArray(rcptTo)) return null; + for (const recipient of rcptTo) { + if (typeof recipient?.email === 'string') addresses.push(recipient.email); + } + } + return addresses; +} + /** 응답에 싣는 모양 (계약의 RejectedRecipient) — SMTP 상태 코드는 로그 전용이라 뺀다 */ export function toRejectedRecipients( rejected: readonly RecipientRejection[], @@ -157,8 +186,8 @@ export function describeRejections( const listed = rejected .slice(0, LOGGED_REJECTIONS_MAX) .map( - ({ email, smtpStatus }) => - `${maskEmail(email)}(${smtpStatus ?? '상태 코드 없음'})`, + ({ email, smtpStatus, envelopeRefused }) => + `${maskEmail(email)}(${envelopeRefused ? '엔진이 주소를 받지 않음' : (smtpStatus ?? '상태 코드 없음')})`, ); const more = rejected.length - listed.length; return `받지 않은 사람 ${rejected.length}명: ${listed.join(', ')}${more > 0 ? ` 외 ${more}명` : ''}`; diff --git a/backend/src/modules/mail/jmap/envelope.spec.ts b/backend/src/modules/mail/jmap/envelope.spec.ts new file mode 100644 index 0000000..a6a528c --- /dev/null +++ b/backend/src/modules/mail/jmap/envelope.spec.ts @@ -0,0 +1,208 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + addressNamedIn, + envelopeKey, + envelopeRecipients, + isEnvelopeRefusal, + submissionEnvelope, +} from './envelope'; + +describe('envelope — 발송 제출의 envelope (9단계 리뷰 뒤 — 늘 직접 준다)', () => { + it('받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소는 처음 쓴 것 하나 (대소문자 · 국제화 도메인 모양 무시)', () => { + expect( + envelopeRecipients({ + to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + cc: [ + { name: null, email: ' DOYUN.LEE@arasystem.test ' }, + { name: null, email: 'kim@partner.example' }, + { name: null, email: 'idn@한국.example.com' }, + ], + bcc: [ + { name: null, email: 'seojun.park@arasystem.test' }, + { name: null, email: 'IDN@xn--3e0b707e.example.com' }, + ], + }), + ).toEqual([ + 'doyun.lee@arasystem.test', + 'kim@partner.example', + 'idn@한국.example.com', + 'seojun.park@arasystem.test', + ]); + }); + + it('맞추는 열쇠 — 소문자, 도메인은 xn-- 꼴(엔진이 제출의 envelope 에 두는 모양), 바꿀 수 없는 도메인은 소문자 그대로', () => { + expect(envelopeKey(' Kim@Partner.EXAMPLE ')).toBe('kim@partner.example'); + expect(envelopeKey('idn@한국.example.com')).toBe( + 'idn@xn--3e0b707e.example.com', + ); + expect(envelopeKey('한글@example.com')).toBe('한글@example.com'); + expect(envelopeKey('x@[1.2.3.4]')).toBe('x@[1.2.3.4]'); + expect(envelopeKey('no-at-sign')).toBe('no-at-sign'); + }); + + it('제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(없으면 null), 받는 사람마다 매개변수 null', () => { + expect( + submissionEnvelope('ara.kim@arasystem.test', ['a@x.example'], { + HOLDFOR: '5', + }), + ).toEqual({ + mailFrom: { + email: 'ara.kim@arasystem.test', + parameters: { HOLDFOR: '5' }, + }, + rcptTo: [{ email: 'a@x.example', parameters: null }], + }); + expect( + submissionEnvelope( + 'ara.kim@arasystem.test', + ['a@x.example', 'b@y.example'], + null, + ), + ).toEqual({ + mailFrom: { email: 'ara.kim@arasystem.test', parameters: null }, + rcptTo: [ + { email: 'a@x.example', parameters: null }, + { email: 'b@y.example', parameters: null }, + ], + }); + }); + + it('엔진이 envelope 을 받지 않은 거절 — invalidProperties 의 속성이 envelope 이거나 그 안일 때만', () => { + expect( + isEnvelopeRefusal({ + type: 'invalidProperties', + properties: ['envelope'], + }), + ).toBe(true); + expect( + isEnvelopeRefusal({ + type: 'invalidProperties', + properties: ['envelope/rcptTo/1/email'], + }), + ).toBe(true); + expect( + isEnvelopeRefusal({ type: 'invalidProperties', properties: ['emailId'] }), + ).toBe(false); + expect( + isEnvelopeRefusal({ + type: 'forbiddenMailFrom', + properties: ['envelope'], + }), + ).toBe(false); + expect(isEnvelopeRefusal(undefined)).toBe(false); + }); + + describe('거절 설명에 적힌 우리 주소 하나 (addressNamedIn) — 개발 엔진이 돌려준 설명의 모양 (Stalwart 0.16.27, 2026-10-29)', () => { + const addresses = [ + 'ara.kim@arasystem.test', + 'typo1@example.cmo', + 'Typo.Two@Example.CMO', + 'idn@한국.cmo', + '"a b"@example.com', + ]; + + it.each<[string, string, string | null]>([ + [ + '보낸 모양 그대로', + 'Invalid e-mail address "typo1@example.cmo".', + 'typo1@example.cmo', + ], + [ + '대소문자 무시', + 'Invalid e-mail address "typo.two@example.cmo".', + 'Typo.Two@Example.CMO', + ], + [ + '국제화 도메인(유니코드 그대로)', + 'Invalid e-mail address "idn@한국.cmo".', + 'idn@한국.cmo', + ], + [ + '국제화 도메인(xn-- 꼴)', + 'Invalid e-mail address "idn@xn--3e0b707e.cmo".', + 'idn@한국.cmo', + ], + [ + '따옴표 앞부분 — 따옴표 · 역슬래시를 이스케이프해 적는다', + 'Invalid e-mail address "\\"a b\\"@example.com".', + '"a b"@example.com', + ], + [ + '따옴표 없이 문장 끝', + 'Invalid e-mail address typo1@example.cmo.', + 'typo1@example.cmo', + ], + ['우리 주소가 없음', 'Invalid e-mail address "other@example.cmo".', null], + ['설명이 없음', '', null], + ])('%s', (_label, description, named) => { + expect(addressNamedIn(description, addresses)).toBe(named); + }); + + it('다른 주소의 일부는 맞지 않는다 — a@x 는 ba@x 안에서, x@example.co 는 x@example.com 안에서 찾지 않는다', () => { + expect( + addressNamedIn('Invalid e-mail address "ba@x.example".', [ + 'a@x.example', + 'ba@x.example', + ]), + ).toBe('ba@x.example'); + expect( + addressNamedIn('Invalid e-mail address "x@example.com".', [ + 'x@example.co', + ]), + ).toBeNull(); + expect(addressNamedIn(undefined, addresses)).toBeNull(); + }); + + describe('따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다 (9단계 리뷰 2 — 발송 DTO 의 IsEmail 이 받는 모양)', () => { + const KIM = 'kim@example.com'; + /** 엔진이 설명에 적는 모양 — 따옴표 · 역슬래시를 이스케이프 (envelope.ts 머리말) */ + const escape = (address: string) => + address.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + it.each([ + '"Kim "@x.cmo', + '"kim@example.com "@x.cmo', + '"kim@example.com"@x.cmo', + '"(kim@example.com)"@x.cmo', + ])('%s 가 거절되면 그 주소 하나만', (bad) => { + const description = `Invalid e-mail address "${escape(bad)}".`; + expect(addressNamedIn(description, [KIM, bad])).toBe(bad); + // 받는 사람의 차례가 바뀌어도 같다 + expect(addressNamedIn(description, [bad, KIM])).toBe(bad); + }); + + it('설명의 모양이 달라 정확히 맞는 것이 없으면 경계로 찾는다 — 여럿이 걸리면 가장 긴 것 하나 (짧은 주소가 긴 주소의 글 안에 든 것이다)', () => { + const bad = '"Kim "@x.cmo'; + expect( + addressNamedIn(`Invalid e-mail address: '${bad}'`, [KIM, bad]), + ).toBe(bad); + }); + + it('경계로 찾은 것의 길이가 같으면 envelope 의 앞 차례 하나 — 한 차례에 하나만 뺀다', () => { + expect( + addressNamedIn('Invalid e-mail addresses a1@x.cmo, a2@x.cmo', [ + 'a2@x.cmo', + 'a1@x.cmo', + ]), + ).toBe('a2@x.cmo'); + }); + }); + + it('설명 길이에 비례한다 — 받는 사람 100명 · 설명 1만 자에도 CPU 시간이 작다', () => { + const many = Array.from( + { length: 100 }, + (_, index) => `user${index}@x.example`, + ); + const description = `${'user1@x.examplez '.repeat(600)}Invalid e-mail address "user99@x.example".`; + // 정확히 맞는 따옴표가 없을 때(경계로 찾기)도 — 따옴표를 600개 더한다 + const unquoted = `${'"user1@x.examplez" '.repeat(600)}Invalid e-mail address user99@x.example.`; + const cpuMs = startCpuTimer(); + for (let round = 0; round < 20; round += 1) { + expect(addressNamedIn(description, many)).toBe('user99@x.example'); + expect(addressNamedIn(unquoted, many)).toBe('user99@x.example'); + } + // 지금 수십 ms — 한도는 넉넉히 (CPU 시간, test/cpu-time.ts) + expect(cpuMs()).toBeLessThan(1_000); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/envelope.ts b/backend/src/modules/mail/jmap/envelope.ts new file mode 100644 index 0000000..c9a8e0d --- /dev/null +++ b/backend/src/modules/mail/jmap/envelope.ts @@ -0,0 +1,187 @@ +import { domainToASCII } from 'url'; + +import type { RecipientFields } from '@/modules/mail/mail.types'; +import type { JmapError } from './jmap.types'; + +/** + * 발송 제출(EmailSubmission/set)의 envelope — 순수 함수 (계약 2절 "받는 사람별 결과"). + * + * 발송은 늘 envelope 을 직접 준다(9단계 리뷰 뒤 — 붙잡든 아니든). 엔진(Stalwart 0.16.27, 2026-10-29 확인)은: + * - 직접 준 envelope 에 받지 않는 주소가 있으면(모르는 최상위 도메인 `x@example.cmo` · `x@k-….example`, 따옴표 앞부분 `"a b"@…` 등) + * 제출 **전체**를 `invalidProperties ["envelope"]` 로 거절하고, 설명에 그 가운데 **첫 주소 하나만** 적는다 — + * `Invalid e-mail address "<주소>".` (보낸 모양 그대로 · 대소문자도 그대로, 따옴표 · 역슬래시는 `\"` · `\\` 로 이스케이프). 붙잡든 아니든 같다. + * 그래서 어댑터는 설명에 적힌 우리 주소 하나(addressNamedIn)를 받지 않은 사람(not-found)으로 빼고 같은 메일을 다시 제출한다 + * (jmap-mail.provider.ts 의 finishSubmission — 한 차례에 하나, 다시 내기는 mail.constants.ts 의 MAX_ENVELOPE_RESUBMITS 차례까지) + * - envelope 을 주지 않으면 같은 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 모두 그런 주소면 noRecipients (9단계 전의 길) + * - 국제화 도메인은 유니코드 · `xn--` 모두 받고 제출의 envelope 에는 `xn--` 로 둔다. 같은 주소(대소문자 무시)는 하나로 합친다 + * - envelope 을 직접 줘도 · 주지 않아도 보내는 메일에서 Bcc 머리글은 뗀다 — 사내 배달 · 바깥(SMTP) 사본 모두 (보낸 사본에만 남는다). + * 개발 환경의 Mailpit 은 받은 envelope 의 받는 사람 가운데 머리글에 없는 주소를 `Bcc:` 머리글로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다 + * (머리글이 없는 메일을 envelope 으로만 보내도 Mailpit 사본 첫 줄이 `Bcc:` — 같은 날 확인) + */ + +/** envelope 을 받지 않았다는 SetError — 그 속성이 envelope(또는 그 안) 일 때 */ +const INVALID_PROPERTIES = 'invalidProperties'; +const ENVELOPE_PROPERTY = 'envelope'; + +/** + * 주소를 맞출 때의 열쇠 — 앞뒤 공백을 빼고 대소문자를 무시하고, 도메인은 `xn--` 꼴로 (엔진이 제출의 envelope · deliveryStatus 에 두는 모양). + * 바꿀 수 없는 도메인(빈 값)은 소문자 그대로 본다 + */ +export function envelopeKey(email: string): string { + const trimmed = email.trim(); + const at = trimmed.lastIndexOf('@'); + if (at < 0) return trimmed.toLowerCase(); + const domain = trimmed.slice(at + 1); + const ascii = domainToASCII(domain); + return `${trimmed.slice(0, at).toLowerCase()}@${(ascii || domain).toLowerCase()}`; +} + +/** + * envelope 의 받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소(envelopeKey)는 처음 쓴 것 하나. + * envelope 을 주지 않을 때 엔진이 머리글로 정하는 받는 사람과 같은 목록이다 + */ +export function envelopeRecipients(recipients: RecipientFields): string[] { + const seen = new Set(); + const addresses: string[] = []; + for (const { email } of [ + ...recipients.to, + ...recipients.cc, + ...recipients.bcc, + ]) { + const key = envelopeKey(email); + if (seen.has(key)) continue; + seen.add(key); + addresses.push(email.trim()); + } + return addresses; +} + +/** 제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(HOLDFOR · HOLDUNTIL — pending-send.ts) */ +export function submissionEnvelope( + from: string, + rcptTo: readonly string[], + parameters: Readonly> | null, +): Record { + return { + mailFrom: { + email: from, + parameters: parameters ? { ...parameters } : null, + }, + rcptTo: rcptTo.map((email) => ({ email, parameters: null })), + }; +} + +/** 엔진이 envelope 을 받지 않은 거절인가 (invalidProperties 의 속성이 envelope 이거나 그 안) */ +export function isEnvelopeRefusal(error: JmapError | undefined): boolean { + return ( + error?.type === INVALID_PROPERTIES && + (error.properties ?? []).some( + (property) => + property === ENVELOPE_PROPERTY || + property.startsWith(`${ENVELOPE_PROPERTY}/`), + ) + ); +} + +/** 설명 안에서 주소의 앞 · 뒤에 올 수 있는 글자 — 따옴표 · 꺾쇠 · 괄호 · 쉼표 · 쌍점 · 쌍반점 · 빈칸 (주소 글자가 붙어 있으면 다른 주소의 일부다) */ +const BOUNDARY = /[\s"'<>()[\],:;]/; + +/** 주소의 끝 뒤 — 경계 글자, 또는 문장 끝의 마침표(뒤가 끝 · 경계) */ +function endsAt(text: string, end: number): boolean { + if (end >= text.length) return true; + const next = text.charAt(end); + if (BOUNDARY.test(next)) return true; + return ( + next === '.' && + (end + 1 >= text.length || BOUNDARY.test(text.charAt(end + 1))) + ); +} + +/** text 안에 needle 이 앞뒤 경계를 두고 들어 있는가 (둘 다 소문자) — 다른 주소의 일부(`a@x` 가 `ba@x` 안)는 맞지 않는다 */ +function containsBounded(text: string, needle: string): boolean { + if (needle === '') return false; + for ( + let index = text.indexOf(needle); + index >= 0; + index = text.indexOf(needle, index + 1) + ) { + const before = index === 0 || BOUNDARY.test(text.charAt(index - 1)); + if (before && endsAt(text, index + needle.length)) return true; + } + return false; +} + +/** 설명에 주소가 적힐 수 있는 모양 — 보낸 그대로 · 따옴표 · 역슬래시를 이스케이프한 것 · 도메인을 `xn--` 로 바꾼 것 */ +function describedForms(address: string): string[] { + const escaped = address.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return [...new Set([address, escaped, envelopeKey(address)])].map((form) => + form.toLowerCase(), + ); +} + +const QUOTE = '"'; +const ESCAPE = '\\'; + +/** + * 설명의 큰따옴표 토큰 — `"…"` 안의 글, `\"` · `\\` 이스케이프를 푼 것 (엔진은 거절한 주소를 이렇게 적는다 — 머리말). 닫히지 않은 따옴표는 버린다. + * 설명 길이에 비례한다 (한 번 훑는다) + */ +function quotedTokens(description: string): string[] { + const tokens: string[] = []; + let token: string[] | null = null; + for (let index = 0; index < description.length; index += 1) { + const char = description.charAt(index); + if (token === null) { + if (char === QUOTE) token = []; + continue; + } + if (char === ESCAPE && index + 1 < description.length) { + index += 1; + token.push(description.charAt(index)); + } else if (char === QUOTE) { + tokens.push(token.join('')); + token = null; + } else { + token.push(char); + } + } + return tokens; +} + +/** + * 엔진의 거절 설명에 적힌 우리 envelope 주소 **하나** — 설명 자체는 남기지 않는다(로그 · 응답 모두). 한 차례에 하나만 뺀다(9단계 리뷰 2 — + * 다른 받는 사람을 함께 빼지 않게 · 빼는 쪽이 틀리면 받을 사람이 못 받는다, 덜 빼면 한 차례 더 낼 뿐이다): + * 1. 큰따옴표 토큰(이스케이프를 푼 것)과 맞추는 열쇠(envelopeKey)가 정확히 같은 주소 — 토큰 차례로 처음 맞는 것. + * 그래서 `"Kim "@x.cmo` 가 거절돼도 그 안에 글자로 든 kim@example.com 은 빼지 않는다 + * 2. 정확히 맞는 것이 없을 때만(설명의 모양이 다름 — 따옴표 없이 적는 등) 대소문자를 무시하고 앞뒤 경계를 두고 찾는다 — + * 여럿이 걸리면 가장 긴 것 하나(짧은 주소가 긴 주소의 글 안에 든 것이다), 길이가 같으면 envelope 의 앞 차례 + * 하나도 없으면 null — 그때는 지금처럼 오류 표대로 답한다 (설명의 모양이 바뀌어도 받는 사람을 함부로 빼지 않는다) + */ +export function addressNamedIn( + description: string | undefined, + addresses: readonly string[], +): string | null { + const text = description ?? ''; + if (text === '') return null; + + const byKey = new Map(); + for (const address of addresses) { + const key = envelopeKey(address); + if (!byKey.has(key)) byKey.set(key, address); + } + for (const token of quotedTokens(text)) { + if (!token.includes('@')) continue; + const exact = byKey.get(envelopeKey(token)); + if (exact !== undefined) return exact; + } + + const lower = text.toLowerCase(); + let longest: string | null = null; + for (const address of addresses) { + if (longest !== null && address.length <= longest.length) continue; + if (describedForms(address).some((form) => containsBounded(lower, form))) { + longest = address; + } + } + return longest; +} diff --git a/backend/src/modules/mail/jmap/filter-source.spec.ts b/backend/src/modules/mail/jmap/filter-source.spec.ts index 1338e27..8a78161 100644 --- a/backend/src/modules/mail/jmap/filter-source.spec.ts +++ b/backend/src/modules/mail/jmap/filter-source.spec.ts @@ -1,11 +1,18 @@ import { MAIL_FILTER_VERSION_PATTERN, + MAX_MAIL_BLOCKS, MAX_MAIL_FILTER_RULES, } from '@/modules/mail/mail.constants'; -import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import type { + BlockedSender, + MailFilterRule, + MailVacation, +} from '@/modules/mail/mail.types'; import { FILTER_SOURCE_FORMAT, + FILTER_SOURCE_FORMAT_WITH_BLOCKS, FILTER_SOURCE_MARKER, + LATEST_FILTER_SOURCE_FORMAT, filterRulesVersion, filterSourceLine, readFilterSource, @@ -44,6 +51,7 @@ const vacation: MailVacation = { const source: FilterScriptSource = { rules: [rule('r1'), rule('r2', { isEnabled: false, match: 'all' })], vacation, + blocked: [], }; /** 원본 줄을 스크립트 머리에 둔 글 — 생성기가 쓰는 모양 (설명 주석 → 원본 → Sieve) */ @@ -108,7 +116,11 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본 } as unknown as MailFilterRule; expect( - filterSourceLine({ rules: [shuffled, source.rules[1]], vacation }), + filterSourceLine({ + rules: [shuffled, source.rules[1]], + vacation, + blocked: [], + }), ).toBe(filterSourceLine(source)); }); @@ -163,6 +175,7 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본 }), ], vacation: { ...vacation, subject: '부재\ud83d중' }, + blocked: [], }; const line = filterSourceLine(lonely); expect(line).toContain('반쪽\\ud800글자'); @@ -172,19 +185,25 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본 expect(sourceOf(uploaded)).toEqual(lonely); }); - it('새 판(이 서버의 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => { - expect( - readFilterSource(scriptWithJson(stored({ v: FILTER_SOURCE_FORMAT + 1 }))), - ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 1 }); + it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => { + expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS); expect( readFilterSource( - scriptWithJson({ v: FILTER_SOURCE_FORMAT + 5, entries: '새 모양' }), + scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })), ), - ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 5 }); + ).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 1 }); + expect( + readFilterSource( + scriptWithJson({ + v: LATEST_FILTER_SOURCE_FORMAT + 5, + entries: '새 모양', + }), + ), + ).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 5 }); // 정수가 아니거나 · 판이 아닌 값 · 옛 판(0 이하)은 깨진 원본이다 — 저장하면 덮어쓴다 for (const v of [ FILTER_SOURCE_FORMAT + 0.5, - `${FILTER_SOURCE_FORMAT + 1}`, + `${LATEST_FILTER_SOURCE_FORMAT + 1}`, 0, -1, null, @@ -299,6 +318,110 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본 }); }); +describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => { + const blocked: BlockedSender[] = [ + { address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' }, + { address: '@ads.example.org', blockedAt: '2026-10-28T00:00:00.000Z' }, + ]; + const withBlocks: FilterScriptSource = { ...source, blocked }; + const storedV2 = (overrides: Record = {}) => + stored({ v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, blocked, ...overrides }); + + it('차단한 주소가 있을 때만 판 2 로 쓴다 — 없으면 판 1 그대로(blocked 칸 없이), 판 2 를 모르는 서버도 읽는다', () => { + const v1 = JSON.parse( + filterSourceLine(source).slice(FILTER_SOURCE_MARKER.length), + ) as Record; + expect(v1.v).toBe(FILTER_SOURCE_FORMAT); + expect('blocked' in v1).toBe(false); + + const v2 = JSON.parse( + filterSourceLine(withBlocks).slice(FILTER_SOURCE_MARKER.length), + ) as Record; + expect(v2.v).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS); + expect(v2.blocked).toEqual(blocked); + }); + + it('판 2 를 그대로 읽는다 — 차례(최근 것부터)도 그대로, 판 1 은 차단 없음으로', () => { + expect(readFilterSource(scriptWith(filterSourceLine(withBlocks)))).toEqual({ + status: 'ok', + source: withBlocks, + }); + expect(sourceOf(scriptWithJson(stored()))?.blocked).toEqual([]); + // 판 1 에 blocked 가 적혀 있어도 보지 않는다 — 판 1 을 쓰는 서버는 그 칸을 모른다 + expect( + sourceOf(scriptWithJson(stored({ blocked: [{ address: 'x' }] }))) + ?.blocked, + ).toEqual([]); + // 판 2 에 빈 목록 — 읽는다(쓸 때는 판 1 로 돌아간다) + expect( + sourceOf(scriptWithJson(storedV2({ blocked: [] })))?.blocked, + ).toEqual([]); + }); + + it('차단한 시각은 읽을 때 ISO 8601 UTC(소수 초 3자리)로 맞추고, 칸은 주소 · 시각만 쓴다', () => { + const read = sourceOf( + scriptWithJson( + storedV2({ + blocked: [{ address: 'a@b.co', blockedAt: '2026-10-29T01:02:03Z' }], + }), + ), + ); + expect(read?.blocked).toEqual([ + { address: 'a@b.co', blockedAt: '2026-10-29T01:02:03.000Z' }, + ]); + + const extra = [ + { ...blocked[0], note: '버린다' }, + ] as unknown as BlockedSender[]; + expect(filterSourceLine({ ...source, blocked: extra })).toBe( + filterSourceLine({ ...source, blocked: [blocked[0]] }), + ); + }); + + it.each<[string, unknown]>([ + ['배열이 아님', { address: 'a@b.co' }], + ['칸 없음', undefined], + ['주소가 글이 아님', [{ address: 1, blockedAt: blocked[0].blockedAt }]], + ['시각 없음', [{ address: 'a@b.co' }]], + ['시각을 읽을 수 없음', [{ address: 'a@b.co', blockedAt: '어제' }]], + [ + '대문자 — 저장 모양이 아님', + [{ address: 'A@b.co', blockedAt: blocked[0].blockedAt }], + ], + ['앞뒤 공백', [{ address: ' a@b.co', blockedAt: blocked[0].blockedAt }]], + ['주소 모양이 아님', [{ address: 'a b', blockedAt: blocked[0].blockedAt }]], + [ + '${ 가 든 주소', + [{ address: '${x}@b.co', blockedAt: blocked[0].blockedAt }], + ], + ['겹친 주소', [blocked[0], { ...blocked[0] }]], + [ + `${MAX_MAIL_BLOCKS + 1}개`, + Array.from({ length: MAX_MAIL_BLOCKS + 1 }, (_, i) => ({ + address: `s${i}@example.net`, + blockedAt: blocked[0].blockedAt, + })), + ], + ])( + '판 2 의 차단 목록이 계약에 맞지 않으면 깨진 원본 — %s', + (_label, value) => { + expect( + readFilterSource(scriptWithJson(storedV2({ blocked: value }))), + ).toEqual(BROKEN); + }, + ); + + it(`${MAX_MAIL_BLOCKS}개까지는 읽는다`, () => { + const many = Array.from({ length: MAX_MAIL_BLOCKS }, (_, i) => ({ + address: `s${i}@example.net`, + blockedAt: blocked[0].blockedAt, + })); + expect( + sourceOf(scriptWithJson(storedV2({ blocked: many })))?.blocked, + ).toHaveLength(MAX_MAIL_BLOCKS); + }); +}); + describe('filterRulesVersion — 규칙의 지문(version)', () => { const rules = source.rules; diff --git a/backend/src/modules/mail/jmap/filter-source.ts b/backend/src/modules/mail/jmap/filter-source.ts index 57660e6..c7bcae1 100644 --- a/backend/src/modules/mail/jmap/filter-source.ts +++ b/backend/src/modules/mail/jmap/filter-source.ts @@ -1,13 +1,18 @@ import { createHash } from 'crypto'; +import { parseBlockTarget } from '@/modules/mail/mail-blocks'; import { MAIL_FILTER_FIELDS, MAIL_FILTER_MATCHES, MAIL_FILTER_OPERATORS, findFilterRulesProblem, } from '@/modules/mail/mail-filters'; -import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants'; +import { + MAX_MAIL_BLOCKS, + MAX_MAIL_FILTER_RULES, +} from '@/modules/mail/mail.constants'; import type { + BlockedSender, MailFilterActions, MailFilterCondition, MailFilterRule, @@ -30,10 +35,20 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: '; /** * 원본 모양의 판 — 모양을 바꾸면 올린다. - * 이보다 큰 판(새 판)은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 — - * 읽기는 빈 규칙 · 동작하지 않음으로 보이고, 쓰기(필터 · 부재중 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게 + * 이 서버가 아는 가장 새 판(LATEST_FILTER_SOURCE_FORMAT)보다 큰 판은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): + * 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 — 읽기는 빈 규칙 · 동작하지 않음으로 보이고, + * 쓰기(필터 · 부재중 · 차단 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게. + * + * - 1: 규칙 · 부재중 (6단계). 차단한 주소가 없으면 9단계부터도 이 판으로 쓴다 — 판 2 를 모르는 옛 서버도 그대로 읽고 쓴다 + * - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. + * 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획: + * 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다) */ export const FILTER_SOURCE_FORMAT = 1; +/** 차단 목록이 든 원본의 판 (9단계) */ +export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2; +/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */ +export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS; /** * 스크립트 글에서 원본을 읽은 결과 @@ -47,10 +62,11 @@ export type FilterSourceReading = | { readonly status: 'broken' } | { readonly status: 'newer'; readonly format: number }; -/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로)과 부재중 자동 응답 */ +/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로) · 부재중 자동 응답 · 차단한 보낸 사람(9단계 — 최근에 차단한 것부터) */ export interface FilterScriptSource { readonly rules: readonly MailFilterRule[]; readonly vacation: MailVacation; + readonly blocked: readonly BlockedSender[]; } /** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */ @@ -62,10 +78,11 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = { textBody: '', }; -/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */ +/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */ export const EMPTY_FILTER_SOURCE: FilterScriptSource = { rules: [], vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }; /** 정해진 키 순서의 사본 — 들어온 객체(DTO 등)의 키 순서 · 다른 속성과 상관없이 같은 규칙이면 같은 JSON 이 된다 */ @@ -112,13 +129,26 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string { .digest('base64url'); } -/** 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중) */ +/** + * 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다 + */ export function filterSourceLine(source: FilterScriptSource): string { - return `${FILTER_SOURCE_MARKER}${JSON.stringify({ - v: FILTER_SOURCE_FORMAT, + const base = { rules: canonicalRules(source.rules), vacation: canonicalVacation(source.vacation), - })}`; + }; + return `${FILTER_SOURCE_MARKER}${JSON.stringify( + source.blocked.length === 0 + ? { v: FILTER_SOURCE_FORMAT, ...base } + : { + v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, + ...base, + blocked: source.blocked.map(({ address, blockedAt }) => ({ + address, + blockedAt, + })), + }, + )}`; } /** 스크립트 맨 앞의 주석들에서 원본 줄을 찾는다 — 주석이 아닌 줄이 나오면 멈춘다 */ @@ -246,13 +276,45 @@ function toVacation(raw: unknown): MailVacation | null { }; } +/** + * 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각. + * 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다 + */ +function toBlocked(raw: unknown): BlockedSender[] | null { + if (!Array.isArray(raw) || raw.length > MAX_MAIL_BLOCKS) return null; + const seen = new Set(); + const blocked: BlockedSender[] = []; + for (const entry of raw) { + if ( + !isRecord(entry) || + typeof entry.address !== 'string' || + typeof entry.blockedAt !== 'string' + ) { + return null; + } + const target = parseBlockTarget(entry.address); + const blockedAt = toUtcIso(entry.blockedAt); + if ( + target === null || + target.value !== entry.address || + blockedAt === null || + seen.has(target.value) + ) { + return null; + } + seen.add(target.value); + blocked.push({ address: target.value, blockedAt }); + } + return blocked; +} + const BROKEN: FilterSourceReading = { status: 'broken' }; /** * 스크립트 글에서 원본을 읽는다 (FilterSourceReading). - * 판(`v`)이 이 서버의 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다. - * 같은 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등) — 어긋나면 broken. - * 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다 + * 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다. + * 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken. + * 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다 */ export function readFilterSource(script: string): FilterSourceReading { const line = findSourceLine(script); @@ -269,12 +331,13 @@ export function readFilterSource(script: string): FilterSourceReading { if ( typeof format === 'number' && Number.isSafeInteger(format) && - format > FILTER_SOURCE_FORMAT + format > LATEST_FILTER_SOURCE_FORMAT ) { return { status: 'newer', format }; } if ( - format !== FILTER_SOURCE_FORMAT || + (format !== FILTER_SOURCE_FORMAT && + format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) || !Array.isArray(parsed.rules) || parsed.rules.length > MAX_MAIL_FILTER_RULES ) { @@ -289,6 +352,14 @@ export function readFilterSource(script: string): FilterSourceReading { } if (findFilterRulesProblem(rules)) return BROKEN; + const blocked = + format === FILTER_SOURCE_FORMAT_WITH_BLOCKS + ? toBlocked(parsed.blocked) + : []; + if (blocked === null) return BROKEN; + const vacation = toVacation(parsed.vacation); - return vacation ? { status: 'ok', source: { rules, vacation } } : BROKEN; + return vacation + ? { status: 'ok', source: { rules, vacation, blocked } } + : BROKEN; } diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts index 8dfa1d3..68bdac7 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts @@ -197,7 +197,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 }); const HANBIT = ' "한빛" '; - /** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */ + /** + * 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) + 옮기는 규칙이면 같은 요청의 계정 기다리는 제출(9단계 — held, 없으면 빈 목록). + * 옮기지 않는 규칙의 요청에는 제출 조회가 없다 — 남는 응답은 쓰이지 않는다 + */ const page = ( ids: string[], list: Record[], @@ -205,10 +208,22 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 state = 's1', total = ids.length, position = 0, - }: { state?: string; total?: number; position?: number } = {}, + held = [], + }: { + state?: string; + total?: number; + position?: number; + held?: Record[]; + } = {}, ): JmapInvocation[] => [ ['Email/query', { ids, position, total }, 'q'], ['Email/get', { state, list, notFound: [] }, 'g'], + [ + 'EmailSubmission/query', + { ids: held.map((submission) => submission.id), total: held.length }, + 'hq', + ], + ['EmailSubmission/get', { list: held }, 'hg'], ]; const updated = ( ids: string[], @@ -285,12 +300,18 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 m3: { 'mailboxIds/a': null, 'mailboxIds/j': true }, }, }); - // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간 + // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간. + // 옮기는 규칙이라 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 (붙잡힌 메일은 옮기지 않는다 — 9단계) expect(methodsOf(recorded)).toEqual([ 'Email/query', 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', 'Email/set', ]); + expect(argsOf(recorded[0], 2)).toMatchObject({ + filter: { after: expect.stringMatching(/Z$/) as unknown }, + }); for (const record of recorded) { for (const [, args] of record.calls) { expect(args).not.toHaveProperty('destroy'); @@ -925,6 +946,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 [ ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], ['Email/get', { list: [mail('m1', HANBIT)] }, 'g'], + ['EmailSubmission/query', { ids: [], total: 0 }, 'hq'], + ['EmailSubmission/get', { list: [] }, 'hg'], ], ]); expect( @@ -932,6 +955,154 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 ).toMatchObject({ code: 'MAIL_001', reason: 'inbox-state-unknown' }); }); + it('붙잡힌 메일(9단계 — 받은편지함으로 옮겨 둔 것)은 옮기지 않고 표시만 한다 — 받은편지함에 남은 메일로 이어 간다', async () => { + const later = new Date(Date.now() + 3_600_000) + .toISOString() + .replace(/\.\d+Z$/, 'Z'); + const { runner, recorded } = fakeRunner([ + page(['m1', 'm2'], [mail('m1', HANBIT), mail('m2', HANBIT)], { + held: [ + { + id: 'sb1', + emailId: 'm2', + undoStatus: 'pending', + sendAt: later, + }, + ], + }), + updated(['m1', 'm2']), + ]); + + await expect( + runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ + scanned: 2, + matched: 2, + remaining: 0, + cursor: null, + }); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { + 'keywords/$seen': true, + 'mailboxIds/a': null, + 'mailboxIds/j': true, + }, + m2: { 'keywords/$seen': true }, + }, + }); + }); + + it('계정의 기다리는 제출을 다 받지 못했으면(total 이 더 큼) 그 차례의 메일 id 로 다시 묻는다 — 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건으로 (9단계 리뷰 2)', async () => { + const later = new Date(Date.now() + 3_600_000) + .toISOString() + .replace(/\.\d+Z$/, 'Z'); + const { runner, recorded } = fakeRunner([ + [ + ['Email/query', { ids: ['m1', 'm2'], position: 0, total: 2 }, 'q'], + [ + 'Email/get', + { + state: 's1', + list: [mail('m1', HANBIT), mail('m2', HANBIT)], + notFound: [], + }, + 'g', + ], + // 상한(500)까지 왔고 더 있다 — m2 의 제출은 그 뒤 + [ + 'EmailSubmission/query', + { + ids: Array.from({ length: 500 }, (_, i) => `s${i}`), + position: 0, + total: 1200, + }, + 'hq', + ], + ['EmailSubmission/get', { list: [] }, 'hg'], + ], + [ + ['EmailSubmission/query', { ids: ['sb1'], total: 1 }, 'hq'], + [ + 'EmailSubmission/get', + { + list: [ + { + id: 'sb1', + emailId: 'm2', + undoStatus: 'pending', + sendAt: later, + }, + ], + }, + 'hg', + ], + ], + updated(['m1', 'm2']), + ]); + + await expect( + runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 2, matched: 2 }); + expect(argsOf(recorded[1])).toMatchObject({ + filter: { + emailIds: ['m1', 'm2'], + after: (argsOf(recorded[0], 2).filter as { after: string }).after, + }, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { + 'keywords/$seen': true, + 'mailboxIds/a': null, + 'mailboxIds/j': true, + }, + m2: { 'keywords/$seen': true }, + }, + }); + }); + + it('옮기는 규칙인데 기다리는 제출을 읽지 못하면 아무것도 바꾸지 않고 MAIL_001 (fail closed) — 표시만 하는 규칙은 묻지 않는다', async () => { + const broken = fakeRunner([ + [ + ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], + ['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'], + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ]); + expect( + await failureOf(broken.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'held-send-check-failed' }); + expect(broken.recorded).toHaveLength(1); + + const markOnly = fakeRunner([ + [ + ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], + ['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'], + ], + updated(['m1']), + ]); + await expect( + markOnly.runner.run( + ARA, + request({ + actions: { moveToMailboxId: null, markAsRead: true, flag: false }, + }), + context(), + ), + ).resolves.toMatchObject({ scanned: 1, matched: 1 }); + expect(methodsOf(markOnly.recorded)).toEqual([ + 'Email/query', + 'Email/get', + 'Email/set', + ]); + }); + it('엔진이 차례의 할 일을 모두 거절하면 — 확인한 것이 없으면 그 거절의 코드, 그사이 지워진 메일(notFound)은 거절로 보지 않는다', async () => { const refused = fakeRunner([ page(['m1'], [mail('m1', HANBIT)]), @@ -1044,6 +1215,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 readonly queries: SimQuery[] = []; /** 엔진 요청 하나를 처리하기 전에 — 요청 도중에 일어나는 일(새 메일 · 다른 탭의 옮기기) */ readonly beforeCall: ((methods: readonly string[]) => void)[] = []; + /** 붙잡힌 메일(9단계) — 보낼 시각이 지금 뒤인 pending 제출이 있는 메일 */ + readonly held = new Set(); private state = 1; private nextDoc = 0; @@ -1138,6 +1311,30 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 }, callId, ]); + } else if (method === 'EmailSubmission/query') { + const ids = [...this.held].map((emailId) => `sub-${emailId}`); + queried.set(callId, ids); + responses.push([ + 'EmailSubmission/query', + { ids, total: ids.length }, + callId, + ]); + } else if (method === 'EmailSubmission/get') { + const later = new Date(Date.now() + 3_600_000) + .toISOString() + .replace(/\.\d+Z$/, 'Z'); + responses.push([ + 'EmailSubmission/get', + { + list: [...this.held].map((emailId) => ({ + id: `sub-${emailId}`, + emailId, + undoStatus: 'pending', + sendAt: later, + })), + }, + callId, + ]); } else if (method === 'Email/set') { expect(args).not.toHaveProperty('destroy'); if ( @@ -1486,5 +1683,28 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 expect(left).toHaveLength(6); expect(left.every((email) => email.receivedAt >= T0 + 20_000)).toBe(true); }); + + it('붙잡힌 메일(9단계)은 모두 맞는 규칙에도 옮기지 않는다 — 표시만 하고 받은편지함에 남긴 채 끝까지 간다', async () => { + const engine = new FakeEngine(); + fill(engine, 700); + const kept = [...engine.emails.keys()].filter( + (_, index) => index % 300 === 5, + ); + for (const id of kept) engine.held.add(id); + const always = request({ + match: 'always', + conditions: [], + actions: { moveToMailboxId: TARGET, markAsRead: true, flag: false }, + }); + + const result = await runAll(engine, always); + expect(result).toMatchObject({ done: true, scanned: 700, matched: 700 }); + const left = engine.sorted(INBOX).map(({ id }) => id); + expect(left.sort()).toEqual([...kept].sort()); + for (const id of kept) { + expect(engine.emails.get(id)?.keywords.has('$seen')).toBe(true); + } + expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length); + }); }); }); diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts index f636005..1e2243e 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts @@ -45,6 +45,15 @@ import type { JmapQueryResult, JmapSetResult, } from './jmap.types'; +import { + heldAmong, + heldEmailsFilter, + heldSubmissionFilter, + readHeldEmails, + submissionCalls, + type HeldEmails, + type HeldLookup, +} from './pending-send'; /** * 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지, @@ -79,6 +88,12 @@ const NOT_FOUND = 'notFound'; const MS_PER_SECOND = 1_000; +/** 붙잡힌 메일(9단계)을 묻는 호출 id 앞글자 — `hq` · `hg` (옮기는 규칙일 때만 쪽 읽기에 덧붙인다) */ +const HELD_GUARD_CALLS = 'h'; + +/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */ +const NO_HELD: HeldEmails = { ids: new Set(), complete: true }; + /** * 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지. * 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다 @@ -110,11 +125,12 @@ interface RunPlan { readonly flag: boolean; } -/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */ +/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 · 붙잡힌 메일을 함께 묻는지(옮기는 규칙) */ interface PageRead { readonly properties: readonly string[]; readonly needsBody: boolean; readonly limit: number; + readonly withHeld: boolean; } /** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */ @@ -136,6 +152,8 @@ interface RoundPage { readonly state: string | null; /** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */ readonly from: RunState; + /** 같은 요청에서 물은 붙잡힌 메일(옮기는 규칙일 때만 — 아니면 없음) */ + readonly held: HeldEmails; } /** 한 차례를 마친 결과 */ @@ -185,10 +203,14 @@ function runPlan( }; } -/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */ +/** + * 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null. + * 붙잡힌 메일(9단계 — held)은 옮기지 않는다 — 표시만 한다 (취소로만 보낸편지함을 떠난다) + */ function emailPatch( plan: RunPlan, email: JmapEmail | undefined, + held: boolean, ): Record | null { const patch: Record = {}; if (plan.markAsRead && email?.keywords?.$seen !== true) { @@ -197,7 +219,7 @@ function emailPatch( if (plan.flag && email?.keywords?.$flagged !== true) { patch['keywords/$flagged'] = true; } - if (plan.moveTo !== null) { + if (plan.moveTo !== null && !held) { patch[`mailboxIds/${plan.inboxId}`] = null; patch[`mailboxIds/${plan.moveTo}`] = true; } @@ -312,6 +334,8 @@ export class JmapFilterRunner { properties: filterRunProperties(fields), needsBody, limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE, + // 옮기는 규칙이면 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 — 붙잡힌 메일은 옮기지 않는다(9단계) + withHeld: plan.moveTo !== null, }; let scanned = 0; @@ -408,35 +432,44 @@ export class JmapFilterRunner { run: BulkRun, ): Promise { if (from.anchor !== null) { + const now = Date.now(); const responses = await this.queryPage( user, inboxId, { before: from.before, anchor: from.anchor }, read, run.callOptions(), + now, ); const query = findResult(responses, 'q', 'Email/query'); if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) { - return this.toPage(responses, from, read); + return this.toPage(responses, from, read, now); } } const restart: RunState = { ...from, anchor: null }; const before = restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before; + const now = Date.now(); const responses = await this.queryPage( user, inboxId, { before, fromTop: true }, read, run.callOptions(), + now, ); - return this.toPage(responses, restart, read); + return this.toPage(responses, restart, read, now); } + /** + * 쪽 읽기 결과 — 옮기는 규칙이면 같은 요청의 붙잡힌 메일도 읽는다(읽은 메일이 있을 때만, 읽지 못하면 MAIL_001 — fail closed) + * @param now 쪽을 물은 때 (붙잡힘 판정의 지금) + */ private toPage( responses: JmapInvocation[], from: RunState, read: PageRead, + now: number, ): RoundPage { const query = expectResult(responses, 'q', 'Email/query'); const found = expectResult>( @@ -447,6 +480,10 @@ export class JmapFilterRunner { const ids = query.ids ?? []; const start = query.position ?? 0; const known = typeof query.total === 'number' ? query.total : null; + const held = + read.withHeld && ids.length > 0 + ? readHeldEmails(responses, HELD_GUARD_CALLS, now) + : NO_HELD; return { start, ids, @@ -457,6 +494,7 @@ export class JmapFilterRunner { emails: new Map((found.list ?? []).map((email) => [email.id, email])), state: isState(found.state) ? found.state : null, from, + held, }; } @@ -470,6 +508,7 @@ export class JmapFilterRunner { start: PageStart, read: PageRead, options: JmapCallOptions, + now: number, ): Promise { const filter: JmapEmailFilterCondition = start.before === null @@ -505,6 +544,14 @@ export class JmapFilterRunner { }, 'g', ], + // 옮기는 규칙 — 계정의 기다리는 제출(보낼 시각이 지금 뒤 — 엔진의 색인)을 같은 요청에서 묻는다 (pending-send.ts) + ...(read.withHeld + ? submissionCalls( + accountId, + heldSubmissionFilter(now), + HELD_GUARD_CALLS, + ) + : []), ], byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options, ); @@ -565,13 +612,28 @@ export class JmapFilterRunner { } } + // 옮기는 규칙이면 맞은 메일 가운데 붙잡힌 메일(9단계)은 옮기지 않는다 — 표시만 한다 + const matchedIds = page.ids.filter((id) => verdicts.get(id) === true); + const held = + plan.moveTo === null || matchedIds.length === 0 + ? new Set() + : await heldAmong( + page.held, + matchedIds, + this.heldLookup(user, run.callOptions()), + ); const patches = new Map>(); + /** 옮기기가 든 패치 — 이 메일은 바뀌면 받은편지함을 떠난다 */ + const moving = new Set(); let unchanged = 0; - for (const id of page.ids) { - if (verdicts.get(id) !== true) continue; - const patch = emailPatch(plan, page.emails.get(id)); - if (patch === null) unchanged += 1; - else patches.set(id, patch); + for (const id of matchedIds) { + const patch = emailPatch(plan, page.emails.get(id), held.has(id)); + if (patch === null) { + unchanged += 1; + continue; + } + patches.set(id, patch); + if (plan.moveTo !== null && !held.has(id)) moving.add(id); } const applied: Applied | null = @@ -586,7 +648,7 @@ export class JmapFilterRunner { const verdict = verdicts.get(id); if (verdict === undefined || verdict === 'gone') continue; judged += 1; - const moved = plan.moveTo !== null && applied.updated.has(id); + const moved = moving.has(id) && applied.updated.has(id); if (!moved && !applied.gone.has(id)) lastStayed = id; } return { @@ -602,6 +664,27 @@ export class JmapFilterRunner { }; } + /** + * 다 받지 못한 계정 전체 조회를 메일 id 로 보충하는 묻기 (pending-send.ts 의 heldAmong — 읽지 못하면 MAIL_001). + * 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id 를 더한다(heldEmailsFilter — 지난 제출이 상한을 채우지 않게) + */ + private heldLookup(user: string, options: JmapCallOptions): HeldLookup { + return async (emailIds) => { + const now = Date.now(); + const responses = await this.jmap.call( + user, + (accountId) => + submissionCalls( + accountId, + heldEmailsFilter(emailIds, now), + HELD_GUARD_CALLS, + ), + options, + ); + return readHeldEmails(responses, HELD_GUARD_CALLS, now); + }; + } + /** * 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState): * 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다. diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts index 0573765..a0f48bf 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts @@ -1,7 +1,11 @@ import { Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; -import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import type { + BlockedSender, + MailFilterRule, + MailVacation, +} from '@/modules/mail/mail.types'; import { NEVER_CONFIGURED_VACATION, filterRulesVersion, @@ -22,6 +26,8 @@ import type { import { buildFilterScript } from './sieve-script'; const ARA = 'ara.kim@arasystem.test'; +/** 대역 엔진의 스팸메일함 */ +const JUNK = { id: 'jk1', name: 'Junk Mail' }; const SIEVE = 'urn:ietf:params:jmap:sieve'; const VACATION = 'urn:ietf:params:jmap:vacationresponse'; @@ -88,6 +94,11 @@ class FakeSieveEngine { missingDownloads = 0; /** 저장 뒤 같은 요청의 SieveScript/get 을 메서드 오류로 */ failRereadAfterSet = false; + /** 메일함 목록 (Mailbox/get) — 차단이 있으면 스팸메일함을 찾는다 */ + mailboxes: { id: string; name: string | null; role: string | null }[] = [ + { id: 'in1', name: 'Inbox', role: 'inbox' }, + { id: JUNK.id, name: JUNK.name, role: 'junk' }, + ]; private sequence = 0; readonly client = { @@ -147,11 +158,24 @@ class FakeSieveEngine { addOurs(source: FilterScriptSource, isActive = true): FakeScript { return this.addScript( FILTER_SCRIPT_NAME, - buildFilterScript(source).text, + buildFilterScript(source, { junkMailbox: JUNK }).text, isActive, ); } + /** 우리 스크립트의 지금 글 */ + ourText(): string | undefined { + const ours = this.ours(); + return ours ? this.blobs.get(ours.blobId) : undefined; + } + + /** 엔진이 받은 메서드 이름들 (차례대로) */ + methods(): string[] { + return this.recorded.flatMap(({ calls }) => + calls.map(([method]) => method), + ); + } + /** 우리 스크립트의 지금 원본 */ ourSource(): FilterScriptSource | null { const ours = this.scripts.find( @@ -205,6 +229,12 @@ class FakeSieveEngine { ]; case 'SieveScript/set': return this.set(args, callId); + case 'Mailbox/get': + return [ + method, + { list: this.mailboxes.map((mailbox) => ({ ...mailbox })) }, + callId, + ]; default: return ['error', { type: 'unknownMethod' }, callId]; } @@ -378,7 +408,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('우리 스크립트가 켜져 있으면 원본의 규칙 · 부재중 — 글은 엔진 한도(102,400바이트)까지만 내려받는다', async () => { const engine = new FakeSieveEngine(); const rules = [rule('r1'), rule('r2', { isEnabled: false })]; - const ours = engine.addOurs({ rules, vacation: vacationOn }); + const ours = engine.addOurs({ rules, vacation: vacationOn, blocked: [] }); await expect(engine.store.getFilters(ARA)).resolves.toEqual({ rules, @@ -399,7 +429,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('엔진에서 다른 스크립트가 켜져 있으면 isActive false — 규칙 · 부재중은 원본 그대로', async () => { const engine = new FakeSieveEngine(); const rules = [rule('r1')]; - engine.addOurs({ rules, vacation: vacationOn }); + engine.addOurs({ rules, vacation: vacationOn, blocked: [] }); engine.addScript('vacation', 'require ["vacation"];\r\n', true); await expect(engine.store.getFilters(ARA)).resolves.toEqual({ @@ -442,7 +472,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('내려받는 사이 글이 없어졌으면(404 — 그사이 바뀜) 다시 묻는다 — 세 번 모두면 MAIL_001', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + engine.addOurs({ + rules: [rule('r1')], + vacation: vacationOn, + blocked: [], + }); engine.missingDownloads = 2; await expect(engine.store.getFilters(ARA)).resolves.toMatchObject({ isActive: true, @@ -486,13 +520,19 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion([]), }), () => engine.store.setVacation(ARA, vacationOn), + () => + engine.store.addBlock(ARA, { + address: 'kim@spam.example', + blockedAt: '2026-10-29T00:00:00.000Z', + }), + () => engine.store.removeBlock(ARA, 'kim@spam.example'), ]) { expect(await failureOf(save())).toEqual({ code: 'MAIL_001', context: { reason: 'filter-source-newer', format: 99, - supportedFormat: 1, + supportedFormat: 2, }, }); } @@ -510,7 +550,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 ); await engine.store.setVacation(ARA, vacationOn); - expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules: [], + vacation: vacationOn, + blocked: [], + }); }); }); @@ -540,7 +584,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 isActive: true, }); - const expectedSource = { rules, vacation: vacationOn }; + const expectedSource = { rules, vacation: vacationOn, blocked: [] }; expect(engine.client.upload).toHaveBeenCalledTimes(1); const [, blob, type] = engine.client.upload.mock.calls[0]; expect(type).toBe('application/sieve'); @@ -575,7 +619,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 const engine = new FakeSieveEngine(); const before = [rule('r1')]; const ours = engine.addOurs( - { rules: before, vacation: vacationOn }, + { rules: before, vacation: vacationOn, blocked: [] }, false, ); engine.addScript('other', 'keep;\r\n', true); @@ -599,13 +643,21 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 onSuccessActivateScript: ours.id, }, ]); - expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); expect(engine.ours()?.isActive).toBe(true); }); it('그사이 다른 곳에서 규칙이 바뀌었으면(지문이 다름) MAIL_012 — 올리지도 쓰지도 않는다', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + engine.addOurs({ + rules: [rule('r1')], + vacation: vacationOn, + blocked: [], + }); expect( await failureOf( @@ -625,7 +677,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('부재중 응답만 바뀐 것은 걸리지 않는다 — 다른 곳에서 바꾼 부재중을 그대로 둔다', async () => { const engine = new FakeSieveEngine(); const before = [rule('r1')]; - engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION }); + engine.addOurs({ + rules: before, + vacation: NEVER_CONFIGURED_VACATION, + blocked: [], + }); const version = (await engine.store.getFilters(ARA)).version; await engine.store.setVacation(ARA, vacationOn); @@ -633,13 +689,17 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 await expect( engine.store.setFilters(ARA, { rules, version }), ).resolves.toMatchObject({ rules }); - expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); }); it('저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 옛 지문이어도 그대로 저장한다 — 같은 요청을 다시 보낸 경우', async () => { const engine = new FakeSieveEngine(); const rules = [rule('r1')]; - engine.addOurs({ rules, vacation: vacationOn }, false); + engine.addOurs({ rules, vacation: vacationOn, blocked: [] }, false); engine.addScript('other', 'keep;\r\n', true); await expect( @@ -657,14 +717,22 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('쓰는 사이 상태가 바뀌면(stateMismatch) 다시 읽어 확인하고 쓴다 — 그사이 바뀐 부재중을 지킨다', async () => { const engine = new FakeSieveEngine(); const before = [rule('r1')]; - engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION }); + engine.addOurs({ + rules: before, + vacation: NEVER_CONFIGURED_VACATION, + blocked: [], + }); // 다른 탭이 부재중을 켰다 engine.beforeSet.push(() => { const ours = engine.ours()!; const blobId = 'other-tab'; engine.blobs.set( blobId, - buildFilterScript({ rules: before, vacation: vacationOn }).text, + buildFilterScript({ + rules: before, + vacation: vacationOn, + blocked: [], + }).text, ); ours.blobId = blobId; engine.state += 1; @@ -679,12 +747,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 ).resolves.toMatchObject({ rules, isActive: true }); expect(engine.setCalls()).toHaveLength(2); expect(engine.client.upload).toHaveBeenCalledTimes(2); - expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); }); it('다시 읽은 글이 같으면 올린 것을 다시 쓴다 — 엔진에 같은 글을 두 번 올리지 않는다', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [], vacation: vacationOn }); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] }); engine.beforeSet.push(() => { engine.state += 1; }); @@ -699,13 +771,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('다시 읽었더니 다른 곳에서 규칙을 저장했으면 MAIL_012', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [], vacation: vacationOn }); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] }); engine.beforeSet.push(() => { const ours = engine.ours()!; engine.blobs.set( 'other-tab', - buildFilterScript({ rules: [rule('theirs')], vacation: vacationOn }) - .text, + buildFilterScript({ + rules: [rule('theirs')], + vacation: vacationOn, + blocked: [], + }).text, ); ours.blobId = 'other-tab'; engine.state += 1; @@ -728,7 +803,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 const engine = new FakeSieveEngine(); // 상태 확인을 지나 만들기에서 겹쳤다 — 다른 요청이 같은 이름으로 먼저 만들었다 engine.setReplies.push(() => { - engine.addOurs({ rules: [], vacation: vacationOn }, true); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] }, true); return [ 'SieveScript/set', { notCreated: { script: { type: 'alreadyExists' } } }, @@ -746,7 +821,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 expect(engine.setCalls()[1]).toMatchObject({ update: { [engine.ours()!.id]: expect.any(Object) as object }, }); - expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); }); it('세 번 모두 어긋나면 MAIL_001 — 쓰지 않았다', async () => { @@ -943,20 +1022,32 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 it('규칙을 그대로 두고 부재중만 바꾼다 — 지문을 보지 않고, 규칙의 지문은 그대로다', async () => { const engine = new FakeSieveEngine(); const rules = [rule('r1'), rule('r2', { isEnabled: false })]; - engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION }); + engine.addOurs({ + rules, + vacation: NEVER_CONFIGURED_VACATION, + blocked: [], + }); const before = await engine.store.getFilters(ARA); await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({ ...vacationOn, isActive: true, }); - expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); await expect(engine.store.getFilters(ARA)).resolves.toEqual(before); }); it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + engine.addOurs({ + rules: [rule('r1')], + vacation: vacationOn, + blocked: [], + }); engine.addScript('vacation', 'require ["vacation"];\r\n', true); await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({ isActive: false, @@ -974,7 +1065,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 engine.addScript('vacation', 'require ["vacation"];\r\n', true); await engine.store.setVacation(ARA, vacationOn); - expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules: [], + vacation: vacationOn, + blocked: [], + }); expect(engine.ours()?.isActive).toBe(true); }); @@ -983,12 +1078,20 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 engine.addScript(FILTER_SCRIPT_NAME, 'keep;\r\n', true); await engine.store.setVacation(ARA, vacationOn); - expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + expect(engine.ourSource()).toEqual({ + rules: [], + vacation: vacationOn, + blocked: [], + }); }); it('쓰는 사이 다른 곳에서 필터를 저장했으면 다시 읽어 그 규칙을 지킨다', async () => { const engine = new FakeSieveEngine(); - engine.addOurs({ rules: [], vacation: NEVER_CONFIGURED_VACATION }); + engine.addOurs({ + rules: [], + vacation: NEVER_CONFIGURED_VACATION, + blocked: [], + }); const theirs = [rule('theirs')]; engine.beforeSet.push(() => { const ours = engine.ours()!; @@ -997,6 +1100,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 buildFilterScript({ rules: theirs, vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }).text, ); ours.blobId = 'other-tab'; @@ -1007,6 +1111,292 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 expect(engine.ourSource()).toEqual({ rules: theirs, vacation: vacationOn, + blocked: [], + }); + }); + }); + + describe('보낸 사람 차단 (getBlocks · addBlock · removeBlock — 9단계)', () => { + const at = '2026-10-29T01:00:00.000Z'; + const kim: BlockedSender = { address: 'kim@spam.example', blockedAt: at }; + const domain: BlockedSender = { + address: '@junk.example', + blockedAt: '2026-10-28T00:00:00.000Z', + }; + const mailboxReads = (engine: FakeSieveEngine) => + engine.methods().filter((method) => method === 'Mailbox/get').length; + + it('우리 스크립트가 없으면 빈 목록 · 동작함, 있으면 저장한 차례(최근 것부터) · 필터와 같은 isActive', async () => { + const empty = new FakeSieveEngine(); + await expect(empty.store.getBlocks(ARA)).resolves.toEqual({ + senders: [], + isActive: true, + }); + + const engine = new FakeSieveEngine(); + engine.addOurs( + { rules: [rule('r1')], vacation: vacationOn, blocked: [kim, domain] }, + false, + ); + await expect(engine.store.getBlocks(ARA)).resolves.toEqual({ + senders: [kim, domain], + isActive: false, + }); + // 읽기는 메일함 목록을 묻지 않는다 + expect(mailboxReads(engine)).toBe(0); + }); + + it('처음 차단 — 맨 앞에 더하고 규칙 · 부재중은 그대로, 스팸메일함(id · 이름)을 엔진에서 찾아 넣고 판 2 로 쓴다', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1')]; + engine.addOurs({ rules, vacation: vacationOn, blocked: [domain] }); + + await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({ + sender: kim, + created: true, + }); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [kim, domain], + }); + const text = engine.ourText() ?? ''; + expect(text).toContain('# arasystem-source: {"v":2,'); + expect(text).toContain('fileinto :mailboxid "jk1" "Junk Mail";'); + expect(text).toContain('address :all :is "from" ["kim@spam.example"]'); + expect(mailboxReads(engine)).toBe(1); + expect(engine.setCalls()).toHaveLength(1); + expect(engine.setCalls()[0]).toMatchObject({ + ifInState: expect.any(String) as unknown, + onSuccessActivateScript: expect.any(String) as unknown, + }); + }); + + it('이미 있는 주소면 쓰지 않고 그 항목을 돌려준다 (created: false — 처음 차단한 시각)', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] }); + + await expect( + engine.store.addBlock(ARA, { + ...kim, + blockedAt: '2026-10-30T00:00:00.000Z', + }), + ).resolves.toEqual({ sender: kim, created: false }); + expect(engine.client.upload).not.toHaveBeenCalled(); + expect(engine.setCalls()).toEqual([]); + }); + + it('이미 있는 주소인데 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을 다시 써서 켠다 — 성공한 차단은 늘 "동작 중" (9단계 리뷰 뒤)', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1')]; + engine.addOurs({ rules, vacation: vacationOn, blocked: [kim] }, false); + // 다른 프로그램(ManageSieve 등)이 켠 스크립트가 있다 + engine.addScript('other', 'keep;', true); + + await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({ + sender: kim, + created: false, + }); + // 원본은 그대로(차단한 시각도 처음 것) — 다시 써서 켰다 + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [kim], + }); + expect(engine.setCalls()).toHaveLength(1); + expect(engine.setCalls()[0]).toMatchObject({ + onSuccessActivateScript: expect.any(String) as unknown, + }); + await expect(engine.store.getBlocks(ARA)).resolves.toEqual({ + senders: [kim], + isActive: true, + }); + }); + + it('500개면 VAL_001 — 쓰지 않는다', async () => { + const engine = new FakeSieveEngine(); + const many = Array.from({ length: 500 }, (_, i) => ({ + address: `s${i}@spam.example`, + blockedAt: at, + })); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: many }); + + expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({ + code: 'VAL_001', + context: { reason: 'too-many-mail-blocks', stored: 500 }, + }); + expect(engine.setCalls()).toEqual([]); + }); + + it('만든 글이 한도(90,000바이트)를 넘으면 MAIL_013 — 올리지 않는다, context 에는 수만 (주소는 남기지 않는다)', async () => { + const engine = new FakeSieveEngine(); + // 주소 모양을 지키는 긴 주소 — @ 앞 64자 · 이름 마디 63자 · 모두 254자까지 (읽을 때 다시 본다) + const filler = (i: number) => ({ + address: `${`s${i}`.padEnd(40, 'x')}@${'d'.repeat(50)}.example`, + blockedAt: at, + }); + const longest = `${'l'.repeat(60)}@${'d'.repeat(60)}.${'e'.repeat(60)}.${'f'.repeat(60)}.example`; + const sourceWith = (count: number): FilterScriptSource => ({ + rules: [], + vacation: vacationOn, + blocked: Array.from({ length: count }, (_, i) => filler(i)), + }); + const bytesOf = (source: FilterScriptSource) => + buildFilterScript(source, { junkMailbox: JUNK }).bytes; + // 한도 바로 아래까지 채운다 — 하나 더 채우면 넘는다. 긴 주소는 채우는 주소보다 길어 더하면 넘는다 + let count = 1; + while (bytesOf(sourceWith(count + 1)) <= 90_000) count += 1; + const stored = sourceWith(count); + expect(bytesOf(stored)).toBeLessThanOrEqual(90_000); + expect( + bytesOf({ + ...stored, + blocked: [{ address: longest, blockedAt: at }, ...stored.blocked], + }), + ).toBeGreaterThan(90_000); + engine.addOurs(stored); + await expect(engine.store.getBlocks(ARA)).resolves.toMatchObject({ + isActive: true, + }); + + const failure = await failureOf( + engine.store.addBlock(ARA, { address: longest, blockedAt: at }), + ); + expect(failure.code).toBe('MAIL_013'); + expect(failure.context).toMatchObject({ + reason: 'filter-script-too-large', + blocked: count + 1, + }); + expect(JSON.stringify(failure.context)).not.toContain('example'); + expect(engine.client.upload).not.toHaveBeenCalled(); + }); + + it('스팸메일함이 없으면 MAIL_001(junk-mailbox-missing) — 쓰지 않는다', async () => { + const engine = new FakeSieveEngine(); + engine.mailboxes = [{ id: 'in1', name: 'Inbox', role: 'inbox' }]; + + expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({ + code: 'MAIL_001', + context: { reason: 'junk-mailbox-missing' }, + }); + expect(engine.setCalls()).toEqual([]); + }); + + it.each<[string, string | null]>([ + ['이름 없음', null], + ['빈 이름', ' '], + ['${ 가 든 이름(다른 프로그램이 바꾼 이름)', '스팸${x}'], + ])('스팸메일함의 대체 이름 — %s 이면 "Junk"', async (_label, name) => { + const engine = new FakeSieveEngine(); + engine.mailboxes = [{ id: 'jk9', name, role: 'junk' }]; + + await engine.store.addBlock(ARA, kim); + expect(engine.ourText()).toContain('fileinto :mailboxid "jk9" "Junk";'); + }); + + it('필터의 지문(version)을 묻지 않는다 — 그사이 다른 곳에서 바꾼 규칙을 다시 읽어 지키고 더한다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ + rules: [rule('r1')], + vacation: vacationOn, + blocked: [], + }); + const theirs = [rule('theirs')]; + engine.beforeSet.push(() => { + const ours = engine.ours()!; + engine.blobs.set( + 'other-tab', + buildFilterScript({ + rules: theirs, + vacation: vacationOn, + blocked: [], + }).text, + ); + ours.blobId = 'other-tab'; + engine.state += 1; + }); + + await engine.store.addBlock(ARA, kim); + expect(engine.ourSource()).toEqual({ + rules: theirs, + vacation: vacationOn, + blocked: [kim], + }); + expect(engine.setCalls()).toHaveLength(2); + }); + + it('차단을 푼다 — 나머지는 그대로, 마지막 하나를 풀면 판 1 로 돌아간다(그때는 메일함 목록을 묻지 않는다)', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1')]; + engine.addOurs({ rules, vacation: vacationOn, blocked: [kim, domain] }); + + await engine.store.removeBlock(ARA, domain.address); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [kim], + }); + const readsBefore = mailboxReads(engine); + + await engine.store.removeBlock(ARA, kim.address); + expect(engine.ourSource()).toEqual({ + rules, + vacation: vacationOn, + blocked: [], + }); + expect(engine.ourText()).toContain('# arasystem-source: {"v":1,'); + expect(engine.ourText()).not.toContain('address :'); + expect(mailboxReads(engine)).toBe(readsBefore); + }); + + it('없는 주소를 풀면 RES_001 — 쓰지 않는다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] }); + + expect( + await failureOf(engine.store.removeBlock(ARA, 'lee@spam.example')), + ).toEqual({ + code: 'RES_001', + context: { reason: 'mail-block-not-found' }, + }); + expect(engine.setCalls()).toEqual([]); + }); + + it('필터 · 부재중 저장은 차단 목록을 그대로 둔다 — 쓸 때 스팸메일함을 다시 찾는다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] }); + + await engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }); + await engine.store.setVacation(ARA, NEVER_CONFIGURED_VACATION); + expect(engine.ourSource()).toEqual({ + rules: [rule('r1')], + vacation: NEVER_CONFIGURED_VACATION, + blocked: [kim], + }); + expect(engine.ourText()).toContain('fileinto :mailboxid "jk1"'); + expect(mailboxReads(engine)).toBe(2); + }); + + it('깨진 원본에 차단을 더하면 덮어쓴다 — 필터 저장과 같다', async () => { + const engine = new FakeSieveEngine(); + engine.addScript( + FILTER_SCRIPT_NAME, + '# arasystem-source: {"v":2,"rules":[],"blocked":"깨짐"}\r\nkeep;\r\n', + true, + ); + await expect(engine.store.getBlocks(ARA)).resolves.toEqual({ + senders: [], + isActive: false, + }); + + await engine.store.addBlock(ARA, kim); + expect(engine.ourSource()).toEqual({ + rules: [], + vacation: NEVER_CONFIGURED_VACATION, + blocked: [kim], }); }); }); diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts index 22a8391..29a3c34 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts @@ -1,7 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; +import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants'; import type { + BlockedSender, + MailBlockAdded, + MailBlockList, MailFilterSet, MailFilterUpdate, MailVacation, @@ -11,7 +15,7 @@ import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { EMPTY_FILTER_SOURCE, - FILTER_SOURCE_FORMAT, + LATEST_FILTER_SOURCE_FORMAT, canonicalRules, filterRulesVersion, readFilterSource, @@ -29,6 +33,7 @@ import { import type { JmapError, JmapGetResult, + JmapMailbox, JmapSetResult, JmapSieveScript, JmapVacationResponse, @@ -38,6 +43,7 @@ import { SIEVE_STRING_MAX_BYTES, buildFilterScript, type BuiltFilterScript, + type JunkMailboxTarget, } from './sieve-script'; /** JMAP for Sieve (RFC 9661) — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-28 확인) */ @@ -115,7 +121,7 @@ interface ScriptSnapshot extends ScriptView { readonly newerFormat: number | null; } -/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 수만 (규칙의 글은 남기지 않는다) */ +/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */ function scriptSummary( script: BuiltFilterScript, source: FilterScriptSource, @@ -125,6 +131,7 @@ function scriptSummary( rules: source.rules.length, enabledRules: source.rules.filter((rule) => rule.isEnabled).length, vacation: source.vacation.isEnabled, + blocked: source.blocked.length, }; } @@ -141,8 +148,20 @@ function toVacationState(view: ScriptView): MailVacationState { return { ...view.source.vacation, isActive: view.isActive }; } +/** 차단 목록의 응답 — 최근에 차단한 것부터(저장한 차례) + 필터와 같은 isActive (계약 2절 "보낸 사람 차단"의 MailBlockList) */ +function toBlockList(view: ScriptView): MailBlockList { + return { + senders: view.source.blocked.map(({ address, blockedAt }) => ({ + address, + blockedAt, + })), + isActive: view.isActive, + }; +} + /** - * 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리). + * 메일 필터 · 부재중 자동 응답 · 보낸 사람 차단(9단계)의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리). + * 셋 가운데 하나를 바꿀 때도 나머지 둘은 다시 읽은 그대로 함께 쓴다. * Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면 * 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다 — 그래서 한 스크립트에 함께 둔다. `vacation` 스크립트는 지우지도 고치지도 않는다(엔진이 forbidden). * @@ -184,7 +203,8 @@ export class JmapFilterScriptStore { }, }); } - return { rules: update.rules, vacation: current.vacation }; + // 부재중 · 차단 목록은 다시 읽은 그대로 둔다 + return { ...current, rules: update.rules }; }); return toFilterSet(saved); } @@ -197,18 +217,68 @@ export class JmapFilterScriptStore { return toVacationState(await this.snapshot(user)); } - /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */ + /** 부재중 자동 응답만 바꾼다 — 규칙 · 차단 목록은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */ async setVacation( user: string, vacation: MailVacation, ): Promise { const saved = await this.write(user, (current) => ({ - rules: current.rules, + ...current, vacation, })); return toVacationState(saved); } + /** 차단 목록 (계약 2절 "보낸 사람 차단" — 9단계) — 원본을 읽을 수 없으면(깨짐 · 새 판) 빈 목록 · 동작하지 않음 */ + async getBlocks(user: string): Promise { + return toBlockList(await this.snapshot(user)); + } + + /** + * 차단을 더한다 — 맨 앞(최근)에. 이미 있는 주소면 그 항목을 돌려준다(created: false) — 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을 + * 다시 써서 켠다(setFilters 가 같은 규칙을 다시 저장하는 것과 같다 — 성공한 차단은 늘 "동작 중"이다), 켜져 있으면 쓰지 않는다. + * 규칙 · 부재중은 다시 읽은 그대로 둔다 — 필터의 지문(version)을 보지 않는다(주소 하나를 더하는 일이라 그사이 바뀐 필터를 덮지 않는다). + * 이미 MAX_MAIL_BLOCKS 개면 VAL_001, 만든 글이 한도를 넘으면 MAIL_013 + */ + async addBlock(user: string, sender: BlockedSender): Promise { + let added: MailBlockAdded = { sender, created: true }; + await this.write(user, (current, isActive) => { + const existing = current.blocked.find( + ({ address }) => address === sender.address, + ); + if (existing) { + added = { sender: existing, created: false }; + return isActive ? null : current; + } + if (current.blocked.length >= MAX_MAIL_BLOCKS) { + throw new BusinessException('VAL_001', { + context: { + reason: 'too-many-mail-blocks', + stored: current.blocked.length, + }, + }); + } + added = { sender, created: true }; + return { ...current, blocked: [sender, ...current.blocked] }; + }); + return added; + } + + /** 차단을 푼다 — 없는 주소면 RES_001(아무것도 쓰지 않는다). 규칙 · 부재중은 다시 읽은 그대로 */ + async removeBlock(user: string, address: string): Promise { + await this.write(user, (current) => { + if (!current.blocked.some((entry) => entry.address === address)) { + throw new BusinessException('RES_001', { + context: { reason: 'mail-block-not-found' }, + }); + } + return { + ...current, + blocked: current.blocked.filter((entry) => entry.address !== address), + }; + }); + } + /** * 지금 엔진 규칙을 읽는다 — 스크립트 목록과 엔진의 부재중 응답을 한 요청으로 묻고, 우리 스크립트가 있으면 글을 내려받는다. * 내려받는 사이 글이 없어졌으면(그사이 바뀜) 다시 묻는다 — SCRIPT_ATTEMPTS 번을 넘으면 MAIL_001 @@ -248,7 +318,7 @@ export class JmapFilterScriptStore { state, scriptId: null, isActive: true, - source: { rules: [], vacation: toVacation(list[0]) }, + source: { rules: [], vacation: toVacation(list[0]), blocked: [] }, newerFormat: null, }; } @@ -297,16 +367,22 @@ export class JmapFilterScriptStore { } /** - * 다음 원본을 정해(change) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012) 그대로 던진다. + * 다음 원본을 정해(change — 지금 원본과 엔진에서 켜져 있는지를 받는다) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012 · VAL_001 · RES_001) 그대로 던진다. + * change 가 null 이면 바꿀 것이 없다 — 쓰지 않고 지금 것을 돌려준다(이미 있는 차단 · 켜져 있음). 같은 원본을 돌려주면 그대로 다시 쓰고 켠다. * 만든 글이 한도를 넘으면 MAIL_013 (올리지 않는다). 같은 글을 다시 쓸 때는 올린 것을 다시 쓴다. - * 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다) + * 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중 · 차단을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다). + * 차단한 주소가 있으면 스팸메일함(id · 이름)을 엔진의 메일함 목록에서 찾아 넣는다 — 없으면 MAIL_001 */ private async write( user: string, - change: (current: FilterScriptSource) => FilterScriptSource, + change: ( + current: FilterScriptSource, + isActive: boolean, + ) => FilterScriptSource | null, ): Promise { let uploaded: { readonly text: string; readonly blobId: string } | null = null; + let junkMailbox: JunkMailboxTarget | null = null; for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) { const current = await this.snapshot(user); if (current.newerFormat !== null) { @@ -314,7 +390,7 @@ export class JmapFilterScriptStore { context: { reason: 'filter-source-newer', format: current.newerFormat, - supportedFormat: FILTER_SOURCE_FORMAT, + supportedFormat: LATEST_FILTER_SOURCE_FORMAT, }, }); } @@ -324,8 +400,14 @@ export class JmapFilterScriptStore { context: { reason: 'filter-script-state-unknown' }, }); } - const next = change(current.source); - const script = buildFilterScript(next); + const next = change(current.source, current.isActive); + if (next === null) { + return { source: current.source, isActive: current.isActive }; + } + if (next.blocked.length > 0 && junkMailbox === null) { + junkMailbox = await this.junkMailbox(user); + } + const script = buildFilterScript(next, { junkMailbox }); if ( script.bytes > FILTER_SCRIPT_MAX_BYTES || script.longestStringBytes > SIEVE_STRING_MAX_BYTES @@ -347,7 +429,7 @@ export class JmapFilterScriptStore { const saved = await this.save(user, current, blobId, script, next); if (saved !== null) { this.logger.debug( - `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, ${attempt}번째`, + `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`, ); return { source: next, isActive: saved.isActive }; } @@ -360,6 +442,36 @@ export class JmapFilterScriptStore { }); } + /** 차단한 메일이 들어갈 스팸메일함 — 엔진의 메일함 목록에서 역할이 junk 인 것. 없으면 MAIL_001 (차단을 쓸 곳이 없다) */ + private async junkMailbox(user: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'name', 'role'] }, + 'm', + ], + ]); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const junk = list.find( + (mailbox) => mailbox.role?.trim().toLowerCase() === 'junk', + ); + if (!junk) { + throw new BusinessException('MAIL_001', { + context: { reason: 'junk-mailbox-missing' }, + }); + } + // 이름은 id 의 메일함이 없을 때만 쓰는 대체다. 다른 프로그램(IMAP)이 바꾼 이름에 `${` 가 있으면 엔진이 변수로 펼친다 — 그때는 기본 이름 + const name = junk.name?.trim() ?? ''; + return { + id: junk.id, + name: name !== '' && !name.includes('${') ? name : 'Junk', + }; + } + private async upload(user: string, text: string): Promise { const { blobId } = await this.jmap.upload( user, diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index ea9c184..2fdaf23 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -6,7 +6,10 @@ import { join } from 'path'; import { Readable } from 'stream'; import { BusinessException } from '@/common/errors/business.exception'; -import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; +import { + MAX_BODY_VALUE_BYTES, + MAX_ENVELOPE_RESUBMITS, +} from '@/modules/mail/mail.constants'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { DraftContent, @@ -134,9 +137,12 @@ interface CallRecord { options: unknown; } -/** JmapClient 대역 — call() 은 넘겨받은 build 로 실제 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다 */ +/** + * JmapClient 대역 — call() 은 넘겨받은 build 로 실제 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다. + * 응답 자리에 Error 를 두면 그 호출은 기록한 뒤 그 오류로 끝난다 (jmap.client.ts 가 던지는 시간 초과 · HTTP 거절 등) + */ function fakeClient( - responses: JmapInvocation[][], + responses: (JmapInvocation[] | Error)[], overrides: { session?: JmapSession; settings?: MailSettings } = {}, ) { const recorded: CallRecord[] = []; @@ -155,6 +161,7 @@ function fakeClient( recorded.push({ calls: build(session.accountId), options }); const next = queue.shift(); if (!next) throw new Error('준비한 응답이 없습니다'); + if (next instanceof Error) return Promise.reject(next); return Promise.resolve(next); }, ), @@ -197,6 +204,23 @@ async function codeOf(promise: Promise): Promise { const argsOf = (record: CallRecord, index = 0) => record.calls[index][1]; +/** + * 붙잡힌 메일을 막는 확인(9단계 — 호출 id `hq` · `hg`)의 응답 — 기다리는 제출(list)을 준다. 없으면 붙잡힌 메일이 없다 + * (옮기기 · 지우기 경로가 같은 요청에 덧붙이거나 먼저 묻는다 — pending-send.ts 의 readHeldEmails) + */ +const heldGuard = (list: Record[] = []): JmapInvocation[] => [ + [ + 'EmailSubmission/query', + { ids: list.map((submission) => submission.id), total: list.length }, + 'hq', + ], + ['EmailSubmission/get', { list }, 'hg'], +]; + +/** 지금부터 ms 뒤의 UTCDate (붙잡은 제출의 보낼 시각) */ +const sendTimeIn = (ms: number) => + new Date(Date.now() + ms).toISOString().replace(/\.\d+Z$/, 'Z'); + describe('JmapMailProvider', () => { describe('프로필 (getProfile)', () => { it('대리 접근 세션의 계정 주소와 발신 ID 의 이름을 쓴다', async () => { @@ -473,6 +497,7 @@ describe('JmapMailProvider', () => { isFlagged: true, hasAttachment: true, size: 1154, + pendingSend: null, }); }); @@ -727,9 +752,12 @@ describe('JmapMailProvider', () => { ids: null, properties: ['id', 'role'], }); + // 같은 요청 끝에 붙잡은 발송(보낼 시각이 지금 뒤인 제출) — 9단계 expect(recorded[1].calls.map(([method]) => method)).toEqual([ 'Email/query', 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', ]); expect(argsOf(recorded[1])).toMatchObject({ filter: { @@ -893,7 +921,20 @@ describe('JmapMailProvider', () => { // 발송 때 정화한 서식 본문을 막는 바이트 수와 같은 상수 — 우리가 보낸 메일은 다시 읽어도 잘리지 않는다 maxBodyValueBytes: MAX_BODY_VALUE_BYTES, }); - expect(recorded[0].calls.map(([name]) => name)).toEqual(['Email/get']); + // 같은 요청에 메일함 역할(스팸메일함에 든 메일인지 — 외부 이미지) · 그 메일의 붙잡은 발송 (9단계) + expect(recorded[0].calls.map(([name]) => name)).toEqual([ + 'Email/get', + 'Mailbox/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + expect(recorded[0].calls[2][1]).toEqual({ + accountId: 'd', + filter: { emailIds: ['m1'] }, + sort: [{ property: 'sentAt', isAscending: true }], + limit: 500, + calculateTotal: true, + }); expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); // 겹치는 속성 이름 없이 (attachments 는 요약·상세 둘 다에 있다) const properties = (args as { properties: string[] }).properties; @@ -1058,8 +1099,9 @@ describe('JmapMailProvider', () => { }); describe('읽음·중요·이동 (updateMessage)', () => { - it('키워드와 메일함을 JMAP 패치로 바꾼다', async () => { + it('키워드와 메일함을 JMAP 패치로 바꾼다 — 옮기기는 먼저 그 메일이 붙잡힌 메일인지 묻는다(메일 id 조건, 9단계)', async () => { const { provider, recorded } = fakeClient([ + heldGuard(), [ ['Email/set', { updated: { m1: null } }, 'u'], [ @@ -1082,7 +1124,21 @@ describe('JmapMailProvider', () => { mailboxId: 'b', }); - expect(argsOf(recorded[0])).toEqual({ + expect(recorded[0].calls.map(([method, , id]) => [method, id])).toEqual([ + ['EmailSubmission/query', 'hq'], + ['EmailSubmission/get', 'hg'], + ]); + // 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id (9단계 리뷰 2), 다 받았는지는 total 로 + expect(argsOf(recorded[0])).toMatchObject({ + filter: { + emailIds: ['m1'], + after: expect.stringMatching(/Z$/) as unknown, + }, + calculateTotal: true, + }); + // 경로의 id 를 엔진이 해석하지 못하면 없는 메일과 같다 + expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + expect(argsOf(recorded[1])).toEqual({ accountId: 'd', update: { m1: { @@ -1094,11 +1150,151 @@ describe('JmapMailProvider', () => { }); expect(summary.mailboxIds).toEqual(['b']); // 바꾼 뒤의 요약도 목록과 같은 방법으로 첨부 표시를 정한다 - expect(argsOf(recorded[0], 1)).toMatchObject({ + expect(argsOf(recorded[1], 1)).toMatchObject({ bodyProperties: ['type', 'disposition', 'cid', 'size'], }); }); + it.each<[string, Record]>([ + ['옮기기', { mailboxId: 'b' }], + ['보낸편지함 그대로여도', { mailboxId: 'e' }], + ['스팸 신고', { isJunk: true }], + ['스팸 아님', { isJunk: false }], + ])( + '붙잡힌 메일(9단계)의 %s 는 409 MAIL_015 — 아무것도 바꾸지 않는다', + async (_label, update) => { + const { provider, recorded } = fakeClient([ + heldGuard([ + { + id: 'sb1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]), + ]); + + expect(await codeOf(provider.updateMessage(ARA, 'm1', update))).toBe( + 'MAIL_015', + ); + expect(recorded).toHaveLength(1); + }, + ); + + it('읽음 · 중요 표시만이면 붙잡힌 메일인지 묻지 않는다 (붙잡힌 메일도 표시는 된다)', async () => { + const { provider, recorded } = fakeClient([ + [ + ['Email/set', { updated: { m1: null } }, 'u'], + [ + 'Email/get', + { list: [summaryEmail('m1', '2026-10-26T05:12:00Z')] }, + 'g', + ], + ['EmailSubmission/query', { ids: [] }, 'pq'], + ['EmailSubmission/get', { list: [] }, 'pg'], + ], + ]); + + await provider.updateMessage(ARA, 'm1', { + isFlagged: true, + isRead: true, + }); + expect(recorded).toHaveLength(1); + expect(recorded[0].calls[0][0]).toBe('Email/set'); + }); + + it('보낼 시각이 지난 pending(배달 중) · final · canceled 제출은 붙잡힌 것이 아니다 — 옮긴다', async () => { + const { provider, recorded } = fakeClient([ + heldGuard([ + { + id: 'a', + emailId: 'm1', + undoStatus: 'pending', + sendAt: sendTimeIn(-5_000), + }, + { + id: 'b', + emailId: 'm1', + undoStatus: 'final', + sendAt: sendTimeIn(60_000), + }, + { + id: 'c', + emailId: 'm1', + undoStatus: 'canceled', + sendAt: sendTimeIn(60_000), + }, + ]), + [ + ['Email/set', { updated: { m1: null } }, 'u'], + [ + 'Email/get', + { + list: [ + summaryEmail('m1', '2026-10-26T05:12:00Z', { + mailboxIds: { b: true }, + }), + ], + }, + 'g', + ], + ], + ]); + + await provider.updateMessage(ARA, 'm1', { mailboxId: 'b' }); + expect(recorded).toHaveLength(2); + }); + + it('붙잡힌 메일인지 읽지 못하면 아무것도 바꾸지 않고 MAIL_001 (fail closed)', async () => { + const { provider, recorded } = fakeClient([ + [ + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ]); + + expect( + await codeOf(provider.updateMessage(ARA, 'm1', { mailboxId: 'b' })), + ).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + }); + + it('pending 인데 보낼 시각을 읽지 못한 제출은 붙잡힌 것으로 — 409 MAIL_015, 옮기지 않는다 (9단계 리뷰 2 — 닫힌 쪽)', async () => { + for (const sendAt of [null, '모름']) { + const { provider, recorded } = fakeClient([ + heldGuard([ + { id: 'sb1', emailId: 'm1', undoStatus: 'pending', sendAt }, + ]), + ]); + + expect( + await codeOf(provider.updateMessage(ARA, 'm1', { mailboxId: 'b' })), + ).toBe('MAIL_015'); + expect(recorded).toHaveLength(1); + } + }); + + it('다 받았는지 응답으로 모르면(total 이 없음 · 줄인 limit 에 닿음) 아무것도 바꾸지 않고 MAIL_001 (9단계 리뷰 2 — 닫힌 쪽)', async () => { + for (const answer of [{ ids: [] }, { ids: ['sb1'], limit: 1 }]) { + const { provider, recorded } = fakeClient([ + [ + ['EmailSubmission/query', answer, 'hq'], + ['EmailSubmission/get', { list: [] }, 'hg'], + ], + ]); + + const error = await provider + .updateMessage(ARA, 'm1', { mailboxId: 'b' }) + .catch((caught: unknown) => caught as BusinessException); + expect((error as BusinessException).code).toBe('MAIL_001'); + expect((error as BusinessException).context).toEqual({ + reason: 'held-submissions-over-limit', + candidates: 1, + }); + expect(recorded).toHaveLength(1); + } + }); + it('없는 메일은 RES_001, 잘못된 값은 VAL_001 이다', async () => { const notFound = fakeClient([ [ @@ -1113,6 +1309,7 @@ describe('JmapMailProvider', () => { ).toBe('RES_001'); const invalid = fakeClient([ + heldGuard(), [ [ 'Email/set', @@ -1139,10 +1336,14 @@ describe('JmapMailProvider', () => { }); describe('삭제 (deleteMessage)', () => { - /** 메일 + 메일함 확인 — 그때의 Email 상태 's1' */ - const lookup = (mailboxIds: Record): JmapInvocation[] => [ + /** 메일 + 메일함 확인 — 그때의 Email 상태 's1', 같은 요청에 그 메일의 제출(붙잡힌 메일 막기 — 9단계) */ + const lookup = ( + mailboxIds: Record, + submissions: Record[] = [], + ): JmapInvocation[] => [ ['Email/get', { state: 's1', list: [{ id: 'm1', mailboxIds }] }, 'g'], ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ...heldGuard(submissions), ]; /** 지우기 전에 다시 확인한 메일 — 그때의 상태 */ const recheck = ( @@ -1162,7 +1363,7 @@ describe('JmapMailProvider', () => { ['Email/set', { destroyed: ['m1'] }, 'd'], ]; - it('휴지통이 아니면 휴지통으로 옮긴다', async () => { + it('휴지통이 아니면 휴지통으로 옮긴다 — 확인 요청에 그 메일의 제출을 함께 묻는다(메일 id 조건)', async () => { const { provider, recorded } = fakeClient([ lookup({ a: true }), [['Email/set', { updated: { m1: null } }, 'd']], @@ -1170,12 +1371,94 @@ describe('JmapMailProvider', () => { await provider.deleteMessage(ARA, 'm1'); + expect(recorded[0].calls.map(([method]) => method)).toEqual([ + 'Email/get', + 'Mailbox/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + // 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id (9단계 리뷰 2) + expect(argsOf(recorded[0], 2)).toMatchObject({ + filter: { + emailIds: ['m1'], + after: expect.stringMatching(/Z$/) as unknown, + }, + calculateTotal: true, + }); expect(argsOf(recorded[1])).toEqual({ accountId: 'd', update: { m1: { mailboxIds: { b: true } } }, }); }); + it('붙잡힌 메일(9단계)은 409 MAIL_015 — 옮기지도 지우지도 않는다 (휴지통에 있어도)', async () => { + const places: Record[] = [{ e: true }, { b: true }]; + for (const mailboxIds of places) { + const { provider, recorded } = fakeClient([ + lookup(mailboxIds, [ + { + id: 'sb1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: sendTimeIn(600_000), + }, + ]), + ]); + + expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe( + 'MAIL_015', + ); + expect(recorded).toHaveLength(1); + } + }); + + it('제출을 읽지 못하면 아무것도 하지 않고 MAIL_001 (fail closed)', async () => { + const { provider, recorded } = fakeClient([ + [ + [ + 'Email/get', + { state: 's1', list: [{ id: 'm1', mailboxIds: { a: true } }] }, + 'g', + ], + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ]); + + expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + }); + + it('보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것(MAIL_015), 다 받았는지 모르면(total 이 없음) MAIL_001 — 아무것도 하지 않는다 (9단계 리뷰 2)', async () => { + const unreadable = fakeClient([ + lookup({ e: true }, [ + { id: 'sb1', emailId: 'm1', undoStatus: 'pending', sendAt: null }, + ]), + ]); + expect(await codeOf(unreadable.provider.deleteMessage(ARA, 'm1'))).toBe( + 'MAIL_015', + ); + expect(unreadable.recorded).toHaveLength(1); + + const unknown = fakeClient([ + [ + [ + 'Email/get', + { state: 's1', list: [{ id: 'm1', mailboxIds: { e: true } }] }, + 'g', + ], + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['EmailSubmission/query', { ids: [] }, 'hq'], + ['EmailSubmission/get', { list: [] }, 'hg'], + ], + ]); + expect(await codeOf(unknown.provider.deleteMessage(ARA, 'm1'))).toBe( + 'MAIL_001', + ); + expect(unknown.recorded).toHaveLength(1); + }); + it('휴지통에만 있으면 확인한 상태 그대로일 때만 영구 삭제한다 (ifInState)', async () => { const { provider, recorded } = fakeClient([ lookup({ b: true }), @@ -1293,6 +1576,7 @@ describe('JmapMailProvider', () => { [ ['Email/get', { list: [], notFound: ['nope'] }, 'g'], ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ...heldGuard(), ], ]); @@ -1826,8 +2110,9 @@ describe('JmapMailProvider', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - it('이동은 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => { + it('이동은 먼저 계정의 기다리는 제출을 묻고(9단계), 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => { const { provider, recorded } = fakeClient([ + heldGuard(), [ ['Mailbox/get', { list: [{ id: 'b' }], notFound: [] }, 'mb'], ['Email/set', { updated: { m1: null, m2: null } }, 'u'], @@ -1838,7 +2123,16 @@ describe('JmapMailProvider', () => { mailboxId: 'b', }); - expect(recorded[0].calls).toEqual([ + // 보낼 시각이 지금 뒤인 제출(엔진의 색인) — 계정 전체를 한 번에 + expect(recorded[0].calls.map(([method, , id]) => [method, id])).toEqual([ + ['EmailSubmission/query', 'hq'], + ['EmailSubmission/get', 'hg'], + ]); + expect(argsOf(recorded[0])).toMatchObject({ + filter: { after: expect.stringMatching(/Z$/) as unknown }, + sort: [{ property: 'sentAt', isAscending: true }], + }); + expect(recorded[1].calls).toEqual([ [ 'Mailbox/get', { accountId: 'd', ids: ['b'], properties: ['id'] }, @@ -1859,8 +2153,176 @@ describe('JmapMailProvider', () => { expect(result).toEqual({ updatedIds: ['m1', 'm2'], failedIds: [] }); }); + it('붙잡힌 메일(9단계)은 옮기지도 스팸으로 바꾸지도 않고 failedIds — 나머지는 그대로 바꾼다(요청 순서), 개수만 경고', async () => { + for (const update of [ + { mailboxId: 'b' }, + { isJunk: true, isRead: true }, + ]) { + const { provider, recorded } = fakeClient([ + heldGuard([ + { + id: 'sb1', + emailId: 'm2', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + { + id: 'sb2', + emailId: 'm9', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]), + [ + ...(update.mailboxId !== undefined + ? ([ + ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'], + ] as JmapInvocation[]) + : []), + ['Email/set', { updated: { m1: null, m3: null } }, 'u'], + ], + ]); + + await expect( + provider.updateMessages(ARA, ['m1', 'm2', 'm3'], update), + ).resolves.toEqual({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] }); + const set = recorded[1].calls.find( + ([method]) => method === 'Email/set', + ); + expect(Object.keys((set?.[1] as { update: object }).update)).toEqual([ + 'm1', + 'm3', + ]); + } + const lines = warnSpy.mock.calls.map(([line]) => String(line)); + expect(lines.every((line) => line.startsWith('[MAIL_015]'))).toBe(true); + expect(lines[0]).toContain('1통'); + }); + + it('붙잡힌 메일뿐이면 엔진에 바꾸기를 보내지 않는다 — 모두 failedIds (옮길 메일함은 지금처럼 확인한다)', async () => { + const held = [ + { + id: 'sb1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]; + const junk = fakeClient([heldGuard(held)]); + await expect( + junk.provider.updateMessages(ARA, ['m1'], { isJunk: true }), + ).resolves.toEqual({ updatedIds: [], failedIds: ['m1'] }); + expect(junk.recorded).toHaveLength(1); + + const move = fakeClient([ + heldGuard(held), + [['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb']], + ]); + expect( + await codeOf( + move.provider.updateMessages(ARA, ['m1'], { mailboxId: 'zzzz' }), + ), + ).toBe('RES_001'); + expect(move.recorded[1].calls.map(([method]) => method)).toEqual([ + 'Mailbox/get', + ]); + }); + + it('계정의 기다리는 제출이 상한(500)에 닿으면 요청한 id 로 다시 묻는다 — 그것도 읽지 못하면 아무것도 바꾸지 않고 MAIL_001', async () => { + const full: JmapInvocation[] = [ + [ + 'EmailSubmission/query', + { ids: Array.from({ length: 500 }, (_, i) => `s${i}`), total: 1200 }, + 'hq', + ], + ['EmailSubmission/get', { list: [] }, 'hg'], + ]; + const { provider, recorded } = fakeClient([ + full, + heldGuard([ + { + id: 'sb9', + emailId: 'm2', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]), + [ + ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'], + ['Email/set', { updated: { m1: null } }, 'u'], + ], + ]); + + await expect( + provider.updateMessages(ARA, ['m1', 'm2'], { mailboxId: 'b' }), + ).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] }); + // 다시 묻기도 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건 — 그 메일들의 지난 제출이 상한을 채우지 않게 (9단계 리뷰 2) + expect(argsOf(recorded[1])).toMatchObject({ + filter: { + emailIds: ['m1', 'm2'], + after: (argsOf(recorded[0]).filter as { after: string }).after, + }, + }); + + const broken = fakeClient([ + [ + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ]); + expect( + await codeOf( + broken.provider.updateMessages(ARA, ['m1'], { mailboxId: 'b' }), + ), + ).toBe('MAIL_001'); + expect(broken.recorded).toHaveLength(1); + }); + + it('엔진이 limit 을 줄여(500 보다 작게) 다 주지 않았으면 다 본 것으로 보지 않는다 — 요청한 id 로 다시 묻는다 (9단계 리뷰 2)', async () => { + // 엔진의 쿼리 최대가 250 — 계정의 기다리는 제출 600개 가운데 앞의 250개만 왔다(m2 의 것은 그 뒤) + const reduced: JmapInvocation[] = [ + [ + 'EmailSubmission/query', + { + ids: Array.from({ length: 250 }, (_, i) => `s${i}`), + position: 0, + limit: 250, + total: 600, + }, + 'hq', + ], + ['EmailSubmission/get', { list: [] }, 'hg'], + ]; + const { provider, recorded } = fakeClient([ + reduced, + heldGuard([ + { + id: 'sb9', + emailId: 'm2', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]), + [ + ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'], + ['Email/set', { updated: { m1: null } }, 'u'], + ], + ]); + const warn = jest + .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn') + .mockImplementation(() => undefined); + + await expect( + provider.updateMessages(ARA, ['m1', 'm2'], { mailboxId: 'b' }), + ).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] }); + expect(argsOf(recorded[2], 1)).toMatchObject({ update: { m1: {} } }); + expect(argsOf(recorded[2], 1).update).not.toHaveProperty('m2'); + expect(warn).toHaveBeenCalled(); + }); + it('옮길 메일함이 없으면 404 RES_001 — 엔진이 메일마다 이동을 거절해 아무것도 바뀌지 않았다', async () => { const { provider, recorded } = fakeClient([ + heldGuard(), [ ['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'], [ @@ -1885,18 +2347,19 @@ describe('JmapMailProvider', () => { ), ).toBe('RES_001'); // 읽음 표시도 같은 패치라 메일마다 통째로 거절된다 — 이동만 빠지고 읽음만 바뀌는 일은 없다 - const set = argsOf(recorded[0], 1) as { + const set = argsOf(recorded[1], 1) as { update: Record>; }; expect(set.update.m1).toEqual({ 'keywords/$seen': true, mailboxIds: { zzzz: true }, }); - expect(recorded).toHaveLength(1); + expect(recorded).toHaveLength(2); }); it('없는 메일함인데 엔진이 바꿨다고 답하면(있을 수 없는 경우) 사실대로 돌려주고 경고한다', async () => { const { provider } = fakeClient([ + heldGuard(), [ ['Mailbox/get', { list: [], notFound: ['k'] }, 'mb'], ['Email/set', { updated: { m1: null } }, 'u'], @@ -1980,11 +2443,12 @@ describe('JmapMailProvider', () => { /** 확인 요청이 돌려주는 메일 (id · 들어 있는 메일함) */ type TrashCandidate = { id: string; mailboxIds: Record }; - /** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다) */ + /** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다), 같은 요청의 계정 기다리는 제출(9단계) */ const lookup = ( emails: TrashCandidate[], state: string | null = 's1', mailboxes: unknown[] = stalwartMailboxes, + held: Record[] = [], ): JmapInvocation[] => [ [ 'Email/get', @@ -1992,6 +2456,7 @@ describe('JmapMailProvider', () => { 'g', ], ['Mailbox/get', { list: mailboxes }, 'm'], + ...heldGuard(held), ]; const mismatch: JmapInvocation[] = [ ['error', { type: 'stateMismatch' }, 'd'], @@ -2016,7 +2481,7 @@ describe('JmapMailProvider', () => { 'i1', ]); - expect(recorded[0].calls).toEqual([ + expect(recorded[0].calls.slice(0, 2)).toEqual([ [ 'Email/get', { @@ -2032,6 +2497,16 @@ describe('JmapMailProvider', () => { 'm', ], ]); + // 같은 요청 끝에 계정의 기다리는 제출(보낼 시각이 지금 뒤) — 붙잡힌 메일은 지우지 않는다 (9단계) + expect( + recorded[0].calls.slice(2).map(([method, , id]) => [method, id]), + ).toEqual([ + ['EmailSubmission/query', 'hq'], + ['EmailSubmission/get', 'hg'], + ]); + expect(argsOf(recorded[0], 2)).toMatchObject({ + filter: { after: expect.stringMatching(/Z$/) as unknown }, + }); expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' }); expect(argsOf(recorded[1])).toEqual({ accountId: 'd', @@ -2045,6 +2520,58 @@ describe('JmapMailProvider', () => { }); }); + it('붙잡힌 메일(9단계 — 다른 프로그램이 휴지통으로 옮겨 둔 것)은 지우지 않고 건너뛴다 — 개수만 경고', async () => { + const { provider, recorded } = fakeClient([ + lookup(mixed, 's1', stalwartMailboxes, [ + { + id: 'sb1', + emailId: 't1', + undoStatus: 'pending', + sendAt: sendTimeIn(60_000), + }, + ]), + [['Email/set', { updated: { t2: null } }, 'd']], + ]); + + await expect( + provider.deleteFromTrash(ARA, ['t1', 't2']), + ).resolves.toEqual({ + deletedIds: ['t2'], + skippedIds: ['t1'], + }); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { t2: { 'mailboxIds/b': null } }, + }); + const [line] = warnSpy.mock.calls[0] as [string]; + expect(line).toMatch(/^\[MAIL_015\].*1통/); + }); + + it('휴지통에 든 메일이 있는데 기다리는 제출을 읽지 못하면 아무것도 지우지 않고 MAIL_001 — 든 것이 없으면 그대로 건너뛴다', async () => { + const broken = (emails: TrashCandidate[]): JmapInvocation[] => [ + ['Email/get', { state: 's1', list: emails, notFound: [] }, 'g'], + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ]; + const failing = fakeClient([broken(mixed)]); + expect(await codeOf(failing.provider.deleteFromTrash(ARA, ['t1']))).toBe( + 'MAIL_001', + ); + expect(failing.recorded).toHaveLength(1); + + const outside = fakeClient([ + broken([{ id: 'i1', mailboxIds: { a: true } }]), + ]); + await expect( + outside.provider.deleteFromTrash(ARA, ['i1']), + ).resolves.toEqual({ + deletedIds: [], + skippedIds: ['i1'], + }); + }); + it('휴지통 밖의 메일은 옮기지 않는다 — 지울 것이 없으면 지우는 요청 없이 모두 건너뛴다', async () => { const { provider, recorded } = fakeClient([ lookup([{ id: 'i1', mailboxIds: { a: true } }]), @@ -2287,6 +2814,7 @@ describe('JmapMailProvider', () => { await expect(provider.sendMessage(ARA, outgoing)).resolves.toEqual({ id: 'maaaaad', rejectedRecipients: [], + pendingSend: null, }); const draft = (argsOf(recorded[2], 0).create as Record) @@ -2312,9 +2840,22 @@ describe('JmapMailProvider', () => { }, ], }); + // envelope 은 늘 직접 준다(9단계 리뷰 뒤) — 받는 사람 · 참조 · 숨은참조, 붙잡지 않으면 MAIL FROM 매개변수 없음 expect(argsOf(recorded[2], 1)).toEqual({ accountId: 'd', - create: { send: { identityId: 'b', emailId: '#draft' } }, + create: { + send: { + identityId: 'b', + emailId: '#draft', + envelope: { + mailFrom: { email: ARA, parameters: null }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }, + }, onSuccessUpdateEmail: { '#send': { 'mailboxIds/d': null, @@ -2651,16 +3192,22 @@ describe('JmapMailProvider', () => { await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({ id: 'maaaaad', rejectedRecipients: [], + pendingSend: null, }); expect(recorded[1].calls.map(([name]) => name)).toEqual([ 'Email/set', 'EmailSubmission/set', ]); + // 결과와 함께 제출의 지금 상태(모두 거절된 붙잡은 발송을 취소할지 — 9단계 리뷰 뒤) expect(recorded[2].calls).toEqual([ [ 'EmailSubmission/get', - { accountId: 'd', ids: ['b'], properties: ['deliveryStatus'] }, + { + accountId: 'd', + ids: ['b'], + properties: ['deliveryStatus', 'undoStatus'], + }, 'd', ], ]); @@ -2679,6 +3226,7 @@ describe('JmapMailProvider', () => { await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({ id: 'maaaaad', rejectedRecipients: [{ email: DOYUN, reason: 'not-found' }], + pendingSend: null, }); // 지우지 않았다 @@ -2734,6 +3282,7 @@ describe('JmapMailProvider', () => { { email: 'd@example.com', reason: 'temporary' }, { email: 'G.Kim@AraSystem.Test', reason: 'not-found' }, ], + pendingSend: null, }); }); @@ -2857,6 +3406,7 @@ describe('JmapMailProvider', () => { await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({ id: 'maaaaad', rejectedRecipients: [], + pendingSend: null, }); expect(recorded).toHaveLength(calls); expect(logged(warn)).toContain( @@ -2887,11 +3437,575 @@ describe('JmapMailProvider', () => { const warn = spyLogger(fake.provider, 'warn'); await expect(fake.provider.sendMessage(ARA, textOnly)).resolves.toEqual( - { id: 'maaaaad', rejectedRecipients: [] }, + { id: 'maaaaad', rejectedRecipients: [], pendingSend: null }, ); expect(fake.recorded).toHaveLength(2); expect(logged(warn)).toContain('reason=no-time'); }); + + describe('엔진이 envelope 의 주소를 받지 않음 (9단계 리뷰 뒤 — Stalwart 0.16.27: 첫 주소 하나만 설명에 적고 제출 전체를 거절)', () => { + const TYPO = 'typo@example.cmo'; + const TYPO2 = 'Second.Typo@Example.CMO'; + /** 엔진이 개발 환경에서 돌려준 설명 그대로 (2026-10-29) */ + const refusal = (address: string): JmapInvocation => [ + 'EmailSubmission/set', + { + notCreated: { + send: { + type: 'invalidProperties', + properties: ['envelope'], + description: `Invalid e-mail address "${address}".`, + }, + }, + }, + 's', + ]; + /** 처음 요청 — 초안은 만들어졌고 제출은 거절 */ + const firstRefused = (address: string): JmapInvocation[] => [ + sent[0], + refusal(address), + ]; + /** 다시 낸 제출 — 성공(보낸편지함으로 옮김) */ + const resubmitted: JmapInvocation[] = [sent[1], sent[2]]; + const withTypos: OutgoingMessage = { + ...textOnly, + to: [{ name: '이도윤', email: DOYUN }], + cc: [{ name: null, email: TYPO }], + bcc: [ + { name: null, email: SEOJUN }, + { name: null, email: TYPO2 }, + ], + }; + /** 그 요청의 제출(EmailSubmission/set create) — 처음 요청은 두 번째 호출, 다시 낸 요청은 첫 호출 */ + type SubmittedSend = { + emailId: string; + envelope: { rcptTo: { email: string }[] }; + }; + const envelopeOf = (record: CallRecord): SubmittedSend => { + const args = argsOf(record, record.calls.length === 1 ? 0 : 1); + return (args.create as Record).send; + }; + + it('설명에 적힌 주소를 받지 않은 사람(not-found)으로 빼고 같은 메일을 나머지에게 다시 낸다 — 한 번에 하나씩, 요청의 차례로 싣는다', async () => { + const { provider, recorded } = fakeClient([ + prepared, + firstRefused(TYPO), + [refusal(TYPO2.toLowerCase())], + resubmitted, + deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }), + ]); + const log = spyLogger(provider, 'log'); + + await expect(provider.sendMessage(ARA, withTypos)).resolves.toEqual({ + id: 'maaaaad', + rejectedRecipients: [ + { email: TYPO, reason: 'not-found' }, + { email: TYPO2, reason: 'not-found' }, + ], + pendingSend: null, + }); + + expect( + envelopeOf(recorded[1]).envelope.rcptTo.map(({ email }) => email), + ).toEqual([DOYUN, TYPO, SEOJUN, TYPO2]); + // 다시 낼 때는 만든 메일 id 로 — 같은 메일, 성공하면 보낸편지함으로 옮기는 것도 같다 + expect(recorded[2].calls.map(([method]) => method)).toEqual([ + 'EmailSubmission/set', + ]); + expect(envelopeOf(recorded[2])).toMatchObject({ emailId: 'maaaaad' }); + expect( + envelopeOf(recorded[2]).envelope.rcptTo.map(({ email }) => email), + ).toEqual([DOYUN, SEOJUN, TYPO2]); + expect( + envelopeOf(recorded[3]).envelope.rcptTo.map(({ email }) => email), + ).toEqual([DOYUN, SEOJUN]); + expect(argsOf(recorded[3])).toMatchObject({ + onSuccessUpdateEmail: { '#send': { 'mailboxIds/e': true } }, + }); + expect(recorded).toHaveLength(5); + // 설명 글은 남기지 않는다 — 가린 주소만 + const lines = logged(log); + expect(lines).toContain('t***@example.cmo'); + expect(lines).not.toContain('Invalid e-mail address'); + expect(lines).not.toContain('typo@example.cmo'); + }); + + it('붙잡는 발송도 같은 붙잡기로 다시 낸다 (HOLDFOR 그대로)', async () => { + const { provider, recorded } = fakeClient([ + prepared, + firstRefused(TYPO), + [ + [ + 'EmailSubmission/set', + { + created: { + send: { + id: 'b', + undoStatus: 'pending', + sendAt: '2030-01-01T00:00:10Z', + }, + }, + }, + 's', + ], + sent[2], + ], + deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }), + ]); + spyLogger(provider, 'log'); + + await expect( + provider.sendMessage(ARA, { + ...withTypos, + bcc: [{ name: null, email: SEOJUN }], + hold: { kind: 'undo', seconds: 10 }, + }), + ).resolves.toMatchObject({ + rejectedRecipients: [{ email: TYPO, reason: 'not-found' }], + pendingSend: { kind: 'undo', sendAt: '2030-01-01T00:00:10.000Z' }, + }); + expect( + ( + argsOf(recorded[2]).create as Record< + string, + { envelope: { mailFrom: unknown } } + > + ).send.envelope.mailFrom, + ).toEqual({ email: ARA, parameters: { HOLDFOR: '10' } }); + }); + + it('남은 사람이 없으면 보내지 않은 것이다 — 만든 메일을 조건 없이 지우고 400 MAIL_010 (제출은 하나도 없다)', async () => { + const { provider, recorded } = fakeClient([ + prepared, + firstRefused(TYPO), + [refusal(TYPO2)], + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + const warn = spyLogger(provider, 'warn'); + spyLogger(provider, 'log'); + + const error = await provider + .sendMessage(ARA, { + ...textOnly, + to: [{ name: null, email: TYPO }], + bcc: [{ name: null, email: TYPO2 }], + }) + .catch((caught: unknown) => caught as BusinessException); + + expect((error as BusinessException).code).toBe('MAIL_010'); + expect((error as BusinessException).context).toEqual({ + reason: 'all-recipients-refused-in-envelope', + rejected: 2, + copyDiscarded: true, + }); + expect(recorded[3].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + expect(recorded).toHaveLength(4); + expect(logged(warn)).toContain('[MAIL_010]'); + expect(logged(warn)).toContain('(엔진이 주소를 받지 않음)'); + expect(logged(warn)).not.toContain('Invalid e-mail address'); + }); + + it('설명에 우리 주소가 없으면 지금처럼 — 만든 메일을 지우고 오류 표대로(VAL_001), 받는 사람을 함부로 빼지 않는다', async () => { + const { provider, recorded } = fakeClient([ + prepared, + [ + sent[0], + [ + 'EmailSubmission/set', + { + notCreated: { + send: { + type: 'invalidProperties', + properties: ['envelope'], + description: + 'Invalid e-mail address "someone@else.example".', + }, + }, + }, + 's', + ], + ], + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + + expect(await codeOf(provider.sendMessage(ARA, withTypos))).toBe( + 'VAL_001', + ); + expect(recorded[2].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + expect(recorded).toHaveLength(3); + }); + + it('다시 낼 시간이 모자라면 만든 메일을 지우고 MAIL_001 — 아직 아무것도 제출하지 않았다', async () => { + const fake = fakeClient([ + prepared, + firstRefused(TYPO), + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + // 준비 20초, 처음 제출 30초 — 남은 5초는 다시 낼 최소 시간(10초)보다 적다 + elapseOnCalls(fake, [20_000, 30_000], { now: 1_000_000 }); + spyLogger(fake.provider, 'log'); + + const error = await fake.provider + .sendMessage(ARA, withTypos) + .catch((caught: unknown) => caught as BusinessException); + expect((error as BusinessException).code).toBe('MAIL_001'); + expect((error as BusinessException).context).toMatchObject({ + reason: 'send-budget-exhausted', + resubmits: 0, + }); + expect(fake.recorded[2].calls[0][0]).toBe('Email/set'); + // 지우기도 남은 시간(5초) 안에서 (9단계 리뷰 2) + expect(fake.recorded[2].options).toEqual({ timeoutMs: 5_000 }); + expect(fake.recorded).toHaveLength(3); + }); + + it('다시 낸 차례가 남은 시간을 거의 다 쓰고 거절되면 만든 메일은 남은 시간 안에서 지운다 — 모자라도 1초, 발송 응답이 56초를 넘지 않게 (9단계 리뷰 2)', async () => { + const fake = fakeClient([ + prepared, + firstRefused(TYPO), + [['error', { type: 'serverFail' }, 's']], + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + // 준비 5초, 처음 제출 10초, 다시 낸 제출 39.5초 — 남은 시간 0.5초 + elapseOnCalls(fake, [5_000, 10_000, 39_500], { now: 1_000_000 }); + spyLogger(fake.provider, 'warn'); + + expect(await codeOf(fake.provider.sendMessage(ARA, withTypos))).toBe( + 'MAIL_001', + ); + // 다시 낸 제출은 남은 시간(55 - 15 = 40초)까지 · 처음 제출과 같게 요청을 해석하지 못하면 VAL_001 + expect(fake.recorded[2].options).toEqual({ + invalidRequestCode: 'VAL_001', + timeoutMs: 40_000, + }); + expect(fake.recorded[3].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + expect(fake.recorded[3].options).toEqual({ timeoutMs: 1_000 }); + }); + + it('받지 않는 주소가 많아 다시 내기가 20차례를 넘으면 더 내지 않는다 — 만든 메일을 지우고 400 VAL_001 (주소는 개수만, 9단계 리뷰 2)', async () => { + // 계약의 숫자 — 처음 제출 + 다시 내기 20차례 + expect(MAX_ENVELOPE_RESUBMITS).toBe(20); + const typos = Array.from( + { length: 22 }, + (_, index) => `typo${index}@example.cmo`, + ); + const { provider, recorded } = fakeClient([ + prepared, + firstRefused(typos[0]), + ...typos.slice(1, 21).map((typo) => [refusal(typo)]), + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + const log = spyLogger(provider, 'log'); + const warn = spyLogger(provider, 'warn'); + + const error = await provider + .sendMessage(ARA, { + ...textOnly, + to: [{ name: '이도윤', email: DOYUN }], + cc: typos.map((email) => ({ name: null, email })), + bcc: [], + }) + .catch((caught: unknown) => caught as BusinessException); + + expect((error as BusinessException).code).toBe('VAL_001'); + // 처음 제출 하나 + 다시 낸 20차례에서 하나씩 뺐다 — 남은 사람은 도윤 · 마지막 오타 + expect((error as BusinessException).context).toEqual({ + reason: 'too-many-refused-addresses', + refused: 21, + resubmits: 20, + left: 2, + }); + const resubmitted = recorded.filter( + (record) => + record.calls.length === 1 && + record.calls[0][0] === 'EmailSubmission/set', + ); + expect(resubmitted).toHaveLength(20); + expect(recorded.at(-1)?.calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + expect(recorded).toHaveLength(23); + // 주소 원문은 로그에 없다 + expect(`${logged(log)}\n${logged(warn)}`).not.toContain( + '@example.cmo', + ); + }); + + it('다시 낸 차례들은 차례 수만 한 줄로 남긴다 — 뺀 주소(가린 것)는 받는 사람별 결과의 로그에 (9단계 리뷰 2)', async () => { + const { provider } = fakeClient([ + prepared, + firstRefused(TYPO), + [refusal(TYPO2.toLowerCase())], + resubmitted, + deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }), + ]); + const log = spyLogger(provider, 'log'); + + await provider.sendMessage(ARA, withTypos); + + const lines = log.mock.calls.map(([message]) => String(message)); + expect( + lines.filter((line) => line.includes('다시 제출했습니다')), + ).toEqual([ + expect.stringContaining( + '엔진이 envelope 의 주소 2개를 받지 않아 빼고 2차례 다시 제출했습니다', + ) as string, + ]); + expect(lines.join('\n')).not.toContain('typo@example.cmo'); + }); + + describe('차례마다 실패를 다루는 법은 처음 제출과 같다 — 남는 것이 같게 (9단계 리뷰 2)', () => { + type Failure = 'method-error' | 'request-refused' | 'timeout'; + /** 그 차례의 엔진 답 — 메서드 오류는 요청 안의 답, 나머지는 호출이 던진다(jmap.client.ts) */ + const answerFor = ( + failure: Failure, + round: 'first' | 'second', + ): JmapInvocation[] | Error => { + if (failure === 'method-error') { + const error: JmapInvocation = [ + 'error', + { type: 'serverFail' }, + 's', + ]; + return round === 'first' ? [sent[0], error] : [error]; + } + return failure === 'request-refused' + ? // 엔진이 요청 전체를 받지 않았다(HTTP 400 — 메서드를 하나도 돌리지 않는다) + new BusinessException('MAIL_001', { + context: { reason: 'http-error', call: 'api', status: 400 }, + }) + : new BusinessException('MAIL_001', { + context: { reason: 'timeout', call: 'api' }, + }); + }; + + /** + * 발송이 끝난 뒤 남는 것 — 지웠거나 엔진이 실행하지 않아 만든 것이 없으면 '남는 것 없음', + * 결과를 몰라 지우지 않았으면 '재시도에 맡김'(같은 키의 재시도가 지운다), 엔진이 거절한 것을 알면서 남겼으면 '숨은 초안' + */ + const outcome = async ( + failure: Failure, + round: 'first' | 'second', + ) => { + const destroyed: JmapInvocation[] = [ + ['Email/set', { destroyed: ['maaaaad'] }, 'x'], + ]; + const fake = fakeClient( + round === 'first' + ? [prepared, answerFor(failure, round), destroyed] + : [ + prepared, + firstRefused(TYPO), + answerFor(failure, round), + destroyed, + ], + ); + spyLogger(fake.provider, 'warn'); + spyLogger(fake.provider, 'log'); + + const code = await codeOf( + fake.provider.sendMessage(ARA, withTypos), + ); + const discarded = fake.recorded.some((record) => + record.calls.some( + ([method, args]) => + method === 'Email/set' && + Object.prototype.hasOwnProperty.call(args, 'destroy'), + ), + ); + // 처음 차례의 요청을 엔진이 실행하지 않았으면 초안도 만들어지지 않았다 + const created = !( + round === 'first' && failure === 'request-refused' + ); + const left = + discarded || !created + ? '남는 것 없음' + : failure === 'timeout' + ? '재시도에 맡김' + : '숨은 초안'; + return { code, left }; + }; + + it.each<[Failure, string]>([ + ['method-error', '남는 것 없음'], + ['request-refused', '남는 것 없음'], + ['timeout', '재시도에 맡김'], + ])( + '%s — 다시 낸 차례도 처음 차례와 같다: %s', + async (failure, left) => { + const first = await outcome(failure, 'first'); + const second = await outcome(failure, 'second'); + expect(second).toEqual(first); + expect(first).toEqual({ code: 'MAIL_001', left }); + }, + ); + }); + + it('결과를 읽지 못해도 envelope 에서 뺀 사람은 받지 않은 사람으로 싣는다', async () => { + const { provider } = fakeClient([ + prepared, + firstRefused(TYPO), + resubmitted, + [['error', { type: 'serverFail' }, 'd']], + ]); + spyLogger(provider, 'warn'); + spyLogger(provider, 'log'); + + await expect( + provider.sendMessage(ARA, { ...withTypos, bcc: [] }), + ).resolves.toEqual({ + id: 'maaaaad', + rejectedRecipients: [{ email: TYPO, reason: 'not-found' }], + pendingSend: null, + }); + }); + + it('envelope 에서 뺀 사람과 제출 때 거절된 사람을 합쳐 모두면 모두 거절 — 보낸 사본을 지우고 MAIL_010', async () => { + const { provider, recorded } = fakeClient([ + prepared, + firstRefused(TYPO), + resubmitted, + deliveryOf({ [DOYUN]: MAILBOX_MISSING }), + discarded, + ]); + spyLogger(provider, 'warn'); + spyLogger(provider, 'log'); + + expect( + await codeOf(provider.sendMessage(ARA, { ...withTypos, bcc: [] })), + ).toBe('MAIL_010'); + expect(recorded[4].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + }); + }); + + describe('모두 거절된 붙잡은 발송 (9단계 리뷰 뒤 — 풀리는 시각에 다시 보내지 않게)', () => { + const held: OutgoingMessage = { + ...textOnly, + hold: { kind: 'undo', seconds: 10 }, + }; + const heldSent: JmapInvocation[] = [ + sent[0], + [ + 'EmailSubmission/set', + { created: { send: { id: 'b', undoStatus: 'pending' } } }, + 's', + ], + sent[2], + ]; + const everyoneBusy = (undoStatus: string): JmapInvocation[] => [ + [ + 'EmailSubmission/get', + { + list: [ + { + id: 'b', + undoStatus, + deliveryStatus: { + [DOYUN]: RATE_LIMITED, + [SEOJUN]: RATE_LIMITED, + }, + }, + ], + }, + 'd', + ], + ]; + + it('제출이 아직 기다리고 있으면(pending) 같은 요청에서 먼저 취소하고 사본을 지운다', async () => { + const { provider, recorded } = fakeClient([ + prepared, + heldSent, + everyoneBusy('pending'), + [ + ['EmailSubmission/set', { updated: { b: null } }, 'c'], + ['Email/set', { destroyed: ['maaaaad'] }, 'x'], + ], + ]); + const warn = spyLogger(provider, 'warn'); + + expect(await codeOf(provider.sendMessage(ARA, held))).toBe( + 'MAIL_011', + ); + expect(recorded[3].calls).toEqual([ + [ + 'EmailSubmission/set', + { accountId: 'd', update: { b: { undoStatus: 'canceled' } } }, + 'c', + ], + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + expect(logged(warn)).not.toContain('취소하지 못했습니다'); + }); + + it('취소를 엔진이 받지 않아도 사본은 지운다 — 경고만 (best effort)', async () => { + const { provider, recorded } = fakeClient([ + prepared, + heldSent, + everyoneBusy('pending'), + [ + [ + 'EmailSubmission/set', + { notUpdated: { b: { type: 'invalidProperties' } } }, + 'c', + ], + ['Email/set', { destroyed: ['maaaaad'] }, 'x'], + ], + ]); + const warn = spyLogger(provider, 'warn'); + + const error = await provider + .sendMessage(ARA, held) + .catch((caught: unknown) => caught as BusinessException); + expect((error as BusinessException).code).toBe('MAIL_011'); + expect((error as BusinessException).context).toMatchObject({ + copyDiscarded: true, + }); + expect(recorded).toHaveLength(4); + expect(logged(warn)).toContain('붙잡은 발송을 취소하지 못했습니다'); + }); + + it('이미 final 이면(모두 5xx — 엔진이 붙잡아도 바로 끝낸다) 취소하지 않고 사본만 지운다', async () => { + const { provider, recorded } = fakeClient([ + prepared, + heldSent, + everyoneBusy('final'), + discarded, + ]); + spyLogger(provider, 'warn'); + + expect(await codeOf(provider.sendMessage(ARA, held))).toBe( + 'MAIL_011', + ); + expect(recorded[3].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + }); + + it('붙잡지 않은 발송은 취소하지 않는다 — 상태가 pending 이어도(대기열의 잠깐)', async () => { + const { provider, recorded } = fakeClient([ + prepared, + sent, + everyoneBusy('pending'), + discarded, + ]); + spyLogger(provider, 'warn'); + + expect(await codeOf(provider.sendMessage(ARA, textOnly))).toBe( + 'MAIL_011', + ); + expect(recorded[3].calls).toEqual([ + ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'], + ]); + }); + }); }); describe('서명 그림 (inlineImages — multipart/related)', () => { @@ -3111,6 +4225,21 @@ describe('JmapMailProvider', () => { ['Email/query', { ids: list.map((email) => email.id) }, 'q'], ['Email/get', { list }, 'g'], ]; + /** 찾은 메일의 제출 (9단계 — 보냈는지 · 붙잡고 있는지 · 취소했는지) */ + const submissions = (list: Record[]): JmapInvocation[] => [ + [ + 'EmailSubmission/query', + { ids: list.map((submission) => submission.id) }, + 'pq', + ], + ['EmailSubmission/get', { list }, 'pg'], + ]; + const RELEASED = { + id: 'sub1', + emailId: 'sent1', + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }; it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다 — 받는 사람별 결과는 읽지 않는다 (지문이 맞을 때 따로)', async () => { const { provider, recorded } = fakeClient([ @@ -3124,11 +4253,14 @@ describe('JmapMailProvider', () => { 'header:X-Ara-Payload-Hash:asText': ' v1:feedface ', }, ]), + submissions([RELEASED]), ]); await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ id: 'sent1', fingerprint: 'v1:feedface', + canceled: false, + pendingSend: null, }); expect(argsOf(recorded[1], 0)).toEqual({ accountId: 'd', @@ -3147,7 +4279,14 @@ describe('JmapMailProvider', () => { 'header:X-Ara-Payload-Hash:asText', ], }); - expect(recorded).toHaveLength(2); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + filter: { emailIds: ['sent1'] }, + sort: [{ property: 'sentAt', isAscending: true }], + limit: 500, + calculateTotal: true, + }); + expect(recorded).toHaveLength(3); }); it('없으면 null', async () => { @@ -3165,11 +4304,14 @@ describe('JmapMailProvider', () => { const { provider } = fakeClient([ mailboxes, recent([{ id: 'sent1', messageId: [MESSAGE_ID], keywords: {} }]), + submissions([RELEASED]), ]); await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ id: 'sent1', fingerprint: null, + canceled: false, + pendingSend: null, }); }); @@ -3183,7 +4325,7 @@ describe('JmapMailProvider', () => { keywords: { $draft: true }, }, ]), - [['EmailSubmission/query', { ids: [] }, 's']], + submissions([]), [['Email/set', { destroyed: ['draft1'] }, 'x']], ]); @@ -3193,6 +4335,9 @@ describe('JmapMailProvider', () => { expect(argsOf(recorded[2])).toEqual({ accountId: 'd', filter: { emailIds: ['draft1'] }, + sort: [{ property: 'sentAt', isAscending: true }], + limit: 500, + calculateTotal: true, }); expect(argsOf(recorded[3])).toEqual({ accountId: 'd', @@ -3211,15 +4356,90 @@ describe('JmapMailProvider', () => { 'header:X-Ara-Payload-Hash:asText': 'v1:feedface', }, ]), - [['EmailSubmission/query', { ids: ['sub1'] }, 's']], + submissions([{ ...RELEASED, emailId: 'draft1' }]), ]); await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ id: 'draft1', fingerprint: 'v1:feedface', + canceled: false, + pendingSend: null, }); expect(recorded).toHaveLength(3); }); + + it('취소한 발송(제출이 모두 canceled) — canceled 로 알리고 지우지 않는다 (임시보관함으로 되돌린 초안이다)', async () => { + const { provider, recorded } = fakeClient([ + mailboxes, + recent([ + { + id: 'sent1', + messageId: [MESSAGE_ID], + keywords: { $draft: true, '$ara-canceled': true }, + 'header:X-Ara-Payload-Hash:asText': 'v1:feedface', + }, + ]), + submissions([{ ...RELEASED, undoStatus: 'canceled' }]), + ]); + + await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ + id: 'sent1', + fingerprint: 'v1:feedface', + canceled: true, + pendingSend: null, + }); + expect(recorded).toHaveLength(3); + }); + + it('취소한 발송 표시가 있으면 제출 기록이 없어졌어도(엔진 정리 · 손으로 지움) 지우지 않고 canceled 다 — 같은 키는 MAIL_005', async () => { + const { provider, recorded } = fakeClient([ + mailboxes, + recent([ + { + id: 'sent1', + messageId: [MESSAGE_ID], + keywords: { $draft: true, '$ara-canceled': true }, + 'header:X-Ara-Payload-Hash:asText': 'v1:feedface', + }, + ]), + submissions([]), + ]); + + await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ + id: 'sent1', + fingerprint: 'v1:feedface', + canceled: true, + pendingSend: null, + }); + // 조건 없는 지우기를 보내지 않았다 + expect(recorded).toHaveLength(3); + }); + + it('붙잡고 있는 발송 — 그 보낼 시각 · 종류를 돌려준다(같은 키의 재시도가 처음 결과를 그대로 준다), 취소와 함께 있으면 보낸 것', async () => { + const later = new Date(Date.now() + 3_600_000).toISOString(); + const { provider } = fakeClient([ + mailboxes, + recent([ + { + id: 'sent1', + messageId: [MESSAGE_ID], + keywords: { $seen: true, '$ara-scheduled': true }, + 'header:X-Ara-Payload-Hash:asText': 'v1:feedface', + }, + ]), + submissions([ + { ...RELEASED, id: 'sub0', undoStatus: 'canceled' }, + { ...RELEASED, undoStatus: 'pending', sendAt: later }, + ]), + ]); + + await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({ + id: 'sent1', + fingerprint: 'v1:feedface', + canceled: false, + pendingSend: { kind: 'scheduled', sendAt: later }, + }); + }); }); /** 계약 2절 "받는 사람별 결과" — 같은 키의 재시도가 처음 결과를 돌려줄 때 */ @@ -3271,13 +4491,46 @@ describe('JmapMailProvider', () => { name: 'EmailSubmission/query', path: '/ids', }, - properties: ['deliveryStatus'], + properties: ['deliveryStatus', 'envelope'], }, 'g', ], ]); }); + it('제출의 envelope 에 없는 받는 사람(엔진이 envelope 에서 받지 않은 주소)은 not-found — 처음 발송의 답과 같다 (9단계 리뷰 뒤)', async () => { + const { provider } = fakeClient([ + [ + ['EmailSubmission/query', { ids: ['sb1'] }, 'q'], + [ + 'EmailSubmission/get', + { + list: [ + { + id: 'sb1', + // 붙잡은 바깥 받는 사람의 결과는 아직 없을 수 있다 — 없는 것으로 정하지 않는다 + deliveryStatus: { [DOYUN]: QUEUED_REPLY }, + envelope: { + mailFrom: { email: ARA }, + rcptTo: [{ email: DOYUN }, { email: SEOJUN }], + }, + }, + ], + }, + 'g', + ], + ], + ]); + + await expect( + provider.getRejectedRecipients(ARA, 'sent1', { + to: [{ name: '이도윤', email: 'DoYun.Lee@AraSystem.Test' }], + cc: [{ name: null, email: 'typo@example.cmo' }], + bcc: [{ name: null, email: SEOJUN }], + }), + ).resolves.toEqual([{ email: 'typo@example.cmo', reason: 'not-found' }]); + }); + it('모두 받았으면 빈 배열', async () => { const { provider } = fakeClient([ submissionsOf({ [DOYUN]: QUEUED_REPLY, [SEOJUN]: QUEUED_REPLY }), @@ -3377,6 +4630,24 @@ describe('JmapMailProvider', () => { 'g', ], ], + // 찾은 사본의 제출 — 보낸 것(final) + [ + ['EmailSubmission/query', { ids: ['b'] }, 'pq'], + [ + 'EmailSubmission/get', + { + list: [ + { + id: 'b', + emailId: 'maaaaad', + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }, + ], + }, + 'pg', + ], + ], [ ['EmailSubmission/query', { ids: ['b'] }, 'q'], [ @@ -3400,7 +4671,12 @@ describe('JmapMailProvider', () => { await expect( provider.findSentMessage(ARA, 'ara.0123abcd@arasystem.test'), - ).resolves.toEqual({ id: 'maaaaad', fingerprint: 'v2:feedface' }); + ).resolves.toEqual({ + id: 'maaaaad', + fingerprint: 'v2:feedface', + canceled: false, + pendingSend: null, + }); await expect( provider.getRejectedRecipients(ARA, 'maaaaad', outgoing), ).resolves.toEqual([{ email: DOYUN, reason: 'not-found' }]); @@ -4021,6 +5297,7 @@ describe('JmapMailProvider', () => { attachments: [], inReplyTo: null, updatedAt: '2026-10-27T01:00:00.000Z', + isCanceledSend: false, }); expect(argsOf(recorded[0], 1)).toMatchObject({ ids: ['dr1'], @@ -4352,6 +5629,7 @@ describe('JmapMailProvider', () => { describe('스팸 신고 · 스팸 아님 (isJunk)', () => { it('true 면 $junk 를 붙이고 $notjunk 를 뗀다 — 이동과 같은 패치 하나로 (한 통)', async () => { const { provider, recorded } = fakeClient([ + heldGuard(), [ ['Email/set', { updated: { m1: null } }, 'u'], [ @@ -4370,7 +5648,7 @@ describe('JmapMailProvider', () => { await provider.updateMessage(ARA, 'm1', { mailboxId: 'c', isJunk: true }); - expect(argsOf(recorded[0])).toEqual({ + expect(argsOf(recorded[1])).toEqual({ accountId: 'd', update: { m1: { @@ -4384,6 +5662,7 @@ describe('JmapMailProvider', () => { it('false 면 반대 — 여러 통도 메일마다 같은 패치', async () => { const { provider, recorded } = fakeClient([ + heldGuard(), [ ['Mailbox/get', { list: [{ id: 'a' }] }, 'mb'], ['Email/set', { updated: { m1: null, m2: null } }, 'u'], @@ -4400,7 +5679,7 @@ describe('JmapMailProvider', () => { 'keywords/$notjunk': true, mailboxIds: { a: true }, }; - expect(argsOf(recorded[0], 1)).toEqual({ + expect(argsOf(recorded[1], 1)).toEqual({ accountId: 'd', update: { m1: patch, m2: patch }, }); @@ -4446,12 +5725,13 @@ describe('JmapMailProvider', () => { ['Mailbox/get', { list }, 'm'], ]; - /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태 */ + /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태, 같은 요청의 계정 기다리는 제출(9단계) */ type Candidate = { id: string; mailboxIds: Record }; const batch = ( emails: Candidate[], state: string | null = 's1', total: number = emails.length, + held: Record[] = [], ): JmapInvocation[] => [ ['Email/query', { ids: emails.map((email) => email.id), total }, 'q'], [ @@ -4459,6 +5739,7 @@ describe('JmapMailProvider', () => { { ...(state === null ? {} : { state }), list: emails, notFound: [] }, 'g', ], + ...heldGuard(held), ]; const only = (id: string, mailboxId: string): Candidate => ({ id, @@ -4758,7 +6039,7 @@ describe('JmapMailProvider', () => { movedToTrash: 0, remaining: 0, }); - expect(recorded[1].calls).toEqual([ + expect(recorded[1].calls.slice(0, 2)).toEqual([ [ 'Email/query', { @@ -4779,6 +6060,13 @@ describe('JmapMailProvider', () => { 'g', ], ]); + // 같은 요청 끝에 계정의 기다리는 제출 — 붙잡힌 메일은 옮기지 않는다 (9단계) + expect( + recorded[1].calls.slice(2).map(([method, , id]) => [method, id]), + ).toEqual([ + ['EmailSubmission/query', 'hq'], + ['EmailSubmission/get', 'hg'], + ]); expect(recorded[2].calls).toEqual([ [ 'Mailbox/set', @@ -4788,6 +6076,66 @@ describe('JmapMailProvider', () => { ]); }); + it('붙잡힌 메일(9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고, 다음 요청은 409 MAIL_015 (끝없이 다시 읽지 않는다)', async () => { + const held = [ + { + id: 'sb1', + emailId: 'h1', + undoStatus: 'pending', + sendAt: sendTimeIn(600_000), + }, + ]; + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('m1', 'u4'), only('h1', 'u4')], 's1', 2, held), + [['Email/set', { updated: { m1: null } }, 's']], + batch([only('h1', 'u4')], 's2', 1, held), + ]); + + await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: false, + processed: 1, + movedToTrash: 1, + remaining: 1, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { m1: { 'mailboxIds/u4': null, 'mailboxIds/b': true } }, + }); + expect(recorded).toHaveLength(4); + + const next = fakeClient([ + mailboxList(), + batch([only('h1', 'u4')], 's2', 1, held), + ]); + expect(await codeOf(next.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_015', + ); + expect(next.recorded).toHaveLength(2); + }); + + it('읽은 메일이 있는데 기다리는 제출을 읽지 못하면 옮기지 않고 MAIL_001 (fail closed)', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Email/query', { ids: ['m1'], total: 1 }, 'q'], + [ + 'Email/get', + { state: 's1', list: [only('m1', 'u4')], notFound: [] }, + 'g', + ], + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ]); + + expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + expect(recorded).toHaveLength(2); + }); + it('안의 메일은 확인한 상태 그대로일 때만 옮긴다 — 이 메일함에만 있으면 휴지통으로, 다른 메일함에도 있으면 이 메일함에서만 뺀다', async () => { const { provider, recorded } = fakeClient([ mailboxList(), @@ -5356,6 +6704,108 @@ describe('JmapMailProvider', () => { }); }); + it('붙잡힌 메일(9단계 — 다른 프로그램이 휴지통으로 옮겨 둔 것)은 지우지 않는다 — 남은 것이 그것뿐이면 지운 것까지, 지운 것이 없으면 409 MAIL_015', async () => { + const held = [ + { + id: 'sb1', + emailId: 'h1', + undoStatus: 'pending', + sendAt: sendTimeIn(600_000), + }, + ]; + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('t1', 'b'), only('h1', 'b')], 's1', 2, held), + [['Email/set', { destroyed: ['t1'] }, 's']], + batch([only('h1', 'b')], 's2', 1, held), + ]); + + await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 1, + remaining: 1, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['t1'], + }); + expect(recorded).toHaveLength(4); + + const next = fakeClient([ + mailboxList(), + batch([only('h1', 'b')], 's2', 1, held), + ]); + expect(await codeOf(next.provider.emptyMailbox(ARA, 'b'))).toBe( + 'MAIL_015', + ); + expect(next.recorded).toHaveLength(2); + }); + + it('계정의 기다리는 제출이 상한(500)에 닿으면 그 차례의 메일 id 로 다시 묻는다', async () => { + const full: JmapInvocation[] = [ + [ + 'EmailSubmission/query', + { + ids: Array.from({ length: 500 }, (_, i) => `s${i}`), + total: 1200, + }, + 'hq', + ], + ['EmailSubmission/get', { list: [] }, 'hg'], + ]; + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Email/query', { ids: ['t1', 'h1'], total: 2 }, 'q'], + [ + 'Email/get', + { + state: 's1', + list: [only('t1', 'b'), only('h1', 'b')], + notFound: [], + }, + 'g', + ], + ...full, + ], + heldGuard([ + { + id: 'sb9', + emailId: 'h1', + undoStatus: 'pending', + sendAt: sendTimeIn(600_000), + }, + ]), + [['Email/set', { destroyed: ['t1'] }, 's']], + // 다음 차례 — 남은 것은 붙잡힌 메일뿐 (이번에는 계정 전체 조회가 다 봤다) + batch([only('h1', 'b')], 's2', 1, [ + { + id: 'sb9', + emailId: 'h1', + undoStatus: 'pending', + sendAt: sendTimeIn(600_000), + }, + ]), + ]); + + await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 1, + remaining: 1, + }); + // 다시 묻기도 "보낼 시각이 지금 뒤" 조건 — 보낸 사본의 지난 제출이 상한을 채우지 않게 (9단계 리뷰 2) + expect(argsOf(recorded[2])).toMatchObject({ + filter: { + emailIds: ['t1', 'h1'], + after: expect.stringMatching(/Z$/) as unknown, + }, + }); + expect(argsOf(recorded[3])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['t1'], + }); + }); + it('스팸메일함도 같은 규칙으로 비운다', async () => { const { provider, recorded } = fakeClient([ mailboxList(), @@ -5820,6 +7270,857 @@ describe('JmapMailProvider', () => { expect(client.download).not.toHaveBeenCalled(); }); }); + /** + * 9단계 — 붙잡은 발송(보내기 취소 · 예약)과 스팸메일함 여부 (계약 2절 "보내기 취소 · 예약 발송" · "외부 이미지 보기"). + * 엔진 동작(HOLDFOR · HOLDUNTIL · 취소 · 숨은참조 머리글 떼기)은 2026-10-29 개발 엔진으로 확인한 모양이다 + */ + describe('9단계 — 붙잡은 발송 · 스팸메일함 여부', () => { + const inFuture = (ms: number) => + new Date(Date.now() + ms).toISOString().replace(/\.\d+Z$/, 'Z'); + const heldOf = ( + list: Record[], + prefix = 'p', + ): JmapInvocation[] => [ + [ + 'EmailSubmission/query', + { ids: list.map((submission) => submission.id) }, + `${prefix}q`, + ], + ['EmailSubmission/get', { list }, `${prefix}g`], + ]; + + it('목록 — 같은 요청 끝에 보낼 시각이 지금 뒤인 제출을 묻고, 붙잡힌 메일에 pendingSend(예약 표시면 scheduled)를 싣는다', async () => { + const soon = inFuture(10_000); + const later = inFuture(3_600_000); + const { provider, recorded } = fakeClient([ + [ + ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'], + [ + 'Email/query', + { ids: ['m1', 'm2', 'm3'], position: 0, total: 3 }, + 'q', + ], + [ + 'Email/get', + { + list: [ + summaryEmail('m1', '2026-10-29T03:00:00Z'), + summaryEmail('m2', '2026-10-29T02:00:00Z', { + keywords: { $seen: true, '$ara-scheduled': true }, + }), + summaryEmail('m3', '2026-10-29T01:00:00Z'), + ], + }, + 'g', + ], + ...heldOf([ + { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: soon }, + { id: 's2', emailId: 'm2', undoStatus: 'pending', sendAt: later }, + // 배달 중인 잠깐의 pending — 보낼 시각이 지났다 + { + id: 's3', + emailId: 'm3', + undoStatus: 'pending', + sendAt: '2026-10-29T00:00:00Z', + }, + ]), + ], + ]); + + const page = await provider.listMessages(ARA, { + mailboxId: 'e', + limit: 20, + }); + + expect( + page.items.map(({ id, pendingSend }) => [id, pendingSend]), + ).toEqual([ + ['m1', { kind: 'undo', sendAt: soon.replace('Z', '.000Z') }], + ['m2', { kind: 'scheduled', sendAt: later.replace('Z', '.000Z') }], + ['m3', null], + ]); + const [, filterArgs] = recorded[0].calls[3]; + expect(filterArgs).toMatchObject({ + filter: { after: expect.stringMatching(/^\d{4}-.+Z$/) as string }, + limit: 500, + }); + // 목록 표시로는 내보내지 않는다 (예약 표시는 화면이 pendingSend 로 안다) + expect(page.items[1]).not.toHaveProperty('keywords'); + }); + + it('목록 · 예약 목록 — 기다리는 제출은 보낼 시각 차례(sentAt)로 묻고, 500개에 닿으면 경고한다 (취소한 예약도 7일 안이면 든다 — 9단계 리뷰 뒤)', async () => { + const fullIds = Array.from({ length: 500 }, (_, index) => `s${index}`); + const list = fakeClient([ + [ + ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'], + ['Email/query', { ids: [], position: 0, total: 0 }, 'q'], + ['Email/get', { list: [] }, 'g'], + ['EmailSubmission/query', { ids: fullIds }, 'pq'], + ['EmailSubmission/get', { list: [] }, 'pg'], + ], + ]); + const warn = jest + .spyOn( + Reflect.get(list.provider, 'logger') as { warn: () => void }, + 'warn', + ) + .mockImplementation(() => undefined); + + await list.provider.listMessages(ARA, { mailboxId: 'e', limit: 20 }); + expect(list.recorded[0].calls[3][1]).toMatchObject({ + sort: [{ property: 'sentAt', isAscending: true }], + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('엔진이 다 주지 않아'), + ); + + const scheduled = fakeClient([ + [ + ['EmailSubmission/query', { ids: fullIds }, 'pq'], + ['EmailSubmission/get', { list: [] }, 'pg'], + ['Email/get', { list: [] }, 'g'], + ], + ]); + const scheduledWarn = jest + .spyOn( + Reflect.get(scheduled.provider, 'logger') as { warn: () => void }, + 'warn', + ) + .mockImplementation(() => undefined); + await scheduled.provider.listScheduledMessages(ARA); + expect(scheduledWarn).toHaveBeenCalledTimes(1); + }); + + it('목록 — 제출 읽기가 거절되면 pendingSend 없이 목록은 그대로', async () => { + const { provider } = fakeClient([ + [ + ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'], + ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], + [ + 'Email/get', + { list: [summaryEmail('m1', '2026-10-29T03:00:00Z')] }, + 'g', + ], + ['error', { type: 'forbidden' }, 'pq'], + ['error', { type: 'forbidden' }, 'pg'], + ], + ]); + + const page = await provider.listMessages(ARA, { + mailboxId: 'e', + limit: 20, + }); + expect(page.items.map(({ pendingSend }) => pendingSend)).toEqual([null]); + }); + + it('상세 — 스팸메일함에 든 메일인지(외부 이미지 판정용) · 붙잡힌 발송을 함께 읽는다', async () => { + const later = inFuture(3_600_000); + const email = { + ...summaryEmail('m1', '2026-10-29T03:00:00Z'), + mailboxIds: { c: true }, + bodyValues: {}, + textBody: [], + htmlBody: [], + attachments: [], + }; + const { provider } = fakeClient([ + [ + ['Email/get', { list: [email] }, 'g'], + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ...heldOf([ + { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later }, + ]), + ], + ]); + + const detail = await provider.getMessage(ARA, 'm1'); + expect(detail.isInJunkMailbox).toBe(true); + expect(detail.pendingSend).toEqual({ + kind: 'undo', + sendAt: later.replace('Z', '.000Z'), + }); + + // 메일함 목록 · 제출을 읽지 못하면 아니다 (상세는 그대로) + const unknown = fakeClient([ + [ + ['Email/get', { list: [email] }, 'g'], + ['error', { type: 'serverFail' }, 'm'], + ['error', { type: 'serverFail' }, 'pq'], + ['error', { type: 'serverFail' }, 'pg'], + ], + ]); + const plain = await unknown.provider.getMessage(ARA, 'm1'); + expect(plain.isInJunkMailbox).toBe(false); + expect(plain.pendingSend).toBeNull(); + }); + + describe('붙잡는 발송 (sendMessage 의 hold)', () => { + const prepared: JmapInvocation[] = [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Identity/get', + { list: [{ id: 'b', name: '김아라', email: ARA }] }, + 'i', + ], + ]; + const sentWith = (send: Record): JmapInvocation[] => [ + [ + 'Email/set', + { created: { draft: { id: 'maaaaad', blobId: 'x', size: 100 } } }, + 'e', + ], + ['EmailSubmission/set', { created: { send } }, 's'], + ['Email/set', { updated: { maaaaad: null } }, 's'], + ]; + const queued: JmapInvocation[] = [ + [ + 'EmailSubmission/get', + { list: [{ id: 'sb1', deliveryStatus: null }], notFound: [] }, + 'd', + ], + ]; + const base: OutgoingMessage = { + to: [{ name: '이도윤', email: DOYUN }], + cc: [{ name: null, email: 'DOYUN.LEE@arasystem.test' }], + bcc: [{ name: null, email: SEOJUN }], + subject: '예약 메일', + body: '본문', + attachments: [], + }; + + it('예약 — 보낸 사본에 예약 표시, 제출에 envelope(HOLDUNTIL · 받는 사람 · 참조 · 숨은참조, 겹치지 않게), 응답에 엔진이 정한 보낼 시각', async () => { + const { provider, recorded } = fakeClient([ + prepared, + sentWith({ + id: 'sb1', + sendAt: '2030-01-01T09:00:00Z', + undoStatus: 'pending', + }), + queued, + ]); + + await expect( + provider.sendMessage(ARA, { + ...base, + hold: { kind: 'scheduled', until: '2030-01-01T09:00:00.000Z' }, + }), + ).resolves.toEqual({ + id: 'maaaaad', + rejectedRecipients: [], + pendingSend: { + kind: 'scheduled', + sendAt: '2030-01-01T09:00:00.000Z', + }, + }); + + const draft = (argsOf(recorded[1], 0).create as Record) + .draft as { keywords: Record }; + expect(draft.keywords).toEqual({ + $draft: true, + $seen: true, + '$ara-sending': true, + '$ara-scheduled': true, + }); + const submission = argsOf(recorded[1], 1) as { + create: { send: Record }; + onSuccessUpdateEmail: Record; + }; + expect(submission.create.send).toEqual({ + identityId: 'b', + emailId: '#draft', + envelope: { + mailFrom: { + email: ARA, + parameters: { HOLDUNTIL: '2030-01-01T09:00:00Z' }, + }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }); + // 붙잡아도 보낸편지함으로 옮기는 것은 같다 (예약 표시는 남는다) + expect(submission.onSuccessUpdateEmail).toEqual({ + '#send': { + 'mailboxIds/d': null, + 'mailboxIds/e': true, + 'keywords/$draft': null, + 'keywords/$ara-sending': null, + }, + }); + }); + + it('보내기 취소 — HOLDFOR 초, 예약 표시 없음. 엔진이 이미 보냈다(final)고 답하면 pendingSend null', async () => { + const held = fakeClient([ + prepared, + sentWith({ + id: 'sb1', + sendAt: inFuture(10_000), + undoStatus: 'pending', + }), + queued, + ]); + const result = await held.provider.sendMessage(ARA, { + ...base, + hold: { kind: 'undo', seconds: 10 }, + }); + expect(result.pendingSend).toMatchObject({ kind: 'undo' }); + const send = ( + argsOf(held.recorded[1], 1) as { + create: { send: { envelope: { mailFrom: unknown } } }; + } + ).create.send; + expect(send.envelope.mailFrom).toEqual({ + email: ARA, + parameters: { HOLDFOR: '10' }, + }); + const draft = ( + argsOf(held.recorded[1], 0).create as Record< + string, + { keywords: Record } + > + ).draft; + expect(draft.keywords).not.toHaveProperty('$ara-scheduled'); + + const released = fakeClient([ + prepared, + sentWith({ id: 'sb1', undoStatus: 'final' }), + queued, + ]); + await expect( + released.provider.sendMessage(ARA, { + ...base, + hold: { kind: 'undo', seconds: 10 }, + }), + ).resolves.toMatchObject({ pendingSend: null }); + }); + + it('붙잡지 않는 발송도 envelope 을 직접 준다 — MAIL FROM 매개변수 없이 (9단계 리뷰 뒤 — 엔진이 받지 않는 주소를 알리게, envelope.ts)', async () => { + const { provider, recorded } = fakeClient([ + prepared, + sentWith({ id: 'sb1' }), + queued, + ]); + + await provider.sendMessage(ARA, base); + const send = ( + argsOf(recorded[1], 1) as { + create: { send: Record }; + } + ).create.send; + expect(send).toEqual({ + identityId: 'b', + emailId: '#draft', + envelope: { + mailFrom: { email: ARA, parameters: null }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }); + }); + + it('엔진이 붙잡을 시각을 받지 않으면(forbiddenMailFrom) 만든 초안을 지우고 400 VAL_001', async () => { + const { provider, recorded } = fakeClient([ + prepared, + [ + [ + 'Email/set', + { created: { draft: { id: 'maaaaad', blobId: 'x', size: 1 } } }, + 'e', + ], + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'forbiddenMailFrom' } } }, + 's', + ], + ], + [['Email/set', { destroyed: ['maaaaad'] }, 'x']], + ]); + + const failure = await provider + .sendMessage(ARA, { + ...base, + hold: { kind: 'scheduled', until: '2030-01-01T09:00:00.000Z' }, + }) + .catch((caught: unknown) => caught as BusinessException); + expect(failure).toBeInstanceOf(BusinessException); + expect((failure as BusinessException).code).toBe('VAL_001'); + expect((failure as BusinessException).context).toMatchObject({ + reason: 'send-hold-refused-by-engine', + hold: 'scheduled', + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + destroy: ['maaaaad'], + }); + }); + }); + + describe('예약한 메일 (listScheduledMessages)', () => { + it('보낼 시각이 지금 뒤인 제출 → 그 메일(#ids /list/*/emailId) — 예약 표시가 있는 것만, 보낼 시각 · id 차례', async () => { + const a = inFuture(7_200_000); + const b = inFuture(3_600_000); + const scheduled = { $seen: true, '$ara-scheduled': true }; + const { provider, recorded } = fakeClient([ + [ + ...heldOf([ + { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: a }, + { id: 's2', emailId: 'm2', undoStatus: 'pending', sendAt: b }, + { id: 's3', emailId: 'm3', undoStatus: 'pending', sendAt: b }, + // 보내기 취소를 기다리는 메일은 들지 않는다 + { + id: 's4', + emailId: 'm4', + undoStatus: 'pending', + sendAt: inFuture(5_000), + }, + // 취소한 것 · 보낸 것은 들지 않는다 + { id: 's5', emailId: 'm5', undoStatus: 'canceled', sendAt: a }, + ]), + [ + 'Email/get', + { + list: [ + summaryEmail('m1', '2026-10-29T01:00:00Z', { + keywords: scheduled, + }), + summaryEmail('m3', '2026-10-29T01:00:00Z', { + keywords: scheduled, + }), + summaryEmail('m2', '2026-10-29T01:00:00Z', { + keywords: scheduled, + }), + summaryEmail('m4', '2026-10-29T01:00:00Z'), + summaryEmail('m5', '2026-10-29T01:00:00Z', { + keywords: scheduled, + }), + ], + }, + 'g', + ], + ], + ]); + + const list = await provider.listScheduledMessages(ARA); + + expect(list.map(({ id }) => id)).toEqual(['m2', 'm3', 'm1']); + expect(list[0].pendingSend).toEqual({ + kind: 'scheduled', + sendAt: b.replace('Z', '.000Z'), + }); + expect(recorded[0].calls[2][1]).toMatchObject({ + '#ids': { + resultOf: 'pg', + name: 'EmailSubmission/get', + path: '/list/*/emailId', + }, + }); + }); + }); + + describe('붙잡은 발송 취소 (cancelPendingSend)', () => { + const lookup = ( + submissions: Record[], + keywords: Record = { $seen: true }, + mailboxIds: Record = { e: true }, + ): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [{ id: 'm1', mailboxIds, keywords }] }, 'g'], + ...heldOf(submissions), + ]; + const later = () => inFuture(3_600_000); + + it('취소한 뒤 다른 요청으로 임시보관함의 초안으로 — $draft · 취소 표시를 붙이고 예약 · 발송 중 표시를 뗀다', async () => { + const { provider, recorded } = fakeClient([ + lookup( + [ + { + id: 's1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: later(), + }, + ], + { $seen: true, '$ara-scheduled': true }, + ), + [['EmailSubmission/set', { updated: { s1: null } }, 'x']], + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + const log = jest + .spyOn(Reflect.get(provider, 'logger') as { log: () => void }, 'log') + .mockImplementation(() => undefined); + + await expect( + provider.cancelPendingSend(ARA, 'm1'), + ).resolves.toBeUndefined(); + + expect(recorded).toHaveLength(3); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + update: { s1: { undoStatus: 'canceled' } }, + }); + expect(recorded[1].calls).toHaveLength(1); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + update: { + m1: { + mailboxIds: { d: true }, + 'keywords/$draft': true, + 'keywords/$ara-canceled': true, + 'keywords/$ara-scheduled': null, + 'keywords/$ara-sending': null, + }, + }, + }); + expect(log).toHaveBeenCalledTimes(1); + }); + + it('붙잡은 제출이 없으면(보낸 제출(final)이 있음 · 배달 중 · 제출이 없는 메일) MAIL_014 — 아무것도 바꾸지 않는다', async () => { + for (const submissions of [ + [], + [ + { + id: 's1', + emailId: 'm1', + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }, + ], + [ + { + id: 's1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: '2026-10-29T00:00:00Z', + }, + ], + // 취소한 것과 보낸 것이 함께 — 보낸 메일이다 + [ + { + id: 's0', + emailId: 'm1', + undoStatus: 'canceled', + sendAt: later(), + }, + { + id: 's1', + emailId: 'm1', + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }, + ], + ]) { + const { provider, recorded } = fakeClient([lookup(submissions)]); + expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe( + 'MAIL_014', + ); + expect(recorded).toHaveLength(1); + } + }); + + describe('이미 취소한 발송을 다시 취소 — 같은 요청을 다시 보내도 같은 결과 (9단계 리뷰 뒤)', () => { + const canceled = [ + { id: 's1', emailId: 'm1', undoStatus: 'canceled', sendAt: later() }, + ]; + + it('되돌리기까지 마쳤으면(임시보관함 · 취소 표시) 아무것도 하지 않고 성공 — 응답을 받지 못한 재시도', async () => { + const { provider, recorded } = fakeClient([ + lookup( + canceled, + { $draft: true, $seen: true, '$ara-canceled': true }, + { d: true }, + ), + ]); + + await expect( + provider.cancelPendingSend(ARA, 'm1'), + ).resolves.toBeUndefined(); + expect(recorded).toHaveLength(1); + }); + + it('되돌리기를 마친 뒤 사용자가 휴지통 · 다른 메일함으로 옮겼으면 그대로 성공 — 옮기지 않는다 (9단계 리뷰 2 — 늦게 온 취소가 버린 초안을 되살리지 않게)', async () => { + const places: Record[] = [{ b: true }, { u4: true }]; + for (const mailboxIds of places) { + const { provider, recorded } = fakeClient([ + lookup( + canceled, + { $draft: true, $seen: true, '$ara-canceled': true }, + mailboxIds, + ), + ]); + + await expect( + provider.cancelPendingSend(ARA, 'm1'), + ).resolves.toBeUndefined(); + // 읽기만 했다 — 옮기는 요청이 없다 + expect(recorded).toHaveLength(1); + } + }); + + it('취소는 됐는데 보낸편지함에 남아 있으면(옮기지 못한 취소) 되돌리기만 다시 한다 — 엔진에 취소를 다시 보내지 않는다', async () => { + const { provider, recorded } = fakeClient([ + lookup(canceled, { $seen: true, '$ara-scheduled': true }), + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + jest + .spyOn( + Reflect.get(provider, 'logger') as { log: () => void }, + 'log', + ) + .mockImplementation(() => undefined); + + await expect( + provider.cancelPendingSend(ARA, 'm1'), + ).resolves.toBeUndefined(); + expect(recorded).toHaveLength(2); + expect(recorded[1].calls[0][0]).toBe('Email/set'); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + update: { + m1: { + mailboxIds: { d: true }, + 'keywords/$draft': true, + 'keywords/$ara-canceled': true, + 'keywords/$ara-scheduled': null, + 'keywords/$ara-sending': null, + }, + }, + }); + }); + + it('되돌리기를 다시 해도 못 하면 MAIL_001 — 한 번 더 한 뒤', async () => { + const { provider, recorded } = fakeClient([ + lookup(canceled), + [['error', { type: 'serverFail' }, 'u']], + [['error', { type: 'serverFail' }, 'u']], + ]); + jest + .spyOn( + Reflect.get(provider, 'logger') as { warn: () => void }, + 'warn', + ) + .mockImplementation(() => undefined); + + expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe( + 'MAIL_001', + ); + expect(recorded).toHaveLength(3); + }); + }); + + it('엔진이 거절하지도 확인하지도 않으면(updated 에 그 제출이 없음) MAIL_001 — 옮기지 않는다 (9단계 리뷰 2 — 보낼 메일이 "취소한 초안"이 되지 않게)', async () => { + const pending = (id: string): Record => ({ + id, + emailId: 'm1', + undoStatus: 'pending', + sendAt: later(), + }); + const cases: [Record[], Record][] = [ + [[pending('s1')], {}], + [[pending('s1')], { updated: {} }], + // 둘 가운데 하나만 확인했다 + [[pending('s1'), pending('s2')], { updated: { s1: null } }], + ]; + for (const [submissions, answer] of cases) { + const { provider, recorded } = fakeClient([ + lookup(submissions), + [['EmailSubmission/set', answer, 'x']], + ]); + + const error = await provider + .cancelPendingSend(ARA, 'm1') + .catch((caught: unknown) => caught as BusinessException); + expect((error as BusinessException).code).toBe('MAIL_001'); + expect((error as BusinessException).context).toMatchObject({ + reason: 'cancel-not-confirmed', + }); + // 취소 요청까지만 — 임시보관함으로 옮기지 않았다 + expect(recorded).toHaveLength(2); + } + }); + + it('엔진이 취소를 받지 않으면(그사이 풀림) MAIL_014 — 옮기지 않는다', async () => { + const { provider, recorded } = fakeClient([ + lookup([ + { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later() }, + ]), + [ + [ + 'EmailSubmission/set', + { notUpdated: { s1: { type: 'cannotUnsend' } } }, + 'x', + ], + ], + ]); + + expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe( + 'MAIL_014', + ); + expect(recorded).toHaveLength(2); + }); + + it('없는 메일은 RES_001', async () => { + const { provider } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [], notFound: ['m1'] }, 'g'], + ...heldOf([]), + ], + ]); + expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe( + 'RES_001', + ); + }); + + it('취소는 됐는데 옮기지 못하면 한 번 더, 그래도 못 하면 MAIL_001 (warn)', async () => { + const { provider, recorded } = fakeClient([ + lookup([ + { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later() }, + ]), + [['EmailSubmission/set', { updated: { s1: null } }, 'x']], + [['Email/set', { notUpdated: { m1: { type: 'serverFail' } } }, 'u']], + [['error', { type: 'serverFail' }, 'u']], + ]); + const warn = jest + .spyOn( + Reflect.get(provider, 'logger') as { warn: () => void }, + 'warn', + ) + .mockImplementation(() => undefined); + + expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe( + 'MAIL_001', + ); + expect(recorded).toHaveLength(4); + expect(warn).toHaveBeenCalled(); + }); + }); + + describe('취소해 되돌린 발송을 이어 쓴다 (getDraft)', () => { + const SIGNATURE_CID = + 'sig-0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f@arasystem.test'; + const canceled = { + id: 'm1', + threadId: 't1', + mailboxIds: { d: true }, + keywords: { $draft: true, $seen: true, '$ara-canceled': true }, + // 발송의 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자의 초안이다 + 'header:X-Ara-Payload-Hash:asText': 'v2:feedface', + from: [{ name: '김아라', email: ARA }], + to: [{ name: '이도윤', email: DOYUN }], + cc: [], + bcc: [], + subject: 'Re: 견적', + receivedAt: '2026-10-29T03:00:00Z', + inReplyTo: ['orig@arasystem.test'], + bodyValues: { '1': { value: '본문', isTruncated: false } }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [ + { + partId: '2', + blobId: 'blob-sig', + name: 'logo.png', + type: 'image/png', + size: 512, + disposition: 'inline', + cid: SIGNATURE_CID, + }, + { + partId: '3', + blobId: 'blob-xlsx', + name: '견적.xlsx', + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size: 2048, + disposition: 'attachment', + cid: null, + }, + ], + }; + + it('서명 그림(cid sig-…)은 첨부로 내보내지 않고, 답장 대상은 같은 스레드에서 In-Reply-To 로 찾는다', async () => { + const { provider, recorded } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [canceled] }, 'g'], + ], + [ + [ + 'Thread/get', + { list: [{ id: 't1', emailIds: ['orig', 'm1'] }] }, + 't', + ], + [ + 'Email/get', + { + list: [ + { id: 'orig', messageId: ['orig@arasystem.test'] }, + { id: 'm1', messageId: ['ara.1@arasystem.test'] }, + ], + }, + 'r', + ], + ], + ]); + + const draft = await provider.getDraft(ARA, 'm1'); + + expect(draft.attachments.map(({ id }) => id)).toEqual(['blob-xlsx']); + expect(draft.inReplyTo).toBe('orig'); + // 서비스가 본문의 cid:sig-… 를 서명 그림 주소로 되돌리게 알린다 + expect(draft.isCanceledSend).toBe(true); + expect(recorded[1].calls.map(([method]) => method)).toEqual([ + 'Thread/get', + 'Email/get', + ]); + }); + + it('취소 표시가 없는 초안의 sig-… 부분(다른 프로그램이 남의 메일을 전달 · 인용한 것)은 지금처럼 첨부다 — 스레드도 찾지 않는다 (9단계 리뷰 뒤)', async () => { + const otherClient = { + ...canceled, + keywords: { $draft: true, $seen: true }, + 'header:X-Ara-Payload-Hash:asText': null, + }; + const { provider, recorded } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [otherClient] }, 'g'], + ], + ]); + + const draft = await provider.getDraft(ARA, 'm1'); + + expect(draft.attachments.map(({ id }) => id)).toEqual([ + 'blob-sig', + 'blob-xlsx', + ]); + expect(draft.isCanceledSend).toBe(false); + expect(draft.inReplyTo).toBeNull(); + expect(recorded).toHaveLength(1); + }); + + it('취소 표시가 없는 발송 사본(지문 머리글이 있는 메일)은 지금처럼 초안이 아니다 — RES_001', async () => { + const { provider } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Email/get', + { + list: [ + { ...canceled, keywords: { $draft: true, $seen: true } }, + ], + }, + 'g', + ], + ], + ]); + expect(await codeOf(provider.getDraft(ARA, 'm1'))).toBe('RES_001'); + }); + }); + }); }); describe('buildThreadHeaders', () => { diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index bec65c3..a8d4c23 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -5,20 +5,25 @@ import { openAsBlob } from 'fs'; import { BusinessException } from '@/common/errors/business.exception'; import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy'; +import { parseSignatureImageContentId } from '@/modules/mail/html/signature-images'; import { MAIL_ID_PATTERN, MAX_BODY_VALUE_BYTES, + MAX_ENVELOPE_RESUBMITS, } from '@/modules/mail/mail.constants'; import type { MailProvider } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { AttachmentContent, + BlockedSender, Correspondence, DraftContent, DraftSource, MailAccountProfile, MailAddress, MailAttachment, + MailBlockAdded, + MailBlockList, Mailbox, MailboxBulkResult, MailboxCreate, @@ -50,6 +55,7 @@ import type { RecipientFields, RejectedRecipient, SavedDraft, + SendHold, SentMessage, SentMessageRecord, UploadedFile, @@ -65,13 +71,20 @@ import { type JmapSession, } from './jmap.client'; import { - DELIVERED_TO_ALL, describeRejections, everyoneRejectedCode, readDeliveryResult, + submittedEnvelope, toRejectedRecipients, type DeliveryResult, } from './delivery-status'; +import { + addressNamedIn, + envelopeKey, + envelopeRecipients, + isEnvelopeRefusal, + submissionEnvelope, +} from './envelope'; import { decodeCursor, encodeCursor } from './jmap-cursor'; import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; @@ -92,6 +105,7 @@ import { import { describeFailure, isMissingAttachmentBlob, + isRequestRefused, jmapFailure, } from './jmap.errors'; import { BulkRun, CONDITIONAL_DESTROY_ATTEMPTS } from './bulk-run'; @@ -129,6 +143,26 @@ import type { JmapSetResult, } from './jmap.types'; import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail'; +import { + CANCELED_SEND_KEYWORD, + HELD_SUBMISSIONS_MAX, + SCHEDULED_SEND_KEYWORD, + heldAmong, + heldEmailsFilter, + heldOverLimit, + heldQueryFull, + heldSendTimes, + heldSubmissionFilter, + holdParameters, + isHeld, + pendingSendOf, + readHeldEmails, + readHeldSendTimes, + submissionCalls, + submittedPendingSend, + type HeldEmails, + type HeldLookup, +} from './pending-send'; import { searchTextConditions, withSearchConditions } from './search-terms'; const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota'; @@ -166,12 +200,16 @@ const DRAFT_CHECK_PROPERTIES = [ FINGERPRINT_PROPERTY, ]; -/** 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만) */ +/** + * 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만). + * In-Reply-To 는 취소해 되돌린 발송(9단계)의 답장 대상을 스레드에서 찾을 때 쓴다 — 보낸 메일에는 답장 대상 머리글(X-Ara-Reply-To-Id)이 없다 + */ const DRAFT_DETAIL_PROPERTIES = [ ...new Set([ ...DETAIL_PROPERTIES, ...DRAFT_CHECK_PROPERTIES, REPLY_TO_ID_PROPERTY, + 'inReplyTo', ]), ]; @@ -226,6 +264,11 @@ const SEND_RESULT_TIMEOUT_MS = 5_000; const SEND_RESULT_MIN_WINDOW_MS = 1_000; /** 받는 사람이 모두 거절했을 때 보낸 사본을 지울 시간 — 결과 읽기가 발송 전체 시간에서 이만큼 남겨 둔다 */ const SEND_DISCARD_RESERVE_MS = 3_000; +/** + * 발송이 만든 메일을 지울 때(보내지 못한 초안 · 모두 거절된 사본) 남은 시간이 이보다 적어도 이만큼은 기다린다 — 지우기는 가벼운 요청 하나다. + * 발송 응답은 SEND_TOTAL_BUDGET_MS + 이만큼(56초)을 넘지 않는다 — 클라이언트의 발송 한도(60초) 안 (9단계 리뷰 2) + */ +const SEND_CLEANUP_MIN_MS = 1_000; /** 사용자 초안을 조건부로 지운 결과 */ type DraftDestroyOutcome = @@ -259,6 +302,24 @@ const MAX_REFERENCES = 20; /** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */ const DOWNLOAD_TYPE = 'application/octet-stream'; +/** 기다리는 발송(9단계)을 묻는 호출 id 의 앞글자 — `pq`(EmailSubmission/query) · `pg`(EmailSubmission/get) */ +const HELD_CALLS = 'p'; +/** + * 붙잡힌 메일을 막는 확인(옮기기 · 지우기 — 9단계 리뷰 뒤)의 호출 id 앞글자 — `hq` · `hg`. 보이기 경로(HELD_CALLS)와 달리 읽지 못하면 바꾸지 않는다 + * (pending-send.ts 의 readHeldEmails — fail closed) + */ +const HELD_GUARD_CALLS = 'h'; + +/** 제출의 상태 — 붙잡고 있다 (pending-send.ts) */ +const UNDO_PENDING = 'pending'; +const UNDO_CANCELED = 'canceled'; + +/** 엔진이 붙잡을 시각을 받지 않을 때의 SetError — Stalwart 는 FUTURERELEASE 한도(7일)를 넘으면 이것으로 거절한다 (2026-10-29 확인) */ +const FORBIDDEN_MAIL_FROM = 'forbiddenMailFrom'; + +/** 붙잡은 발송을 취소한 뒤 그 메일을 임시보관함으로 옮기는 횟수 — 실패하면 한 번 더 (계약 2절 "보내기 취소 · 예약 발송") */ +const RETURN_TO_DRAFTS_ATTEMPTS = 2; + function toJmapAddress(address: MailAddress): { name: string | null; email: string; @@ -273,11 +334,6 @@ function roleMailboxId( return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id; } -/** - * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것. - * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다 - * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다 - */ /** 메일이 들어 있는 메일함 id */ function inMailboxes(email: JmapEmail): string[] { return Object.entries(email.mailboxIds ?? {}) @@ -285,17 +341,25 @@ function inMailboxes(email: JmapEmail): string[] { .map(([mailboxId]) => mailboxId); } +/** + * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것. + * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다 + * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다 + * - 취소해 되돌린 발송(9단계 — CANCELED_SEND_KEYWORD)은 지문 헤더가 남아 있어도 사용자 초안이다 (메일은 고칠 수 없다 — pending-send.ts) + */ function isUserDraft(email: JmapEmail, draftsId: string): boolean { const fingerprint = (email as unknown as Record)[ FINGERPRINT_PROPERTY ]; const mailboxes = inMailboxes(email); + const isCanceledSend = email.keywords?.[CANCELED_SEND_KEYWORD] === true; return ( mailboxes.length === 1 && mailboxes[0] === draftsId && email.keywords?.$draft === true && email.keywords?.[INTERNAL_SEND_KEYWORD] !== true && - !(typeof fingerprint === 'string' && fingerprint.trim() !== '') + (isCanceledSend || + !(typeof fingerprint === 'string' && fingerprint.trim() !== '')) ); } @@ -322,6 +386,21 @@ function toEmailPatch(update: MessageUpdate): Record { return patch; } +/** + * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)을 옮기거나 지우려 했다 — 409 MAIL_015, 아무것도 바꾸지 않았다. + * 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 — 옮기거나 지워도 엔진은 시간이 되면 보낸다 (계약 2절 "보내기 취소 · 예약 발송") + */ +function heldSendLocked(action: string): BusinessException { + return new BusinessException('MAIL_015', { + context: { reason: 'held-send-locked', action }, + }); +} + +/** 메일 하나를 옮기거나 스팸으로 바꾸는 변경인가 — 붙잡힌 메일에는 하지 않는다(읽음 · 중요 표시는 된다) */ +function movesMessage(update: MessageUpdate): boolean { + return update.mailboxId !== undefined || update.isJunk !== undefined; +} + /** 메일함에서 메일을 지우는 방법 — 영구 삭제 · 그 메일함에서만 빼기 */ type MailboxRemoval = 'destroy' | 'detach'; @@ -618,6 +697,58 @@ function bodyProperties( }; } +/** 발송 제출을 만들 재료 — 처음 제출과 envelope 을 고쳐 다시 내는 제출이 같다 (sendMessage · finishSubmission) */ +interface SubmissionPlan { + readonly identityId: string; + /** envelope 의 보내는 사람 — 발신 ID 의 주소 */ + readonly from: string; + /** MAIL FROM 매개변수 — 붙잡을 때만 (HOLDFOR · HOLDUNTIL) */ + readonly parameters: Readonly> | null; + readonly draftsId: string; + readonly sentId: string; +} + +/** 엔진이 만든 제출 — id · 보낼 시각 · 상태 (EmailSubmission/set 의 created) */ +interface CreatedSubmission { + readonly id?: string; + readonly sendAt?: string | null; + readonly undoStatus?: string | null; +} + +/** + * 제출 하나(EmailSubmission/set create, 호출 id 's') — 늘 envelope 을 직접 준다. 성공하면 초안 → 보낸편지함으로 옮기고 발송 중 표시를 뗀다 + * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때) + */ +function submitCall( + accountId: string, + plan: SubmissionPlan, + emailId: string, + rcptTo: readonly string[], +): JmapInvocation { + return [ + 'EmailSubmission/set', + { + accountId, + create: { + send: { + identityId: plan.identityId, + emailId, + envelope: submissionEnvelope(plan.from, rcptTo, plan.parameters), + }, + }, + onSuccessUpdateEmail: { + '#send': { + [`mailboxIds/${plan.draftsId}`]: null, + [`mailboxIds/${plan.sentId}`]: true, + 'keywords/$draft': null, + [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, + }, + }, + }, + 's', + ]; +} + /** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */ export function buildThreadHeaders( original: Pick | undefined, @@ -796,7 +927,9 @@ export class JmapMailProvider implements MailProvider { * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). * 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다 * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). - * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다) + * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다). + * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고(남은 수에 든다), + * 옮긴 것이 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게 — 클라이언트의 되풀이도 끝난다). 차례마다 제출을 읽지 못하면 MAIL_001 */ async deleteMailbox(user: string, id: string): Promise { const run = new BulkRun(); @@ -849,15 +982,28 @@ export class JmapMailProvider implements MailProvider { }); } + // 붙잡힌 메일(9단계)은 그대로 둔다 — 취소로만 보낸편지함을 떠난다 + const held = await heldAmong( + batch.held, + batch.emails.map((email) => email.id), + this.heldLookup(user, run.callOptions()), + ); const toTrash: string[] = []; const detach: string[] = []; for (const email of batch.emails) { + if (held.has(email.id)) continue; if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { toTrash.push(email.id); } else { detach.push(email.id); } } + if (toTrash.length === 0 && detach.length === 0) { + // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 옮긴 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게) + this.warnHeldSkipped(user, held.size, '지우는 메일함에서 옮기지'); + if (run.processed > 0) break; + throw heldSendLocked('mailbox-delete'); + } const set = await this.conditionalEmailSet( user, batch.state, @@ -998,6 +1144,7 @@ export class JmapMailProvider implements MailProvider { * 다른 메일함에도 있으면 그 메일함에서만 뺀다. 어긋나면 다시 읽는다. 한 요청의 시간 · 멈춘 차례는 BulkRun — * 이 요청에서 지운 것이 없이 멈추면 MAIL_001 (지우지 않았다). 남은 수를 돌려준다. * 없는 메일함은 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001. + * 붙잡힌 메일(9단계 — 다른 프로그램이 옮겨 둔 것)은 지우지 않는다 — 남은 것이 그것뿐이면 이 요청에서 지운 것까지로 답하고, 지운 것이 없으면 MAIL_015 */ async emptyMailbox(user: string, id: string): Promise { const run = new BulkRun(); @@ -1031,15 +1178,28 @@ export class JmapMailProvider implements MailProvider { }); } + // 붙잡힌 메일(9단계)은 지우지 않는다 — 취소로만 보낸편지함을 떠난다 + const held = await heldAmong( + batch.held, + batch.emails.map((email) => email.id), + this.heldLookup(user, run.callOptions()), + ); const destroy: string[] = []; const detach: string[] = []; for (const email of batch.emails) { + if (held.has(email.id)) continue; if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { destroy.push(email.id); } else { detach.push(email.id); } } + if (destroy.length === 0 && detach.length === 0) { + // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 지운 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게) + this.warnHeldSkipped(user, held.size, '메일함을 비우며 지우지'); + if (run.processed > 0) break; + throw heldSendLocked('mailbox-empty'); + } const set = await this.conditionalEmailSet( user, batch.state, @@ -1111,6 +1271,21 @@ export class JmapMailProvider implements MailProvider { return this.filterScripts.setFilters(user, update); } + /** 차단한 보낸 사람 — 필터 · 부재중과 같은 스크립트에 둔다 (계약 2절 "보낸 사람 차단" — jmap-filter-script.ts) */ + getMailBlocks(user: string): Promise { + return this.filterScripts.getBlocks(user); + } + + /** 차단을 더한다 — 이미 있으면 그대로(created: false). 500개면 VAL_001, 만든 스크립트가 한도를 넘으면 MAIL_013 */ + addMailBlock(user: string, sender: BlockedSender): Promise { + return this.filterScripts.addBlock(user, sender); + } + + /** 차단을 푼다 — 없는 주소면 RES_001 */ + removeMailBlock(user: string, address: string): Promise { + return this.filterScripts.removeBlock(user, address); + } + /** 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 차례 · 판정 · 이어 할 자리는 JmapFilterRunner (계약 2절 "이미 받은 메일에 적용") */ runMailFilter( user: string, @@ -1213,19 +1388,26 @@ export class JmapMailProvider implements MailProvider { textConditions, ); + // 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts) + const now = Date.now(); let responses = await this.queryMessages( user, query, filter, resume ? { anchor: resume.anchor } : { position: 0 }, + now, ); // 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다 const first = findResult(responses, 'q', 'Email/query'); if (resume && !first.ok && first.error.type === 'anchorNotFound') { - responses = await this.queryMessages(user, query, filter, { - position: resume.position, - }); + responses = await this.queryMessages( + user, + query, + filter, + { position: resume.position }, + now, + ); } if (query.mailboxId !== undefined) { @@ -1252,12 +1434,14 @@ export class JmapMailProvider implements MailProvider { const ids = result.ids ?? []; const start = result.position ?? 0; const total = typeof result.total === 'number' ? result.total : null; + const held = readHeldSendTimes(responses, HELD_CALLS, now); + this.warnIfHeldQueryFull(responses, user); // Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다 const byId = new Map(list.map((email) => [email.id, email])); const items = ids.flatMap((id) => { const email = byId.get(id); - return email ? [toSummary(email)] : []; + return email ? [toSummary(email, pendingSendOf(email, held))] : []; }); const hasNext = @@ -1278,7 +1462,12 @@ export class JmapMailProvider implements MailProvider { }; } + /** + * 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다. + * 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다 + */ async getMessage(user: string, id: string): Promise { + const now = Date.now(); const responses = await this.jmap.call( user, (accountId) => [ @@ -1297,19 +1486,58 @@ export class JmapMailProvider implements MailProvider { }, 'g', ], + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS), ], { invalidRequestCode: 'RES_001' }, ); - return toDetailSource(this.requireEmail(responses, 'g')); + const email = this.requireEmail(responses, 'g'); + return toDetailSource(email, { + pendingSend: pendingSendOf( + email, + readHeldSendTimes(responses, HELD_CALLS, now), + ), + isInJunkMailbox: this.isInJunk(responses, email), + }); } + /** 같은 요청의 메일함 목록으로 본 스팸메일함에 든 메일인가 — 목록을 읽지 못하면 아니다 */ + private isInJunk(responses: JmapInvocation[], email: JmapEmail): boolean { + let mailboxes: JmapMailbox[]; + try { + const result = findResult(responses, 'm', 'Mailbox/get'); + if (!result.ok) return false; + mailboxes = (result.args as JmapGetResult).list ?? []; + } catch { + return false; + } + const junkId = roleMailboxId(mailboxes, 'junk'); + return junkId !== undefined && inMailboxes(email).includes(junkId); + } + + /** + * 한 통 바꾸기 — 옮기기 · 스팸 표시(mailboxId · isJunk)는 먼저 붙잡힌 메일인지 묻는다(메일 id 조건 — 요청 하나 더): 붙잡혔으면 MAIL_015, + * 확인하지 못하면(읽지 못함 · 다 받았는지 모름) MAIL_001 — 어느 쪽이든 바꾸지 않는다. 읽음 · 중요 표시만이면 묻지 않는다 + */ async updateMessage( user: string, id: string, update: MessageUpdate, ): Promise { + if (movesMessage(update)) { + const held = await this.heldByEmailIds(user, [id], { + invalidRequestCode: 'RES_001', + }); + if (held.ids.size > 0) throw heldSendLocked('update'); + if (!held.complete) throw heldOverLimit(1); + } const patch = toEmailPatch(update); + const now = Date.now(); const responses = await this.jmap.call( user, @@ -1325,6 +1553,8 @@ export class JmapMailProvider implements MailProvider { }, 'g', ], + // 바꾼 뒤의 요약도 목록과 같게 기다리는 발송을 싣는다 (9단계) + ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS), ], { invalidRequestCode: 'RES_001' }, ); @@ -1333,7 +1563,11 @@ export class JmapMailProvider implements MailProvider { const failure = firstSetError(set.notUpdated); if (failure) throw jmapFailure(failure, { method: 'Email/set' }); - return toSummary(this.requireEmail(responses, 'g')); + const email = this.requireEmail(responses, 'g'); + return toSummary( + email, + pendingSendOf(email, readHeldSendTimes(responses, HELD_CALLS, now)), + ); } /** @@ -1343,6 +1577,8 @@ export class JmapMailProvider implements MailProvider { * 같은 요청의 Email/set 도 아무것도 바꾸지 않는다. 메일 하나가 바뀐 것처럼 오면(있을 수 없는 경우) 사실대로 돌려준다 * - 메서드 단위 실패는 그 코드(엔진 장애 MAIL_001) — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내도 결과가 같다) * id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 요청 전체를 거부하면 VAL_001 (아무것도 바꾸지 않았다) + * - 옮기기 · 스팸 표시면 먼저 붙잡힌 메일(9단계)을 묻는다(계정 전체 — 보낼 시각이 지금 뒤인 제출, 요청 하나 더) — 붙잡힌 메일은 바꾸지 않고 failedIds. + * 확인하지 못하면 아무것도 바꾸지 않고 MAIL_001 */ async updateMessages( user: string, @@ -1351,6 +1587,17 @@ export class JmapMailProvider implements MailProvider { ): Promise { const patch = toEmailPatch(update); const { mailboxId } = update; + const held = movesMessage(update) + ? await this.heldAmongAccount(user, ids) + : new Set(); + const targets = ids.filter((id) => !held.has(id)); + if (held.size > 0) { + this.warnHeldSkipped(user, held.size, '옮기거나 스팸으로 바꾸지'); + } + // 붙잡힌 메일뿐이고 옮길 메일함도 묻지 않는다 — 엔진에 보낼 것이 없다 + if (targets.length === 0 && mailboxId === undefined) { + return { updatedIds: [], failedIds: [...ids] }; + } const responses = await this.jmap.call( user, @@ -1364,22 +1611,30 @@ export class JmapMailProvider implements MailProvider { ] as JmapInvocation, ] : []), - [ - 'Email/set', - { - accountId, - update: Object.fromEntries(ids.map((id) => [id, patch])), - }, - 'u', - ], + ...(targets.length > 0 + ? [ + [ + 'Email/set', + { + accountId, + update: Object.fromEntries(targets.map((id) => [id, patch])), + }, + 'u', + ] as JmapInvocation, + ] + : []), ], { invalidRequestCode: 'VAL_001' }, ); - const set = expectResult(responses, 'u', 'Email/set'); + const set: JmapSetResult = + targets.length > 0 + ? expectResult(responses, 'u', 'Email/set') + : {}; const updated = new Set(Object.keys(set.updated ?? {})); - const updatedIds = ids.filter((id) => updated.has(id)); - const failedIds = ids.filter((id) => !updated.has(id)); + const updatedIds = targets.filter((id) => updated.has(id)); + // 붙잡힌 메일은 보내지 않았으니 바뀐 것에 없다 — 바꾸지 못한 메일과 함께 failedIds + const failedIds = ids.filter((id) => held.has(id) || !updated.has(id)); if (mailboxId !== undefined) { const mailbox = expectResult>( @@ -1403,7 +1658,12 @@ export class JmapMailProvider implements MailProvider { return { updatedIds, failedIds }; } + /** + * 한 통 삭제 — 휴지통이 아니면 휴지통으로, 휴지통에만 있으면 영구 삭제(조건부). 확인 요청에 그 메일의 제출을 함께 묻는다 — + * 붙잡힌 메일(9단계)이면 MAIL_015, 제출을 읽지 못하거나 다 받았는지 모르면 MAIL_001 (어느 쪽이든 아무것도 바꾸지 않는다) + */ async deleteMessage(user: string, id: string): Promise { + const now = Date.now(); const lookup = await this.jmap.call( user, (accountId) => [ @@ -1417,11 +1677,19 @@ export class JmapMailProvider implements MailProvider { { accountId, ids: null, properties: ['id', 'role'] }, 'm', ], + ...submissionCalls( + accountId, + heldEmailsFilter([id], now), + HELD_GUARD_CALLS, + ), ], { invalidRequestCode: 'RES_001' }, ); const email = this.requireEmail(lookup, 'g'); + const held = readHeldEmails(lookup, HELD_GUARD_CALLS, now); + if (held.ids.size > 0) throw heldSendLocked('delete'); + if (!held.complete) throw heldOverLimit(1); const { list: mailboxes = [] } = expectResult>( lookup, 'm', @@ -1558,6 +1826,7 @@ export class JmapMailProvider implements MailProvider { * - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고) * - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001 * - 휴지통이 없으면 MAIL_001 (지우지 않는다) + * - 붙잡힌 메일(9단계)은 건너뛴다(skippedIds) — 확인 요청에 계정의 기다리는 제출을 함께 묻는다. 읽지 못하면 아무것도 지우지 않고 MAIL_001 * id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 확인 요청 전체를 거부하면 VAL_001 (지우지 않았다) */ async deleteFromTrash( @@ -1569,6 +1838,7 @@ export class JmapMailProvider implements MailProvider { attempt < CONDITIONAL_DESTROY_ATTEMPTS; attempt += 1 ) { + const now = Date.now(); const lookup = await this.jmap.call( user, (accountId) => [ @@ -1582,6 +1852,11 @@ export class JmapMailProvider implements MailProvider { { accountId, ids: null, properties: ['id', 'role'] }, 'm', ], + ...submissionCalls( + accountId, + heldSubmissionFilter(now), + HELD_GUARD_CALLS, + ), ], { invalidRequestCode: 'VAL_001' }, ); @@ -1606,10 +1881,26 @@ export class JmapMailProvider implements MailProvider { const byId = new Map( (found.list ?? []).map((email) => [email.id, email]), ); + const inTrash = ids.filter( + (id) => byId.has(id) && byId.get(id)?.mailboxIds?.[trashId] === true, + ); + // 휴지통에 든 것이 없으면 지울 것도 없다 — 제출을 읽지 못했어도 그대로 건너뛴다 + const held = + inTrash.length === 0 + ? new Set() + : await heldAmong( + readHeldEmails(lookup, HELD_GUARD_CALLS, now), + inTrash, + this.heldLookup(user), + ); + if (held.size > 0) { + this.warnHeldSkipped(user, held.size, '휴지통에서 지우지'); + } const destroy: string[] = []; const untrash: string[] = []; for (const id of ids) { const email = byId.get(id); + if (held.has(id)) continue; const removal = email ? removalFrom(inMailboxes(email), trashId) : null; if (removal === 'destroy') destroy.push(id); else if (removal === 'detach') untrash.push(id); @@ -1668,6 +1959,12 @@ export class JmapMailProvider implements MailProvider { ): Promise { const startedAt = Date.now(); const budgetLeft = () => SEND_TOTAL_BUDGET_MS - (Date.now() - startedAt); + // 만든 메일을 지우는 제한 시간 — 남은 시간 안에서(기본 10초까지), 남지 않았어도 SEND_CLEANUP_MIN_MS 는 (응답이 56초를 넘지 않게) + const cleanupTimeoutMs = () => + Math.max( + SEND_CLEANUP_MIN_MS, + Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()), + ); // 1) 준비 — 메일함 역할, 발신 ID, (답장이면) 원 메일의 스레드 헤더 const session = await this.jmap.getSession(user); @@ -1743,10 +2040,20 @@ export class JmapMailProvider implements MailProvider { } // 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다. - // 발송 중 키워드로 사용자의 임시 저장본 목록에 보이지 않게 하고, 옮길 때 뗀다 + // 발송 중 키워드로 사용자의 임시 저장본 목록에 보이지 않게 하고, 옮길 때 뗀다. + // 제출에는 늘 envelope 을 직접 준다(받는 사람 · 참조 · 숨은참조 — 붙잡는 발송은 MAIL FROM 에 붙잡는 매개변수, 9단계). + // 예약은 사본에 예약 표시를 붙인다(옮겨도 남는다) + const { hold } = message; const draft = { mailboxIds: { [draftsId]: true }, - keywords: { $draft: true, $seen: true, [INTERNAL_SEND_KEYWORD]: true }, + keywords: { + $draft: true, + $seen: true, + [INTERNAL_SEND_KEYWORD]: true, + ...(hold?.kind === 'scheduled' + ? { [SCHEDULED_SEND_KEYWORD]: true } + : {}), + }, // 중복 발송 방지 — 같은 키의 재시도는 같은 Message-ID 로 이 메일을 다시 찾는다 ...(message.messageId ? { messageId: [message.messageId] } : {}), ...(message.fingerprint @@ -1768,26 +2075,21 @@ export class JmapMailProvider implements MailProvider { ...bodyProperties(message, inline), }; + // 제출의 envelope — 받는 사람은 받는 사람 · 참조 · 숨은참조의 주소(엔진이 머리글로 정하는 것과 같은 목록). + // Bcc 머리글은 엔진이 보낼 때 뗀다 — 보낸 사본에만 남는다 (envelope.ts 머리말) + const plan: SubmissionPlan = { + identityId: identity.id, + from: identity.email ?? session.username, + parameters: hold ? holdParameters(hold) : null, + draftsId, + sentId, + }; + const rcptTo = envelopeRecipients(message); const responses = await this.jmap.call( user, (accountId) => [ ['Email/set', { accountId, create: { draft } }, 'e'], - [ - 'EmailSubmission/set', - { - accountId, - create: { send: { identityId: identity.id, emailId: '#draft' } }, - onSuccessUpdateEmail: { - '#send': { - [`mailboxIds/${draftsId}`]: null, - [`mailboxIds/${sentId}`]: true, - 'keywords/$draft': null, - [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, - }, - }, - }, - 's', - ], + submitCall(accountId, plan, '#draft', rcptTo), ], // 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다 (발송 전체 시간 안에서) { invalidRequestCode: 'VAL_001', timeoutMs: sendTimeoutMs }, @@ -1813,49 +2115,67 @@ export class JmapMailProvider implements MailProvider { }); } - const submission = findResult(responses, 's', 'EmailSubmission/set'); - const submitted = submission.ok - ? (submission.args as JmapSetResult) - : undefined; - const send = submitted?.created?.send; - if (!send) { - // 보내지 못한 초안이 임시보관함에 남지 않게 지운다 - await this.discardDraft(user, draftId); - throw jmapFailure( - submission.ok ? firstSetError(submitted?.notCreated) : submission.error, - { method: 'EmailSubmission/set' }, - ); - } - - this.warnIfNotMovedToSent(responses, user); - - // 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 (계약 2절 "받는 사람별 결과") - const delivery = await this.readSendResult( + const submitted = await this.finishSubmission( user, - message, - send.id, - Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS), + plan, + draftId, + rcptTo, + responses, + { hold, budgetLeft, cleanupTimeoutMs }, ); - if (delivery.isEveryoneRejected) { - // 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고 - // (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다. - // 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다 - const code = everyoneRejectedCode(delivery.rejected); + if (submitted === null) { + // 엔진이 받는 사람 주소를 모두 envelope 에서 받지 않았다 — 제출이 하나도 없다(아무에게도 가지 않았다). 만든 메일은 조건 없이 지운다 + // (이 요청이 방금 만든 메일 — 제출하지 않았다). 지운 메일은 같은 키의 재시도에 걸리지 않는다 — 다시 보내면 새로 보낸다 + const { rejected } = readDeliveryResult(message, [], []); this.logger.warn( - `[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`, + `[MAIL_010]${traceTag()} 엔진이 받는 사람 주소를 모두 받지 않아 보내지 않았습니다 — 만든 메일을 지웁니다: user=${maskEmail(user)}, ${describeRejections(rejected)}`, ); const discarded = await this.discardQuietly( user, draftId, - '받는 사람이 모두 받지 않은 보낸 사본', + '받는 사람 주소를 엔진이 모두 받지 않은 메일', { - remains: '보낸편지함에 한 통이 남습니다', - timeoutMs: Math.max( - 1, - Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()), - ), + remains: + '발송 중 표시가 붙은 초안 한 통이 남습니다(목록에는 보이지 않습니다)', + timeoutMs: cleanupTimeoutMs(), }, ); + throw new BusinessException('MAIL_010', { + context: { + reason: 'all-recipients-refused-in-envelope', + rejected: rejected.length, + copyDiscarded: discarded, + }, + }); + } + const { send } = submitted; + this.warnIfNotMovedToSent(submitted.responses, user); + + // 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 · envelope 에서 받지 않은 주소 (계약 2절 "받는 사람별 결과") + const result = await this.readSendResult( + user, + message, + send.id, + Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS), + submitted.rcptTo, + ); + const { delivery } = result; + if (delivery.isEveryoneRejected) { + // 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고 + // (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다. + // 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다. + // 붙잡은 제출이 아직 기다리고 있으면(pending) 먼저 취소한다 — 풀리는 시각에 다시 보내지 않게 + // (모두 5xx 거절이면 엔진이 붙잡아도 바로 final 이라 취소할 것이 없다 — 2026-10-29 확인) + const code = everyoneRejectedCode(delivery.rejected); + this.logger.warn( + `[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`, + ); + const discarded = await this.discardRejectedSend( + user, + draftId, + hold && result.undoStatus === UNDO_PENDING ? (send.id ?? null) : null, + cleanupTimeoutMs(), + ); throw new BusinessException(code, { context: { reason: 'all-recipients-rejected', @@ -1872,9 +2192,189 @@ export class JmapMailProvider implements MailProvider { return { id: draftId, rejectedRecipients: toRejectedRecipients(delivery.rejected), + pendingSend: hold ? submittedPendingSend(hold, send, startedAt) : null, }; } + /** + * 제출을 마친다 — 엔진이 envelope 에서 받지 않은 주소를 빼며 (envelope.ts 머리말). 처음 제출(초안을 만든 요청)의 결과에서 시작해, + * 거절 설명에 적힌 우리 주소 하나(addressNamedIn)를 받지 않은 사람으로 빼고 **같은 메일**을 나머지에게 다시 낸다 — 붙잡는 매개변수는 그대로. + * 엔진은 한 번에 주소 하나만 알려 주므로 차례마다 하나가 빠진다 — 다시 내기는 MAX_ENVELOPE_RESUBMITS 차례까지(9단계 리뷰 2 — + * 한 요청이 엔진 제출을 받는 사람 수만큼 시키지 않게). + * 차례마다 실패를 다루는 법은 처음 제출과 같다(9단계 리뷰 2 — 남는 것이 같게): + * - 엔진의 답이 거절(메서드 오류 · SetError)이면 만든 메일을 지우고 던진다 + * - 엔진이 요청을 실행하지 않았다고 답했으면(HTTP 4xx · 429 — isRequestRefused) 아무것도 나가지 않았다 — 만든 메일을 지우고 던진다 + * (처음 제출이면 초안도 만들어지지 않았다 — 남는 것이 없다) + * - 결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답)는 지우지 않고 던진다 — 나갔을 수 있다. + * 같은 키의 재시도가 맡는다(findSentMessage — 제출이 없는 초안은 지우고 새로 보낸다) + * 지우기는 발송 전체 시간 안에서(context.cleanupTimeoutMs — 응답이 56초를 넘지 않게) + * @returns 만든 제출 · 그 응답 · 실제로 낸 받는 사람. 남은 사람이 없으면 null (부른 쪽이 MAIL_010 — 만든 메일은 부른 쪽이 지운다) + * @throws 설명에 우리 주소가 없는 거절 · 그 밖의 거절은 지금처럼 오류 표대로(붙잡을 시각의 거절은 VAL_001). + * 받지 않는 주소가 상한보다 많으면 VAL_001, 다시 낼 시간이 모자라면 MAIL_001 — 둘 다 만든 메일을 지운다(아직 아무것도 제출하지 않았다 — + * 같은 키로 다시 보내면 새로 보낸다) + */ + private async finishSubmission( + user: string, + plan: SubmissionPlan, + draftId: string, + firstRcptTo: readonly string[], + firstResponses: JmapInvocation[], + context: { + readonly hold?: SendHold; + readonly budgetLeft: () => number; + readonly cleanupTimeoutMs: () => number; + }, + ): Promise<{ + readonly send: CreatedSubmission; + readonly responses: JmapInvocation[]; + readonly rcptTo: readonly string[]; + } | null> { + let rcptTo = firstRcptTo; + let responses = firstResponses; + let resubmits = 0; + let refused = 0; + const discard = () => + this.discardDraft(user, draftId, context.cleanupTimeoutMs()); + for (;;) { + const result = findResult(responses, 's', 'EmailSubmission/set'); + const set = result.ok ? (result.args as JmapSetResult) : undefined; + // 만든 제출 — 엔진이 정한 보낼 시각 · 상태를 함께 준다(id · sendAt · undoStatus — 2026-10-29 확인) + const send = set?.created?.send; + if (send) { + if (resubmits > 0) { + // 뺀 주소(가린 것)는 부른 쪽의 받는 사람별 결과 로그에 함께 남는다 — 여기서는 차례 수만 한 줄로 + this.logger.log( + `${traceTag()} 엔진이 envelope 의 주소 ${refused}개를 받지 않아 빼고 ${resubmits}차례 다시 제출했습니다: user=${maskEmail(user)}, 받는 사람 ${rcptTo.length}명`, + ); + } + return { send, responses, rcptTo }; + } + + const failure = result.ok ? firstSetError(set?.notCreated) : result.error; + const named = isEnvelopeRefusal(failure) + ? addressNamedIn(failure?.description, rcptTo) + : null; + if (named === null) { + // 보내지 못한 초안이 임시보관함에 남지 않게 지운다 + await discard(); + if (context.hold && failure?.type === FORBIDDEN_MAIL_FROM) { + // 엔진이 붙잡을 시각을 받지 않았다(한도 — 지금 7일) — 우리 검사(7일 안)를 지났으니 엔진 설정이 더 짧다 + throw new BusinessException('VAL_001', { + context: { + reason: 'send-hold-refused-by-engine', + hold: context.hold.kind, + }, + }); + } + throw jmapFailure(failure, { method: 'EmailSubmission/set' }); + } + + // 엔진이 받지 않은 주소 — 받지 않은 사람(not-found)이다. 설명 글 · 주소는 여기서 남기지 않는다 + const refusedKey = envelopeKey(named); + rcptTo = rcptTo.filter((address) => envelopeKey(address) !== refusedKey); + refused += 1; + if (rcptTo.length === 0) return null; + + if (resubmits >= MAX_ENVELOPE_RESUBMITS) { + // 받지 않는 주소가 너무 많다 — 더 내지 않는다 (아직 제출한 것이 없어 만든 메일을 지워도 된다). 주소는 개수만 + await discard(); + throw new BusinessException('VAL_001', { + context: { + reason: 'too-many-refused-addresses', + refused, + resubmits, + left: rcptTo.length, + }, + }); + } + // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 다시 내지 않는다 (아직 제출한 것이 없어 만든 메일을 지워도 된다) + const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.budgetLeft()); + if (timeoutMs < MIN_SEND_WINDOW_MS) { + await discard(); + throw new BusinessException('MAIL_001', { + context: { + reason: 'send-budget-exhausted', + leftMs: timeoutMs, + resubmits, + }, + }); + } + resubmits += 1; + const remaining = rcptTo; + try { + responses = await this.jmap.call( + user, + (accountId) => [submitCall(accountId, plan, draftId, remaining)], + // 처음 제출과 같게 — 엔진이 요청을 해석하지 못하면 VAL_001 + { invalidRequestCode: 'VAL_001', timeoutMs }, + ); + } catch (error) { + // 엔진이 실행하지 않았다고 답했다 — 만든 메일을 지운다. 결과를 모르는 실패는 남긴다 (머리말) + if (isRequestRefused(error)) await discard(); + throw error; + } + } + } + + /** + * 받는 사람이 모두 받지 않은 발송의 보낸 사본을 지운다 — 붙잡은 제출이 아직 기다리고 있으면(submissionId) 같은 요청에서 **먼저** 취소한다 + * (메서드는 차례대로 돈다). 취소를 엔진이 받지 않아도 사본은 지운다 — 둘 다 할 수 있는 만큼 하고 경고만 남긴다 (던지지 않는다). + * 조건 없이 지운다 — 이 요청이 방금 만든 메일이다 (discardQuietly 와 같다) + * @returns 사본을 지웠거나 이미 없으면 true + */ + private async discardRejectedSend( + user: string, + draftId: string, + submissionId: string | null, + timeoutMs: number, + ): Promise { + if (submissionId === null) { + return this.discardQuietly( + user, + draftId, + '받는 사람이 모두 받지 않은 보낸 사본', + { remains: '보낸편지함에 한 통이 남습니다', timeoutMs }, + ); + } + try { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: { [submissionId]: { undoStatus: UNDO_CANCELED } }, + }, + 'c', + ], + ['Email/set', { accountId, destroy: [draftId] }, 'x'], + ], + { timeoutMs }, + ); + const cancel = findResult(responses, 'c', 'EmailSubmission/set'); + const cancelFailure = cancel.ok + ? firstSetError((cancel.args as JmapSetResult).notUpdated) + : cancel.error; + if (cancelFailure) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 붙잡은 발송을 취소하지 못했습니다 — 풀리는 시각에 엔진이 다시 보낼 수 있습니다: user=${maskEmail(user)}, jmapType=${cancelFailure.type ?? 'unknown'}`, + ); + } + const set = expectResult(responses, 'x', 'Email/set'); + const failure = firstSetError(set.notDestroyed); + if (failure && failure.type !== NOT_FOUND) { + throw jmapFailure(failure, { method: 'Email/set' }); + } + return true; + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 붙잡은 발송의 보낸 사본을 지우지 못했습니다 — 보낸편지함에 한 통이 남습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`, + ); + return false; + } + } + async findSentMessage( user: string, messageId: string, @@ -1932,10 +2432,23 @@ export class JmapMailProvider implements MailProvider { ); if (!email) return null; - // 초안으로 남아 있으면 실제로 제출됐는지 확인한다 — 제출 기록이 없으면 보내지 못한 초안이다 + // 그 메일의 제출 — 보냈는지 · 붙잡고 있는지 · 취소했는지 (9단계) + const now = Date.now(); + const submissions = await this.submissionsOf(user, email.id); + // 취소해 임시보관함으로 되돌린 메일(취소한 발송 표시)은 늘 취소한 발송이다 — 제출 기록이 없어졌어도(엔진 정리 · 손으로 지움) 지우지 않는다. + // 사용자가 이어 쓰는 초안이다 (계약 2절 "중복 발송 방지와 함께") + const returnedToDrafts = email.keywords?.[CANCELED_SEND_KEYWORD] === true; + const sent = + !returnedToDrafts && + submissions.some((submission) => submission.undoStatus !== UNDO_CANCELED); + const canceled = returnedToDrafts || (submissions.length > 0 && !sent); + + // 초안으로 남아 있는데 제출 기록이 없으면 보내지 못한 초안이다 — 지운다(다시 보내도 된다). + // 취소한 발송(제출이 모두 canceled · 취소한 발송 표시)은 지우지 않는다 if ( + !canceled && email.keywords?.$draft === true && - !(await this.wasSubmitted(user, email.id)) + submissions.length === 0 ) { await this.discardDraft(user, email.id); return null; @@ -1950,6 +2463,10 @@ export class JmapMailProvider implements MailProvider { typeof fingerprint === 'string' && fingerprint.trim() !== '' ? fingerprint.trim() : null, + canceled, + pendingSend: canceled + ? null + : pendingSendOf(email, heldSendTimes(submissions, now)), }; } @@ -1957,7 +2474,8 @@ export class JmapMailProvider implements MailProvider { * 이미 보낸 메일의 받지 않은 사람 — 같은 키의 재시도가 처음 결과를 돌려줄 때 (계약 2절 "받는 사람별 결과"). * 그 메일의 제출 기록을 찾아(EmailSubmission/query 의 emailIds) 같은 요청에서 결과를 읽고(get — #ids 참조, 발송 요청과 달리 * 엔진이 덧붙이는 응답이 없다) 이 요청의 받는 사람과 맞춘다. 제출 기록이 없거나 읽지 못하면 빈 배열 (읽지 못한 것은 경고). - * 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다 + * 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다. + * 제출의 envelope 도 읽는다 — 그 envelope 에 없는 받는 사람(엔진이 envelope 에서 받지 않은 주소)은 not-found 로, 처음 발송의 답과 같게 (delivery-status.ts) */ async getRejectedRecipients( user: string, @@ -1980,7 +2498,7 @@ export class JmapMailProvider implements MailProvider { name: 'EmailSubmission/query', path: '/ids', }, - properties: ['deliveryStatus'], + properties: ['deliveryStatus', 'envelope'], }, 'g', ], @@ -1991,7 +2509,7 @@ export class JmapMailProvider implements MailProvider { 'EmailSubmission/get', ); return toRejectedRecipients( - readDeliveryResult(recipients, list).rejected, + readDeliveryResult(recipients, list, submittedEnvelope(list)).rejected, ); } catch (error) { this.warnDeliveryUnknown(user, describeFailure(error)); @@ -1999,6 +2517,215 @@ export class JmapMailProvider implements MailProvider { } } + /** + * 예약한 메일 — 보낼 시각이 지금 뒤인 제출(엔진의 sendAt 색인)과 그 메일을 한 요청으로 읽어, 붙잡고 있고(pending) 예약 표시가 있는 것만 + * 보낼 시각 차례로 (계약 2절 "보내기 취소 · 예약 발송"). 메일이 지워졌으면(그사이 다른 프로그램) 빠진다 + */ + async listScheduledMessages(user: string): Promise { + const now = Date.now(); + const responses = await this.jmap.call(user, (accountId) => [ + ...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS), + [ + 'Email/get', + { + accountId, + '#ids': { + resultOf: `${HELD_CALLS}g`, + name: 'EmailSubmission/get', + path: '/list/*/emailId', + }, + properties: SUMMARY_PROPERTIES, + bodyProperties: SUMMARY_BODY_PART_PROPERTIES, + }, + 'g', + ], + ]); + const { list: submissions = [] } = expectResult< + JmapGetResult + >(responses, `${HELD_CALLS}g`, 'EmailSubmission/get'); + const { list: emails = [] } = expectResult>( + responses, + 'g', + 'Email/get', + ); + this.warnIfHeldQueryFull(responses, user); + const times = heldSendTimes(submissions, now); + return emails + .flatMap((email) => { + const pendingSend = pendingSendOf(email, times); + return pendingSend?.kind === 'scheduled' + ? [toSummary(email, pendingSend)] + : []; + }) + .sort( + (a, b) => + Date.parse(a.pendingSend?.sendAt ?? '') - + Date.parse(b.pendingSend?.sendAt ?? '') || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + } + + /** + * 붙잡은 발송 취소 (계약 2절 "보내기 취소 · 예약 발송") — 1) 그 메일과 제출을 읽는다 2) 붙잡고 있는 제출을 취소한다 3) **다른 요청으로** + * 그 메일을 임시보관함의 초안으로 되돌린다(`$draft` · 취소한 발송 표시를 붙이고 발송 중 · 예약 표시를 뗀다). + * - 메일이 없으면 RES_001 + * - 이미 취소했다(제출이 하나 이상이고 모두 canceled — 응답을 받지 못한 재시도 · 옮기지 못한 취소): 취소한 발송 표시가 있으면(되돌리기를 마쳤다) + * 어느 메일함에 있든 그대로 성공 — 옮기지 않는다(그 뒤 휴지통 등으로 옮긴 초안을 되살리지 않게). 표시가 없으면 되돌리기만 다시 한다(못 하면 MAIL_001) + * — 같은 요청을 다시 보내도 같은 결과다 + * - 그 밖에 붙잡고 있는 제출이 없으면(보낸 제출(final) · 보낼 시각이 지나 배달 중 · 제출이 없는 메일) MAIL_014 — 아무것도 바꾸지 않는다 + * - 엔진이 취소를 받지 않으면(그사이 풀림 등) MAIL_014. 거절도 확인(updated 에 그 제출)도 없으면 MAIL_001 — 옮기지 않는다 + * - 취소는 됐는데 옮기지 못하면 한 번 더, 그래도 못 하면 MAIL_001 — 그 메일은 보내지 않은 채 보낸편지함에 남는다(warn). 다시 부르면 되돌리기만 한다 + */ + async cancelPendingSend(user: string, id: string): Promise { + const now = Date.now(); + const lookup = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + [ + 'Email/get', + { + accountId, + ids: [id], + properties: ['id', 'mailboxIds', 'keywords'], + }, + 'g', + ], + ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS), + ], + { invalidRequestCode: 'RES_001' }, + ); + const draftsId = this.requireDraftsMailbox(lookup); + const email = this.requireEmail(lookup, 'g'); + const { list: submissions = [] } = expectResult< + JmapGetResult + >(lookup, `${HELD_CALLS}g`, 'EmailSubmission/get'); + const held = submissions.filter((submission) => isHeld(submission, now)); + const alreadyCanceled = + submissions.length > 0 && + submissions.every( + (submission) => submission.undoStatus === UNDO_CANCELED, + ); + if (held.length === 0 && alreadyCanceled) { + // 이미 취소했다 — 되돌리기까지 마쳤으면(취소한 발송 표시 — 메일함과 표시를 한 번에 바꾼다) 어느 메일함에 있든 그대로 성공: + // 그 뒤 사용자가 휴지통 등으로 옮긴 초안을 늦게 온 · 다시 보낸 취소가 되살리지 않는다 (9단계 리뷰 2). + // 표시가 없으면(앞의 옮기기가 실패 — 보낸편지함에 남았다) 되돌리기만 다시 한다 (보내지 않은 메일이 보낸편지함에 남지 않게) + if (email.keywords?.[CANCELED_SEND_KEYWORD] === true) return; + await this.returnToDrafts(user, id, draftsId); + this.logger.log( + `${traceTag()} 이미 취소한 발송의 메일을 임시보관함으로 되돌렸습니다: user=${maskEmail(user)}, id=${id}`, + ); + return; + } + if (held.length === 0) { + throw new BusinessException('MAIL_014', { + context: { + reason: 'not-a-pending-send', + submissions: submissions.map( + (submission) => submission.undoStatus ?? 'unknown', + ), + }, + }); + } + const kind = + email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo'; + + // 1) 취소 — 엔진이 받지 않으면(cannotUnsend 등 — 그사이 풀렸다) 보낸 것이다 + const canceled = await this.jmap.call(user, (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: Object.fromEntries( + held.map((submission) => [ + submission.id, + { undoStatus: 'canceled' }, + ]), + ), + }, + 'x', + ], + ]); + const set = expectResult( + canceled, + 'x', + 'EmailSubmission/set', + ); + const refused = firstSetError(set.notUpdated); + if (refused) { + throw new BusinessException('MAIL_014', { + context: { + reason: 'engine-refused-cancel', + jmapType: refused.type ?? 'unknown', + canceled: Object.keys(set.updated ?? {}).length, + }, + }); + } + // 엔진이 취소했다고 답한 것만 취소된 것이다(updated 에 그 제출 id) — 거절도 확인도 없으면 모른다: 옮기지 않고 MAIL_001 (9단계 리뷰 2). + // 옮기면 아직 기다리는 발송이 "취소한 초안"이 되어 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다 + const confirmed = set.updated ?? {}; + const unconfirmed = held.filter( + (submission) => + !Object.prototype.hasOwnProperty.call(confirmed, submission.id), + ); + if (unconfirmed.length > 0) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'cancel-not-confirmed', + unconfirmed: unconfirmed.length, + held: held.length, + }, + }); + } + + // 2) 다른 요청 — 임시보관함의 초안으로. 같은 요청에 두면 취소가 실패해도 옮겨진다 + await this.returnToDrafts(user, id, draftsId); + this.logger.log( + `${traceTag()} 붙잡은 발송을 취소하고 임시보관함으로 되돌렸습니다: user=${maskEmail(user)}, id=${id}, ${kind === 'scheduled' ? '예약' : '보내기 취소'}, 보낼 시각=${held + .map((submission) => toUtcIso(submission.sendAt) ?? 'unknown') + .join(',')}`, + ); + } + + /** 취소한 발송의 메일을 임시보관함의 초안으로 — 실패하면 한 번 더, 그래도 못 하면 MAIL_001 */ + private async returnToDrafts( + user: string, + id: string, + draftsId: string, + ): Promise { + const patch = { + mailboxIds: { [draftsId]: true }, + 'keywords/$draft': true, + [`keywords/${CANCELED_SEND_KEYWORD}`]: true, + [`keywords/${SCHEDULED_SEND_KEYWORD}`]: null, + [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, + }; + let reason = 'unknown'; + for (let attempt = 1; attempt <= RETURN_TO_DRAFTS_ATTEMPTS; attempt += 1) { + try { + const responses = await this.jmap.call(user, (accountId) => [ + ['Email/set', { accountId, update: { [id]: patch } }, 'u'], + ]); + const set = expectResult(responses, 'u', 'Email/set'); + const failure = firstSetError(set.notUpdated); + if (!failure) return; + reason = `jmap:${failure.type ?? 'unknown'}`; + } catch (error) { + reason = describeFailure(error); + } + } + this.logger.warn( + `[MAIL_001]${traceTag()} 발송은 취소했지만 메일을 임시보관함으로 옮기지 못했습니다 — 보내지 않은 채 보낸편지함에 남습니다: user=${maskEmail(user)}, id=${id}, reason=${reason}`, + ); + throw new BusinessException('MAIL_001', { + context: { reason: 'canceled-send-not-returned-to-drafts' }, + }); + } + async uploadAttachment( user: string, file: UploadedFile, @@ -2290,6 +3017,17 @@ export class JmapMailProvider implements MailProvider { } const detail = toDetailSource(email); + // 취소해 되돌린 발송(9단계)에 함께 실은 서명 그림(Content-ID `sig-…`)은 첨부가 아니다 — 본문이 서명 그림 주소로 다시 가리킨다 + // (서비스의 restoreSignatureImageSources). 취소한 발송 표시가 있을 때만 — 다른 프로그램이 만든 초안의 `sig-…`(남의 메일을 전달 · 인용한 것)는 + // 지금처럼 첨부다. 그 밖의 부분도 지금처럼 첨부다 + const isCanceledSend = email.keywords?.[CANCELED_SEND_KEYWORD] === true; + const signatureParts = new Set( + isCanceledSend + ? detail.inlineParts + .filter(({ cid }) => parseSignatureImageContentId(cid) !== null) + .map(({ attachmentIndex }) => attachmentIndex) + : [], + ); return { id: detail.id, to: detail.to, @@ -2298,9 +3036,14 @@ export class JmapMailProvider implements MailProvider { subject: detail.subject, textBody: detail.textBody, rawHtmlBody: detail.rawHtmlBody, - attachments: detail.attachments, - inReplyTo: await this.existingReplyTarget(user, email), + attachments: detail.attachments.filter( + (_attachment, index) => !signatureParts.has(index), + ), + inReplyTo: + (await this.existingReplyTarget(user, email)) ?? + (isCanceledSend ? await this.replyTargetInThread(user, email) : null), updatedAt: detail.receivedAt, + isCanceledSend, }; } @@ -2623,13 +3366,21 @@ export class JmapMailProvider implements MailProvider { /** * 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다), - * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다 + * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다. + * 같은 요청에 계정의 기다리는 제출(보낼 시각이 지금 뒤)을 묻는다 — 붙잡힌 메일(9단계)은 옮기지도 지우지도 않는다. + * 읽은 메일이 있는데 제출을 읽지 못하면 MAIL_001 (fail closed — 비었으면 묻지 않은 것과 같다) */ private async readMailboxBatch( user: string, mailboxId: string, options: JmapCallOptions, - ): Promise<{ emails: JmapEmail[]; state: string | null; total: number }> { + ): Promise<{ + emails: JmapEmail[]; + state: string | null; + total: number; + held: HeldEmails; + }> { + const now = Date.now(); const responses = await this.jmap.call( user, (accountId) => [ @@ -2652,6 +3403,11 @@ export class JmapMailProvider implements MailProvider { }, 'g', ], + ...submissionCalls( + accountId, + heldSubmissionFilter(now), + HELD_GUARD_CALLS, + ), ], options, ); @@ -2671,6 +3427,10 @@ export class JmapMailProvider implements MailProvider { emails, state: isState(found.state) ? found.state : null, total: queryTotal(query, ids.length), + held: + emails.length === 0 + ? { ids: new Set(), complete: true } + : readHeldEmails(responses, HELD_GUARD_CALLS, now), }; } @@ -2784,6 +3544,7 @@ export class JmapMailProvider implements MailProvider { query: MessageListQuery, filter: JmapEmailFilter, start: { anchor: string } | { position: number }, + now: number, ): Promise { const { mailboxId } = query; return this.jmap.call( @@ -2823,6 +3584,8 @@ export class JmapMailProvider implements MailProvider { }, 'g', ], + // 기다리는 발송 — 목록과 상관없이 그 계정의 것을 모두(몇 개뿐이다). 실패해도 목록은 그대로 준다 (readHeldSendTimes) + ...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS), ], { invalidRequestCode: mailboxId !== undefined ? 'RES_001' : 'VAL_001' }, ); @@ -2905,21 +3668,31 @@ export class JmapMailProvider implements MailProvider { * 방금 한 제출의 받는 사람별 결과 (계약 2절 "받는 사람별 결과") — 제출 응답(created.send)에는 없어 제출 다음에 따로 묻는다. * 발송 요청 안에서 참조(#ids)로 읽지 않는 이유: onSuccessUpdateEmail 이 같은 호출 id 로 Email/set 응답을 덧붙여 * 엔진이 참조를 풀지 못한다 (invalidResultReference — Stalwart 0.16.27, 2026-10-28 확인). - * 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고). + * 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고). 다만 envelope 에서 빠진 주소(엔진이 받지 않았다)는 + * 읽지 않아도 알므로 그대로 받지 않은 사람이다. 제출의 지금 상태(undoStatus)도 함께 읽는다 — 모두 거절된 붙잡은 발송을 취소할지 정한다 * @param timeoutMs 쓸 수 있는 시간 — SEND_RESULT_MIN_WINDOW_MS 보다 적으면 묻지 않는다 + * @param envelope 실제로 낸 envelope 의 받는 사람 */ private async readSendResult( user: string, recipients: RecipientFields, submissionId: string | undefined, timeoutMs: number, - ): Promise { + envelope: readonly string[], + ): Promise<{ + readonly delivery: DeliveryResult; + readonly undoStatus: string | null; + }> { + const unknown = () => ({ + delivery: readDeliveryResult(recipients, [], envelope), + undoStatus: null, + }); if (!submissionId || timeoutMs < SEND_RESULT_MIN_WINDOW_MS) { this.warnDeliveryUnknown( user, submissionId ? `no-time(${Math.max(0, timeoutMs)}ms)` : 'no-id', ); - return DELIVERED_TO_ALL; + return unknown(); } try { @@ -2928,7 +3701,11 @@ export class JmapMailProvider implements MailProvider { (accountId) => [ [ 'EmailSubmission/get', - { accountId, ids: [submissionId], properties: ['deliveryStatus'] }, + { + accountId, + ids: [submissionId], + properties: ['deliveryStatus', 'undoStatus'], + }, 'd', ], ], @@ -2939,10 +3716,13 @@ export class JmapMailProvider implements MailProvider { 'd', 'EmailSubmission/get', ); - return readDeliveryResult(recipients, list); + return { + delivery: readDeliveryResult(recipients, list, envelope), + undoStatus: list[0]?.undoStatus ?? null, + }; } catch (error) { this.warnDeliveryUnknown(user, describeFailure(error)); - return DELIVERED_TO_ALL; + return unknown(); } } @@ -2953,21 +3733,87 @@ export class JmapMailProvider implements MailProvider { ); } - /** 이 메일로 만든 제출(EmailSubmission)이 있는가 */ - private async wasSubmitted(user: string, emailId: string): Promise { - const responses = await this.jmap.call(user, (accountId) => [ - [ - 'EmailSubmission/query', - { accountId, filter: { emailIds: [emailId] } }, - 's', - ], - ]); - const { ids = [] } = expectResult( - responses, - 's', - 'EmailSubmission/query', + /** + * 메일 id 로 붙잡힌 메일을 묻는다 — 옮기기 · 지우기를 막는 확인(fail closed: 읽지 못하면 MAIL_001 — readHeldEmails). + * 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id 를 더한다(heldEmailsFilter — 지난 제출이 상한을 채우지 않게). + * 메일 id 조건은 엔진의 색인을 탄다. 엔진이 요청 전체를 거부하면 options 의 코드(한 통은 RES_001) + */ + private async heldByEmailIds( + user: string, + emailIds: readonly string[], + options: JmapCallOptions = {}, + ): Promise { + const now = Date.now(); + const responses = await this.jmap.call( + user, + (accountId) => + submissionCalls( + accountId, + heldEmailsFilter(emailIds, now), + HELD_GUARD_CALLS, + ), + options, + ); + return readHeldEmails(responses, HELD_GUARD_CALLS, now); + } + + /** 상한에 닿은 계정 전체 조회를 메일 id 로 보충하는 묻기 (pending-send.ts 의 heldAmong) */ + private heldLookup(user: string, options: JmapCallOptions = {}): HeldLookup { + return (emailIds) => this.heldByEmailIds(user, emailIds, options); + } + + /** + * 후보 가운데 붙잡힌 메일 — 계정의 기다리는 제출(보낼 시각이 지금 뒤 — 엔진의 색인, 요청 하나)로 가리고, 상한에 닿았으면 후보의 메일 id 로 다시 묻는다. + * 읽지 못하면 MAIL_001 (fail closed) + */ + private async heldAmongAccount( + user: string, + candidates: readonly string[], + ): Promise> { + const now = Date.now(); + const responses = await this.jmap.call(user, (accountId) => + submissionCalls(accountId, heldSubmissionFilter(now), HELD_GUARD_CALLS), + ); + return heldAmong( + readHeldEmails(responses, HELD_GUARD_CALLS, now), + candidates, + this.heldLookup(user), + ); + } + + /** 붙잡힌 메일이라 하지 않은 것 — 개수만 경고로 (여러 통 처리 · 메일함 처리는 실패 코드 대신 그 id 를 돌려주거나 거기서 멈춘다) */ + private warnHeldSkipped(user: string, count: number, doing: string): void { + this.logger.warn( + `[MAIL_015]${traceTag()} 보내기를 기다리는 메일이라 ${doing} 않았습니다: user=${maskEmail(user)}, ${count}통`, + ); + } + + /** + * 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull) + * — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX) + */ + private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void { + if (!heldQueryFull(responses, HELD_CALLS)) return; + this.logger.warn( + `[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`, + ); + } + + /** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */ + private async submissionsOf( + user: string, + emailId: string, + ): Promise { + const responses = await this.jmap.call(user, (accountId) => + submissionCalls(accountId, { emailIds: [emailId] }, HELD_CALLS), + ); + return ( + expectResult>( + responses, + `${HELD_CALLS}g`, + 'EmailSubmission/get', + ).list ?? [] ); - return ids.length > 0; } /** 계정의 모든 메일 크기를 더한다 — 엔진이 limit 을 줄여 돌려줘도 실제로 받은 개수만큼 전진한다 */ @@ -3266,6 +4112,52 @@ export class JmapMailProvider implements MailProvider { return list.length > 0 ? id : null; } + /** + * 취소해 되돌린 발송(9단계)의 답장 대상 — 보낸 메일에는 답장 대상 머리글(X-Ara-Reply-To-Id)이 없어, 같은 스레드에서 In-Reply-To 의 + * Message-ID 를 가진 메일을 찾는다. 찾지 못하거나 읽지 못하면 null (스레드 머리글 없이 이어 쓴다 — 던지지 않는다) + */ + private async replyTargetInThread( + user: string, + draft: JmapEmail, + ): Promise { + const wanted = draft.inReplyTo?.[0]; + if (!wanted || !draft.threadId) return null; + try { + const responses = await this.jmap.call(user, (accountId) => [ + ['Thread/get', { accountId, ids: [draft.threadId] }, 't'], + [ + 'Email/get', + { + accountId, + '#ids': { + resultOf: 't', + name: 'Thread/get', + path: '/list/*/emailIds', + }, + properties: ['id', 'messageId'], + }, + 'r', + ], + ]); + const { list = [] } = expectResult>( + responses, + 'r', + 'Email/get', + ); + const original = list.find( + (candidate) => + candidate.id !== draft.id && + (candidate.messageId ?? []).includes(wanted), + ); + return original?.id ?? null; + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} 취소한 발송의 답장 대상을 찾지 못해 스레드 머리글 없이 이어 씁니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`, + ); + return null; + } + } + /** * 메일 안에 함께 실을 그림을 엔진에 올린다 (서명 그림 — 한 장 300KB 이하, 3장까지). * 한꺼번에 올리고 짧게 기다린다 — 일반 첨부 업로드의 제한 시간(120초)을 쓰면 발송이 클라이언트 한도(60초)를 넘는다. @@ -3310,11 +4202,21 @@ export class JmapMailProvider implements MailProvider { return buildThreadHeaders(email); } - private async discardDraft(user: string, draftId: string): Promise { + /** + * 보내지 못한 초안을 지운다 — 실패해도 던지지 않고 경고만 남긴다. + * @param timeoutMs 제한 시간 — 발송 중에는 남은 시간 안에서(finishSubmission), 주지 않으면 기본 10초 + */ + private async discardDraft( + user: string, + draftId: string, + timeoutMs?: number, + ): Promise { try { - const responses = await this.jmap.call(user, (accountId) => [ - ['Email/set', { accountId, destroy: [draftId] }, 'x'], - ]); + const responses = await this.jmap.call( + user, + (accountId) => [['Email/set', { accountId, destroy: [draftId] }, 'x']], + timeoutMs === undefined ? undefined : { timeoutMs }, + ); const set = expectResult(responses, 'x', 'Email/set'); const failure = firstSetError(set.notDestroyed); if (failure) throw jmapFailure(failure, { method: 'Email/set' }); diff --git a/backend/src/modules/mail/jmap/jmap.errors.spec.ts b/backend/src/modules/mail/jmap/jmap.errors.spec.ts index 319e56a..8337fa2 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.spec.ts @@ -2,6 +2,7 @@ import { BusinessException } from '@/common/errors/business.exception'; import { describeFailure, isMissingAttachmentBlob, + isRequestRefused, jmapFailure, mapJmapErrorType, } from './jmap.errors'; @@ -147,3 +148,48 @@ describe('로그용 실패 요약 (describeFailure)', () => { expect(describeFailure('문자열')).toBe('unknown'); }); }); + +describe('엔진이 요청을 실행하지 않았다고 답한 실패 (isRequestRefused — 9단계 리뷰 2)', () => { + const failure = (code: 'MAIL_001' | 'VAL_001' | 'REQ_001', context = {}) => + new BusinessException(code, { context }); + + it('HTTP 4xx(요청 전체 거절 · 그 밖의 4xx) · 429 는 실행하지 않은 것이다', () => { + expect( + isRequestRefused( + failure('VAL_001', { + reason: 'request-rejected', + jmapType: 'notRequest', + }), + ), + ).toBe(true); + expect( + isRequestRefused( + failure('MAIL_001', { reason: 'http-error', call: 'api', status: 400 }), + ), + ).toBe(true); + expect( + isRequestRefused( + failure('MAIL_001', { reason: 'http-error', call: 'api', status: 413 }), + ), + ).toBe(true); + expect( + isRequestRefused( + failure('REQ_001', { reason: 'mail-engine-rate-limit', call: 'api' }), + ), + ).toBe(true); + }); + + it('시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 · 코드 없는 오류는 아니다 — 엔진이 실행했는지 모른다', () => { + for (const context of [ + { reason: 'timeout', call: 'api' }, + { reason: 'network', call: 'api' }, + { reason: 'http-error', call: 'api', status: 502 }, + { reason: 'malformed-response', call: 'api' }, + { reason: 'http-error', call: 'api' }, + ]) { + expect(isRequestRefused(failure('MAIL_001', context))).toBe(false); + } + expect(isRequestRefused(new Error('terminated'))).toBe(false); + expect(isRequestRefused('문자열')).toBe(false); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap.errors.ts b/backend/src/modules/mail/jmap/jmap.errors.ts index 16813a2..bd9784a 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.ts @@ -97,3 +97,25 @@ export function describeFailure(error: unknown): string { : null; return causeCode ? `${error.name}/${causeCode}` : error.name; } + +/** 요청 거절의 HTTP 상태 범위 — 4xx */ +const CLIENT_ERROR_MIN = 400; +const SERVER_ERROR_MIN = 500; + +/** + * 엔진이 요청을 **실행하지 않았다고** 답한 실패인가 (jmap.client.ts 의 call · exchange 가 던진 것): + * HTTP 4xx — 요청 전체 거절(`request-rejected` — invalidRequestCode · 그 밖의 4xx `http-error`) · 429(REQ_001). JMAP 은 이런 요청의 메서드를 하나도 돌리지 않는다(RFC 8620 §3.6.1). + * 시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답은 아니다 — 엔진이 실행했는지 모른다 (발송이면 나갔을 수 있다) + */ +export function isRequestRefused(error: unknown): boolean { + if (!(error instanceof BusinessException)) return false; + if (error.code === 'REQ_001') return true; + const { reason, status } = error.context ?? {}; + if (reason === 'request-rejected') return true; + return ( + reason === 'http-error' && + typeof status === 'number' && + status >= CLIENT_ERROR_MIN && + status < SERVER_ERROR_MIN + ); +} diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index b9b2ba3..8bc77e3 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -10,6 +10,7 @@ import type { MailVacation, MessageDetailSource, MessageSummary, + PendingSend, } from '@/modules/mail/mail.types'; import type { JmapBodyPart, @@ -171,7 +172,13 @@ function trueKeys(record: Record | undefined): string[] { .map(([key]) => key); } -export function toSummary(email: JmapEmail): MessageSummary { +/** + * 요약 — 기다리는 발송(pendingSend — 9단계)은 같은 요청의 제출 기록으로 어댑터가 정해 넘긴다 (jmap/pending-send.ts, 없으면 null) + */ +export function toSummary( + email: JmapEmail, + pendingSend: PendingSend | null = null, +): MessageSummary { return { id: email.id, threadId: email.threadId ?? email.id, @@ -185,6 +192,7 @@ export function toSummary(email: JmapEmail): MessageSummary { isFlagged: email.keywords?.$flagged === true, hasAttachment: hasRealAttachment(email.attachments ?? []), size: email.size ?? 0, + pendingSend, }; } @@ -248,7 +256,16 @@ function joinBodyValues( }; } -export function toDetailSource(email: JmapEmail): MessageDetailSource { +/** + * 상세 원본 — 기다리는 발송 · 스팸메일함에 들었는지는 같은 요청의 제출 · 메일함으로 어댑터가 정해 넘긴다 (없으면 null · false) + */ +export function toDetailSource( + email: JmapEmail, + extra: { + readonly pendingSend?: PendingSend | null; + readonly isInJunkMailbox?: boolean; + } = {}, +): MessageDetailSource { const attachments = downloadableAttachments(email); const inlineParts: InlinePart[] = attachments.flatMap((part, index) => typeof part.cid === 'string' && part.cid.trim() !== '' @@ -267,7 +284,8 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource { const text = joinBodyValues(email, email.textBody, 'text/plain'); return { - ...toSummary(email), + ...toSummary(email, extra.pendingSend ?? null), + isInJunkMailbox: extra.isInJunkMailbox ?? false, cc: toAddresses(email.cc), bcc: toAddresses(email.bcc), replyTo: toAddresses(email.replyTo), diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index ff573c8..d96798a 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -82,6 +82,8 @@ export interface JmapEmail { hasAttachment?: boolean; size?: number; messageId?: string[] | null; + /** In-Reply-To 머리글의 Message-ID (꺾쇠 없이) */ + inReplyTo?: string[] | null; references?: string[] | null; bodyValues?: Record; htmlBody?: JmapBodyPart[]; @@ -146,8 +148,25 @@ export interface JmapDeliveryStatus { /** RFC 8621 §7 EmailSubmission 중 이 어댑터가 읽는 부분 */ export interface JmapEmailSubmission { id: string; + /** 이 제출이 보내는 메일 */ + emailId?: string; + /** + * pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7). + * 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계) + */ + undoStatus?: string | null; + /** 엔진이 보낼(보낸) 시각 — UTCDate. 붙잡은 제출은 풀리는 시각 (HOLDFOR · HOLDUNTIL — 2026-10-29 확인) */ + sendAt?: string | null; /** 받는 사람 주소 → 결과 (엔진이 모르면 null) */ deliveryStatus?: Record | null; + /** + * 이 제출의 envelope — 받는 사람(rcptTo)은 엔진이 받은 주소뿐이다(받지 않는 주소가 있으면 제출 전체를 거절한다 — envelope.ts). + * 엔진은 주소를 소문자 · 국제화 도메인은 `xn--` 꼴로 둔다 (2026-10-29 확인) + */ + envelope?: { + mailFrom?: { email?: string | null } | null; + rcptTo?: { email?: string | null }[] | null; + } | null; } export interface JmapGetResult { @@ -168,12 +187,21 @@ export interface JmapChangesResult { destroyed?: string[]; } +/** set 의 created 항목 — 엔진이 만든 뒤 알려 주는 속성. id 는 늘 오고, 제출(EmailSubmission)은 보낼 시각 · 상태도 준다 (2026-10-29 확인) */ +export interface JmapCreatedObject { + id?: string; + /** EmailSubmission — 엔진이 보낼 시각 (붙잡은 제출은 풀리는 시각) */ + sendAt?: string | null; + /** EmailSubmission — pending · final · canceled */ + undoStatus?: string | null; +} + export interface JmapSetResult { /** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */ oldState?: string | null; /** 이 요청이 바꾼 뒤의 상태 */ newState?: string; - created?: Record; + created?: Record; updated?: Record; destroyed?: string[]; notCreated?: Record; @@ -208,7 +236,10 @@ export type JmapEmailFilter = export interface JmapQueryResult { ids?: string[]; position?: number; + /** calculateTotal 을 물었을 때만 */ total?: number; + /** 엔진이 정한 한도 — 우리가 준 limit 을 줄였거나(RFC 8620 §5.5) 그 한도에 닿아 잘랐을 때만 온다 (Stalwart 0.16.27, 2026-10-29 확인) */ + limit?: number; } /** RFC 9425 Quota */ diff --git a/backend/src/modules/mail/jmap/pending-send.spec.ts b/backend/src/modules/mail/jmap/pending-send.spec.ts new file mode 100644 index 0000000..c9868e1 --- /dev/null +++ b/backend/src/modules/mail/jmap/pending-send.spec.ts @@ -0,0 +1,357 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { + CANCELED_SEND_KEYWORD, + HELD_SUBMISSIONS_MAX, + SCHEDULED_SEND_KEYWORD, + heldAmong, + heldEmailsFilter, + heldQueryFull, + heldSendTimes, + heldSubmissionFilter, + holdParameters, + isHeld, + pendingSendOf, + readHeldEmails, + readHeldSendTimes, + submissionCalls, + submittedPendingSend, +} from './pending-send'; +import type { JmapEmailSubmission, JmapInvocation } from './jmap.types'; + +const NOW = Date.parse('2026-10-29T06:00:00.000Z'); +const LATER = '2026-10-29T07:00:00Z'; +const EARLIER = '2026-10-29T05:00:00Z'; + +const submission = ( + extra: Partial = {}, +): JmapEmailSubmission => ({ + id: 'sb1', + emailId: 'm1', + undoStatus: 'pending', + sendAt: LATER, + ...extra, +}); + +describe('pending-send — 붙잡아 둔 발송(9단계)의 순수 규칙', () => { + it('붙잡는 방법 → 엔진의 MAIL FROM 매개변수 — 보내기 취소는 HOLDFOR 초, 예약은 HOLDUNTIL(소수 초 없는 UTC)', () => { + expect(holdParameters({ kind: 'undo', seconds: 10 })).toEqual({ + HOLDFOR: '10', + }); + expect( + holdParameters({ kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' }), + ).toEqual({ HOLDUNTIL: '2026-11-02T00:30:00Z' }); + }); + + it('기다리는 제출은 보낼 시각(after — 엔진의 색인)으로 찾는다 — 같은 요청에 query → get(#ids), 보낼 시각 차례(정렬 이름은 sentAt), 500개까지, total 도 묻는다', () => { + expect(heldSubmissionFilter(NOW)).toEqual({ + after: '2026-10-29T06:00:00Z', + }); + const calls = submissionCalls('d', { emailIds: ['m1'] }, 'p'); + expect(calls).toEqual([ + [ + 'EmailSubmission/query', + { + accountId: 'd', + filter: { emailIds: ['m1'] }, + // Stalwart 0.16.27 은 sendAt 을 unsupportedSort 로 거절하고 RFC 8621 §7.3 의 이름 sentAt 으로 sendAt 차례를 준다 (2026-10-29 확인) + sort: [{ property: 'sentAt', isAscending: true }], + limit: HELD_SUBMISSIONS_MAX, + // 다 받았는지는 응답의 total 로 정한다 (9단계 리뷰 2) + calculateTotal: true, + }, + 'pq', + ], + [ + 'EmailSubmission/get', + { + accountId: 'd', + '#ids': { + resultOf: 'pq', + name: 'EmailSubmission/query', + path: '/ids', + }, + properties: ['emailId', 'undoStatus', 'sendAt'], + }, + 'pg', + ], + ]); + }); + + it('메일 id 로 다시 묻는 조건 — 계정 전체 조회와 같은 "지금 뒤" 조건에 메일 id 를 더한다 (9단계 리뷰 2 — 지난 제출이 상한을 채우지 않게)', () => { + expect(heldEmailsFilter(['m1', 'm2'], NOW)).toEqual({ + emailIds: ['m1', 'm2'], + after: '2026-10-29T06:00:00Z', + }); + }); + + it('붙잡고 있는 제출 — pending 이고 보낼 시각이 아직 오지 않은 것만 (배달 중인 잠깐의 pending · final · canceled 는 아니다)', () => { + expect(isHeld(submission(), NOW)).toBe(true); + expect(isHeld(submission({ sendAt: EARLIER }), NOW)).toBe(false); + expect(isHeld(submission({ sendAt: '2026-10-29T06:00:00Z' }), NOW)).toBe( + false, + ); + expect(isHeld(submission({ undoStatus: 'final' }), NOW)).toBe(false); + expect(isHeld(submission({ undoStatus: 'canceled' }), NOW)).toBe(false); + expect(isHeld(submission({ sendAt: null }), NOW)).toBe(false); + expect(isHeld(submission({ sendAt: '모름' }), NOW)).toBe(false); + }); + + it('메일 id → 보낼 시각(ISO 8601 UTC) — 붙잡은 것만, 한 메일에 여럿이면 가장 이른 것, 메일 id 없는 제출은 뺀다', () => { + const times = heldSendTimes( + [ + submission({ id: 'a', emailId: 'm1', sendAt: '2026-10-29T08:00:00Z' }), + submission({ id: 'b', emailId: 'm1', sendAt: LATER }), + submission({ id: 'c', emailId: 'm2', undoStatus: 'final' }), + submission({ id: 'd', emailId: undefined }), + submission({ id: 'e', emailId: 'm3', sendAt: EARLIER }), + ], + NOW, + ); + expect([...times]).toEqual([['m1', '2026-10-29T07:00:00.000Z']]); + }); + + it('같은 요청의 제출 읽기 결과 — 엔진이 거절했거나 응답이 없으면 빈 표(목록 · 상세를 실패로 만들지 않는다)', () => { + const ok: JmapInvocation[] = [ + ['EmailSubmission/get', { list: [submission()] }, 'pg'], + ]; + expect([...readHeldSendTimes(ok, 'p', NOW)]).toEqual([ + ['m1', '2026-10-29T07:00:00.000Z'], + ]); + expect( + readHeldSendTimes([['error', { type: 'forbidden' }, 'pg']], 'p', NOW) + .size, + ).toBe(0); + expect(readHeldSendTimes([], 'p', NOW).size).toBe(0); + }); + + it('보이기 경로의 계정 전체 조회가 다 주지 못했는지 — 상한 · 엔진이 줄인 limit 에 닿았거나 total 보다 적으면 true(경고용), 다 받았거나 모르면 · 오류 · 응답 없음은 false', () => { + const ids = (count: number) => + Array.from({ length: count }, (_, index) => `s${index}`); + const full = (answer: Record) => + heldQueryFull([['EmailSubmission/query', answer, 'pq']], 'p'); + expect(full({ ids: ids(HELD_SUBMISSIONS_MAX) })).toBe(true); + expect(full({ ids: ids(HELD_SUBMISSIONS_MAX - 1) })).toBe(false); + // 엔진이 limit 을 줄였다 · total 보다 적게 왔다 (9단계 리뷰 2) + expect(full({ ids: ids(250), limit: 250 })).toBe(true); + expect(full({ ids: ids(250), total: 600 })).toBe(true); + expect(full({ ids: ids(3), total: 3 })).toBe(false); + expect(heldQueryFull([['error', { type: 'serverFail' }, 'pq']], 'p')).toBe( + false, + ); + expect(heldQueryFull([], 'p')).toBe(false); + }); + + describe('막기 경로 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (fail closed)', () => { + /** 조회 · 읽기 응답 — 조회의 ids 는 count 개, total 은 엔진이 준 값(기본: 받은 수 그대로 — Stalwart 0.16.27 은 calculateTotal 이면 준다) */ + const responses = ( + list: JmapEmailSubmission[], + count = list.length, + extra: Record = { total: count }, + ): JmapInvocation[] => [ + [ + 'EmailSubmission/query', + { + ids: Array.from({ length: count }, (_, index) => `s${index}`), + position: 0, + ...extra, + }, + 'hq', + ], + ['EmailSubmission/get', { list }, 'hg'], + ]; + + it('붙잡힌 제출의 메일만 — pending 이고 보낼 시각이 아직 오지 않은 것, 조회가 맞는 제출을 다 주었으면(total) complete', () => { + const held = readHeldEmails( + responses([ + submission({ id: 'a', emailId: 'm1' }), + submission({ id: 'b', emailId: 'm2', undoStatus: 'canceled' }), + submission({ id: 'c', emailId: 'm3', sendAt: EARLIER }), + submission({ id: 'd', emailId: undefined }), + submission({ id: 'e', emailId: 'm4', undoStatus: 'final' }), + ]), + 'h', + NOW, + ); + expect([...held.ids]).toEqual(['m1']); + expect(held.complete).toBe(true); + // 상한(500)을 받았고 더 있다 — 다 받지 못했다. 맞는 것이 꼭 500개였으면 다 받은 것이다 + expect( + readHeldEmails( + responses([], HELD_SUBMISSIONS_MAX, { + total: HELD_SUBMISSIONS_MAX + 1, + }), + 'h', + NOW, + ).complete, + ).toBe(false); + expect( + readHeldEmails(responses([], HELD_SUBMISSIONS_MAX), 'h', NOW).complete, + ).toBe(true); + }); + + it.each<[string, number, Record]>([ + // 엔진의 쿼리 최대가 우리 limit(500)보다 작다 — limit 을 줄여 알리고 그만큼만 준다 (RFC 8620 §5.5) + ['엔진이 limit 을 줄여 알림', 250, { limit: 250, total: 600 }], + // 줄였다고 알리지 않아도 total 이 더 크면 더 있다 + ['엔진이 말없이 적게 줌 (total 이 더 큼)', 250, { total: 600 }], + // total 을 주지 않으면 다 받았는지 모른다 — 닫힌 쪽 + ['total 이 없음', 3, {}], + ['total 은 없고 줄인 limit 에 닿음', 250, { limit: 250 }], + ['처음부터가 아님 (position)', 3, { total: 3, position: 2 }], + ])( + '다 받았는지를 요청한 limit 이 아니라 응답으로 정한다 — %s: complete 가 아니다 (9단계 리뷰 2)', + (_label, count, extra) => { + expect(readHeldEmails(responses([], count, extra), 'h', NOW)).toEqual({ + ids: new Set(), + complete: false, + }); + }, + ); + + it('보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(닫힌 쪽) — 보이기 경로(heldSendTimes)는 그대로 뺀다 (9단계 리뷰 2)', () => { + const unreadable = [ + submission({ id: 'a', emailId: 'm1', sendAt: null }), + submission({ id: 'b', emailId: 'm2', sendAt: '모름' }), + submission({ id: 'c', emailId: 'm3', sendAt: undefined }), + // 상태를 읽지 못한 것도 같은 쪽 — 끝난 제출(final · canceled)만 아니다 + submission({ id: 'd', emailId: 'm4', undoStatus: undefined }), + submission({ id: 'e', emailId: 'm5', undoStatus: '?', sendAt: null }), + // 보낼 시각이 지난 pending(배달 중) · 끝난 제출은 막지 않는다 + submission({ id: 'f', emailId: 'm6', sendAt: EARLIER }), + submission({ + id: 'g', + emailId: 'm7', + undoStatus: 'final', + sendAt: null, + }), + submission({ + id: 'h', + emailId: 'm8', + undoStatus: 'canceled', + sendAt: null, + }), + ]; + expect([...readHeldEmails(responses(unreadable), 'h', NOW).ids]).toEqual([ + 'm1', + 'm2', + 'm3', + 'm4', + 'm5', + ]); + expect(heldSendTimes(unreadable, NOW).size).toBe(0); + }); + + it.each<[string, JmapInvocation[]]>([ + [ + '조회가 오류', + [ + ['error', { type: 'serverFail' }, 'hq'], + ['error', { type: 'serverFail' }, 'hg'], + ], + ], + [ + '읽기가 오류', + [ + ['EmailSubmission/query', { ids: [] }, 'hq'], + ['error', { type: 'forbidden' }, 'hg'], + ], + ], + ['응답 없음', []], + ])( + '읽지 못하면(%s) 던진다 — MAIL_001 (보이기 경로의 readHeldSendTimes 처럼 삼키지 않는다)', + (_label, broken) => { + let thrown: unknown; + try { + readHeldEmails(broken, 'h', NOW); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(BusinessException); + expect((thrown as BusinessException).code).toBe('MAIL_001'); + expect((thrown as BusinessException).context).toMatchObject({ + reason: 'held-send-check-failed', + }); + }, + ); + + it('후보 가운데 붙잡힌 메일 — 계정 전체 조회가 다 봤으면 다시 묻지 않는다', async () => { + const lookup = jest.fn(); + await expect( + heldAmong( + { ids: new Set(['m1', 'm9']), complete: true }, + ['m1', 'm2'], + lookup, + ), + ).resolves.toEqual(new Set(['m1'])); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('계정 전체 조회가 상한에 닿았으면 후보의 메일 id 로 다시 묻는다 — 그것도 닿으면 MAIL_001', async () => { + const lookup = jest + .fn() + .mockResolvedValueOnce({ ids: new Set(['m2']), complete: true }); + await expect( + heldAmong( + { ids: new Set(['m1']), complete: false }, + ['m1', 'm2', 'm3'], + lookup, + ), + ).resolves.toEqual(new Set(['m1', 'm2'])); + expect(lookup).toHaveBeenCalledWith(['m1', 'm2', 'm3']); + + lookup.mockResolvedValueOnce({ ids: new Set(), complete: false }); + await expect( + heldAmong({ ids: new Set(), complete: false }, ['m4'], lookup), + ).rejects.toMatchObject({ code: 'MAIL_001' }); + // 후보가 없으면 묻지 않는다 + lookup.mockClear(); + await expect( + heldAmong({ ids: new Set(), complete: false }, [], lookup), + ).resolves.toEqual(new Set()); + expect(lookup).not.toHaveBeenCalled(); + }); + }); + + it('발송 응답의 pendingSend — 엔진이 만든 제출의 보낼 시각, 없으면 붙잡은 값으로 어림, 이미 final · canceled 면 null', () => { + const undo = { kind: 'undo', seconds: 10 } as const; + expect( + submittedPendingSend(undo, { sendAt: LATER, undoStatus: 'pending' }, NOW), + ).toEqual({ kind: 'undo', sendAt: '2026-10-29T07:00:00.000Z' }); + expect(submittedPendingSend(undo, {}, NOW)).toEqual({ + kind: 'undo', + sendAt: '2026-10-29T06:00:10.000Z', + }); + expect( + submittedPendingSend( + { kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' }, + { sendAt: null }, + NOW, + ), + ).toEqual({ kind: 'scheduled', sendAt: '2026-11-02T00:30:00.000Z' }); + expect(submittedPendingSend(undo, { undoStatus: 'final' }, NOW)).toBeNull(); + expect( + submittedPendingSend(undo, { undoStatus: 'canceled' }, NOW), + ).toBeNull(); + }); + + it('메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 undo, 붙잡은 제출이 없으면 null', () => { + const times = new Map([['m1', '2026-10-29T07:00:00.000Z']]); + expect( + pendingSendOf( + { id: 'm1', keywords: { [SCHEDULED_SEND_KEYWORD]: true } }, + times, + ), + ).toEqual({ kind: 'scheduled', sendAt: '2026-10-29T07:00:00.000Z' }); + expect(pendingSendOf({ id: 'm1', keywords: {} }, times)).toEqual({ + kind: 'undo', + sendAt: '2026-10-29T07:00:00.000Z', + }); + expect(pendingSendOf({ id: 'm2', keywords: {} }, times)).toBeNull(); + }); + + it('표시 이름은 엔진 키워드 규칙($ · 영문 소문자 · -)을 지킨다', () => { + for (const keyword of [SCHEDULED_SEND_KEYWORD, CANCELED_SEND_KEYWORD]) { + expect(keyword).toMatch(/^\$[a-z-]+$/); + } + }); +}); diff --git a/backend/src/modules/mail/jmap/pending-send.ts b/backend/src/modules/mail/jmap/pending-send.ts new file mode 100644 index 0000000..5024eff --- /dev/null +++ b/backend/src/modules/mail/jmap/pending-send.ts @@ -0,0 +1,344 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { PendingSend, SendHold } from '@/modules/mail/mail.types'; +import { toJmapUtcDate, toUtcIso } from './jmap.mapper'; +import { expectResult, findResult } from './jmap-results'; +import type { + JmapEmail, + JmapEmailSubmission, + JmapGetResult, + JmapInvocation, + JmapQueryResult, +} from './jmap.types'; + +/** + * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수. 엔진의 FUTURERELEASE(Stalwart 0.16.27, 2026-10-29 확인): + * - 제출(EmailSubmission/set create)의 `envelope.mailFrom.parameters` 에 `HOLDFOR: "<초>"` · `HOLDUNTIL: ""` → `undoStatus: pending`, + * `update { undoStatus: 'canceled' }` 로 취소(배달되지 않는다), 시간이 되면 보낸다(`final`) + * - 붙잡는 매개변수는 직접 준 envelope 의 MAIL FROM 에 싣는다 — 발송은 붙잡든 아니든 늘 envelope 을 직접 준다(받는 사람 · 참조 · 숨은참조의 주소, + * 엔진이 받지 않는 주소 · Bcc 머리글은 envelope.ts 머리말) + * - 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — "기다리는 발송"은 pending 이고 보낼 시각이 아직 오지 않은 것으로 가린다 + * - 기다리는 제출은 보낼 시각(sendAt)으로 찾는다(`filter.after` — 엔진의 색인을 탄다, 제출 기록 462개에 4ms). `undoStatus` 조건은 모든 제출을 + * 훑어 같은 계정에 65~140ms 라 쓰지 않는다 (같은 날 잼). 차례는 보낼 시각 오름차순 — 정렬 이름은 `sentAt` 이다(RFC 8621 §7.3 의 이름 — + * Stalwart 는 `sendAt` 을 unsupportedSort 로 거절하고 `sentAt` 으로 sendAt 차례를 준다, 2026-10-29 확인) + * - 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 — 옮기기 · 지우기 경로는 readHeldEmails · heldAmong 으로 막는다(fail closed — 읽지 못하면 MAIL_001. + * 보낼 시각을 읽지 못한 기다리는 제출도 막고, 다 받았는지는 응답의 total 로 정한다 — 9단계 리뷰 2) + */ + +/** + * 예약 발송 표시 (JMAP 키워드) — 보낸 사본에 붙여 보내기 취소를 기다리는 것과 가른다. 목록 · 화면에는 표시로 내보내지 않는다. + * 보낸 뒤에도 남는다(해가 없다 — 기다리는 발송인지는 제출로 정한다) + */ +export const SCHEDULED_SEND_KEYWORD = '$ara-scheduled'; + +/** + * 취소한 발송을 임시보관함으로 되돌린 초안 표시 (JMAP 키워드) — 그 메일에는 발송의 지문 머리글이 남아 있어(메일은 고칠 수 없다) + * 이 표시가 있어야 사용자의 임시 저장본으로 다룬다 (jmap-mail.provider.ts 의 isUserDraft). 다시 저장하면 새 초안이 되어 표시도 지문도 없어진다 + */ +export const CANCELED_SEND_KEYWORD = '$ara-canceled'; + +/** + * 한 번에 읽는 기다리는 제출 수 — 엔진의 maxObjectsInGet(500). 보낼 시각이 지금 뒤인 제출에는 취소한 예약도 든다(보낼 시각이 7일 안에 남는다) — + * 넘으면(응답의 total 로 안다 — queryCoverage) 보이기 경로는 경고만 남기고(보낼 시각이 이른 것부터 500개), 막기 경로는 메일 id 로 다시 묻는다(heldAmong) + */ +export const HELD_SUBMISSIONS_MAX = 500; + +/** 제출에서 읽는 속성 */ +const SUBMISSION_PROPERTIES = ['emailId', 'undoStatus', 'sendAt']; + +/** 제출 조회의 차례 — 보낼 시각 오름차순 (정렬 이름은 `sentAt` — 머리말) */ +const BY_SEND_TIME = [{ property: 'sentAt', isAscending: true }]; + +const PENDING = 'pending'; +const FINAL = 'final'; +const CANCELED = 'canceled'; + +/** 붙잡는 방법 → 엔진의 MAIL FROM 매개변수 (HOLDUNTIL 은 초 단위 UTC — 소수 초 없이) */ +export function holdParameters(hold: SendHold): Record { + return hold.kind === 'undo' + ? { HOLDFOR: String(hold.seconds) } + : { HOLDUNTIL: toJmapUtcDate(hold.until) }; +} + +/** 지금 이후에 보낼 제출을 묻는 조건 (보낼 시각으로 — 엔진의 색인) */ +export function heldSubmissionFilter(nowMs: number): { after: string } { + return { after: toJmapUtcDate(new Date(nowMs).toISOString()) }; +} + +/** + * 메일 id 로 붙잡힌 제출을 묻는 조건 — 계정 전체 조회(heldSubmissionFilter)와 같은 "지금 뒤" 조건에 메일 id 를 더한다(한 조건 객체 — AND). + * 그 메일들의 지난 제출(final · 지난 취소)이 상한을 채우지 않게 (9단계 리뷰 2 — 엔진은 둘을 함께 받는다, 2026-10-29 확인) + */ +export function heldEmailsFilter( + emailIds: readonly string[], + nowMs: number, +): { emailIds: string[]; after: string } { + return { emailIds: [...emailIds], ...heldSubmissionFilter(nowMs) }; +} + +/** + * 같은 요청에 넣는 제출 조회 · 읽기 — 호출 id 는 `${prefix}q` · `${prefix}g`. + * 조회는 total 도 묻는다(calculateTotal) — 다 받았는지를 요청한 limit 이 아니라 응답으로 정한다(queryCoverage) + */ +export function submissionCalls( + accountId: string, + filter: Record, + prefix: string, +): JmapInvocation[] { + return [ + [ + 'EmailSubmission/query', + { + accountId, + filter, + sort: BY_SEND_TIME, + limit: HELD_SUBMISSIONS_MAX, + calculateTotal: true, + }, + `${prefix}q`, + ], + [ + 'EmailSubmission/get', + { + accountId, + '#ids': { + resultOf: `${prefix}q`, + name: 'EmailSubmission/query', + path: '/ids', + }, + properties: SUBMISSION_PROPERTIES, + }, + `${prefix}g`, + ], + ]; +} + +/** 붙잡고 있는 제출인가 — pending 이고 보낼 시각이 아직 오지 않음 (보이기 경로 — 읽지 못한 보낼 시각은 붙잡지 않은 것으로) */ +export function isHeld( + submission: JmapEmailSubmission, + nowMs: number, +): boolean { + const sendAt = Date.parse(submission.sendAt ?? ''); + return submission.undoStatus === PENDING && sendAt > nowMs; +} + +/** + * 막기 경로의 판정 — 끝난 제출(final · canceled)이 아니고, 보낼 시각이 아직 오지 않았거나 **읽지 못한** 제출 (닫힌 쪽 — 9단계 리뷰 2). + * 보이기 경로의 isHeld 와 다르다: 화면에는 시각을 모르는 기다리는 발송을 싣지 않지만, 옮기기 · 지우기는 "읽지 못하면 바꾸지 않는다". + * 상태(undoStatus)를 읽지 못한 것도 같은 쪽이다. 배달 중인 잠깐의 pending 은 보낼 시각이 지난 값으로 읽히므로 막지 않는다 + */ +function holdsForGuard( + submission: JmapEmailSubmission, + nowMs: number, +): boolean { + const { undoStatus } = submission; + if (undoStatus === FINAL || undoStatus === CANCELED) return false; + const sendAt = Date.parse(submission.sendAt ?? ''); + return Number.isNaN(sendAt) || sendAt > nowMs; +} + +/** 메일 id → 그 메일의 붙잡힌 제출이 보낼 시각(ISO 8601 UTC) — 여럿이면 가장 이른 것 */ +export function heldSendTimes( + submissions: readonly JmapEmailSubmission[], + nowMs: number, +): Map { + const times = new Map(); + for (const submission of submissions) { + const emailId = submission.emailId; + if (!emailId || !isHeld(submission, nowMs)) continue; + const sendAt = toUtcIso(submission.sendAt); + if (sendAt === null) continue; + const known = times.get(emailId); + if (known === undefined || Date.parse(sendAt) < Date.parse(known)) { + times.set(emailId, sendAt); + } + } + return times; +} + +/** + * 같은 요청의 제출 읽기 결과 — 엔진이 거절했으면(메서드 오류 · 응답 없음) 빈 표: 기다리는 발송 표시가 빠질 뿐 목록 · 상세를 실패로 만들지 않는다. + * **보이기 경로 전용**이다 — 옮기기 · 지우기를 막는 데는 쓰지 않는다(실패를 삼켜 통과시킨다 — readHeldEmails 를 쓴다) + */ +export function readHeldSendTimes( + responses: JmapInvocation[], + prefix: string, + nowMs: number, +): Map { + try { + const result = findResult(responses, `${prefix}g`, 'EmailSubmission/get'); + if (!result.ok) return new Map(); + const { list = [] } = result.args as JmapGetResult; + return heldSendTimes(list, nowMs); + } catch { + return new Map(); + } +} + +/** + * 제출 조회가 맞는 제출을 다 주었나 — **응답으로** 정한다(요청한 limit 이 아니라 — 9단계 리뷰 2). 엔진은 자기 최대가 더 작으면 limit 을 줄인다 + * (RFC 8620 §5.5 — 줄였으면 응답에 limit 이 온다. Stalwart 0.16.27 은 limit 에 닿아 잘랐을 때도 limit 을 싣고, calculateTotal 이면 total 을 준다 — + * 2026-10-29 확인). + * - total 이 있으면: 처음부터(position 0) total 만큼 받았으면 'all', 아니면 'partial' + * - total 이 없으면: 응답의 limit(없으면 우리 상한)에 닿았으면 'partial', 닿지 않았으면 'unknown' — 엔진이 말없이 줄였을 수 있다 + */ +function queryCoverage(query: JmapQueryResult): 'all' | 'partial' | 'unknown' { + const count = (query.ids ?? []).length; + if (typeof query.total === 'number') { + return (query.position ?? 0) === 0 && count >= query.total + ? 'all' + : 'partial'; + } + const limit = + typeof query.limit === 'number' + ? Math.min(query.limit, HELD_SUBMISSIONS_MAX) + : HELD_SUBMISSIONS_MAX; + return count >= limit ? 'partial' : 'unknown'; +} + +/** + * 같은 요청의 제출 조회가 다 주지 못했나(상한 · 엔진이 줄인 limit · total 보다 적음) — 더 있다. 보이기 경로가 경고를 남길 때 (던지지 않는다). + * 다 받았는지 모를 때(total 없음)는 경고하지 않는다 — 막기 경로는 그때도 닫힌 쪽이다(readHeldEmails) + */ +export function heldQueryFull( + responses: JmapInvocation[], + prefix: string, +): boolean { + try { + const result = findResult(responses, `${prefix}q`, 'EmailSubmission/query'); + if (!result.ok) return false; + return queryCoverage(result.args) === 'partial'; + } catch { + return false; + } +} + +/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */ +export interface HeldEmails { + /** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */ + readonly ids: ReadonlySet; + /** + * 조회가 맞는 제출을 다 주었다고 응답으로 확인했다(total — queryCoverage) — 계정 전체 조회(heldSubmissionFilter)라면 붙잡힌 메일은 이것이 전부다. + * 모르면 false (닫힌 쪽) + */ + readonly complete: boolean; +} + +/** 메일 id 로 물었는데도 다 받지 못했다 — 가릴 수 없다: 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */ +export function heldOverLimit(candidates: number): BusinessException { + return new BusinessException('MAIL_001', { + context: { reason: 'held-submissions-over-limit', candidates }, + }); +} + +/** 막기 경로가 판정하지 못했다 — 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */ +function heldCheckFailed(cause: unknown): BusinessException { + const code = cause instanceof BusinessException ? cause.code : 'unknown'; + const jmapType = + cause instanceof BusinessException ? cause.context?.jmapType : undefined; + return new BusinessException('MAIL_001', { + context: { + reason: 'held-send-check-failed', + cause: code, + ...(jmapType !== undefined ? { jmapType } : {}), + }, + }); +} + +/** + * 같은 요청의 제출 조회 · 읽기(submissionCalls)로 붙잡힌 메일 — **fail closed**: 조회 · 읽기를 엔진이 거절했거나 응답이 없으면 던진다(MAIL_001), + * 보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(holdsForGuard), 다 받았는지는 응답으로 정한다(queryCoverage — 모르면 complete 가 아니다). + * 옮기기 · 지우기 경로(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다")가 쓴다 — 보이기 경로는 readHeldSendTimes + */ +export function readHeldEmails( + responses: JmapInvocation[], + prefix: string, + nowMs: number, +): HeldEmails { + let query: JmapQueryResult; + let list: JmapEmailSubmission[]; + try { + query = expectResult( + responses, + `${prefix}q`, + 'EmailSubmission/query', + ); + list = + expectResult>( + responses, + `${prefix}g`, + 'EmailSubmission/get', + ).list ?? []; + } catch (error) { + throw heldCheckFailed(error); + } + const ids = new Set(); + for (const submission of list) { + if (submission.emailId && holdsForGuard(submission, nowMs)) { + ids.add(submission.emailId); + } + } + return { ids, complete: queryCoverage(query) === 'all' }; +} + +/** + * 메일 id 로 붙잡힌 메일을 다시 묻는다 (submissionCalls 의 heldEmailsFilter — 계정 전체 조회와 같은 "지금 뒤" 조건 + 메일 id, readHeldEmails) + * — 다 받지 못한 계정 전체 조회의 보충 + */ +export type HeldLookup = (emailIds: readonly string[]) => Promise; + +/** + * 후보 가운데 붙잡힌 메일 — 같은 요청에 덧붙여 읽은 계정 전체 조회(account)로 가린다. 그 조회가 다 주지 못했으면(complete 가 아님) 후보의 메일 id 로 다시 묻는다(lookup). + * 다시 물은 것도 다 받지 못하면 가릴 수 없다 — MAIL_001 (fail closed) + */ +export async function heldAmong( + account: HeldEmails, + candidates: readonly string[], + lookup: HeldLookup, +): Promise> { + const held = new Set(candidates.filter((id) => account.ids.has(id))); + if (account.complete || candidates.length === 0) return held; + const exact = await lookup(candidates); + if (!exact.complete) throw heldOverLimit(candidates.length); + for (const id of candidates) { + if (exact.ids.has(id)) held.add(id); + } + return held; +} + +/** 1초 (밀리초) */ +const SECOND_MS = 1_000; + +/** + * 붙잡아 만든 제출의 기다리는 발송 (발송 응답의 pendingSend) — 보낼 시각은 엔진이 만든 제출에 알려 준 값(없으면 붙잡은 값으로 어림). + * 엔진이 이미 final · canceled 로 답했으면 null + */ +export function submittedPendingSend( + hold: SendHold, + created: { + readonly sendAt?: string | null; + readonly undoStatus?: string | null; + }, + submittedAtMs: number, +): PendingSend | null { + if (created.undoStatus && created.undoStatus !== PENDING) return null; + const sendAt = + toUtcIso(created.sendAt) ?? + (hold.kind === 'undo' + ? new Date(submittedAtMs + hold.seconds * SECOND_MS).toISOString() + : toUtcIso(hold.until)); + return sendAt === null ? null : { sendAt, kind: hold.kind }; +} + +/** 메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 보내기 취소를 기다리는 중(undo) */ +export function pendingSendOf( + email: Pick, + times: ReadonlyMap, +): PendingSend | null { + const sendAt = times.get(email.id); + if (sendAt === undefined) return null; + return { + sendAt, + kind: + email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo', + }; +} diff --git a/backend/src/modules/mail/jmap/sieve-script.spec.ts b/backend/src/modules/mail/jmap/sieve-script.spec.ts index 876f5cd..b37822d 100644 --- a/backend/src/modules/mail/jmap/sieve-script.spec.ts +++ b/backend/src/modules/mail/jmap/sieve-script.spec.ts @@ -1,4 +1,8 @@ -import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import type { + BlockedSender, + MailFilterRule, + MailVacation, +} from '@/modules/mail/mail.types'; import { FILTER_SOURCE_MARKER, NEVER_CONFIGURED_VACATION, @@ -82,6 +86,7 @@ const representative: FilterScriptSource = { }), ], vacation: vacationOn, + blocked: [], }; /** 스크립트의 줄 — 머리 주석 두 줄(설명 · 원본)은 따로 본다 */ @@ -170,6 +175,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( }), ], vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }); expect(text).toContain( 'if allof (header :contains "subject" "a\\"b\\\\c 가$나{}") {', @@ -186,6 +192,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( }), ], vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }); const uploaded = Buffer.from(built.text, 'utf8').toString('utf8'); @@ -200,6 +207,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( buildFilterScript({ rules: [rule('one')], vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }).text, ); expect(lines.slice(2)).toEqual([ @@ -218,6 +226,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( const { text } = buildFilterScript({ rules, vacation: NEVER_CONFIGURED_VACATION, + blocked: [], }); const lines = linesOf(text); expect(lines).toHaveLength(3); @@ -228,7 +237,9 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( describe('부재중', () => { const vacationLines = (vacation: MailVacation) => - linesOf(buildFilterScript({ rules: [], vacation }).text).slice(2); + linesOf( + buildFilterScript({ rules: [], vacation, blocked: [] }).text, + ).slice(2); it('날짜가 없으면 기간 검사 없이 — date 확장도 쓰지 않는다', () => { const lines = vacationLines({ @@ -275,7 +286,11 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( it('꺼져 있으면 부재중이 없다 — 원본에는 남는다', () => { const off = { ...vacationOn, isEnabled: false }; - const { text } = buildFilterScript({ rules: [rule('a')], vacation: off }); + const { text } = buildFilterScript({ + rules: [rule('a')], + vacation: off, + blocked: [], + }); expect(text).not.toContain('vacation :mime'); expect(linesOf(text)[2]).toBe('require ["imap4flags"];'); expect(sourceOf(text)?.vacation).toEqual(off); @@ -289,6 +304,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( subject: '부재 ${x} "중" \\', textBody: '${unicode:41} \\ "따옴표"', }, + blocked: [], }); const sieve = linesOf(text).slice(2).join('\r\n'); expect(sieve).not.toContain('${'); @@ -304,6 +320,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( subject: '가'.repeat(150), textBody: '나'.repeat(600), }, + blocked: [], }); expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8')); // 내용 1,800바이트 → base64 2,400자 + 줄바꿈 · MIME 머리 · 따옴표 @@ -311,3 +328,105 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( expect(built.longestStringBytes).toBeLessThan(4_096); }); }); + +describe('buildFilterScript — 보낸 사람 차단(9단계)', () => { + const at = '2026-10-29T00:00:00.000Z'; + const blocked: BlockedSender[] = [ + { address: 'kim@spam.example', blockedAt: at }, + { address: '@junk.example', blockedAt: at }, + { address: 'lee@spam.example', blockedAt: at }, + ]; + const junkMailbox = { id: 'jk1', name: 'Junk Mail' }; + + it('차단은 스팸 막기 감싸기 밖 맨 앞 — 주소(:all) · 도메인(:domain)을 anyof 로, 스팸메일함에 넣고 멈춘다. 그 뒤가 부재중 · 규칙', () => { + const source: FilterScriptSource = { + rules: [rule('r1')], + vacation: { ...vacationOn, fromDate: null, toDate: null }, + blocked, + }; + const { text } = buildFilterScript(source, { junkMailbox }); + const lines = linesOf(text); + + expect(lines[1]).toBe(filterSourceLine(source)); + expect(lines.slice(2, 8)).toEqual([ + 'require ["fileinto", "imap4flags", "mailbox", "mailboxid", "vacation"];', + 'if anyof (address :all :is "from" ["kim@spam.example", "lee@spam.example"], address :domain :is "from" ["junk.example"]) {', + ' fileinto :mailboxid "jk1" "Junk Mail";', + ' stop;', + '}', + 'if not header :matches "X-Spam-Score" "spam*" {', + ]); + expect(lines[8]).toMatch(/^ {2}vacation :mime /); + // 엔진에 없는 비교 방법(i;unicode-casemap)은 쓰지 않는다 — 기본 비교(ASCII 대소문자 무시) + expect(text).not.toContain(':comparator'); + expect(sourceOf(text)?.blocked).toEqual(blocked); + }); + + it('주소만 · 도메인만이면 그 검사 하나 — anyof 없이', () => { + const only = (list: BlockedSender[]) => + linesOf( + buildFilterScript( + { rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: list }, + { junkMailbox }, + ).text, + ).slice(2); + + expect(only([blocked[0]])).toEqual([ + 'require ["fileinto", "mailbox", "mailboxid"];', + 'if address :all :is "from" ["kim@spam.example"] {', + ' fileinto :mailboxid "jk1" "Junk Mail";', + ' stop;', + '}', + '', + ]); + expect(only([blocked[1]])[1]).toBe( + 'if address :domain :is "from" ["junk.example"] {', + ); + }); + + it('차단만 있으면 스팸 막기 감싸기를 쓰지 않는다 — 켠 규칙 · 부재중이 없을 때', () => { + const { text } = buildFilterScript( + { + rules: [rule('off', { isEnabled: false })], + vacation: NEVER_CONFIGURED_VACATION, + blocked, + }, + { junkMailbox }, + ); + expect(text).not.toContain('X-Spam-Score'); + expect(text).toContain('stop;'); + }); + + it('차단이 없으면 지금과 같다 — 판 1 원본, 차단 검사 · 스팸메일함 없이 (스팸메일함을 받아도)', () => { + const plain = buildFilterScript(representative); + const withJunk = buildFilterScript(representative, { junkMailbox }); + expect(withJunk.text).toBe(plain.text); + expect(plain.text).not.toContain('address :'); + }); + + it('차단이 있는데 스팸메일함을 받지 못하면 만들지 않는다 (어댑터가 그 앞에서 MAIL_001)', () => { + const source: FilterScriptSource = { + rules: [], + vacation: NEVER_CONFIGURED_VACATION, + blocked, + }; + expect(() => buildFilterScript(source)).toThrow(); + expect(() => buildFilterScript(source, { junkMailbox: null })).toThrow(); + }); + + it('스팸메일함 이름의 따옴표 · 역슬래시는 이스케이프한다 — 바이트 수 · 가장 긴 문자열에 차단 목록도 든다', () => { + const many = Array.from({ length: 200 }, (_, i) => ({ + address: `sender${i}@spam.example`, + blockedAt: at, + })); + const built = buildFilterScript( + { rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: many }, + { junkMailbox: { id: 'jk1', name: '스팸 "메일" \\' } }, + ); + expect(built.text).toContain( + 'fileinto :mailboxid "jk1" "스팸 \\"메일\\" \\\\";', + ); + expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8')); + expect(built.bytes).toBeGreaterThan(200 * 'sender0@spam.example'.length); + }); +}); diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts index 890c9b9..4e92e1d 100644 --- a/backend/src/modules/mail/jmap/sieve-script.ts +++ b/backend/src/modules/mail/jmap/sieve-script.ts @@ -1,4 +1,5 @@ import type { + BlockedSender, MailFilterCondition, MailFilterField, MailFilterRule, @@ -15,12 +16,19 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text'; * # 설명 한 줄 * # arasystem-source: {원본 JSON — filter-source.ts} * require [쓰는 확장만]; + * if anyof (address :all … :is "from" [차단한 주소들], address :domain … :is "from" [차단한 도메인들]) { ← 9단계 "보낸 사람 차단" — 있을 때만 + * fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>"; + * stop; + * } * if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다 * 부재중 — 켜져 있으면 (기간은 currentdate, UTC) * 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop } * } * ``` * + * - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다. + * 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고, + * id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절) * - 스팸: 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로 막는다 — 엔진의 **분류 표시**를 본다 (SPAM_VERDICT_HEADER). * `spamtest` 의 점수는 쓰지 않는다: 점수를 정수로 내린 값(10에서 멈춤)이라 기준(엔진 설정 `scoreSpam` — 지금 5)과 상관없는 문턱을 * 우리가 따로 정하게 된다 — 9.70점 스팸이 "10 이상"을 지나 규칙대로 옮겨지고 위조한 보낸 사람에게 부재중 응답까지 나갔다 @@ -70,7 +78,19 @@ const INDENT = ' '; /** 첫 줄 — 엔진의 관리 화면 등에서 이 스크립트를 보는 사람에게 */ const DESCRIPTION_LINE = - '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.'; + '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 · 보낸 사람 차단 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.'; + +/** 차단한 메일이 들어갈 스팸메일함 — id 로 가리키고 없으면 이름으로 (어댑터가 엔진의 메일함 목록에서 찾아 넘긴다) */ +export interface JunkMailboxTarget { + readonly id: string; + readonly name: string; +} + +/** 스크립트를 만들 때 원본 밖에서 정하는 것 */ +export interface FilterScriptOptions { + /** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */ + readonly junkMailbox?: JunkMailboxTarget | null; +} /** * 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인). @@ -233,11 +253,61 @@ function vacationBlock( return lines; } -/** 규칙 · 부재중 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES) */ +/** + * 차단 — 머리글 From 의 주소(`:all`)나 도메인(`:domain`)이 차단 목록에 있으면 스팸메일함으로 넣고 멈춘다 (스팸 막기 감싸기 밖, 맨 앞). + * 주소 · 도메인이 한쪽만 있으면 그 검사 하나, 둘 다면 anyof. + * 비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이라 `:comparator` 를 쓰지 않는다. 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없다 + * (모르는 비교 방법을 받아 주기는 하지만 기대지 않는다 — 2026-10-29 세션의 sieveExtensions 로 확인). 도메인은 ASCII(punycode)로만 받고 + * 주소 · 도메인은 소문자로 저장해 두었다 — 머리글 쪽의 대소문자와 상관없이 맞는다 (실제 배달로 확인 — 계약 2절 "보낸 사람 차단") + */ +function blockSection( + writer: ScriptWriter, + blocked: readonly BlockedSender[], + junk: JunkMailboxTarget, +): string[] { + const addresses = blocked + .filter(({ address }) => !address.startsWith('@')) + .map(({ address }) => address); + const domains = blocked + .filter(({ address }) => address.startsWith('@')) + .map(({ address }) => address.slice(1)); + const list = (values: readonly string[]) => + `[${values.map((value) => writer.quote(value)).join(', ')}]`; + const tests = [ + ...(addresses.length > 0 + ? [`address :all :is "from" ${list(addresses)}`] + : []), + ...(domains.length > 0 + ? [`address :domain :is "from" ${list(domains)}`] + : []), + ]; + writer.use('fileinto', 'mailbox', 'mailboxid'); + return [ + `if ${tests.length === 1 ? tests[0] : `anyof (${tests.join(', ')})`} {`, + indent( + 1, + `fileinto :mailboxid ${writer.quote(junk.id)} ${writer.quote(junk.name)};`, + ), + indent(1, 'stop;'), + '}', + ]; +} + +/** + * 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES). + * 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001) + */ export function buildFilterScript( source: FilterScriptSource, + options: FilterScriptOptions = {}, ): BuiltFilterScript { const writer = new ScriptWriter(); + let blocks: string[] = []; + if (source.blocked.length > 0) { + const junk = options.junkMailbox; + if (!junk) throw new Error('차단 목록을 쓰려면 스팸메일함이 있어야 한다'); + blocks = blockSection(writer, source.blocked, junk); + } const body: string[] = []; if (source.vacation.isEnabled) { body.push(...vacationBlock(writer, source.vacation, 1)); @@ -247,8 +317,15 @@ export function buildFilterScript( } const lines = [DESCRIPTION_LINE, filterSourceLine(source)]; - if (body.length > 0) { - const spamCheck = `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`; + if (blocks.length > 0 || body.length > 0) { + const spamCheck = + body.length > 0 + ? [ + `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`, + ...body, + '}', + ] + : []; const extensions = EXTENSION_ORDER.filter((extension) => writer.extensions.has(extension), ); @@ -258,7 +335,7 @@ export function buildFilterScript( `require [${extensions.map((extension) => writer.quote(extension)).join(', ')}];`, ); } - lines.push(spamCheck, ...body, '}'); + lines.push(...blocks, ...spamCheck); } const text = `${lines.join(CRLF)}${CRLF}`; diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts new file mode 100644 index 0000000..6d9a922 --- /dev/null +++ b/backend/src/modules/mail/mail-block.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + BlockedSenderDto, + MailBlockCreateDto, + MailBlockListDto, + MailBlockRemovedDto, +} from './dto/mail-block.dto'; +import { MailBlockService } from './mail-block.service'; +import { + MAIL_BLOCKS_ROUTE, + MAX_EMAIL_LENGTH, + MAX_MAIL_BLOCKS, +} from './mail.constants'; + +/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */ +const BLOCK_NOTE = + '차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' + + '이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.'; + +// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계) +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다', +) +@Controller(MAIL_BLOCKS_ROUTE) +export class MailBlockController { + constructor(private readonly blocks: MailBlockService) {} + + @Get() + @ApiOperation({ + summary: '차단한 주소', + description: `${BLOCK_NOTE} 최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지. 한 번도 차단하지 않았으면 빈 목록이다.`, + }) + @ApiStandardResponse(MailBlockListDto, { + description: '차단 목록 · 동작하는지', + }) + list(@CurrentUser() user: AuthenticatedUser) { + return this.blocks.list(user); + } + + @Post() + @ApiOperation({ + summary: '보낸 사람 차단 (주소 또는 @도메인)', + description: + `${BLOCK_NOTE} 이미 차단한 주소면 바꾸지 않고 200 으로 그 항목을 돌려준다(새로 차단하면 201). ` + + `내 주소 · 사내 도메인(그 하위 포함) · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과는 400 VAL_001.`, + }) + @ApiStandardResponse(BlockedSenderDto, { + status: HttpStatus.CREATED, + description: '차단한 항목 (이미 있었으면 200 으로 그 항목)', + }) + @ApiErrorResponse( + 'VAL_001', + `VAL_001 형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과 · 모르는 필드 ` + + '/ MAIL_013 필터 · 부재중과 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다', + ) + async add( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailBlockCreateDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { sender, created } = await this.blocks.add(user, dto); + // 새로 차단했으면 201(POST 의 기본), 이미 있던 주소면 200 + if (!created) res.status(HttpStatus.OK); + return sender; + } + + @Delete(':address') + @ApiOperation({ + summary: '차단 풀기', + description: + '경로의 주소 · @도메인은 URL 인코딩한다(대소문자는 가리지 않는다). 이미 받은 메일은 옮기지 않는다.', + }) + @ApiStandardResponse(MailBlockRemovedDto, { + description: '푼 주소 · 도메인', + }) + @ApiErrorResponse('RES_001', '차단한 적 없는 주소 · 형식이 틀린 주소') + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('address') address: string, + ) { + return this.blocks.remove(user, address); + } +} diff --git a/backend/src/modules/mail/mail-block.service.spec.ts b/backend/src/modules/mail/mail-block.service.spec.ts new file mode 100644 index 0000000..dec18b4 --- /dev/null +++ b/backend/src/modules/mail/mail-block.service.spec.ts @@ -0,0 +1,161 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MailBlockService } from './mail-block.service'; +import type { MailProvider } from './mail.provider'; +import type { MailSettings } from './mail.settings'; +import type { BlockedSender } from './mail.types'; + +const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; + +const settings = { + domain: 'arasystem.test', +} as unknown as MailSettings; + +function fakeProvider() { + return { + getMailBlocks: jest.fn().mockResolvedValue({ senders: [], isActive: true }), + addMailBlock: jest.fn((_user: string, sender: BlockedSender) => + Promise.resolve({ sender, created: true }), + ), + removeMailBlock: jest.fn().mockResolvedValue(undefined), + }; +} + +async function failureOf( + run: () => Promise, +): Promise<{ code?: string; reason?: unknown }> { + try { + await run(); + } catch (error) { + return error instanceof BusinessException + ? { code: error.code, reason: error.context?.reason } + : { code: 'NOT_BUSINESS' }; + } + return {}; +} + +describe('MailBlockService — 보낸 사람 차단 (9단계)', () => { + let provider: ReturnType; + let service: MailBlockService; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + provider = fakeProvider(); + service = new MailBlockService( + provider as unknown as MailProvider, + settings, + ); + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + const logged = () => + logSpy.mock.calls.map(([line]) => String(line)).join('\n'); + + it('차단 — 앞뒤 공백을 빼고 소문자로, 차단한 시각은 서버 시계(UTC). 로그의 주소는 가린다', async () => { + const result = await service.add(user, { address: ' Kim@Spam.Example ' }); + + expect(result.created).toBe(true); + expect(result.sender.address).toBe('kim@spam.example'); + expect(result.sender.blockedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ); + expect(provider.addMailBlock).toHaveBeenCalledWith(user.email, { + address: 'kim@spam.example', + blockedAt: result.sender.blockedAt, + }); + expect(logged()).not.toContain('kim@spam.example'); + expect(logged()).not.toContain(user.email); + }); + + it('도메인 차단의 로그는 도메인을 그대로 — 이미 있던 주소면 로그를 남기지 않는다', async () => { + await service.add(user, { address: '@Ads.Example.org' }); + expect(logged()).toContain('도메인=@ads.example.org'); + + logSpy.mockClear(); + provider.addMailBlock.mockResolvedValueOnce({ + sender: { + address: '@ads.example.org', + blockedAt: '2026-10-28T00:00:00.000Z', + }, + created: false, + }); + await expect( + service.add(user, { address: '@ads.example.org' }), + ).resolves.toMatchObject({ created: false }); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + '내 주소(대소문자 무시)', + 'ARA.KIM@arasystem.test', + 'mail-block-own-address', + ], + ['사내 도메인', '@arasystem.test', 'mail-block-internal-domain'], + ['사내 하위 도메인', '@mail.AraSystem.test', 'mail-block-internal-domain'], + [ + '사내 직원 주소', + 'doyun.lee@arasystem.test', + 'mail-block-internal-domain', + ], + [ + '여러 사람이 쓰는 메일 서비스', + '@gmail.com', + 'mail-block-public-mail-domain', + ], + ['모양이 아님', 'not an address', 'malformed-mail-block'], + ])( + '막을 수 없는 대상은 VAL_001 — %s (엔진에 묻지 않는다)', + async (_label, address, reason) => { + expect(await failureOf(() => service.add(user, { address }))).toEqual({ + code: 'VAL_001', + reason, + }); + expect(provider.addMailBlock).not.toHaveBeenCalled(); + }, + ); + + it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소 · 여러 사람이 쓰는 서비스는 그대로 막는다', async () => { + const unconfigured = new MailBlockService( + provider as unknown as MailProvider, + { domain: null } as unknown as MailSettings, + ); + await expect( + unconfigured.add(user, { address: '@arasystem.test' }), + ).resolves.toMatchObject({ created: true }); + expect( + await failureOf(() => unconfigured.add(user, { address: user.email })), + ).toMatchObject({ code: 'VAL_001' }); + expect( + await failureOf(() => unconfigured.add(user, { address: '@naver.com' })), + ).toMatchObject({ code: 'VAL_001' }); + }); + + it('차단 풀기 — 경로의 주소를 차단 모양으로 맞춰 넘기고 그 값을 돌려준다. 모양이 틀리면 RES_001', async () => { + await expect(service.remove(user, ' LEE@Spam.Example ')).resolves.toEqual({ + address: 'lee@spam.example', + }); + expect(provider.removeMailBlock).toHaveBeenCalledWith( + user.email, + 'lee@spam.example', + ); + + expect(await failureOf(() => service.remove(user, 'a b'))).toEqual({ + code: 'RES_001', + reason: 'malformed-mail-block', + }); + expect(provider.removeMailBlock).toHaveBeenCalledTimes(1); + }); + + it('목록은 포트가 준 그대로', async () => { + await expect(service.list(user)).resolves.toEqual({ + senders: [], + isActive: true, + }); + expect(provider.getMailBlocks).toHaveBeenCalledWith(user.email); + }); +}); diff --git a/backend/src/modules/mail/mail-block.service.ts b/backend/src/modules/mail/mail-block.service.ts new file mode 100644 index 0000000..372c25a --- /dev/null +++ b/backend/src/modules/mail/mail-block.service.ts @@ -0,0 +1,91 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import type { MailBlockCreateDto } from './dto/mail-block.dto'; +import { + findBlockProblem, + parseBlockTarget, + type BlockTarget, +} from './mail-blocks'; +import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; +import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; +import type { MailBlockAdded, MailBlockList } from './mail.types'; + +/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */ +function maskedTarget(target: BlockTarget): string { + return target.kind === 'domain' ? target.value : maskEmail(target.value); +} + +/** + * 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다. + * - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001) + * - 500개 · 엔진 규칙 크기(MAIL_013)는 어댑터가 저장하는 그 판으로 본다 + * - 차단한 시각은 서버 시계(UTC) + */ +@Injectable() +export class MailBlockService { + private readonly logger = new Logger(MailBlockService.name); + + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + ) {} + + list(user: AuthenticatedUser): Promise { + return this.provider.getMailBlocks(user.email); + } + + /** 차단을 더한다 — 이미 있으면 그 항목(created: false — 컨트롤러가 200 으로 답한다) */ + async add( + user: AuthenticatedUser, + dto: MailBlockCreateDto, + ): Promise { + const target = parseBlockTarget(dto.address); + if (target === null) { + throw new BusinessException('VAL_001', { + context: { reason: 'malformed-mail-block' }, + }); + } + const problem = findBlockProblem(target, { + self: user.email, + internalDomain: this.settings.domain, + }); + if (problem !== null) { + throw new BusinessException('VAL_001', { + context: { reason: `mail-block-${problem}`, kind: target.kind }, + }); + } + + const result = await this.provider.addMailBlock(user.email, { + address: target.value, + blockedAt: new Date().toISOString(), + }); + if (result.created) { + this.logger.log( + `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + ); + } + return result; + } + + /** 차단을 푼다 — 경로의 주소(URL 인코딩을 푼 값). 모양이 틀리거나 없는 주소면 RES_001 */ + async remove( + user: AuthenticatedUser, + raw: string, + ): Promise<{ address: string }> { + const target = parseBlockTarget(raw); + if (target === null) { + throw new BusinessException('RES_001', { + context: { reason: 'malformed-mail-block' }, + }); + } + await this.provider.removeMailBlock(user.email, target.value); + this.logger.log( + `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + ); + return { address: target.value }; + } +} diff --git a/backend/src/modules/mail/mail-blocks.spec.ts b/backend/src/modules/mail/mail-blocks.spec.ts new file mode 100644 index 0000000..7588c18 --- /dev/null +++ b/backend/src/modules/mail/mail-blocks.spec.ts @@ -0,0 +1,172 @@ +import { PUBLIC_MAIL_DOMAINS } from './mail.constants'; +import { + findBlockProblem, + isBlockableDomain, + parseBlockTarget, +} from './mail-blocks'; + +const CONTEXT = { + self: 'Ara.Kim@arasystem.test', + internalDomain: 'arasystem.test', +}; + +describe('parseBlockTarget — 차단할 주소 · @도메인의 모양', () => { + it.each([ + ['kim@spam.example', { kind: 'address', value: 'kim@spam.example' }], + [' Kim@SPAM.Example ', { kind: 'address', value: 'kim@spam.example' }], + [ + 'news+promo@mail.shop.example', + { kind: 'address', value: 'news+promo@mail.shop.example' }, + ], + // dot-atom 의 atext 기호는 모두 받는다 (프론트 isStrictEmail 과 같다) + [ + "o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example", + { + kind: 'address', + value: "o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example", + }, + ], + ['@spam.example', { kind: 'domain', value: '@spam.example' }], + ['@SPAM.Example', { kind: 'domain', value: '@spam.example' }], + [ + '@mail.sub-domain.example', + { kind: 'domain', value: '@mail.sub-domain.example' }, + ], + [ + '@xn--vg1b002a.example', + { kind: 'domain', value: '@xn--vg1b002a.example' }, + ], + ])('%j → %j', (raw, target) => { + expect(parseBlockTarget(raw)).toEqual(target); + }); + + it.each([ + ['빈 글', ''], + ['@ 뒤가 없음', '@'], + ['점이 없는 도메인', '@localhost'], + ['IP 주소', '@192.168.0.1'], + ['IPv6', '@::1'], + ['숫자만인 최상위', '@spam.123'], + ['- 로 시작하는 낱말', '@-spam.example'], + ['- 로 끝나는 낱말', '@spam-.example'], + ['빈 낱말', '@spam..example'], + ['밑줄', '@spam_x.example'], + ['한글 도메인(xn-- 로 쓴다)', '@뉴스.example'], + ['도메인 끝 점', '@spam.example.'], + ['너무 긴 낱말', `@${'a'.repeat(64)}.example`], + ['너무 긴 도메인', `@${'a.'.repeat(130)}example`], + ['주소가 아님', 'kim'], + ['@ 가 둘', 'kim@spam@example.com'], + ['변수 표시', 'a${x}@spam.example'], + // 따옴표 앞부분 — IsEmail 은 받지만 Sieve 글에 줄바꿈 · 제어 문자가 들어가고 엔진은 따옴표를 뗀 주소와 견준다 (2026-10-29 리뷰) + ['따옴표 앞부분 안의 줄바꿈', '"a\nb"@spam.example'], + ['따옴표 앞부분 안의 CR LF', '"a\r\nb"@spam.example'], + ['따옴표 앞부분 안의 제어 문자', '"a\u0001b"@spam.example'], + ['따옴표 앞부분 안의 빈칸', '"a b"@spam.example'], + ['따옴표 앞부분 안의 탭', '"a\tb"@spam.example'], + ['따옴표 앞부분', '"ab"@spam.example'], + ['@ 앞의 점으로 시작', '.kim@spam.example'], + ['@ 앞의 점으로 끝남', 'kim.@spam.example'], + ['@ 앞의 연달은 점', 'k..im@spam.example'], + ['@ 앞 65자', `${'a'.repeat(65)}@spam.example`], + ['@ 앞의 한글', '김@spam.example'], + [ + '254자 넘음', + `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(63)}.com`, + ], + ])('%s(%j)는 차단 대상이 아니다', (_label, raw) => { + expect(parseBlockTarget(raw)).toBeNull(); + }); + + it('도메인 모양은 마지막 낱말이 숫자만이면(IP 꼴) 받지 않는다', () => { + expect(isBlockableDomain('spam.example')).toBe(true); + expect(isBlockableDomain('1.2.3.4')).toBe(false); + expect(isBlockableDomain('spam.4')).toBe(false); + expect(isBlockableDomain('4spam.example')).toBe(true); + }); +}); + +describe('findBlockProblem — 막을 수 없는 대상', () => { + const target = (raw: string) => { + const parsed = parseBlockTarget(raw); + if (!parsed) throw new Error(`모양이 틀림: ${raw}`); + return parsed; + }; + + it('내 주소 · 사내 도메인(그 하위 포함)의 주소와 도메인은 막을 수 없다', () => { + expect(findBlockProblem(target('ara.kim@arasystem.test'), CONTEXT)).toBe( + 'own-address', + ); + for (const raw of [ + 'doyun.lee@arasystem.test', + 'noreply@mail.arasystem.test', + '@arasystem.test', + '@ARASYSTEM.test', + '@hr.arasystem.test', + ]) { + expect(findBlockProblem(target(raw), CONTEXT)).toBe('internal-domain'); + } + }); + + it('사내 도메인과 끝만 같은 다른 도메인은 막을 수 있다 (점으로 나뉜 하위만 사내)', () => { + expect(findBlockProblem(target('@notarasystem.test'), CONTEXT)).toBeNull(); + expect(findBlockProblem(target('kim@xarasystem.test'), CONTEXT)).toBeNull(); + }); + + it('여러 사람이 쓰는 메일 서비스 목록 — 글자 · 차례 그대로 (짝: frontend src/shared/constants/mail-block.ts 의 PUBLIC_MAIL_DOMAINS)', () => { + expect(PUBLIC_MAIL_DOMAINS).toEqual([ + 'gmail.com', + 'googlemail.com', + 'naver.com', + 'daum.net', + 'hanmail.net', + 'kakao.com', + 'nate.com', + 'empas.com', + 'hanmir.com', + 'korea.com', + 'dreamwiz.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'msn.com', + 'yahoo.com', + 'yahoo.co.kr', + 'icloud.com', + 'me.com', + 'mac.com', + 'aol.com', + 'proton.me', + 'protonmail.com', + 'gmx.com', + 'gmx.net', + 'mail.com', + 'zoho.com', + 'yandex.com', + 'qq.com', + '163.com', + ]); + }); + + it('여러 사람이 쓰는 메일 서비스는 도메인으로는 막을 수 없고 주소로는 된다 (목록 그대로)', () => { + expect(PUBLIC_MAIL_DOMAINS).toHaveLength(30); + for (const domain of PUBLIC_MAIL_DOMAINS) { + expect(findBlockProblem(target(`@${domain}`), CONTEXT)).toBe( + 'public-mail-domain', + ); + expect(findBlockProblem(target(`spammer@${domain}`), CONTEXT)).toBeNull(); + } + // 하위 도메인은 목록에 없으면 막을 수 있다 + expect(findBlockProblem(target('@mail.naver.com'), CONTEXT)).toBeNull(); + }); + + it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소는 그대로 막는다', () => { + const unconfigured = { self: CONTEXT.self, internalDomain: null }; + expect( + findBlockProblem(target('@arasystem.test'), unconfigured), + ).toBeNull(); + expect( + findBlockProblem(target('ara.kim@arasystem.test'), unconfigured), + ).toBe('own-address'); + }); +}); diff --git a/backend/src/modules/mail/mail-blocks.ts b/backend/src/modules/mail/mail-blocks.ts new file mode 100644 index 0000000..d4aca3c --- /dev/null +++ b/backend/src/modules/mail/mail-blocks.ts @@ -0,0 +1,121 @@ +import { isEmail } from 'class-validator'; +import { isIP } from 'net'; + +import { + MAX_EMAIL_LENGTH, + PUBLIC_MAIL_DOMAINS, + SIEVE_VARIABLE_MARKER, +} from './mail.constants'; + +/** + * 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 메일 엔진과 무관한 순수 규칙. + * 요청 검증(dto/mail-block.dto.ts)이 모양을, 서비스가 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스를 이것으로 본다. + * 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 모양 규칙으로 본다 — 어긋나면 깨진 원본이다. + * + * - 주소: 발송의 주소와 같은 규칙(IsEmail · 254자)에 더해 @ 앞은 dot-atom 만(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~ 덩어리를 점으로 — + * 빈칸 · 제어 문자 · 따옴표 없음, 64자) — 소문자로 맞춰 둔다. 따옴표 앞부분(`"a b"@…`)은 IsEmail 이 받지만 Sieve 글에 줄바꿈이 그대로 들어가고 + * 엔진은 따옴표를 뗀 주소와 견주므로 결코 맞지 않는다(2026-10-29 리뷰). 프론트의 isStrictEmail(shared/utils/mail-address.ts)과 같은 @ 앞 규칙이다 + * - 도메인: `@` + 호스트 이름 — 점이 하나 이상, 253자까지, 낱말은 영문 · 숫자 · `-`(앞뒤는 아님) 1~63자, 마지막 낱말이 숫자만은 아님(IP 주소 아님) + * — 국제화 도메인은 `xn--` 꼴로 쓴다(머리글에 오는 모양). 그 도메인에서 오는 메일 모두이고 하위 도메인은 따로다 + * - `${` 는 받지 않는다 — 엔진이 규칙 글 안의 `${…}` 를 늘 변수로 펼쳐 다른 주소를 막게 된다 (계약 2절 "만드는 Sieve") + */ + +/** 차단 대상 — 주소 · 도메인 (value 는 소문자, 도메인은 `@` 를 붙인 모양 — 계약의 BlockedSender.address) */ +export interface BlockTarget { + readonly kind: 'address' | 'domain'; + readonly value: string; +} + +/** 서비스가 막는 까닭 — 로그용 */ +export type BlockProblem = + | 'own-address' + | 'internal-domain' + | 'public-mail-domain'; + +/** 호스트 이름 최대 길이 (RFC 1035) */ +const MAX_HOSTNAME_LENGTH = 253; +/** 호스트 이름 낱말 하나 — 영문 · 숫자 · '-', 앞뒤는 '-' 가 아님, 1~63자 */ +const HOSTNAME_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +/** 숫자만인 낱말 — 마지막 낱말(최상위 도메인)이 이렇다면 IP 주소 꼴이다 */ +const NUMERIC_LABEL = /^\d+$/; +/** + * 주소의 @ 앞 — RFC 5322 dot-atom (atext 덩어리를 점으로 잇는다 — 앞 · 뒤 · 연달은 점 없음, 64자). 덩어리 안에 점이 없어 입력 길이에 비례한다. + * 짝: frontend `src/shared/utils/mail-address.ts` 의 `DOT_ATOM_LOCAL_PART` · `LOCAL_PART_MAX_LENGTH` — 한쪽을 고치면 둘 다 + */ +const DOT_ATOM_LOCAL_PART = + /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/; +const LOCAL_PART_MAX_LENGTH = 64; + +const PUBLIC_DOMAINS: ReadonlySet = new Set(PUBLIC_MAIL_DOMAINS); + +/** 차단할 수 있는 도메인 모양인가 (소문자로 맞춘 값, `@` 없이) */ +export function isBlockableDomain(domain: string): boolean { + if (domain.length === 0 || domain.length > MAX_HOSTNAME_LENGTH) return false; + if (isIP(domain) !== 0) return false; + const labels = domain.split('.'); + if (labels.length < 2) return false; + if (!labels.every((label) => HOSTNAME_LABEL.test(label))) return false; + return !NUMERIC_LABEL.test(labels[labels.length - 1]); +} + +/** 차단할 수 있는 주소의 @ 앞인가 — dot-atom · 64자 (빈칸 · 제어 문자 · 따옴표 없음) */ +function isDotAtomLocalPart(local: string): boolean { + return ( + local.length > 0 && + local.length <= LOCAL_PART_MAX_LENGTH && + DOT_ATOM_LOCAL_PART.test(local) + ); +} + +/** + * 주소 · `@도메인` 글 → 차단 대상 (앞뒤 공백을 빼고 소문자로) — 모양이 틀리면 null. + * 요청 검증 · 경로(DELETE) · 저장해 둔 원본 읽기(jmap/filter-source.ts 의 toBlocked)가 같이 쓴다 — 그래서 원본에 따옴표 앞부분이 있으면 깨진 원본이다 + */ +export function parseBlockTarget(raw: string): BlockTarget | null { + const value = raw.trim().toLowerCase(); + if (value.length === 0 || value.length > MAX_EMAIL_LENGTH) return null; + if (value.includes(SIEVE_VARIABLE_MARKER)) return null; + if (value.startsWith('@')) { + return isBlockableDomain(value.slice(1)) ? { kind: 'domain', value } : null; + } + const at = value.lastIndexOf('@'); + if (at <= 0 || !isDotAtomLocalPart(value.slice(0, at))) return null; + return isEmail(value) ? { kind: 'address', value } : null; +} + +/** 주소 · 도메인 대상의 도메인 (`@` 없이) */ +export function blockTargetDomain(target: BlockTarget): string { + return target.value.slice(target.value.lastIndexOf('@') + 1); +} + +/** 사내 도메인이거나 그 하위 도메인인가 */ +function isInternalDomain(domain: string, internal: string): boolean { + return domain === internal || domain.endsWith(`.${internal}`); +} + +/** + * 막을 수 없는 대상 — 내 주소, 사내 도메인(MAIL_DOMAIN 과 그 하위)의 주소 · 도메인, 여러 사람이 쓰는 메일 서비스의 도메인(주소로는 된다). + * 사내 도메인을 모르면(엔진 설정 없음 — 비운영) 사내 검사는 건너뛴다 + */ +export function findBlockProblem( + target: BlockTarget, + context: { readonly self: string; readonly internalDomain: string | null }, +): BlockProblem | null { + const domain = blockTargetDomain(target); + if ( + target.kind === 'address' && + target.value === context.self.trim().toLowerCase() + ) { + return 'own-address'; + } + if ( + context.internalDomain !== null && + isInternalDomain(domain, context.internalDomain) + ) { + return 'internal-domain'; + } + if (target.kind === 'domain' && PUBLIC_DOMAINS.has(domain)) { + return 'public-mail-domain'; + } + return null; +} diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index eaf0a65..f7208ad 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -11,6 +11,12 @@ export const MAIL_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/; /** 받는 사람(to + cc + bcc) 합계 상한 */ export const MAX_RECIPIENTS = 100; +/** + * 발송 때 메일 서버가 받지 않는 주소(모르는 최상위 도메인 등 — 계약 2절 "받는 사람별 결과")를 빼며 같은 메일을 다시 내는 차례의 상한 (9단계 리뷰 2). + * 서버는 한 번에 주소 하나만 알려 주므로 처음 제출 + 이만큼 — 이보다 하나 많은 주소까지 뺀다. 더 빼야 하면 보내지 않는다(400 VAL_001) + */ +export const MAX_ENVELOPE_RESUBMITS = 20; + /** 제목 최대 길이 */ export const MAX_SUBJECT_LENGTH = 255; @@ -129,6 +135,70 @@ export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/; */ export const SIEVE_VARIABLE_MARKER = '${'; +/** + * 붙잡아 둔 발송(보내기 취소 · 예약) 라우트 경로 (`/api/scheduled-messages`) — 계약 2절 "보내기 취소 · 예약 발송" (9단계) + */ +export const SCHEDULED_MESSAGES_ROUTE = 'scheduled-messages'; + +/** + * 예약 발송 시각의 범위 — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안 (계약 2절 "보내기 취소 · 예약 발송"). 밖이면 400 VAL_001. + * 7일은 엔진 한도다(`x:MtaExtensions` 의 futureRelease — 넘기면 엔진이 forbiddenMailFrom 으로 거절, 2026-10-29 확인). + * 우리가 본 뒤 엔진이 보는 때까지 남은 시간은 줄기만 하므로 이 범위면 엔진 한도 안이다. 붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이다 + */ +export const SCHEDULED_SEND_MIN_LEAD_MS = 5 * 60_000; +export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000; + +/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */ +export const MAIL_BLOCKS_ROUTE = 'mail-blocks'; + +/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */ +export const MAX_MAIL_BLOCKS = 500; + +/** + * 여러 사람이 쓰는 메일 서비스 — 도메인(`@gmail.com`)으로는 막을 수 없다(주소로는 된다). 계약 2절 "보낸 사람 차단". + * ⚠️ 짝: frontend `src/shared/constants/mail-block.ts` 의 `PUBLIC_MAIL_DOMAINS` — 클라이언트도 같은 목록을 먼저 본다. + * 한쪽을 고치면 둘 다 (차례도 같게 둔다 — mail-blocks.spec.ts 가 글자 · 차례를 그대로 본다) + */ +export const PUBLIC_MAIL_DOMAINS: readonly string[] = [ + 'gmail.com', + 'googlemail.com', + 'naver.com', + 'daum.net', + 'hanmail.net', + 'kakao.com', + 'nate.com', + 'empas.com', + 'hanmir.com', + 'korea.com', + 'dreamwiz.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'msn.com', + 'yahoo.com', + 'yahoo.co.kr', + 'icloud.com', + 'me.com', + 'mac.com', + 'aol.com', + 'proton.me', + 'protonmail.com', + 'gmx.com', + 'gmx.net', + 'mail.com', + 'zoho.com', + 'yandex.com', + 'qq.com', + '163.com', +]; + +/** + * 외부 이미지 중계 라우트 경로 (`/api/mail-images/{token}`) — 계약 2절 "외부 이미지 보기" (9단계). + * 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH`(`/api/mail-images/` — 본문 틀의 CSP · 정화가 남기는 주소) — + * 경로를 바꾸면 둘 다. 서버가 넣는 주소의 앞부분은 mail.service.ts 의 `MAIL_IMAGE_PATH_PREFIX` + */ +export const MAIL_IMAGES_ROUTE = 'mail-images'; + /** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */ export const MAX_SUGGESTION_QUERY_LENGTH = 100; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 43ec754..e9eac09 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -9,12 +9,27 @@ import { BusinessException } from '@/common/errors/business.exception'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; +import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; +import { MailImageController } from './images/mail-image.controller'; +import { + MAIL_IMAGE_RESOLVER, + MAIL_IMAGE_TRANSPORT, + MailImageFetcher, +} from './images/mail-image-fetcher'; +import { MailImageProxyService } from './images/mail-image-proxy.service'; +import { MailImageTokens } from './images/mail-image-token'; +import { + DnsMailImageResolver, + NodeMailImageTransport, +} from './images/node-image-transport'; import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; import { MailAttachmentController } from './mail-attachment.controller'; +import { MailBlockController } from './mail-block.controller'; +import { MailBlockService } from './mail-block.service'; import { MailEventsController } from './mail-events.controller'; import { MailFilterController, @@ -39,6 +54,8 @@ import { MessageDeletionController } from './message-deletion.controller'; import { MessageController } from './message.controller'; import { RecipientSuggestionController } from './recipient-suggestion.controller'; import { RecipientSuggestionService } from './recipient-suggestions.service'; +import { ScheduledMessageController } from './scheduled-message.controller'; +import { ScheduledMessagesService } from './scheduled-messages.service'; function readMailSettings(config: ConfigService): MailSettings { return buildMailSettings((key) => config.get(key)); @@ -97,6 +114,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { ContactsModule, // 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천") MailDirectoryModule, + // 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계) + MailUserSettingsModule, ], controllers: [ MailboxController, @@ -106,13 +125,26 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailVacationController, MailFilterController, MailFilterRunController, + MailBlockController, MessageController, MessageDeletionController, + ScheduledMessageController, MailAttachmentController, + MailImageController, DraftController, RecipientSuggestionController, ], providers: [ + // 보낸 사람 차단 (9단계) — 저장은 필터 · 부재중과 같은 엔진 규칙(포트) + MailBlockService, + // 붙잡아 둔 발송 — 예약한 메일 목록 · 취소 (9단계) + ScheduledMessagesService, + // 외부 이미지 중계 (9단계) — 표(서버가 뜰 때 만든 키로 암호화) · 바깥 받기(SSRF 막기) · 직원마다 동시 수 + MailImageTokens, + MailImageFetcher, + MailImageProxyService, + { provide: MAIL_IMAGE_RESOLVER, useClass: DnsMailImageResolver }, + { provide: MAIL_IMAGE_TRANSPORT, useClass: NodeMailImageTransport }, // 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다 JmapFilterScriptStore, // 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다 diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index c0b35a7..72258cf 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -1,10 +1,13 @@ import type { AttachmentContent, + BlockedSender, Correspondence, DraftContent, DraftSource, MailAccountProfile, MailAttachment, + MailBlockAdded, + MailBlockList, Mailbox, MailboxBulkResult, MailboxCreate, @@ -96,6 +99,7 @@ export interface MailProvider { * 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다. * 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다). * 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001. + * 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015 */ deleteMailbox(user: string, id: string): Promise; @@ -105,6 +109,7 @@ export interface MailProvider { /** * 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다. * 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001. + * 붙잡힌 메일(9단계)은 지우지 않는다 — 남은 것이 그것뿐이고 이 요청에서 지운 것이 없으면 MAIL_015 */ emptyMailbox(user: string, id: string): Promise; @@ -123,6 +128,21 @@ export interface MailProvider { /** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */ getMailFilters(user: string): Promise; + /** + * 차단한 보낸 사람 (계약 2절 "보낸 사람 차단" — 9단계) — 최근에 차단한 것부터 · 동작하는지(isActive — 메일 필터의 것과 같은 값). + * 저장 자리는 메일 필터 · 부재중과 같은 엔진 규칙이다 + */ + getMailBlocks(user: string): Promise; + + /** + * 차단을 더한다 — 이미 있으면 바꾸지 않고 그 항목(created: false). 필터 · 부재중은 그대로 두고 필터의 지문(version)을 보지 않는다. + * 이미 MAX_MAIL_BLOCKS(500)개면 VAL_001, 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 대상의 모양 · 막을 수 있는지는 서비스가 검사한 값이다 + */ + addMailBlock(user: string, sender: BlockedSender): Promise; + + /** 차단을 푼다 — 없는 주소면 RES_001. 필터 · 부재중은 그대로 */ + removeMailBlock(user: string, address: string): Promise; + /** * 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive). * 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 — @@ -182,9 +202,15 @@ export interface MailProvider { */ listMessages(user: string, query: MessageListQuery): Promise; - /** 조회만 한다 — 읽음 처리하지 않는다 */ + /** + * 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다 + */ getMessage(user: string, id: string): Promise; + /** + * 한 통 바꾸기 — 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)의 이동 · 스팸 표시(mailboxId · isJunk)는 MAIL_015, + * 붙잡혔는지 확인하지 못하면 MAIL_001 (어느 쪽이든 아무것도 바꾸지 않는다). 읽음 · 중요 표시는 된다 + */ updateMessage( user: string, id: string, @@ -193,7 +219,8 @@ export interface MailProvider { /** * 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로. - * 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001. + * 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001. 이동 · 스팸 표시는 붙잡힌 메일(9단계)에 하지 않는다 — 그 id 도 failedIds + * (붙잡혔는지 확인하지 못하면 아무것도 바꾸지 않고 MAIL_001) */ updateMessages( user: string, @@ -201,13 +228,13 @@ export interface MailProvider { update: MessageUpdate, ): Promise; - /** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */ + /** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 — 붙잡힌 메일(9단계)은 MAIL_015 (아무것도 바꾸지 않는다) */ deleteMessage(user: string, id: string): Promise; /** * 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다. * 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다). - * 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. + * 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. 붙잡힌 메일(9단계)은 건너뛴다(skippedIds) */ deleteFromTrash( user: string, @@ -215,23 +242,43 @@ export interface MailProvider { ): Promise; /** - * @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다) + * @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다), + * 붙잡아 둔 발송이면 그 정보(pendingSend — 9단계) * 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002, * 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004. * 받는 사람을 엔진이 모두 받지 않았으면 보내지 않은 것으로 다룬다 — 이 요청이 만든 보낸 사본을 지우고 - * MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음) + * MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음). + * `message.hold` 가 있으면 엔진이 그만큼 붙잡았다가 보낸다(보내기 취소 · 예약) — 예약은 사본에 예약 표시를 붙인다. + * 엔진이 붙잡을 시각을 받지 않으면(한도) VAL_001 */ sendMessage(user: string, message: OutgoingMessage): Promise; /** * 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key). * 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다). + * 취소한 발송(제출이 모두 canceled — 9단계)은 지우지 않고 canceled 로 돌려준다 (서비스가 MAIL_005 로 답한다) */ findSentMessage( user: string, messageId: string, ): Promise; + /** + * 예약한 메일 (계약 2절 "보내기 취소 · 예약 발송" — GET /api/scheduled-messages) — 예약 표시가 있고 아직 보내지 않은 것만, + * 보낼 시각 차례(같으면 id 차례). 보내기 취소를 기다리는 메일은 들지 않는다 + */ + listScheduledMessages(user: string): Promise; + + /** + * 붙잡은 발송을 취소하고 그 메일을 임시보관함의 초안으로 되돌린다 (보내기 취소 · 예약 취소) — `id` 는 보낸 사본의 메일 id. + * 취소한 **뒤에** 다른 요청으로 옮긴다(같은 요청이면 취소가 실패해도 옮겨진다). 없는 메일 RES_001, + * 이미 보냈거나(엔진이 풀었음) 붙잡은 적이 없는 메일이면 MAIL_014(아무것도 바꾸지 않는다), 엔진이 취소를 확인해 주지 않으면 MAIL_001(옮기지 않는다), + * 취소는 됐는데 옮기지 못하면(한 번 더 한 뒤) MAIL_001. + * 이미 취소한 발송(제출이 모두 canceled)이면 성공 — 되돌리기를 마쳤으면(취소한 발송 표시) 어느 메일함에 있든 옮기지 않고, 마치지 못했으면 되돌리기만 다시 한다 + * (같은 요청을 다시 보내도 같은 결과) + */ + cancelPendingSend(user: string, id: string): Promise; + /** * 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을 * 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다) diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 62d709d..06b44ba 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -5,10 +5,15 @@ import { startCpuTimer } from '@/../test/cpu-time'; import { BusinessException } from '@/common/errors/business.exception'; import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants'; import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service'; +import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types'; +import type { ContactsService } from '@/modules/contacts/contacts.service'; +import type { DirectoryService } from '@/modules/contacts/directory.service'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto'; import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget'; +import { MailImageTokens } from './images/mail-image-token'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, @@ -25,6 +30,7 @@ import type { MailFilterRule, MessageDetailSource, SentMessage, + SentMessageRecord, } from './mail.types'; import type { RecipientSuggestionService } from './recipient-suggestions.service'; import { idempotentMessageId, payloadFingerprint } from './send-idempotency'; @@ -61,7 +67,7 @@ function mockOf(): ProviderMocks[K] { } /** 대역 엔진의 발송 결과 — 모두 받았다 */ -const SENT = { id: 'sent-1', rejectedRecipients: [] }; +const SENT = { id: 'sent-1', rejectedRecipients: [], pendingSend: null }; function fakeProvider(): ProviderMocks { const sendMessage = mockOf<'sendMessage'>(); @@ -110,6 +116,11 @@ function fakeProvider(): ProviderMocks { getDraft: mockOf<'getDraft'>(), deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined), listCorrespondents: mockOf<'listCorrespondents'>(), + getMailBlocks: mockOf<'getMailBlocks'>(), + addMailBlock: mockOf<'addMailBlock'>(), + removeMailBlock: mockOf<'removeMailBlock'>(), + listScheduledMessages: mockOf<'listScheduledMessages'>(), + cancelPendingSend: mockOf<'cancelPendingSend'>(), }; } @@ -172,6 +183,18 @@ async function failureOf( return {}; } +/** 같은 키로 이미 보낸 메일의 기록 — 취소하지 않았고 붙잡혀 있지 않다 */ +const sentRecord = ( + fingerprint: string | null, + overrides: Partial = {}, +): SentMessageRecord => ({ + id: 'sent-1', + fingerprint, + canceled: false, + pendingSend: null, + ...overrides, +}); + const source = ( overrides: Partial = {}, ): MessageDetailSource => ({ @@ -199,6 +222,8 @@ const source = ( isTextBodyTruncated: false, attachments: [], inlineParts: [], + pendingSend: null, + isInJunkMailbox: false, ...overrides, }); @@ -230,13 +255,67 @@ const draftSource = (overrides: Partial = {}): DraftSource => ({ attachments: [], inReplyTo: null, updatedAt: '2026-10-27T01:00:00.000Z', + isCanceledSend: false, ...overrides, }); +/** 9단계 — 보내기 · 읽기 설정과 외부 이미지 "주소록 · 사내 직원" 판정의 대역 */ +interface NinthStepMocks { + userSettings: { + get: jest.Mock, [string]>; + }; + contacts: { hasEmail: jest.Mock, [string, string]> }; + directory: { hasEmail: jest.Mock, [string]> }; + imageTokens: MailImageTokens; +} + +/** + * 기존 발송 테스트는 붙잡지 않는 발송(보내기 취소 0초)을 본다 — 보내기 취소 · 예약은 따로 본다. + * 외부 이미지는 처음 값('ask') + */ +function fakeNinthStep(): NinthStepMocks { + return { + userSettings: { + get: jest + .fn, [string]>() + .mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }), + }, + contacts: { + hasEmail: jest + .fn, [string, string]>() + .mockResolvedValue(false), + }, + directory: { + hasEmail: jest.fn, [string]>().mockResolvedValue(false), + }, + imageTokens: new MailImageTokens(), + }; +} + +function createService( + provider: ProviderMocks, + mailSettings: MailSettings, + signatures: SignatureMocks, + suggestions: { invalidate: jest.Mock }, + ninth: NinthStepMocks, +): MailService { + return new MailService( + provider, + mailSettings, + signatures as unknown as MailSignatureService, + suggestions as unknown as RecipientSuggestionService, + ninth.userSettings as unknown as MailUserSettingsService, + ninth.contacts as unknown as ContactsService, + ninth.directory as unknown as DirectoryService, + ninth.imageTokens, + ); +} + describe('MailService', () => { let provider: ProviderMocks; let signatures: SignatureMocks; let suggestions: { invalidate: jest.Mock }; + let ninth: NinthStepMocks; let service: MailService; let logSpy: jest.SpyInstance; let warnSpy: jest.SpyInstance; @@ -245,12 +324,8 @@ describe('MailService', () => { provider = fakeProvider(); signatures = fakeSignatures(); suggestions = { invalidate: jest.fn() }; - service = new MailService( - provider, - settings, - signatures as unknown as MailSignatureService, - suggestions as unknown as RecipientSuggestionService, - ); + ninth = fakeNinthStep(); + service = createService(provider, settings, signatures, suggestions, ninth); logSpy = jest .spyOn(Logger.prototype, 'log') .mockImplementation(() => undefined); @@ -854,6 +929,7 @@ describe('MailService', () => { isFlagged: false, hasAttachment: false, size: 1, + pendingSend: null, }); provider.updateMessages.mockResolvedValue({ updatedIds: ['m1'], @@ -1077,11 +1153,12 @@ describe('MailService', () => { }); it('메일 도메인 설정이 없으면 사내 주소인지 알 수 없어 전달을 받지 않는다', async () => { - const unconfigured = new MailService( + const unconfigured = createService( provider, { ...settings, domain: null }, - signatures as unknown as MailSignatureService, - suggestions as unknown as RecipientSuggestionService, + signatures, + suggestions, + ninth, ); expect( await failureOf(() => @@ -2164,10 +2241,7 @@ describe('MailService', () => { it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => { const fingerprint = await fingerprintOf(sendDto()); provider.sendMessage.mockClear(); - provider.findSentMessage.mockResolvedValue({ - id: 'sent-1', - fingerprint, - }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); await expect(send()).resolves.toEqual(SENT); expect(provider.sendMessage).not.toHaveBeenCalled(); @@ -2179,12 +2253,16 @@ describe('MailService', () => { const rejectedRecipients = [ { email: 'nobody@arasystem.test', reason: 'not-found' as const }, ]; - provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); provider.getRejectedRecipients.mockResolvedValue(rejectedRecipients); const result = await send(); - expect(result).toEqual({ id: 'sent-1', rejectedRecipients }); + expect(result).toEqual({ + id: 'sent-1', + rejectedRecipients, + pendingSend: null, + }); expect(result).not.toHaveProperty('fingerprint'); expect(provider.sendMessage).not.toHaveBeenCalled(); expect(provider.getRejectedRecipients).toHaveBeenCalledWith( @@ -2204,7 +2282,7 @@ describe('MailService', () => { const everyone = [ { email: 'doyun.lee@arasystem.test', reason: 'not-found' as const }, ]; - provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); provider.getRejectedRecipients.mockResolvedValue(everyone); await expect( @@ -2213,14 +2291,18 @@ describe('MailService', () => { sendDto({ draftId: 'draft-1' }), IDEMPOTENCY_KEY, ), - ).resolves.toEqual({ id: 'sent-1', rejectedRecipients: everyone }); + ).resolves.toEqual({ + id: 'sent-1', + rejectedRecipients: everyone, + pendingSend: null, + }); expect(provider.sendMessage).not.toHaveBeenCalled(); expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1'); }); it('내용이 달라 MAIL_005 로 끝나면 처음 발송의 결과를 읽지 않는다 (제출 기록을 헛되이 읽지 않게)', async () => { const fingerprint = await fingerprintOf(sendDto()); - provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); expect(await codeOf(() => send(sendDto({ subject: '고친 제목' })))).toBe( 'MAIL_005', @@ -2236,10 +2318,7 @@ describe('MailService', () => { expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY)); provider.sendMessage.mockClear(); - provider.findSentMessage.mockResolvedValue({ - id: 'sent-1', - fingerprint, - }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); // 정화로 지워지는 부분만 다르면 같은 메일이다 await expect( @@ -2258,10 +2337,7 @@ describe('MailService', () => { it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => { const fingerprint = await fingerprintOf(sendDto()); provider.sendMessage.mockClear(); - provider.findSentMessage.mockResolvedValue({ - id: 'sent-1', - fingerprint, - }); + provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint)); for (const changed of [ sendDto({ subject: '고친 제목' }), @@ -2275,10 +2351,7 @@ describe('MailService', () => { }); it('지문 없이 남은 메일(헤더가 지워진 경우 등)도 같은 내용인지 알 수 없으므로 MAIL_005', async () => { - provider.findSentMessage.mockResolvedValue({ - id: 'sent-1', - fingerprint: null, - }); + provider.findSentMessage.mockResolvedValue(sentRecord(null)); expect(await codeOf(() => send())).toBe('MAIL_005'); }); @@ -2299,10 +2372,11 @@ describe('MailService', () => { send(sendDto(), '00000000-0000-4000-8000-000000000000'), ).resolves.toEqual(SENT); - finish({ id: 'sent-first', rejectedRecipients: [] }); + finish({ id: 'sent-first', rejectedRecipients: [], pendingSend: null }); await expect(first).resolves.toEqual({ id: 'sent-first', rejectedRecipients: [], + pendingSend: null, }); await expect(send()).resolves.toEqual(SENT); }); @@ -2702,6 +2776,319 @@ describe('MailService', () => { }); }); + /** 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 (계약 2절 "보내기 취소 · 예약 발송" · "외부 이미지 보기") */ + describe('9단계 — 붙잡는 발송 · 외부 이미지', () => { + const MINUTE_MS = 60_000; + const DAY_MS = 24 * 60 * MINUTE_MS; + const sentMessage = () => provider.sendMessage.mock.calls.at(-1)?.[1]; + + describe('붙잡는 발송', () => { + it('sendAt 이 있으면 그 시각(소수 초를 버린 값)까지 — 설정은 읽지 않는다(예약에는 보내기 취소 시간을 더하지 않는다)', async () => { + const second = + Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000; + + await service.sendMessage( + user, + sendDto({ sendAt: new Date(second + 999).toISOString() }), + ); + + expect(sentMessage()?.hold).toEqual({ + kind: 'scheduled', + until: new Date(second).toISOString(), + }); + expect(ninth.userSettings.get).not.toHaveBeenCalled(); + }); + + it.each<[string, () => string]>([ + [ + '4분 59초 뒤', + () => new Date(Date.now() + 5 * MINUTE_MS - 1_000).toISOString(), + ], + [ + '7일하고 1분 뒤', + () => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(), + ], + ['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()], + ])( + '예약 시각이 %s 이면 엔진에 묻기 전에 VAL_001', + async (_label, sendAt) => { + expect( + await failureOf(() => + service.sendMessage(user, sendDto({ sendAt: sendAt() })), + ), + ).toEqual({ code: 'VAL_001', reason: 'send-at-out-of-range' }); + expect(provider.sendMessage).not.toHaveBeenCalled(); + expect(provider.findSentMessage).not.toHaveBeenCalled(); + }, + ); + + it('sendAt 이 없거나 null 이면 설정의 보내기 취소 초만큼 — 0초면 붙잡지 않는다(hold 없음)', async () => { + ninth.userSettings.get.mockResolvedValue({ + undoSendSeconds: 20, + externalImages: 'ask', + }); + await service.sendMessage(user, sendDto()); + expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 }); + await service.sendMessage(user, sendDto({ sendAt: null })); + expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 }); + expect(ninth.userSettings.get).toHaveBeenCalledWith(user.email); + + ninth.userSettings.get.mockResolvedValue({ + undoSendSeconds: 0, + externalImages: 'ask', + }); + await service.sendMessage(user, sendDto()); + expect(sentMessage()).not.toHaveProperty('hold'); + }); + + it('설정을 읽지 못하면(DB 장애) 처음 값(10초)으로 보내고 경고만 — 발송을 실패로 만들지 않는다', async () => { + ninth.userSettings.get.mockRejectedValue( + new Error('connect ECONNREFUSED'), + ); + + await expect(service.sendMessage(user, sendDto())).resolves.toEqual( + SENT, + ); + expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 10 }); + const warned = warnSpy.mock.calls + .map(([line]) => String(line)) + .join('\n'); + expect(warned).toContain('[SYS_001]'); + expect(warned).not.toContain(user.email); + }); + + it('붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송(붙잡은 시각 포함)을 그대로 돌려준다', async () => { + ninth.userSettings.get.mockResolvedValue({ + undoSendSeconds: 10, + externalImages: 'ask', + }); + await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY); + const first = sentMessage(); + const pendingSend = { + kind: 'undo' as const, + sendAt: '2026-10-29T06:00:10.000Z', + }; + provider.sendMessage.mockClear(); + provider.findSentMessage.mockResolvedValue( + sentRecord(first?.fingerprint ?? null, { pendingSend }), + ); + + await expect( + service.sendMessage( + user, + sendDto({ + sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(), + }), + IDEMPOTENCY_KEY, + ), + ).resolves.toEqual({ + id: 'sent-1', + rejectedRecipients: [], + pendingSend, + }); + expect(provider.sendMessage).not.toHaveBeenCalled(); + }); + + it('취소한 발송의 키로 다시 보내면 보내지 않고 409 MAIL_005 — 클라이언트는 새 키로 보낸다', async () => { + await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY); + const first = sentMessage(); + provider.sendMessage.mockClear(); + provider.findSentMessage.mockResolvedValue( + sentRecord(first?.fingerprint ?? null, { canceled: true }), + ); + + expect( + await failureOf(() => + service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY), + ), + ).toEqual({ + code: 'MAIL_005', + reason: 'idempotency-key-of-canceled-send', + }); + expect(provider.sendMessage).not.toHaveBeenCalled(); + expect(provider.getRejectedRecipients).not.toHaveBeenCalled(); + }); + + it('붙잡은 발송의 응답은 어댑터의 pendingSend 그대로 — 로그에 보낼 시각', async () => { + const pendingSend = { + kind: 'scheduled' as const, + sendAt: '2030-01-01T09:00:00.000Z', + }; + provider.sendMessage.mockResolvedValue({ ...SENT, pendingSend }); + + await expect( + service.sendMessage( + user, + sendDto({ + sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(), + }), + ), + ).resolves.toEqual({ ...SENT, pendingSend }); + const logged = logSpy.mock.calls + .map(([line]) => String(line)) + .join('\n'); + expect(logged).toContain('보낼 시각=2030-01-01T09:00:00.000Z'); + }); + }); + + describe('외부 이미지', () => { + const EXTERNAL = 'https://news.example/track/abc123.png'; + const imageMail = (overrides: Partial = {}) => + source({ + from: { name: '소식지', email: 'news@letters.example' }, + rawHtmlBody: `

안녕하세요

로고`, + ...overrides, + }); + const settingsAre = (externalImages: 'ask' | 'trusted' | 'always') => + ninth.userSettings.get.mockResolvedValue({ + undoSendSeconds: 10, + externalImages, + }); + /** 본문의 중계 주소 → 표를 푼 원래 주소 */ + const proxied = (html: string | null) => + [...(html ?? '').matchAll(/src="\/api\/mail-images\/([^"]+)"/g)].map( + ([, token]) => ninth.imageTokens.open(token, user.email), + ); + + it('늘 묻기(처음 값)면 지우고 hasBlockedImages — 주소록 · 사내 목록은 묻지 않는다', async () => { + provider.getMessage.mockResolvedValue(imageMail()); + + const detail = await service.getMessage(user, 'm1'); + + expect(detail.htmlBody).toBe('

안녕하세요

'); + expect(detail.hasBlockedImages).toBe(true); + expect(detail.imagesShown).toBe(false); + expect(ninth.contacts.hasEmail).not.toHaveBeenCalled(); + expect(ninth.directory.hasEmail).not.toHaveBeenCalled(); + }); + + it('images=show 면 이 직원의 표로 중계 주소를 넣는다 — 설정을 읽지 않고, 스팸메일함도', async () => { + provider.getMessage.mockResolvedValue( + imageMail({ isInJunkMailbox: true }), + ); + + const detail = await service.getMessage(user, 'm1', { images: 'show' }); + + expect(proxied(detail.htmlBody)).toEqual([EXTERNAL]); + expect(detail.htmlBody).not.toContain('news.example'); + expect(detail.htmlBody).toContain('alt="로고"'); + expect(detail.imagesShown).toBe(true); + expect(detail.hasBlockedImages).toBe(false); + expect(ninth.userSettings.get).not.toHaveBeenCalled(); + // 스팸메일함에 들었는지는 응답에 싣지 않는다 + expect(detail).not.toHaveProperty('isInJunkMailbox'); + }); + + it('늘 보기(always)면 보인다 — 스팸메일함의 메일은 설정과 상관없이 지운다', async () => { + settingsAre('always'); + provider.getMessage.mockResolvedValue(imageMail()); + expect( + proxied((await service.getMessage(user, 'm1')).htmlBody), + ).toEqual([EXTERNAL]); + + provider.getMessage.mockResolvedValue( + imageMail({ isInJunkMailbox: true }), + ); + const junk = await service.getMessage(user, 'm1'); + expect(junk.imagesShown).toBe(false); + expect(junk.hasBlockedImages).toBe(true); + }); + + it('주소록 · 사내 직원(trusted)이면 보낸 사람이 내 주소록이나 사내 목록에 있을 때만', async () => { + settingsAre('trusted'); + provider.getMessage.mockResolvedValue(imageMail()); + + expect((await service.getMessage(user, 'm1')).imagesShown).toBe(false); + expect(ninth.contacts.hasEmail).toHaveBeenCalledWith( + user.email, + 'news@letters.example', + ); + expect(ninth.directory.hasEmail).toHaveBeenCalledWith( + 'news@letters.example', + ); + + ninth.contacts.hasEmail.mockResolvedValueOnce(true); + expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true); + + ninth.directory.hasEmail.mockResolvedValueOnce(true); + expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true); + }); + + it('trusted 인데 주소록 · 사내 목록을 읽지 못하면 없는 것으로 — 메일은 열린다(보이지 않을 뿐)', async () => { + settingsAre('trusted'); + provider.getMessage.mockResolvedValue(imageMail()); + ninth.contacts.hasEmail.mockRejectedValue( + new BusinessException('MAIL_001'), + ); + ninth.directory.hasEmail.mockRejectedValue( + new BusinessException('MAIL_001'), + ); + + const detail = await service.getMessage(user, 'm1'); + expect(detail.imagesShown).toBe(false); + expect(detail.hasBlockedImages).toBe(true); + + // 한쪽만 읽지 못하면 다른 쪽으로 본다 + ninth.directory.hasEmail.mockResolvedValueOnce(true); + expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true); + }); + + it('바깥 그림이 없으면 보이게 해도 imagesShown false — 텍스트만인 메일도', async () => { + provider.getMessage.mockResolvedValue( + source({ rawHtmlBody: '

그림 없음

' }), + ); + const plain = await service.getMessage(user, 'm1', { images: 'show' }); + expect(plain.imagesShown).toBe(false); + expect(plain.hasBlockedImages).toBe(false); + + provider.getMessage.mockResolvedValue(source()); + const text = await service.getMessage(user, 'm1', { images: 'show' }); + expect(text.imagesShown).toBe(false); + }); + + it('설정을 읽지 못하면(DB 장애) 처음 값(늘 묻기)으로 — 메일은 열린다', async () => { + ninth.userSettings.get.mockRejectedValue(new Error('db down')); + provider.getMessage.mockResolvedValue(imageMail()); + + const detail = await service.getMessage(user, 'm1'); + expect(detail.hasBlockedImages).toBe(true); + expect(detail.imagesShown).toBe(false); + }); + }); + + it('취소해 되돌린 발송을 이어 쓸 때 서명 그림(cid:sig-…)을 서명 그림 주소로 되돌린다 — 내 그림만 남는다', async () => { + signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID])); + provider.getDraft.mockResolvedValue( + draftSource({ + rawHtmlBody: `

본문

`, + isCanceledSend: true, + }), + ); + + const draft = await service.getDraft(user, 'draft-1'); + + expect(draft.bodyHtml).toBe( + `

본문

`, + ); + }); + + it('취소한 발송이 아닌 초안의 cid:sig-… 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지운다, 9단계 리뷰 뒤)', async () => { + signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID])); + provider.getDraft.mockResolvedValue( + draftSource({ + rawHtmlBody: `

본문

`, + isCanceledSend: false, + }), + ); + + const draft = await service.getDraft(user, 'draft-1'); + + expect(draft.bodyHtml).toBe('

본문

'); + // 서명 그림 주소가 생기지 않았으니 그림 목록을 읽지도 않는다 + expect(signatures.imageIdsOf).not.toHaveBeenCalled(); + }); + }); + /** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */ describe('보낸 뒤', () => { it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => { @@ -2728,10 +3115,9 @@ describe('MailService', () => { await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY); const { fingerprint } = provider.sendMessage.mock.calls[0][1]; provider.sendMessage.mockClear(); - provider.findSentMessage.mockResolvedValue({ - id: 'sent-1', - fingerprint: fingerprint ?? null, - }); + provider.findSentMessage.mockResolvedValue( + sentRecord(fingerprint ?? null), + ); await expect( service.sendMessage( @@ -2818,6 +3204,7 @@ describe('MailService', () => { rejectedRecipients: [ { email: 'nobody@arasystem.test', reason: 'not-found' as const }, ], + pendingSend: null, }; provider.sendMessage.mockResolvedValue(partial); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index ee75cb0..d9fb371 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -6,9 +6,16 @@ import { toPaginatedResult, } from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; +import { ContactsService } from '@/modules/contacts/contacts.service'; +import { DirectoryService } from '@/modules/contacts/directory.service'; import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service'; import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format'; +import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import { + DEFAULT_MAIL_USER_SETTINGS, + type MailUserSettings, +} from '@/modules/mail-user-settings/mail-user-settings.types'; import { traceTag } from '@/shared/logger/request-context'; import { toWholeSecondIso } from '@/shared/utils/format'; import { maskEmail } from '@/shared/utils/mask'; @@ -31,6 +38,10 @@ import type { MailboxMessagesPatchDto, RenameMailboxDto, } from './dto/mailbox.dto'; +import { + SHOW_IMAGES, + type MessageDetailQueryDto, +} from './dto/message-detail-query.dto'; import type { MessageListQueryDto } from './dto/message-list-query.dto'; import type { ComposeContentDto, @@ -53,15 +64,21 @@ import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html'; import { SIGNATURE_IMAGE_PATH_PREFIX, findSignatureImageIds, + restoreSignatureImageSources, rewriteSignatureImages, + signatureImageContentId, } from './html/signature-images'; +import { MailImageTokens } from './images/mail-image-token'; import { + MAIL_IMAGES_ROUTE, MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, MAX_BODY_VALUE_BYTES, MAX_RECIPIENTS, MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_TEXT_LENGTH, + SCHEDULED_SEND_MAX_LEAD_MS, + SCHEDULED_SEND_MIN_LEAD_MS, } from './mail.constants'; import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; @@ -97,6 +114,7 @@ import type { OutgoingInlineImage, OutgoingMessage, SavedDraft, + SendHold, SentMessage, } from './mail.types'; import { presentMailbox, presentMailboxes } from './mailbox.presenter'; @@ -243,10 +261,37 @@ function assertMoveTargetsAllowed( } } -/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */ -function signatureImageCid(imageId: string, sender: string): string { - const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase(); - return `sig-${imageId}@${domain}`; +/** 1초 (밀리초) — 예약 시각을 초 단위로 맞출 때 */ +const SECOND_MS = 1_000; + +/** + * 외부 이미지 중계 주소 — 같은 서버의 상대 경로 (`/api/mail-images/<표>`, 계약 2절 "외부 이미지 보기"). + * 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH` — 화면은 이 앞부분으로 시작하는 그림만 남긴다(한쪽을 고치면 둘 다) + */ +const MAIL_IMAGE_PATH_PREFIX = `/api/${MAIL_IMAGES_ROUTE}/`; + +/** + * 예약 발송 시각(sendAt) → 붙잡는 방법 — 소수 초를 버린 값(엔진의 HOLDUNTIL 은 초 단위)이 지금(서버 시계)부터 5분 뒤 이상 · 7일 안이어야 한다. + * 밖이면 엔진에 묻기 전에 VAL_001 (계약 2절 "보내기 취소 · 예약 발송") + */ +function scheduledHold(sendAt: string, nowMs: number): SendHold { + const until = Math.floor(Date.parse(sendAt) / SECOND_MS) * SECOND_MS; + const lead = until - nowMs; + if ( + !Number.isFinite(until) || + lead < SCHEDULED_SEND_MIN_LEAD_MS || + lead > SCHEDULED_SEND_MAX_LEAD_MS + ) { + throw new BusinessException('VAL_001', { + context: { + reason: 'send-at-out-of-range', + leadSeconds: Number.isFinite(lead) + ? Math.round(lead / SECOND_MS) + : null, + }, + }); + } + return { kind: 'scheduled', until: new Date(until).toISOString() }; } /** @@ -283,6 +328,13 @@ export class MailService { @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, private readonly signatures: MailSignatureService, private readonly suggestions: RecipientSuggestionService, + /** 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계) */ + private readonly userSettings: MailUserSettingsService, + /** 외부 이미지 "주소록 · 사내 직원은 늘 보기" 판정 — 내 주소록 · 사내 목록 (9단계) */ + private readonly contacts: ContactsService, + private readonly directory: DirectoryService, + /** 외부 이미지 중계 표 (9단계) */ + private readonly imageTokens: MailImageTokens, ) {} async listMailboxes( @@ -618,10 +670,15 @@ export class MailService { * isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML * (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다). * 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다). + * + * 외부 이미지(9단계 "외부 이미지 보기"): `images=show` 이거나 설정이 보이라고 하면(always · trusted — 스팸메일함의 메일은 빼고) + * `` 의 외부 주소를 중계 주소(`/api/mail-images/<표>`)로 바꿔 넣는다 — imagesShown 은 바꿔 넣은 것이 있으면 true, + * 보이게 한 본문은 hasBlockedImages 가 false 다(srcset · 배경 그림은 지금처럼 지운다 — 알려진 한계) */ async getMessage( user: AuthenticatedUser, id: string, + query: MessageDetailQueryDto = {}, ): Promise { const { rawHtmlBody, @@ -629,6 +686,8 @@ export class MailService { isTextBodyTruncated, inlineParts, attachments, + // 스팸메일함에 들었는지는 이미지 판정에만 쓴다 — 응답에 싣지 않는다 + isInJunkMailbox, ...detail } = await this.provider.getMessage(user.email, id); @@ -645,6 +704,7 @@ export class MailService { attachments: markInline(NO_INLINE_ATTACHMENTS), htmlBody: null, hasBlockedImages: false, + imagesShown: false, isHtmlBodyOmitted, isBodyTruncated: isTextBodyTruncated, }); @@ -661,12 +721,24 @@ export class MailService { return withoutHtml(true); } - const inlineImages = await this.loadInlineImages( - user.email, + const [inlineImages, showImages] = await Promise.all([ + this.loadInlineImages(user.email, rawHtmlBody, inlineParts), + this.showsExternalImages( + user, + { sender: detail.from?.email ?? null, isInJunkMailbox }, + query, + ), + ]); + const sanitized = sanitizeMailHtml( rawHtmlBody, - inlineParts, + inlineImages.dataUris, + showImages + ? { + externalImageSrc: (url) => + `${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`, + } + : {}, ); - const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris); const inlined = new Set( [...sanitized.embeddedContentIds].flatMap((contentId) => { const index = inlineImages.attachmentIndexes.get(contentId); @@ -678,12 +750,71 @@ export class MailService { ...detail, attachments: markInline(inlined), htmlBody: sanitized.html, - hasBlockedImages: sanitized.hasBlockedImages, + hasBlockedImages: showImages ? false : sanitized.hasBlockedImages, + imagesShown: sanitized.proxiedImageCount > 0, isHtmlBodyOmitted: false, isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated, }; } + /** + * 외부 이미지를 보일까 (계약 2절 "외부 이미지 보기") — `images=show` 면 늘(스팸메일함도). 아니면 설정: + * ask 는 안 보이고, 스팸메일함의 메일은 설정과 상관없이 안 보인다, always 는 보이고, trusted 는 보낸 사람이 내 주소록이나 사내 목록에 있을 때. + * 주소록 · 사내 목록을 읽지 못하면 보이지 않는다(묻는 쪽으로 — 경고만) + */ + private async showsExternalImages( + user: AuthenticatedUser, + message: { + readonly sender: string | null; + readonly isInJunkMailbox: boolean; + }, + query: MessageDetailQueryDto, + ): Promise { + if (query.images === SHOW_IMAGES) return true; + const { externalImages } = await this.userSettingsOf(user); + if (externalImages === 'ask' || message.isInJunkMailbox) return false; + if (externalImages === 'always') return true; + return message.sender ? this.isTrustedSender(user, message.sender) : false; + } + + /** + * 그 직원의 보내기 · 읽기 설정 — 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 남긴다. 발송 · 메일 읽기를 설정 때문에 실패로 만들지 않는다 + */ + private async userSettingsOf( + user: AuthenticatedUser, + ): Promise { + try { + return await this.userSettings.get(user.email); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return DEFAULT_MAIL_USER_SETTINGS; + } + } + + /** 보낸 사람이 내 주소록 · 사내 목록(직원 · 공용 주소)에 있는가 — 읽지 못한 쪽은 없는 것으로 본다 */ + private async isTrustedSender( + user: AuthenticatedUser, + sender: string, + ): Promise { + const lookups: [string, () => Promise][] = [ + ['contacts', () => this.contacts.hasEmail(user.email, sender)], + ['directory', () => this.directory.hasEmail(sender)], + ]; + for (const [where, lookup] of lookups) { + try { + if (await lookup()) return true; + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + this.logger.debug( + `${traceTag()} 외부 이미지의 보낸 사람 확인에서 ${where} 를 읽지 못해 없는 것으로 봅니다: user=${maskEmail(user.email)}, 원인=${error.code}`, + ); + } + } + return false; + } + async updateMessage( user: AuthenticatedUser, id: string, @@ -740,6 +871,10 @@ export class MailService { * * `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다. * 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다. + * + * 붙잡기 (9단계 "보내기 취소 · 예약 발송"): `sendAt` 이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안 — 밖이면 엔진에 묻기 전에 VAL_001), + * 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 지금처럼 바로) 엔진이 붙잡았다가 보낸다 — 응답의 pendingSend. + * 예약에는 보내기 취소 시간을 더하지 않는다. 붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송을 그대로 돌려준다 */ async sendMessage( user: AuthenticatedUser, @@ -752,8 +887,15 @@ export class MailService { context: { reason: 'invalid-idempotency-key' }, }); } + // 예약 시각은 본문 준비 · 엔진보다 먼저 본다 — 범위 밖이면 아무것도 하지 않는다. null 은 없는 것과 같다(다른 선택 칸처럼) + const scheduled = + dto.sendAt === undefined || dto.sendAt === null + ? null + : scheduledHold(dto.sendAt, Date.now()); - const message = await this.prepareOutgoing(user, dto); + const prepared = await this.prepareOutgoing(user, dto); + const hold = scheduled ?? (await this.undoHold(user)); + const message: OutgoingMessage = hold ? { ...prepared, hold } : prepared; const result = parsed.key === null ? await this.deliver(user, message) @@ -762,6 +904,14 @@ export class MailService { return result; } + /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */ + private async undoHold(user: AuthenticatedUser): Promise { + const { undoSendSeconds } = await this.userSettingsOf(user); + return undoSendSeconds > 0 + ? { kind: 'undo', seconds: undoSendSeconds } + : null; + } + /** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */ async createDraft( user: AuthenticatedUser, @@ -910,6 +1060,16 @@ export class MailService { messageId, ); if (previous) { + // 취소한 발송의 키(9단계) — 보내지 않는다. 클라이언트는 취소 뒤 새 키로 보낸다 (계약 2절 "보내기 취소 · 예약 발송") + if (previous.canceled) { + throw new BusinessException('MAIL_005', { + context: { + reason: 'idempotency-key-of-canceled-send', + from: maskEmail(user.email), + sentId: previous.id, + }, + }); + } if (previous.fingerprint !== fingerprint) { throw new BusinessException('MAIL_005', { context: { @@ -927,6 +1087,7 @@ export class MailService { previous.id, message, ), + pendingSend: previous.pendingSend, }; this.logger.log( `${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`, @@ -1095,7 +1256,7 @@ export class MailService { const cids = new Map( images.map((image) => [ image.id, - signatureImageCid(image.id, user.email), + signatureImageContentId(image.id, user.email), ]), ); const inlineImages = images.map( @@ -1113,22 +1274,29 @@ export class MailService { return { htmlBody, inlineImages }; } - /** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */ + /** + * 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null. + * 취소해 되돌린 발송(9단계 — isCanceledSend)만 서명 그림이 메일 안의 `cid:sig-…` 라 정화 전에 서명 그림 주소로 되돌린다(정화가 내 그림만 남긴다). + * 다른 초안의 `cid:sig-…` 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지우고 그 부분은 첨부로 남는다) + */ private async draftHtml( user: AuthenticatedUser, source: DraftSource, ): Promise { - const raw = source.rawHtmlBody; - if (!raw) return null; + if (!source.rawHtmlBody) return null; - const budget = checkHtmlParseBudget(raw); + const budget = checkHtmlParseBudget(source.rawHtmlBody); if (!budget.ok) { // 본문 내용은 남기지 않는다 (.claude/rules/logging.md) this.logger.warn( - `${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`, + `${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${source.rawHtmlBody.length}자`, ); return null; } + // 비용을 본 뒤에 되돌린다 — 되돌리기는 입력 길이에 비례하고 태그 · 중첩을 늘리지 않는다 + const raw = source.isCanceledSend + ? restoreSignatureImageSources(source.rawHtmlBody) + : source.rawHtmlBody; const html = sanitizeOutgoingHtml(raw, { signatureImageIds: await this.signatureImageIdsFor(user, raw), }); @@ -1183,11 +1351,15 @@ export class MailService { ): Promise { const result = await this.provider.sendMessage(user.email, message); - // 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다 + // 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다. + // 붙잡은 발송은 메일 id · 보낼 시각만 (계약 2절 "보내기 취소 · 예약 발송"의 로그) const recipientCount = message.to.length + message.cc.length + message.bcc.length; + const held = result.pendingSend + ? `, ${result.pendingSend.kind === 'scheduled' ? '예약' : '보내기 취소 대기'} id=${result.id} 보낼 시각=${result.pendingSend.sendAt}` + : ''; this.logger.log( - `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}`, + `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}${held}`, ); return result; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 7a9c86b..3029d7a 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -32,6 +32,18 @@ export interface Mailbox { readonly unreadCount: number; } +/** + * 엔진이 붙잡아 둔 발송의 종류 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 보내기 취소를 기다리는 중 · 예약 발송 + */ +export type PendingSendKind = 'undo' | 'scheduled'; + +/** 아직 보내지 않은 내 메일의 발송 — 엔진의 기다리는 제출(undoStatus: pending, 보낼 시각이 아직 오지 않음) */ +export interface PendingSend { + /** 엔진이 보낼 시각 (ISO 8601 UTC) */ + readonly sendAt: string; + readonly kind: PendingSendKind; +} + export interface MessageSummary { readonly id: string; readonly threadId: string; @@ -48,6 +60,8 @@ export interface MessageSummary { readonly hasAttachment: boolean; /** 바이트 */ readonly size: number; + /** 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 발송 — 그 밖에는 null (9단계) */ + readonly pendingSend: PendingSend | null; } export interface MailAttachment { @@ -78,8 +92,10 @@ export interface MessageDetail extends MessageSummary { /** 서버가 정화한 HTML — 정화 전 원본은 절대 내보내지 않는다 */ readonly htmlBody: string | null; readonly textBody: string | null; - /** 외부 이미지를 지웠으면 true */ + /** 외부 이미지를 지웠으면 true — 보이게 했으면(imagesShown 을 정한 요청) false */ readonly hasBlockedImages: boolean; + /** 외부 이미지를 중계 주소(/api/mail-images/…)로 바꿔 넣었으면 true — images=show 또는 설정 (9단계 "외부 이미지 보기") */ + readonly imagesShown: boolean; /** HTML 이 있었지만 정화 전 사전 검사(html/html-parse-budget.ts)를 넘어 htmlBody 를 비웠으면 true */ readonly isHtmlBodyOmitted: boolean; /** @@ -111,12 +127,17 @@ export interface MessageDetailSource extends Omit< | 'htmlBody' | 'textBody' | 'hasBlockedImages' + | 'imagesShown' | 'isHtmlBodyOmitted' | 'isBodyTruncated' | 'attachments' > { /** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */ readonly attachments: MailAttachment[]; + /** + * 스팸메일함에 든 메일인가 — 외부 이미지를 설정(trusted · always)으로 보이지 않는다 (images=show 로만 — 계약 2절 "외부 이미지 보기") + */ + readonly isInJunkMailbox: boolean; readonly rawHtmlBody: string | null; /** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */ readonly isRawHtmlBodyTruncated: boolean; @@ -446,8 +467,21 @@ export interface OutgoingMessage { readonly messageId?: string; /** 내용 지문 — 같은 키로 내용이 바뀌었는지 비교한다. 어댑터는 메일과 함께 남겨 `findSentMessage` 로 돌려준다 */ readonly fingerprint?: string; + /** + * 엔진이 붙잡았다가 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 없으면 지금처럼 바로 보낸다. + * 서비스가 정한다: 예약(sendAt)이면 그 시각까지, 아니면 그 직원 설정의 보내기 취소 초(0 이면 없음) + */ + readonly hold?: SendHold; } +/** + * 발송을 붙잡는 방법 — 보내기 취소(초 — HOLDFOR) · 예약(UTC 시각, 초 단위 — HOLDUNTIL). + * 어댑터는 이것으로 발송 envelope 을 직접 적는다 (엔진의 FUTURERELEASE — Stalwart 0.16.27, 2026-10-29 확인) + */ +export type SendHold = + | { readonly kind: 'undo'; readonly seconds: number } + | { readonly kind: 'scheduled'; readonly until: string }; + /** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */ export type RecipientFields = Pick; @@ -491,6 +525,11 @@ export interface DraftSource { /** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */ readonly inReplyTo: string | null; readonly updatedAt: string; + /** + * 취소해 임시보관함으로 되돌린 발송인가 (9단계 — 취소한 발송 표시). 이때만 서식 본문의 `cid:sig-…`(우리가 실어 보낸 서명 그림)를 + * 서명 그림 주소로 되돌린다 — 어댑터는 그 그림 부분을 첨부에서 이미 뺐다 + */ + readonly isCanceledSend: boolean; } /** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */ @@ -558,6 +597,8 @@ export interface SentMessage { readonly id: string; /** 엔진이 받지 않은 사람 — 없으면 빈 배열. 같은 주소는 한 번만, 요청의 받는 사람 · 참조 · 숨은참조 순서 */ readonly rejectedRecipients: RejectedRecipient[]; + /** 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null */ + readonly pendingSend: PendingSend | null; } /** @@ -569,6 +610,36 @@ export interface SentMessageRecord { readonly id: string; /** 발송 때 남긴 내용 지문 — 없으면 null */ readonly fingerprint: string | null; + /** + * 그 발송을 취소했다(9단계 — 제출이 모두 canceled) — 보낸 것이 아니다. 같은 키로 다시 보내면 서비스가 409 MAIL_005 로 답한다 + * (클라이언트는 취소 뒤 새 키로 보낸다 — 계약 2절 "보내기 취소 · 예약 발송") + */ + readonly canceled: boolean; + /** 아직 엔진이 붙잡고 있는 발송이면 그 정보 — 같은 키의 재시도가 처음 결과를 그대로 돌려줄 때 싣는다 */ + readonly pendingSend: PendingSend | null; +} + +/** + * 보낸 사람 차단 하나 (계약 2절 "보낸 사람 차단" — 9단계) — 주소("kim@spam.example") 또는 도메인("@spam.example") · 소문자 + */ +export interface BlockedSender { + readonly address: string; + /** 차단한 시각 (ISO 8601 UTC) */ + readonly blockedAt: string; +} + +/** 차단 목록 (GET /api/mail-blocks) */ +export interface MailBlockList { + /** 최근에 차단한 것부터 */ + readonly senders: readonly BlockedSender[]; + /** 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다 */ + readonly isActive: boolean; +} + +/** 차단을 더한 결과 — 이미 있던 주소면 created: false (그 항목 그대로) */ +export interface MailBlockAdded { + readonly sender: BlockedSender; + readonly created: boolean; } /** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */ diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 0121a54..07f29ca 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -137,6 +137,10 @@ export class MailboxController { }) @ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음') @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse( + 'MAIL_015', + '남은 메일이 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)뿐이고 이 요청에서 옮긴 것이 없다 — 옮기지 않는다 (먼저 보내기를 취소한다)', + ) @ApiErrorResponse('REQ_001', BULK_IN_PROGRESS) @ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS) remove( @@ -178,6 +182,10 @@ export class MailboxController { }) @ApiErrorResponse('VAL_001', '휴지통 · 스팸메일함이 아님') @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse( + 'MAIL_015', + '남은 메일이 붙잡힌 메일(9단계 — 다른 프로그램이 옮겨 둔 것)뿐이고 이 요청에서 지운 것이 없다 — 지우지 않는다', + ) @ApiErrorResponse('REQ_001', BULK_IN_PROGRESS) @ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS) empty( diff --git a/backend/src/modules/mail/message-deletion.controller.ts b/backend/src/modules/mail/message-deletion.controller.ts index eece9f4..9509ebd 100644 --- a/backend/src/modules/mail/message-deletion.controller.ts +++ b/backend/src/modules/mail/message-deletion.controller.ts @@ -41,7 +41,7 @@ export class MessageDeletionController { summary: '휴지통에서 영구 삭제 (여러 통)', description: `ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 휴지통에 있는 메일만 다룬다 — 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다. ` + - '휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일은 지우지 않고 skippedIds 로 돌려준다. ' + + '휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일 · 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 지우지 않고 skippedIds 로 돌려준다. ' + '대상을 확인한 뒤 그 상태 그대로일 때만 한 번에 지운다(그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다) — ' + '계속 어긋나면 아무것도 지우지 않고 503 MAIL_001 (다시 시도하면 된다).', }) @@ -54,7 +54,7 @@ export class MessageDeletionController { ) @ApiErrorResponse( 'MAIL_001', - '확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다', + '확인하는 사이 계속 바뀜 · 휴지통 없음 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다', ) remove( @CurrentUser() user: AuthenticatedUser, diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index 9fc539b..95756bd 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -50,12 +50,14 @@ import { MAX_BODY_LENGTH, MAX_BODY_VALUE_BYTES, MAX_BULK_MESSAGE_IDS, + MAX_ENVELOPE_RESUBMITS, MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, MAX_SUBJECT_LENGTH, MESSAGES_ROUTE, } from './mail.constants'; import { UpdateMessagesDto } from './dto/bulk-messages.dto'; +import { MessageDetailQueryDto } from './dto/message-detail-query.dto'; import { MessageListQueryDto } from './dto/message-list-query.dto'; import { SendMessageDto } from './dto/send-message.dto'; import { UpdateMessageDto } from './dto/update-message.dto'; @@ -119,6 +121,7 @@ export class MessageController { description: `ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만, isJunk = $junk · $notjunk). 엔진 요청 한 번으로 처리한다. ` + '바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' + + '옮기기 · 스팸 표시는 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)에 하지 않는다 — 그 id 도 failedIds (확인하지 못하면 아무것도 바꾸지 않고 503 MAIL_001). ' + '없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).', }) @ApiStandardResponse(MessagesPatchedDto, { @@ -146,27 +149,40 @@ export class MessageController { description: 'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' + '정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' + - `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다.`, + `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` + + 'images=show 면 외부 이미지( 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' + + '없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).', }) @ApiStandardResponse(MessageDetailDto, { description: '메일 상세' }) + @ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리') @ApiErrorResponse('RES_001', '없는 메일') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') get( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, + @Query() query: MessageDetailQueryDto, ) { - return this.mail.getMessage(user, id); + return this.mail.getMessage(user, id, query); } @Patch(':id') @ApiOperation({ summary: '읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)', description: - 'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다.', + 'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다. ' + + '붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 mailboxId · isJunk 를 받지 않는다(409 MAIL_015 — 취소로만 보낸편지함을 떠난다), 읽음 · 중요 표시는 된다.', }) @ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' }) @ApiErrorResponse('RES_001', '없는 메일') @ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동') + @ApiErrorResponse( + 'MAIL_015', + '붙잡힌 메일의 mailboxId · isJunk — 아무것도 바꾸지 않았다 (먼저 보내기를 취소한다)', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 — 옮기기 · 스팸 표시는 붙잡힌 메일인지 확인하지 못하면 바꾸지 않는다', + ) update( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, @@ -186,6 +202,14 @@ export class MessageController { description: '삭제(이동) 완료 — 본문 없음', }) @ApiErrorResponse('RES_001', '없는 메일') + @ApiErrorResponse( + 'MAIL_015', + '붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계) — 옮기지도 지우지도 않았다 (먼저 보내기를 취소한다)', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 확인하는 사이 계속 바뀜 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다', + ) async remove( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, @@ -209,7 +233,12 @@ export class MessageController { `본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` + `bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` + '엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' + - '이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).', + '이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002). ' + + '9단계 — 메일 서버가 붙잡았다가 보낸다: sendAt(UTC)이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안, 밖이면 400 VAL_001), 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 바로). ' + + '201 의 pendingSend 가 그 발송이다(바로 보냈으면 null) — 붙잡아도 사내 받는 사람 확인은 제출 순간이라 rejectedRecipients · MAIL_010 · MAIL_011 은 그대로다. ' + + '메일 서버가 받지 않는 주소(모르는 최상위 도메인 등)는 rejectedRecipients 의 not-found 로 빼고 나머지에게 보낸다 — 서버는 한 번에 하나만 알려 주므로 ' + + `${MAX_ENVELOPE_RESUBMITS + 1}개를 넘게 빼야 하면(다시 내기 ${MAX_ENVELOPE_RESUBMITS}차례) 보내지 않는다(400 VAL_001 — 만든 메일을 지웠다, 주소를 고쳐 같은 키로 다시 보내면 새로 보낸다). ` + + '취소(DELETE /api/scheduled-messages/{id})한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다.', }) @ApiHeader({ name: 'Idempotency-Key', @@ -236,7 +265,7 @@ export class MessageController { ) @ApiErrorResponse( 'MAIL_005', - '같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)', + '같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 · 그 키의 발송을 취소했다(9단계) — 보내지 않았다 (보낸편지함 확인 후 새 키로)', ) @ApiErrorResponse( 'FILE_002', diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts new file mode 100644 index 0000000..e325632 --- /dev/null +++ b/backend/src/modules/mail/scheduled-message.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Delete, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + ScheduledMessageCanceledDto, + ScheduledMessagePageDto, +} from './dto/mail-response.dto'; +import { MailIdPipe } from './mail-id.pipe'; +import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants'; +import { ScheduledMessagesService } from './scheduled-messages.service'; + +// 붙잡아 둔 발송 — 예약한 메일 목록 · 보내기 취소 · 예약 취소 (계약 2절 "보내기 취소 · 예약 발송", 9단계) +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(SCHEDULED_MESSAGES_ROUTE) +export class ScheduledMessageController { + constructor(private readonly scheduled: ScheduledMessagesService) {} + + @Get() + @ApiOperation({ + summary: '예약한 메일 (보낼 시각 차례, 커서 페이지네이션)', + description: + '예약 발송(sendAt)으로 붙잡아 둔 메일 가운데 아직 보내지 않은 것 — 보내기 취소를 기다리는 메일은 들지 않는다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. ' + + '목록 · 상태는 늘 메일 엔진의 기다리는 제출에 묻는다(서버가 따로 기억하지 않는다).', + }) + @ApiStandardResponse(ScheduledMessagePageDto, { + description: '예약한 메일 (total 포함)', + }) + @ApiErrorResponse('VAL_001', '다른 목록의 커서 · 깨진 커서 · limit 범위 밖') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + ) { + return this.scheduled.list(user, query); + } + + @Delete(':id') + @ApiOperation({ + summary: '붙잡은 발송 취소 — 보내기 취소 · 예약 취소 (임시보관함으로)', + description: + 'id 는 보낸 사본의 메일 id 다. 메일 서버에서 발송을 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨 초안으로 되돌린다 — 받는 사람 · 제목 · 본문 · 첨부는 그대로이고 ' + + 'draftId 는 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 취소한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다. ' + + '이미 취소한 발송을 다시 취소하면(응답을 받지 못한 재시도 · 옮기지 못한 취소) 같은 200 — 되돌리기를 마쳤으면 그 뒤 어느 메일함으로 옮겼든 그대로 두고, ' + + '마치지 못했으면(보낸편지함에 남음) 되돌리기만 다시 한다. 메일 서버가 취소를 확인해 주지 않으면 503 MAIL_001 — 옮기지 않는다(다시 취소하면 처음부터 다시 본다).', + }) + @ApiStandardResponse(ScheduledMessageCanceledDto, { + description: '취소하고 임시보관함으로 되돌렸다', + }) + @ApiErrorResponse('RES_001', '없는 메일 (형식이 틀린 id 포함)') + @ApiErrorResponse( + 'MAIL_014', + '이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다', + ) + cancel( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + ) { + return this.scheduled.cancel(user, id); + } +} diff --git a/backend/src/modules/mail/scheduled-messages.service.spec.ts b/backend/src/modules/mail/scheduled-messages.service.spec.ts new file mode 100644 index 0000000..08baf9d --- /dev/null +++ b/backend/src/modules/mail/scheduled-messages.service.spec.ts @@ -0,0 +1,118 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; +import type { MailProvider } from './mail.provider'; +import type { MessageSummary } from './mail.types'; +import { ScheduledMessagesService } from './scheduled-messages.service'; + +const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; + +const summary = (id: string, minute: number): MessageSummary => ({ + id, + threadId: id, + mailboxIds: ['e'], + from: { name: '김아라', email: user.email }, + to: [{ name: null, email: 'doyun.lee@arasystem.test' }], + subject: `예약 ${id}`, + preview: '', + receivedAt: '2026-10-29T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + pendingSend: { + kind: 'scheduled', + sendAt: new Date(Date.UTC(2030, 0, 1, 9, minute)).toISOString(), + }, +}); + +async function failureOf( + run: () => Promise, +): Promise<{ code?: string; reason?: unknown }> { + try { + await run(); + } catch (error) { + return error instanceof BusinessException + ? { code: error.code, reason: error.context?.reason } + : { code: 'NOT_BUSINESS' }; + } + return {}; +} + +describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취소 (9단계)', () => { + const all = ['s1', 's2', 's3', 's4', 's5'].map((id, i) => summary(id, i)); + let provider: { + listScheduledMessages: jest.Mock; + cancelPendingSend: jest.Mock; + }; + let service: ScheduledMessagesService; + + beforeEach(() => { + provider = { + listScheduledMessages: jest.fn().mockResolvedValue(all), + cancelPendingSend: jest.fn().mockResolvedValue(undefined), + }; + service = new ScheduledMessagesService(provider as unknown as MailProvider); + }); + + it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => { + const first = await service.list(user, { limit: 2 }); + expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']); + expect(first.pageInfo).toEqual({ + hasNext: true, + nextCursor: expect.any(String) as string, + total: 5, + }); + + const second = await service.list(user, { + limit: 2, + cursor: first.pageInfo.nextCursor ?? undefined, + }); + expect(second.items.map(({ id }) => id)).toEqual(['s3', 's4']); + + const last = await service.list(user, { + limit: 2, + cursor: second.pageInfo.nextCursor ?? undefined, + }); + expect(last.items.map(({ id }) => id)).toEqual(['s5']); + expect(last.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 5, + }); + }); + + it('예약한 메일이 없으면 빈 쪽 · total 0', async () => { + provider.listScheduledMessages.mockResolvedValue([]); + await expect(service.list(user, { limit: 20 })).resolves.toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + }); + + it('다른 목록의 커서 · 깨진 커서는 엔진에 묻기 전에 VAL_001', async () => { + for (const cursor of [ + encodeOffsetCursor(2, 'contacts'), + 'bm90LWEtY3Vyc29y', + '!!', + ]) { + expect( + await failureOf(() => service.list(user, { limit: 20, cursor })), + ).toMatchObject({ code: 'VAL_001' }); + } + expect(provider.listScheduledMessages).not.toHaveBeenCalled(); + }); + + it('취소 — 포트에 넘기고 같은 메일 id 를 draftId 로 돌려준다 (실패는 포트의 코드 그대로)', async () => { + await expect(service.cancel(user, 'm1')).resolves.toEqual({ + draftId: 'm1', + }); + expect(provider.cancelPendingSend).toHaveBeenCalledWith(user.email, 'm1'); + + provider.cancelPendingSend.mockRejectedValueOnce( + new BusinessException('MAIL_014', { context: { reason: 'x' } }), + ); + expect(await failureOf(() => service.cancel(user, 'm1'))).toMatchObject({ + code: 'MAIL_014', + }); + }); +}); diff --git a/backend/src/modules/mail/scheduled-messages.service.ts b/backend/src/modules/mail/scheduled-messages.service.ts new file mode 100644 index 0000000..2439d8f --- /dev/null +++ b/backend/src/modules/mail/scheduled-messages.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { + type CursorPaginationQueryDto, + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; +import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; +import type { MessageSummary } from './mail.types'; + +/** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */ +const SCHEDULED_SCOPE = 'scheduled-messages'; + +/** + * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소. + * 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다). + * 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다 + */ +@Injectable() +export class ScheduledMessagesService { + constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {} + + /** + * 예약한 메일 — 보낼 시각 차례, total 포함 (화면의 「예약됨」 통 수). 커서는 위치다 — 차례 사이에 보내지거나 취소되면 한두 개가 겹치거나 빠질 수 있다. + * 커서는 엔진에 묻기 전에 본다 — 다른 목록 · 깨진 커서는 VAL_001 + */ + async list( + user: AuthenticatedUser, + query: CursorPaginationQueryDto, + ): Promise> { + let start = 0; + if (query.cursor !== undefined) { + const reading = decodeOffsetCursor(query.cursor, SCHEDULED_SCOPE); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-scheduled-cursor:${reading.problem}` }, + }); + } + start = reading.offset; + } + + const all = await this.provider.listScheduledMessages(user.email); + const items = all.slice(start, start + query.limit); + const next = start + items.length; + const hasNext = next < all.length; + return toPaginatedResult(items, { + hasNext, + nextCursor: hasNext ? encodeOffsetCursor(next, SCHEDULED_SCOPE) : null, + total: all.length, + }); + } + + /** + * 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId). + * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다) + */ + async cancel( + user: AuthenticatedUser, + id: string, + ): Promise<{ draftId: string }> { + await this.provider.cancelPendingSend(user.email, id); + return { draftId: id }; + } +} diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts index 0160da3..263ee52 100644 --- a/backend/src/shared/utils/mask.spec.ts +++ b/backend/src/shared/utils/mask.spec.ts @@ -57,6 +57,32 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => { // 값 안의 = 도 값이다 expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>'); }); + + it('외부 이미지 중계의 표(경로)는 길이만 남긴다 — 쿼리가 붙어도, 다른 경로는 그대로 (9단계)', () => { + const token = 'AbC_-1'.repeat(20); + expect(maskUrlForLog(`/api/mail-images/${token}`)).toBe( + '/api/mail-images/<120자>', + ); + expect(maskUrlForLog(`/api/mail-images/${token}?q=abc&x=1`)).toBe( + '/api/mail-images/<120자>?q=<3자>&x=1', + ); + expect(maskUrlForLog('/api/mail-images/')).toBe('/api/mail-images/'); + expect(maskUrlForLog('/api/mail-imagesx/abc')).toBe( + '/api/mail-imagesx/abc', + ); + expect(maskUrlForLog('/api/messages/abc?images=show')).toBe( + '/api/messages/abc?images=show', + ); + }); + + it('외부 이미지 중계의 표는 경로의 대소문자와 상관없이 길이만 남긴다 — 라우팅이 대소문자를 가리지 않는다 (앞부분은 들어온 그대로)', () => { + expect(maskUrlForLog('/API/MAIL-IMAGES/AbCdef')).toBe( + '/API/MAIL-IMAGES/<6자>', + ); + expect(maskUrlForLog('/Api/Mail-Images/AbCdef?q=x')).toBe( + '/Api/Mail-Images/<6자>?q=<1자>', + ); + }); }); describe('maskEmail', () => { diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts index 1bbadd0..8179613 100644 --- a/backend/src/shared/utils/mask.ts +++ b/backend/src/shared/utils/mask.ts @@ -17,6 +17,14 @@ const MASK = '***'; */ const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i; +/** + * 값을 남기지 않는 경로 조각 — 외부 이미지 중계의 표(`/api/mail-images/<표>` — 9단계). + * 표는 원래 주소(추적 번호가 든다)를 서버 비밀로 암호화한 글이라 풀리지는 않지만, 4,000자까지 길고 로그에 둘 까닭이 없다 — 길이만 남긴다 + * (계약 2절 "외부 이미지 보기"의 로그). 경로의 대소문자는 가리지 않는다 — Express 의 라우팅이 가리지 않아(`/API/MAIL-IMAGES/<표>` 도 처리기에 닿는다) + * 그 요청이 401 · 429 로 끝나면 전역 필터의 로그에 표가 남는다 (2026-10-29 리뷰). 앞부분은 들어온 모양 그대로 둔다 + */ +const REDACTED_PATH = /^(\/api\/mail-images\/)([^/?#]+)/i; + /** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */ function decodeQueryPart(part: string): string { try { @@ -43,11 +51,16 @@ export function maskEmail(email: string): string { /** * 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다. - * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). + * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). 외부 이미지 중계의 표(경로)도 길이만 (REDACTED_PATH). * * 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>' + * '/api/mail-images/AbC…(120자)' → '/api/mail-images/<120자>' */ -export function maskUrlForLog(url: string): string { +export function maskUrlForLog(rawUrl: string): string { + const url = rawUrl.replace( + REDACTED_PATH, + (_whole, prefix: string, token: string) => `${prefix}<${token.length}자>`, + ); const queryStart = url.indexOf('?'); if (queryStart < 0) return url; diff --git a/backend/src/shared/utils/public-address.spec.ts b/backend/src/shared/utils/public-address.spec.ts new file mode 100644 index 0000000..9e4c9f2 --- /dev/null +++ b/backend/src/shared/utils/public-address.spec.ts @@ -0,0 +1,89 @@ +import { isPublicIpAddress } from './public-address'; + +describe('isPublicIpAddress — 바깥 이미지 중계가 연결해도 되는 주소', () => { + it.each([ + '8.8.8.8', + '1.1.1.1', + '93.184.216.34', + '172.15.255.255', + '172.32.0.1', + '100.63.255.255', + '100.128.0.1', + '192.0.1.1', + '198.17.255.255', + '198.20.0.1', + '223.255.255.255', + '2606:4700:4700::1111', + '2001:4860:4860::8888', + '2a00:1450:4001:80b::200e', + ])('공인 주소 %s 는 받는다', (address) => { + expect(isPublicIpAddress(address)).toBe(true); + }); + + it.each([ + ['이 망', '0.0.0.0'], + ['이 망', '0.1.2.3'], + ['사설', '10.0.0.1'], + ['사설', '172.16.0.1'], + ['사설 — 도커 앱 망(APP_NETWORK_SUBNET 기본)', '172.16.71.10'], + ['사설 — 도커 edge 망(EDGE_NETWORK_SUBNET 기본)', '172.16.72.3'], + ['사설', '172.31.255.255'], + ['사설', '192.168.0.1'], + ['CGNAT', '100.64.0.1'], + ['CGNAT', '100.127.255.255'], + ['루프백', '127.0.0.1'], + ['루프백', '127.255.255.254'], + ['링크 로컬(클라우드 메타데이터)', '169.254.169.254'], + ['IETF 프로토콜용', '192.0.0.8'], + ['문서용', '192.0.2.1'], + ['6to4 릴레이', '192.88.99.1'], + ['벤치마크', '198.18.0.1'], + ['벤치마크', '198.19.255.255'], + ['문서용', '198.51.100.7'], + ['문서용', '203.0.113.9'], + ['멀티캐스트', '224.0.0.1'], + ['멀티캐스트', '239.255.255.250'], + ['예약', '240.0.0.1'], + ['브로드캐스트', '255.255.255.255'], + ])('IPv4 %s 대역(%s)은 막는다', (_label, address) => { + expect(isPublicIpAddress(address)).toBe(false); + }); + + it.each([ + ['루프백', '::1'], + ['지정 안 됨', '::'], + ['IPv4 대응 — 루프백', '::ffff:127.0.0.1'], + ['IPv4 대응 — 공인 주소여도 막는다', '::ffff:8.8.8.8'], + ['IPv4 대응(16진)', '::ffff:7f00:1'], + ['IPv4 호환(옛)', '::127.0.0.1'], + ['NAT64', '64:ff9b::7f00:1'], + ['NAT64 로컬', '64:ff9b:1::1'], + ['버리는 대역', '100::1'], + ['고유 로컬', 'fc00::1'], + ['고유 로컬', 'fd12:3456:789a::1'], + ['링크 로컬', 'fe80::1'], + ['사이트 로컬(옛)', 'fec0::1'], + ['멀티캐스트', 'ff02::1'], + ['Teredo', '2001:0:4136:e378:8000:63bf:3fff:fdd2'], + ['IETF 프로토콜용', '2001:2::1'], + ['문서용', '2001:db8::1'], + ['문서용(새)', '3fff::1'], + ['6to4 — 안에 사설 IPv4', '2002:c0a8:101::1'], + ])('IPv6 %s 대역(%s)은 막는다', (_label, address) => { + expect(isPublicIpAddress(address)).toBe(false); + }); + + it.each([ + '', + 'localhost', + 'example.com', + '[2001:4860:4860::8888]', + '8.8.8', + '256.1.1.1', + '08.8.8.8', + 'fe80::1%eth0', + '2001:4860:4860::8888%eth0', + ])('IP 주소가 아닌 글 · 영역이 붙은 주소 %j 은 막는다', (value) => { + expect(isPublicIpAddress(value)).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/public-address.ts b/backend/src/shared/utils/public-address.ts new file mode 100644 index 0000000..08cc7e6 --- /dev/null +++ b/backend/src/shared/utils/public-address.ts @@ -0,0 +1,68 @@ +import { BlockList, isIP } from 'net'; + +/** + * 공인(인터넷에서 닿는) IP 주소인가 — 순수 함수. 바깥 이미지 중계(계약 2절 "외부 이미지 보기")가 연결하기 전에 본다. + * 이 서버가 바깥으로 대신 묻는 곳이 사내 망 · 자기 자신 · 도커 망(메일 엔진 관리 API · DB)이 되지 않게 하는 막이다 (SSRF). + * + * - IPv4: 아래 특수 대역이 아니면 공인 — 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) · + * "이 망"(0/8) · IETF 프로토콜용(192.0.0/24) · 문서용 셋 · 6to4 릴레이(192.88.99/24) · 벤치마크(198.18/15) · 멀티캐스트 · 예약(240/4, 브로드캐스트 포함) + * - IPv6: 전역 유니캐스트(2000::/3) 안에서만 공인으로 본다(허용 목록) — 그 밖(::1 · :: · ::ffff:0:0/96(IPv4 대응) · 64:ff9b::/96 · 64:ff9b:1::/48(NAT64) · + * 100::/64 · fc00::/7 · fe80::/10 · fec0::/10 · ff00::/8 …)은 모두 막는다. 2000::/3 안의 특수 대역 — IETF 프로토콜용(2001::/23 — Teredo 포함) · + * 문서용(2001:db8::/32 · 3fff::/20) · 6to4(2002::/16 — 안에 IPv4 를 담아 사설 대역으로 닿을 수 있다)도 막는다 + * - 우리 도커 망(APP_NETWORK_SUBNET · EDGE_NETWORK_SUBNET)은 사설 대역이라 함께 막힌다 (계약 2절) + * - IP 주소가 아닌 글은 공인이 아니다 + */ + +/** IPv4 특수 대역 [주소, 접두 길이] — RFC 6890 의 표와 같다 */ +const IPV4_SPECIAL: readonly (readonly [string, number])[] = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]; + +/** IPv6 전역 유니캐스트 — 이 안에서만 공인으로 본다 */ +const IPV6_GLOBAL_UNICAST: readonly [string, number] = ['2000::', 3]; + +/** 전역 유니캐스트 안의 특수 대역 */ +const IPV6_SPECIAL: readonly (readonly [string, number])[] = [ + ['2001::', 23], + ['2001:db8::', 32], + ['2002::', 16], + ['3fff::', 20], +]; + +const blockedV4 = new BlockList(); +for (const [network, prefix] of IPV4_SPECIAL) { + blockedV4.addSubnet(network, prefix, 'ipv4'); +} + +const globalV6 = new BlockList(); +globalV6.addSubnet(IPV6_GLOBAL_UNICAST[0], IPV6_GLOBAL_UNICAST[1], 'ipv6'); + +const blockedV6 = new BlockList(); +for (const [network, prefix] of IPV6_SPECIAL) { + blockedV6.addSubnet(network, prefix, 'ipv6'); +} + +/** IP 주소 글(대괄호 없이)이 공인 주소인가 — IP 가 아니면 false. 영역 표시(`%eth0`)가 붙은 IPv6 도 막는다(링크 안에서만 닿는 주소) */ +export function isPublicIpAddress(address: string): boolean { + if (address.includes('%')) return false; + const family = isIP(address); + if (family === 4) return !blockedV4.check(address, 'ipv4'); + if (family === 6) { + return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6'); + } + return false; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 46f397d..94b9451 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -40,9 +40,17 @@ import { MAX_VACATION_TEXT_LENGTH, MAX_VACATION_TEXT_LENGTH_WHEN_OFF, } from '@/modules/mail/mail.constants'; +import { + MAIL_IMAGE_RESOLVER, + MAIL_IMAGE_TRANSPORT, + type MailImageRequest, + type MailImageResponse, + type ResolvedAddress, +} from '@/modules/mail/images/mail-image-fetcher'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { + BlockedSender, Correspondence, DraftContent, DraftSource, @@ -50,6 +58,8 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailBlockAdded, + MailBlockList, MailFilterRule, MailFilterRunOutcome, MailFilterRunRequest, @@ -60,6 +70,7 @@ import type { MailWatch, MailWatchListener, MessageDeletionResult, + MessageDetailSource, MessageListQuery, MessageSource, MessagesPatchResult, @@ -67,6 +78,7 @@ import type { MessageUpdate, OutgoingMessage, PasswordChangeOutcome, + PendingSend, RecipientRejectionReason, SavedDraft, SentMessage, @@ -224,6 +236,25 @@ const NEVER_CONFIGURED_VACATION: MailVacation = { textBody: '', }; +/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */ +const ALREADY_RELEASED_MESSAGE = 'alreadyreleased'; + +/** + * 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로). + * 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다 + */ +const HELD_MESSAGE = 'eheldbyengine'; + +/** 9단계 — 외부 이미지가 든 메일 (대역 엔진의 GET /api/messages/{id}) · 그 바깥 주소 */ +const IMAGE_MESSAGE = 'imagemessage'; +const PUBLIC_IMAGE_URL = 'https://images.e2e.example/logo.png'; +const INTERNAL_IMAGE_URL = 'https://intranet.e2e.example/secret.png'; +/** 대역 이름 풀기 — 공인 주소 · 사설 주소 (그 밖의 이름은 풀지 못한다) */ +const IMAGE_HOSTS = new Map([ + ['images.e2e.example', [{ address: '93.184.216.34', family: 4 }]], + ['intranet.e2e.example', [{ address: '10.0.0.7', family: 4 }]], +]); + /** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */ const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월'; const RAW_MESSAGE = @@ -413,6 +444,37 @@ describe('응답 계약 (e2e)', () => { update: MessageUpdate; }[] = []; + /** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */ + const mailBlocks = new Map(); + const scheduledMessages = new Map(); + const canceledSends: { user: string; id: string }[] = []; + /** 대역 엔진이 받은 한 통 삭제 (DELETE /api/messages/{id}) */ + const deletedMessages: { user: string; id: string }[] = []; + const imageRequests: MailImageRequest[] = []; + const fakeImageResolver = { + resolve: (hostname: string): Promise => { + const addresses = IMAGE_HOSTS.get(hostname); + return addresses + ? Promise.resolve(addresses) + : Promise.reject(new Error('e2e-unknown-host')); + }, + }; + const fakeImageTransport = { + get: (imageRequest: MailImageRequest): Promise => { + imageRequests.push(imageRequest); + const data = pngBytes(48, 16); + return Promise.resolve({ + status: 200, + location: null, + contentLength: data.length, + body: (async function* () { + yield await Promise.resolve(new Uint8Array(data)); + })(), + close: () => undefined, + }); + }, + }; + /** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */ const liveWatches: { user: string; @@ -636,9 +698,21 @@ describe('응답 계약 (e2e)', () => { ); } sentMessages.push({ user, message }); + // 9단계 — 엔진이 붙잡았으면 보낼 시각과 종류 (실제 계산은 jmap/pending-send.ts) + const hold = message.hold; + const pendingSend: PendingSend | null = hold + ? { + kind: hold.kind, + sendAt: + hold.kind === 'scheduled' + ? hold.until + : new Date(Date.now() + hold.seconds * 1_000).toISOString(), + } + : null; return Promise.resolve({ id: `sent${sentMessages.length}`, rejectedRecipients, + pendingSend, }); }, // 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts) @@ -689,6 +763,7 @@ describe('응답 계약 (e2e)', () => { attachments: [], inReplyTo: null, updatedAt, + isCanceledSend: false, }); }, deleteDraft: (user: string, id: string) => { @@ -744,9 +819,14 @@ describe('응답 계약 (e2e)', () => { }), ); } + // 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 failedIds 로 (9단계) + const moving = + update.mailboxId !== undefined || update.isJunk !== undefined; + const fails = (id: string) => + id === GONE_MESSAGE || (moving && id === HELD_MESSAGE); return Promise.resolve({ - updatedIds: ids.filter((id) => id !== GONE_MESSAGE), - failedIds: ids.filter((id) => id === GONE_MESSAGE), + updatedIds: ids.filter((id) => !fails(id)), + failedIds: ids.filter(fails), }); }, deleteFromTrash: ( @@ -964,6 +1044,16 @@ describe('응답 계약 (e2e)', () => { update: MessageUpdate, ): Promise => { singleUpdates.push({ user, id, update }); + if ( + id === HELD_MESSAGE && + (update.mailboxId !== undefined || update.isJunk !== undefined) + ) { + return Promise.reject( + new BusinessException('MAIL_015', { + context: { reason: 'e2e-held-send-locked' }, + }), + ); + } return Promise.resolve({ id, threadId: id, @@ -977,8 +1067,125 @@ describe('응답 계약 (e2e)', () => { isFlagged: false, hasAttachment: false, size: 1, + pendingSend: null, }); }, + // 9단계 — 메일 읽기 (외부 이미지 확인용 한 통만). 정화 · 그림 판정은 html/*.spec.ts · mail.service.spec.ts 가 본다 + getMessage: ( + user: string, + id: string, + ): Promise => { + if (id !== IMAGE_MESSAGE) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ); + } + return Promise.resolve({ + id, + threadId: id, + mailboxIds: ['a'], + from: { name: '바깥 보낸 사람', email: 'news@letters.e2e.example' }, + to: [{ name: null, email: user }], + subject: '그림이 든 메일', + preview: '', + receivedAt: '2026-10-29T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 2_000, + pendingSend: null, + cc: [], + bcc: [], + replyTo: [], + sentAt: null, + messageId: 'image@e2e', + references: [], + rawHtmlBody: `

안녕하세요

로고`, + isRawHtmlBodyTruncated: false, + textBody: '안녕하세요', + isTextBodyTruncated: false, + attachments: [], + inlineParts: [], + isInJunkMailbox: false, + }); + }, + // 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다) + getMailBlocks: (user: string): Promise => + Promise.resolve({ + senders: [...(mailBlocks.get(user) ?? [])], + isActive: true, + }), + addMailBlock: ( + user: string, + sender: BlockedSender, + ): Promise => { + const current = mailBlocks.get(user) ?? []; + const existing = current.find( + ({ address }) => address === sender.address, + ); + if (existing) { + return Promise.resolve({ sender: existing, created: false }); + } + mailBlocks.set(user, [sender, ...current]); + return Promise.resolve({ sender, created: true }); + }, + removeMailBlock: (user: string, address: string): Promise => { + const current = mailBlocks.get(user) ?? []; + if (!current.some((entry) => entry.address === address)) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mail-block-not-found' }, + }), + ); + } + mailBlocks.set( + user, + current.filter((entry) => entry.address !== address), + ); + return Promise.resolve(); + }, + // 한 통 삭제 — 붙잡힌 메일은 409 MAIL_015 (9단계), 없으면 RES_001 (조건부 영구 삭제는 jmap-mail.provider.spec.ts) + deleteMessage: (user: string, id: string): Promise => { + if (id === HELD_MESSAGE) { + return Promise.reject( + new BusinessException('MAIL_015', { + context: { reason: 'e2e-held-send-locked' }, + }), + ); + } + if (id === GONE_MESSAGE) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ); + } + deletedMessages.push({ user, id }); + return Promise.resolve(); + }, + // 9단계 — 붙잡은 발송 (엔진의 기다리는 제출 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다) + listScheduledMessages: (user: string): Promise => + Promise.resolve(scheduledMessages.get(user) ?? []), + cancelPendingSend: (user: string, id: string): Promise => { + if (id === ALREADY_RELEASED_MESSAGE) { + return Promise.reject( + new BusinessException('MAIL_014', { + context: { reason: 'e2e-not-a-pending-send' }, + }), + ); + } + if (id === GONE_MESSAGE) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ); + } + canceledSends.push({ user, id }); + return Promise.resolve(); + }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -986,6 +1193,11 @@ describe('응답 계약 (e2e)', () => { .useValue(fakeContactsProvider) .overrideProvider(DIRECTORY_PROVIDER) .useValue(fakeDirectoryProvider) + // 9단계 — 바깥 이미지 받기는 실제 네트워크에 나가지 않는다 (SSRF 검사 · 다시 가기 · 크기는 images/*.spec.ts) + .overrideProvider(MAIL_IMAGE_RESOLVER) + .useValue(fakeImageResolver) + .overrideProvider(MAIL_IMAGE_TRANSPORT) + .useValue(fakeImageTransport) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1023,6 +1235,7 @@ describe('응답 계약 (e2e)', () => { 'DELETE FROM mail_signature_images WHERE user_email LIKE $1', 'DELETE FROM mail_signatures WHERE user_email LIKE $1', 'DELETE FROM mail_directory WHERE email LIKE $1', + 'DELETE FROM mail_settings WHERE user_email LIKE $1', ]) { await dataSource.query(statement, [TEST_DOMAIN_PATTERN]); } @@ -3189,6 +3402,8 @@ describe('응답 계약 (e2e)', () => { data: { id: expect.stringMatching(/^sent\d+$/) as string, rejectedRecipients: [], + // 9단계 — 처음 설정은 보내기 취소 10초: 엔진이 붙잡았다가 보낸다 + pendingSend: { kind: 'undo', sendAt: expect.any(String) as string }, }, }); }); @@ -3209,6 +3424,7 @@ describe('응답 계약 (e2e)', () => { { email: NOBODY, reason: 'not-found' }, { email: BUSY, reason: 'temporary' }, ], + pendingSend: { kind: 'undo', sendAt: expect.any(String) as string }, }); expect(deletedDrafts).toContain(draftId); }); @@ -4376,6 +4592,565 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 보낸 사람 차단 · 보내기와 읽기 설정 (계약 2절). + * 엔진 쪽(붙잡기 · 취소 · Sieve · 바깥 받기의 SSRF 검사)은 jmap/*.spec.ts · images/*.spec.ts 가 본다 — + * 여기서는 HTTP 형식 · 요청 검사 · 설정(DB 의 mail_settings)이 발송과 읽기에 닿는지를 본다 + */ + describe('9단계 — 보내기 취소 · 예약 · 외부 이미지 · 차단 · 설정', () => { + const patchSettings = (email: string, payload: object) => + request(server) + .patch('/api/mail-settings') + .set('Cookie', cookieOf(email)) + .send(payload); + + describe('보내기 · 읽기 설정 (/api/mail-settings)', () => { + const ME = 'settings.me@e2e.arasystem.test'; + + it('로그인하지 않았으면 401 AUTH_001', async () => { + for (const res of [ + await request(server).get('/api/mail-settings'), + await request(server) + .patch('/api/mail-settings') + .send({ undoSendSeconds: 5 }), + ]) { + expect(res.status).toBe(401); + expect(body(res).error?.code).toBe('AUTH_001'); + } + }); + + it('처음에는 처음 값 — 보내기 취소 10초 · 외부 이미지 늘 묻기 (DB 에 줄이 없어도)', async () => { + const res = await request(server) + .get('/api/mail-settings') + .set('Cookie', cookieOf(ME)) + .expect(200); + + expect(body(res)).toEqual({ + success: true, + data: { undoSendSeconds: 10, externalImages: 'ask' }, + }); + }); + + it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => { + const first = await patchSettings(ME, { + externalImages: 'always', + }).expect(200); + expect(dataOf(first)).toEqual({ + undoSendSeconds: 10, + externalImages: 'always', + }); + + const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect( + 200, + ); + expect(dataOf(second)).toEqual({ + undoSendSeconds: 30, + externalImages: 'always', + }); + + const read = await request(server) + .get('/api/mail-settings') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual({ + undoSendSeconds: 30, + externalImages: 'always', + }); + const rows: { + undo_send_seconds: number; + external_images: string; + }[] = await dataSource.query( + 'SELECT undo_send_seconds, external_images FROM mail_settings WHERE user_email = $1', + [ME], + ); + expect(rows).toEqual([ + { undo_send_seconds: 30, external_images: 'always' }, + ]); + }); + + it.each<[string, object]>([ + ['빈 본문(하나 이상)', {}], + ['고를 수 없는 초', { undoSendSeconds: 7 }], + ['글로 쓴 초', { undoSendSeconds: '10' }], + ['null 초', { undoSendSeconds: null }], + ['모르는 외부 이미지 값', { externalImages: 'never' }], + ['null 외부 이미지', { externalImages: null }], + ['모르는 칸', { theme: 'dark' }], + ])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => { + const res = await patchSettings(ME, payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + }); + }); + + describe('보내기 취소 · 예약 발송 (POST /api/messages · /api/scheduled-messages)', () => { + const ME = 'schedule.me@e2e.arasystem.test'; + const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }; + const MINUTE_MS = 60_000; + const DAY_MS = 24 * 60 * MINUTE_MS; + + const send = (payload: object = {}) => + request(server) + .post('/api/messages') + .set('Cookie', cookieOf(ME)) + .send({ to: [DOYUN], subject: '제목', body: '본문', ...payload }); + const lastHold = () => + sentMessages[sentMessages.length - 1]?.message.hold; + const pendingOf = (res: Response) => + dataOf<{ pendingSend: PendingSend | null }>(res).pendingSend; + const summary = (id: string, sendAt: string): MessageSummary => ({ + id, + threadId: id, + mailboxIds: ['e'], + from: { name: '나', email: ME }, + to: [DOYUN], + subject: `예약 ${id}`, + preview: '', + receivedAt: '2026-10-29T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + pendingSend: { kind: 'scheduled', sendAt }, + }); + + it('sendAt 이 있으면 그 시각(소수 초는 버린다)까지 붙잡는다 — 201 의 pendingSend 는 예약, 보내기 취소 시간을 더하지 않는다', async () => { + const second = + Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000; + const until = new Date(second).toISOString(); + + const res = await send({ + sendAt: new Date(second + 750).toISOString(), + }).expect(201); + + expect(pendingOf(res)).toEqual({ kind: 'scheduled', sendAt: until }); + expect(lastHold()).toEqual({ kind: 'scheduled', until }); + }); + + it.each<[string, () => string]>([ + [ + '4분 뒤 — 5분보다 이르다', + () => new Date(Date.now() + 4 * MINUTE_MS).toISOString(), + ], + [ + '7일하고 1분 뒤', + () => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(), + ], + ['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()], + ['UTC 가 아님(+09:00)', () => '2030-12-01T09:00:00+09:00'], + ['날짜가 아님', () => '내일 아침'], + ])( + '예약 시각이 %s 이면 400 VAL_001 — 엔진에 보내지 않는다', + async (_label, sendAt) => { + const before = sentMessages.length; + const res = await send({ sendAt: sendAt() }).expect(400); + + expect(body(res).error?.code).toBe('VAL_001'); + expect(sentMessages).toHaveLength(before); + }, + ); + + it('sendAt 이 없으면 설정의 보내기 취소 초만큼 붙잡는다 — 0초면 붙잡지 않고 pendingSend 는 null', async () => { + const undo = await send().expect(201); + expect(pendingOf(undo)).toEqual({ + kind: 'undo', + sendAt: expect.any(String) as string, + }); + expect(lastHold()).toEqual({ kind: 'undo', seconds: 10 }); + + await patchSettings(ME, { undoSendSeconds: 0 }).expect(200); + const now = await send().expect(201); + expect(pendingOf(now)).toBeNull(); + expect(lastHold()).toBeUndefined(); + + await patchSettings(ME, { undoSendSeconds: 20 }).expect(200); + await send().expect(201); + expect(lastHold()).toEqual({ kind: 'undo', seconds: 20 }); + }); + + it('예약한 메일 — { items, pageInfo }(total 포함), 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => { + const at = (minutes: number) => + new Date(Date.UTC(2030, 0, 1, 9, minutes)).toISOString(); + scheduledMessages.set(ME, [ + summary('s1', at(0)), + summary('s2', at(10)), + summary('s3', at(20)), + ]); + + const first = await request(server) + .get('/api/scheduled-messages?limit=2') + .set('Cookie', cookieOf(ME)) + .expect(200); + const page = dataOf>(first); + expect(page.items.map(({ id }) => id)).toEqual(['s1', 's2']); + expect(page.items[0].pendingSend).toEqual({ + kind: 'scheduled', + sendAt: at(0), + }); + expect(page.pageInfo).toEqual({ + hasNext: true, + nextCursor: expect.any(String) as string, + total: 3, + }); + + const second = await request(server) + .get( + `/api/scheduled-messages?limit=2&cursor=${encodeURIComponent(page.pageInfo.nextCursor ?? '')}`, + ) + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf>(second)).toEqual({ + items: [expect.objectContaining({ id: 's3' }) as MessageSummary], + pageInfo: { hasNext: false, nextCursor: null, total: 3 }, + }); + + const broken = await request(server) + .get('/api/scheduled-messages?cursor=bm90LWEtY3Vyc29y') + .set('Cookie', cookieOf(ME)) + .expect(400); + expect(body(broken).error?.code).toBe('VAL_001'); + await request(server).get('/api/scheduled-messages').expect(401); + }); + + it('취소 — 200 { draftId }(같은 메일 id), 이미 보냈으면 409 MAIL_014, 없거나 id 형식이 틀리면 404 RES_001 (엔진에 묻지 않는다)', async () => { + const cancel = (id: string) => + request(server) + .delete(`/api/scheduled-messages/${id}`) + .set('Cookie', cookieOf(ME)); + + const ok = await cancel('eheldmsg1').expect(200); + expect(body(ok)).toEqual({ + success: true, + data: { draftId: 'eheldmsg1' }, + }); + expect(canceledSends).toContainEqual({ user: ME, id: 'eheldmsg1' }); + + const released = await cancel(ALREADY_RELEASED_MESSAGE).expect(409); + expect(body(released).error?.code).toBe('MAIL_014'); + const gone = await cancel(GONE_MESSAGE).expect(404); + expect(body(gone).error?.code).toBe('RES_001'); + const before = canceledSends.length; + const malformed = await cancel('a"b').expect(404); + expect(body(malformed).error?.code).toBe('RES_001'); + expect(canceledSends).toHaveLength(before); + }); + + it('같은 발송을 두 번 취소해도 둘 다 200 { draftId } — 응답을 받지 못한 재시도 (멱등은 jmap-mail.provider.spec.ts)', async () => { + const cancel = () => + request(server) + .delete('/api/scheduled-messages/etwicecanceled') + .set('Cookie', cookieOf(ME)); + + for (const res of [await cancel(), await cancel()]) { + expect(res.status).toBe(200); + expect(body(res)).toEqual({ + success: true, + data: { draftId: 'etwicecanceled' }, + }); + } + }); + + describe('붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (409 MAIL_015)', () => { + const patchOne = (payload: object) => + request(server) + .patch(`/api/messages/${HELD_MESSAGE}`) + .set('Cookie', cookieOf(ME)) + .send(payload); + + it.each<[string, object]>([ + ['옮기기', { mailboxId: 'b' }], + ['스팸 신고', { isJunk: true }], + ['옮기기와 읽음 함께', { mailboxId: 'b', isRead: true }], + ])( + '한 통 %s 는 409 MAIL_015 — 표준 실패 형식', + async (_label, payload) => { + const res = await patchOne(payload).expect(409); + expect(body(res)).toEqual({ + success: false, + error: { + code: 'MAIL_015', + message: + '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', + traceId: expect.any(String) as string, + }, + }); + }, + ); + + it('한 통의 읽음 · 중요 표시는 된다 — 200', async () => { + const res = await patchOne({ isFlagged: true, isRead: true }).expect( + 200, + ); + expect(dataOf(res).id).toBe(HELD_MESSAGE); + }); + + it('여러 통 옮기기는 200 — 붙잡힌 메일만 failedIds, 나머지는 옮긴다', async () => { + const res = await request(server) + .patch('/api/messages') + .set('Cookie', cookieOf(ME)) + .send({ ids: ['eaaaaab', HELD_MESSAGE], mailboxId: 'b' }) + .expect(200); + expect(dataOf(res)).toEqual({ + updatedIds: ['eaaaaab'], + failedIds: [HELD_MESSAGE], + }); + }); + + it('한 통 삭제(DELETE)는 409 MAIL_015 — 지우지 않는다', async () => { + const before = deletedMessages.length; + const res = await request(server) + .delete(`/api/messages/${HELD_MESSAGE}`) + .set('Cookie', cookieOf(ME)) + .expect(409); + expect(body(res).error?.code).toBe('MAIL_015'); + expect(deletedMessages).toHaveLength(before); + + // 붙잡히지 않은 메일은 지금처럼 204 + await request(server) + .delete('/api/messages/eaaaaab') + .set('Cookie', cookieOf(ME)) + .expect(204); + expect(deletedMessages).toContainEqual({ user: ME, id: 'eaaaaab' }); + }); + }); + }); + + describe('외부 이미지 (GET /api/messages/{id}?images=show · GET /api/mail-images/{token})', () => { + const ME = 'images.me@e2e.arasystem.test'; + const OTHER = 'images.other@e2e.arasystem.test'; + + interface ReadDetail { + htmlBody: string | null; + hasBlockedImages: boolean; + imagesShown: boolean; + isInJunkMailbox?: unknown; + } + const read = (query = '', who = ME) => + request(server) + .get(`/api/messages/${IMAGE_MESSAGE}${query}`) + .set('Cookie', cookieOf(who)); + /** 본문의 중계 주소들 (sanitize-html 이 & 를 & 로 쓰지 않는 base64url 표) */ + const proxiedSources = (html: string | null) => + [...(html ?? '').matchAll(/src="(\/api\/mail-images\/[^"]+)"/g)].map( + (match) => match[1], + ); + + it('images 의 모르는 값 · 모르는 쿼리는 400 VAL_001', async () => { + for (const query of ['?images=1', '?images=SHOW', '?show=1']) { + const res = await read(query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('묻기(처음 설정)면 바깥 그림을 지우고 hasBlockedImages · imagesShown false — 스팸메일함 여부는 응답에 싣지 않는다', async () => { + const res = await read().expect(200); + const detail = dataOf(res); + + expect(detail.hasBlockedImages).toBe(true); + expect(detail.imagesShown).toBe(false); + expect(detail.htmlBody).not.toContain('e2e.example'); + expect(proxiedSources(detail.htmlBody)).toEqual([]); + expect(detail).not.toHaveProperty('isInJunkMailbox'); + }); + + it('images=show 면 그림 주소를 이 서버의 중계 주소로 바꾼다 — 받은 그림은 표준 래퍼 없이 그대로, 머리글은 계약대로', async () => { + const res = await read('?images=show').expect(200); + const detail = dataOf(res); + expect(detail.imagesShown).toBe(true); + expect(detail.hasBlockedImages).toBe(false); + expect(detail.htmlBody).not.toContain('e2e.example'); + const [publicImage, internalImage] = proxiedSources(detail.htmlBody); + expect(internalImage).toBeDefined(); + + const before = imageRequests.length; + // superagent 는 image/* 를 Buffer 로 받는다 + const image = await request(server) + .get(publicImage) + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(Buffer.isBuffer(image.body)).toBe(true); + expect(image.body as Buffer).toEqual(pngBytes(48, 16)); + expect(image.headers['content-type']).toBe('image/png'); + expect(image.headers['content-length']).toBe( + String(pngBytes(48, 16).length), + ); + expect(image.headers['x-content-type-options']).toBe('nosniff'); + expect(image.headers['cache-control']).toBe('private, max-age=3600'); + expect(image.headers['content-disposition']).toBe('inline'); + expect(image.headers['content-security-policy']).toBe( + "default-src 'none'; sandbox", + ); + + // 바깥으로는 확인한 주소로만 · 쿠키 · Referer 없이 + expect(imageRequests).toHaveLength(before + 1); + const sent = imageRequests[imageRequests.length - 1]; + expect(sent.url.href).toBe(PUBLIC_IMAGE_URL); + expect(sent.addresses).toEqual([ + { address: '93.184.216.34', family: 4 }, + ]); + expect(Object.keys(sent.headers).sort()).toEqual([ + 'accept', + 'accept-encoding', + 'user-agent', + ]); + + // 사설 주소로 풀리는 이름은 묻지 않고 404 RES_001 (까닭을 가리지 않는다) + const blocked = await request(server) + .get(internalImage) + .set('Cookie', cookieOf(ME)) + .expect(404); + expect(body(blocked)).toEqual({ + success: false, + error: { + code: 'RES_001', + message: expect.any(String) as string, + traceId: blocked.headers['x-request-id'], + }, + }); + expect(imageRequests).toHaveLength(before + 1); + }); + + it('표는 그 직원 것만 — 다른 직원 · 고친 표 · 없는 표는 404 RES_001, 로그인하지 않았으면 401 AUTH_001', async () => { + const [source] = proxiedSources( + dataOf(await read('?images=show').expect(200)).htmlBody, + ); + const token = source.slice('/api/mail-images/'.length); + const tampered = `${token.slice(0, -2)}${token.endsWith('AA') ? 'BB' : 'AA'}`; + + for (const [path, who] of [ + [source, OTHER], + [`/api/mail-images/${tampered}`, ME], + ['/api/mail-images/not-a-token', ME], + ] as const) { + const res = await request(server) + .get(path) + .set('Cookie', cookieOf(who)) + .expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + const anonymous = await request(server).get(source).expect(401); + expect(body(anonymous).error?.code).toBe('AUTH_001'); + }); + + it('설정이 늘 보기(always)면 images 없이도 보인다', async () => { + await patchSettings(ME, { externalImages: 'always' }).expect(200); + const detail = dataOf(await read().expect(200)); + + expect(detail.imagesShown).toBe(true); + expect(proxiedSources(detail.htmlBody)).toHaveLength(2); + await patchSettings(ME, { externalImages: 'ask' }).expect(200); + }); + }); + + describe('보낸 사람 차단 (/api/mail-blocks)', () => { + const ME = 'blocks.me@e2e.arasystem.test'; + const add = (address: unknown, extra: object = {}) => + request(server) + .post('/api/mail-blocks') + .set('Cookie', cookieOf(ME)) + .send({ address, ...extra }); + const list = async () => + dataOf<{ senders: BlockedSender[]; isActive: boolean }>( + await request(server) + .get('/api/mail-blocks') + .set('Cookie', cookieOf(ME)) + .expect(200), + ); + + it('처음에는 빈 목록 · 동작함, 로그인하지 않았으면 401 AUTH_001', async () => { + await expect(list()).resolves.toEqual({ senders: [], isActive: true }); + const res = await request(server).get('/api/mail-blocks').expect(401); + expect(body(res).error?.code).toBe('AUTH_001'); + }); + + it('차단 — 앞뒤 공백을 빼고 소문자로, 새로 차단하면 201 · 이미 있으면 200(처음 차단한 시각) · 목록은 최근 것부터', async () => { + const created = await add(' Kim@Spam.Example ').expect(201); + const kim = dataOf(created); + expect(kim).toEqual({ + address: 'kim@spam.example', + blockedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.+Z$/) as string, + }); + + const again = await add('kim@spam.example').expect(200); + expect(dataOf(again)).toEqual(kim); + + await add('@Ads.Example.org').expect(201); + // 여러 사람이 쓰는 메일 서비스도 주소로는 막는다 + await add('friend@gmail.com').expect(201); + expect((await list()).senders.map(({ address }) => address)).toEqual([ + 'friend@gmail.com', + '@ads.example.org', + 'kim@spam.example', + ]); + }); + + it.each<[string, unknown]>([ + ['내 주소(대소문자 무시)', ME.toUpperCase()], + ['사내 도메인', '@e2e.arasystem.test'], + ['사내 하위 도메인', '@mail.e2e.arasystem.test'], + ['사내 직원 주소', 'doyun.lee@e2e.arasystem.test'], + ['여러 사람이 쓰는 메일 서비스의 도메인', '@Naver.com'], + ['주소 모양이 아님', 'not an address'], + ['${ 가 든 주소', '${x}@spam.example'], + ['IP 주소 도메인', '@127.0.0.1'], + ['점 없는 도메인', '@localhost'], + ['255자', `${'a'.repeat(240)}@spam.example`], + ['빈 글', ' '], + ['글이 아님', 42], + ])( + '막을 수 없거나 모양이 틀리면 400 VAL_001 — %s', + async (_label, address) => { + const res = await add(address).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + }, + ); + + it('모르는 칸이 있으면 400 VAL_001', async () => { + const res = await add('lee@spam.example', { note: '메모' }).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + }); + + it('차단 풀기 — URL 인코딩한 주소(대소문자 무시)는 200 { address }, 없거나 모양이 틀리면 404 RES_001', async () => { + await add('lee@spam.example').expect(201); + const removed = await request(server) + .delete(`/api/mail-blocks/${encodeURIComponent('LEE@spam.example')}`) + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(body(removed)).toEqual({ + success: true, + data: { address: 'lee@spam.example' }, + }); + expect( + (await list()).senders.map(({ address }) => address), + ).not.toContain('lee@spam.example'); + + // 도메인 차단도 같은 길 — `@` 를 인코딩한 경로(%40…)를 풀어 도메인으로 맞춘다 (대소문자 무시) + await add('@Domain-Spam.example').expect(201); + const domainRemoved = await request(server) + .delete('/api/mail-blocks/%40DOMAIN-spam.example') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(body(domainRemoved)).toEqual({ + success: true, + data: { address: '@domain-spam.example' }, + }); + expect( + (await list()).senders.map(({ address }) => address), + ).not.toContain('@domain-spam.example'); + + for (const path of [ + encodeURIComponent('lee@spam.example'), + encodeURIComponent('not an address'), + ]) { + const res = await request(server) + .delete(`/api/mail-blocks/${path}`) + .set('Cookie', cookieOf(ME)) + .expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). @@ -4406,6 +5181,10 @@ describe('응답 계약 (e2e)', () => { ['post', '/api/mail-attachments'], ['patch', '/api/contacts/abc'], ['post', '/api/contact-restorations'], + ['patch', '/api/mail-settings'], + ['post', '/api/mail-blocks'], + ['delete', '/api/mail-blocks/kim%40spam.example'], + ['delete', '/api/scheduled-messages/eaaaaab'], ] as const)( 'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)', async (method, path) => { diff --git a/backend/test/image-fixtures.ts b/backend/test/image-fixtures.ts index 0057164..ab20470 100644 --- a/backend/test/image-fixtures.ts +++ b/backend/test/image-fixtures.ts @@ -73,3 +73,13 @@ export function jpegBytes( Buffer.from([0xff, 0xd9]), ]); } + +/** WebP 머리 — 'RIFF' 크기 'WEBP' 첫 덩어리(VP8 · VP8L · VP8X). `size` 바이트가 되게 0 으로 채운다 (바깥 이미지 중계 — 9단계) */ +export function webpBytes(chunk = 'VP8 ', size = 30): Buffer { + const data = Buffer.alloc(Math.max(size, 20)); + data.write('RIFF', 0, 'ascii'); + data.writeUInt32LE(data.length - 8, 4); + data.write('WEBP', 8, 'ascii'); + data.write(chunk, 12, 'ascii'); + return data; +} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 19cc4a5..396bfd9 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -25,7 +25,9 @@ const Map kErrorCodeMessages = { 'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', 'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', - 'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', + 'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.', + 'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', }; diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 1841bb2..4728a48 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -25,7 +25,9 @@ export const ERROR_CODE_MESSAGES = { MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', - MAIL_013: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', + MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.', + MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', } as const -- 2.52.0 From b99ccfbf6cb9698d4b546d49ba8b34c52e671c26 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 22:08:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(frontend):=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=C2=B7=20=EC=9D=B8=EC=87=84=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=20=EC=82=AC=EB=9E=8C=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]), 인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절. 리뷰로 고친 것: - 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다). 읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다. - 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도). - 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다. - 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도). - 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다. - 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게), 차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역. Co-Authored-By: Claude Opus 5.5 --- .../components/feedback/ToastCountdown.vue | 74 ++ .../src/components/feedback/ToastHost.spec.ts | 148 ++++ .../src/components/feedback/ToastHost.vue | 106 ++- .../src/components/mail/ComposeFooter.spec.ts | 67 ++ .../src/components/mail/ComposeFooter.vue | 193 ++++- .../mail/ComposeScheduleDialog.spec.ts | 146 ++++ .../components/mail/ComposeScheduleDialog.vue | 336 ++++++++ .../src/components/mail/MailBodyFrame.spec.ts | 42 +- .../src/components/mail/MailBodyFrame.vue | 26 +- frontend/src/components/mail/MailCheckbox.vue | 29 +- .../src/components/mail/MailListFrame.vue | 15 +- .../src/components/mail/MailListHeader.vue | 12 +- .../src/components/mail/MailboxMoreButton.vue | 61 +- frontend/src/components/mail/MailboxPanel.vue | 5 + .../components/mail/MailboxScheduledRow.vue | 95 +++ frontend/src/components/mail/MailboxTree.vue | 51 +- .../components/mail/MessageBlockDialog.vue | 239 ++++++ .../components/mail/MessageDeleteButton.vue | 86 +- .../src/components/mail/MessageList.spec.ts | 21 + .../src/components/mail/MessageListItem.vue | 33 +- .../src/components/mail/MessageMoreMenu.vue | 126 ++- .../src/components/mail/MessageReader.spec.ts | 19 +- .../mail/MessageReader.stage9.spec.ts | 738 ++++++++++++++++++ .../src/components/mail/MessageReader.vue | 149 ++-- .../components/mail/MessageReaderNotices.vue | 266 +++++++ .../src/components/mail/MessageSpamButton.vue | 5 +- .../src/components/mail/MessageTable.spec.ts | 35 + .../src/components/mail/MessageTableRow.vue | 29 +- .../components/mail/ScheduledMessageItem.vue | 167 ++++ .../components/mail/ScheduledMessageList.vue | 57 ++ .../components/settings/SettingsPageFrame.vue | 23 +- frontend/src/composables/useMail.spec.ts | 78 ++ frontend/src/composables/useMail.ts | 65 +- frontend/src/composables/useMailPrint.ts | 38 + frontend/src/composables/useMailSettings.ts | 47 ++ frontend/src/composables/useMessageRemoval.ts | 20 +- .../composables/usePendingSendRefresh.spec.ts | 163 ++++ .../src/composables/usePendingSendRefresh.ts | 110 +++ frontend/src/composables/usePopupPlacement.ts | 68 ++ .../composables/useScheduledMessageActions.ts | 118 +++ frontend/src/composables/useSenderBlock.ts | 115 +++ frontend/src/composables/useToast.ts | 25 +- .../pages/mail/ComposePage.sendlater.spec.ts | 402 ++++++++++ frontend/src/pages/mail/ComposePage.spec.ts | 5 +- frontend/src/pages/mail/ComposePage.vue | 145 +++- frontend/src/pages/mail/MailLayout.vue | 19 +- frontend/src/pages/mail/MailPrintPage.spec.ts | 270 +++++++ frontend/src/pages/mail/MailPrintPage.vue | 462 +++++++++++ .../pages/mail/MailboxPage.scheduled.spec.ts | 245 ++++++ frontend/src/pages/mail/MailboxPage.vue | 33 +- .../src/pages/settings/MailBlocksPage.spec.ts | 303 +++++++ .../src/pages/settings/MailBlocksPage.vue | 390 +++++++++ .../pages/settings/MailSendReadPage.spec.ts | 303 +++++++ .../src/pages/settings/MailSendReadPage.vue | 243 ++++++ .../src/pages/settings/SettingsLayout.spec.ts | 28 +- frontend/src/router/routes.ts | 40 +- frontend/src/shared/constants/icons.ts | 12 + frontend/src/shared/constants/mail-block.ts | 112 +++ frontend/src/shared/constants/mail-print.ts | 32 + .../src/shared/constants/mail-send-later.ts | 113 +++ .../src/shared/constants/mail-settings.ts | 34 + frontend/src/shared/constants/mail.ts | 23 + .../src/shared/constants/settings-menu.ts | 9 +- frontend/src/shared/types/mail.ts | 69 +- frontend/src/shared/utils/format.spec.ts | 30 + frontend/src/shared/utils/format.ts | 60 ++ .../shared/utils/keyboard-shortcut.spec.ts | 35 +- .../src/shared/utils/keyboard-shortcut.ts | 21 + frontend/src/shared/utils/mail-block.spec.ts | 136 ++++ frontend/src/shared/utils/mail-block.ts | 82 ++ frontend/src/shared/utils/mail-html.spec.ts | 89 ++- frontend/src/shared/utils/mail-html.ts | 70 +- .../src/shared/utils/mail-list-row.spec.ts | 17 +- frontend/src/shared/utils/mail-list-row.ts | 16 + frontend/src/shared/utils/mail-list-source.ts | 42 +- frontend/src/shared/utils/mail-quote.spec.ts | 16 + .../src/shared/utils/mail-send-result.spec.ts | 22 + frontend/src/shared/utils/mail-send-result.ts | 17 +- .../src/shared/utils/pending-send.spec.ts | 90 +++ frontend/src/shared/utils/pending-send.ts | 128 +++ .../src/shared/utils/schedule-send.spec.ts | 131 ++++ frontend/src/shared/utils/schedule-send.ts | 157 ++++ frontend/src/stores/mail-block.store.spec.ts | 98 +++ frontend/src/stores/mail-block.store.ts | 127 +++ frontend/src/stores/mail-settings.store.ts | 173 ++++ frontend/src/stores/mail.store.stage9.spec.ts | 456 +++++++++++ frontend/src/stores/mail.store.ts | 24 +- frontend/src/stores/mail/list.ts | 70 +- frontend/src/stores/mail/message.ts | 56 +- frontend/src/stores/mail/scheduled.ts | 156 ++++ frontend/src/stores/mail/selection.ts | 49 +- frontend/src/stores/toast.store.spec.ts | 64 ++ frontend/src/stores/toast.store.ts | 78 +- frontend/src/styles/base.css | 5 + frontend/src/styles/tokens.css | 4 + frontend/src/test-utils/router.ts | 21 + 96 files changed, 9749 insertions(+), 329 deletions(-) create mode 100644 frontend/src/components/feedback/ToastCountdown.vue create mode 100644 frontend/src/components/feedback/ToastHost.spec.ts create mode 100644 frontend/src/components/mail/ComposeFooter.spec.ts create mode 100644 frontend/src/components/mail/ComposeScheduleDialog.spec.ts create mode 100644 frontend/src/components/mail/ComposeScheduleDialog.vue create mode 100644 frontend/src/components/mail/MailboxScheduledRow.vue create mode 100644 frontend/src/components/mail/MessageBlockDialog.vue create mode 100644 frontend/src/components/mail/MessageReader.stage9.spec.ts create mode 100644 frontend/src/components/mail/MessageReaderNotices.vue create mode 100644 frontend/src/components/mail/ScheduledMessageItem.vue create mode 100644 frontend/src/components/mail/ScheduledMessageList.vue create mode 100644 frontend/src/composables/useMailPrint.ts create mode 100644 frontend/src/composables/useMailSettings.ts create mode 100644 frontend/src/composables/usePendingSendRefresh.spec.ts create mode 100644 frontend/src/composables/usePendingSendRefresh.ts create mode 100644 frontend/src/composables/usePopupPlacement.ts create mode 100644 frontend/src/composables/useScheduledMessageActions.ts create mode 100644 frontend/src/composables/useSenderBlock.ts create mode 100644 frontend/src/pages/mail/ComposePage.sendlater.spec.ts create mode 100644 frontend/src/pages/mail/MailPrintPage.spec.ts create mode 100644 frontend/src/pages/mail/MailPrintPage.vue create mode 100644 frontend/src/pages/mail/MailboxPage.scheduled.spec.ts create mode 100644 frontend/src/pages/settings/MailBlocksPage.spec.ts create mode 100644 frontend/src/pages/settings/MailBlocksPage.vue create mode 100644 frontend/src/pages/settings/MailSendReadPage.spec.ts create mode 100644 frontend/src/pages/settings/MailSendReadPage.vue create mode 100644 frontend/src/shared/constants/mail-block.ts create mode 100644 frontend/src/shared/constants/mail-print.ts create mode 100644 frontend/src/shared/constants/mail-send-later.ts create mode 100644 frontend/src/shared/constants/mail-settings.ts create mode 100644 frontend/src/shared/utils/mail-block.spec.ts create mode 100644 frontend/src/shared/utils/mail-block.ts create mode 100644 frontend/src/shared/utils/pending-send.spec.ts create mode 100644 frontend/src/shared/utils/pending-send.ts create mode 100644 frontend/src/shared/utils/schedule-send.spec.ts create mode 100644 frontend/src/shared/utils/schedule-send.ts create mode 100644 frontend/src/stores/mail-block.store.spec.ts create mode 100644 frontend/src/stores/mail-block.store.ts create mode 100644 frontend/src/stores/mail-settings.store.ts create mode 100644 frontend/src/stores/mail.store.stage9.spec.ts create mode 100644 frontend/src/stores/mail/scheduled.ts diff --git a/frontend/src/components/feedback/ToastCountdown.vue b/frontend/src/components/feedback/ToastCountdown.vue new file mode 100644 index 0000000..f6e7888 --- /dev/null +++ b/frontend/src/components/feedback/ToastCountdown.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/components/feedback/ToastHost.spec.ts b/frontend/src/components/feedback/ToastHost.spec.ts new file mode 100644 index 0000000..cecfe63 --- /dev/null +++ b/frontend/src/components/feedback/ToastHost.spec.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { useAuthStore } from '@/stores/auth.store' +import { useToastStore } from '@/stores/toast.store' +import { flushPromises } from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import ToastHost from './ToastHost.vue' + +// 화면 오른쪽 아래 알림 — 단추는 한 번만 움직이고(리뷰 #9), 초점이 든 알림이 닫히면 초점을 돌려준다(리뷰 #8) + +describe('ToastHost', () => { + let pinia: Pinia + let wrapper: VueWrapper | null = null + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = null + useToastStore().clear() + document.body.innerHTML = '' + }) + + /** 화면 — main 안의 입력칸 하나 + 알림 틀 */ + async function mountHost() { + const main = document.createElement('main') + const field = document.createElement('input') + main.append(field) + document.body.append(main) + wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } }) + await flushPromises() + return { main, field } + } + + function showUndo(onRun: () => void, expiresAt?: number): number { + return useToastStore().show({ + kind: 'success', + message: '메일을 보냈습니다.', + action: { label: '보내기 취소', run: onRun }, + expiresAt: expiresAt ?? null, + }) + } + + const actionButton = () => document.querySelector('.toast__action') + + it('단추를 누르면 알림을 닫고 할 일을 한 번 한다 — 닫히는 동안 한 번 더 눌러도 또 하지 않는다', async () => { + await mountHost() + let runs = 0 + showUndo(() => { + runs += 1 + }) + await flushPromises() + const button = actionButton() + if (!button) throw new Error('단추가 없다') + + button.click() + // 퇴장 전환 동안 남아 있는 같은 단추를 한 번 더 누른다 + button.click() + await flushPromises() + + expect(runs).toBe(1) + expect(useToastStore().toasts).toEqual([]) + }) + + it('초점이 든 알림이 닫히면(마감 시각 · 시간이 다 됨) 알림에 들어오기 전 자리로 초점을 돌려준다', async () => { + const { field } = await mountHost() + const id = showUndo(() => undefined, Date.now() + 10_000) + await flushPromises() + field.focus() + actionButton()?.focus() + expect(document.activeElement).toBe(actionButton()) + + // 마감이 있는 알림은 초점이 있어도 닫힌다 (그 시각이 지나면 되돌릴 수 없다) + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(field) + }) + + it('그 자리가 사라졌으면 화면의 main 으로 — 초점을 받게 잠깐 단 tabindex 는 초점이 떠나면 뗀다', async () => { + const { main, field } = await mountHost() + const id = showUndo(() => undefined) + await flushPromises() + field.focus() + actionButton()?.focus() + field.remove() + + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(main) + expect(main.getAttribute('tabindex')).toBe('-1') + expect(main.hasAttribute('data-focus-fallback')).toBe(true) + + main.blur() + expect(main.hasAttribute('tabindex')).toBe(false) + expect(main.hasAttribute('data-focus-fallback')).toBe(false) + }) + + it('단추를 눌러 닫혀도 초점을 돌려준다 — 할 일이 초점을 옮기면 그쪽이 이긴다', async () => { + const { field } = await mountHost() + const other = document.createElement('button') + document.body.append(other) + showUndo(() => other.focus()) + await flushPromises() + field.focus() + actionButton()?.focus() + + actionButton()?.click() + await flushPromises() + + expect(document.activeElement).toBe(other) + }) + + it('초점이 알림 밖에 있으면 닫혀도 초점을 건드리지 않는다', async () => { + const { field } = await mountHost() + const id = showUndo(() => undefined) + await flushPromises() + field.focus() + + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(field) + }) + + it('알림 사이를 옮겨 다녀도 처음 들어온 자리를 기억한다', async () => { + const { field } = await mountHost() + const first = showUndo(() => undefined) + const second = showUndo(() => undefined) + await flushPromises() + const buttons = () => [...document.querySelectorAll('.toast__action')] + field.focus() + buttons()[0]?.focus() + buttons()[1]?.focus() + + useToastStore().dismiss(second) + await flushPromises() + expect(document.activeElement).toBe(field) + useToastStore().dismiss(first) + }) +}) diff --git a/frontend/src/components/feedback/ToastHost.vue b/frontend/src/components/feedback/ToastHost.vue index 47500ce..66845ad 100644 --- a/frontend/src/components/feedback/ToastHost.vue +++ b/frontend/src/components/feedback/ToastHost.vue @@ -4,7 +4,14 @@ // 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등) // 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update) // 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1) +// 마감 시각이 있는 알림(9단계 [보내기 취소])은 둘째 줄에 남은 시간 막대(ToastCountdown) — 그 시각에 닫히고 붙잡아도 멈추지 않는다 +// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 — +// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰) +// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction) +import { ref, watch } from 'vue' + import AppIcon from '@/components/AppIcon.vue' +import ToastCountdown from '@/components/feedback/ToastCountdown.vue' import type { IconName } from '@/shared/constants/icons' import { formatTraceCode } from '@/shared/utils/format' import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store' @@ -18,6 +25,13 @@ const TOAST_ICONS: Readonly(null) +/** 알림으로 초점이 들어오기 전 초점이 있던 요소 — 알림 사이를 옮겨 다닌 것은 세지 않는다 */ +let returnFocusTo: HTMLElement | null = null + /** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */ function onFocusOut(event: FocusEvent, id: number) { const next = event.relatedTarget @@ -25,18 +39,70 @@ function onFocusOut(event: FocusEvent, id: number) { toastStore.release(id, 'focus') } -/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */ +/** 바깥에서 알림으로 초점이 들어왔다 — 온 자리를 기억한다 (Tab · F6 · 마우스로 누름 — 온 자리가 없으면 null) */ +function onHostFocusIn(event: FocusEvent) { + const from = event.relatedTarget + if (from instanceof Node && hostRef.value?.contains(from)) return + returnFocusTo = from instanceof HTMLElement ? from : null +} + +/** 화면의 main 에 초점을 둔다 — 초점을 받을 수 없으면 tabindex="-1" 을 잠깐 단다 */ +function focusMainRegion() { + const main = document.querySelector('main') + if (!main) return + if (!main.hasAttribute('tabindex')) { + main.setAttribute('tabindex', '-1') + main.setAttribute(MAIN_FALLBACK_ATTR, '') + main.addEventListener( + 'blur', + () => { + main.removeAttribute('tabindex') + main.removeAttribute(MAIN_FALLBACK_ATTR) + }, + { once: true }, + ) + } + main.focus({ preventScroll: true }) +} + +/** 알림에 들어오기 전 자리로 초점을 돌려준다 — 그 요소가 사라졌거나 초점을 받지 못하면 main */ +function restoreFocus() { + const target = returnFocusTo + returnFocusTo = null + if (target?.isConnected && !hostRef.value?.contains(target)) { + target.focus({ preventScroll: true }) + if (document.activeElement === target) return + } + focusMainRegion() +} + +/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 (이미 닫힌 알림이면 아무것도 하지 않는다) */ function runAction(toast: Toast) { - const action = toast.action - toastStore.dismiss(toast.id) + const action = toastStore.takeAction(toast.id) if (action && 'run' in action) action.run() } + +// 알림이 닫힐 때 — 초점이 그 안에 있었으면 들어오기 전 자리로 (닫는 바로 그때 — 퇴장 전환 동안 남은 단추에 초점이 머물지 않게) +watch( + () => toastStore.toasts.map((toast) => toast.id), + (ids) => { + const host = hostRef.value + const active = document.activeElement + if (!host || !(active instanceof Element) || !host.contains(active)) return + const focusedId = Number(active.closest('[data-toast-id]')?.getAttribute('data-toast-id')) + if (ids.includes(focusedId)) return + restoreFocus() + }, + { flush: 'sync' }, +)