diff --git a/docs/design/webmail/BulkSelect.dc.html b/docs/design/webmail/BulkSelect.dc.html new file mode 100644 index 0000000..1e732ca --- /dev/null +++ b/docs/design/webmail/BulkSelect.dc.html @@ -0,0 +1,449 @@ + + + + +여러 통 선택 · 목록 보기 + + + + + + + +
+ + + +
+ +
+

메일

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

{{ boxName }}

+ {{ countText }} +
+ + + 분할 + + +
+ +
+
+ + +
+

{{ pickText }}

+ + + + +
+ + +
+ + + +
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
    + +
  • + + + + 안 읽음, + {{ whoHeader }} {{ item.who }} + + + {{ item.subject }} + + + 날짜 {{ item.date }} + 크기 {{ item.size }} +
  • +
    +
+
+
+
+ +
+
+ + +
+ +

{{ emptyText }}

+
+
+
+
+
+
+ + +
+
+

{{ confirmTitle }}

+

휴지통에서 지운 메일은 되돌릴 수 없습니다.

+
+ + +
+
+
+
+ + +
+ + {{ toastText }} + + + +
+
+
+
+ + + diff --git a/docs/design/webmail/ListStates.dc.html b/docs/design/webmail/ListStates.dc.html new file mode 100644 index 0000000..7679ba7 --- /dev/null +++ b/docs/design/webmail/ListStates.dc.html @@ -0,0 +1,252 @@ + + + + +새 메일 · 검색 · 여러 통 처리 상태 모음 + + + + + + + +
+ +
+

새 메일 · 검색 · 여러 통 처리

+

3단계에서 목록에 더해지는 것들입니다. 분할 보기의 검색은 「메일 검색 · 분할 보기」, 목록 보기의 일괄 처리는 「여러 통 선택 · 목록 보기」 보드에서 눌러 볼 수 있습니다. 오류 문구는 에러 코드 사전에서 가져옵니다.

+
+ +
+ +
+
+

새 메일 · 목록을 내려 본 채일 때

+

분할 · 목록 보기 공통

+
+

화면이 보이는 동안 1분마다, 탭으로 돌아왔을 때 바로 한 번 바뀐 것이 있는지 확인합니다. 새 메일은 목록 맨 위에 생깁니다. 목록을 내려 본 채라면 보던 위치는 그대로 두고 머리 아래에 알림 줄을 띄웁니다 — 누르면 맨 위로 갑니다.

+
+
+

받은편지함

+ 전체 148 · 안 읽음 5 +
+ +
+ 총무팀10월 23일 + [총무팀] 사무실 공조 설비 점검 일정 안내 +
+
+ 이도윤10월 22일 + 메일 서버 이전 일정 공유 +
+
+

맨 위를 보고 있으면 알림 줄 없이 바로 나타납니다(굵은 글자 = 안 읽음). 검색 결과는 저절로 바꾸지 않습니다.

+
+ +
+
+

탭 제목 · 메일함 숫자

+

다른 탭에서 일할 때 알아보게

+
+

받은편지함에 안 읽은 메일이 있으면 브라우저 탭 제목 앞에 그 수를 붙입니다. 메일함 패널의 숫자도 같은 확인에서 바뀝니다. 소리 · 데스크톱 알림은 하지 않습니다(브라우저 권한을 물어야 해서 다음 단계).

+
+
+
안 읽음 3통
+
(3) 메일 · ARA SYSTEM
+
+
+
모두 읽음
+
메일 · ARA SYSTEM
+
+
+
쓰기 화면
+
(3) 메일 쓰기 · ARA SYSTEM
+
+
+

확인이 실패해도 알리지 않고 다음 주기에 다시 묻습니다. 세션이 끝났으면 지금처럼 다시 로그인 창이 뜹니다.

+
+ +
+
+

분할 보기에서 여러 통 고르기

+

목록 칸 384px — 도구는 아이콘만(이름은 툴팁)

+
+

행에 마우스를 올리거나 초점이 가면 왼쪽에 고르기 칸이 나타나고, 하나라도 고르면 모든 행에 보입니다. 행을 누르면 지금처럼 메일을 엽니다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고르고, Esc 는 선택을 풉니다.

+
+
+ 2통 선택됨 + + + + + + +
+
    +
  • + + + 인사팀오전 9:15 + [인사팀] 11월 연차 사용 계획 제출 안내 + +
  • +
  • + + + 박준호어제 + 주간 인프라 점검 결과 (10/20~10/24) + +
  • +
  • + + + (주)한빛솔루션어제 + 서버 증설 견적서 송부드립니다 + +
  • +
+
+

고르기 칸은 행 왼쪽 36px(16px 상자) — 고르지 않았고 마우스도 없을 때는 칸 없이 지금 행 그대로입니다. 분할 보기에서 여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보입니다.

+
+ +
+
+

검색 결과 · 목록 보기 — 전체 메일함에서 찾았을 때

+

칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 메일함 96 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — 받은편지함 안에서 찾으면 메일함 칸이 없다(지금 표와 같다)

+
+
+
+ +

검색 결과

+ ‘회의’ · 6통 + + 찾을 곳 + + 받은편지함 + 전체 메일함 + + 휴지통 · 스팸메일함은 빼고 찾습니다 +
+
+ + + 보낸 사람 + 메일함 + 제목 + 날짜 + 크기 +
+ +
+ + + {{ r.who }} + {{ r.box }} + {{ r.subject }} + {{ r.date }} + {{ r.size }} +
+
+
+

보낸편지함 · 임시보관함의 메일은 보낸 사람 칸에 받는 사람을 「받는 사람: 이름」으로 보입니다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 씁니다. 검색 결과에서도 여러 통을 골라 처리할 수 있습니다.

+
+ +
+
+

검색어 · 찾는 방법

+

상단 검색 칸 — 메일 화면에서는 「메일 검색」

+
+

Enter 로 찾습니다(치는 동안에는 찾지 않는다). 보낸 사람 · 받는 사람 · 제목 · 본문에서 찾고, 여러 낱말이면 모두 들어간 메일만 찾습니다. ✕ 나 Esc 는 검색을 닫고 보던 메일함으로 돌아갑니다. 뒤로 가기도 같습니다.

+
    +
  • ‘회의’로 찾으면 ‘회의를’ · ‘회의는’ · ‘회의에서’ · ‘회의입니다’처럼 조사가 붙은 말도 함께 찾습니다.
  • +
  • 붙여 쓴 말의 일부(‘회의실’의 ‘회의’, ‘서버이전’의 ‘서버’)와 끝이 바뀐 말(‘보냈습니다’의 ‘보내’)은 찾지 못합니다.
  • +
  • 제목 맨 앞의 말머리([공지] · [긴급] · Re: · Fwd:)는 메일 엔진이 색인하지 않아 찾지 못합니다. 제목 가운데의 [결재] 는 찾습니다.
  • +
  • 따옴표로 감싸면 그 구절 그대로 찾습니다 — "회의를 잡겠습니다".
  • +
+
+ +
+
+

결과 알림

+

오른쪽 아래 · 되돌리기가 있는 알림은 10초

+
+

읽음 · 안 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않습니다. 옮기거나 지운 것만 알리고, 「되돌리기」는 원래 메일함으로 다시 옮깁니다.

+
+ + 메일 3통을 휴지통으로 옮겼습니다. + +
+
+ + 메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다. + +
+
+ + 메일 3통을 영구 삭제했습니다. +
+
+ +
+
+

휴지통 · 영구 삭제 확인

+

휴지통에서만 — 다른 메일함의 「삭제」는 휴지통으로 옮긴다

+
+

휴지통에서 고르면 도구 줄의 「삭제」가 「영구 삭제」가 되고, 누르면 먼저 묻습니다. 대화상자가 열리면 「취소」에 초점이 갑니다. 「이동」으로 다른 메일함에 되돌릴 수 있습니다.

+
+

메일 3통을 영구 삭제할까요?

+

휴지통에서 지운 메일은 되돌릴 수 없습니다.

+
+ + +
+
+

그사이 다른 창에서 휴지통 밖으로 되돌린 메일은 지우지 않습니다(서버가 확인한 상태 그대로일 때만 지운다).

+
+ +
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 64c262a..1e5df15 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -1,12 +1,13 @@ -# 웹메일 디자인 원본 (1·2단계) +# 웹메일 디자인 원본 (1~3단계) 확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다. -- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 19) +- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 21) - 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일) · `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부) · `States`(오류·빈 상태·피드백 모음) · 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계" + · 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계" - 파일 형식은 캔버스 전용(`` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다. `Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다. @@ -215,6 +216,52 @@ 화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다. - 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다. +## 3단계 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정 — `Search` · `BulkSelect` · `ListStates`) + +API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 통 한꺼번에". + +### 새 메일 + +- 화면이 보이는 동안 1분마다(탭으로 돌아오면 바로 한 번) 바뀐 것이 있는지 묻고, 바뀌었으면 메일함 숫자와 보이는 목록을 맞춘다 — + 고른 메일 · 열린 메일 · 스크롤 위치는 그대로다. 검색 결과는 저절로 바꾸지 않는다. 확인이 실패해도 알리지 않는다. +- 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다(굵은 글자 = 안 읽음). 목록을 내려 본 채면 머리 아래에 알림 줄 + "새 메일 N통 · 맨 위로"(높이 40 · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표)를 띄운다 — 누르면 맨 위로 가고 줄은 사라진다. +- 탭 제목: 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " — "(3) 메일 · ARA SYSTEM". 소리 · 데스크톱 알림은 하지 않는다. + +### 검색 + +- 상단 검색 칸이 메일 화면에서는 "메일 검색"이다. Enter 로 찾고, 치는 동안에는 찾지 않는다. + 검색 중인 칸은 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40 · "검색어 지우기" — Esc 와 같다). +- 결과는 보던 보기 방식(분할 · 목록) 그대로 목록 자리에 보인다. + - 머리(52): [←](40×44 · "검색 닫기") · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px #667085) · 보기 전환. + - 그 아래 범위 줄(48 · 바탕 #FBFBFC): "찾을 곳" + [그 메일함 | 전체 메일함](36 — 설정 화면의 전환과 같은 밀도 예외) + + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"(12px #667085). +- 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085), + 목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름". +- 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다. +- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 + 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기]. +- 찾는 동안은 지금의 목록 뼈대를 보인다. + +### 여러 통 처리 + +- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크. + 머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0). + 칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72). +- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다. + 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통). + 여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다. +- **도구 줄**: 하나라도 고르면 목록 머리(52)가 도구 줄로 바뀐다(바탕 #F3F8F8) — "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · + 중요 표시(고른 것이 모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", #B42318) … 오른쪽 끝 "선택 해제"(✕). + 단추는 높이 44 · 아이콘 + 이름이고, 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · `aria-label`). +- **이동 메뉴**(항목 높이 40 — 메뉴 항목 밀도 예외): 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함. +- **알림**(오른쪽 아래 · 되돌리기가 있으면 10초): "메일 N통을 보낸편지함으로 옮겼습니다." [되돌리기] · "메일 N통을 휴지통으로 옮겼습니다." [되돌리기] · + "메일 N통을 영구 삭제했습니다." · 일부만 처리했으면 뒤에 "1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." + (메일함 이름의 받침에 맞춰 '으로/로'). 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않는다. +- **영구 삭제 확인**: 휴지통에서만. 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · + [영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다. +- 메일함을 바꾸거나 검색하면 선택을 푼다. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -256,3 +303,20 @@ - 설정 화면의 미리 보기 전환 단추와 그림 크기 줄(작게 · 보통 · 원본 · 그림 빼기, 36px)은 목록의 보기 전환처럼 밀도 예외다 (캔버스 확정값). - **받는 사람 자동 완성**: Tab 은 고른 추천을 넣고 그 칸에 머문다(고른 것이 없으면 원래 Tab). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다. 초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다). + +### 3단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다) + +- **읽기 화면의 삭제**: 휴지통 밖의 메일은 휴지통으로 **옮기고**(알림 "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]), + 휴지통의 메일은 단추가 「영구 삭제」가 되어 여러 통과 같은 확인 대화상자를 거친다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하지 않게 + (웹 화면은 삭제 요청 `DELETE` 를 쓰지 않는다 — 계약 2절 "여러 통 한꺼번에"). +- **검색** + - 검색 화면의 탭 제목은 "메일 검색"이다 — "(3) 메일 검색 · ARA SYSTEM". + - 낱말이 8개를 넘으면 찾지 않고 알린다(서버 한도와 같다). 같은 검색어로 다시 Enter 를 누르면 보이는 채로 다시 찾고 고른 것을 푼다. + - 주소로 바로 들어와 검색을 시작한 곳을 모르면, 닫을 때 그 범위의 메일함으로 간다(범위가 전체면 받은편지함). + - 검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않는다(메일함에서 시작한 첫 검색만 쌓인다). + - 전체 메일함 결과에서 다른 메일함으로 옮긴 메일은 결과에 남기고 메일함 표시만 바꾼다(휴지통 · 스팸메일함으로 옮기면 뺀다). +- **여러 통 처리** + - 목록 보기의 「선택 해제」는 폭 1100 미만에서도 이름을 남긴다(분할 보기의 목록 칸에서는 아이콘만). + - 요청이 통째로 실패하면 고른 것을 그대로 둔다(다시 시도할 수 있게). 열어 둔 메일이 옮겨져 목록에서 빠지면 목록 주소로 바꾼다. + - 영구 삭제 확인은 공용 대화상자 폭(360)을 쓴다. + - 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌리고, 로그아웃 · 다른 계정 로그인 뒤에는 움직이지 않는다 (계약 2절 "알려진 한계"). diff --git a/docs/design/webmail/Search.dc.html b/docs/design/webmail/Search.dc.html new file mode 100644 index 0000000..60a2612 --- /dev/null +++ b/docs/design/webmail/Search.dc.html @@ -0,0 +1,436 @@ + + + + +메일 검색 · 분할 보기 + + + + + + + +
+ + + +
+ +
+

메일

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

검색 결과

+ {{ countText }} +
+ + + + 목록 + +
+
+ +
+ 찾을 곳 +
+ + +
+ 휴지통 · 스팸메일함은 빼고 찾습니다 +
+ + +
    + +
  • + + +
  • +
    +
+
+ + +
    + +
  • + + +
  • +
    +
+
+ + +
+ +

‘회의’에 맞는 메일이 없습니다

+

낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.

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

{{ m.subject }}

+ +
+
+ +
+
+ {{ m.fromName }} + {{ m.fromEmail }} + {{ m.date }} +
+ 받는 사람 {{ m.to }} + {{ m.boxLine }} +
+
+
{{ m.body }}
+ +
+

첨부 파일 {{ m.attCount }}개

+
    + +
  • + +
    + {{ att.name }} + {{ att.size }} +
    + +
  • +
    +
+
+
+
+
+
+ + +
+ +

메일을 선택하세요

+

검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.

+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 471dac4..82d9342 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -13,12 +13,16 @@ "MainListRead.dc.html": { "x": 3040, "y": 1020, "w": 1440, "h": 900, "title": "목록 보기 · 메일 읽기", "expand": "fill", "is_interactive": true }, "ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" }, "Settings.dc.html": { "x": 0, "y": 2480, "w": 1440, "h": 900, "title": "설정 · 메일 서명", "expand": "fill", "is_interactive": true }, - "ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" } + "ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" }, + "Search.dc.html": { "x": 0, "y": 4480, "w": 1440, "h": 900, "title": "메일 검색 · 분할 보기", "expand": "fill", "is_interactive": true }, + "BulkSelect.dc.html": { "x": 1520, "y": 4480, "w": 1440, "h": 900, "title": "여러 통 선택 · 목록 보기", "expand": "fill", "is_interactive": true }, + "ListStates.dc.html": { "x": 3040, "y": 4480, "w": 1440, "h": 1560, "title": "새 메일 · 검색 · 여러 통 처리 상태" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html"], + "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"], "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 } + "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, + "phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 9b55496..e908770 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -176,10 +176,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | --- | --- | --- | --- | | `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` | | `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` | -| `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | +| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` | +| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | | `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` | | `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` | -| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | +| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | +| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | +| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | | `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` | | `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` | | `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` | @@ -197,6 +200,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | 상황 | 응답 | | --- | --- | | 없는 `mailboxId`, 엔진이 해석하지 못하는 `mailboxId` | 404 `RES_001` | +| 엔진이 해석하지 못하는 id — 여러 통 처리의 `ids`(`PATCH /api/messages` · `POST /api/message-deletions`), 메일함 없이 하는 검색 | 400 `VAL_001` (본문에서 온 값이라서 — 메일함 하나의 목록은 위처럼 404 `RES_001`) | | 형식이 틀린 경로 id | 404 `RES_001` | | 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` | | 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) | @@ -348,7 +352,8 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string - 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다. 그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공). - **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다), - 버리기 `DELETE /api/messages/{id}`(휴지통으로), 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`. + 버리기는 휴지통으로 옮기기 `PATCH /api/messages/{id}` `{ mailboxId: <휴지통> }`(웹은 `DELETE` 를 쓰지 않는다 — 이미 휴지통인 메일은 `DELETE` 가 영구 삭제한다), + 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`. - **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤 (같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 · 확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다. @@ -430,7 +435,108 @@ interface RecipientSuggestion { 같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다. - **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤). +### 새 메일 확인 (`/api/mail-state`) — 3단계 + +```ts +// GET /api/mail-state — 새 메일 · 다른 창(메일 프로그램)의 변화를 알아채는 데 쓴다 +interface MailState { + state: string // 불투명 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다 +} +``` + +- 서버는 엔진의 메일함 · 메일 상태 문자열을 이어 붙여 돌려준다 (JMAP `Mailbox/get` · `Email/get` 에 `ids: []` — 메일을 읽지 않는 가벼운 조회). + 새 메일이 오거나 다른 창 · 메일 프로그램이 읽음 · 이동 · 삭제를 하면 값이 바뀐다 (2026-10-27 개발 엔진에서 확인). 응답은 `Cache-Control: private, no-store`. +- **클라이언트**: 로그인한 동안, 화면이 보이는 동안(`visibilityState === 'visible'`)만 60초마다 묻는다. 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번. 한 번에 하나씩. + - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 메일함 개수 · 사용량을 다시 받고, 보이는 메일함 목록을 **지금까지 불러온 만큼(최대 100통)** 다시 받아 맞춘다 — + 고른 메일 · 열린 메일 · 스크롤 위치는 그대로. 100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다. + 검색 결과는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게). + - 이 탭이 바꾼 것(읽음 · 이동 등)도 값을 바꾸므로 다음 확인에서 한 번 더 받게 된다 — 해롭지 않다. + - 실패(`MAIL_001` · 네트워크)는 알리지 않고(`silent`) 다음 주기에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 그대로 탄다. + - 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에 `(N) ` 을 붙인다 (디자인 README "3단계"). +- 요청 수 한도(`THROTTLE_LIMIT`, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때 + 1분 확인이 429 `REQ_001` 로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(`GET /api/messages` · `/api/mailboxes` · `/api/mail-quota`)는 + 평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다. 운영 전환 때 직원 수에 맞춰 정한다. +- 실시간 푸시는 이 단계에서 하지 않는다. 엔진은 JMAP EventSource 를 지원하므로(2026-10-27 확인) 1분이 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 — + 그때는 nginx · 외부 프록시의 응답 버퍼링과 시간 제한도 함께 바꿔야 한다. + +### 검색 (`GET /api/messages` 의 `q`) — 3단계 + +- `mailboxId` 가 있으면 그 메일함에서, **없으면 휴지통 · 스팸메일함을 뺀 모든 메일함**에서 찾는다. 둘 다 없으면 400 `VAL_001`. +- `q` 는 앞뒤 공백을 뺀 1~200자(JS 문자열 길이 — UTF-16 단위), 낱말(띄어쓰기로 나눈 것) 8개까지(넘으면 400 `VAL_001`). 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문에서 찾고, + 여러 낱말이면 모두 들어간 메일만 찾는다. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. + 낱말 앞뒤의 문장부호는 떼고 찾는다('회의,' · '(회의)' → '회의' — 엔진도 문장부호에서 낱말을 나눈다, 가운데의 문장부호는 둔다). + 글자 · 숫자가 하나도 없는 낱말(`-` · 따옴표뿐 등)은 찾지 않고, 찾을 낱말이 하나도 남지 않으면 엔진에 묻지 않고 빈 결과(200)다. +- **한국어 — 서버가 보완한다**: 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기 · 문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 — + '회의'로는 '회의를'을 찾지 못한다 (2026-10-27 개발 엔진에서 확인). 그래서 서버는 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다 + (회의 → 회의가 · 회의를 · 회의는 · 회의에서 · 회의입니다 … / 일정 → 일정이 · 일정을 · 일정은 …, 낱말 안에서는 OR · 낱말끼리는 AND). + 영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다. + 조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고, + 개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다. +- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'), + 끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다). + 화면은 결과가 없을 때 이 한계를 한 줄로 알린다. +- 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다. +- 검색어는 접근 로그 · 백엔드 로그에 남기지 않는다 — 백엔드는 지금처럼 `maskUrlForLog()`(`q` · `cursor` 는 길이만). 화면 주소에도 검색어가 들어가므로 nginx 는 `/api` 뿐 아니라 + **화면 요청의 접근 로그도 쿼리 없이** 남기고(Referer 도 `?` 앞까지), 화면 응답의 `Referrer-Policy` 를 `strict-origin` 으로 둔다 — + 검색 화면에서 나가는 요청(1분 확인 등)의 Referer 에 검색어가 실리지 않게. 오류 로그는 장애 원인 줄을 잃지 않게 지금처럼 요청 줄을 쿼리째 남긴다 (docker 스킬 "로그"). +- **클라이언트**: 상단 검색 칸(메일 화면에서는 "메일 검색")에서 Enter 로 찾는다 — 치는 동안에는 묻지 않는다. + 서버와 같은 한도(200자 · 낱말 8개)를 먼저 보고, 낱말이 8개를 넘으면 보내지 않고 `VAL_001` 사전 문구로 알린다. + 검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않고 주소를 바꿔 끼운다(메일함에서 시작한 첫 검색만 기록에 쌓인다). + 주소는 `/mail/search?q=…&in=<메일함 id | all>`, 결과에서 연 메일은 `/mail/search/<메일 id>?q=…&in=…` (뒤로 가기: 메일 → 결과 → 검색 전 메일함). + 범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다). + ✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다. + +### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계 + +```ts +// PATCH /api/messages — 읽음 · 중요 표시 · 이동을 여러 통에 +interface MessagesPatch { + ids: string[] // 1~100, 같은 id 를 두 번 넣지 않는다 + isRead?: boolean + isFlagged?: boolean + mailboxId?: string // 이동 = 그 메일함 하나에만 속하게 (단건 PATCH 와 같다) +} // 바꿀 것이 하나 이상 +interface MessagesPatched { + updatedIds: string[] + failedIds: string[] // 없음 · 그사이 지워짐 · 엔진이 거절함 — 나머지는 그대로 처리한다 +} + +// POST /api/message-deletions — 휴지통에서 영구 삭제, 여러 통 +interface MessageDeletionRequest { ids: string[] } // 1~100, 같은 id 를 두 번 넣지 않는다 +interface MessageDeletionResult { + deletedIds: string[] // 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다) + skippedIds: string[] // 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다 +} +``` + +- `PATCH /api/messages` 는 엔진 요청 한 번으로 처리한다. 없는 `mailboxId` 는 404 `RES_001` 이고 아무것도 바꾸지 않는다. + 503 `MAIL_001` 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등). +- `POST /api/message-deletions` 는 **휴지통에 있는 메일만** 다룬다 — `DELETE /api/messages/{id}` 와 달리 휴지통 밖의 메일을 휴지통으로 옮기지 않는다. + 대상을 확인한 뒤 확인한 상태 그대로일 때만 한 번에 지운다(`ifInState` — 그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다). + 어긋나면 다시 확인해 합계 `CONDITIONAL_DESTROY_ATTEMPTS` 번, 계속 어긋나면 **아무것도 지우지 않고** 503 `MAIL_001`. 휴지통이 없어도 503 `MAIL_001`. + 지우는 요청이 엔진 시간 초과로 끝난 503 `MAIL_001` 만은 일부가 지워졌을 수 있다 — 같은 요청을 다시 보내면 이미 지운 메일은 `skippedIds` 로 온다. + 한 번에 지우기(destroy)와 휴지통에서 빼기(update)를 함께 보내고, 옛 상태면 엔진이 둘 다 거절한다 (2026-10-27 개발 엔진에서 확인). +- 한 요청의 `ids` 는 100개까지다 (엔진의 한 번 처리 한도 500 보다 작게). +- **클라이언트**: + - 고를 수 있는 것은 불러온 메일까지다 (표 머리의 고르기 = 불러온 메일 모두). 100통이 넘으면 100통씩 차례로 보낸다. + - 휴지통 밖의 「삭제」는 `PATCH { mailboxId: 휴지통 }` 이다 — 「되돌리기」는 원래 메일함별로 묶어 다시 `PATCH`. + `POST /api/message-deletions` 는 휴지통에서 「영구 삭제」를 확인한 뒤에만 부른다. + - **웹 클라이언트는 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다. + 읽기 화면의 「삭제」도 그 메일의 `mailboxIds` 를 보고 휴지통 밖이면 휴지통으로 `PATCH`(+ 되돌리기), 휴지통이면 확인 대화상자 뒤 `POST /api/message-deletions` 다. + 휴지통 목록 밖(검색 결과 등)에서는 **휴지통에만 있는** 메일만 「영구 삭제」다 — 휴지통과 다른 메일함에 함께 있는 메일은 「삭제」(휴지통으로 옮기기)로 다룬다 + (영구 삭제를 확인했는데 서버가 휴지통에서만 빼고 다른 메일함에 남기는 일이 없게). + `mailboxIds` 가 낡았어도 해가 없다 — 이미 휴지통인 메일을 휴지통으로 옮기면 그대로이고, 휴지통 밖으로 돌아간 메일은 서버가 `skippedIds` 로 건너뛴다. + - **알려진 한계**: 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌린다 — 그사이 다른 창이 옮긴 메일(저절로 바뀌지 않는 검색 결과 등)은 그 전 메일함으로, + 여러 메일함에 함께 있던 메일(다른 메일 프로그램의 복사)은 첫 메일함으로만 돌아간다. + - 「되돌리기」 알림은 만든 세션에서만 움직인다 — 로그아웃 · 세션 만료 · 다른 계정 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다). + - 읽음 · 중요 표시는 화면을 먼저 바꾸고 `failedIds` 만 되돌린다. 이동 · 삭제는 응답을 받은 뒤 `updatedIds` · `deletedIds` 만 목록에서 빼고 개수를 다시 맞춘다 + (전체 메일함 검색 결과에서 범위 안의 다른 메일함으로 옮긴 메일은 여전히 찾은 메일이라 빼지 않고 메일함 표시만 바꾼다 — 휴지통 · 스팸메일함으로 옮기면 뺀다). + 요청이 통째로 실패해 하나도 처리하지 못하면 고른 것을 그대로 둔다(다시 시도할 수 있게). + - `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다. + - 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다. + ### 이 단계에서 하지 않는 것 -실시간 새 메일 알림(SSE), 검색 화면, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리, -여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록. +실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), +여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조, +불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.