From 099faeaafc9100dcd2d43a2cceb0176a28571b1c Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 9 Nov 2026 13:56:00 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=EB=A9=94=EC=9D=BC=2015=EB=8B=A8?= =?UTF-8?q?=EA=B3=84(=EB=A9=94=EC=9D=BC=20=C2=B7=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=ED=95=A8=20=EB=8B=A4=EB=A3=A8=EA=B8=B0)=20=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=85=8B=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기 화면 [이동 ▾] · 예약 메일 지금 보내기 · 같은 대화 모두 펼치기(Reader15), 목록 끌어다 놓기 · 좁은 폭 상단 바(ListDrag15), 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리(Mailbox15). 캔버스 v71 에서 확정 — 글은 화면 상수가 기준이다. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/ListDrag15.dc.html | 843 ++++++++++++++++ docs/design/webmail/Mailbox15.dc.html | 765 ++++++++++++++ docs/design/webmail/Reader15.dc.html | 1274 ++++++++++++++++++++++++ docs/design/webmail/canvas.json | 10 +- 4 files changed, 2889 insertions(+), 3 deletions(-) create mode 100644 docs/design/webmail/ListDrag15.dc.html create mode 100644 docs/design/webmail/Mailbox15.dc.html create mode 100644 docs/design/webmail/Reader15.dc.html diff --git a/docs/design/webmail/ListDrag15.dc.html b/docs/design/webmail/ListDrag15.dc.html new file mode 100644 index 0000000..eb24f53 --- /dev/null +++ b/docs/design/webmail/ListDrag15.dc.html @@ -0,0 +1,843 @@ + + + + +끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바 + + + + + + + +
+ +
+

끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바

+

메일 15단계(메일 · 메일함 다루기)에서 정한 것 가운데 3 · 4 · 9의 화면입니다. 목록에서 고른 메일이나 대화 줄을 메일함 패널로 끌어다 놓아 옮기고, 스팸메일함으로 옮기는 세 길(단추, 메뉴, 끌기)을 같게 맞추고, 휴대폰 폭에서 상단 바의 [로그아웃]이 꺾이지 않게 합니다. 1의 단추로 마우스가 있는 곳을 바꿔 볼 수 있습니다. 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ +
+
+

1. 분할 보기 (1440) · 고른 메일 3통을 끌어다 놓는 중

+

받은편지함에서 메일 3통을 고른 채 그중 한 줄(박지훈)을 눌러 메일함 패널로 끌고 있습니다. 마우스 오른쪽 아래에 메일 수를 적은 표가 따라옵니다.

+
+
+ 마우스가 있는 곳 +
+ + + +
+
+
+

{{ overTitle }}

+

{{ overNote }}

+
+
+ +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ +
+
+ +
+ + + +
+
+

3통 선택됨

+
+ + + + + + + +
+
+ +
+ +
+
+
+ + + 답장 + + + + 전체 답장 + + + + 전달 + + + + + 이동 + + + + + 스팸 신고 + + + + 삭제 + + +
+
+
+
+

Re: 유지보수 계약 갱신 건

+ +
+
+ +
+
+ 이도윤 + doyun.lee@arasystem.it.kr + 2026. 11. 9. (월) 오전 10:42 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
+

김아라님, 안녕하세요.

+

말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.
기간은 1년이고, 단가는 작년과 같습니다.

+

이도윤 드림

+
+
+

첨부 파일 1개

+
+ + + 유지보수_계약서_초안.docx + 64 KB + + +
+
+
+
+ +
+
+
+

고르지 않은 줄을 끌면 그 줄만 옮기고, 고른 메일은 그대로 둡니다. 보내기를 기다리는 메일의 줄은 끌 수 없습니다. 읽기 화면 도구 줄의 [이동 ▾]는 정한 것 1의 모습입니다.

+ +
+
+

2. 목록 보기 (1440) · 대화 줄 하나를 끌어다 놓는 중

+

대화로 묶은 받은편지함에서 고르지 않은 줄(박지훈, 나 · 3통)을 끌어 견적 메일함 위에 두었습니다. 따라오는 표에는 대화 수를 적습니다.

+
+
+ +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ +
+
+ +
+ + + +
+
+
+

받은편지함

+ 대화 7개, 안 읽은 메일 2통 +
+
+ +
+ + + 분할 + + + + 목록 + +
+ + + +
+
+ +
    + +
  • + + + + {{ c.who }} + {{ c.count }} + + + {{ c.subject }} + + + {{ c.date }} +
  • +
    +
+
+ 메일 더 보기 +
+
+ +
+
+
+

대화 줄을 끌면 그 대화에서 지금 메일함(받은편지함)에 있는 메일만 옮깁니다. [이동 ▾]와 같이 보내기를 기다리는 메일은 빼고, 놓은 뒤 알림은 3e입니다.

+ +
+
+

3. 놓은 뒤 알림

+

메뉴로 옮길 때와 같은 알림이고, 화면 오른쪽 아래에 뜹니다. [되돌리기]가 있는 알림은 10초 동안 남습니다.

+
+
+ + +
+
+

{{ t.title }}

+

{{ t.note }}

+
+
+ + {{ t.text }} + +
+
+
+ +
+

끌어다 놓기는 마우스로만 합니다. 키보드와 터치 화면에서는 지금처럼 [이동 ▾]로 옮기고, 옮길 곳과 알림은 같습니다.

+
+ +
+
+

4. 상단 바 · 휴대폰 폭 360 (2배로 확대)

+

레일 72와 상단 바만 잘라 2배로 키웠습니다. 바 안쪽 폭은 256입니다(360에서 레일 72와 바의 좌우 여백 32를 뺀 폭). 넓은 화면은 달라지지 않습니다.

+
+ +
+
+
+
+ + + + 메일 + + + + 캘린더 + +
+
+
+ 메일 +
+ + 메일 검색 + + + 조건 + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+
+ + + 메일 쓰기 + +
+
+
+
+
+

4a. 지금

+
    +
  • 검색 칸(기본 폭 280)이 둘째 줄로, 계정 칸이 셋째 줄로 내려갑니다.
  • +
  • 계정 칸은 아바타 32, 이름과 주소, [로그아웃] 순서입니다. 주소는 줄지 않아 [로그아웃]만 줄어들고 글자가 두 줄로 꺾입니다.
  • +
  • 주소가 더 긴 직원은 [로그아웃]이 한 글자씩 꺾여 셋째 줄이 더 높아집니다.
  • +
+
+
+ +
+
+
+
+ + + + 메일 + + + + 캘린더 + +
+
+
+ 메일 +
+ + 메일 검색 + + + 조건 + +
+
+ +
+ 김아라 +
+ + + 로그아웃 + +
+
+
+ + + 메일 쓰기 + +
+
+
+
+
+

4b. 고친 뒤

+
    +
  • [로그아웃]은 줄지도 꺾이지도 않습니다. 늘 한 줄입니다.
  • +
  • 화면 폭이 480 이하면 주소를 감추고 이름만 둡니다.
  • +
  • 이름이나 주소가 아주 길어 넘치면 끝을 "…"로 줄이고, 마우스를 올리면 다 보입니다.
  • +
  • 줄 수와 차례(메일, 검색 칸, 계정)는 지금과 같습니다.
  • +
+
+
+
+ +
+ +
+

정한 것 · 끌 수 있는 것과 놓을 곳

+
    +
  • 고른 줄을 끌면 고른 메일을 모두, 고르지 않은 줄을 끌면 그 줄만 옮깁니다. 고른 것은 그대로 둡니다.
  • +
  • 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 있는 메일을 옮깁니다. 보내기를 기다리는 메일은 빼고([이동 ▾]와 같다), 그런 메일뿐인 줄은 끌 수 없습니다.
  • +
  • 놓을 곳은 [이동 ▾]의 옮길 곳에 휴지통을 더한 곳입니다. 지금 메일함, 임시보관함, 예약됨에는 놓을 수 없습니다.
  • +
  • 휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같습니다. 알림과 [되돌리기]도 같습니다.
  • +
+
+ +
+

끌어다 놓는 동안의 모양

+
    +
  • 마우스를 따라오는 표는 커서 오른쪽 아래에 붙습니다. "메일 N통" 또는 "대화 N개"이고, 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투나 말풍선 아이콘입니다.
  • +
  • 놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선과 옅은 청록 바탕이 생깁니다. 쓰기 화면에 파일을 끌어다 놓을 때와 같은 모양이라 초점 선(실선)과 헷갈리지 않습니다.
  • +
  • 놓을 수 없는 곳 위에서는 줄이 그대로이고 마우스 커서만 금지 모양(⊘)이 됩니다. 메일함 줄마다 금지 표시를 그리지 않습니다.
  • +
  • 끌기 시작한 줄은 흐리게 하지 않습니다. 고른 줄은 고른 바탕 그대로입니다.
  • +
+
+ +
+

정한 것 · 스팸메일함으로 옮기는 길

+
    +
  • [스팸 신고], [이동 ▾]에서 스팸메일함 고르기, 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮깁니다(3c). 지금은 [이동 ▾]만 표시 없이 옮깁니다.
  • +
  • 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴, 끌기) [스팸 아님]과 같이 스팸 아님 표시를 붙입니다(3d). 휴지통으로 옮길 때는 표시를 바꾸지 않습니다.
  • +
  • [되돌리기]는 원래 메일함으로 옮기며 표시도 되돌립니다. 지금의 [스팸 신고], [스팸 아님]과 같습니다.
  • +
+
+ +
+

구현 때 맞출 것

+
    +
  • HTML5 끌어다 놓기로 만들고, 따라오는 표는 setDragImage 로 붙입니다. 놓을 수 없는 곳에서는 dropEffect 를 none 으로 두어 브라우저가 금지 커서를 보입니다. 커서 모양과 따라오는 표의 투명도는 운영체제와 브라우저마다 조금 다릅니다.
  • +
  • 목록 줄은 링크라 지금도 끌면 그 메일 화면의 주소(URL)가 끌려 나갑니다. 우리 끌기 자료로 바꾸고, 다른 창이나 바깥 프로그램에 놓아도 아무 일도 일어나지 않게 합니다.
  • +
  • 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않습니다(첨부 놓기와 겹치지 않게). 메일 화면에 파일을 잘못 놓으면 지금은 브라우저가 그 파일을 열어 웹메일을 떠나는데, 앞으로는 무시합니다.
  • +
  • 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지 확인합니다.
  • +
  • 상단 바는 아바타와 [로그아웃]을 줄지 않게 하고 이름 칸만 줄게 합니다. 480 이하에서 주소를 감추는 것은 tokens.css 의 화면 폭 기준에 한 줄을 더합니다.
  • +
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/Mailbox15.dc.html b/docs/design/webmail/Mailbox15.dc.html new file mode 100644 index 0000000..c57d516 --- /dev/null +++ b/docs/design/webmail/Mailbox15.dc.html @@ -0,0 +1,765 @@ + + + + +메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기 + + + + + + + +
+ +
+

메일 15단계 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기

+

메일함 패널의 ⋯ 메뉴와 보낸 사람 차단 창에 더하는 것입니다. 메일함을 다른 메일함 안이나 맨 위로 옮기고, 하위 메일함이 있는 메일함도 한 번에 지우고, 보낸 사람을 차단할 때 받은편지함에 이미 있는 그 사람의 메일까지 스팸메일함으로 옮깁니다. 2의 목록은 줄을 눌러 고를 수 있습니다. 예시의 보낸 사람은 news@example.com입니다. 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ +
+
+

1. 메일함 ⋯ 메뉴

+

내 메일함의 ⋯ 메뉴에 [옮기기]가 생깁니다. 이름 바꾸기 다음, 지우기 앞입니다. 받은편지함 같은 기본 메일함에는 없습니다.

+
+
+ +
+
+

내 메일함의 ⋯

+

거래처 안의 2025

+
+ +
+ +
+
+

받은편지함의 ⋯

+

기본 메일함에는 옮기기가 없습니다

+
+ +
+ +
+

정한 것

+
    +
  • 항목 차례는 모두 읽음으로 표시, 하위 메일함 만들기, 이름 바꾸기, 옮기기, 지우기입니다. 안 읽은 메일이 없으면 첫 항목이 빠지는 것은 지금과 같습니다.
  • +
  • 옮기기는 내 메일함에만 있습니다. 받은편지함, 보낸편지함 같은 기본 메일함은 옮기지 않습니다.
  • +
  • 하위 메일함이 있어도 옮길 수 있습니다. 하위 메일함과 안의 메일이 함께 가고, 그 메일함으로 옮기는 필터도 그대로 돕니다.
  • +
  • 창을 여는 다른 항목(하위 메일함 만들기, 이름 바꾸기)처럼 이름 뒤에 …를 붙이지 않았습니다.
  • +
  • 메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없습니다. 항목 높이 40은 지금 메뉴와 같습니다.
  • +
+
+ +
+
+ +
+
+

2. 옮기기 창

+

옮길 곳은 맨 위, 받은편지함, 내 메일함 가운데서 고릅니다. 목록의 줄을 눌러 고를 수 있습니다.

+
+
+ +
+
+

‘2025’ 메일함을 어디로 옮길까요?

+

하위 메일함과 안의 메일도 함께 옮깁니다.

+
+ + +

{{ r.name }}

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

정한 것

+
    +
  • 창 폭은 400입니다(주소록의 그룹 창과 같습니다). 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없습니다.
  • +
  • 목록은 메일함 패널과 같은 차례와 들여쓰기이고 줄 높이는 40입니다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕에 굵은 글자이고, 오른쪽에 체크 표시가 붙습니다.
  • +
  • 옮기는 메일함(거래처 안의 2025)과 그 안의 정산은 흐리게 두고 고를 수 없습니다. 지금 있는 곳(거래처)도 고를 수 없고 지금 있는 곳 표시를 붙입니다. 맨 위에 있는 메일함이면 맨 위 줄에 붙습니다.
  • +
  • 바로 아래에 같은 이름의 메일함이 있는 곳(보관 안의 2025)도 처음부터 고를 수 없게 하고, 같은 자리에 같은 이름의 메일함이 있습니다 표시를 붙입니다. 대소문자는 가리지 않고 견줍니다. 서버에 묻기 전에 화면이 하는 검사라 화면 상수 글입니다.
  • +
  • 옮길 곳에 보낸편지함, 임시보관함, 스팸메일함, 휴지통은 없습니다. 하위 메일함을 만들 수 있는 곳과 같습니다.
  • +
  • 하위 메일함이 없으면 제목 아래 글은 "안의 메일도 함께 옮깁니다."입니다.
  • +
  • 메일함이 많으면 목록 칸 안에서 스크롤합니다. 화살표 키로 줄을 옮기고, Enter는 [옮기기], Esc는 [취소]와 같습니다.
  • +
+
+ +
+
+ +
+
+

3. 그사이 같은 이름이 생겼을 때

+

드문 경우입니다. 창을 연 사이 다른 창에서 프로젝트 안에 2025를 만들어, 프로젝트를 고르고 [옮기기]를 누르자 서버가 거절한 모습입니다.

+
+
+ +
+
+

‘2025’ 메일함을 어디로 옮길까요?

+

하위 메일함과 안의 메일도 함께 옮깁니다.

+
+ + +

{{ r.name }}

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

정한 것

+
    +
  • 같은 이름은 보통 2의 표시로 미리 막습니다. 이 그림은 창의 목록이 연 때의 것이라 막지 못한 경우입니다.
  • +
  • 글은 오류 사전의 MAIL_009를 그대로 씁니다. 알림은 띄우지 않고 창을 그대로 둡니다(이름 바꾸기 창과 같습니다). 다른 곳을 고르면 글이 사라집니다.
  • +
  • 대소문자만 다른 이름(Report와 report)도 같은 이름으로 봅니다. 미리 막는 검사도 같은 규칙입니다.
  • +
  • 그사이 메일함이 사라졌으면 RES_001 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받습니다(이름 바꾸기 창과 같습니다).
  • +
  • 옮기는 동안에는 [옮기기]를 다시 누를 수 없습니다.
  • +
+
+ +
+
+ +
+
+

4. 옮긴 뒤 알림

+

메일함 패널에 바로 나타나고, 알림의 [되돌리기]로 원래 있던 곳에 되돌립니다.

+
+
+ +
+
+

옮긴 뒤 메일함 패널

+

2025가 정산과 함께 프로젝트 안으로

+
+ +
+ +
+
+

다른 메일함 안으로 옮겼을 때

+
+ + ‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다. + +
+
+
+

맨 위로 옮겼을 때

+
+ + ‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다. + +
+
+
+

[되돌리기]를 누른 뒤

+
+ + ‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다. +
+
+
+ +
+

정한 것

+
    +
  • 메일함 패널에 바로 나타납니다. 보던 메일함을 옮겨도 그 메일함을 계속 봅니다.
  • +
  • [되돌리기]는 원래 있던 곳으로 다시 옮깁니다. 단추가 있는 알림은 지금처럼 10초 동안 남습니다.
  • +
  • 받은편지함 안으로 옮겼으면 "‘2025’ 메일함을 받은편지함 안으로 옮겼습니다."처럼 받은편지함에는 따옴표를 붙이지 않습니다.
  • +
+
+ +
+
+ +
+
+

5. 하위 메일함까지 지우기

+

하위 메일함이 있는 메일함을 지우려 하면 지금은 지울 수 없다고만 알립니다. 15단계부터는 하위 메일함까지 함께 지울지 묻습니다.

+
+
+ +
+
+

지금

+

하위 메일함을 먼저 지우라고만 합니다

+
+
+
+

‘거래처’ 메일함을 지울 수 없습니다

+

하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.

+
+ +
+
+
+
+ +
+
+

15단계

+

하위 메일함까지 지울지 묻습니다 · 처음 초점은 [취소]

+
+
+
+

‘거래처’ 메일함과 하위 메일함 2개를 지울까요?

+

하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.

+
+ + +
+
+
+
+ +
+

정한 것

+
    +
  • 하위 메일함 수는 아래의 모든 깊이를 셉니다(2025와 그 안의 정산으로 2개). 메일 수도 하위 메일함까지 더한 수입니다.
  • +
  • 메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다."입니다.
  • +
  • 처음 초점은 [취소]이고, 휴지통을 비우는 동안에는 [지우기]를 누를 수 없습니다. 둘 다 지금 지우기 창과 같습니다.
  • +
+
+ +
+

진행과 결과는 지금 메일함 지우기와 같습니다. "‘거래처’ 메일함을 지우는 중 · 12 / 40통" 알림이 끝나면 "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다."로 바뀝니다.

+
+ +
+
+

6. 보낸 사람 차단 창

+

지금 창에 칸 하나를 더해, 받은편지함에 이미 있는 같은 보낸 사람의 메일까지 스팸메일함으로 옮깁니다. 많을 수 있고 한꺼번에 되돌릴 수 없어서 처음에는 꺼져 있습니다.

+
+
+ +
+
+

처음 모습

+

이 주소만 · 새 칸은 꺼져 있습니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ + +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

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

이 도메인 전체를 고르고 새 칸을 켬

+

이 메일이 받은편지함에 있어 첫 칸이 켜진 채 잠깁니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ +

받은편지함의 메일을 옮길 때 함께 옮깁니다.

+ +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

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

스팸메일함의 메일에서 차단할 때

+

첫 칸은 지금처럼 없고 새 칸은 남습니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

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

이 탭에서 필터를 적용하는 중일 때

+

창 안의 두 칸만 그렸습니다 · 새 칸이 꺼지고 까닭이 붙습니다

+
+
+ + +

받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.

+
+
+ +
+

정한 것

+
    +
  • 새 칸은 창을 열 때마다 꺼진 채 시작합니다. 첫 칸은 지금처럼 켜진 채 시작합니다.
  • +
  • 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 칸 아래에 까닭이 붙습니다. 새 칸을 끄면 첫 칸은 원래대로 돌아옵니다. 이 메일이 다른 메일함에 있으면 잠그지 않습니다.
  • +
  • 이 도메인 전체를 고르면 새 칸의 이 보낸 사람이 이 도메인으로 바뀝니다. 차단처럼 그 도메인만 보고 하위 도메인은 보지 않습니다.
  • +
  • 받은편지함에 있는 메일만 옮깁니다. 직원이 정리해 둔 다른 메일함은 그대로 둡니다(필터 적용과 같습니다).
  • +
  • 이 탭에서 필터를 받은편지함에 적용하는 동안에는 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 바꿉니다. 7단계 필터 창의 칸과 같습니다.
  • +
  • 두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춥니다. 칸 아래 글은 글 선(28)에 맞춰 들여 씁니다.
  • +
+
+ +
+
+ +
+
+

7. 차단한 뒤 알림

+

새 칸을 켜고 차단하면 차단 알림을 따로 띄우지 않습니다. 알림 하나가 차단했다는 글로 시작해 진행 숫자를 보이다가, 같은 자리에서 결과로 바뀝니다. 7단계 필터 적용의 진행 알림과 같은 결입니다.

+
+
+ +
+
+

새 칸을 켜지 않았을 때

+

지금 차단 알림 그대로

+
+
+ + ‘news@example.com’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다. + +
+
+ +
+
+

새 칸을 켰을 때

+

알림 하나가 진행을 보이다가 같은 자리에서 결과로

+
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인 +
+
+ + ‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다. + 스팸메일함 보기 +
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다. +
+
+ +
+
+

새 칸을 켰는데 멈췄을 때

+

도중에 멈춤 · 처음부터 실패 · 다른 창에서 하는 중

+
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요. + + +
+
+ + ‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요. + + +
+
+ + ‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요. + + +
+
+ +
+
+

정한 것

+
    +
  • 새 칸을 켠 차단은 알림이 하나라 [되돌리기]가 없습니다. 차단은 [설정] → [차단한 주소]에서 풉니다. 새 칸을 켜지 않았으면 지금 알림이 [되돌리기]와 함께 그대로 뜹니다.
  • +
  • 결과와 멈춤 글은 모두 차단했다는 말로 시작합니다. 차단은 이미 끝났고, 받은편지함의 메일 옮기기만 멈춘 것입니다.
  • +
  • 숫자는 확인한 메일 / 받은편지함의 메일 수이고, 최근 메일부터 확인합니다. 첫 응답 전에는 받은편지함의 메일 수를 씁니다.
  • +
  • [스팸메일함 보기]는 스팸메일함 목록으로 갑니다. 옮긴 메일이 없으면 단추가 없습니다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓습니다.
  • +
  • [다시 시도]는 멈춘 자리부터, 처음부터 실패했으면 처음부터 합니다. 차단한 뒤에는 ⋯ 메뉴가 차단 풀기로 바뀌어 다시 고를 길이 없으므로, 다른 창에서 하는 중일 때도 단추를 둡니다.
  • +
  • 다른 창에서 하는 중 글은 필터 적용과 함께 씁니다. 지금 필터 글(다른 창에서 필터로 받은편지함을 정리하는 중입니다.)도 구현 때 이 글로 바꿉니다.
  • +
  • 그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫습니다. 필터 적용과 이 일은 한 직원에 한 번에 하나만 돕니다.
  • +
+
+
+ +
+
+ + + diff --git a/docs/design/webmail/Reader15.dc.html b/docs/design/webmail/Reader15.dc.html new file mode 100644 index 0000000..67d6d94 --- /dev/null +++ b/docs/design/webmail/Reader15.dc.html @@ -0,0 +1,1274 @@ + + + + +15단계 · 읽기 화면 · 이동 · 지금 보내기 · 같은 대화 펼치기 + + + + + + + +
+ +
+

15단계 · 읽기 화면에서 옮기기 · 지금 보내기 · 같은 대화 모두 펼치기

+

메일 15단계(메일 · 메일함 다루기) 가운데 읽기 화면에 더하는 셋입니다. 1~3은 도구 줄의 [이동 ▾]과 옮길 메일함의 들여쓰기, 4~5는 예약한 메일의 [지금 보내기], 6은 같은 대화 칸의 [모두 펼치기]와 펼친 줄 머리의 [답장]입니다. 화면 글은 지금 화면 상수의 글을 그대로 쓰고, 새로 지은 글은 [지금 보내기], [모두 펼치기], [모두 접기], 들여 쓴 메일함의 화면 낭독 이름(거래처 안의 2025), 흐린 줄의 까닭 글(이 메일이 있는 메일함), 같은 이름이 있을 때 알림에 붙는 위 메일함 이름(거래처 › 2025로)입니다. 예시 날은 2026년 11월 9일(월) 오전이고 직원은 김아라입니다. 메인 검토(정할 것 여섯)를 반영한 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ + +
+

{{ f.title }}

+

{{ f.desc }}

+ +
+ + + +
+
+
+ + +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+
+

{{ f.listTitle }}

+ {{ f.listCount }} +
+
+ + + +
+ + + 분할 + + + + 목록 + +
+ +
+
+ +
+ +
+
+
+ + + + 답장 + + + + 전체 답장 + + + + 전달 + + +
+ + +
+ +
+ + + +
+
+
+
+ +
+ + +
+
+ +
+
+

{{ f.subject }}

+ +
+
+ +
+
+ {{ f.fromName }} + {{ f.fromEmail }} + {{ f.date }} +
+ 받는 사람{{ f.to }} +
+
+ +
+ + {{ f.noticeText }} + + + +
+
+
{{ f.body }}
+ +
+

첨부 파일 1개

+
+ +
+ {{ f.attName }} + {{ f.attSize }} +
+ +
+
+
+ +
+
+

같은 대화 6통

+ +
+
    + +
  1. +
    + + + {{ p.who }} + {{ p.mailbox }} + {{ p.preview }} + {{ p.time }} +
    +
  2. +
    +
+
+
+
+
+
+
+ + +
+ + {{ f.toastText }} + +
+
+ +
+ + 메일을 보냈습니다. + + +
+
+
+
+ + +
+
+ +
+
+

2-1. 폭 1024 · 읽기 칸 744 · 이름이 보임

+

읽기 칸이 목록 아래로 내려와 넓어서 이름이 다 보입니다. 읽기 칸에서 왼쪽 640만 잘랐습니다.

+
+
+
+
+
+ 답장 + 전체 답장 + 전달 + + + + + +
+
+
+
+

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+ +
+ (주)한빛솔루션 영업팀 + sales@example.com +
+
+
+
+
+
+ +
+
+

2-2. 폭 1200 · 분할 보기의 읽기 칸 536 · 아이콘만

+

읽기 칸이 570보다 좁아 아이콘만 남고 한 줄입니다. 화면 폭 1100 기준이던 때 이름이 두 줄로 꺾이던 폭입니다.

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

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+ +
+ (주)한빛솔루션 영업팀 + sales@example.com +
+
+
+
+
+ +
+
+
+
+

2-3. 폭 360 · 읽기 칸 288

+

왼쪽 띠는 앱 레일(72)입니다. 읽기 칸이 288이라 아이콘만 남고, [삭제]와 [⋯]가 둘째 줄로 넘어가 두 줄입니다.

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

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+
+
+
+ +
+
+

2-4. 폭 360 · [이동 ▾]을 연 모습

+

메뉴가 화면 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에 맞춥니다. 메뉴 자리 정하기에 좌우 바꾸기를 더해, 같은 부품을 쓰는 모든 메뉴에 걸립니다.

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

2025년 유지보수 정산 자료 보내 드립니다

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

{{ col.title }}

+

{{ col.note }}

+
+
+
+
이번 주 안에 회신 주시면 다음 주 월요일에 서명하겠습니다.

이도윤 드림
+
+

첨부 파일 1개

+
+ +
+ 유지보수_계약서_수정본.docx + 52 KB +
+ +
+
+ +
+
+

같은 대화 6통

+ +
+
    + +
  1. + + + + + +
    + + {{ c.who }} + 지금 보고 있는 메일 + {{ c.time }} +
    +
    + +
    + + 답장 +
    + +
    + 받는 사람{{ c.to }} + 제목{{ c.subject }} +
    {{ c.body }}
    + + {{ c.attNote }} + + +
    + + +
    +
    + +
    +
    +
    +
  2. +
    +
+
+
+
+
+
+
+
+ + +
+ +
+
+

{{ t.title }}

+

{{ t.note }}

+
+ +
+ + {{ t.toastText }} + {{ t.actionLabel }} +
+
+ +
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+ +
+

{{ n.title }}

+
    + +
  • {{ i.text }}
  • +
    +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index da141b2..791ec14 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -59,9 +59,12 @@ "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true }, "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true }, "App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true }, - "SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true } + "SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true }, + "Reader15.dc.html": { "x": 0, "y": 42600, "w": 1440, "h": 8043, "title": "읽기 화면 · 이동 · 지금 보내기 · 같은 대화", "is_interactive": true }, + "ListDrag15.dc.html": { "x": 1520, "y": 42600, "w": 1440, "h": 3845, "title": "목록 · 끌어다 놓기 · 상단 바 좁은 폭", "is_interactive": true }, + "Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true } }, - "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", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.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", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.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 }, @@ -80,7 +83,8 @@ "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 }, "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 }, "app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 }, - "appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 } + "appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }, + "phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 } }, "designSystems": [] } -- 2.52.0 From 1ca210dec4a7016a1212fd2e05db599bac87f7a4 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 9 Nov 2026 18:02:40 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(backend):=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=ED=95=A8=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=C2=B7=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=EA=B9=8C=EC=A7=80=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B0=A8=EB=8B=A8=ED=95=A0=20=EB=95=8C=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=ED=8E=B8=EC=A7=80=ED=95=A8=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=98=88=EC=95=BD=20=EB=A9=94=EC=9D=BC=20=EC=A7=80?= =?UTF-8?q?=EA=B8=88=20=EB=B3=B4=EB=82=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/mailboxes/{id} 에 parentId — 맨 위(null) · 자기 하위로는 VAL_001 · 새 상위에 같은 이름(대소문자 무시)은 MAIL_009 · 역할 메일함 막기 - DELETE /api/mailboxes/{id}?withChildren=true — 나무를 깊은 것부터 지금 지우기와 같은 차례로(휴지통으로 → destroy) - POST /api/mail-block-runs — 받은편지함에서 차단한 보낸 사람의 메일을 스팸메일함으로(Sieve 차단 규칙과 같은 판정, 필터 적용과 같은 cursor · 잠금, 도는 동안 스팸메일함 잠금) - POST /api/scheduled-messages/{id}/release — 예약 제출을 취소하고 원래 envelope 으로 다시 제출(보내기 취소 설정만큼 붙잡음). 결과를 모르는 실패면 다시 내지 않고 503 — 같은 요청이 이어 낸다(두 통이 나가지 않게), 엔진이 분명히 거절했을 때만 임시보관함으로 엔진 사실은 개발 엔진(Stalwart 0.16.27)으로 확인했다: parentId 바꾸기 · 고리 거절, destroy 는 적은 차례대로, from 검색은 비슷한 주소 · 표시 이름의 주소까지 넓게 잡힘, 취소 뒤 다시 제출하면 받는 사람에게 한 통. Co-Authored-By: Claude Opus 5.5 --- .../file-sharing/file-sharing.repository.ts | 13 + .../file-sharing/file-sharing.service.spec.ts | 38 + .../file-sharing/file-sharing.service.ts | 9 + .../modules/mail/dto/mail-block-run.dto.ts | 86 ++ .../src/modules/mail/dto/mail-block.dto.ts | 3 +- .../src/modules/mail/dto/mailbox.dto.spec.ts | 78 +- backend/src/modules/mail/dto/mailbox.dto.ts | 83 +- .../mail/jmap/filter-run-cursor.spec.ts | 61 + .../modules/mail/jmap/filter-run-cursor.ts | 133 +- .../src/modules/mail/jmap/filter-run-texts.ts | 27 + .../modules/mail/jmap/jmap-filter-run.spec.ts | 340 +++++ .../src/modules/mail/jmap/jmap-filter-run.ts | 300 ++++- .../mail/jmap/jmap-mail.provider.spec.ts | 1199 ++++++++++++++++- .../modules/mail/jmap/jmap-mail.provider.ts | 978 ++++++++++++-- backend/src/modules/mail/jmap/jmap.types.ts | 2 + .../src/modules/mail/mail-block-match.spec.ts | 52 + backend/src/modules/mail/mail-block-match.ts | 40 + .../src/modules/mail/mail-block.controller.ts | 58 +- .../src/modules/mail/mail-block.service.ts | 11 +- backend/src/modules/mail/mail-blocks.ts | 11 + .../mail/mail-large-files.service.spec.ts | 26 + .../modules/mail/mail-large-files.service.ts | 23 + backend/src/modules/mail/mail.constants.ts | 3 + backend/src/modules/mail/mail.module.ts | 7 +- backend/src/modules/mail/mail.provider.ts | 64 +- backend/src/modules/mail/mail.service.spec.ts | 366 ++++- backend/src/modules/mail/mail.service.ts | 166 ++- backend/src/modules/mail/mail.types.ts | 50 +- backend/src/modules/mail/mailbox-tree.spec.ts | 72 + backend/src/modules/mail/mailbox-tree.ts | 95 ++ .../src/modules/mail/mailbox.controller.ts | 52 +- .../mail/scheduled-message.controller.ts | 48 + .../mail/scheduled-messages.service.spec.ts | 156 ++- .../mail/scheduled-messages.service.ts | 112 +- backend/src/modules/mail/send-hold.ts | 11 + backend/test/app.e2e-spec.ts | 597 +++++++- 36 files changed, 5055 insertions(+), 315 deletions(-) create mode 100644 backend/src/modules/mail/dto/mail-block-run.dto.ts create mode 100644 backend/src/modules/mail/mail-block-match.spec.ts create mode 100644 backend/src/modules/mail/mail-block-match.ts create mode 100644 backend/src/modules/mail/mailbox-tree.spec.ts create mode 100644 backend/src/modules/mail/mailbox-tree.ts create mode 100644 backend/src/modules/mail/send-hold.ts diff --git a/backend/src/modules/file-sharing/file-sharing.repository.ts b/backend/src/modules/file-sharing/file-sharing.repository.ts index dd04ee4..ffa968c 100644 --- a/backend/src/modules/file-sharing/file-sharing.repository.ts +++ b/backend/src/modules/file-sharing/file-sharing.repository.ts @@ -97,6 +97,19 @@ export class FileSharingRepository { await this.links.update({ id }, { sentEmailId }); } + /** 보낸 때를 앞당긴다 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만, 한 문장으로 (true = 바꿨다) */ + async moveSentAtEarlier(id: string, sentAt: Date): Promise { + const result = await this.links + .createQueryBuilder() + .update(ShareLinkEntity) + .set({ sentAt }) + .where('id = :id', { id }) + .andWhere('canceled_at IS NULL') + .andWhere('sent_at > :sentAt', { sentAt }) + .execute(); + return (result.affected ?? 0) > 0; + } + /** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */ async cancel( id: string, diff --git a/backend/src/modules/file-sharing/file-sharing.service.spec.ts b/backend/src/modules/file-sharing/file-sharing.service.spec.ts index 4fe6cfc..d8549b0 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.spec.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.spec.ts @@ -91,6 +91,16 @@ class FakeRepository { return Promise.resolve(); } + /** 실제 표처럼 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만 (조건부 UPDATE 한 문장) */ + moveSentAtEarlier(id: string, sentAt: Date) { + const link = this.records.find((item) => item.link.id === id)?.link; + if (!link || link.canceledAt !== null || link.sentAt <= sentAt) { + return Promise.resolve(false); + } + link.sentAt = sentAt; + return Promise.resolve(true); + } + cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) { const link = this.records.find((item) => item.link.id === id)?.link; if (!link || link.canceledAt !== null) return Promise.resolve(false); @@ -314,6 +324,34 @@ describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계) expect(await codeOf(() => service.publicLink(token))).toBe('RES_001'); }); + it('예약 메일을 지금 보냈으면(메일 15단계) 보낸 때를 앞당긴다 — 끝나는 때는 그대로(덩이가 적은 날짜), 취소한 링크 · 더 늦은 시각은 바꾸지 않는다', async () => { + const { service, repository } = setup(); + const scheduled = new Date(Date.now() + 5 * 24 * 60 * 60_000); + const created = await service.createMailLink({ + ownerEmail: ARA, + senderName: '김아라', + files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }], + sentAt: scheduled, + messageId: 'ara.release@arasystem.test', + }); + const { link } = created.record; + const expiresAt = link.expiresAt.getTime(); + const now = new Date(); + + await expect(service.markReleased(link.id, now)).resolves.toBe(true); + expect(link.sentAt).toEqual(now); + expect(link.expiresAt.getTime()).toBe(expiresAt); + // 다시 와도(응답을 받지 못한 재시도) 더 늦은 시각으로 되돌리지 않는다 + await expect(service.markReleased(link.id, scheduled)).resolves.toBe(false); + expect(link.sentAt).toEqual(now); + + await service.cancel(created.record, 'send-canceled'); + await expect( + service.markReleased(link.id, new Date(now.getTime() - 1_000)), + ).resolves.toBe(false); + expect(repository.records).toHaveLength(1); + }); + describe('파일 응답 (Range · HEAD · 횟수)', () => { it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => { const { service } = setup(); diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts index 76fc345..9d480d7 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -199,6 +199,15 @@ export class FileSharingService implements BeforeApplicationShutdown { await this.repository.markSent(linkId, sentEmailId); } + /** + * 예약한 메일을 지금 보냈다(메일 15단계 — 계약 2절 "보내기 취소 · 예약 발송") — 보낸 때를 실제로 보낸 때로 앞당긴다(보낸 링크 목록 · 공개 화면의 보낸 때). + * **끝나는 때는 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 그 날짜를 이미 적었다. 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다(파일 기간도 그대로라 다시 세지 않는다). + * 취소한 링크 · 이미 그보다 앞선 보낸 때는 바꾸지 않는다 (true = 바꿨다) + */ + async markReleased(linkId: string, sentAt: Date): Promise { + return this.repository.moveSentAtEarlier(linkId, sentAt); + } + /** * 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다. * 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때") diff --git a/backend/src/modules/mail/dto/mail-block-run.dto.ts b/backend/src/modules/mail/dto/mail-block-run.dto.ts new file mode 100644 index 0000000..dfc3901 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-block-run.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MaxLength, ValidateIf } from 'class-validator'; + +import { + MAIL_FILTER_RUN_CURSOR_PATTERN, + MAX_EMAIL_LENGTH, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, +} from '@/modules/mail/mail.constants'; +import type { + MailBlockRunRequest, + MailFilterRun, +} from '@/modules/mail/mail.types'; +import { BlockAddress } from './mail-block.dto'; + +/** + * 차단할 때 받은편지함 정리 — POST /api/mail-block-runs 의 본문과 응답 (계약 2절 "보낸 사람 차단" — 15단계). + * 보낸 사람은 차단하기와 같은 검사(주소 · `@도메인` 모양 · 254자 — mail-blocks.ts), 차단 목록에 있는지는 서비스가 본다(없으면 404 RES_001). + * cursor 는 이미 받은 메일에 필터 적용(dto/mail-filter-run.dto.ts)과 같은 검사다 + */ + +/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +/** POST /api/mail-block-runs — 정리할 보낸 사람(차단 목록의 값)과 이어 할 자리 */ +export class MailBlockRunRequestDto implements MailBlockRunRequest { + @ApiProperty({ + description: + '차단 목록의 값 그대로 — 소문자 주소("kim@spam.example") 또는 도메인("@spam.example"). 차단한 뒤에만 부른다 — 차단 목록에 없으면 404 RES_001', + example: '@spam.example', + maxLength: MAX_EMAIL_LENGTH, + }) + @BlockAddress() + sender!: string; + + @ApiProperty({ + description: + '이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' + + '다른 보낸 사람의 cursor · 깨진 cursor 는 400 VAL_001', + nullable: true, + type: String, + maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, + example: null, + }) + @ValidateIf(unlessNull) + @IsString({ message: '이어 할 자리를 다시 받아 주세요.' }) + @MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + @Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + cursor!: string | null; +} + +/** POST /api/mail-block-runs 의 응답 — 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 */ +export class MailBlockRunDto implements MailFilterRun { + @ApiProperty({ + description: '이번 요청에서 확인한 받은편지함 메일 수', + example: 500, + }) + scanned!: number; + + @ApiProperty({ + description: + '그중 그 보낸 사람의 메일이라 스팸메일함으로 옮긴 수 (스팸 표시도 붙였다). 보내기를 기다리는 메일은 옮기지 않아 세지 않는다', + example: 37, + }) + matched!: number; + + @ApiProperty({ + description: + '아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값', + example: 4_300, + }) + remaining!: number; + + @ApiProperty({ + description: + 'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)', + nullable: true, + type: String, + example: + 'eyJ2IjoyLCJiIjoxNzkzMjY0NDAxLCJhIjoic2VhYWFhZXIiLCJ0IjoxNzkzMjY0MzQwLCJyIjoiZDJWM2R0ejdOMmdCVVRyZyJ9', + }) + cursor!: string | null; +} diff --git a/backend/src/modules/mail/dto/mail-block.dto.ts b/backend/src/modules/mail/dto/mail-block.dto.ts index e1c001e..818922d 100644 --- a/backend/src/modules/mail/dto/mail-block.dto.ts +++ b/backend/src/modules/mail/dto/mail-block.dto.ts @@ -32,7 +32,8 @@ function IsBlockTarget() { ); } -function BlockAddress(): PropertyDecorator { +/** 차단 대상 칸 — 차단하기(POST /api/mail-blocks) · 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 같이 쓴다 */ +export function BlockAddress(): PropertyDecorator { return applyDecorators( TrimString(), IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }), diff --git a/backend/src/modules/mail/dto/mailbox.dto.spec.ts b/backend/src/modules/mail/dto/mailbox.dto.spec.ts index e1fc5ba..3e53ac7 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.spec.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.spec.ts @@ -4,8 +4,9 @@ import { validate } from 'class-validator'; import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants'; import { CreateMailboxDto, + MailboxDeleteQueryDto, MailboxMessagesPatchDto, - RenameMailboxDto, + UpdateMailboxDto, } from './mailbox.dto'; /** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */ @@ -29,7 +30,7 @@ async function check( describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { it.each([ ['만들기', CreateMailboxDto], - ['이름 바꾸기', RenameMailboxDto], + ['바꾸기', UpdateMailboxDto], ] as const)( `%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`, async (_name, type) => { @@ -65,7 +66,7 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { it.each([ ['만들기', CreateMailboxDto], - ['이름 바꾸기', RenameMailboxDto], + ['바꾸기', UpdateMailboxDto], ] as const)( '%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류', async (_name, type) => { @@ -87,31 +88,72 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { expect((await check(type, { name: 7 })).constraints).toEqual( expect.arrayContaining(['isString']), ); - expect((await check(type, {})).constraints).toEqual( + expect((await check(type, { name: null })).constraints).toEqual( expect.arrayContaining(['isString']), ); }, ); - it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => { + it('만들기는 이름이 없으면 형식 오류 — 바꾸기는 이름을 보내지 않아도 된다(빈 본문은 서비스가 VAL_001)', async () => { + expect((await check(CreateMailboxDto, {})).constraints).toEqual( + expect.arrayContaining(['isString']), + ); + expect((await check(UpdateMailboxDto, {})).constraints).toEqual([]); + }); + + it.each([ + ['만들기', CreateMailboxDto, { name: '거래처' }], + ['바꾸기(옮기기 — 15단계)', UpdateMailboxDto, {}], + ] as const)( + '%s의 상위는 없거나 null(맨 위) · 메일함 id 형식', + async (_name, type, base) => { + expect((await check(type, base)).constraints).toEqual([]); + expect( + (await check(type, { ...base, parentId: null })).constraints, + ).toEqual([]); + expect( + (await check(type, { ...base, parentId: 'a' })).constraints, + ).toEqual([]); + for (const parentId of ['../a', '', 'a b', 7]) { + expect((await check(type, { ...base, parentId })).constraints).toEqual([ + 'matches', + ]); + } + }, + ); + + it('바꾸기는 이름 · 상위를 함께 받고, 보낸 값을 그대로 둔다 — null 상위는 null(맨 위), 보내지 않은 칸은 undefined', async () => { + const both = await check(UpdateMailboxDto, { + name: ' 2025 ', + parentId: 'u1', + }); + expect(both.constraints).toEqual([]); + expect(both.dto).toMatchObject({ name: '2025', parentId: 'u1' }); + + const top = await check(UpdateMailboxDto, { parentId: null }); + expect(top.constraints).toEqual([]); + expect(top.dto.parentId).toBeNull(); + expect(top.dto.name).toBeUndefined(); + expect( - (await check(CreateMailboxDto, { name: '거래처' })).constraints, - ).toEqual([]); + (await check(UpdateMailboxDto, { name: '거래처', role: 'inbox' })) + .constraints, + ).toEqual(['whitelistValidation']); + }); + + it('지우기의 쿼리는 withChildren=true 만 받는다 (15단계 — 하위 메일함까지)', async () => { + expect((await check(MailboxDeleteQueryDto, {})).constraints).toEqual([]); expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: null })) + (await check(MailboxDeleteQueryDto, { withChildren: 'true' })) .constraints, ).toEqual([]); + for (const withChildren of ['false', 'TRUE', '1', '', true]) { + expect( + (await check(MailboxDeleteQueryDto, { withChildren })).constraints, + ).toEqual(['isIn']); + } expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: 'a' })) - .constraints, - ).toEqual([]); - expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: '../a' })) - .constraints, - ).toEqual(['matches']); - expect( - (await check(RenameMailboxDto, { name: '거래처', parentId: 'a' })) - .constraints, + (await check(MailboxDeleteQueryDto, { recursive: 'true' })).constraints, ).toEqual(['whitelistValidation']); }); diff --git a/backend/src/modules/mail/dto/mailbox.dto.ts b/backend/src/modules/mail/dto/mailbox.dto.ts index a4731b9..d10cec2 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.ts @@ -1,6 +1,13 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Equals, IsOptional, IsString, Matches } from 'class-validator'; +import { + Equals, + IsIn, + IsOptional, + IsString, + Matches, + ValidateIf, +} from 'class-validator'; import { MaxCodeUnits, @@ -16,15 +23,32 @@ import { /** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */ const NO_SLASH_PATTERN = /^[^/]*$/; -/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */ -function MailboxName() { +/** 메일함 이름 칸의 설명 */ +const MAILBOX_NAME_DESCRIPTION = `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`; + +/** 보낸 칸만 검사한다 — 없으면(undefined) 건너뛰고, null 은 검사해 형식 오류로 막는다 (이름 · 상위 가운데 바꿀 것만 보내는 PATCH) */ +const unlessAbsent = (_object: object, value: unknown) => value !== undefined; + +/** + * 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 바꾸기가 같이 쓴다. + * `optional` 이면(바꾸기 — 15단계) 보내지 않아도 된다 — 보낸 값은 같은 검사를 한다(null 도 형식 오류) + */ +function MailboxName({ optional = false }: { optional?: boolean } = {}) { return applyDecorators( - ApiProperty({ - description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`, - example: '프로젝트', - minLength: 1, - maxLength: MAX_MAILBOX_NAME_LENGTH, - }), + optional + ? ApiPropertyOptional({ + description: `${MAILBOX_NAME_DESCRIPTION}. 보내지 않으면 이름은 그대로`, + example: '프로젝트', + minLength: 1, + maxLength: MAX_MAILBOX_NAME_LENGTH, + }) + : ApiProperty({ + description: MAILBOX_NAME_DESCRIPTION, + example: '프로젝트', + minLength: 1, + maxLength: MAX_MAILBOX_NAME_LENGTH, + }), + ...(optional ? [ValidateIf(unlessAbsent)] : []), TrimString(), IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }), MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }), @@ -59,10 +83,43 @@ export class CreateMailboxDto { parentId?: string | null; } -/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */ -export class RenameMailboxDto { - @MailboxName() - name!: string; +/** + * PATCH /api/mailboxes/{id} — 이름 · 상위(옮기기 — 15단계) 가운데 바꿀 것만. 둘 다 없으면(빈 본문) 서비스가 400 VAL_001. + * 상위의 자리 규칙(만들기와 같다) · 자기 자신 · 하위 아래로 옮기기는 어댑터가 본다 + */ +export class UpdateMailboxDto { + @MailboxName({ optional: true }) + name?: string; + + @ApiPropertyOptional({ + description: + '새 상위 메일함 id — null 이면 맨 위, 보내지 않으면 상위는 그대로. 만들기와 같은 자리만(받은편지함 · 보관함 · 내 메일함 아래 — 그 밖 400 VAL_001, 없는 id 는 404 RES_001). ' + + '자기 자신 · 자기 하위 아래로는 400 VAL_001. 하위 메일함과 안의 메일은 함께 간다', + nullable: true, + type: String, + example: 'a', + }) + @IsOptional() + @Matches(MAIL_ID_PATTERN, { message: '옮길 곳을 다시 선택해 주세요.' }) + parentId?: string | null; +} + +/** 하위 메일함까지 지우기의 값 — 이것만 받는다 (15단계) */ +export const WITH_CHILDREN = 'true'; + +/** DELETE /api/mailboxes/{id} 의 쿼리 — withChildren=true 면 하위 메일함까지 지운다 (15단계). 모르는 쿼리는 400 VAL_001 */ +export class MailboxDeleteQueryDto { + @ApiPropertyOptional({ + description: + 'true 면 하위 메일함이 있어도 그 메일함과 하위 모두를 지운다(깊은 것부터 — 안의 메일은 휴지통으로). 없으면 하위가 있을 때 400 VAL_001(옛 화면이 모르고 하위를 지우지 않게). 다른 값은 400 VAL_001', + enum: [WITH_CHILDREN], + example: WITH_CHILDREN, + }) + @IsOptional() + @IsIn([WITH_CHILDREN], { + message: '하위 메일함 지우기 값이 올바르지 않습니다.', + }) + withChildren?: typeof WITH_CHILDREN; } /** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */ diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts index 8fdaba7..fe660ef 100644 --- a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts +++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts @@ -1,7 +1,9 @@ import { BusinessException } from '@/common/errors/business.exception'; import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; import { + decodeBlockRunCursor, decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, MAX_FILTER_RUN_SECONDS, type FilterRunPosition, @@ -172,3 +174,62 @@ describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 ).toEqual({ before: NINE, anchor: null, anchorTime: null }); }); }); + +describe('차단할 때 받은편지함 정리의 cursor (15단계 — 계약 2절 "보낸 사람 차단")', () => { + const position: FilterRunPosition = { + before: NINE + 1, + anchor: 'seaaaaer', + anchorTime: NINE - 60, + }; + + it('자리는 필터 적용과 같은 모양으로 되돌린다 — 지문은 그 보낸 사람', () => { + for (const sender of ['kim@spam.example', '@spam.example']) { + expect( + decodeBlockRunCursor(encodeBlockRunCursor(position, sender), sender), + ).toEqual(position); + } + expect(encodeBlockRunCursor(position, '@spam.example')).toMatch( + /^[A-Za-z0-9_-]+$/, + ); + }); + + it('다른 보낸 사람의 cursor · 필터 적용의 cursor 는 VAL_001 — 거꾸로 차단 정리의 cursor 로 필터를 이어 하지도 못한다', () => { + const cursor = encodeBlockRunCursor(position, 'kim@spam.example'); + for (const other of ['lee@spam.example', '@spam.example']) { + expect(failureOf(() => decodeBlockRunCursor(cursor, other))).toEqual({ + code: 'VAL_001', + reason: 'invalid-mail-block-run-cursor:other-sender', + }); + } + expect( + failureOf(() => + decodeBlockRunCursor( + encodeFilterRunCursor(position, rule), + 'kim@spam.example', + ), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'invalid-mail-block-run-cursor:other-sender', + }); + expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({ + code: 'VAL_001', + reason: 'invalid-filter-run-cursor:other-rule', + }); + }); + + it('깨진 cursor 는 필터 적용과 같은 검사로 VAL_001', () => { + for (const [cursor, reason] of [ + ['%%%', 'not-json'], + [forged({ v: 1, b: NINE }), 'shape'], + ['a'.repeat(1_025), 'too-long'], + ] as const) { + expect( + failureOf(() => decodeBlockRunCursor(cursor, 'kim@spam.example')), + ).toEqual({ + code: 'VAL_001', + reason: `invalid-mail-block-run-cursor:${reason}`, + }); + } + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts index 057c3e1..840c4f5 100644 --- a/backend/src/modules/mail/jmap/filter-run-cursor.ts +++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts @@ -17,7 +17,10 @@ import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다 * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다 * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다) - * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다 + * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다. + * + * 차단할 때 받은편지함 정리(15단계 — 계약 2절 "보낸 사람 차단")도 같은 자리 · 같은 모양을 쓴다 — 지문만 그 보낸 사람(차단 목록의 값)이다. + * 지문의 재료가 달라(필터는 규칙의 세 칸, 차단은 표시 글 + 보낸 사람) 필터 적용의 cursor 를 차단 정리에 쓰면 다른 주인의 것으로 막힌다 */ const CURSOR_VERSION = 2; @@ -51,9 +54,20 @@ interface EncodedFilterRunCursor { r: string; } +/** 차단 정리 cursor 의 지문 재료 앞에 두는 표시 — 필터 규칙의 재료(첫 칸이 묶는 방법)와 섞이지 않게 */ +const MAIL_BLOCK_SCOPE = 'mail-block'; + +/** 정해진 차례의 JSON 을 SHA-256 으로 — 짧게 자른다 */ +function scopeHash(canonical: unknown[]): string { + return createHash('sha256') + .update(JSON.stringify(canonical), 'utf8') + .digest('base64url') + .slice(0, RULE_HASH_LENGTH); +} + /** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */ function ruleHash(rule: FilterRunRule): string { - const canonical = JSON.stringify([ + return scopeHash([ rule.match, rule.conditions.map(({ field, operator, value }) => [ field, @@ -62,15 +76,19 @@ function ruleHash(rule: FilterRunRule): string { ]), [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag], ]); - return createHash('sha256') - .update(canonical, 'utf8') - .digest('base64url') - .slice(0, RULE_HASH_LENGTH); } -function invalidCursor(reason: string): BusinessException { +/** 차단 정리의 지문 — 보낸 사람(차단 목록의 값 — 소문자) */ +function senderHash(sender: string): string { + return scopeHash([MAIL_BLOCK_SCOPE, sender]); +} + +/** 깨진 cursor 의 로그 까닭 — 필터 적용 · 차단 정리를 가른다 */ +type CursorKind = 'filter-run' | 'mail-block-run'; + +function invalidCursor(kind: CursorKind, reason: string): BusinessException { return new BusinessException('VAL_001', { - context: { reason: `invalid-filter-run-cursor:${reason}` }, + context: { reason: `invalid-${kind}-cursor:${reason}` }, }); } @@ -84,50 +102,85 @@ function isSeconds(value: unknown): value is number { ); } -export function encodeFilterRunCursor( - position: FilterRunPosition, - rule: FilterRunRule, -): string { +/** 자리와 지문을 cursor 로 */ +function encodeRunCursor(position: FilterRunPosition, hash: string): string { const payload: EncodedFilterRunCursor = { v: CURSOR_VERSION, b: position.before, a: position.anchor, t: position.anchorTime, - r: ruleHash(rule), + r: hash, }; return Buffer.from(JSON.stringify(payload)).toString('base64url'); } +/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 지문이 다르면(다른 주인의 cursor) VAL_001 */ +function decodeRunCursor( + cursor: string, + hash: string, + kind: CursorKind, +): FilterRunPosition { + if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { + throw invalidCursor(kind, 'too-long'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw invalidCursor(kind, 'not-json'); + } + + const { v, b, a, t, r } = (parsed ?? {}) as Partial; + if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { + throw invalidCursor(kind, 'shape'); + } + if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { + throw invalidCursor(kind, 'shape'); + } + // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 + if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { + throw invalidCursor(kind, 'shape'); + } + if (typeof r !== 'string') throw invalidCursor(kind, 'shape'); + + if (r !== hash) { + throw invalidCursor( + kind, + kind === 'filter-run' ? 'other-rule' : 'other-sender', + ); + } + + return { before: b, anchor: a, anchorTime: t }; +} + +export function encodeFilterRunCursor( + position: FilterRunPosition, + rule: FilterRunRule, +): string { + return encodeRunCursor(position, ruleHash(rule)); +} + /** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */ export function decodeFilterRunCursor( cursor: string, rule: FilterRunRule, ): FilterRunPosition { - if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { - throw invalidCursor('too-long'); - } - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); - } catch { - throw invalidCursor('not-json'); - } - - const { v, b, a, t, r } = (parsed ?? {}) as Partial; - if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { - throw invalidCursor('shape'); - } - if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { - throw invalidCursor('shape'); - } - // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 - if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { - throw invalidCursor('shape'); - } - if (typeof r !== 'string') throw invalidCursor('shape'); - - if (r !== ruleHash(rule)) throw invalidCursor('other-rule'); - - return { before: b, anchor: a, anchorTime: t }; + return decodeRunCursor(cursor, ruleHash(rule), 'filter-run'); +} + +/** 차단 정리의 cursor (15단계) — 자리는 필터 적용과 같고 지문은 그 보낸 사람 */ +export function encodeBlockRunCursor( + position: FilterRunPosition, + sender: string, +): string { + return encodeRunCursor(position, senderHash(sender)); +} + +/** 차단 정리의 cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 보낸 사람(또는 필터 적용)의 것이면 VAL_001 */ +export function decodeBlockRunCursor( + cursor: string, + sender: string, +): FilterRunPosition { + return decodeRunCursor(cursor, senderHash(sender), 'mail-block-run'); } diff --git a/backend/src/modules/mail/jmap/filter-run-texts.ts b/backend/src/modules/mail/jmap/filter-run-texts.ts index acf2db8..8b7b4b5 100644 --- a/backend/src/modules/mail/jmap/filter-run-texts.ts +++ b/backend/src/modules/mail/jmap/filter-run-texts.ts @@ -83,6 +83,33 @@ function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] { ); } +/** + * 차단할 때 받은편지함 정리(15단계)가 읽는 보낸 사람 — 엔진이 푼 From 머리글의 주소 목록, 같은 이름의 머리글이 여럿이면 모두(머리글마다 배열). + * Sieve 의 `address` 판정(jmap/sieve-script.ts 의 blockSection)이 견주는 것과 같다 — 표시 이름에 쓴 주소는 들지 않는다. + * 엔진은 물은 이름 그대로 답한다(Stalwart 0.16.27 — 2026-11-09 확인: `[[{ name, email }]]`) + */ +export const FROM_ADDRESSES_PROPERTY = 'header:From:asAddresses:all'; + +/** From 머리글들의 주소 — 물은 이름으로 찾되 대소문자가 달라도 찾는다. 모양이 틀린 칸은 건너뛴다 */ +export function fromAddressesOf(email: JmapEmail): string[] { + const wanted = FROM_ADDRESSES_PROPERTY.toLowerCase(); + for (const [key, value] of Object.entries(email)) { + if (key.toLowerCase() !== wanted || !Array.isArray(value)) continue; + return value.flatMap((header: unknown) => + Array.isArray(header) + ? header.flatMap((address: unknown) => { + const found = + typeof address === 'object' && address !== null + ? (address as { email?: unknown }).email + : undefined; + return typeof found === 'string' ? [found] : []; + }) + : [], + ); + } + return []; +} + /** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */ export function headerTextsOf(email: JmapEmail): Omit { const subject = email.subject; 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 68bdac7..40f7ce8 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts @@ -11,6 +11,7 @@ import type { } from '@/modules/mail/mail.types'; import { decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, } from './filter-run-cursor'; import type { JmapClient } from './jmap.client'; @@ -1180,6 +1181,278 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 ).toMatchObject({ code: 'MAIL_001', reason: 'timeout' }); }); }); + + describe('차단할 때 받은편지함 정리 (runBlock — 15단계)', () => { + const JUNK = 'k'; + const BLOCK_MAILBOXES: readonly Mailbox[] = [ + mailbox(INBOX, 'inbox', 3), + mailbox(TARGET, null), + mailbox(JUNK, 'junk'), + ]; + const blockContext = ( + overrides: Partial = {}, + ): MailFilterRunContext => ({ + mailboxes: BLOCK_MAILBOXES, + startedAt: Date.now(), + ...overrides, + }); + /** 받은편지함의 메일 — 엔진이 푼 From 머리글의 주소(머리글마다 배열) */ + const fromMail = ( + id: string, + ...headers: { name: string | null; email: string }[][] + ) => ({ + id, + mailboxIds: { [INBOX]: true }, + receivedAt: utc(T0), + 'header:From:asAddresses:all': headers, + }); + const KIM = { name: null, email: 'kim@spam.example' }; + const junkPatch = { + [`mailboxIds/${INBOX}`]: null, + [`mailboxIds/${JUNK}`]: true, + 'keywords/$junk': true, + 'keywords/$notjunk': null, + }; + + it('받은편지함을 받은 시각 내림차순으로 읽어 엔진이 푼 From 주소로 판정하고, 맞은 메일을 스팸메일함으로 옮기며 스팸 표시 — 옮기기는 읽은 상태 그대로일 때만', async () => { + const { runner, recorded } = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + fromMail('m1', [KIM]), + fromMail('m2', [{ name: null, email: 'lee@spam.example' }]), + fromMail('m3', [{ name: 'Kim', email: 'KIM@Spam.Example' }]), + ], + ), + updated(['m1', 'm3']), + ]); + + await expect( + runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ), + ).resolves.toEqual({ + scanned: 3, + matched: 2, + remaining: 0, + cursor: null, + rounds: 1, + }); + expect(argsOf(recorded[0])).toMatchObject({ + filter: { inMailbox: INBOX }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 500, + calculateTotal: true, + }); + expect(argsOf(recorded[0], 1)).toMatchObject({ + properties: [ + 'id', + 'mailboxIds', + 'receivedAt', + 'header:From:asAddresses:all', + ], + }); + // 붙잡힌 메일을 같은 요청에서 묻는다 (9단계 — 옮기지 않는다) + expect(methodsOf([recorded[0]])).toEqual([ + 'Email/query', + 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { m1: junkPatch, m3: junkPatch }, + }); + }); + + it('판정은 차단 규칙(Sieve 의 address)과 같다 — 주소 전체가 같을 때 · From 이 여럿이면 하나라도 · 표시 이름의 주소 · 비슷한 주소는 아니다 (대소문자는 ASCII 만)', async () => { + const cases: [string, ReturnType][] = [ + ['맞음', fromMail('m1', [KIM])], + [ + '맞음', + fromMail('m2', [ + { name: 'First', email: 'first@example.net' }, + { name: 'Kim', email: 'kim@spam.example' }, + ]), + ], + [ + '맞음', + fromMail('m3', [{ name: null, email: 'first@example.net' }], [KIM]), + ], + [ + '아님', + fromMail('m4', [ + { name: 'kim@spam.example', email: 'other@example.net' }, + ]), + ], + [ + '아님', + fromMail('m5', [{ name: null, email: 'kim.lee@spam.example' }]), + ], + [ + '아님', + fromMail('m6', [{ name: null, email: 'kim@sub.spam.example' }]), + ], + [ + '아님', + fromMail('m7', [{ name: null, email: 'akim@spam.example' }]), + ], + ['아님', fromMail('m8')], + ]; + const { runner, recorded } = fakeRunner([ + page( + cases.map(([, mail]) => mail.id), + cases.map(([, mail]) => mail), + ), + updated(['m1', 'm2', 'm3']), + ]); + + await runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ); + expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual( + cases + .filter(([verdict]) => verdict === '맞음') + .map(([, mail]) => mail.id), + ); + }); + + it('도메인 차단(@도메인)은 From 주소의 @ 뒤가 그 도메인일 때만 — 하위 도메인 · 더 긴 도메인 · 표시 이름은 아니다', async () => { + const mails = [ + fromMail('m1', [{ name: null, email: 'kim@spam.example' }]), + fromMail('m2', [{ name: null, email: 'Lee@SPAM.example' }]), + fromMail('m3', [{ name: null, email: 'kim@sub.spam.example' }]), + fromMail('m4', [{ name: null, email: 'kim@spam.example.net' }]), + fromMail('m5', [ + { name: '@spam.example', email: 'kim@other.example' }, + ]), + ]; + const { runner, recorded } = fakeRunner([ + page( + mails.map(({ id }) => id), + mails, + ), + updated(['m1', 'm2']), + ]); + + await runner.runBlock( + ARA, + { sender: '@spam.example', cursor: null }, + blockContext(), + ); + expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual([ + 'm1', + 'm2', + ]); + }); + + it('붙잡힌 메일(9단계)은 맞아도 옮기지 않는다 — 받은편지함에 남긴 것으로 세고 matched 에 넣지 않는다', async () => { + const later = utc(Math.floor(Date.now() / 1_000) + 3_600); + const { runner, recorded } = fakeRunner([ + page(['m1', 'm2'], [fromMail('m1', [KIM]), fromMail('m2', [KIM])], { + held: [ + { + id: 'sub1', + emailId: 'm2', + undoStatus: 'pending', + sendAt: later, + }, + ], + }), + updated(['m1']), + ]); + + await expect( + runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 1 }); + expect(argsOf(recorded[1])).toMatchObject({ + update: { m1: junkPatch }, + }); + expect(argsOf(recorded[1]).update).not.toHaveProperty('m2'); + }); + + it('스팸메일함이 없으면 엔진에 묻지 않고 MAIL_001 · 받은편지함이 없으면 할 메일이 없다', async () => { + const noJunk = fakeRunner([]); + expect( + await failureOf( + noJunk.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext({ + mailboxes: BLOCK_MAILBOXES.filter( + ({ role }) => role !== 'junk', + ), + }), + ), + ), + ).toMatchObject({ code: 'MAIL_001', reason: 'junk-mailbox-missing' }); + expect(noJunk.recorded).toHaveLength(0); + + const noInbox = fakeRunner([]); + await expect( + noInbox.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext({ + mailboxes: BLOCK_MAILBOXES.filter(({ role }) => role !== 'inbox'), + }), + ), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, + }); + }); + + it('이어 할 자리 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 엔진에 묻기 전에 VAL_001', async () => { + const position = { before: T0 + 1, anchor: 'm1', anchorTime: T0 }; + const others = [ + encodeBlockRunCursor(position, 'lee@spam.example'), + encodeFilterRunCursor(position, request()), + 'not-a-cursor', + ]; + for (const cursor of others) { + const fake = fakeRunner([]); + expect( + await failureOf( + fake.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor }, + blockContext(), + ), + ), + ).toMatchObject({ code: 'VAL_001' }); + expect(fake.recorded).toHaveLength(0); + } + + // 같은 보낸 사람의 cursor 는 그 자리부터 — anchor 다음 · 위 경계 미만 + const fake = fakeRunner([page([], [])]); + await fake.runner.runBlock( + ARA, + { + sender: 'kim@spam.example', + cursor: encodeBlockRunCursor(position, 'kim@spam.example'), + }, + blockContext(), + ); + expect(argsOf(fake.recorded[0])).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm1', + anchorOffset: 1, + }); + }); + }); }); /** @@ -1369,6 +1642,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 ), receivedAt: utc(email.receivedAt), 'header:From:all': [` ${email.from}`], + // 엔진이 푼 주소 — 꺾쇠 안 (차단 정리 — 15단계) + 'header:From:asAddresses:all': [ + [{ name: null, email: /<([^>]+)>/.exec(email.from)?.[1] ?? '' }], + ], }; } @@ -1706,5 +1983,68 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 } expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length); }); + + it('차단 정리(15단계)도 같은 틀로 끝까지 — 그 보낸 사람의 메일만 스팸메일함으로(스팸 표시) · 요청 사이에 온 새 메일은 건드리지 않는다', async () => { + const JUNK = 'k'; + const engine = new FakeEngine(); + fill(engine, 1_200); + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const client = { + call: jest.fn( + (_user: string, build: (accountId: string) => JmapInvocation[]) => { + const calls = build('d'); + if (calls[0][0] === 'Email/query') now += 21_000; + return Promise.resolve(engine.handle(calls)); + }, + ), + }; + const runner = new JmapFilterRunner(client as unknown as JmapClient); + const sender = 'sales@hanbit-partners.co.kr'; + const wanted = engine + .sorted(INBOX) + .filter(({ from }) => from.includes(sender)).length; + + let cursor: string | null = null; + let requests = 0; + let matched = 0; + do { + // 요청 사이에 그 보낸 사람의 새 메일이 온다 — 시작한 뒤 온 메일은 보지 않는다 (새 메일은 Sieve 가 이미 판정했다) + if (cursor !== null) engine.add(T0 + 10_000 + requests, HANBIT_FROM); + const result = await runner.runBlock( + ARA, + { sender, cursor }, + { + mailboxes: [ + mailbox(INBOX, 'inbox', engine.sorted(INBOX).length), + mailbox(JUNK, 'junk'), + ], + startedAt: now, + }, + ); + matched += result.matched; + cursor = result.cursor; + requests += 1; + now += 100_000; + } while (cursor !== null && requests < 100); + + expect(cursor).toBeNull(); + expect(requests).toBeGreaterThan(1); + expect(matched).toBe(wanted); + const moved = engine.sorted(JUNK); + expect(moved).toHaveLength(wanted); + for (const email of moved) { + expect(email.keywords.has('$junk')).toBe(true); + expect(email.receivedAt).toBeLessThan(T0 + 10_000); + } + // 받은편지함에 남은 그 보낸 사람의 메일은 요청 사이에 온 새 메일뿐 + const left = engine + .sorted(INBOX) + .filter(({ from }) => from.includes(sender)); + expect(left).toHaveLength(requests - 1); + expect(left.every(({ receivedAt }) => receivedAt >= T0 + 10_000)).toBe( + true, + ); + }); }); }); diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts index 0a5dbb9..0264747 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; +import { isBlockedSenderMail } from '@/modules/mail/mail-block-match'; import { filterFields, judgeFilter, @@ -8,6 +9,7 @@ import { } from '@/modules/mail/mail-filter-match'; import type { Mailbox, + MailBlockRunRequest, MailFilterRunContext, MailFilterRunOutcome, MailFilterRunRequest, @@ -15,15 +17,20 @@ import type { import { BulkRun } from './bulk-run'; import { MAX_FILTER_RUN_SECONDS, + decodeBlockRunCursor, decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, + type FilterRunPosition, } from './filter-run-cursor'; import { FILTER_RUN_BODY_PROPERTIES, FILTER_RUN_PART_PROPERTIES, + FROM_ADDRESSES_PROPERTY, bodyTextsOf, estimatedBodyBytes, filterRunProperties, + fromAddressesOf, headerTextsOf, textPartsOf, } from './filter-run-texts'; @@ -97,6 +104,32 @@ const HELD_GUARD_CALLS = 'h'; /** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */ const NO_HELD: HeldEmails = { ids: new Set(), complete: true }; +/** 처음부터 — 위 경계 · 남긴 메일 · 그 받은 시각을 아직 모른다 (첫 차례가 위 경계를 잡는다) */ +const FROM_TOP: RunState = { before: null, anchor: null, anchorTime: null }; + +/** 바꾼 것이 없다 — 맞은 메일이 없거나 모두 바꿀 것이 없는 차례 */ +const NOTHING_APPLIED: Applied = { updated: new Set(), gone: new Set() }; + +/** + * 차단 정리(15단계)가 차례마다 읽는 속성 — 받은편지함에 아직 있는지 · 받은 시각(이어 할 자리) · 엔진이 푼 From 머리글의 주소(filter-run-texts.ts). + * 표시는 읽지 않는다 — 맞은 메일은 늘 옮기고 스팸 표시를 붙인다(이미 붙어 있어도 같은 패치) + */ +const BLOCK_RUN_PROPERTIES = [ + 'id', + 'mailboxIds', + 'receivedAt', + FROM_ADDRESSES_PROPERTY, +]; + +/** 받은편지함이 없는 계정 — 할 메일이 없다 (필터 적용 · 차단 정리 모두) */ +const NOTHING_TO_RUN: MailFilterRunOutcome = { + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, +}; + /** * 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지. * 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다 @@ -185,6 +218,37 @@ interface BodyBatch { readonly ids: readonly string[]; } +/** 한 차례를 판정하고 할 일을 한다 — 필터 적용(applyRound) · 차단 정리(applyBlockRound). 멈춰야 하면 null */ +type RoundRunner = (page: RoundPage) => Promise; + +/** 받은편지함을 차례로 훑은 결과 (sweep) */ +interface SweepResult { + readonly scanned: number; + readonly matched: number; + readonly remaining: number; + /** 끝까지 했다 — 이어 할 자리가 없다 */ + readonly finished: boolean; + /** 마지막으로 마친 차례까지의 자리 */ + readonly position: RunState; + readonly rounds: number; +} + +/** 판정 — true 맞음 · false 안 맞음 · 'gone' 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ +type Verdict = boolean | 'gone'; + +/** + * 차단 정리(15단계)의 패치 — 받은편지함에서 빼고 스팸메일함에 넣고 스팸 표시(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다, + * jmap-mail.provider.ts 의 toEmailPatch) + */ +function blockPatch(inboxId: string, junkId: string): Record { + return { + [`mailboxIds/${inboxId}`]: null, + [`mailboxIds/${junkId}`]: true, + 'keywords/$junk': true, + 'keywords/$notjunk': null, + }; +} + /** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */ function runPlan( request: MailFilterRunRequest, @@ -242,6 +306,41 @@ function utcDate(seconds: number): string { return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString()); } +/** + * 한 차례를 마친 결과 — 판정한 수(받은편지함에서 빠진 메일은 빼고)와 받은편지함에 남긴 마지막 메일(다음 차례의 anchor). + * 옮긴 메일(moving 가운데 바뀐 것)과 그사이 없어진 메일은 남긴 것이 아니다 + * @param moving 옮기기가 든 패치를 보낸 메일 — 바뀌면 받은편지함을 떠난다 + * @param matched 이 차례에서 할 일을 한 수 (필터 적용은 이미 표시가 있던 메일 포함 · 차단 정리는 옮긴 수) + */ +function roundResult( + page: RoundPage, + verdicts: ReadonlyMap, + moving: ReadonlySet, + applied: Applied, + matched: number, +): RoundResult { + let judged = 0; + let lastStayed: string | null = null; + for (const id of page.ids) { + const verdict = verdicts.get(id); + if (verdict === undefined || verdict === 'gone') continue; + judged += 1; + const moved = moving.has(id) && applied.updated.has(id); + if (!moved && !applied.gone.has(id)) lastStayed = id; + } + return { + judged, + matched, + lastStayed: + lastStayed === null + ? null + : { + id: lastStayed, + time: receivedSeconds(page.emails.get(lastStayed)), + }, + }; +} + /** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */ function newestSecond(page: RoundPage): number | null { let newest: number | null = null; @@ -305,6 +404,9 @@ function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] { * 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다 * - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다) * - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다 + * + * 차단할 때 받은편지함 정리(runBlock — 15단계, 계약 2절 "보낸 사람 차단")도 같은 틀이다 — 차례 · 위 경계 · 자리 · 시간 · 멈춤 · 옮기기의 조건(ifInState) · + * 붙잡힌 메일 막기는 그대로(sweep · readPage · applyPatches), 판정만 차단 규칙(Sieve 의 `address` — mail-block-match.ts)이고 할 일은 스팸메일함으로 옮기기 + 스팸 표시다 */ @Injectable() export class JmapFilterRunner { @@ -318,17 +420,15 @@ export class JmapFilterRunner { context: MailFilterRunContext, ): Promise { // 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001 - let position: RunState = + const start: RunState = request.cursor === null - ? { before: null, anchor: null, anchorTime: null } + ? FROM_TOP : decodeFilterRunCursor(request.cursor, request); const run = new BulkRun(context.startedAt); const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox'); // 받은편지함이 없는 계정 — 적용할 메일이 없다 - if (!inbox) { - return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 }; - } + if (!inbox) return NOTHING_TO_RUN; const plan = runPlan(request, inbox.id, context.mailboxes); const fields = filterFields(request); @@ -341,6 +441,78 @@ export class JmapFilterRunner { withHeld: plan.moveTo !== null, }; + const swept = await this.sweep(user, inbox, start, read, run, (page) => + this.applyRound(user, request, plan, page, run), + ); + if (!swept.finished) run.assertProgress('filter-run'); + return this.outcomeOf(swept, (position) => + encodeFilterRunCursor(position, request), + ); + } + + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함의 그 보낸 사람 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다. + * 판정은 엔진이 푼 From 머리글의 주소로 차단 규칙과 같게(mail-block-match.ts) — 머리글 원문을 읽지 않는다(표시 이름에 쓴 주소가 걸리지 않게). + * 맞은 메일은 늘 옮긴다(옮기기라 읽은 상태 그대로일 때만 — ifInState). 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). + * 스팸메일함이 없으면 엔진에 묻기 전에 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 그 앞에 VAL_001. + * 응답의 matched 는 스팸메일함으로 옮긴 수다 + */ + async runBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise { + const start: RunState = + request.cursor === null + ? FROM_TOP + : decodeBlockRunCursor(request.cursor, request.sender); + + const run = new BulkRun(context.startedAt); + const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox'); + if (!inbox) return NOTHING_TO_RUN; + const junk = context.mailboxes.find((mailbox) => mailbox.role === 'junk'); + if (!junk) { + throw new BusinessException('MAIL_001', { + context: { reason: 'junk-mailbox-missing' }, + }); + } + + const plan: RunPlan = { + inboxId: inbox.id, + moveTo: junk.id, + markAsRead: false, + flag: false, + }; + const read: PageRead = { + properties: BLOCK_RUN_PROPERTIES, + needsBody: false, + limit: HEADER_ROUND_SIZE, + withHeld: true, + }; + + const swept = await this.sweep(user, inbox, start, read, run, (page) => + this.applyBlockRound(user, request.sender, plan, junk.id, page, run), + ); + if (!swept.finished) run.assertProgress('mail-block-run'); + return this.outcomeOf(swept, (position) => + encodeBlockRunCursor(position, request.sender), + ); + } + + /** + * 받은편지함을 받은 시각이 최근인 메일부터 차례로 훑는다 — 차례마다 읽고(readPage) 판정 · 할 일(applyRound)을 한 뒤 자리를 옮긴다. + * 한 요청의 시간 · 멈춤은 BulkRun. 처음 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다(그 뒤에 온 메일은 보지 않는다). + * 남은 시간 때문에 끊긴 호출은 진행한 것이 있으면 거기까지로 답한다(cutOffAfterProgress) + */ + private async sweep( + user: string, + inbox: Mailbox, + start: RunState, + read: PageRead, + run: BulkRun, + applyRound: RoundRunner, + ): Promise { + let position = start; let scanned = 0; let matched = 0; let rounds = 0; @@ -359,7 +531,7 @@ export class JmapFilterRunner { const newest = position.before === null ? newestSecond(page) : null; if (newest !== null) position = { ...position, before: newest + 1 }; - const round = await this.applyRound(user, request, plan, page, run); + const round = await applyRound(page); // 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다) if (round === null) break; @@ -387,22 +559,38 @@ export class JmapFilterRunner { } catch (error) { if (!run.cutOffAfterProgress(error)) throw error; } - if (!finished) run.assertProgress('filter-run'); - return { scanned, matched, remaining: finished ? 0 : remaining, - cursor: finished ? null : this.cursorOf(position, request), + finished, + position, rounds, }; } + /** 훑은 결과 → 응답 — 끝까지 했으면 cursor 없음, 아니면 마친 자리의 cursor (cursorOf) */ + private outcomeOf( + swept: SweepResult, + encode: (position: FilterRunPosition) => string, + ): MailFilterRunOutcome { + return { + scanned: swept.scanned, + matched: swept.matched, + remaining: swept.remaining, + cursor: swept.finished ? null : this.cursorOf(swept.position, encode), + rounds: swept.rounds, + }; + } + /** * 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001 * (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다) */ - private cursorOf(position: RunState, request: MailFilterRunRequest): string { + private cursorOf( + position: RunState, + encode: (position: FilterRunPosition) => string, + ): string { if ( position.before === null || (position.anchor !== null && position.anchorTime === null) @@ -411,14 +599,11 @@ export class JmapFilterRunner { context: { reason: 'received-at-missing' }, }); } - return encodeFilterRunCursor( - { - before: position.before, - anchor: position.anchor, - anchorTime: position.anchorTime, - }, - request, - ); + return encode({ + before: position.before, + anchor: position.anchor, + anchorTime: position.anchorTime, + }); } /** @@ -571,7 +756,7 @@ export class JmapFilterRunner { run: BulkRun, ): Promise { /** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ - const verdicts = new Map(); + const verdicts = new Map(); const undecided: JmapEmail[] = []; const headerTexts = new Map>(); for (const id of page.ids) { @@ -641,30 +826,69 @@ export class JmapFilterRunner { const applied: Applied | null = patches.size === 0 - ? { updated: new Set(), gone: new Set() } + ? NOTHING_APPLIED : await this.applyPatches(user, plan, page.state, patches, run); if (applied === null) return null; - let judged = 0; - let lastStayed: string | null = null; + return roundResult( + page, + verdicts, + moving, + applied, + unchanged + applied.updated.size, + ); + } + + /** + * 차단 정리의 한 차례 (15단계) — 엔진이 푼 From 주소로 판정하고(mail-block-match.ts) 맞은 메일을 스팸메일함으로 옮긴다(스팸 표시와 함께). + * 붙잡힌 메일(9단계)은 옮기지 않는다 — 받은편지함에 남은 것으로 센다. matched 는 옮긴 수다. 멈춰야 하면 null (applyPatches) + */ + private async applyBlockRound( + user: string, + sender: string, + plan: RunPlan, + junkId: string, + page: RoundPage, + run: BulkRun, + ): Promise { + const verdicts = new Map(); for (const id of page.ids) { - const verdict = verdicts.get(id); - if (verdict === undefined || verdict === 'gone') continue; - judged += 1; - const moved = moving.has(id) && applied.updated.has(id); - if (!moved && !applied.gone.has(id)) lastStayed = id; + const email = page.emails.get(id); + verdicts.set( + id, + email?.mailboxIds?.[plan.inboxId] !== true + ? 'gone' + : isBlockedSenderMail(fromAddressesOf(email), sender), + ); } - return { - judged, - matched: unchanged + applied.updated.size, - lastStayed: - lastStayed === null - ? null - : { - id: lastStayed, - time: receivedSeconds(page.emails.get(lastStayed)), - }, - }; + + const matchedIds = page.ids.filter((id) => verdicts.get(id) === true); + const held = + matchedIds.length === 0 + ? new Set() + : await heldAmong( + page.held, + matchedIds, + this.heldLookup(user, run.callOptions()), + ); + const patches = new Map>(); + for (const id of matchedIds) { + if (!held.has(id)) patches.set(id, blockPatch(plan.inboxId, junkId)); + } + + const applied: Applied | null = + patches.size === 0 + ? NOTHING_APPLIED + : await this.applyPatches(user, plan, page.state, patches, run); + if (applied === null) return null; + + return roundResult( + page, + verdicts, + new Set(patches.keys()), + applied, + applied.updated.size, + ); } /** 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 59fc8cf..55614ee 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -11,7 +11,10 @@ import { MAX_ENVELOPE_RESUBMITS, MAX_SEARCH_WORDS, } from '@/modules/mail/mail.constants'; -import { SendNotStartedException } from '@/modules/mail/mail.provider'; +import { + ScheduledSendReturnedToDraftsException, + SendNotStartedException, +} from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { DraftContent, @@ -6545,7 +6548,7 @@ describe('JmapMailProvider', () => { }); }); - describe('이름 바꾸기 (renameMailbox)', () => { + describe('이름 바꾸기 · 옮기기 (updateMailbox — 15단계)', () => { it('그 메일함만 이름을 바꾸고 같은 요청에서 다시 읽어 돌려준다', async () => { const { provider, recorded } = fakeClient([ mailboxList(), @@ -6566,9 +6569,14 @@ describe('JmapMailProvider', () => { ], ]); + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); await expect( - provider.renameMailbox(ARA, 'u1', '새 이름'), + provider.updateMailbox(ARA, 'u1', { name: '새 이름' }), ).resolves.toMatchObject({ id: 'u1', name: '새 이름', totalCount: 2 }); + // 이름만 바꾸기는 지금처럼 로그를 남기지 않는다 (옮겼을 때만) + expect(log).not.toHaveBeenCalled(); expect(recorded[1].calls).toEqual([ [ 'Mailbox/set', @@ -6594,41 +6602,201 @@ describe('JmapMailProvider', () => { ['i1', 'VAL_001'], ['nope', 'RES_001'], ] as const) { - const { provider, recorded } = fakeClient([ - mailboxList(withUnknownRole), - ]); - expect(await codeOf(provider.renameMailbox(ARA, id, '새 이름'))).toBe( - code, - ); - expect(recorded).toHaveLength(1); + for (const change of [{ name: '새 이름' }, { parentId: 'u3' }]) { + const { provider, recorded } = fakeClient([ + mailboxList(withUnknownRole), + ]); + expect(await codeOf(provider.updateMailbox(ARA, id, change))).toBe( + code, + ); + expect(recorded).toHaveLength(1); + } } }); - it('지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => { - const { provider, recorded } = fakeClient([mailboxList()]); - - await expect( - provider.renameMailbox(ARA, 'u1', '프로젝트'), - ).resolves.toMatchObject({ id: 'u1', name: '프로젝트' }); - expect(recorded).toHaveLength(1); + it('지금 이름 · 지금 상위와 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => { + const cases: [string, { name?: string; parentId?: string | null }][] = [ + ['u1', { name: '프로젝트' }], + ['u2', { parentId: 'u1' }], + ['u3', { parentId: null }], + ['u2', { name: '하위', parentId: 'u1' }], + ]; + for (const [id, change] of cases) { + const { provider, recorded } = fakeClient([mailboxList()]); + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); + await expect( + provider.updateMailbox(ARA, id, change), + ).resolves.toMatchObject({ id }); + expect(recorded).toHaveLength(1); + // 바꾸지 않았으니 "옮겼습니다" 로그도 없다 + expect(log).not.toHaveBeenCalled(); + } }); - it('같은 이름이 있으면(alreadyExists) MAIL_009, 그사이 없어졌으면 RES_001', async () => { + it('같은 이름이 있으면(alreadyExists — 대소문자 무시) MAIL_009, 그사이 없어졌으면 RES_001', async () => { for (const [setError, code] of [ [{ type: 'alreadyExists' }, 'MAIL_009'], [{ type: 'notFound' }, 'RES_001'], ] as const) { + for (const change of [{ name: '보관 자료' }, { parentId: 'u4' }]) { + const { provider } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'], + ['Mailbox/get', { list: [] }, 'm'], + ], + ]); + expect( + await codeOf(provider.updateMailbox(ARA, 'u1', change)), + ).toBe(code); + } + } + }); + + it('옮기기 — parentId 를 바꾸고 목록 전체를 같은 요청에서 다시 읽는다 (맨 위는 null, 이름과 함께도 된다)', async () => { + const cases: [ + string, + { name?: string; parentId: string | null }, + Record, + ][] = [ + ['u1', { parentId: 'u4' }, { parentId: 'u4' }], + ['u2', { parentId: null }, { parentId: null }], + [ + 'u3', + { name: '2025', parentId: 'a' }, + { name: '2025', parentId: 'a' }, + ], + ]; + for (const [id, change, update] of cases) { + const moved = userMailbox(id, change.name ?? id, { + parentId: change.parentId, + }); + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { updated: { [id]: null } }, 'u'], + [ + 'Mailbox/get', + { + list: [ + ...mailboxes.filter((mailbox) => mailbox.id !== id), + moved, + ], + }, + 'm', + ], + ], + ]); + + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); + await expect( + provider.updateMailbox(ARA, id, change), + ).resolves.toMatchObject({ id, parentId: change.parentId }); + // 옮겼을 때만 — 메일함 id · 새 상위 id 만 (이름 · 주소 없이) + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0] as [string]; + expect(line).toContain(`메일함=${id}`); + expect(line).toContain(`새 상위=${change.parentId ?? '맨 위'}`); + expect(line).not.toContain('2025'); + expect(line).not.toContain(ARA); + expect(recorded[1].calls).toEqual([ + ['Mailbox/set', { accountId: 'd', update: { [id]: update } }, 'u'], + [ + 'Mailbox/get', + { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + } + }); + + it('받은편지함 · 보관함 · 내 메일함 아래로는 옮길 수 있다 — 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 VAL_001, 없는 상위는 RES_001 (엔진에 묻지 않는다)', async () => { + for (const parentId of ['a', 'f', 'u3']) { const { provider } = fakeClient([ mailboxList(), [ - ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'], - ['Mailbox/get', { list: [] }, 'm'], + ['Mailbox/set', { updated: { u4: null } }, 'u'], + [ + 'Mailbox/get', + { list: [userMailbox('u4', '보관 자료', { parentId })] }, + 'm', + ], ], ]); - expect( - await codeOf(provider.renameMailbox(ARA, 'u1', '보관 자료')), - ).toBe(code); + await expect( + provider.updateMailbox(ARA, 'u4', { parentId }), + ).resolves.toMatchObject({ parentId }); } + for (const [parentId, code] of [ + ['e', 'VAL_001'], + ['d', 'VAL_001'], + ['c', 'VAL_001'], + ['b', 'VAL_001'], + ['nope', 'RES_001'], + ] as const) { + const { provider, recorded } = fakeClient([mailboxList()]); + expect( + await codeOf(provider.updateMailbox(ARA, 'u4', { parentId })), + ).toBe(code); + expect(recorded).toHaveLength(1); + } + }); + + it('자기 자신 · 자기 하위(손주까지) 아래로는 VAL_001 — 엔진에 묻기 전에 본다', async () => { + const deep = [ + ...mailboxes, + userMailbox('u5', '손주', { parentId: 'u2' }), + ]; + for (const parentId of ['u1', 'u2', 'u5']) { + const { provider, recorded } = fakeClient([mailboxList(deep)]); + await expect( + provider.updateMailbox(ARA, 'u1', { parentId }), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'mailbox-move-into-itself' }, + }); + expect(recorded).toHaveLength(1); + } + }); + + it('엔진이 상위를 거절하면(invalidProperties parentId) — 다시 읽은 목록에 그 상위가 있으면 고리라 VAL_001, 없으면 그사이 지워져 RES_001', async () => { + const refusal = { + type: 'invalidProperties', + properties: ['parentId'], + }; + const cycle = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: refusal } }, 'u'], + ['Mailbox/get', { list: mailboxes }, 'm'], + ], + ]); + expect( + await codeOf( + cycle.provider.updateMailbox(ARA, 'u1', { parentId: 'u4' }), + ), + ).toBe('VAL_001'); + + const gone = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: refusal } }, 'u'], + [ + 'Mailbox/get', + { list: mailboxes.filter((mailbox) => mailbox.id !== 'u4') }, + 'm', + ], + ], + ]); + expect( + await codeOf( + gone.provider.updateMailbox(ARA, 'u1', { parentId: 'u4' }), + ), + ).toBe('RES_001'); }); }); @@ -7093,6 +7261,240 @@ describe('JmapMailProvider', () => { 'MAIL_001', ); }); + + describe('하위 메일함까지 지우기 (withChildren — 15단계)', () => { + /** u1 › u2 › u5 (u5 는 손주) */ + const tree = [ + ...mailboxes, + userMailbox('u5', '손주', { parentId: 'u2', totalEmails: 1 }), + ]; + const children = { withChildren: true }; + + it('나무(그 메일함 + 하위 모두)의 메일을 한 조회(OR)로 읽어 옮긴다 — 나무 안에만 있으면 휴지통으로, 나무 밖에도 있으면 나무에서만 뺀다 · 깊은 것부터 한 요청으로 지운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch( + [ + only('m1', 'u1'), + only('m2', 'u5'), + { id: 'm3', mailboxIds: { u1: true, u2: true } }, + { id: 'm4', mailboxIds: { u2: true, a: true } }, + ], + 's1', + 4, + ), + [ + [ + 'Email/set', + { updated: { m1: null, m2: null, m3: null, m4: null } }, + 's', + ], + ], + batch([]), + [['Mailbox/set', { destroyed: ['u5', 'u2', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: true, + processed: 4, + movedToTrash: 3, + remaining: 0, + }); + expect(argsOf(recorded[1])).toMatchObject({ + filter: { + operator: 'OR', + conditions: [ + { inMailbox: 'u1' }, + { inMailbox: 'u2' }, + { inMailbox: 'u5' }, + ], + }, + calculateTotal: true, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { 'mailboxIds/u1': null, 'mailboxIds/b': true }, + m2: { 'mailboxIds/u5': null, 'mailboxIds/b': true }, + m3: { + 'mailboxIds/u1': null, + 'mailboxIds/u2': null, + 'mailboxIds/b': true, + }, + m4: { 'mailboxIds/u2': null }, + }, + }); + // 깊은 것부터 — 엔진은 적은 차례대로 지운다 (메일을 지우는 옵션은 쓰지 않는다) + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + destroy: ['u5', 'u2', 'u1'], + onDestroyRemoveEmails: false, + }); + }); + + it('하위가 있는데 withChildren 이 아니면 VAL_001(지금처럼) · 하위에 역할이 있는 메일함이 있으면 VAL_001 — 아무것도 옮기지 않는다', async () => { + const plain = fakeClient([mailboxList(tree)]); + await expect( + plain.provider.deleteMailbox(ARA, 'u1'), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'mailbox-has-children' }, + }); + + const odd = fakeClient([ + mailboxList([ + ...tree, + userMailbox('i1', 'Important', { + role: 'important', + parentId: 'u2', + }), + ]), + ]); + await expect( + odd.provider.deleteMailbox(ARA, 'u1', children), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'role-mailbox-in-tree' }, + }); + expect(odd.recorded).toHaveLength(1); + }); + + it('하위가 없는 메일함에 withChildren 을 줘도 지금 지우기와 같다 — 조건은 inMailbox 하나', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [['Mailbox/set', { destroyed: ['u3'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u3', children), + ).resolves.toMatchObject({ deleted: true }); + expect(argsOf(recorded[1])).toMatchObject({ + filter: { inMailbox: 'u3' }, + }); + expect(argsOf(recorded[2])).toMatchObject({ destroy: ['u3'] }); + }); + + it('지우는 사이 깊은 메일함에 메일이 들어오면(mailboxHasEmail) 지운 하위는 나무에서 빼고 남은 나무의 메일을 옮긴 뒤 다시 지운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [ + [ + 'Mailbox/set', + { + destroyed: ['u5'], + notDestroyed: { + u2: { type: 'mailboxHasEmail' }, + u1: { type: 'mailboxHasChild' }, + }, + }, + 'x', + ], + ], + batch([only('m9', 'u2')]), + [['Email/set', { updated: { m9: null } }, 's']], + batch([]), + [['Mailbox/set', { destroyed: ['u2', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: true, + processed: 1, + movedToTrash: 1, + remaining: 0, + }); + expect(argsOf(recorded[3])).toMatchObject({ + filter: { + operator: 'OR', + conditions: [{ inMailbox: 'u1' }, { inMailbox: 'u2' }], + }, + }); + expect(argsOf(recorded[6])).toMatchObject({ destroy: ['u2', 'u1'] }); + }); + + it('그사이 나무 밖의 하위 메일함이 생기면(mailboxHasChild) 목록을 다시 읽어 그 메일함까지 지운다', async () => { + const grown = [ + ...tree, + userMailbox('u6', '새 하위', { parentId: 'u1' }), + ]; + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [ + [ + 'Mailbox/set', + { + destroyed: ['u5', 'u2'], + notDestroyed: { u1: { type: 'mailboxHasChild' } }, + }, + 'x', + ], + ], + mailboxList( + grown.filter( + (mailbox) => mailbox.id !== 'u2' && mailbox.id !== 'u5', + ), + ), + batch([]), + [['Mailbox/set', { destroyed: ['u6', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toMatchObject({ deleted: true }); + expect(argsOf(recorded[5])).toMatchObject({ destroy: ['u6', 'u1'] }); + }); + + it('나무의 남은 수(remaining)는 OR 조회의 전체 수다 — 다 옮기지 못하면 deleted: false', async () => { + const fake = fakeClient([ + mailboxList(tree), + batch( + ids('m', 500).map((id) => only(id, 'u2')), + 's1', + 700, + ), + [ + [ + 'Email/set', + { + updated: Object.fromEntries( + ids('m', 500).map((id) => [id, null]), + ), + }, + 's', + ], + ], + ]); + advanceClockAfterCall(fake, 3, 21_000); + + await expect( + fake.provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: false, + processed: 500, + movedToTrash: 500, + remaining: 200, + }); + }); + + it('한 요청의 시간은 서비스가 받은 때(startedAt)부터 센다 — 남은 시간이 모자라면 차례를 시작하지 않고 MAIL_001', async () => { + const { provider, recorded } = fakeClient([mailboxList(tree)]); + expect( + await codeOf( + provider.deleteMailbox(ARA, 'u3', { + startedAt: Date.now() - 21_000, + }), + ), + ).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + }); + }); }); describe('전체 읽음 (markMailboxRead)', () => { @@ -8684,6 +9086,757 @@ describe('JmapMailProvider', () => { }); }); + describe('예약 메일 지금 보내기 (releaseScheduledSend — 15단계)', () => { + const SCHEDULED = { $seen: true, '$ara-scheduled': true }; + /** 예약한 보낸 사본 — 받는 사람 둘레에 숨은참조(SEOJUN)가 있다(보낸 사본에는 Bcc 머리글이 남는다) */ + const sentCopy = (keywords: Record = SCHEDULED) => ({ + id: 'm1', + mailboxIds: { e: true }, + keywords, + to: [{ name: '이도윤', email: DOYUN }], + cc: [], + bcc: [{ name: null, email: SEOJUN }], + }); + const queued = { delivered: 'queued', smtpReply: '250 2.1.5 Queued' }; + /** 예약 제출 — 원래 envelope(HOLDUNTIL · 받는 사람 · 숨은참조)과 발신 ID */ + const scheduledSubmission = ( + overrides: Record = {}, + ) => ({ + id: 'sub1', + emailId: 'm1', + identityId: 'b', + undoStatus: 'pending', + sendAt: inFuture(3_600_000), + envelope: { + mailFrom: { + email: ARA, + parameters: { HOLDUNTIL: inFuture(3_600_000) }, + }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + deliveryStatus: { [DOYUN]: queued, [SEOJUN]: queued }, + ...overrides, + }); + /** 한 요청으로 읽는 것 — 메일함 · 그 메일 · 그 메일의 제출 */ + const releaseLookup = ( + submissions: Record[], + email: Record | null = sentCopy(), + ): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Email/get', + { list: email ? [email] : [], notFound: email ? [] : ['m1'] }, + 'g', + ], + [ + 'EmailSubmission/query', + { + ids: submissions.map((submission) => submission.id), + total: submissions.length, + }, + 'rq', + ], + ['EmailSubmission/get', { list: submissions }, 'rg'], + ]; + const canceledOk: JmapInvocation[] = [ + ['EmailSubmission/set', { updated: { sub1: null } }, 'x'], + ]; + const created = (sendAt: string, undoStatus = 'pending') => + [ + [ + 'EmailSubmission/set', + { created: { send: { id: 'sub2', sendAt, undoStatus } } }, + 's', + ], + ['Email/set', { updated: { m1: null } }, 's'], + ] as JmapInvocation[]; + const result = ( + deliveryStatus: Record, + undoStatus = 'pending', + ): JmapInvocation[] => [ + [ + 'EmailSubmission/get', + { list: [{ id: 'sub2', undoStatus, deliveryStatus }] }, + 'd', + ], + ]; + const quiet = (provider: JmapMailProvider) => { + const logger = Reflect.get(provider, 'logger') as { + log: () => void; + warn: () => void; + }; + jest.spyOn(logger, 'log').mockImplementation(() => undefined); + return jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + }; + + it('기다리는 예약 제출을 먼저 취소하고, 같은 메일을 원래 envelope 그대로(숨은참조 포함) 보내기 취소 초만큼 붙잡아 다시 낸다 — 예약 표시를 떼고, 받는 사람별 결과는 발송과 같다', async () => { + const undoAt = inFuture(10_000); + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(undoAt), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + const released = await provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }); + + expect(released).toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: expect.any(String) as string, + }); + expect(recorded).toHaveLength(4); + // 1) 한 요청으로 읽는다 — 엔진이 id 를 해석하지 못하면 RES_001 + expect(recorded[0].options).toMatchObject({ + invalidRequestCode: 'RES_001', + }); + expect(recorded[0].calls.map(([method]) => method)).toEqual([ + 'Mailbox/get', + 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + // 2) 취소가 먼저 — 다시 내기와 다른 요청 + expect(recorded[1].calls).toEqual([ + [ + 'EmailSubmission/set', + { accountId: 'd', update: { sub1: { undoStatus: 'canceled' } } }, + 'x', + ], + ]); + // 3) 같은 메일 · 같은 발신 ID · 원래 받는 사람(숨은참조 SEOJUN 포함) — MAIL FROM 의 붙잡기만 HOLDFOR 로 + expect(recorded[2].calls).toEqual([ + [ + 'EmailSubmission/set', + { + accountId: 'd', + create: { + send: { + identityId: 'b', + emailId: 'm1', + envelope: { + mailFrom: { email: ARA, parameters: { HOLDFOR: '10' } }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }, + }, + onSuccessUpdateEmail: { + '#send': { + 'mailboxIds/d': null, + 'mailboxIds/e': true, + 'keywords/$draft': null, + 'keywords/$ara-sending': null, + 'keywords/$ara-scheduled': null, + }, + }, + }, + 's', + ], + ]); + // 4) 받는 사람별 결과는 따로 읽는다 (발송과 같다) + expect(argsOf(recorded[3])).toMatchObject({ ids: ['sub2'] }); + }); + + it('보내기 취소를 0초로 두었으면 붙잡지 않고 낸다 — pendingSend null, 일부 거절은 rejectedRecipients 로', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(inFuture(0), 'final'), + result( + { + [DOYUN]: queued, + [SEOJUN]: { + delivered: 'no', + smtpReply: '550 5.1.2 Mailbox does not exist.', + }, + }, + 'final', + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ + pendingSend: null, + rejectedRecipients: [{ email: SEOJUN, reason: 'not-found' }], + }); + expect(argsOf(recorded[2])).toMatchObject({ + create: { + send: { + envelope: { mailFrom: { email: ARA, parameters: null } }, + }, + }, + }); + }); + + it('예약이 아닌 메일은 RES_001 — 보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없는 메일 · 보낸 일반 메일 · 없는 메일 (아무것도 바꾸지 않는다)', async () => { + const cases: [ + Record[], + Record | null, + ][] = [ + // 보내기 취소를 기다린다 (예약 표시 없음) + [ + [scheduledSubmission({ sendAt: inFuture(8_000) })], + sentCopy({ $seen: true }), + ], + // 예약을 취소해 임시보관함으로 갔다 + [ + [scheduledSubmission({ undoStatus: 'canceled' })], + sentCopy({ $draft: true, '$ara-canceled': true }), + ], + // 제출이 없다 + [[], sentCopy()], + // 예약이 아니었던 보낸 메일 + [ + [ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + ], + sentCopy({ $seen: true }), + ], + // 없는 메일 + [[], null], + ]; + for (const [submissions, email] of cases) { + const { provider, recorded } = fakeClient([ + releaseLookup(submissions, email), + ]); + quiet(provider); + expect( + await codeOf( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ), + ).toBe('RES_001'); + expect(recorded).toHaveLength(1); + } + }); + + it('이미 보낸 메일(예약 시각이 막 지나 final · 배달 중)은 오류가 아니다 — pendingSend null · releasedAt null, 다시 내지 않는다', async () => { + for (const submission of [ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + // 보낼 시각이 지나 배달 중 (pending 이지만 기다리지 않는다) + scheduledSubmission({ sendAt: '2026-10-29T00:00:00Z' }), + ]) { + const { provider, recorded } = fakeClient([ + releaseLookup([submission]), + ]); + quiet(provider); + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: null, + releasedAt: null, + }); + expect(recorded).toHaveLength(1); + } + }); + + it('취소하려니 엔진이 받지 않으면(cannotUnsend — 그사이 보냄) 다시 읽어 보낸 것으로 답한다 — 다시 내지 않는다', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + [ + [ + 'EmailSubmission/set', + { notUpdated: { sub1: { type: 'cannotUnsend' } } }, + 'x', + ], + ], + releaseLookup([ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + ]), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ pendingSend: null, releasedAt: null }); + expect(recorded).toHaveLength(3); + }); + + it('이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)은 그 새 제출의 지금 상태로 — 다시 내지 않는다', async () => { + const undoAt = inFuture(6_000); + const { provider, recorded } = fakeClient([ + releaseLookup( + [ + scheduledSubmission({ undoStatus: 'canceled' }), + scheduledSubmission({ + id: 'sub2', + undoStatus: 'pending', + sendAt: undoAt, + }), + ], + sentCopy({ $seen: true }), + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: undoAt.replace('Z', '.000Z'), + }); + expect(recorded).toHaveLength(1); + }); + + it('앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시 · 제출은 모두 canceled) 취소를 건너뛰고 다시 낸다', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(inFuture(10_000)), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toMatchObject({ pendingSend: { kind: 'undo' } }); + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[1])).toHaveProperty('create'); + }); + + it('envelope · 발신 ID 를 읽지 못하면 취소하지 않고 MAIL_001 — 취소한 뒤 다시 내지 못하는 메일이 생기지 않게', async () => { + for (const broken of [ + { envelope: null }, + { identityId: undefined }, + { + envelope: { + mailFrom: { email: ARA }, + rcptTo: [{ email: DOYUN }, { email: null }], + }, + }, + { envelope: { mailFrom: { email: ARA }, rcptTo: [] } }, + ]) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission(broken)]), + ]); + quiet(provider); + expect( + await codeOf(provider.releaseScheduledSend(ARA, 'm1', null)), + ).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + } + }); + + it('엔진이 취소를 확인하지 않으면 다시 읽어 — 아직 기다리면 다시 내지 않고 MAIL_001, 취소됐으면 잇는다', async () => { + const silent: JmapInvocation[] = [ + ['EmailSubmission/set', { updated: {} }, 'x'], + ]; + const still = fakeClient([ + releaseLookup([scheduledSubmission()]), + silent, + releaseLookup([scheduledSubmission()]), + ]); + quiet(still.provider); + expect( + await codeOf(still.provider.releaseScheduledSend(ARA, 'm1', null)), + ).toBe('MAIL_001'); + expect(still.recorded).toHaveLength(3); + + const gone = fakeClient([ + releaseLookup([scheduledSubmission()]), + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(inFuture(0), 'final'), + result({ [DOYUN]: queued, [SEOJUN]: queued }, 'final'), + ]); + quiet(gone.provider); + await expect( + gone.provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ pendingSend: null }); + expect(gone.recorded).toHaveLength(5); + }); + + it('엔진이 다시 내기를 분명히 거절하면(SetError · 메서드 오류 · 4xx) 다시 읽지 않고 한 번 더, 그래도 거절이면 임시보관함으로 되돌린 뒤 MAIL_001 (ScheduledSendReturnedToDraftsException)', async () => { + const setError: JmapInvocation[] = [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ]; + const methodError: JmapInvocation[] = [ + ['error', { type: 'serverFail' }, 's'], + ]; + // 엔진이 요청을 실행하지 않았다(4xx — isRequestRefused) + const rejected = new BusinessException('MAIL_001', { + context: { reason: 'http-error', status: 400 }, + }); + for (const [first, second] of [ + [setError, methodError], + [rejected, setError], + ] as const) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + first, + second, + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + const warn = quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-resubmit-refused' }, + }); + // 읽기 · 취소 · 내기 두 번 · 되돌리기 — 거절 사이에 다시 읽지 않는다 + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'Email/set', + ]); + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + update: { + m1: { + mailboxIds: { d: true }, + 'keywords/$draft': true, + 'keywords/$ara-canceled': true, + 'keywords/$ara-scheduled': null, + 'keywords/$ara-sending': null, + }, + }, + }); + expect(warn).toHaveBeenCalled(); + } + }); + + it('거듭 거절해 임시보관함으로 되돌리려는데 그것마저 못 하면 그냥 MAIL_001 — 되돌림 예외가 아니다(메일은 보낸편지함에 그대로)', async () => { + const setError: JmapInvocation[] = [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ]; + const notMoved: JmapInvocation[] = [ + ['Email/set', { notUpdated: { m1: { type: 'serverFail' } } }, 'u'], + ]; + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + setError, + setError, + notMoved, + notMoved, + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'canceled-send-not-returned-to-drafts' }, + }); + expect(recorded).toHaveLength(6); + }); + + it('다시 내기가 결과를 모르게 끊겼는데 다시 읽으니 새 제출이 있으면 그 상태로 답한다 — 임시보관함으로 옮기지 않는다', async () => { + const undoAt = inFuture(9_000); + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + releaseLookup( + [ + scheduledSubmission({ undoStatus: 'canceled' }), + scheduledSubmission({ id: 'sub2', sendAt: undoAt }), + ], + sentCopy({ $seen: true }), + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toMatchObject({ + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + }); + expect(recorded).toHaveLength(4); + }); + + it('다시 내기도 · 다시 읽기도 실패하면 보냈는지 모른다 — 임시보관함으로 옮기지 않고 MAIL_001 (다시 부르면 끊긴 지금 보내기로 잇는다)', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect((failure as BusinessException).code).toBe('MAIL_001'); + expect(recorded).toHaveLength(4); + }); + + it('다시 내기의 결과를 모르고(시간 초과 · 5xx · 해석하지 못한 응답) 다시 읽어도 새 제출이 없으면 — 둘째 제출 없음 · 임시보관함으로 옮기지 않음 · 그냥 MAIL_001', async () => { + const unknowns: (JmapInvocation[] | Error)[] = [ + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + new BusinessException('MAIL_001', { + context: { reason: 'http-error', status: 502 }, + }), + // 응답에 제출 결과가 없다 — 엔진이 실행했는지 모른다 + [['Email/set', { updated: {} }, 'x']], + ]; + for (const unknown of unknowns) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + unknown, + // 다시 읽기 — 예약 표시 그대로 · 예약 제출은 취소됨 · 새 제출 없음 (끊긴 지금 보내기) + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + ]); + const warn = quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', { kind: 'undo', seconds: 10 }) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { + reason: 'release-resubmit-unknown', + reread: 'interrupted', + }, + }); + // 읽기 · 취소 · 내기 한 번 · 다시 읽기 — 둘째 제출도 임시보관함으로 옮기기(Email/set)도 없다 + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'Mailbox/get', + ]); + expect(argsOf(recorded[2])).toHaveProperty('create'); + expect(warn).toHaveBeenCalledTimes(1); + } + }); + + it('결과를 모르게 끝난 뒤 같은 요청을 다시 받으면 이어 낸다 — 예약 취소를 건너뛰고 다시 제출만 (끊긴 지금 보내기)', async () => { + const undoAt = inFuture(10_000); + const { provider, recorded } = fakeClient([ + // 1) 첫 요청 — 취소한 뒤 다시 내기가 시간 초과 · 다시 읽어도 새 제출 없음 + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + // 2) 같은 요청을 다시 — 끊긴 지금 보내기로 이어 낸다 + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(undoAt), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + expect( + await codeOf( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ), + ).toBe('MAIL_001'); + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: expect.any(String) as string, + }); + // 둘째 요청은 취소하지 않고(이미 취소됨) 원래 envelope 그대로 다시 낸다 + expect(recorded.slice(4).map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[5])).toMatchObject({ + create: { + send: { + emailId: 'm1', + envelope: { + mailFrom: { email: ARA, parameters: { HOLDFOR: '10' } }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }, + }, + }); + }); + + it('제출마다 남은 시간을 먼저 본다 — 10초보다 적으면 내지 않고 그냥 MAIL_001 (예약은 취소된 채 — 다시 부르면 이어 낸다)', async () => { + const realNow = Date.now(); + // 시계를 손으로 — 엔진 호출이 n 번 기록된 뒤부터 40초 지난 것으로 (한 요청의 시간 45초 가운데 5초 남음) + const clockAfter = (calls: number, recorded: unknown[]) => + jest + .spyOn(Date, 'now') + .mockImplementation(() => + recorded.length >= calls ? realNow + 40_000 : realNow, + ); + + // 첫 제출 전 — 취소한 뒤 남은 시간이 모자라다 + const beforeFirst = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + ]); + quiet(beforeFirst.provider); + const firstClock = clockAfter(2, beforeFirst.recorded); + try { + const failure = await beforeFirst.provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-budget-exhausted', attempt: 1 }, + }); + expect(beforeFirst.recorded).toHaveLength(2); + } finally { + firstClock.mockRestore(); + } + + // 둘째 제출 전 — 첫 제출을 엔진이 거절하는 사이 시간이 갔다: 둘째는 내지 않고 되돌리지도 않는다 + const beforeSecond = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ], + ]); + quiet(beforeSecond.provider); + const secondClock = clockAfter(3, beforeSecond.recorded); + try { + const failure = await beforeSecond.provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-budget-exhausted', attempt: 2 }, + }); + expect(beforeSecond.recorded).toHaveLength(3); + // 첫 제출은 남은 시간(최대 40초)으로 — 10초 아래로 보낸 적이 없다 + expect(beforeSecond.recorded[2].options).toEqual({ + timeoutMs: JMAP_SEND_TIMEOUT_MS, + }); + } finally { + secondClock.mockRestore(); + } + }); + + it('받는 사람이 모두 받지 않았으면 기다리는 새 제출을 취소하고 임시보관함으로 되돌린다 — MAIL_010 (ScheduledSendReturnedToDraftsException)', async () => { + const rejected = { + delivered: 'no', + smtpReply: '550 5.1.2 Mailbox does not exist.', + }; + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(inFuture(10_000)), + result({ [DOYUN]: rejected, [SEOJUN]: rejected }), + [['EmailSubmission/set', { updated: { sub2: null } }, 'c']], + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', { kind: 'undo', seconds: 10 }) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(ScheduledSendReturnedToDraftsException); + expect((failure as BusinessException).code).toBe('MAIL_010'); + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + update: { sub2: { undoStatus: 'canceled' } }, + }); + expect(argsOf(recorded[5])).toMatchObject({ + update: { m1: { mailboxIds: { d: true } } }, + }); + }); + }); + describe('취소해 되돌린 발송을 이어 쓴다 (getDraft)', () => { const SIGNATURE_CID = 'sig-0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f@arasystem.test'; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index cc10059..ad634c9 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -12,6 +12,7 @@ import { MAX_ENVELOPE_RESUBMITS, } from '@/modules/mail/mail.constants'; import { + ScheduledSendReturnedToDraftsException, SendNotStartedException, type MailProvider, } from '@/modules/mail/mail.provider'; @@ -34,9 +35,12 @@ import type { MailAttachment, MailBlockAdded, MailBlockList, + MailBlockRunRequest, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailFilterRunContext, MailFilterRunOutcome, @@ -64,6 +68,7 @@ import type { PasswordChangeOutcome, RecipientFields, RejectedRecipient, + ReleasedScheduledSend, SavedDraft, SendHold, SentMessage, @@ -72,6 +77,11 @@ import type { TwoFactorLoginResult, UploadedFile, } from '@/modules/mail/mail.types'; +import { + deepestFirstIds, + descendantsOf, + isSelfOrDescendant, +} from '@/modules/mail/mailbox-tree'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { @@ -114,6 +124,7 @@ import { firstSetError, isState, warnRefusals, + type MethodResult, } from './jmap-results'; import { describeFailure, @@ -593,9 +604,18 @@ function assertUserMailbox(mailbox: JmapMailbox, action: string): void { } } +/** 엔진이 상위(parentId)를 받지 않은 거절인가 — 없는 상위 · 고리(자기 아래로 옮기기 — 2026-11-09 확인) */ +function isParentRefusal(error: JmapError): boolean { + return ( + error.type === 'invalidProperties' && + (error.properties ?? []).includes('parentId') + ); +} + /** - * 메일함 만들기 · 이름 바꾸기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함)은 MAIL_009, - * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts) + * 메일함 만들기 · 이름 바꾸기 · 옮기기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함 — 대소문자 무시)은 MAIL_009, + * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts). + * 옮기기의 고리(상위는 있는데 parentId 거절)는 부르는 쪽(updateMailbox)이 먼저 VAL_001 로 가른다 */ function mailboxSetFailure( error: JmapError, @@ -606,10 +626,7 @@ function mailboxSetFailure( context: { reason: 'mailbox-name-taken', method }, }); } - if ( - error.type === 'invalidProperties' && - (error.properties ?? []).includes('parentId') - ) { + if (isParentRefusal(error)) { return new BusinessException('RES_001', { context: { reason: 'mailbox-parent-gone', method }, }); @@ -617,6 +634,80 @@ function mailboxSetFailure( return jmapFailure(error, { method }); } +/** + * 메일함 가운데 하나에라도 든 메일 (Email/query 의 조건) — 하나면 지금처럼 `inMailbox` 하나, 여럿이면(하위까지 지우기의 나무 — 15단계) OR. + * 엔진은 OR 조건의 전체 수를 겹치지 않게 센다(2026-11-09 확인) + */ +function inAnyMailbox(mailboxIds: readonly string[]): JmapEmailFilter { + return mailboxIds.length === 1 + ? { inMailbox: mailboxIds[0] } + : { + operator: 'OR', + conditions: mailboxIds.map((inMailbox) => ({ inMailbox })), + }; +} + +/** 지울 나무 (15단계) — 그 메일함과(하위까지 지우기면) 하위 모두 */ +interface DeletionTree { + /** 나무의 메일함 — 그 메일함이 맨 앞 */ + readonly ids: readonly string[]; + /** 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝 (mailbox-tree.ts) */ + readonly order: readonly string[]; + /** 처음 남은 수의 어림 — 나무의 메일함마다 든 수의 합 (차례를 하나도 읽지 못하고 끝날 때만 쓴다) */ + readonly initialTotal: number; +} + +/** + * 지울 나무를 정한다 — 하위 메일함이 있는데 하위까지 지우기가 아니면 VAL_001(옛 화면이 모르고 하위를 지우지 않게), + * 하위에 역할이 있는 메일함이 있으면(다른 프로그램이 만든 나무) VAL_001 + */ +function deletionTree( + mailboxes: readonly JmapMailbox[], + root: JmapMailbox, + withChildren: boolean, +): DeletionTree { + const descendants = descendantsOf(mailboxes, root.id).map( + ({ mailbox }) => mailbox, + ); + if (descendants.length > 0 && !withChildren) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + const roleMailbox = descendants.find( + (mailbox) => engineRole(mailbox) !== null, + ); + if (roleMailbox) { + throw new BusinessException('VAL_001', { + context: { + reason: 'role-mailbox-in-tree', + role: engineRole(roleMailbox), + }, + }); + } + return { + ids: [root.id, ...descendants.map((mailbox) => mailbox.id)], + order: deepestFirstIds(mailboxes, root.id), + initialTotal: [root, ...descendants].reduce( + (sum, mailbox) => sum + (mailbox.totalEmails ?? 0), + 0, + ), + }; +} + +/** 지운 메일함을 나무에서 뺀다 — 나머지를 지우는 사이 메일이 들어와 다음 차례에 다시 읽을 때 */ +function withoutDestroyed( + tree: DeletionTree, + destroyed: ReadonlySet, +): DeletionTree { + if (destroyed.size === 0) return tree; + return { + ids: tree.ids.filter((id) => !destroyed.has(id)), + order: tree.order.filter((id) => !destroyed.has(id)), + initialTotal: tree.initialTotal, + }; +} + /** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */ function queryTotal(result: JmapQueryResult, received: number): number { if (typeof result.total === 'number' && result.total >= received) { @@ -835,13 +926,15 @@ interface CreatedSubmission { /** * 제출 하나(EmailSubmission/set create, 호출 id 's') — 늘 envelope 을 직접 준다. 성공하면 초안 → 보낸편지함으로 옮기고 발송 중 표시를 뗀다 - * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때) + * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때 · 예약 메일 지금 보내기 — 15단계) + * @param extraPatch 성공하면 메일에 함께 할 것 (지금 보내기는 예약 표시를 뗀다) */ function submitCall( accountId: string, plan: SubmissionPlan, emailId: string, rcptTo: readonly string[], + extraPatch: Readonly> = {}, ): JmapInvocation { return [ 'EmailSubmission/set', @@ -860,6 +953,7 @@ function submitCall( [`mailboxIds/${plan.sentId}`]: true, 'keywords/$draft': null, [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, + ...extraPatch, }, }, }, @@ -867,6 +961,147 @@ function submitCall( ]; } +/** + * 예약 메일 지금 보내기(15단계) 한 요청이 엔진을 기다리는 시간 — 엔진 호출마다 남은 시간만큼만 기다린다. + * 정리(새 제출 취소 · 임시보관함으로 되돌리기 두 번 · 결과를 모를 때 다시 읽기)는 남은 시간이 모자라도 RELEASE_CLEANUP_MIN_MS 씩 더 쓴다 — + * 그래서 한 요청은 54초 남짓까지 걸린다(45 + 3 × 3). 앞단 nginx 의 기본 시간 제한(60초) · 클라이언트의 발송 한도(60초) 안이다 + */ +const RELEASE_BUDGET_MS = 45_000; +/** 정리(임시보관함으로 되돌리기 · 새 제출 취소 · 결과를 모를 때 다시 읽기)에 남은 시간이 모자라도 쓰는 최소 시간 */ +const RELEASE_CLEANUP_MIN_MS = 3_000; +/** 다시 제출하는 횟수 — 엔진이 분명히 거절했을 때만 한 번 더 (결과를 모르면 다시 내지 않는다 — 계약 2절 "보내기 취소 · 예약 발송") */ +const RELEASE_SUBMIT_ATTEMPTS = 2; +/** 지금 보내기가 읽는 그 메일의 제출 속성 — 상태 · 보낼 시각 · 다시 제출할 envelope · 발신 ID · 받는 사람별 결과 */ +const RELEASE_SUBMISSION_PROPERTIES = [ + 'id', + 'emailId', + 'identityId', + 'undoStatus', + 'sendAt', + 'envelope', + 'deliveryStatus', +]; +/** 지금 보내기가 읽는 그 메일의 속성 — 메일함 · 표시(예약 · 취소한 발송) · 받는 사람(받는 사람별 결과를 맞춘다) */ +const RELEASE_EMAIL_PROPERTIES = [ + 'id', + 'mailboxIds', + 'keywords', + 'to', + 'cc', + 'bcc', +]; + +/** 지금 보내기가 본 그 메일의 지금 상태 (releaseStateOf) */ +type ReleaseState = + /** 예약 표시가 있고 기다리는 제출이 있다 — 취소하고 다시 제출한다 */ + | { + readonly kind: 'scheduled'; + readonly held: readonly JmapEmailSubmission[]; + } + /** 예약 표시가 있고 취소한 발송 표시는 없는데 제출이 모두 취소됐다 — 앞 요청이 취소한 뒤 끊겼다: 다시 제출만 한다 */ + | { readonly kind: 'interrupted'; readonly source: JmapEmailSubmission } + /** 취소한 제출과 그 뒤 제출이 함께 있다 — 지금 보내기를 이미 했다(다시 누름 · 응답을 받지 못한 재시도) */ + | { readonly kind: 'released'; readonly current: JmapEmailSubmission } + /** 예약 표시가 있고 끝나지 않은 제출이 있는데 기다리지 않는다 — 예약 시각이 지나 보냈다(배달 중 포함) */ + | { readonly kind: 'sent'; readonly live: readonly JmapEmailSubmission[] } + /** 예약이 아니다 — 보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음 */ + | { readonly kind: 'not-scheduled'; readonly why: string }; + +/** 보낼 시각이 가장 늦은 제출 (같으면 뒤의 것) */ +function latestSubmission( + submissions: readonly JmapEmailSubmission[], +): JmapEmailSubmission { + return submissions.reduce((latest, submission) => + (Date.parse(submission.sendAt ?? '') || 0) >= + (Date.parse(latest.sendAt ?? '') || 0) + ? submission + : latest, + ); +} + +/** + * 지금 보내기가 본 그 메일의 상태 — 순수 함수 (계약 2절 "보내기 취소 · 예약 발송" — 15단계). 차례가 중요하다: + * 취소한 발송 표시(임시보관함으로 되돌림) → 기다리는 예약 → 이미 지금 보냄 → 앞 요청이 끊김 → 이미 보냄 → 예약이 아님. + * 기다리는 제출은 pending 이고 보낼 시각이 아직인 것(isHeld)이다 — 보낼 시각이 지나 배달 중인 제출은 보낸 것으로 본다 + */ +export function releaseStateOf( + email: Pick, + submissions: readonly JmapEmailSubmission[], + nowMs: number, +): ReleaseState { + if (email.keywords?.[CANCELED_SEND_KEYWORD] === true) { + return { kind: 'not-scheduled', why: 'returned-to-drafts' }; + } + const scheduled = email.keywords?.[SCHEDULED_SEND_KEYWORD] === true; + const canceled = submissions.filter( + (submission) => submission.undoStatus === UNDO_CANCELED, + ); + const live = submissions.filter( + (submission) => submission.undoStatus !== UNDO_CANCELED, + ); + const held = live.filter((submission) => isHeld(submission, nowMs)); + if (scheduled && held.length > 0) return { kind: 'scheduled', held }; + if (canceled.length > 0 && live.length > 0) { + return { + kind: 'released', + current: + held.length > 0 ? latestSubmission(held) : latestSubmission(live), + }; + } + if (scheduled && canceled.length > 0) { + return { kind: 'interrupted', source: latestSubmission(canceled) }; + } + if (scheduled && live.length > 0) return { kind: 'sent', live }; + return { + kind: 'not-scheduled', + why: held.length > 0 ? 'undo-pending' : 'no-scheduled-submission', + }; +} + +/** + * 다시 제출할 envelope — 그 예약 제출의 보내는 사람 · 받는 사람 그대로(받는 사람을 머리글에서 다시 만들지 않는다 — 숨은참조 · envelope 에서 뺀 주소가 있다), + * MAIL FROM 의 붙잡기 매개변수만 바꾼다(hold — 보내기 취소 설정, 없으면 바로). 하나라도 읽지 못하면 null (다시 제출하지 않는다 — 닫힌 쪽) + */ +export function releaseEnvelopeOf(source: JmapEmailSubmission): { + readonly identityId: string; + readonly from: string; + readonly rcptTo: string[]; +} | null { + const identityId = source.identityId; + const from = source.envelope?.mailFrom?.email; + const recipients = source.envelope?.rcptTo; + if (!identityId || typeof from !== 'string' || from.trim() === '') { + return null; + } + if (!Array.isArray(recipients) || recipients.length === 0) return null; + const rcptTo: string[] = []; + for (const recipient of recipients) { + const address = recipient?.email; + if (typeof address !== 'string' || address.trim() === '') return null; + rcptTo.push(address); + } + return { identityId, from, rcptTo }; +} + +/** 지금 보내기가 한 요청으로 읽은 것 (readReleaseSnapshot) */ +interface ReleaseSnapshot { + readonly email: JmapEmail; + readonly submissions: JmapEmailSubmission[]; + readonly draftsId: string; + readonly sentId: string; +} + +/** 보낸 사본의 받는 사람 · 참조 · 숨은참조 — 받는 사람별 결과를 맞춘다(보낸 사본에는 숨은참조 머리글이 남는다 — envelope.ts 머리말) */ +function recipientsOf( + email: Pick, +): RecipientFields { + return { + to: toAddresses(email.to), + cc: toAddresses(email.cc), + bcc: toAddresses(email.bcc), + }; +} + /** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */ export function buildThreadHeaders( original: Pick | undefined, @@ -1029,67 +1264,123 @@ export class JmapMailProvider implements MailProvider { } /** - * 메일함 이름 바꾸기 — 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001, 같은 이름이 있으면 MAIL_009. - * 지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게) + * 메일함 이름 바꾸기 · 옮기기 (15단계 — 계약 2절 "메일함 관리") — 보낸 칸(이름 · 상위)만 바꾼다. 엔진은 상위를 바꾸면 하위와 안의 메일을 함께 옮긴다. + * - 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001 + * - 새 상위: 없으면 RES_001, 만들기와 같은 자리(받은편지함 · 보관함 · 역할 없는 메일함)가 아니면 VAL_001, 자기 자신 · 자기 하위면 VAL_001 — 엔진에 묻기 전에 본다 + * (엔진도 고리를 invalidProperties(parentId)로 막는다 — 2026-11-09 확인. 그때 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 VAL_001, 그사이 없어졌으면 RES_001) + * - 새 자리에 같은 이름이 있으면 엔진이 alreadyExists 로 거절한다(대소문자 무시 — 2026-11-09 확인) → MAIL_009 + * - 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게). 깊이 한도는 두지 않는다(만들기와 같다) */ - async renameMailbox( + async updateMailbox( user: string, id: string, - name: string, + change: MailboxChange, ): Promise { - const mailbox = findMailbox(await this.loadMailboxes(user), id); - assertUserMailbox(mailbox, 'rename'); - if (mailbox.name === name) return toMailbox(mailbox); + const mailboxes = await this.loadMailboxes(user); + const mailbox = findMailbox(mailboxes, id); + assertUserMailbox(mailbox, 'update'); + + const { name, parentId } = change; + if (parentId !== undefined && parentId !== null) { + const role = engineRole(findMailbox(mailboxes, parentId)); + if (role !== null && !PARENT_ALLOWED_ROLES.has(role)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-parent-not-allowed', parentRole: role }, + }); + } + if (isSelfOrDescendant(mailboxes, mailbox.id, parentId)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-move-into-itself' }, + }); + } + } + + const update: Record = {}; + if (name !== undefined && name !== mailbox.name) update.name = name; + if (parentId !== undefined && parentId !== (mailbox.parentId ?? null)) { + update.parentId = parentId; + } + if (Object.keys(update).length === 0) return toMailbox(mailbox); + const moving = 'parentId' in update; const responses = await this.jmap.call(user, (accountId) => [ - ['Mailbox/set', { accountId, update: { [mailbox.id]: { name } } }, 'u'], + ['Mailbox/set', { accountId, update: { [mailbox.id]: update } }, 'u'], [ 'Mailbox/get', - { accountId, ids: [mailbox.id], properties: MAILBOX_PROPERTIES }, + { + accountId, + // 옮길 때는 목록 전체 — 엔진이 상위를 거절하면 그 상위가 아직 있는지 같은 순간의 목록으로 가른다 + ids: moving ? null : [mailbox.id], + properties: MAILBOX_PROPERTIES, + }, 'm', ], ]); const set = expectResult(responses, 'u', 'Mailbox/set'); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); const failure = firstSetError(set.notUpdated); - if (failure) throw mailboxSetFailure(failure, 'Mailbox/set'); + if (failure) { + if ( + moving && + typeof parentId === 'string' && + isParentRefusal(failure) && + list.some((candidate) => candidate.id === parentId) + ) { + // 상위는 있는데 엔진이 거절했다 — 그사이 그 상위가 이 메일함의 하위가 됐다(고리) + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-move-refused', method: 'Mailbox/set' }, + }); + } + throw mailboxSetFailure(failure, 'Mailbox/set'); + } - const [renamed] = - expectResult>(responses, 'm', 'Mailbox/get') - .list ?? []; - if (!renamed) { + const updated = list.find((candidate) => candidate.id === mailbox.id); + if (!updated) { throw new BusinessException('RES_001', { context: { reason: 'mailbox-gone' }, }); } - return toMailbox(renamed); + if (moving) { + // 옮겼을 때만 — 메일함 id 만 남긴다 (이름 없이 — 사용자가 정한 글이다). 지금 자리 그대로면 위에서 엔진에 묻지 않고 돌려줬다 + this.logger.log( + `${traceTag()} 메일함을 옮겼습니다: user=${maskEmail(user)}, 메일함=${mailbox.id}, 새 상위=${parentId ?? '맨 위'}${'name' in update ? ', 이름도 바꿈' : ''}`, + ); + } + return toMailbox(updated); } /** * 메일함 지우기 (계약 2절 "메일함 관리") — 메일을 영구 삭제하지 않는다. 한 요청의 시간 · 멈춘 차례는 BulkRun. - * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001 - * 2) 차례마다 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 — - * 이 메일함에만 있으면 휴지통으로(이 메일함에서 빼고 휴지통에 넣는다), 다른 메일함에도 있으면 이 메일함에서만 뺀다 - * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). - * 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다 - * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). + * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001. + * 하위까지 지우기(withChildren — 15단계)면 그 메일함과 하위 모두(나무)를 같은 규칙으로 다룬다 — 하위에 역할이 있는 메일함이 있으면 VAL_001 + * 2) 차례마다 나무의 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 — + * 나무 안에만 있으면 휴지통으로(나무의 메일함에서 빼고 휴지통에 넣는다), 나무 밖의 메일함에도 있으면 나무의 메일함에서만 뺀다 + * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). 나무는 깊은 것부터 한 요청에 적는다 — + * 엔진은 적은 차례대로 지운다(2026-11-09 확인). 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다. + * 그사이 하위 메일함이 생기면(mailboxHasChild) 하위까지 지울 때는 나무를 다시 읽어 잇고, 아니면 VAL_001 + * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 나무에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다). * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고(남은 수에 든다), * 옮긴 것이 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게 — 클라이언트의 되풀이도 끝난다). 차례마다 제출을 읽지 못하면 MAIL_001 */ - async deleteMailbox(user: string, id: string): Promise { - const run = new BulkRun(); + async deleteMailbox( + user: string, + id: string, + options: MailboxDeleteOptions = {}, + ): Promise { + const run = new BulkRun(options.startedAt); + const withChildren = options.withChildren === true; const mailboxes = await this.loadMailboxes(user, run.callOptions()); const mailbox = findMailbox(mailboxes, id); assertUserMailbox(mailbox, 'delete'); - if (mailboxes.some((candidate) => candidate.parentId === mailbox.id)) { - throw new BusinessException('VAL_001', { - context: { reason: 'mailbox-has-children' }, - }); - } + let tree = deletionTree(mailboxes, mailbox, withChildren); const trashId = roleMailboxId(mailboxes, 'trash'); - const target = `mailboxIds/${mailbox.id}`; let movedToTrash = 0; - let remaining = mailbox.totalEmails ?? 0; + let remaining = tree.initialTotal; const result = (deleted: boolean): MailboxDeletion => ({ deleted, processed: run.processed, @@ -1101,16 +1392,33 @@ export class JmapMailProvider implements MailProvider { while (run.canStartRound()) { const batch = await this.readMailboxBatch( user, - mailbox.id, + tree.ids, run.callOptions(), ); remaining = batch.total; if (batch.emails.length === 0) { - if ( - await this.destroyEmptyMailbox(user, mailbox.id, run.callOptions()) - ) { - return result(true); + const destroyed = await this.destroyEmptyMailboxes( + user, + tree.order, + run.callOptions(), + ); + if (destroyed.outcome === 'destroyed') return result(true); + if (destroyed.outcome === 'has-child') { + if (!withChildren) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + // 그사이 하위 메일함이 생겼다 — 나무를 다시 읽어 그 메일함까지 (그사이 그 메일함이 없어졌으면 지운 것으로) + const fresh = await this.loadMailboxes(user, run.callOptions()); + const current = fresh.find((candidate) => candidate.id === id); + if (!current) return result(true); + tree = deletionTree(fresh, current, true); + if (run.stalled('mailbox-has-child')) break; + continue; } + // 지운 하위는 나무에서 뺀다 — 다음 차례가 없는 메일함을 묻지 않게 + tree = withoutDestroyed(tree, destroyed.destroyed); // 비운 사이 메일이 들어왔다 — 다시 옮긴다 (읽은 목록이 비어 있는데도 이어서 거절되면 엔진 이상 — 멈춘다) if (run.stalled('mailbox-has-email')) break; continue; @@ -1133,17 +1441,26 @@ export class JmapMailProvider implements MailProvider { batch.emails.map((email) => email.id), this.heldLookup(user, run.callOptions()), ); + const inTree = new Set(tree.ids); const toTrash: string[] = []; - const detach: string[] = []; + const update: Record> = {}; for (const email of batch.emails) { if (held.has(email.id)) continue; - if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { + const mailboxIds = inMailboxes(email); + // 나무의 메일함에서 뺀다 — 나무 안에만 있었으면 휴지통에 넣는다 + const patch: Record = Object.fromEntries( + mailboxIds + .filter((mailboxId) => inTree.has(mailboxId)) + .map((mailboxId) => [`mailboxIds/${mailboxId}`, null]), + ); + if (mailboxIds.every((mailboxId) => inTree.has(mailboxId))) { + patch[`mailboxIds/${trashId}`] = true; toTrash.push(email.id); - } else { - detach.push(email.id); } + update[email.id] = patch; } - if (toTrash.length === 0 && detach.length === 0) { + const patched = Object.keys(update); + if (patched.length === 0) { // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 옮긴 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게) this.warnHeldSkipped(user, held.size, '지우는 메일함에서 옮기지'); if (run.processed > 0) break; @@ -1152,19 +1469,7 @@ export class JmapMailProvider implements MailProvider { const set = await this.conditionalEmailSet( user, batch.state, - { - update: { - ...Object.fromEntries( - toTrash.map((emailId) => [ - emailId, - { [target]: null, [`mailboxIds/${trashId}`]: true }, - ]), - ), - ...Object.fromEntries( - detach.map((emailId) => [emailId, { [target]: null }]), - ), - }, - }, + { update }, run.callOptions(), ); if (set === null) { @@ -1174,8 +1479,9 @@ export class JmapMailProvider implements MailProvider { const updated = new Set(Object.keys(set.updated ?? {})); const moved = toTrash.filter((emailId) => updated.has(emailId)).length; - const removed = - moved + detach.filter((emailId) => updated.has(emailId)).length; + const removed = patched.filter((emailId) => + updated.has(emailId), + ).length; this.warnRefusals(user, set.notUpdated, '지우는 메일함에서 옮기지'); movedToTrash += moved; remaining = Math.max(0, batch.total - removed); @@ -1309,7 +1615,7 @@ export class JmapMailProvider implements MailProvider { while (run.canStartRound()) { const batch = await this.readMailboxBatch( user, - mailbox.id, + [mailbox.id], run.callOptions(), ); if (batch.emails.length === 0) { @@ -1440,6 +1746,15 @@ export class JmapMailProvider implements MailProvider { return this.filterRuns.run(user, request, context); } + /** 차단할 때 받은편지함 정리 (15단계) — 필터 적용과 같은 틀(JmapFilterRunner.runBlock — 계약 2절 "보낸 사람 차단") */ + runMailBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise { + return this.filterRuns.runBlock(user, request, context); + } + async getUsage(user: string): Promise { const responses = await this.jmap.call( user, @@ -3059,6 +3374,7 @@ export class JmapMailProvider implements MailProvider { user: string, id: string, draftsId: string, + options: () => JmapCallOptions | undefined = () => undefined, ): Promise { const patch = { mailboxIds: { [draftsId]: true }, @@ -3070,9 +3386,13 @@ export class JmapMailProvider implements MailProvider { 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 responses = await this.jmap.call( + user, + (accountId) => [ + ['Email/set', { accountId, update: { [id]: patch } }, 'u'], + ], + options(), + ); const set = expectResult(responses, 'u', 'Email/set'); const failure = firstSetError(set.notUpdated); if (!failure) return; @@ -3089,6 +3409,476 @@ export class JmapMailProvider implements MailProvider { }); } + /** + * 예약 메일 지금 보내기 (계약 2절 "보내기 취소 · 예약 발송" — 15단계). 엔진 사실(2026-11-09 개발 엔진 — phase15 E4): 기다리는 예약 제출을 취소하고 + * 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 — 취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 cannotUnsend). + * 1) 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽어 상태를 가른다(releaseStateOf) + * 2) scheduled — 다시 제출할 envelope 을 먼저 정하고(못 읽으면 아무것도 바꾸지 않고 MAIL_001) 기다리는 예약 제출을 취소한다(cancelHeld). + * 엔진이 받지 않으면(cannotUnsend — 그사이 보냄) 다시 읽어 보냈으면 pendingSend null, 취소를 확인하지 못하면 다시 읽어 취소됐을 때만 잇는다 + * 3) interrupted(앞 요청이 취소한 뒤 끊김) · 2) 다음 — 같은 메일을 그 envelope 그대로 다시 제출한다(resubmitReleased — 성공하면 같은 요청에서 예약 표시를 뗀다). + * 엔진이 거듭 거절하면 임시보관함으로 되돌리고, 결과를 모르거나 남은 시간이 모자라면 그대로 두고 MAIL_001 (다시 부르면 3) 으로 이어 낸다) + * 4) 받는 사람별 결과를 발송과 같은 규칙으로 읽는다 — 모두 받지 않았으면 기다리는 새 제출을 취소하고 임시보관함으로 되돌린다(MAIL_010 · MAIL_011) + * ScheduledSendReturnedToDraftsException 은 임시보관함으로 **되돌린 뒤에만** 던진다 — 되돌리지 못하면 그냥 MAIL_001(메일은 보낸편지함에 그대로) + * released · sent 는 지금 상태로 답한다(다시 제출하지 않는다). 로그는 메일 id · 보낼 시각만 (받는 사람 · 제목 없음 — 9단계와 같다) + */ + async releaseScheduledSend( + user: string, + id: string, + hold: SendHold | null, + ): Promise { + const startedAt = Date.now(); + const left = () => RELEASE_BUDGET_MS - (Date.now() - startedAt); + /** 엔진 호출의 제한 시간 — 기본과 남은 시간 가운데 짧은 쪽, 정리는 그래도 RELEASE_CLEANUP_MIN_MS */ + const callOptions = (floorMs = 1): JmapCallOptions => ({ + timeoutMs: Math.max(floorMs, Math.min(JMAP_REQUEST_TIMEOUT_MS, left())), + }); + + const snapshot = await this.readReleaseSnapshot(user, id, callOptions()); + const state = releaseStateOf( + snapshot.email, + snapshot.submissions, + startedAt, + ); + if (state.kind === 'not-scheduled') { + throw new BusinessException('RES_001', { + context: { reason: 'not-a-scheduled-send', state: state.why }, + }); + } + if (state.kind === 'released' || state.kind === 'sent') { + return this.releasedNow(user, id, snapshot, state, startedAt); + } + + // envelope 을 먼저 정한다 — 읽지 못하면 취소하지 않는다(취소한 뒤 다시 내지 못하는 메일이 생기지 않게) + const source = + state.kind === 'scheduled' ? latestSubmission(state.held) : state.source; + const envelope = releaseEnvelopeOf(source); + if (envelope === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'scheduled-envelope-unknown', state: state.kind }, + }); + } + if (state.kind === 'scheduled') { + const canceled = await this.cancelHeld( + user, + id, + state.held, + callOptions, + startedAt, + ); + if (canceled !== null) return canceled; + } else { + this.logger.warn( + `[MAIL_001]${traceTag()} 앞서 예약을 취소하고 끊긴 지금 보내기를 잇습니다 — 다시 제출합니다: user=${maskEmail(user)}, id=${id}`, + ); + } + + const plan: SubmissionPlan = { + identityId: envelope.identityId, + from: envelope.from, + parameters: hold ? holdParameters(hold) : null, + draftsId: snapshot.draftsId, + sentId: snapshot.sentId, + }; + const submitted = await this.resubmitReleased( + user, + id, + plan, + envelope.rcptTo, + { callOptions, left, startedAt, draftsId: snapshot.draftsId }, + ); + if ('released' in submitted) return submitted.released; + + // 받는 사람별 결과 — 발송과 같은 규칙 (계약 2절 "받는 사람별 결과") + const recipients = recipientsOf(snapshot.email); + const result = await this.readSendResult( + user, + recipients, + submitted.send.id, + Math.min(SEND_RESULT_TIMEOUT_MS, left() - RELEASE_CLEANUP_MIN_MS), + envelope.rcptTo, + ); + const { delivery } = result; + if (delivery.isEveryoneRejected) { + const code = everyoneRejectedCode(delivery.rejected); + this.logger.warn( + `[${code}]${traceTag()} 지금 보낸 예약 메일을 받는 사람이 모두 받지 않아 임시보관함으로 되돌립니다: user=${maskEmail(user)}, id=${id}, ${describeRejections(delivery.rejected)}`, + ); + // 새 제출이 아직 기다리면 먼저 취소한다 — 풀리는 시각에 다시 보내지 않게 (못 해도 되돌린다: 받을 사람이 없다) + if (result.undoStatus === UNDO_PENDING && submitted.send.id) { + await this.cancelQuietly(user, submitted.send.id, () => + callOptions(RELEASE_CLEANUP_MIN_MS), + ); + } + await this.returnToDrafts(user, id, snapshot.draftsId, () => + callOptions(RELEASE_CLEANUP_MIN_MS), + ); + throw new ScheduledSendReturnedToDraftsException(code, { + context: { + reason: 'release-all-recipients-rejected', + rejected: delivery.rejected.length, + }, + }); + } + if (delivery.rejected.length > 0) { + this.logger.log( + `${traceTag()} 지금 보낸 예약 메일을 엔진이 받는 사람 일부에게 받지 않았습니다 — 나머지에게는 보냈습니다: user=${maskEmail(user)}, id=${id}, ${describeRejections(delivery.rejected)}`, + ); + } + const pendingSend = hold + ? submittedPendingSend(hold, submitted.send, startedAt) + : null; + this.logger.log( + `${traceTag()} 예약한 메일을 지금 보냈습니다: user=${maskEmail(user)}, id=${id}, 예약했던 시각=${toUtcIso(source.sendAt) ?? 'unknown'}, ${pendingSend ? `보내기 취소 대기 — 보낼 시각=${pendingSend.sendAt}` : '바로 보냄'}`, + ); + return { + id, + rejectedRecipients: toRejectedRecipients(delivery.rejected), + pendingSend, + releasedAt: new Date(startedAt).toISOString(), + }; + } + + /** 지금 보내기가 읽는 것 — 메일함(임시보관함 · 보낸편지함) · 그 메일 · 그 메일의 제출 (한 요청 — 엔진이 그 id 를 해석하지 못하면 RES_001) */ + private async readReleaseSnapshot( + user: string, + id: string, + options: JmapCallOptions, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + [ + 'Email/get', + { accountId, ids: [id], properties: RELEASE_EMAIL_PROPERTIES }, + 'g', + ], + [ + 'EmailSubmission/query', + { accountId, filter: { emailIds: [id] } }, + 'rq', + ], + [ + 'EmailSubmission/get', + { + accountId, + '#ids': { + resultOf: 'rq', + name: 'EmailSubmission/query', + path: '/ids', + }, + properties: RELEASE_SUBMISSION_PROPERTIES, + }, + 'rg', + ], + ], + { ...options, invalidRequestCode: 'RES_001' }, + ); + const draftsId = this.requireDraftsMailbox(responses); + const { list: mailboxes = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const sentId = roleMailboxId(mailboxes, 'sent'); + if (!sentId) { + throw new BusinessException('MAIL_001', { + context: { reason: 'sent-mailbox-missing' }, + }); + } + const email = this.requireEmail(responses, 'g'); + // 첫 메서드(제출 찾기)의 오류부터 본다 — 막혔으면 뒤 메서드는 참조를 풀지 못해 invalidResultReference 로 끝난다 (listScheduledMessages 와 같다) + expectResult(responses, 'rq', 'EmailSubmission/query'); + const { list: submissions = [] } = expectResult< + JmapGetResult + >(responses, 'rg', 'EmailSubmission/get'); + return { email, submissions, draftsId, sentId }; + } + + /** + * 이미 지금 보내기를 했거나(released) 예약 시각에 보낸(sent) 메일 — 다시 제출하지 않고 지금 상태로 답한다. + * released 는 새 제출이 아직 기다리면 보내기 취소를 기다리는 발송(pendingSend undo), 받는 사람별 결과는 그 제출로 맞춘다 + */ + private releasedNow( + user: string, + id: string, + snapshot: Pick, + state: Extract, + nowMs: number, + ): ReleasedScheduledSend { + const submissions = + state.kind === 'released' ? [state.current] : state.live; + const delivery = readDeliveryResult( + recipientsOf(snapshot.email), + submissions, + submittedEnvelope(submissions), + ); + const sendAt = + state.kind === 'released' && isHeld(state.current, nowMs) + ? toUtcIso(state.current.sendAt) + : null; + this.logger.log( + `${traceTag()} ${state.kind === 'released' ? '이미 지금 보내기를 한 예약 메일입니다 — 그 발송의 지금 상태로 답합니다' : '예약 시각이 지나 이미 보낸 메일입니다 — 보낸 것으로 답합니다'}: user=${maskEmail(user)}, id=${id}`, + ); + return { + id, + rejectedRecipients: toRejectedRecipients(delivery.rejected), + pendingSend: sendAt === null ? null : { kind: 'undo', sendAt }, + releasedAt: + state.kind === 'released' + ? (toUtcIso(state.current.sendAt) ?? new Date(nowMs).toISOString()) + : null, + }; + } + + /** + * 기다리는 예약 제출을 취소한다 — 엔진이 취소했다고 답한 것만 취소된 것이다(updated 에 그 제출 — cancelPendingSend 와 같다). + * @returns null 이면 모두 취소했다(다시 제출한다). 아니면 그대로 답할 결과 — 엔진이 받지 않았거나 확인하지 못해 다시 읽어 보니 보냈다 · 이미 지금 보냈다 + * @throws 다시 읽어도 아직 기다리거나(취소되지 않음) 그 밖의 상태면 MAIL_001 — 아무것도 다시 내지 않았다 + */ + private async cancelHeld( + user: string, + id: string, + held: readonly JmapEmailSubmission[], + callOptions: () => JmapCallOptions, + startedAt: number, + ): Promise { + let outcome: 'canceled' | 'refused' | 'unknown' = 'unknown'; + let jmapType: string | undefined; + try { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: Object.fromEntries( + held.map((submission) => [ + submission.id, + { undoStatus: UNDO_CANCELED }, + ]), + ), + }, + 'x', + ], + ], + callOptions(), + ); + const set = expectResult( + responses, + 'x', + 'EmailSubmission/set', + ); + const refused = firstSetError(set.notUpdated); + const confirmed = set.updated ?? {}; + if (refused) { + outcome = 'refused'; + jmapType = refused.type ?? 'unknown'; + } else if ( + held.every((submission) => + Object.prototype.hasOwnProperty.call(confirmed, submission.id), + ) + ) { + outcome = 'canceled'; + } + } catch (error) { + // 결과를 모른다 — 다시 읽어 가른다 (취소됐으면 잇고, 아니면 바꾸지 않은 것으로) + jmapType = describeFailure(error); + } + if (outcome === 'canceled') return null; + + // 엔진이 받지 않았거나(그사이 보냄) 확인하지 못했다 — 다시 읽어 지금 상태로 가른다 + const fresh = await this.readReleaseSnapshot(user, id, callOptions()); + const state = releaseStateOf(fresh.email, fresh.submissions, Date.now()); + if (state.kind === 'released' || state.kind === 'sent') { + return this.releasedNow(user, id, fresh, state, startedAt); + } + if (outcome === 'unknown' && state.kind === 'interrupted') return null; + throw new BusinessException('MAIL_001', { + context: { + reason: + outcome === 'refused' + ? 'release-cancel-refused' + : 'release-cancel-not-confirmed', + state: state.kind, + ...(jmapType !== undefined ? { jmapType } : {}), + }, + }); + } + + /** + * 취소한 예약 메일을 같은 envelope 으로 다시 제출한다 — 성공하면 같은 요청에서 예약 표시를 뗀다(이제 보내기 취소를 기다리는 메일 · 보낸 메일이다). + * 실패는 둘로 가른다 (처음 발송의 finishSubmission 과 같은 결 — 9단계 리뷰 2): + * - 엔진이 분명히 거절했다(SetError · 메서드 오류 · 요청을 실행하지 않았다는 답 isRequestRefused) — 아무것도 나가지 않았다. + * 한 번 더 내고(RELEASE_SUBMIT_ATTEMPTS), 그래도 거절이면 그 메일을 임시보관함으로 되돌린 **뒤에** ScheduledSendReturnedToDraftsException(MAIL_001). + * 되돌리지 못하면 returnToDrafts 의 MAIL_001 그대로다(되돌림 예외가 아니다 — 메일은 예약이 취소된 채 보낸편지함에 있다) + * - 결과를 모른다(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답) — 나갔을 수 있다: 다시 내지도 · 되돌리지도 않는다 (afterUnknownResubmit) + * 제출마다 먼저 남은 시간을 본다 — MIN_SEND_WINDOW_MS 보다 적으면 내지 않고 MAIL_001 (보내다 끊기면 나갔는지 알 수 없다). + * 메일은 그대로라(예약 표시 · 취소한 예약 제출) 같은 요청을 다시 받으면 끊긴 지금 보내기(interrupted)로 이어 낸다 + * @returns 만든 제출, 또는 그대로 답할 결과(released — 결과를 모르게 끝났지만 다시 읽으니 엔진이 만들었다) + */ + private async resubmitReleased( + user: string, + id: string, + plan: SubmissionPlan, + rcptTo: readonly string[], + context: { + readonly callOptions: (floorMs?: number) => JmapCallOptions; + readonly left: () => number; + readonly startedAt: number; + readonly draftsId: string; + }, + ): Promise< + | { readonly send: CreatedSubmission } + | { readonly released: ReleasedScheduledSend } + > { + let reason = 'unknown'; + for (let attempt = 1; attempt <= RELEASE_SUBMIT_ATTEMPTS; attempt += 1) { + // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 내지 않는다 (예약은 취소된 채 — 다시 부르면 이어 낸다) + const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.left()); + if (timeoutMs < MIN_SEND_WINDOW_MS) { + this.logger.warn( + `[MAIL_001]${traceTag()} 지금 보내기에 남은 시간이 모자라 다시 제출하지 않았습니다 — 예약을 취소한 채 보낸편지함에 둡니다(같은 요청을 다시 받으면 이어 냅니다): user=${maskEmail(user)}, id=${id}, ${attempt}번째, 남은 시간=${Math.max(0, timeoutMs)}ms`, + ); + throw new BusinessException('MAIL_001', { + context: { + reason: 'release-budget-exhausted', + leftMs: Math.max(0, timeoutMs), + attempt, + }, + }); + } + + let responses: JmapInvocation[]; + let result: MethodResult; + try { + responses = await this.jmap.call( + user, + (accountId) => [ + submitCall(accountId, plan, id, rcptTo, { + [`keywords/${SCHEDULED_SEND_KEYWORD}`]: null, + }), + ], + { timeoutMs }, + ); + // 응답에 제출 결과가 없으면(해석하지 못한 응답) 결과를 모른다 — findResult 가 MAIL_001 missing-response 로 던진다 + result = findResult(responses, 's', 'EmailSubmission/set'); + } catch (error) { + // 엔진이 실행하지 않았다고 답했다(4xx · 429) — 아무것도 나가지 않았다: 한 번 더 낸다 + if (isRequestRefused(error)) { + reason = describeFailure(error); + continue; + } + return this.afterUnknownResubmit(user, id, error, context); + } + + const set = result.ok ? (result.args as JmapSetResult) : undefined; + const send = set?.created?.send; + if (send) { + this.warnIfNotMovedToSent(responses, user); + return { send }; + } + // 엔진의 답이 거절(메서드 오류 · SetError) — 만들지 않았다: 한 번 더 낸다 + const failure = result.ok ? firstSetError(set?.notCreated) : result.error; + reason = `jmap:${failure?.type ?? 'unknown'}`; + } + + // 엔진이 거듭 거절했다 — 아무것도 나가지 않았다: 임시보관함으로 되돌린다 (못 하면 returnToDrafts 의 MAIL_001 — 되돌림 예외가 아니다) + this.logger.warn( + `[MAIL_001]${traceTag()} 예약을 취소한 메일을 엔진이 다시 받지 않아 임시보관함으로 되돌립니다 — 보내지 않았습니다: user=${maskEmail(user)}, id=${id}, reason=${reason}`, + ); + await this.returnToDrafts(user, id, context.draftsId, () => + context.callOptions(RELEASE_CLEANUP_MIN_MS), + ); + throw new ScheduledSendReturnedToDraftsException('MAIL_001', { + context: { reason: 'release-resubmit-refused', jmapReason: reason }, + }); + } + + /** + * 다시 제출의 결과를 모른다(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답) — 나갔을 수 있어 다시 내지도 · 임시보관함으로 되돌리지도 않는다. + * 제출을 한 번만 다시 읽어(남은 시간이 없어도 RELEASE_CLEANUP_MIN_MS) 새 제출이 보이면(엔진은 만들었다) 그 상태로 답한다. + * 아니면 503 MAIL_001 — 그냥 BusinessException 이다(되돌림 예외가 아니다 — 대용량 첨부 링크도 그대로). + * 메일은 예약이 취소된 채 보낸편지함에 그대로라 같은 요청을 다시 받으면 이어 낸다(그때 새 제출이 보이면 released 로 200) + */ + private async afterUnknownResubmit( + user: string, + id: string, + failure: unknown, + context: { + readonly callOptions: (floorMs?: number) => JmapCallOptions; + readonly startedAt: number; + }, + ): Promise<{ readonly released: ReleasedScheduledSend }> { + let reread: string; + try { + const fresh = await this.readReleaseSnapshot( + user, + id, + context.callOptions(RELEASE_CLEANUP_MIN_MS), + ); + const state = releaseStateOf(fresh.email, fresh.submissions, Date.now()); + if (state.kind === 'released') { + return { + released: this.releasedNow(user, id, fresh, state, context.startedAt), + }; + } + reread = state.kind; + } catch (error) { + reread = describeFailure(error); + } + this.logger.warn( + `[MAIL_001]${traceTag()} 지금 보내기의 다시 제출이 결과를 모르게 끝났습니다 — 다시 내지도 · 임시보관함으로 옮기지도 않고 그대로 둡니다(같은 요청을 다시 받으면 이어 냅니다): user=${maskEmail(user)}, id=${id}, reason=${describeFailure(failure)}, 다시 읽기=${reread}`, + ); + throw new BusinessException('MAIL_001', { + context: { reason: 'release-resubmit-unknown', reread }, + }); + } + + /** 기다리는 제출 하나를 취소한다 — 못 해도 던지지 않는다(경고만 — 받을 사람이 없는 제출이다) */ + private async cancelQuietly( + user: string, + submissionId: string, + options: () => JmapCallOptions, + ): Promise { + try { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: { [submissionId]: { undoStatus: UNDO_CANCELED } }, + }, + 'c', + ], + ], + options(), + ); + const set = expectResult( + responses, + 'c', + 'EmailSubmission/set', + ); + const refused = firstSetError(set.notUpdated); + if (refused) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 지금 보내기의 제출을 취소하지 못했습니다: user=${maskEmail(user)}, jmapType=${refused.type ?? 'unknown'}`, + ); + } + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 지금 보내기의 제출을 취소하지 못했습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`, + ); + } + } + async uploadAttachment( user: string, file: UploadedFile, @@ -3763,12 +4553,13 @@ export class JmapMailProvider implements MailProvider { /** * 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다), * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다. + * 메일함이 여럿이면(하위까지 지우기의 나무 — 15단계) 그 가운데 하나에라도 든 메일 — 전체 수도 겹치지 않게 센다(엔진의 OR 조건). * 같은 요청에 계정의 기다리는 제출(보낼 시각이 지금 뒤)을 묻는다 — 붙잡힌 메일(9단계)은 옮기지도 지우지도 않는다. * 읽은 메일이 있는데 제출을 읽지 못하면 MAIL_001 (fail closed — 비었으면 묻지 않은 것과 같다) */ private async readMailboxBatch( user: string, - mailboxId: string, + mailboxIds: readonly string[], options: JmapCallOptions, ): Promise<{ emails: JmapEmail[]; @@ -3784,7 +4575,7 @@ export class JmapMailProvider implements MailProvider { 'Email/query', { accountId, - filter: { inMailbox: mailboxId }, + filter: inAnyMailbox(mailboxIds), limit: BULK_BATCH_SIZE, calculateTotal: true, }, @@ -3817,7 +4608,10 @@ export class JmapMailProvider implements MailProvider { const byId = new Map((found.list ?? []).map((email) => [email.id, email])); const emails = ids.flatMap((emailId) => { const email = byId.get(emailId); - return email && email.mailboxIds?.[mailboxId] === true ? [email] : []; + return email && + mailboxIds.some((mailboxId) => email.mailboxIds?.[mailboxId] === true) + ? [email] + : []; }); return { emails, @@ -3869,36 +4663,52 @@ export class JmapMailProvider implements MailProvider { } /** - * 빈 메일함을 지운다 — 메일이 든 메일함은 엔진이 mailboxHasEmail 로 거절한다(메일을 지우는 옵션은 쓰지 않는다, 확인함). - * @returns 지웠거나 이미 없으면 true, 그사이 메일이 들어와 거절됐으면 false - * @throws 하위 메일함이 생겼으면 VAL_001, 그 밖의 거절은 그 코드 + * 빈 메일함들을 지운다 — 적은 차례대로(하위까지 지우기는 깊은 것부터 — 엔진은 한 요청의 destroy 를 적은 차례대로 한다, 2026-11-09 확인). + * 메일이 든 메일함은 엔진이 mailboxHasEmail 로, 하위가 남은 메일함은 mailboxHasChild 로 거절한다(메일을 지우는 옵션은 쓰지 않는다, 확인함). + * 이미 없으면(notFound) 지운 것으로 본다 + * @returns destroyed — 모두 지웠다 · has-email — 그사이 메일이 들어와 거절된 메일함이 있다(그 위는 하위가 남아 함께 거절된다) · + * has-child — 메일은 없는데 하위가 남았다(그사이 나무 밖의 하위 메일함이 생겼다). 이번에 지운 메일함 id 도 함께 + * @throws 그 밖의 거절은 그 코드 */ - private async destroyEmptyMailbox( + private async destroyEmptyMailboxes( user: string, - mailboxId: string, + mailboxIds: readonly string[], options: JmapCallOptions, - ): Promise { + ): Promise<{ + readonly outcome: 'destroyed' | 'has-email' | 'has-child'; + readonly destroyed: ReadonlySet; + }> { const responses = await this.jmap.call( user, (accountId) => [ [ 'Mailbox/set', - { accountId, destroy: [mailboxId], onDestroyRemoveEmails: false }, + { + accountId, + destroy: [...mailboxIds], + onDestroyRemoveEmails: false, + }, 'x', ], ], options, ); const set = expectResult(responses, 'x', 'Mailbox/set'); - const failure = firstSetError(set.notDestroyed); - if (!failure || failure.type === NOT_FOUND) return true; - if (failure.type === MAILBOX_HAS_EMAIL) return false; - if (failure.type === MAILBOX_HAS_CHILD) { - throw new BusinessException('VAL_001', { - context: { reason: 'mailbox-has-children' }, - }); + const failures = Object.entries(set.notDestroyed ?? {}).flatMap( + ([mailboxId, error]) => + error && error.type !== NOT_FOUND ? [{ mailboxId, error }] : [], + ); + const failed = new Set(failures.map(({ mailboxId }) => mailboxId)); + const destroyed = new Set(mailboxIds.filter((id) => !failed.has(id))); + if (failures.length === 0) return { outcome: 'destroyed', destroyed }; + if (failures.some(({ error }) => error.type === MAILBOX_HAS_EMAIL)) { + return { outcome: 'has-email', destroyed }; } - throw jmapFailure(failure, { method: 'Mailbox/set' }); + const other = failures.find( + ({ error }) => error.type !== MAILBOX_HAS_CHILD, + ); + if (other) throw jmapFailure(other.error, { method: 'Mailbox/set' }); + return { outcome: 'has-child', destroyed }; } /** diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index ebfeb40..4304673 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -150,6 +150,8 @@ export interface JmapEmailSubmission { id: string; /** 이 제출이 보내는 메일 */ emailId?: string; + /** 이 제출의 발신 ID — 예약 메일 지금 보내기(15단계)가 같은 발신 ID 로 다시 제출한다 */ + identityId?: string; /** * pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7). * 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계) diff --git a/backend/src/modules/mail/mail-block-match.spec.ts b/backend/src/modules/mail/mail-block-match.spec.ts new file mode 100644 index 0000000..ddfa6e7 --- /dev/null +++ b/backend/src/modules/mail/mail-block-match.spec.ts @@ -0,0 +1,52 @@ +import { asciiCasemap, isBlockedSenderMail } from './mail-block-match'; + +/** + * 차단할 때 받은편지함 정리(15단계)의 판정 — 새로 오는 메일의 차단 규칙(jmap/sieve-script.ts 의 blockSection — Sieve `address :all :is` · + * `address :domain :is`, `i;ascii-casemap`)과 같은지. 표의 "엔진이 넓게 잡은 것"은 2026-11-09 개발 엔진의 `Email/query` from 조건이 잡은 메일이다(phase15 E3) + */ +describe('차단 정리의 판정 (mail-block-match — Sieve 의 address 와 짝)', () => { + it('ASCII 글자만 소문자로 — 그 밖의 글자는 그대로 (i;ascii-casemap)', () => { + expect(asciiCasemap('Kim@Spam.EXAMPLE')).toBe('kim@spam.example'); + expect(asciiCasemap('ÄBC@Ü.example')).toBe('Äbc@Ü.example'); + }); + + it.each<[string, string[], boolean]>([ + ['정확한 주소', ['kim@spam.example'], true], + ['대문자 주소', ['KIM@SPAM.EXAMPLE'], true], + [ + '보낸 사람 둘 가운데 둘째', + ['first@example.net', 'kim@spam.example'], + true, + ], + // 엔진 검색(from)은 잡지만 차단 규칙은 맞지 않는 것 + ['점이 더 붙은 주소', ['kim.lee@spam.example'], false], + ['하위 도메인', ['kim@sub.spam.example'], false], + ['더 긴 도메인', ['kim@spam.example.example.net'], false], + ['앞에 글자가 더', ['akim@spam.example'], false], + ['다른 도메인', ['kim@spam.example.net'], false], + ['From 이 없음', [], false], + ])('주소 차단 — %s', (_label, addresses, expected) => { + expect(isBlockedSenderMail(addresses, 'kim@spam.example')).toBe(expected); + }); + + it('주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다 — 엔진 규칙과 같은 한계', () => { + expect(isBlockedSenderMail(['ü@spam.example'], 'ü@spam.example')).toBe( + true, + ); + expect(isBlockedSenderMail(['Ü@spam.example'], 'ü@spam.example')).toBe( + false, + ); + }); + + it.each<[string, string[], boolean]>([ + ['그 도메인', ['kim@spam.example'], true], + ['대문자 도메인', ['Lee@SPAM.Example'], true], + ['둘째 보낸 사람이 그 도메인', ['a@example.net', 'b@spam.example'], true], + ['하위 도메인', ['kim@sub.spam.example'], false], + ['더 긴 도메인', ['kim@spam.example.net'], false], + ['도메인이 이름 끝에만', ['kim@notspam.example'], false], + ['@ 가 없는 주소', ['spam.example'], false], + ])('도메인 차단 — %s', (_label, addresses, expected) => { + expect(isBlockedSenderMail(addresses, '@spam.example')).toBe(expected); + }); +}); diff --git a/backend/src/modules/mail/mail-block-match.ts b/backend/src/modules/mail/mail-block-match.ts new file mode 100644 index 0000000..b8d2a73 --- /dev/null +++ b/backend/src/modules/mail/mail-block-match.ts @@ -0,0 +1,40 @@ +/** + * 차단할 때 받은편지함 정리(계약 2절 "보낸 사람 차단" — 15단계)의 판정 — 순수 함수. + * **짝: jmap/sieve-script.ts 의 blockSection**(새로 오는 메일의 차단 규칙) — `address :all :is "from" [주소들]` · + * `address :domain :is "from" [도메인들]`, 비교는 엔진 기본 `i;ascii-casemap`(ASCII 대소문자만 무시). 한쪽을 바꾸면 다른 쪽도. + * + * 견주는 것은 엔진이 푼 From 머리글의 주소다(같은 이름의 머리글이 여럿이면 모두 — 표시 이름은 보지 않는다, jmap/filter-run-texts.ts 의 fromAddressesOf). + * 엔진의 검색(`Email/query` 의 from)은 쓰지 않는다 — 낱말 단위라 비슷한 주소(`kim.lee@` · `@sub.` · `.example.net`)와 + * 표시 이름에만 그 주소를 쓴 남의 메일까지 잡는다(2026-11-09 개발 엔진 확인) + */ + +/** ASCII 글자만 소문자로 — 엔진의 `i;ascii-casemap` (그 밖의 글자는 그대로 견준다) */ +export function asciiCasemap(value: string): string { + return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); +} + +/** 주소의 도메인 — 마지막 `@` 뒤 (없으면 빈 글) */ +function domainOf(address: string): string { + const at = address.lastIndexOf('@'); + return at < 0 ? '' : address.slice(at + 1); +} + +/** + * 그 메일의 From 주소 가운데 하나라도 차단 대상에 맞는가 — 주소 차단(`kim@spam.example`)은 주소 전체가 같을 때, + * 도메인 차단(`@spam.example`)은 주소의 도메인이 같을 때(하위 도메인 · 더 긴 도메인은 아님) + * @param fromAddresses 엔진이 푼 From 머리글의 주소들 + * @param sender 차단 목록의 값 (소문자 주소 또는 `@도메인` — BlockedSender.address) + */ +export function isBlockedSenderMail( + fromAddresses: readonly string[], + sender: string, +): boolean { + const wanted = asciiCasemap(sender); + if (wanted.startsWith('@')) { + const domain = wanted.slice(1); + return fromAddresses.some( + (address) => asciiCasemap(domainOf(address)) === domain, + ); + } + return fromAddresses.some((address) => asciiCasemap(address) === wanted); +} diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts index c544b39..d89e294 100644 --- a/backend/src/modules/mail/mail-block.controller.ts +++ b/backend/src/modules/mail/mail-block.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + HttpCode, HttpStatus, Param, Post, @@ -28,17 +29,23 @@ import { MailBlockListDto, MailBlockRemovedDto, } from './dto/mail-block.dto'; +import { + MailBlockRunDto, + MailBlockRunRequestDto, +} from './dto/mail-block-run.dto'; import { MailBlockService } from './mail-block.service'; import { + MAIL_BLOCK_RUNS_ROUTE, MAIL_BLOCKS_ROUTE, MAX_EMAIL_LENGTH, MAX_MAIL_BLOCKS, } from './mail.constants'; +import { MailService } from './mail.service'; /** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */ const BLOCK_NOTE = '차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' + - '이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.'; + '이미 받은 메일은 저절로 옮기지 않는다 — 받은편지함에 있는 것은 POST /api/mail-block-runs 로 옮긴다(15단계). 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.'; // 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계) @ApiTags('Mail') @@ -109,3 +116,52 @@ export class MailBlockController { return this.blocks.remove(user, address); } } + +// 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단", 15단계) — 이미 받은 메일에 필터 적용(7단계)과 같은 틀 · 같은 잠금 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@Controller(MAIL_BLOCK_RUNS_ROUTE) +export class MailBlockRunController { + constructor(private readonly mail: MailService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로', + description: + '받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다($junk — 스팸 신고와 같다). 받은편지함만 본다(정리해 둔 메일함은 건드리지 않는다). ' + + '판정은 새로 오는 메일의 차단 규칙과 같다 — 주소는 머리글 From 의 주소 가운데 하나가 같을 때, 도메인은 From 주소의 @ 뒤가 그 도메인과 같을 때(하위 도메인은 아님), 대소문자는 ASCII 만 무시. ' + + '표시 이름에 쓴 주소 · 비슷한 주소는 맞지 않는다. 보내기를 기다리는 메일은 옮기지 않는다. 되돌리기는 없다(스팸메일함에서 스팸 아님으로). ' + + '받은 시각이 최근인 메일부터, 첫 요청 뒤에 온 메일은 보지 않는다. 한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' + + '같은 직원의 받은편지함 정리(이것 · POST /api/mail-filter-runs)는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001. ' + + '요청이 도는 동안 그 직원의 스팸메일함을 잡는다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429, 비우는 중이면 이 요청이 메일함 목록을 읽은 뒤 429(받은편지함은 건드리지 않았다).', + }) + @ApiStandardResponse(MailBlockRunDto, { + description: + '이번 요청에서 확인한 수 · 스팸메일함으로 옮긴 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)', + }) + @ApiErrorResponse( + 'VAL_001', + `형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 깨진 cursor · 다른 보낸 사람의 cursor · 모르는 필드 — 옮기지 않았다`, + ) + @ApiErrorResponse( + 'RES_001', + '차단 목록에 없는 보낸 사람(차단한 뒤에만 부른다 · 그사이 풀었다) — 옮기지 않았다', + ) + @ApiErrorResponse( + 'REQ_001', + '같은 직원의 받은편지함 정리(필터 적용 포함)가 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다 · 스팸메일함 비우기가 진행 중 — 옮기지 않았다', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 스팸메일함이 없음 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 옮겼을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다', + ) + run( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailBlockRunRequestDto, + ) { + return this.mail.runMailBlock(user, dto); + } +} diff --git a/backend/src/modules/mail/mail-block.service.ts b/backend/src/modules/mail/mail-block.service.ts index 372c25a..df80be5 100644 --- a/backend/src/modules/mail/mail-block.service.ts +++ b/backend/src/modules/mail/mail-block.service.ts @@ -6,19 +6,14 @@ import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import type { MailBlockCreateDto } from './dto/mail-block.dto'; import { + describeBlockTarget, 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) @@ -65,7 +60,7 @@ export class MailBlockService { }); if (result.created) { this.logger.log( - `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`, ); } return result; @@ -84,7 +79,7 @@ export class MailBlockService { } await this.provider.removeMailBlock(user.email, target.value); this.logger.log( - `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`, ); return { address: target.value }; } diff --git a/backend/src/modules/mail/mail-blocks.ts b/backend/src/modules/mail/mail-blocks.ts index d4aca3c..3d5b8f7 100644 --- a/backend/src/modules/mail/mail-blocks.ts +++ b/backend/src/modules/mail/mail-blocks.ts @@ -1,6 +1,7 @@ import { isEmail } from 'class-validator'; import { isIP } from 'net'; +import { maskEmail } from '@/shared/utils/mask'; import { MAX_EMAIL_LENGTH, PUBLIC_MAIL_DOMAINS, @@ -88,6 +89,16 @@ export function blockTargetDomain(target: BlockTarget): string { return target.value.slice(target.value.lastIndexOf('@') + 1); } +/** + * 로그에 남기는 차단 대상 — `주소=k***@spam.example` · `도메인=@spam.example` (계약 2절 "보낸 사람 차단"의 로그 — 주소는 가리고(maskEmail), + * 도메인 차단은 도메인만). 차단 · 풀기 · 받은편지함 정리(15단계)가 같이 쓴다 + */ +export function describeBlockTarget(target: BlockTarget): string { + return target.kind === 'domain' + ? `도메인=${target.value}` + : `주소=${maskEmail(target.value)}`; +} + /** 사내 도메인이거나 그 하위 도메인인가 */ function isInternalDomain(domain: string, internal: string): boolean { return domain === internal || domain.endsWith(`.${internal}`); diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts index 4ecabf8..eb24a9e 100644 --- a/backend/src/modules/mail/mail-large-files.service.spec.ts +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -147,6 +147,7 @@ function setup() { url: shareUrl(ORIGIN, TOKEN), }), markSent: jest.fn().mockResolvedValue(undefined), + markReleased: jest.fn().mockResolvedValue(true), cancel: jest.fn().mockResolvedValue(undefined), attachRetry: jest.fn().mockResolvedValue(undefined), findBySentEmail: jest.fn().mockResolvedValue(null), @@ -389,6 +390,31 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = FILE_B, ]); }); + + it('예약 메일을 지금 보냈으면(15단계) 그 링크의 보낸 때를 앞당긴다 — 링크가 없거나 취소한 링크면 그대로, 실패해도 던지지 않는다', async () => { + const { service, sharing } = setup(); + const now = new Date('2026-11-09T05:00:00.000Z'); + + sharing.findBySentEmail.mockResolvedValue(record()); + await service.onScheduledSendReleased(ARA, 'sent-1', now); + expect(sharing.findBySentEmail).toHaveBeenCalledWith(ARA.email, 'sent-1'); + expect(sharing.markReleased).toHaveBeenCalledWith('link-1', now); + + sharing.markReleased.mockClear(); + for (const found of [ + null, + record({ canceledAt: new Date('2026-11-09T04:00:00.000Z') }), + ]) { + sharing.findBySentEmail.mockResolvedValueOnce(found); + await service.onScheduledSendReleased(ARA, 'sent-1', now); + } + expect(sharing.markReleased).not.toHaveBeenCalled(); + + sharing.findBySentEmail.mockRejectedValueOnce(new Error('db down')); + await expect( + service.onScheduledSendReleased(ARA, 'sent-1', now), + ).resolves.toBeUndefined(); + }); }); describe('읽기 — 덩이 알아보기', () => { diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts index d046515..9cb905f 100644 --- a/backend/src/modules/mail/mail-large-files.service.ts +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -303,6 +303,29 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { } } + /** + * 예약 메일을 지금 보냈다(15단계 — 엔진에서 다시 제출한 뒤) — 그 발송이 만든 링크의 보낸 때를 실제로 보낸 때로 앞당긴다. + * 끝나는 때는 그대로 둔다 — 보낸 메일의 덩이가 그 날짜(예약 시각 기준)를 이미 적었다(FileSharingService.markReleased). 링크가 없거나 취소한 링크면 그대로. + * 실패해도 보내기는 된 것이다 — 경고만 + */ + async onScheduledSendReleased( + user: AuthenticatedUser, + sentId: string, + sentAt: Date, + ): Promise { + try { + const record = await this.sharing.findBySentEmail(user.email, sentId); + if (!record || record.link.canceledAt !== null) return; + await this.sharing.markReleased(record.link.id, sentAt); + } catch (error) { + this.warn( + '지금 보낸 예약 메일의 링크에 보낸 때를 적지 못했습니다', + user, + error, + ); + } + } + // ── 임시 저장 ──────────────────────────────────────────────────────────── /** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */ diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index a412474..68730ee 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -151,6 +151,9 @@ export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000; /** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */ export const MAIL_BLOCKS_ROUTE = 'mail-blocks'; +/** 차단할 때 받은편지함 정리 라우트 경로 (`/api/mail-block-runs`) — 계약 2절 "보낸 사람 차단" (15단계) */ +export const MAIL_BLOCK_RUNS_ROUTE = 'mail-block-runs'; + /** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */ export const MAX_MAIL_BLOCKS = 500; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e2b6c7e..f2d723a 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -34,7 +34,10 @@ 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 { + MailBlockController, + MailBlockRunController, +} from './mail-block.controller'; import { MailBlockService } from './mail-block.service'; import { MailEventsController } from './mail-events.controller'; import { @@ -148,6 +151,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailFilterController, MailFilterRunController, MailBlockController, + // 차단할 때 받은편지함 정리 (15단계 — 필터 적용과 같은 잠금) + MailBlockRunController, MessageController, // 같은 대화 · 대화로 묶은 목록 (14단계) ConversationController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index ec33dcf..c3e971d 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -13,9 +13,12 @@ import type { MailAttachment, MailBlockAdded, MailBlockList, + MailBlockRunRequest, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailFilterRunContext, MailFilterRunOutcome, @@ -40,7 +43,9 @@ import type { PasswordChangeOutcome, RecipientFields, RejectedRecipient, + ReleasedScheduledSend, SavedDraft, + SendHold, SentMessage, SentMessageRecord, SummariesWithHeld, @@ -68,6 +73,14 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); */ export class SendNotStartedException extends BusinessException {} +/** + * 예약 메일 지금 보내기(15단계)가 끝내 보내지 못해 그 메일을 임시보관함의 초안으로 **되돌렸다** — 예약 취소(DELETE /api/scheduled-messages/{id})와 같은 자리다. + * 되돌리기까지 된 때만 이 형식이다(엔진이 다시 제출을 거듭 거절함 MAIL_001 · 받는 사람이 모두 받지 않음 MAIL_010 · MAIL_011). 코드 · 응답은 BusinessException 그대로다. + * 결과를 모르는 실패 · 남은 시간 모자람 · 되돌리기 실패는 그냥 BusinessException(MAIL_001)이다 — 메일은 보낸편지함에 그대로다. + * 서비스는 이 형식으로 갈라 대용량 첨부 링크를 취소한 발송처럼 둔다(되돌린 초안이 그 파일을 쓴다) — context 는 로그 전용이라 갈래에 쓰지 않는다 + */ +export class ScheduledSendReturnedToDraftsException extends BusinessException {} + /** * 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다 * (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다. @@ -148,16 +161,30 @@ export interface MailProvider { */ createMailbox(user: string, input: MailboxCreate): Promise; - /** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */ - renameMailbox(user: string, id: string, name: string): Promise; + /** + * 메일함의 이름 · 상위를 바꾼다 (15단계 — 옮기기) — 보내지 않은 칸은 그대로. 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, + * 새 상위가 없으면 RES_001 · 만들 수 없는 자리(만들기와 같다)이거나 자기 자신 · 자기 하위면 VAL_001, 새 자리에 같은 이름이 있으면 MAIL_009. + * 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 하위 메일함과 안의 메일은 함께 간다(메일함 id 는 그대로다) + */ + updateMailbox( + user: string, + id: string, + change: MailboxChange, + ): Promise; /** * 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다. * 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다). * 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001. + * `withChildren` 이면(15단계) 하위가 있어도 나무 전체(그 메일함과 하위 모두)를 같은 규칙으로 — 메일을 옮긴 뒤 깊은 것부터 지운다. + * 하위에 역할이 있는 메일함이 있으면 VAL_001. 수(processed · movedToTrash · remaining)는 나무 전체의 것이다. * 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015 */ - deleteMailbox(user: string, id: string): Promise; + deleteMailbox( + user: string, + id: string, + options?: MailboxDeleteOptions, + ): Promise; /** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */ markMailboxRead(user: string, id: string): Promise; @@ -224,6 +251,18 @@ export interface MailProvider { context: MailFilterRunContext, ): Promise; + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 한다. + * 판정은 새로 오는 메일의 차단 규칙(Sieve 의 `address` 판정 — mail-block-match.ts)과 같다. 차례 · 시간 · cursor 는 runMailFilter 와 같은 규칙이다 + * (matched = 스팸메일함으로 옮긴 수). 스팸메일함이 없으면 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 엔진에 묻기 전에 VAL_001. + * 차단 목록에 있는지는 서비스가 본 값이다. 받은편지함 · 스팸메일함은 context.mailboxes 에서 찾는다 + */ + runMailBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise; + /** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */ getUsage(user: string): Promise; @@ -391,6 +430,25 @@ export interface MailProvider { */ cancelPendingSend(user: string, id: string): Promise; + /** + * 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — `id` 는 보낸 사본의 메일 id. + * 기다리는 예약 제출을 취소하고(엔진이 확인한 뒤에만) 같은 메일을 그 제출의 envelope 그대로 다시 제출한다 — `hold` 만큼 붙잡는다(보내기 취소 설정 — null 이면 바로). + * 예약 표시를 떼고, 받는 사람별 결과는 발송과 같은 규칙으로 읽는다. + * - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) · 없는 메일 → RES_001 (아무것도 바꾸지 않는다) + * - 이미 보냈다(예약 시각이 막 지남 — 제출이 final · 취소하려니 엔진이 받지 않음) → 오류가 아니다: pendingSend null (releasedAt null) + * - 이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답이 끊겨 다시 보냄) → 그 새 제출의 지금 상태 + * - 엔진이 다시 제출을 분명히 거절하면 한 번 더 — 그래도 거절이면 그 메일을 임시보관함으로 되돌린 뒤 ScheduledSendReturnedToDraftsException(MAIL_001). + * 받는 사람이 모두 받지 않았어도 임시보관함으로 되돌린 뒤 같은 예외(MAIL_010 · MAIL_011). 되돌리지 못하면 그냥 MAIL_001 + * - 다시 제출의 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 · 되돌리지도 않는다 — 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 MAIL_001. + * 남은 시간이 모자라 내지 않았어도 그냥 MAIL_001 — 둘 다 메일은 예약이 취소된 채 보낸편지함에 있고, 같은 요청을 다시 받으면 이어 낸다 + * - 예약 제출의 envelope · 발신 ID 를 읽지 못하거나 엔진이 취소를 확인해 주지 않으면 MAIL_001 — 취소하지 않았거나 취소를 확인하지 못해 다시 제출하지 않았다 + */ + releaseScheduledSend( + user: string, + id: string, + hold: SendHold | null, + ): 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 5d44722..32982cc 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -9,6 +9,7 @@ import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail- 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 { MailBlockRunRequestDto } from './dto/mail-block-run.dto'; 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'; @@ -82,7 +83,7 @@ function fakeProvider(): ProviderMocks { getProfile: mockOf<'getProfile'>(), listMailboxes: mockOf<'listMailboxes'>(), createMailbox: mockOf<'createMailbox'>(), - renameMailbox: mockOf<'renameMailbox'>(), + updateMailbox: mockOf<'updateMailbox'>(), deleteMailbox: mockOf<'deleteMailbox'>(), markMailboxRead: mockOf<'markMailboxRead'>(), emptyMailbox: mockOf<'emptyMailbox'>(), @@ -91,6 +92,7 @@ function fakeProvider(): ProviderMocks { getMailFilters: mockOf<'getMailFilters'>(), setMailFilters: mockOf<'setMailFilters'>(), runMailFilter: mockOf<'runMailFilter'>(), + runMailBlock: mockOf<'runMailBlock'>(), openMessageSource: mockOf<'openMessageSource'>(), getMessageSubjects: mockOf<'getMessageSubjects'>(), getUsage: mockOf<'getUsage'>(), @@ -132,6 +134,7 @@ function fakeProvider(): ProviderMocks { removeMailBlock: mockOf<'removeMailBlock'>(), listScheduledMessages: mockOf<'listScheduledMessages'>(), cancelPendingSend: mockOf<'cancelPendingSend'>(), + releaseScheduledSend: mockOf<'releaseScheduledSend'>(), }; } @@ -544,7 +547,7 @@ describe('MailService', () => { it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => { provider.createMailbox.mockResolvedValue(mailbox); - provider.renameMailbox.mockResolvedValue({ + provider.updateMailbox.mockResolvedValue({ ...mailbox, id: 'a', name: 'Inbox', @@ -562,13 +565,34 @@ describe('MailService', () => { // (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다 await expect( - service.renameMailbox(user, 'a', { name: '새 이름' }), + service.updateMailbox(user, 'a', { name: '새 이름' }), ).resolves.toMatchObject({ name: '받은편지함' }); - expect(provider.renameMailbox).toHaveBeenCalledWith( - user.email, - 'a', - '새 이름', - ); + expect(provider.updateMailbox).toHaveBeenCalledWith(user.email, 'a', { + name: '새 이름', + }); + }); + + it('메일함 바꾸기(15단계) — 이름 · 상위 가운데 보낸 것만 넘긴다, 둘 다 없으면 엔진에 묻지 않고 VAL_001', async () => { + provider.updateMailbox.mockResolvedValue(mailbox); + + expect( + await failureOf(() => service.updateMailbox(user, 'n1', {})), + ).toEqual({ + code: 'VAL_001', + reason: 'empty-mailbox-update', + }); + expect(provider.updateMailbox).not.toHaveBeenCalled(); + + await service.updateMailbox(user, 'n1', { parentId: null }); + await service.updateMailbox(user, 'n1', { parentId: 'u1' }); + await service.updateMailbox(user, 'n1', { name: '2025', parentId: 'u1' }); + expect(provider.updateMailbox.mock.calls).toEqual([ + [user.email, 'n1', { parentId: null }], + [user.email, 'n1', { parentId: 'u1' }], + [user.email, 'n1', { name: '2025', parentId: 'u1' }], + ]); + // 옮긴 로그는 어댑터가 실제로 옮겼을 때만 남긴다 (지금 자리 그대로면 남기지 않게 — jmap-mail.provider.spec.ts) + expect(logSpy).not.toHaveBeenCalled(); }); it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => { @@ -598,6 +622,37 @@ describe('MailService', () => { expect(line).toContain('휴지통으로 옮김=7통'); expect(line).toContain('이 메일함에서만 뺌=2통'); expect(line).not.toContain(user.email); + // 하위까지가 아니면 메일함 목록을 읽지 않고, 한 요청의 시간은 서비스가 받은 때부터 (startedAt) + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.deleteMailbox).toHaveBeenLastCalledWith( + user.email, + 'u4', + { + startedAt: expect.any(Number) as number, + }, + ); + }); + + it('하위 메일함까지 지우기(15단계 — withChildren=true)는 메일함 목록으로 나무를 정해 넘긴다', async () => { + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + { ...mailbox, id: 'u3' }, + ]); + provider.deleteMailbox.mockResolvedValue({ + deleted: true, + processed: 3, + movedToTrash: 3, + remaining: 0, + }); + + await service.deleteMailbox(user, 'u1', { withChildren: 'true' }); + expect(provider.deleteMailbox).toHaveBeenCalledWith(user.email, 'u1', { + withChildren: true, + startedAt: expect.any(Number) as number, + }); + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('하위까지(메일함 2개)'); }); it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => { @@ -712,6 +767,103 @@ describe('MailService', () => { provider.emptyMailbox.mockResolvedValueOnce(emptied); await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied); }); + + it('하위까지 지우기는 나무의 메일함 모두를 잡는다 — 하위 메일함의 처리 · 그 안으로 옮기기는 REQ_001, 나무 밖은 된다 (15단계)', async () => { + const deletion = { + deleted: true, + processed: 0, + movedToTrash: 0, + remaining: 0, + }; + const first = pending(); + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + { ...mailbox, id: 'u3' }, + ]); + provider.deleteMailbox.mockReturnValueOnce(first.promise); + provider.markMailboxRead.mockResolvedValue({ + processed: 0, + remaining: 0, + }); + provider.updateMailbox.mockResolvedValue(mailbox); + + const running = service.deleteMailbox(user, 'u1', { + withChildren: 'true', + }); + // 메일함 목록(엔진)을 지나 어댑터에 들어갈 때까지 기다린다 + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.deleteMailbox).toHaveBeenCalledTimes(1); + + for (const attempt of [ + () => service.updateMailboxMessages(user, 'u2', { isRead: true }), + () => service.emptyMailbox(user, 'u2'), + // 지우는 나무 안으로 옮기기 · 지우는 나무의 메일함을 빼 가기 + () => service.updateMailbox(user, 'u3', { parentId: 'u2' }), + () => service.updateMailbox(user, 'u2', { parentId: null }), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'mailbox-operation-in-progress', + }); + } + expect(provider.updateMailbox).not.toHaveBeenCalled(); + + // 나무 밖은 된다 — 이름만 바꾸기는 잠그지 않는다(지금처럼) + await service.updateMailboxMessages(user, 'u3', { isRead: true }); + await service.updateMailbox(user, 'u2', { name: '새 이름' }); + expect(provider.updateMailbox).toHaveBeenCalledTimes(1); + + first.finish(deletion); + await running; + await service.updateMailbox(user, 'u3', { parentId: 'u1' }); + expect(provider.updateMailbox).toHaveBeenCalledTimes(2); + }); + + it('하위까지 지우기는 그 메일함을 먼저 잡는다 — 그 메일함이 처리 중이면 목록을 읽지 않고 REQ_001, 하위가 처리 중이면 목록만 읽고 REQ_001 (15단계)', async () => { + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + ]); + const busy = pending<{ processed: number; remaining: number }>(); + provider.markMailboxRead.mockReturnValueOnce(busy.promise); + + // 그 메일함이 처리 중 — 엔진에 묻지 않는다 + const rootBusy = service.updateMailboxMessages(user, 'u1', { + isRead: true, + }); + expect( + await failureOf(() => + service.deleteMailbox(user, 'u1', { withChildren: 'true' }), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + busy.finish({ processed: 0, remaining: 0 }); + await rootBusy; + + // 하위가 처리 중 — 목록만 읽고 지우지 않는다, 그 메일함의 잠금은 푼다 + const child = pending<{ processed: number; remaining: number }>(); + provider.markMailboxRead.mockReturnValueOnce(child.promise); + const childBusy = service.updateMailboxMessages(user, 'u2', { + isRead: true, + }); + expect( + await failureOf(() => + service.deleteMailbox(user, 'u1', { withChildren: 'true' }), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.listMailboxes).toHaveBeenCalledTimes(1); + expect(provider.deleteMailbox).not.toHaveBeenCalled(); + provider.markMailboxRead.mockResolvedValueOnce({ + processed: 0, + remaining: 0, + }); + await expect( + service.updateMailboxMessages(user, 'u1', { isRead: true }), + ).resolves.toEqual({ processed: 0, remaining: 0 }); + child.finish({ processed: 0, remaining: 0 }); + await childBusy; + }); }); describe('부재중 자동 응답 저장', () => { @@ -1456,6 +1608,204 @@ describe('MailService', () => { }); }); + describe('차단할 때 받은편지함 정리 (15단계)', () => { + const outcome = { + scanned: 500, + matched: 12, + remaining: 40, + cursor: 'next-cursor', + rounds: 1, + }; + const blocked = (address: string) => ({ + address, + blockedAt: '2026-11-09T01:00:00.000Z', + }); + const mailboxes: Mailbox[] = [ + { + id: 'a', + name: 'Inbox', + role: 'inbox', + parentId: null, + sortOrder: 0, + totalCount: 540, + unreadCount: 0, + }, + { + id: 'c', + name: 'Junk Mail', + role: 'junk', + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + ]; + const body = ( + sender: string, + cursor: string | null = null, + ): MailBlockRunRequestDto => ({ sender, cursor }); + + /** 손으로 끝내는 정리 — 끝낼 때까지 진행 중이다 */ + function pending(): { + promise: Promise; + finish: (value: T) => void; + } { + let finish!: (value: T) => void; + const promise = new Promise((resolve) => { + finish = resolve; + }); + return { promise, finish }; + } + + beforeEach(() => { + provider.getMailBlocks.mockResolvedValue({ + senders: [blocked('kim@spam.example'), blocked('@spam.example')], + isActive: true, + }); + provider.listMailboxes.mockResolvedValue(mailboxes); + provider.runMailBlock.mockResolvedValue(outcome); + }); + + it('차단 목록에 있는 보낸 사람이면 메일함 목록과 함께 넘기고 결과를 돌려준다 — 대소문자 · 앞뒤 공백은 맞춘다, 로그에는 가린 주소 · 수만', async () => { + await expect( + service.runMailBlock(user, body(' Kim@Spam.Example ')), + ).resolves.toEqual({ + scanned: 500, + matched: 12, + remaining: 40, + cursor: 'next-cursor', + }); + expect(provider.runMailBlock).toHaveBeenCalledWith( + user.email, + { sender: 'kim@spam.example', cursor: null }, + { mailboxes, startedAt: expect.any(Number) as number }, + ); + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('확인 500통 · 옮김 12통 · 남음 40통'); + expect(line).not.toContain('kim@spam.example'); + expect(line).not.toContain('next-cursor'); + expect(line).not.toContain(user.email); + + // 도메인 차단은 도메인만 남긴다 + await service.runMailBlock(user, body('@spam.example', 'next-cursor')); + expect(provider.runMailBlock).toHaveBeenLastCalledWith( + user.email, + { sender: '@spam.example', cursor: 'next-cursor' }, + expect.any(Object), + ); + const [domainLine] = logSpy.mock.calls.at(-1) as [string]; + expect(domainLine).toContain('도메인=@spam.example'); + }); + + it('차단 목록에 없으면 RES_001 — 받은편지함을 읽지 않는다 · 모양이 틀리면 VAL_001 (엔진에 묻지 않는다)', async () => { + expect( + await failureOf(() => + service.runMailBlock(user, body('lee@spam.example')), + ), + ).toEqual({ code: 'RES_001', reason: 'mail-block-not-found' }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.runMailBlock).not.toHaveBeenCalled(); + + provider.getMailBlocks.mockClear(); + expect( + await failureOf(() => + service.runMailBlock(user, body('not an address')), + ), + ).toEqual({ code: 'VAL_001', reason: 'malformed-mail-block-run-sender' }); + expect(provider.getMailBlocks).not.toHaveBeenCalled(); + }); + + it('차단 정리가 도는 동안 그 직원의 스팸메일함을 잡는다 — 비우기 · 전체 읽음은 REQ_001, 비우는 중이면 차단 정리가 목록을 읽은 뒤 REQ_001 (받은편지함 정리 잠금도 푼다)', async () => { + const first = pending(); + provider.runMailBlock.mockReturnValueOnce(first.promise); + provider.emptyMailbox.mockResolvedValue({ processed: 0, remaining: 0 }); + provider.markMailboxRead.mockResolvedValue({ + processed: 0, + remaining: 0, + }); + + const running = service.runMailBlock(user, body('kim@spam.example')); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.runMailBlock).toHaveBeenCalledTimes(1); + for (const attempt of [ + () => service.emptyMailbox(user, 'c'), + () => service.updateMailboxMessages(user, 'c', { isRead: true }), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'mailbox-operation-in-progress', + }); + } + expect(provider.emptyMailbox).not.toHaveBeenCalled(); + // 다른 메일함은 된다 + await expect( + service.updateMailboxMessages(user, 'b', { isRead: true }), + ).resolves.toEqual({ processed: 0, remaining: 0 }); + first.finish(outcome); + await running; + + // 스팸메일함을 비우는 중이면 차단 정리가 REQ_001 — 받은편지함을 건드리지 않는다 + const emptying = pending<{ processed: number; remaining: number }>(); + provider.emptyMailbox.mockReturnValueOnce(emptying.promise); + const empty = service.emptyMailbox(user, 'c'); + provider.runMailBlock.mockClear(); + expect( + await failureOf(() => + service.runMailBlock(user, body('kim@spam.example')), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.runMailBlock).not.toHaveBeenCalled(); + // 받은편지함 정리 잠금(filterRuns)은 풀렸다 — 필터 적용은 된다 + provider.runMailFilter.mockResolvedValueOnce(outcome); + await expect( + service.runMailFilter(user, { + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: true, flag: false }, + cursor: null, + } as unknown as MailFilterRunRequestDto), + ).resolves.toMatchObject({ matched: 12 }); + emptying.finish({ processed: 0, remaining: 0 }); + await empty; + await expect( + service.runMailBlock(user, body('kim@spam.example')), + ).resolves.toMatchObject({ matched: 12 }); + }); + + it('같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 REQ_001', async () => { + const first = pending(); + provider.runMailBlock.mockReturnValueOnce(first.promise); + provider.runMailFilter.mockResolvedValue(outcome); + + const running = service.runMailBlock(user, body('kim@spam.example')); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.runMailBlock).toHaveBeenCalledTimes(1); + + expect( + await failureOf(() => + service.runMailBlock(user, body('@spam.example')), + ), + ).toMatchObject({ code: 'REQ_001' }); + expect( + await failureOf(() => + service.runMailFilter(user, { + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: true, flag: false }, + cursor: null, + } as unknown as MailFilterRunRequestDto), + ), + ).toMatchObject({ code: 'REQ_001' }); + expect(provider.runMailFilter).not.toHaveBeenCalled(); + + first.finish(outcome); + await running; + await expect( + service.runMailBlock(user, body('kim@spam.example')), + ).resolves.toMatchObject({ matched: 12 }); + }); + }); + describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => { it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => { const state = { diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 40ef947..3f92fe5 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -30,13 +30,16 @@ import type { MessageDeletionDto, UpdateMessagesDto, } from './dto/bulk-messages.dto'; +import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { MailVacationDto } from './dto/mail-vacation.dto'; -import type { - CreateMailboxDto, - MailboxMessagesPatchDto, - RenameMailboxDto, +import { + WITH_CHILDREN, + type CreateMailboxDto, + type MailboxDeleteQueryDto, + type MailboxMessagesPatchDto, + type UpdateMailboxDto, } from './dto/mailbox.dto'; import { SHOW_IMAGES, @@ -80,6 +83,7 @@ import { SCHEDULED_SEND_MAX_LEAD_MS, SCHEDULED_SEND_MIN_LEAD_MS, } from './mail.constants'; +import { describeBlockTarget, parseBlockTarget } from './mail-blocks'; import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; @@ -118,6 +122,7 @@ import type { SentMessage, } from './mail.types'; import { presentMailbox, presentMailboxes } from './mailbox.presenter'; +import { subtreeIds } from './mailbox-tree'; import { highlightDetail } from './message-highlight'; import { RecipientSuggestionService } from './recipient-suggestions.service'; import { @@ -131,6 +136,7 @@ import { type LargeFilePlan, type RecognizedLargeFiles, } from './mail-large-files.service'; +import { undoSendHold } from './send-hold'; /** * 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간. @@ -336,13 +342,15 @@ export class MailService { /** * 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다. * 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다. + * 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 잡는다. * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. */ private readonly mailboxOperations = new Set(); /** - * 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). - * 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. + * 진행 중인 받은편지함 정리 — "이미 받은 메일에 필터 적용"(7단계)과 차단할 때 받은편지함 정리(15단계)의 직원. 한 직원에 하나씩만, 종류가 달라도 막는다 + * (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). + * 두 정리가 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. */ private readonly filterRuns = new Set(); @@ -415,32 +423,75 @@ export class MailService { ); } - /** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */ - async renameMailbox( + /** + * 메일함 이름 바꾸기 · 옮기기 (15단계) — 이름 · 상위 가운데 보낸 것만 바꾼다. 둘 다 없으면 엔진에 묻지 않고 VAL_001. + * 기본 메일함(역할 있음) · 만들 수 없는 자리 · 자기 하위 아래는 VAL_001, 없는 새 상위는 RES_001, 같은 이름은 MAIL_009 (어댑터). + * 옮길 때(parentId 가 있을 때)는 그 메일함과 새 상위의 오래 걸리는 처리와 함께 하지 않는다 — 진행 중이면 REQ_001 (runExclusive — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게). + * 옮긴 로그는 어댑터가 남긴다 — 실제로 옮겼을 때만(지금 자리 그대로면 엔진에 묻지 않아 남기지 않는다), 메일함 id 만 + */ + async updateMailbox( user: AuthenticatedUser, id: string, - dto: RenameMailboxDto, + dto: UpdateMailboxDto, ): Promise { + const { name, parentId } = dto; + if (name === undefined && parentId === undefined) { + throw new BusinessException('VAL_001', { + context: { reason: 'empty-mailbox-update' }, + }); + } + if (parentId === undefined) { + return presentMailbox( + await this.provider.updateMailbox(user.email, id, { name }), + ); + } + + const keys = parentId === null ? [id] : [id, parentId]; return presentMailbox( - await this.provider.renameMailbox(user.email, id, dto.name), + await this.runExclusive(user, keys, () => + this.provider.updateMailbox(user.email, id, { + ...(name === undefined ? {} : { name }), + parentId, + }), + ), ); } /** * 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false. - * 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive). + * 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 엔진에 묻지 않고 REQ_001 — runExclusive). 한 요청의 시간(25초)은 여기 들어온 때부터 센다. + * 하위 메일함까지 지우기(withChildren=true — 15단계)는 나무(그 메일함과 하위 모두)를 지운다 — 그 메일함을 먼저 잡고(진행 중이면 엔진에 묻지 않고 REQ_001) + * 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 REQ_001 — 아무것도 바꾸지 않는다). * 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다) */ async deleteMailbox( user: AuthenticatedUser, id: string, + query: MailboxDeleteQueryDto = {}, ): Promise { - const result = await this.runExclusive(user, id, () => - this.provider.deleteMailbox(user.email, id), - ); + const startedAt = Date.now(); + const withChildren = query.withChildren === WITH_CHILDREN; + let treeSize = 1; + const result = await this.runExclusive(user, [id], async () => { + if (!withChildren) { + return this.provider.deleteMailbox(user.email, id, { startedAt }); + } + // 나무의 하위 메일함 — 없는 메일함이면 없음(어댑터가 RES_001) + const descendants = subtreeIds( + await this.provider.listMailboxes(user.email), + id, + ).filter((mailboxId) => mailboxId !== id); + treeSize = descendants.length + 1; + return this.runExclusive(user, descendants, () => + this.provider.deleteMailbox(user.email, id, { + withChildren, + startedAt, + }), + ); + }); if (result.deleted) { this.logger.log( - `${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`, + `${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, ${withChildren ? `하위까지(메일함 ${treeSize}개), ` : ''}이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`, ); } return result; @@ -461,7 +512,7 @@ export class MailService { context: { reason: 'unsupported-mailbox-messages-change' }, }); } - return this.runExclusive(user, id, () => + return this.runExclusive(user, [id], () => this.provider.markMailboxRead(user.email, id), ); } @@ -474,7 +525,7 @@ export class MailService { user: AuthenticatedUser, id: string, ): Promise { - const result = await this.runExclusive(user, id, () => + const result = await this.runExclusive(user, [id], () => this.provider.emptyMailbox(user.email, id), ); if (result.processed > 0) { @@ -648,6 +699,61 @@ export class MailService { ); } + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮긴다. + * 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 한 요청의 시간(25초)은 여기 들어온 때부터, 이어 할 자리(cursor)는 어댑터가 만든다. + * - 보낸 사람은 차단 목록의 모양(주소 · `@도메인`)이어야 한다 — 아니면 엔진에 묻지 않고 VAL_001 + * - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금(filterRuns), 진행 중이면 엔진에 묻지 않고 REQ_001 + * - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) RES_001 — 요청마다 본다(도중에 풀면 다음 요청에서 멈춘다) + * - 요청이 도는 동안 그 직원의 스팸메일함을 메일함 처리 잠금(mailboxOperations)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 모두 읽음이 함께 돌지 않게. + * 비우는 중이면 메일함 목록을 읽은 뒤 REQ_001 (받은편지함은 건드리지 않았다) + * 로그에는 가린 보낸 사람(도메인 차단은 도메인만) · 확인 · 옮긴 수 · 차례 수 · 걸린 시간만 남긴다 + */ + async runMailBlock( + user: AuthenticatedUser, + dto: MailBlockRunRequestDto, + ): Promise { + const startedAt = Date.now(); + const target = parseBlockTarget(dto.sender); + if (target === null) { + throw new BusinessException('VAL_001', { + context: { reason: 'malformed-mail-block-run-sender' }, + }); + } + + return this.exclusive( + this.filterRuns, + user.email.toLowerCase(), + 'mail-block-run-in-progress', + user, + async () => { + const { senders } = await this.provider.getMailBlocks(user.email); + if (!senders.some(({ address }) => address === target.value)) { + throw new BusinessException('RES_001', { + context: { reason: 'mail-block-not-found', kind: target.kind }, + }); + } + const mailboxes = await this.provider.listMailboxes(user.email); + // 스팸메일함이 없으면 잡을 것이 없다 — 어댑터가 MAIL_001 로 멈춘다 + const junk = mailboxes.find((mailbox) => mailbox.role === 'junk'); + const { rounds, ...result } = await this.runExclusive( + user, + junk ? [junk.id] : [], + () => + this.provider.runMailBlock( + user.email, + { sender: target.value, cursor: dto.cursor }, + { mailboxes, startedAt }, + ), + ); + this.logger.log( + `${traceTag()} 차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로 옮겼습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}, 확인 ${result.scanned}통 · 옮김 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`, + ); + return result; + }, + ); + } + /** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */ async openMessageSource( user: AuthenticatedUser, @@ -974,12 +1080,10 @@ export class MailService { ); } - /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */ + /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다 — send-hold.ts) */ private async undoHold(user: AuthenticatedUser): Promise { const { undoSendSeconds } = await this.userSettingsOf(user); - return undoSendSeconds > 0 - ? { kind: 'undo', seconds: undoSendSeconds } - : null; + return undoSendHold(undoSendSeconds); } /** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */ @@ -1091,15 +1195,19 @@ export class MailService { ); } - /** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */ + /** + * 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive). + * 메일함 여럿(하위까지 지우기의 나무 · 옮기기의 그 메일함과 새 상위 — 15단계)은 모두 비어 있을 때만 한꺼번에 잡는다 + */ private runExclusive( user: AuthenticatedUser, - mailboxId: string, + mailboxIds: readonly string[], operation: () => Promise, ): Promise { + const owner = user.email.toLowerCase(); return this.exclusive( this.mailboxOperations, - `${user.email.toLowerCase()}\n${mailboxId}`, + mailboxIds.map((mailboxId) => `${owner}\n${mailboxId}`), 'mailbox-operation-in-progress', user, operation, @@ -1108,25 +1216,27 @@ export class MailService { /** * 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양). + * 열쇠가 여럿이면 하나라도 잡혀 있을 때 REQ_001 이고, 모두 비었으면 await 없이 한꺼번에 잡는다(사이에 다른 요청이 끼지 않는다). * 끝나면 성공 · 실패와 상관없이 푼다 */ private async exclusive( running: Set, - key: string, + keys: string | readonly string[], reason: string, user: AuthenticatedUser, operation: () => Promise, ): Promise { - if (running.has(key)) { + const held = [...new Set(typeof keys === 'string' ? [keys] : keys)]; + if (held.some((key) => running.has(key))) { throw new BusinessException('REQ_001', { context: { reason, user: maskEmail(user.email) }, }); } - running.add(key); + for (const key of held) running.add(key); try { return await operation(); } finally { - running.delete(key); + for (const key of held) running.delete(key); } } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 3ba5a07..d9a4927 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -285,18 +285,38 @@ export interface MailboxCreate { readonly parentId: string | null; } +/** + * 메일함 바꾸기 (PATCH /api/mailboxes/{id} — 계약 2절 "메일함 관리") — 이름 · 상위(15단계 — 옮기기). 보내지 않은 칸은 그대로다. + * 서비스가 하나 이상인지 · 이름 형식을 본 값이다 + */ +export interface MailboxChange { + /** 앞뒤 공백을 뺀 1~64자, `/` 없음 — 없으면 이름은 그대로 */ + readonly name?: string; + /** 새 상위 메일함 — null 이면 맨 위, 없으면(undefined) 상위는 그대로 */ + readonly parentId?: string | null; +} + +/** 메일함 지우기의 방법 (DELETE /api/mailboxes/{id} — 15단계 "하위 메일함까지 지우기") */ +export interface MailboxDeleteOptions { + /** true 면 하위 메일함이 있어도 깊은 것부터 모두 지운다 — 없거나 false 면 하위가 있을 때 VAL_001 (지금처럼) */ + readonly withChildren?: boolean; + /** 요청이 서비스에 들어온 때(Date.now() 의 ms) — 한 요청의 시간(25초)을 이때부터 센다. 없으면 어댑터가 시작한 때부터 */ + readonly startedAt?: number; +} + /** * 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다. * 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다. + * 하위 메일함까지 지우면(15단계 — withChildren) 수는 나무 전체(그 메일함과 하위 모두)의 것이다 */ export interface MailboxDeletion { - /** true = 지웠다 */ + /** true = 지웠다 (하위까지 지울 때는 나무 전체를 지웠다) */ readonly deleted: boolean; - /** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */ + /** 이번 요청에서 이 메일함(나무)에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함(나무)에서만 뺀 것 (진행 수) */ readonly processed: number; /** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */ readonly movedToTrash: number; - /** 아직 이 메일함에 남은 메일 수 */ + /** 아직 이 메일함(나무)에 남은 메일 수 */ readonly remaining: number; } @@ -428,6 +448,18 @@ export interface MailFilterRunOutcome extends MailFilterRun { readonly rounds: number; } +/** + * 차단할 때 받은편지함 정리 (POST /api/mail-block-runs — 계약 2절 "보낸 사람 차단" · 15단계) — 서비스가 차단 목록에 있는지 본 값이다. + * 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 응답 · 서비스가 넘기는 것(MailFilterRunContext) · 어댑터의 결과(MailFilterRunOutcome)는 그것을 함께 쓴다 + * (matched 는 스팸메일함으로 옮긴 수) + */ +export interface MailBlockRunRequest { + /** 차단 목록의 값 — 소문자 주소 또는 `@도메인` (BlockedSender.address) */ + readonly sender: string; + /** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 보낸 사람의 것이면 VAL_001) */ + readonly cursor: string | null; +} + /** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */ export interface MessageSource { readonly stream: Readable; @@ -738,6 +770,18 @@ export interface SentMessage { readonly pendingSend: PendingSend | null; } +/** + * 예약 메일 지금 보내기의 결과 (POST /api/scheduled-messages/{id}/release — 15단계) — 응답은 발송 결과(SentMessage)와 같은 칸이고, + * 대용량 첨부 링크에 적을 보낸 때를 더했다 (서비스가 떼고 답한다) + */ +export interface ReleasedScheduledSend extends SentMessage { + /** + * 지금 보내기로 다시 제출한 때(ISO 8601 UTC — 이 요청이나 응답을 받지 못한 앞 요청) — 대용량 첨부 링크의 "보낸 때"로 적는다. + * 예약 시각에 이미 보낸 메일(지금 보내기가 아니다)이면 null — 링크를 그대로 둔다 + */ + readonly releasedAt: string | null; +} + /** * 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다 * (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게) diff --git a/backend/src/modules/mail/mailbox-tree.spec.ts b/backend/src/modules/mail/mailbox-tree.spec.ts new file mode 100644 index 0000000..1132295 --- /dev/null +++ b/backend/src/modules/mail/mailbox-tree.spec.ts @@ -0,0 +1,72 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + deepestFirstIds, + descendantsOf, + isSelfOrDescendant, + subtreeIds, +} from './mailbox-tree'; + +/** 거래처 › 2025 › 1분기 · 거래처 › 2026, 프로젝트(맨 위), 받은편지함 › 프로젝트 */ +const mailboxes = [ + { id: 'a', parentId: null }, + { id: 'u1', parentId: null }, + { id: 'u2', parentId: 'u1' }, + { id: 'u3', parentId: 'u2' }, + { id: 'u4', parentId: 'u1' }, + { id: 'p', parentId: undefined }, + { id: 'q', parentId: 'a' }, +]; + +describe('메일함 나무 (mailbox-tree — 15단계 옮기기 · 하위까지 지우기)', () => { + it('하위 메일함 모두 — 깊이와 함께 가까운 것부터, 그 메일함 자신은 넣지 않는다', () => { + expect( + descendantsOf(mailboxes, 'u1').map(({ mailbox, depth }) => [ + mailbox.id, + depth, + ]), + ).toEqual([ + ['u2', 1], + ['u4', 1], + ['u3', 2], + ]); + expect(descendantsOf(mailboxes, 'p')).toEqual([]); + expect(descendantsOf(mailboxes, 'nope')).toEqual([]); + }); + + it('잠글 메일함(그 메일함이 맨 앞) · 지울 차례(깊은 것부터, 그 메일함은 맨 끝)', () => { + expect(subtreeIds(mailboxes, 'u1')).toEqual(['u1', 'u2', 'u4', 'u3']); + expect(deepestFirstIds(mailboxes, 'u1')).toEqual(['u3', 'u2', 'u4', 'u1']); + expect(deepestFirstIds(mailboxes, 'u3')).toEqual(['u3']); + }); + + it('자기 자신 · 자기 하위(손주까지)인가 — 위 · 옆 메일함은 아니다', () => { + for (const candidate of ['u1', 'u2', 'u3', 'u4']) { + expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(true); + } + for (const candidate of ['a', 'p', 'q']) { + expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(false); + } + expect(isSelfOrDescendant(mailboxes, 'u2', 'u1')).toBe(false); + }); + + it('읽은 목록에 고리가 있어도 끝난다 — 한 번 본 메일함은 다시 보지 않는다', () => { + const looped = [ + { id: 'x', parentId: 'z' }, + { id: 'y', parentId: 'x' }, + { id: 'z', parentId: 'y' }, + ]; + expect(subtreeIds(looped, 'x')).toEqual(['x', 'y', 'z']); + expect(deepestFirstIds(looped, 'x')).toEqual(['z', 'y', 'x']); + }); + + it('메일함 수에 비례한다 — 깊이 3,000 의 한 줄 나무도 바로 (CPU 시간)', () => { + const chain = Array.from({ length: 3_000 }, (_, index) => ({ + id: `m${index}`, + parentId: index === 0 ? null : `m${index - 1}`, + })); + const elapsed = startCpuTimer(); + expect(deepestFirstIds(chain, 'm0')).toHaveLength(3_000); + expect(isSelfOrDescendant(chain, 'm0', 'm2999')).toBe(true); + expect(elapsed()).toBeLessThan(500); + }); +}); diff --git a/backend/src/modules/mail/mailbox-tree.ts b/backend/src/modules/mail/mailbox-tree.ts new file mode 100644 index 0000000..8766b3d --- /dev/null +++ b/backend/src/modules/mail/mailbox-tree.ts @@ -0,0 +1,95 @@ +/** + * 메일함 나무 (순수 함수 — 계약 2절 "메일함 관리"의 15단계: 옮기기 · 하위 메일함까지 지우기) — 메일함은 parentId 로 잇는다. + * 엔진의 메일함(JmapMailbox)과 도메인의 메일함(Mailbox)이 함께 쓴다 — id · parentId 만 본다. + * 엔진이 고리를 막지만(옮기기의 invalidProperties — 2026-11-09 확인) 읽은 목록에 고리가 있어도 끝나게, 한 번 본 메일함은 다시 보지 않는다. + * 메일함 수에 비례한다(부모 → 자식 표를 한 번 만든다) + */ + +/** 나무를 만드는 데 쓰는 칸 */ +interface TreeNode { + readonly id: string; + readonly parentId?: string | null; +} + +/** 하위 메일함 하나와 그 깊이 (그 메일함의 바로 아래가 1) */ +export interface MailboxTreeEntry { + readonly mailbox: T; + readonly depth: number; +} + +/** + * 그 메일함의 하위 메일함 모두 — 깊이와 함께, 위에서부터(가까운 것부터) 차례. 그 메일함 자신은 넣지 않는다. + * 그 메일함이 목록에 없어도 그 id 를 상위로 둔 메일함을 찾는다(부르는 쪽이 먼저 있는지 본다) + */ +export function descendantsOf( + mailboxes: readonly T[], + rootId: string, +): MailboxTreeEntry[] { + const children = new Map(); + for (const mailbox of mailboxes) { + const parentId = mailbox.parentId ?? null; + if (parentId === null) continue; + const siblings = children.get(parentId); + if (siblings) siblings.push(mailbox); + else children.set(parentId, [mailbox]); + } + + const seen = new Set([rootId]); + const found: MailboxTreeEntry[] = []; + let level = [rootId]; + for (let depth = 1; level.length > 0; depth += 1) { + const next: string[] = []; + for (const id of level) { + for (const child of children.get(id) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + found.push({ mailbox: child, depth }); + next.push(child.id); + } + } + level = next; + } + return found; +} + +/** 그 메일함과 하위 모두의 id — 그 메일함이 맨 앞, 하위는 가까운 것부터 (잠금 · 메일 찾기) */ +export function subtreeIds( + mailboxes: readonly T[], + rootId: string, +): string[] { + return [ + rootId, + ...descendantsOf(mailboxes, rootId).map(({ mailbox }) => mailbox.id), + ]; +} + +/** + * 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝. 엔진은 한 요청의 destroy 를 적은 차례대로 하므로(E2 — 2026-11-09 확인) + * 이 차례면 빈 나무를 요청 하나로 지운다. 깊이가 같으면 찾은 차례 그대로다 + */ +export function deepestFirstIds( + mailboxes: readonly T[], + rootId: string, +): string[] { + const descendants = descendantsOf(mailboxes, rootId); + return [ + ...[...descendants] + .sort((a, b) => b.depth - a.depth) + .map(({ mailbox }) => mailbox.id), + rootId, + ]; +} + +/** candidateId 가 그 메일함 자신이거나 그 하위인가 — 옮기기의 고리(자기 아래로 옮기기) 막기 */ +export function isSelfOrDescendant( + mailboxes: readonly T[], + rootId: string, + candidateId: string, +): boolean { + return ( + candidateId === rootId || + descendantsOf(mailboxes, rootId).some( + ({ mailbox }) => mailbox.id === candidateId, + ) + ); +} diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 51a8c7f..fa42085 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Put, + Query, UseGuards, } from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -34,8 +35,9 @@ import { import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto'; import { CreateMailboxDto, + MailboxDeleteQueryDto, MailboxMessagesPatchDto, - RenameMailboxDto, + UpdateMailboxDto, } from './dto/mailbox.dto'; import { MailIdPipe } from './mail-id.pipe'; import { @@ -51,7 +53,8 @@ import { MailService } from './mail.service'; const BULK_NOTE = '한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' + '도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' + - '같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.'; + '같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다. ' + + '차단할 때 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 도는 동안은 스팸메일함도 처리 중이다.'; /** 오래 걸리는 처리가 진행 중일 때의 안내 */ const BULK_IN_PROGRESS = @@ -108,20 +111,36 @@ export class MailboxController { @Patch(':id') @ApiOperation({ - summary: '메일함 이름 바꾸기', + summary: '메일함 이름 바꾸기 · 옮기기', description: - '기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.', + '이름(name) · 상위(parentId — 15단계 "메일함 옮기기") 가운데 바꿀 것만 보낸다 — 하나 이상(빈 본문은 400 VAL_001). parentId: null 은 맨 위다. ' + + '기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 보관함 · 내 메일함), ' + + '자기 자신 · 자기 하위 아래로는 옮길 수 없다. 하위 메일함과 안의 메일은 함께 가고 메일함 id 는 그대로다(그 메일함으로 옮기는 필터도 그대로 돈다). ' + + '이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 옮길 때(parentId 가 있을 때) 그 메일함이나 새 상위의 오래 걸리는 처리가 진행 중이면 429 REQ_001.', }) @ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' }) - @ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드') - @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') - @ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다') - rename( + @ApiErrorResponse( + 'VAL_001', + '빈 본문 · 기본 메일함 · 이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 자기 자신 · 자기 하위 아래 · 모르는 필드', + ) + @ApiErrorResponse( + 'RES_001', + '없는 메일함 · 없는 새 상위 (형식이 틀린 id 포함)', + ) + @ApiErrorResponse( + 'MAIL_009', + '새 자리(같은 상위 안)에 같은 이름의 메일함이 있다 — 대소문자는 가리지 않는다', + ) + @ApiErrorResponse( + 'REQ_001', + '옮길 때 — 그 메일함이나 새 상위의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 진행 중 — 엔진에 묻지 않았다', + ) + update( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, - @Body() dto: RenameMailboxDto, + @Body() dto: UpdateMailboxDto, ) { - return this.mail.renameMailbox(user, id, dto); + return this.mail.updateMailbox(user, id, dto); } @Delete(':id') @@ -129,13 +148,17 @@ export class MailboxController { summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다', description: '메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' + - `옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`, + '하위 메일함이 있으면 withChildren=true 일 때만 지운다(15단계) — 그 메일함과 하위 모두(나무)의 메일을 같은 규칙으로 옮긴 뒤 깊은 것부터 지운다. 수는 나무 전체의 것이다. ' + + `옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} 하위까지 지울 때는 그 메일함을 먼저 잡고 목록을 읽어 하위 메일함까지 잡는다 — 하위가 처리 중이면 목록만 읽고 429. (deleted: false 면 다시 보낸다)`, }) @ApiStandardResponse(MailboxDeletionDto, { description: - '지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)', + '지웠다(deleted: true — 하위까지면 나무 전체) · 아직 옮길 메일이 남았다(deleted: false)', }) - @ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음') + @ApiErrorResponse( + 'VAL_001', + '기본 메일함 · 하위 메일함이 있는데 withChildren=true 가 아님 · 하위에 기본 메일함이 있음 · withChildren 의 다른 값 · 모르는 쿼리', + ) @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') @ApiErrorResponse( 'MAIL_015', @@ -146,8 +169,9 @@ export class MailboxController { remove( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, + @Query() query: MailboxDeleteQueryDto, ) { - return this.mail.deleteMailbox(user, id); + return this.mail.deleteMailbox(user, id, query); } @Patch(':id/messages') diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts index 2a54fcc..506d9e0 100644 --- a/backend/src/modules/mail/scheduled-message.controller.ts +++ b/backend/src/modules/mail/scheduled-message.controller.ts @@ -2,7 +2,10 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, + Post, Query, UseGuards, } from '@nestjs/common'; @@ -22,6 +25,7 @@ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { ScheduledMessageCanceledDto, ScheduledMessagePageDto, + SentMessageDto, } from './dto/mail-response.dto'; import { MailIdPipe } from './mail-id.pipe'; import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants'; @@ -72,10 +76,54 @@ export class ScheduledMessageController { 'MAIL_014', '이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다', ) + @ApiErrorResponse( + 'REQ_001', + '같은 메일의 지금 보내기가 처리 중(15단계) — 엔진에 묻지 않았다', + ) cancel( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, ) { return this.scheduled.cancel(user, id); } + + @Post(':id/release') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '예약한 메일 지금 보내기 (15단계)', + description: + 'id 는 보낸 사본의 메일 id 다. 본문 없음. 기다리는 예약 제출을 메일 서버에서 취소한 **뒤에** 같은 메일을 그 제출의 받는 사람(envelope — 숨은참조 포함) 그대로 다시 제출한다 — ' + + '[보내기]를 누른 것처럼 그 직원의 보내기 취소 설정 초만큼 붙잡았다 보낸다(0초면 바로 — 응답 pendingSend). 예약 표시를 떼서 이제 보내기 취소를 기다리는 메일이다(취소는 DELETE /api/scheduled-messages/{id}). ' + + '대용량 첨부 링크의 보낸 때는 지금으로 바꾸고 끝나는 날은 그대로 둔다(본문에 적힌 날짜). 받는 사람별 결과는 발송과 같다(rejectedRecipients). ' + + '이미 보낸 메일(예약 시각이 막 지남)은 오류가 아니다 — 200 pendingSend: null. 이미 지금 보내기를 한 메일(다시 누름 · 응답을 받지 못한 재시도)도 그 발송의 지금 상태로 200. ' + + '메일 서버가 다시 제출을 거절하면 한 번 더 하고, 그래도 거절이면 그 메일을 임시보관함으로 되돌리고(예약 취소와 같은 자리) 503 MAIL_001. ' + + '결과를 모르게 끝나면(시간 초과 · 연결 끊김) 다시 내지도 되돌리지도 않고 503 MAIL_001 — 메일은 보낸편지함에 그대로라 같은 요청을 다시 보내면 이어 낸다 ' + + '(503 뒤에는 그 메일을 다시 읽어 임시보관함인지 보낸편지함인지로 가른다). 54초 남짓 안에 답한다(정리 몫 포함 — 앞단 · 클라이언트 60초 안).', + }) + @ApiStandardResponse(SentMessageDto, { + description: + '보냈다 · 보내기 취소를 기다린다(pendingSend.kind = undo) — 발송 응답과 같은 칸 (id 는 같은 메일 id)', + }) + @ApiErrorResponse( + 'RES_001', + '없는 메일 · 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) — 형식이 틀린 id 포함, 아무것도 바꾸지 않았다', + ) + @ApiErrorResponse( + 'MAIL_010', + '받는 사람 모두 거절(없는 주소 등) — 보내지 않고 임시보관함으로 되돌렸다', + ) + @ApiErrorResponse( + 'MAIL_011', + '받는 사람 모두 지금은 받을 수 없음 — 보내지 않고 임시보관함으로 되돌렸다', + ) + @ApiErrorResponse( + 'REQ_001', + '같은 메일의 예약 취소 · 지금 보내기가 처리 중 — 엔진에 묻지 않았다', + ) + release( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + ) { + return this.scheduled.release(user, id); + } } diff --git a/backend/src/modules/mail/scheduled-messages.service.spec.ts b/backend/src/modules/mail/scheduled-messages.service.spec.ts index b9a0624..eee9d46 100644 --- a/backend/src/modules/mail/scheduled-messages.service.spec.ts +++ b/backend/src/modules/mail/scheduled-messages.service.spec.ts @@ -1,8 +1,15 @@ +import { Logger } from '@nestjs/common'; + import { BusinessException } from '@/common/errors/business.exception'; +import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types'; import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; import type { MailLargeFilesService } from './mail-large-files.service'; -import type { MailProvider } from './mail.provider'; -import type { MessageSummary } from './mail.types'; +import { + ScheduledSendReturnedToDraftsException, + type MailProvider, +} from './mail.provider'; +import type { MessageSummary, ReleasedScheduledSend } from './mail.types'; import { ScheduledMessagesService } from './scheduled-messages.service'; const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; @@ -44,24 +51,48 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 let provider: { listScheduledMessages: jest.Mock; cancelPendingSend: jest.Mock; + releaseScheduledSend: jest.Mock; }; let service: ScheduledMessagesService; - let largeFiles: { onPendingSendCanceled: jest.Mock }; + let largeFiles: { + onPendingSendCanceled: jest.Mock; + onScheduledSendReleased: jest.Mock; + }; + let userSettings: { get: jest.Mock }; + + /** 대역 엔진의 지금 보내기 결과 — 보내기 취소를 기다린다 */ + const released: ReleasedScheduledSend = { + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, + releasedAt: '2026-11-09T05:00:00.000Z', + }; beforeEach(() => { provider = { listScheduledMessages: jest.fn().mockResolvedValue(all), cancelPendingSend: jest.fn().mockResolvedValue(undefined), + releaseScheduledSend: jest.fn().mockResolvedValue(released), }; largeFiles = { onPendingSendCanceled: jest.fn().mockResolvedValue(undefined), + onScheduledSendReleased: jest.fn().mockResolvedValue(undefined), + }; + userSettings = { + get: jest.fn().mockResolvedValue({ + ...DEFAULT_MAIL_USER_SETTINGS, + undoSendSeconds: 20, + }), }; service = new ScheduledMessagesService( provider as unknown as MailProvider, largeFiles as unknown as MailLargeFilesService, + userSettings as unknown as MailUserSettingsService, ); }); + afterEach(() => jest.restoreAllMocks()); + it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => { const first = await service.list(user, { limit: 2 }); expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']); @@ -138,4 +169,123 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 await failureOf(() => service.cancel(user, 'm2')); expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled(); }); + + describe('지금 보내기 (15단계)', () => { + it('그 직원의 보내기 취소 초만큼 붙잡아 다시 내게 하고, 발송과 같은 칸으로 답한다 — 다시 냈으면 대용량 링크의 보낸 때를 그 시각으로', async () => { + await expect(service.release(user, 'm1')).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, + }); + expect(provider.releaseScheduledSend).toHaveBeenCalledWith( + user.email, + 'm1', + { kind: 'undo', seconds: 20 }, + ); + expect(largeFiles.onScheduledSendReleased).toHaveBeenCalledWith( + user, + 'm1', + new Date('2026-11-09T05:00:00.000Z'), + ); + }); + + it('보내기 취소를 0초로 두었으면 붙잡지 않는다(null) · 설정을 읽지 못하면 처음 값(10초)으로 — 경고만', async () => { + userSettings.get.mockResolvedValueOnce({ + ...DEFAULT_MAIL_USER_SETTINGS, + undoSendSeconds: 0, + }); + await service.release(user, 'm1'); + expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith( + user.email, + 'm1', + null, + ); + + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + userSettings.get.mockRejectedValueOnce(new Error('db down')); + await service.release(user, 'm1'); + expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith( + user.email, + 'm1', + { kind: 'undo', seconds: 10 }, + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).not.toContain(user.email); + }); + + it('이미 보낸 메일(releasedAt null)은 링크를 그대로 둔다 — 응답은 pendingSend null', async () => { + provider.releaseScheduledSend.mockResolvedValueOnce({ + ...released, + pendingSend: null, + releasedAt: null, + }); + await expect(service.release(user, 'm1')).resolves.toMatchObject({ + pendingSend: null, + }); + expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled(); + }); + + it('끝내 보내지 못해 임시보관함으로 되돌렸으면 링크를 취소한 발송처럼 둔다 — 그 밖의 실패는 링크를 건드리지 않는다 (코드는 그대로)', async () => { + provider.releaseScheduledSend.mockRejectedValueOnce( + new ScheduledSendReturnedToDraftsException('MAIL_010', { + context: { reason: 'x' }, + }), + ); + expect(await failureOf(() => service.release(user, 'm1'))).toMatchObject({ + code: 'MAIL_010', + }); + expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1'); + + largeFiles.onPendingSendCanceled.mockClear(); + for (const code of ['RES_001', 'MAIL_001'] as const) { + provider.releaseScheduledSend.mockRejectedValueOnce( + new BusinessException(code, { context: { reason: 'x' } }), + ); + expect( + await failureOf(() => service.release(user, 'm1')), + ).toMatchObject({ + code, + }); + } + expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled(); + expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled(); + }); + + it('같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 REQ_001, 다른 메일은 된다, 끝나면 다시 받는다', async () => { + let finish!: (value: ReleasedScheduledSend) => void; + provider.releaseScheduledSend.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + + const running = service.release(user, 'm1'); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.releaseScheduledSend).toHaveBeenCalledTimes(1); + + for (const attempt of [ + () => service.release(user, 'm1'), + () => service.cancel(user, 'm1'), + () => + service.release({ ...user, email: 'ARA.KIM@arasystem.test' }, 'm1'), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'scheduled-send-in-progress', + }); + } + expect(provider.cancelPendingSend).not.toHaveBeenCalled(); + await expect(service.cancel(user, 'm2')).resolves.toEqual({ + draftId: 'm2', + }); + + finish(released); + await running; + await expect(service.cancel(user, 'm1')).resolves.toEqual({ + draftId: 'm1', + }); + }); + }); }); diff --git a/backend/src/modules/mail/scheduled-messages.service.ts b/backend/src/modules/mail/scheduled-messages.service.ts index 2a8e91c..a448072 100644 --- a/backend/src/modules/mail/scheduled-messages.service.ts +++ b/backend/src/modules/mail/scheduled-messages.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { @@ -7,28 +7,48 @@ import { toPaginatedResult, } from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; +import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; import { decodeOffsetCursor, encodeOffsetCursor, } from '@/shared/utils/offset-cursor'; import { MailLargeFilesService } from './mail-large-files.service'; -import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; -import type { MessageSummary } from './mail.types'; +import { + MAIL_PROVIDER, + ScheduledSendReturnedToDraftsException, + type MailProvider, +} from './mail.provider'; +import type { MessageSummary, SendHold, SentMessage } from './mail.types'; +import { undoSendHold } from './send-hold'; /** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */ const SCHEDULED_SCOPE = 'scheduled-messages'; /** - * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소. + * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록 · 취소 · 지금 보내기(15단계). * 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다). * 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다 */ @Injectable() export class ScheduledMessagesService { + private readonly logger = new Logger(ScheduledMessagesService.name); + + /** + * 진행 중인 (직원, 메일)의 취소 · 지금 보내기 — 같은 메일에는 하나씩만(종류가 달라도 막는다 — 계약 2절, 진행 중이면 429 REQ_001). + * 취소와 지금 보내기가 같은 예약 제출을 함께 다루면 한쪽이 취소한 뒤 다른 쪽이 다시 내거나 임시보관함으로 옮긴다. + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다 + */ + private readonly inProgress = new Set(); + constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, - /** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */ + /** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 · 지금 보낸 예약의 링크에 보낸 때를 (11단계 · 15단계) */ private readonly largeFiles: MailLargeFilesService, + /** 지금 보내기의 보내기 취소 초 (15단계 — [보내기]를 누른 것처럼) */ + private readonly userSettings: MailUserSettingsService, ) {} /** @@ -64,14 +84,88 @@ export class ScheduledMessagesService { /** * 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId). * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다). - * 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다) + * 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다). + * 같은 메일의 지금 보내기가 진행 중이면 REQ_001 (15단계) */ async cancel( user: AuthenticatedUser, id: string, ): Promise<{ draftId: string }> { - await this.provider.cancelPendingSend(user.email, id); - await this.largeFiles.onPendingSendCanceled(user, id); - return { draftId: id }; + return this.exclusive(user, id, async () => { + await this.provider.cancelPendingSend(user.email, id); + await this.largeFiles.onPendingSendCanceled(user, id); + return { draftId: id }; + }); + } + + /** + * 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — [보내기]를 누른 것처럼 그 직원의 보내기 취소 초만큼 붙잡았다 보낸다(0 이면 바로). + * 응답은 발송 결과(SentMessage)와 같은 칸이다 — 예약 시각이 막 지나 이미 보냈으면 pendingSend null(오류가 아니다). + * - 예약이 아닌 메일 · 없는 메일 RES_001, 다시 제출하지 못함 MAIL_001 · 받는 사람 모두 거절 MAIL_010 · MAIL_011 (어댑터 — 로그도 어댑터가 남긴다) + * - 다시 제출한 뒤 그 발송이 만든 대용량 첨부 링크의 보낸 때를 지금으로 (끝나는 때는 그대로 — 덩이가 이미 적었다) + * - 끝내 보내지 못해 임시보관함으로 **되돌렸을 때만**(ScheduledSendReturnedToDraftsException) 링크를 취소한 발송처럼 둔다(되돌린 초안이 쓴다). + * 그 밖의 MAIL_001(결과를 모름 · 남은 시간 모자람 · 되돌리기 실패)은 링크를 건드리지 않는다 — 메일은 보낸편지함에 그대로고 다시 부르면 이어 낸다 + * - 같은 메일의 취소 · 지금 보내기가 진행 중이면 REQ_001 + */ + async release(user: AuthenticatedUser, id: string): Promise { + return this.exclusive(user, id, async () => { + const hold = await this.undoHold(user); + try { + const { releasedAt, ...sent } = + await this.provider.releaseScheduledSend(user.email, id, hold); + if (releasedAt !== null) { + await this.largeFiles.onScheduledSendReleased( + user, + id, + new Date(releasedAt), + ); + } + return sent; + } catch (error) { + if (error instanceof ScheduledSendReturnedToDraftsException) { + await this.largeFiles.onPendingSendCanceled(user, id); + } + throw error; + } + }); + } + + /** + * 지금 보내기의 붙잡기 — 그 직원 설정의 보내기 취소 초(send-hold.ts). 설정을 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 — + * 발송과 같다(MailService.userSettingsOf) + */ + private async undoHold(user: AuthenticatedUser): Promise { + try { + const { undoSendSeconds } = await this.userSettings.get(user.email); + return undoSendHold(undoSendSeconds); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return undoSendHold(DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds); + } + } + + /** 같은 직원 · 같은 메일의 취소 · 지금 보내기를 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001. 끝나면 성공 · 실패와 상관없이 푼다 */ + private async exclusive( + user: AuthenticatedUser, + id: string, + operation: () => Promise, + ): Promise { + const key = `${user.email.toLowerCase()}\n${id}`; + if (this.inProgress.has(key)) { + throw new BusinessException('REQ_001', { + context: { + reason: 'scheduled-send-in-progress', + user: maskEmail(user.email), + }, + }); + } + this.inProgress.add(key); + try { + return await operation(); + } finally { + this.inProgress.delete(key); + } } } diff --git a/backend/src/modules/mail/send-hold.ts b/backend/src/modules/mail/send-hold.ts new file mode 100644 index 0000000..d13f176 --- /dev/null +++ b/backend/src/modules/mail/send-hold.ts @@ -0,0 +1,11 @@ +import type { SendHold } from './mail.types'; + +/** + * 보내기 취소를 기다리는 붙잡기 (계약 2절 "보내기 취소 · 예약 발송") — 그 직원 설정의 초, 0 이면 붙잡지 않는다(null — 바로 보낸다). + * 발송(MailService.sendMessage)과 예약 메일 지금 보내기(ScheduledMessagesService.release — 15단계)가 같이 쓴다 — [보내기]를 누른 것처럼 붙잡는다 + */ +export function undoSendHold(undoSendSeconds: number): SendHold | null { + return undoSendSeconds > 0 + ? { kind: 'undo', seconds: undoSendSeconds } + : null; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 159b1c5..1f36631 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -84,6 +84,7 @@ import { AccountLoginDisabledException, MAIL_PROVIDER, PasswordExpiredException, + ScheduledSendReturnedToDraftsException, } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { @@ -93,10 +94,13 @@ import type { DraftSource, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailBlockAdded, MailBlockList, + MailBlockRunRequest, MailFilterRule, MailFilterRunOutcome, MailFilterRunRequest, @@ -118,7 +122,9 @@ import type { PasswordChangeOutcome, PendingSend, RecipientRejectionReason, + ReleasedScheduledSend, SavedDraft, + SendHold, SentMessage, } from '@/modules/mail/mail.types'; import { @@ -326,6 +332,9 @@ const NEVER_CONFIGURED_VACATION: MailVacation = { /** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */ const ALREADY_RELEASED_MESSAGE = 'alreadyreleased'; +/** 15단계 — 대역 메일 엔진의 지금 보내기가 받는 사람을 모두 받지 않아 임시보관함으로 되돌리는 메일 id (400 MAIL_010 경로) */ +const RELEASE_REJECTED_MESSAGE = 'releaserejected'; + /** * 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로). * 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다 @@ -860,6 +869,12 @@ describe('응답 계약 (e2e)', () => { update: MessageUpdate; }[] = []; + /** 15단계 — 대역 엔진이 받은 차단 정리 · 지금 보내기, 직원마다 더한 메일함(하위 메일함이 있는 나무) · 지금 보내기를 붙잡는 문 */ + const blockRuns: { user: string; request: MailBlockRunRequest }[] = []; + const releases: { user: string; id: string; hold: SendHold | null }[] = []; + const extraMailboxes = new Map(); + let slowReleaseGate: Promise | null = null; + /** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */ const mailBlocks = new Map(); const scheduledMessages = new Map(); @@ -1588,30 +1603,57 @@ describe('응답 계약 (e2e)', () => { unreadCount: 0, }); }, - renameMailbox: ( + // 15단계 — 이름 · 상위 바꾸기. 상위의 자리 · 고리 판정은 jmap-mail.provider.spec.ts 가 본다 (여기서는 엔진의 답만 흉내 낸다) + updateMailbox: ( user: string, id: string, - name: string, + change: MailboxChange, ): Promise => { - mailboxCalls.push({ user, action: 'rename', args: [id, name] }); + mailboxCalls.push({ user, action: 'update', args: [id, change] }); + if (change.parentId === MISSING_MAILBOX) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-parent-not-found' }, + }), + ); + } + if (change.parentId === id) { + return Promise.reject( + new BusinessException('VAL_001', { + context: { reason: 'e2e-mailbox-move-into-itself' }, + }), + ); + } + if (change.name === TAKEN_MAILBOX_NAME) { + return Promise.reject( + new BusinessException('MAIL_009', { + context: { reason: 'e2e-mailbox-name-taken' }, + }), + ); + } return Promise.resolve({ id, - name, + name: change.name ?? id, role: null, - parentId: null, + parentId: change.parentId ?? null, sortOrder: 0, totalCount: 3, unreadCount: 1, }); }, - deleteMailbox: (user: string, id: string): Promise => { - mailboxCalls.push({ user, action: 'delete', args: [id] }); - return Promise.resolve({ + deleteMailbox: async ( + user: string, + id: string, + options?: MailboxDeleteOptions, + ): Promise => { + mailboxCalls.push({ user, action: 'delete', args: [id, options] }); + if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; + return { deleted: true, processed: 4, movedToTrash: 3, remaining: 0, - }); + }; }, markMailboxRead: ( user: string, @@ -1646,7 +1688,11 @@ describe('응답 계약 (e2e)', () => { inactiveScripts.delete(user); return Promise.resolve({ ...vacation, isActive: true }); }, - listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), + listMailboxes: (user: string): Promise => + Promise.resolve([ + ...E2E_MAILBOXES, + ...(extraMailboxes.get(user) ?? []), + ]), // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 getMailFilters: (user: string): Promise => { const rules = mailScripts.get(user)?.rules ?? []; @@ -1724,6 +1770,34 @@ describe('응답 계약 (e2e)', () => { rounds: 1, }; }, + // 15단계 — 차단 정리. 판정 · 차례 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 필터 적용과 같은 문으로 붙잡는다(같은 잠금 확인) + runMailBlock: async ( + user: string, + request: MailBlockRunRequest, + ): Promise => { + blockRuns.push({ user, request }); + if (request.cursor === OTHER_RULE_CURSOR) { + throw new BusinessException('VAL_001', { + context: { reason: 'e2e-mail-block-run-other-sender' }, + }); + } + if (slowFilterRunGate) await slowFilterRunGate; + return request.cursor === null + ? { + scanned: 500, + matched: 7, + remaining: 40, + cursor: 'e2eblockcursor', + rounds: 1, + } + : { + scanned: 40, + matched: 1, + remaining: 0, + cursor: null, + rounds: 1, + }; + }, openMessageSource: ( _user: string, id: string, @@ -1905,6 +1979,46 @@ describe('응답 계약 (e2e)', () => { canceledSends.push({ user, id }); return Promise.resolve(); }, + // 15단계 — 지금 보내기 (취소 → 다시 제출 · 상태 가르기 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다) + releaseScheduledSend: async ( + user: string, + id: string, + hold: SendHold | null, + ): Promise => { + releases.push({ user, id, hold }); + if (slowReleaseGate) await slowReleaseGate; + if (id === GONE_MESSAGE) { + throw new BusinessException('RES_001', { + context: { reason: 'e2e-not-a-scheduled-send' }, + }); + } + if (id === RELEASE_REJECTED_MESSAGE) { + throw new ScheduledSendReturnedToDraftsException('MAIL_010', { + context: { reason: 'e2e-release-all-recipients-rejected' }, + }); + } + if (id === ALREADY_RELEASED_MESSAGE) { + return { + id, + rejectedRecipients: [], + pendingSend: null, + releasedAt: null, + }; + } + const now = Date.now(); + return { + id, + rejectedRecipients: [], + pendingSend: + hold?.kind === 'undo' + ? { + kind: 'undo', + sendAt: new Date(now + hold.seconds * 1_000).toISOString(), + } + : null, + releasedAt: new Date(now).toISOString(), + }; + }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -2449,6 +2563,10 @@ describe('응답 계약 (e2e)', () => { ['get', '/api/mail-filters'], ['put', '/api/mail-filters'], ['post', '/api/mail-filter-runs'], + // 15단계 — 차단 정리 · 지금 보내기 (옮기기 · 하위까지 지우기는 위의 PATCH · DELETE 와 같은 경로) + ['post', '/api/mail-block-runs'], + ['post', '/api/scheduled-messages/eaaaaab/release'], + ['delete', '/api/mailboxes/u1?withChildren=true'], ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], @@ -3638,27 +3756,21 @@ describe('응답 계약 (e2e)', () => { expect(body(orphan).error?.code).toBe('RES_001'); }); - it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => { + it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 이름만 보내면 상위는 그대로 (15단계부터 상위도 받는다 — 아래 "15단계")', async () => { const res = await send('patch', '/api/mailboxes/u1', { name: ' 새 이름 ', }).expect(200); expect(dataOf<{ name: string }>(res).name).toBe('새 이름'); expect(mailboxCalls.at(-1)).toEqual({ user: ME, - action: 'rename', - args: ['u1', '새 이름'], + action: 'update', + args: ['u1', { name: '새 이름' }], }); const malformed = await send('patch', '/api/mailboxes/a%20b', { name: '새 이름', }).expect(404); expect(body(malformed).error?.code).toBe('RES_001'); - - const move = await send('patch', '/api/mailboxes/u1', { - name: '새 이름', - parentId: 'a', - }).expect(400); - expect(body(move).error?.code).toBe('VAL_001'); }); it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => { @@ -3671,7 +3783,7 @@ describe('응답 계약 (e2e)', () => { expect(mailboxCalls.at(-1)).toEqual({ user: ME, action: 'delete', - args: ['u1'], + args: ['u1', { startedAt: expect.any(Number) as number }], }); }); @@ -6201,6 +6313,31 @@ describe('응답 계약 (e2e)', () => { return { sentId, message, token, link }; }; + it('예약 메일을 지금 보내면(15단계) 그 링크의 보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다 — 덩이가 적은 날짜 (실제 DB 의 조건부 UPDATE)', async () => { + const ME = 'lf.release@e2e.arasystem.test'; + const file = await uploadReady(ME, '지금 보내기 시험', '도면.zip'); + const sendAt = new Date( + Math.floor((Date.now() + 3 * 24 * 60 * 60_000) / 1_000) * 1_000, + ).toISOString(); + const { sentId, link } = await sendAndShare(ME, [file.id], { sendAt }); + expect(link?.sentAt).toBe(sendAt); + const expiresAt = link?.expiresAt; + + const before = Date.now(); + await client( + request(server).post(`/api/scheduled-messages/${sentId}/release`), + ME, + ).expect(200); + const after = (await shareLinksOf(ME)).items.find( + (item) => item.sentEmailId === sentId, + ); + expect(Date.parse(after?.sentAt ?? '')).toBeGreaterThanOrEqual( + Math.floor(before / 1_000) * 1_000, + ); + expect(Date.parse(after?.sentAt ?? '')).toBeLessThan(Date.parse(sendAt)); + expect(after?.expiresAt).toBe(expiresAt); + }); + it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => { const id = randomUUID(); for (const res of [ @@ -11119,6 +11256,426 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절). + * 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다 + */ + describe('15단계 — 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기', () => { + /** 보내기 · 읽기 설정 — 지금 보내기의 보내기 취소 초 (9단계 "보내기 · 읽기 설정") */ + const patchSettings = (email: string, payload: object) => + request(server) + .patch('/api/mail-settings') + .set('Cookie', cookieOf(email)) + .send(payload); + + describe('메일함 옮기기 (PATCH /api/mailboxes/{id} 의 parentId)', () => { + const ME = 'move.me@e2e.arasystem.test'; + const patch = (id: string, payload: object) => + request(server) + .patch(`/api/mailboxes/${id}`) + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('parentId 로 옮긴다 — null 은 맨 위, 이름과 함께도 된다 · 보낸 칸만 엔진에 넘긴다', async () => { + const res = await patch('u1', { parentId: 'a' }).expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: 'u1', + name: 'u1', + role: null, + parentId: 'a', + sortOrder: 0, + totalCount: 3, + unreadCount: 1, + }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'update', + args: ['u1', { parentId: 'a' }], + }); + + await patch('u1', { parentId: null }).expect(200); + expect(mailboxCalls.at(-1)?.args).toEqual(['u1', { parentId: null }]); + + await patch('u1', { name: ' 2025 ', parentId: 'j' }).expect(200); + expect(mailboxCalls.at(-1)?.args).toEqual([ + 'u1', + { name: '2025', parentId: 'j' }, + ]); + }); + + it('빈 본문 · 상위 id 형식 · null 이름 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => { + const before = mailboxCalls.length; + for (const payload of [ + {}, + { parentId: '../a' }, + { parentId: 7 }, + { name: null }, + { parentId: 'a', role: 'inbox' }, + ]) { + const res = await patch('u1', payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it('엔진의 답 그대로 — 없는 상위 404 RES_001 · 자기 아래 400 VAL_001 · 같은 이름 409 MAIL_009', async () => { + const orphan = await patch('u1', { parentId: MISSING_MAILBOX }).expect( + 404, + ); + expect(body(orphan).error?.code).toBe('RES_001'); + const self = await patch('u1', { parentId: 'u1' }).expect(400); + expect(body(self).error?.code).toBe('VAL_001'); + const taken = await patch('u1', { + name: TAKEN_MAILBOX_NAME, + parentId: 'a', + }).expect(409); + expect(body(taken).error?.code).toBe('MAIL_009'); + }); + }); + + describe('하위 메일함까지 지우기 (DELETE /api/mailboxes/{id}?withChildren=true)', () => { + const ME = 'tree.me@e2e.arasystem.test'; + const CHILD = 'slowchild'; + const remove = (id: string, query = '') => + request(server) + .delete(`/api/mailboxes/${id}${query}`) + .set('Cookie', cookieOf(ME)); + + beforeAll(() => { + // 대역 엔진의 나무 — SLOW_MAILBOX › CHILD + extraMailboxes.set(ME, [ + { + id: SLOW_MAILBOX, + name: '거래처', + role: null, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + { + id: CHILD, + name: '2025', + role: null, + parentId: SLOW_MAILBOX, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + ]); + }); + + it('withChildren=true 면 하위까지 지우기를 넘기고 200 MailboxDeletion — 다른 값 · 모르는 쿼리는 400 VAL_001 (엔진에 묻지 않는다)', async () => { + const res = await remove('u1', '?withChildren=true').expect(200); + expect(body(res)).toEqual({ + success: true, + data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'delete', + args: [ + 'u1', + { withChildren: true, startedAt: expect.any(Number) as number }, + ], + }); + + const before = mailboxCalls.length; + for (const query of [ + '?withChildren=false', + '?withChildren=TRUE', + '?withChildren=1', + '?recursive=true', + ]) { + const rejected = await remove('u1', query).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it( + '나무를 지우는 동안 나무의 메일함 모두가 처리 중이다 — 하위의 전체 읽음 · 나무 안으로 옮기기는 429 REQ_001, 나무 밖은 된다', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowMailboxGate = gate.opened; + const before = mailboxCalls.length; + const first = remove(SLOW_MAILBOX, '?withChildren=true').then( + (res) => res, + ); + + try { + await waitUntil( + () => mailboxCalls.length > before, + GATE_DEADLINE_MS, + ); + for (const [method, path, payload] of [ + ['patch', `/api/mailboxes/${CHILD}/messages`, { isRead: true }], + ['delete', `/api/mailboxes/${CHILD}`, undefined], + ['patch', '/api/mailboxes/j', { parentId: CHILD }], + ] as const) { + const req = request(server) + [method](path) + .set('Cookie', cookieOf(ME)); + const busy = await ( + payload === undefined ? req : req.send(payload) + ).expect(429); + expect(body(busy).error?.code).toBe('REQ_001'); + } + expect(mailboxCalls).toHaveLength(before + 1); + + // 나무 밖은 된다 · 이름만 바꾸기는 잠그지 않는다(지금처럼) + await request(server) + .patch('/api/mailboxes/a/messages') + .set('Cookie', cookieOf(ME)) + .send({ isRead: true }) + .expect(200); + await request(server) + .patch(`/api/mailboxes/${CHILD}`) + .set('Cookie', cookieOf(ME)) + .send({ name: '새 이름' }) + .expect(200); + } finally { + gate.open(); + slowMailboxGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + + describe('차단할 때 받은편지함 정리 (POST /api/mail-block-runs)', () => { + const ME = 'blockrun.me@e2e.arasystem.test'; + const SENDER = 'kim@spam.e2e.example'; + const run = (payload: object) => + request(server) + .post('/api/mail-block-runs') + .set('Cookie', cookieOf(ME)) + .send(payload); + + beforeAll(() => { + mailBlocks.set(ME, [ + { address: SENDER, blockedAt: '2026-11-09T01:00:00.000Z' }, + { + address: '@spam.e2e.example', + blockedAt: '2026-11-09T01:00:00.000Z', + }, + ]); + }); + + it('200 { scanned, matched, remaining, cursor } — cursor 로 이어 하고 null 이면 끝 (차례 수는 응답에 없다)', async () => { + const first = await run({ sender: SENDER, cursor: null }).expect(200); + expect(body(first)).toEqual({ + success: true, + data: { + scanned: 500, + matched: 7, + remaining: 40, + cursor: 'e2eblockcursor', + }, + }); + expect(blockRuns.at(-1)).toEqual({ + user: ME, + request: { sender: SENDER, cursor: null }, + }); + + const second = await run({ + sender: '@SPAM.e2e.example', + cursor: 'e2eblockcursor', + }).expect(200); + expect(dataOf<{ cursor: string | null }>(second).cursor).toBeNull(); + // 차단 목록의 모양(소문자)으로 넘긴다 + expect(blockRuns.at(-1)?.request.sender).toBe('@spam.e2e.example'); + }); + + it('차단 목록에 없으면 404 RES_001 · 형식 · cursor 키 없음 · 모르는 필드는 400 VAL_001 · 어댑터의 다른 보낸 사람 cursor 도 400 — 받은편지함을 읽지 않는다', async () => { + const before = blockRuns.length; + const missing = await run({ + sender: 'lee@spam.e2e.example', + cursor: null, + }).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + + for (const payload of [ + { sender: SENDER }, + { sender: 'not an address', cursor: null }, + { sender: SENDER, cursor: 7 }, + { sender: SENDER, cursor: 'a b' }, + { sender: SENDER, cursor: null, mailboxId: 'a' }, + ]) { + const rejected = await run(payload).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(blockRuns).toHaveLength(before); + + const other = await run({ + sender: SENDER, + cursor: OTHER_RULE_CURSOR, + }).expect(400); + expect(body(other).error?.code).toBe('VAL_001'); + }); + + it( + '같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 429 REQ_001', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowFilterRunGate = gate.opened; + const before = blockRuns.length; + const first = run({ sender: SENDER, cursor: null }).then( + (res) => res, + ); + try { + await waitUntil(() => blockRuns.length > before, GATE_DEADLINE_MS); + const again = await run({ sender: SENDER, cursor: null }).expect( + 429, + ); + expect(body(again).error?.code).toBe('REQ_001'); + const filter = await request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookieOf(ME)) + .send({ + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + }, + cursor: null, + }) + .expect(429); + expect(body(filter).error?.code).toBe('REQ_001'); + // 차단 정리가 도는 동안 그 직원의 스팸메일함(c)도 잡혀 있다 — 비우기는 429 (엔진에 묻지 않는다) + const emptyCalls = mailboxCalls.length; + const empty = await request(server) + .delete('/api/mailboxes/c/messages') + .set('Cookie', cookieOf(ME)) + .expect(429); + expect(body(empty).error?.code).toBe('REQ_001'); + expect(mailboxCalls).toHaveLength(emptyCalls); + } finally { + gate.open(); + slowFilterRunGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + + describe('예약 메일 지금 보내기 (POST /api/scheduled-messages/{id}/release)', () => { + const ME = 'release.me@e2e.arasystem.test'; + const release = (id: string) => + request(server) + .post(`/api/scheduled-messages/${id}/release`) + .set('Cookie', cookieOf(ME)); + + it('200 — 발송 응답과 같은 칸 { id, rejectedRecipients, pendingSend }, 보내기 취소 설정 초만큼 붙잡게 넘긴다(0초면 null)', async () => { + const res = await release('escheduled1').expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: 'escheduled1', + rejectedRecipients: [], + pendingSend: { + kind: 'undo', + sendAt: expect.any(String) as string, + }, + }, + }); + expect(releases.at(-1)).toEqual({ + user: ME, + id: 'escheduled1', + hold: { kind: 'undo', seconds: 10 }, + }); + + await patchSettings(ME, { undoSendSeconds: 0 }).expect(200); + const now = await release('escheduled2').expect(200); + expect( + dataOf<{ pendingSend: PendingSend | null }>(now).pendingSend, + ).toBeNull(); + expect(releases.at(-1)?.hold).toBeNull(); + await patchSettings(ME, { undoSendSeconds: 10 }).expect(200); + }); + + it('이미 보낸 메일은 오류가 아니다 — 200 pendingSend: null', async () => { + const res = await release(ALREADY_RELEASED_MESSAGE).expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: ALREADY_RELEASED_MESSAGE, + rejectedRecipients: [], + pendingSend: null, + }, + }); + }); + + it('예약이 아닌 메일 · 형식이 틀린 id 는 404 RES_001 · 받는 사람이 모두 받지 않으면 400 MAIL_010 (임시보관함으로 되돌렸다)', async () => { + const gone = await release(GONE_MESSAGE).expect(404); + expect(body(gone).error?.code).toBe('RES_001'); + + const before = releases.length; + const malformed = await release('a"b').expect(404); + expect(body(malformed).error?.code).toBe('RES_001'); + expect(releases).toHaveLength(before); + + const rejected = await release(RELEASE_REJECTED_MESSAGE).expect(400); + expect(body(rejected).error?.code).toBe('MAIL_010'); + }); + + it( + '같은 메일의 지금 보내기 · 예약 취소는 한 번에 하나 — 진행 중이면 429 REQ_001, 다른 메일은 된다', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowReleaseGate = gate.opened; + const before = releases.length; + const first = release('eslowrelease').then((res) => res); + try { + await waitUntil(() => releases.length > before, GATE_DEADLINE_MS); + const again = await release('eslowrelease').expect(429); + expect(body(again).error?.code).toBe('REQ_001'); + const cancel = await request(server) + .delete('/api/scheduled-messages/eslowrelease') + .set('Cookie', cookieOf(ME)) + .expect(429); + expect(body(cancel).error?.code).toBe('REQ_001'); + // 다른 메일의 취소는 된다 + await request(server) + .delete('/api/scheduled-messages/eotherheld') + .set('Cookie', cookieOf(ME)) + .expect(200); + } finally { + gate.open(); + slowReleaseGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example'; -- 2.52.0 From d02a983d57f6576aa312ab26660d77cf6be5cb6e Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 9 Nov 2026 18:37:55 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(frontend):=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20=C2=B7=20=EB=81=8C?= =?UTF-8?q?=EC=96=B4=EB=8B=A4=20=EB=86=93=EA=B8=B0=20=C2=B7=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=ED=95=A8=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=A0=20=EB=95=8C=20=EB=B0=9B=EC=9D=80?= =?UTF-8?q?=ED=8E=B8=EC=A7=80=ED=95=A8=20=EC=A0=95=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EB=A9=94=EC=9D=BC=20=EC=A7=80=EA=B8=88=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만 - [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두), 같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름" - 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시 - 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님 - 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창 - 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나 - 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다 - 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장] Co-Authored-By: Claude Opus 5.5 --- .../components/mail/ConversationListItem.vue | 8 + .../mail/ConversationMessagePanel.vue | 3 + .../mail/ConversationMessageRow.vue | 123 +++- .../components/mail/ConversationTableRow.vue | 8 + .../components/mail/MailBulkMoveButton.vue | 27 +- frontend/src/components/mail/MailDragChip.vue | 82 +++ .../mail/MailListDrag.stage15.spec.ts | 422 ++++++++++++ .../src/components/mail/MailToolButton.vue | 55 +- .../components/mail/MailboxDeleteDialog.vue | 59 +- .../src/components/mail/MailboxDialogs.vue | 113 +++- .../src/components/mail/MailboxMoveDialog.vue | 371 +++++++++++ .../components/mail/MailboxPanel.move.spec.ts | 437 +++++++++++++ .../src/components/mail/MailboxPanel.spec.ts | 15 +- frontend/src/components/mail/MailboxPanel.vue | 3 +- .../src/components/mail/MailboxPanelRow.vue | 75 ++- frontend/src/components/mail/MailboxTree.vue | 13 + .../mail/MessageBlockDialog.spec.ts | 148 +++++ .../components/mail/MessageBlockDialog.vue | 160 ++++- .../mail/MessageConversation.stage15.spec.ts | 354 ++++++++++ .../components/mail/MessageConversation.vue | 74 ++- .../components/mail/MessageDeleteButton.vue | 2 + .../src/components/mail/MessageListItem.vue | 8 + .../src/components/mail/MessageMoreMenu.vue | 15 +- .../src/components/mail/MessageMoveButton.vue | 138 ++++ .../mail/MessageReader.sendnow-block.spec.ts | 606 ++++++++++++++++++ .../src/components/mail/MessageReader.spec.ts | 3 +- .../mail/MessageReader.stage15.spec.ts | 309 +++++++++ .../mail/MessageReader.stage9.spec.ts | 4 +- .../src/components/mail/MessageReader.vue | 119 +++- .../components/mail/MessageReaderNotices.vue | 89 ++- .../src/components/mail/MessageSpamButton.vue | 2 + .../src/components/mail/MessageTableRow.vue | 8 + .../src/components/mail/editor/EditorMenu.vue | 29 +- .../mail/editor/EditorPopup.spec.ts | 87 +++ .../components/mail/editor/EditorPopup.vue | 17 +- .../src/components/mail/editor/editor-menu.ts | 6 + .../src/components/mail/mail-move-menu.ts | 29 + .../src/components/mail/mailbox-dialog.ts | 2 + .../src/components/shell/AppTopBar.spec.ts | 51 ++ frontend/src/components/shell/AppTopBar.vue | 36 +- .../src/composables/useBulkMailActions.ts | 75 ++- .../useConversationPanelVisibility.ts | 71 ++ frontend/src/composables/useMail.spec.ts | 69 ++ frontend/src/composables/useMail.ts | 75 ++- frontend/src/composables/useMailDrag.ts | 169 +++++ .../src/composables/useMailFilterRun.spec.ts | 29 +- frontend/src/composables/useMailFilterRun.ts | 18 +- .../src/composables/useMailMoveFeedback.ts | 22 +- frontend/src/composables/useMailboxActions.ts | 88 ++- frontend/src/composables/useMessageRemoval.ts | 23 +- frontend/src/composables/usePopupPlacement.ts | 59 +- .../composables/useScheduledMessageActions.ts | 99 ++- frontend/src/composables/useSenderBlock.ts | 20 +- .../src/composables/useSenderBlockRun.spec.ts | 285 ++++++++ frontend/src/composables/useSenderBlockRun.ts | 125 ++++ .../src/composables/useWindowFileDrop.spec.ts | 87 +++ frontend/src/composables/useWindowFileDrop.ts | 49 +- frontend/src/pages/mail/MailLayout.vue | 8 + .../src/pages/settings/MailFilterPage.spec.ts | 19 +- .../src/pages/settings/MailFilterPage.vue | 10 +- frontend/src/shared/constants/mail-block.ts | 57 ++ .../src/shared/constants/mail-conversation.ts | 6 + frontend/src/shared/constants/mail-filter.ts | 32 +- .../src/shared/constants/mail-send-later.ts | 25 + frontend/src/shared/constants/mail.ts | 9 + .../src/shared/constants/mailbox-manage.ts | 56 +- frontend/src/shared/types/mail.ts | 42 +- frontend/src/shared/utils/mail-block.spec.ts | 38 ++ frontend/src/shared/utils/mail-block.ts | 56 +- .../shared/utils/mail-move-targets.spec.ts | 138 ++++ .../src/shared/utils/mail-move-targets.ts | 97 +++ .../shared/utils/mailbox-operation.spec.ts | 26 +- .../src/shared/utils/mailbox-operation.ts | 30 +- frontend/src/shared/utils/mailbox.spec.ts | 188 +++++- frontend/src/shared/utils/mailbox.ts | 151 ++++- .../src/shared/utils/pending-send.spec.ts | 27 + frontend/src/shared/utils/pending-send.ts | 29 + .../src/shared/utils/popup-placement.spec.ts | 41 +- frontend/src/shared/utils/popup-placement.ts | 36 +- frontend/src/stores/inbox-cleanup-run.ts | 85 +++ .../src/stores/mail-block-run.store.spec.ts | 177 +++++ frontend/src/stores/mail-block-run.store.ts | 96 +++ frontend/src/stores/mail-filter-run.store.ts | 56 +- frontend/src/stores/mail.store.ts | 28 +- frontend/src/stores/mail/conversation.ts | 130 +++- frontend/src/stores/mail/mailbox-admin.ts | 86 ++- frontend/src/stores/mail/scheduled.ts | 163 ++++- frontend/src/styles/tokens.css | 2 + frontend/src/test-utils/drag.ts | 59 ++ 89 files changed, 7105 insertions(+), 371 deletions(-) create mode 100644 frontend/src/components/mail/MailDragChip.vue create mode 100644 frontend/src/components/mail/MailListDrag.stage15.spec.ts create mode 100644 frontend/src/components/mail/MailboxMoveDialog.vue create mode 100644 frontend/src/components/mail/MailboxPanel.move.spec.ts create mode 100644 frontend/src/components/mail/MessageBlockDialog.spec.ts create mode 100644 frontend/src/components/mail/MessageConversation.stage15.spec.ts create mode 100644 frontend/src/components/mail/MessageMoveButton.vue create mode 100644 frontend/src/components/mail/MessageReader.sendnow-block.spec.ts create mode 100644 frontend/src/components/mail/MessageReader.stage15.spec.ts create mode 100644 frontend/src/components/mail/editor/EditorPopup.spec.ts create mode 100644 frontend/src/components/mail/mail-move-menu.ts create mode 100644 frontend/src/components/shell/AppTopBar.spec.ts create mode 100644 frontend/src/composables/useConversationPanelVisibility.ts create mode 100644 frontend/src/composables/useMailDrag.ts create mode 100644 frontend/src/composables/useSenderBlockRun.spec.ts create mode 100644 frontend/src/composables/useSenderBlockRun.ts create mode 100644 frontend/src/composables/useWindowFileDrop.spec.ts create mode 100644 frontend/src/shared/utils/mail-move-targets.spec.ts create mode 100644 frontend/src/shared/utils/mail-move-targets.ts create mode 100644 frontend/src/stores/inbox-cleanup-run.ts create mode 100644 frontend/src/stores/mail-block-run.store.spec.ts create mode 100644 frontend/src/stores/mail-block-run.store.ts create mode 100644 frontend/src/test-utils/drag.ts diff --git a/frontend/src/components/mail/ConversationListItem.vue b/frontend/src/components/mail/ConversationListItem.vue index 8dcdaed..d7d3901 100644 --- a/frontend/src/components/mail/ConversationListItem.vue +++ b/frontend/src/components/mail/ConversationListItem.vue @@ -5,11 +5,14 @@ // - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결) // - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다) // - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5) +// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만. +// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag) import { computed } from 'vue' import type { RouteLocationRaw } from 'vue-router' import AppIcon from '@/components/AppIcon.vue' import MailCheckbox from '@/components/mail/MailCheckbox.vue' +import { useMailRowDrag } from '@/composables/useMailDrag' import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail' import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation' import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later' @@ -55,6 +58,8 @@ const props = withDefaults(defineProps(), { const emit = defineEmits() +const drag = useMailRowDrag() + const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT) const countText = computed(() => conversationCountText(props.row)) /** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */ @@ -85,7 +90,10 @@ function onStar() { { 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking }, ]" :data-row-id="row.id" + :draggable="!held" @mousedown="onMouseDown" + @dragstart="(event: DragEvent) => drag.start(event, row.id)" + @dragend="drag.end" >

() /** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */ const panelId = computed(() => `conversation-panel-${props.row.message.id}`) +/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */ +const headReply = computed(() => { + if (!props.expanded || !props.row.replyLocation) return null + const detail = props.detail + if (detail?.status === 'error' && detail.error.code === 'RES_001') return null + return props.row.replyLocation +})