docs: 메일 15단계(메일 · 메일함 다루기)를 계약 · 디자인 README · 스킬에 적는다

계약 2절에 메일함 옮기기 · 하위까지 지우기 · 받은편지함 정리 · 지금 보내기(결과를 모르는 실패와 알려진 한계까지),
디자인 README 에 15단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표에 새 파일과 잠금(여러 대로 늘릴 때 옮길 것).
14단계 "미룬 것"에서 15단계에 한 것(대화째 끌기 · 모두 펼치기 · 줄 머리 답장)을 뺐다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:38:11 +09:00
parent d02a983d57
commit 7f39aa4eb2
4 changed files with 296 additions and 24 deletions
+174 -2
View File
@@ -1088,8 +1088,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
### 처음 판에서 미룬 것
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다.
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
대화째 끌어다 놓기와 같은 대화 칸 머리의 [모두 펼치기] · 펼친 줄 머리의 [답장](2026-11-07 사용자 관점 점검에서 나와 미뤘던 것)은 15단계에서 했다(아래 "15단계").
## 휴대폰 앱 받기 (2026-11-08 확정 — `SettingsMobileApp`)
@@ -1164,6 +1164,114 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 서명 키가 다른 판(시험판)이 깔린 휴대폰은 새 판을 덮어 설치할 수 없다 — 그때는 앱을 지운 뒤 설치하고 로그인을 다시 한다.
- 미룬 것: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰(Ad Hoc · TestFlight), Google 개발자 인증(2027 전 세계 — 그 전에 등록할지 정한다).
## 15단계 — 메일 · 메일함 다루기 (2026-11-09 확정 — `Reader15` · `ListDrag15` · `Mailbox15`)
메일과 메일함을 옮기고 정리하는 일을 더한다 — 읽기 화면 [이동 ▾] · 끌어다 놓기 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기 · 예약 메일 [지금 보내기].
서버 약속은 계약 2절("메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")이다. 글의 원본은 화면 상수(`shared/constants/mailbox-manage.ts` · `mail-block.ts` · `mail-send-later.ts` · `mail-filter.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — 옮기기 창의 줄 40 은 메일함 패널의 줄(`--size-row-min`), [지금 보내기] 36 은 안내 줄 단추(`--size-notice-action`)를 다시 쓴다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
### 읽기 화면 · 목록 · 상단 바 (`Reader15` 1~3 · 6 · `ListDrag15`)
- **읽기 화면 [이동 ▾]**: 도구 줄 답장 · 전체 답장 · 전달 | **이동 ▾** · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다(목록 보기는 지금처럼 구분선 없이 [전달] 뒤).
메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품(항목 40) · 같은 옮길 곳이다. 그 메일이 든 메일함(검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통은 없다 — 휴지통으로는 [삭제]로 옮긴다.
보내기를 기다리는 메일(보내는 중 · 예약)에는 [스팸 신고]처럼 없다. 키보드는 목록의 [이동 ▾]와 같다(Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 [이동 ▾]으로).
- **옮긴 뒤**: [삭제]와 같은 길 — 분할 보기는 목록의 다음 메일을 열고 목록 보기는 목록으로 돌아간다. 알림 "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기](10초 — 되돌리면 "메일 1통을 받은편지함으로 되돌렸습니다.").
- **옮길 메일함은 메일함 패널처럼**: 같은 차례로 깊이만큼 들여 쓰고(10 · 28 · 44 · 56, 3단계까지) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선.
들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025"). 목록의 [이동 ▾]도 같은 모양이다.
- **그 메일이 든 메일함에 하위 메일함이 있으면** 빼지 않고 고를 수 없는 흐린 줄(aria-disabled)로 남기고 이름 아래에 "이 메일이 있는 메일함" — 하위 메일함이 누구 밑에 있는지 보이게.
하위 메일함이 없으면 지금처럼 뺀다. 목록의 [이동 ▾]는 같은 규칙에 글만 "고른 메일이 있는 메일함"이다.
- **알림의 메일함 이름**: 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다("거래처 › 2025로" — 정하는 규칙은 아래 "15단계 구현하며 정한 보완"의 메일함 나무 도우미).
목록의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림도 같다.
- **메뉴 자리**: 화면(보이는 영역) 밖으로 넘치면 반대 끝에 맞춘다 — 단추 왼쪽 끝에 맞춘 메뉴가 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에. 위아래만 바꾸던 메뉴 자리 정하기에 좌우를 더해 같은 부품의 모든 메뉴에 걸린다.
- **좁은 읽기 칸**: 도구 줄의 이름은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리)으로 숨긴다 — 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함 · 휴지통은 [영구 삭제] 때문에 595)보다 좁으면 아이콘만 40×44([이동 ▾]는 ▾ 까지).
이름은 마우스를 올리면 뜨는 글(title)과 화면 낭독(aria-label)으로 읽힌다. 분할 보기 1144~1233px 에서 두 줄로 꺾이던 일이 없고, 360 에서도 두 줄로 끝난다.
- **끌어다 놓기**(목록 → 메일함 패널): 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 든 메일(보내기를 기다리는 메일은 빼고 — [이동 ▾]와 같다),
보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다.
- 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(알림과 같은 어두운 바탕 · 12.5 굵은 흰 글자 · 봉투 · 말풍선 아이콘). 끈 줄은 흐리게 하지 않는다.
- 놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선 · 옅은 청록 바탕(쓰기 화면 파일 놓기와 같은 모양 — 초점 선과 헷갈리지 않게). 놓을 수 없는 곳(지금 메일함 · 임시보관함 · 「예약됨」)은 줄이 그대로이고 마우스 커서만 금지 모양이다.
- 놓을 곳은 [이동 ▾]의 옮길 곳 + 휴지통. 휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다).
- 마우스로만 한다 — 키보드 · 터치는 [이동 ▾]. 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않는다(첨부 놓기와 겹치지 않게). 줄의 링크가 끌어 가던 메일 주소(URL)는 싣지 않는다 — 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다.
- **스팸메일함으로 옮기는 세 길 같게**: [스팸 신고] · [이동 ▾]에서 스팸메일함 · 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮긴다. 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴 · 끌기)
[스팸 아님]과 같이 스팸 아님 표시를 붙인다 — "메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다." 휴지통으로는 표시를 바꾸지 않는다. [되돌리기]는 표시도 되돌린다.
- **메일 화면에 파일을 놓으면**: 지금은 브라우저가 그 파일을 열어 웹메일을 떠났다 — 메일 화면에서는 무시한다(놓는 동안 금지 커서). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다.
- **상단 바 좁은 폭**(360 — 바 안쪽 256): [로그아웃]은 줄지도 꺾이지도 않는다. 이름 · 주소는 넘치면 끝을 "…"로 줄이고 마우스를 올리면 다 보인다(title "김아라 (ara.kim@arasystem.it.kr)").
화면 폭 480 이하에서는 주소를 감추고 이름만 둔다. 줄 수와 차례(메일 · 검색 칸 · 계정)는 지금과 같다 — 계정 줄을 첫 줄로 올리지 않는다(Tab 차례).
- **같은 대화 [모두 펼치기]**: 칸 머리 오른쪽의 글자 단추(36 — 펼친 줄의 [답장]과 같은 크기, 안내 줄 단추 `--size-notice-action` 재사용). 불러온 줄(지금 보는 메일 빼고)을 모두 펼치고 [모두 접기]로 바뀐다 —
줄을 하나씩 다 펼쳐도 [모두 접기]이고, 하나라도 접혀 있으면 [모두 펼치기]. [더 보기]로 더 받은 줄은 접힌 채로 온다.
본문은 한꺼번에 받지 않는다 — 화면에 들어오는 줄부터 하나씩(요청 수 한도와 화면 무게), 아직 화면 밖인 줄은 받는 중과 같은 자리만 둔다.
읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이 되고 펼쳤어도 화면 밖이면 그대로다.
- **펼친 줄 머리 [답장]**: 펼친 줄 머리 오른쪽(36) — 긴 메일을 끝까지 내리지 않고 답장한다. 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀을 두고 펼침 단추와 [답장]을 나란히 놓는다(마우스를 올린 바탕은 틀 전체).
아래쪽 [답장] · [이 메일만 열기]는 그대로이고, 임시 저장본 · 예약한 메일의 줄에는 없다(지금과 같다).
### 메일함 ⋯ 「옮기기」 · 옮기기 창 (`Mailbox15` 1~4)
- **⋯ 메뉴**: 내 메일함(역할 없는 메일함 — 받은편지함 아래에 만든 메일함 포함)에만 「옮기기」. 차례는 모두 읽음으로 표시 · 하위 메일함 만들기 · 이름 바꾸기 · **옮기기** · 지우기.
창을 여는 다른 항목처럼 이름 뒤에 …를 붙이지 않는다. 메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없다.
- **옮기기 창**(모달 400): 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") → 옮길 곳 목록 → [옮기기] [취소].
- 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56 · 패널 아이콘): 「맨 위」(위 화살표 — "다른 메일함 안에 두지 않습니다") · 받은편지함(과 그 아래) · "내 메일함" 묶음.
보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 없다(하위 메일함을 만들 수 있는 곳과 같다). 메일함이 많으면 목록 칸 안에서 스크롤한다.
- 처음에는 아무 곳도 고르지 않은 채 열리고 [옮기기]는 꺼져 있다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 · 오른쪽 체크.
- 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳("지금 있는 곳" — 맨 위의 메일함이면 맨 위 줄) ·
바로 아래에 같은 이름의 메일함이 있는 곳("같은 이름의 메일함이 있습니다" — 대소문자를 가리지 않는다, 서버에 묻기 전에 화면이 하는 검사).
- 줄은 radiogroup 의 radio 다. 화살표로 줄을 옮기면 고를 수 있는 줄은 바로 고르고, Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄은 위 메일함부터 읽는다("거래처 안의 2025").
- **그사이 같은 이름이 생겼을 때**(드묾 — 409 `MAIL_009`): 목록 아래 빨간 사전 문구 "같은 이름의 메일함이 이미 있습니다." — 알림 없이 창을 둔다. 다른 곳을 고르면 사라진다.
그사이 메일함이 사라졌으면(`RES_001`) 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받는다(이름 바꾸기 창과 같다). 옮기는 동안에는 [옮기기]를 다시 누를 수 없다.
- **옮긴 뒤**: 메일함 패널에 바로 나타나고(하위 메일함과 안의 메일이 함께 간다 · 그 메일함으로 옮기는 필터도 그대로 돈다), 보던 메일함이면 그 메일함을 계속 본다.
알림 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다."(받은편지함은 따옴표 없이 "…받은편지함 안으로 …") · 맨 위로는 "‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다." + [되돌리기](원래 있던 곳으로 —
"‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.").
### 하위 메일함까지 지우기 (`Mailbox15` 5)
- 하위 메일함이 있는 메일함의 「지우기」는 지금까지의 "지울 수 없습니다" 창 대신 확인 창(처음 초점 [취소]): "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" ·
"하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다." [지우기] [취소].
메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다." 하위 메일함 수 · 메일 수는 모든 깊이를 센다.
- 확인하면 같은 지우기를 하위까지(`?withChildren=true`) 보낸다 — 진행 · 결과는 지금 메일함 지우기 그대로("‘거래처’ 메일함을 지우는 중 · 12 / 40통" → "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.").
휴지통을 비우는 동안에는 [지우기]를 누를 수 없다(지금과 같다).
### 보낸 사람 차단 창 — 받은편지함의 메일도 옮기기 (`Mailbox15` 6~7)
- 「이 메일도 스팸메일함으로 옮기기」 아래에 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」 + "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." — 창을 열 때마다 **꺼진 채** 시작한다.
「이 도메인 전체」를 고르면 "…이 도메인의 메일도 …"(그 도메인만 — 하위 도메인은 보지 않는다). 두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다. 칸 아래 글은 글 선(28)에 맞춘다.
- 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." — 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다.
이 메일이 다른 메일함에 있으면 잠그지 않는다. 스팸메일함의 메일이면 첫 칸은 지금처럼 없고 새 칸은 남는다.
- 이 탭에서 받은편지함을 정리하는 동안(필터 적용 · 다른 차단 정리)은 새 칸을 끄고(aria-disabled) 칸 아래 글을 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."로 바꾼다.
이 탭에서 스팸메일함을 비우는 동안도 끄고 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다."로 바꾼다(보드에 없다 — 아래 "15단계 구현하며 정한 보완").
- **차단한 뒤**: 새 칸을 켜지 않았으면 지금 알림 그대로(+ [되돌리기]). 켰으면 차단 알림을 따로 띄우지 않고 알림 하나가 진행을 보이다 같은 자리에서 결과로 바뀐다 — [되돌리기]는 없다(차단은 설정에서 푼다):
"‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인"(확인한 메일 / 받은편지함의 메일 수 — 첫 응답 전에는 받은편지함의 메일 수) →
"‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다." + [스팸메일함 보기] · 옮길 메일이 없으면 "…차단했습니다. 받은편지함에는 옮길 메일이 없습니다."(단추 없음).
- 멈춤: "…받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요." + [다시 시도](멈춘 자리부터 · 수는 이어 센다)
- 처음부터 실패: "…차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
- 다른 창에서 하는 중: "…차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + [다시 시도] — 차단한 뒤에는 ⋯ 메뉴가 「차단 풀기」라 다시 고를 길이 이것뿐이다.
서버의 차단 정리는 받은편지함 정리(필터 적용)뿐 아니라 스팸메일함 비우기와도 잠금을 나누어 두 곳을 함께 말한다(2026-11-09 재리뷰).
필터 적용의 429 글은 "다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." 그대로다(7단계의 "다른 창에서 필터로 …"를 이 글로 바꿨다).
- 그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 필터 적용과 이 일은 한 직원에 한 번에 하나다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓는다.
### 예약한 메일 [지금 보내기] (`Reader15` 4~5)
- 예약한 메일의 청록 안내 줄 단추가 [지금 보내기] [고치기] [예약 취소] 차례다(같은 흰 단추 36 — 누르는 동안 세 단추를 함께 막는다). 예약됨 목록 줄 · 휴대폰 앱에는 두지 않는다.
- 누르면 [보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 기다렸다 보내고, 그동안 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(9단계 마감 알림 그대로). 확인 창은 두지 않는다.
끔으로 두었거나 예약 시각이 막 지나 이미 보낸 메일이면 오류가 아니라 "메일을 보냈습니다." + [보낸편지함 보기]. 받는 사람 일부가 받지 못하면 쓰기 화면과 같은 문장이다.
- 알림의 [보내기 취소]는 지금의 [보내기 취소]와 같다(그 메일을 쓰기 화면으로 — 다시 예약하려면 [보내기 ▾] → [예약 발송…]).
- 보낸 메일은 예약됨에서 빠지므로 분할 보기는 다음 예약 메일을 열고 목록 보기는 예약됨 목록으로 간다([예약 취소]와 같다). 예약됨 수를 다시 맞춘다. 보낸편지함에서 연 메일이면 그 자리에 두고 안내 줄만 사라진다(날짜 칸은 보내는 동안 "보내는 중").
- 예약 시각보다 먼저 보내므로 대용량 첨부 링크의 기간은 실제로 보낸 날부터 센다(서버).
- **보내지 못했거나 결과를 모를 때**(서버가 503 `MAIL_001` 로 답함 — 결과를 모르는 실패와 엔진이 거절해 임시보관함으로 되돌린 실패가 같은 꼴이다.
앞단의 502 · 504 · 시간 초과 · 연결 끊김(`SYS_001`)도 결과를 모르는 실패라 같은 갈래다): 사전 문구 알림은 끄고, 화면이 그 메일을 다시 받아 붙잡은 발송과 메일함으로 가른다(2026-11-09 15단계 리뷰 · 재리뷰).
- 임시보관함: "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] — 예약됨을 떠났으니 안내 줄을 내리고 이동은 [예약 취소]와 같다.
- 예약이 그대로(예약을 거두기 전에 멈췄다 — 예약한 시각에 나간다): "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도](같은 요청 — 같은 메일은 요청 하나로 묶는다). 안내 줄은 그대로 둔다.
- 예약만 풀렸다(보낸편지함에 있고 기다리는 발송이 없다 — 예약한 시각에 따로 나가지는 않는다): "메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다." + [다시 시도].
이미 나갔을 수 있어(보내기 취소 0초로 바로 나감 · 응답만 잃음 · 그사이 예약 시각이 됨) 못 보냈다고 단정하지 않는다 — 그 글을 믿고 손으로 다시 보내면 두 통이 간다(2026-11-09 마지막 확인).
[다시 시도]는 같은 요청이라 안전하다(이미 보낸 메일이면 서버가 200 으로 답한다). 예약이 아닌 것은 확인했으니 "…에 보낼 메일입니다." 안내 줄은 내린다 — 예약됨 · 보낸편지함 · 검색 어디서 열었든.
예약됨에서는 빠지므로 이동은 [예약 취소]와 같고, 보낸편지함 · 검색에서 연 메일은 그 자리에 둔다.
- 다시 낸 발송이 보내기 취소를 기다린다: 보낸 것과 같다("메일을 보냈습니다." + [보내기 취소]).
- 그 메일을 다시 받지 못했다: 위와 같은 "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도] — 보냈다 · 못 보냈다 · 되돌렸다고 말하지 않는다(닫힌 쪽).
안내 줄은 마지막으로 안 상태 그대로 두고, 다음 다시 맞추기에서 바로잡힌다.
- 예약됨 수와 목록은 어느 쪽이든 다시 맞춘다. 받는 사람이 모두 받지 않으면(400 `MAIL_010` · 503 `MAIL_011` — 그 메일은 임시보관함에) 사전 문구 그대로다.
- **알려진 한계**: 드물게 [다시 시도]를 누르지 않고 알림을 닫으면 그 메일은 보내지 않은 채 보낸편지함에 남는다 — 9단계 예약 취소와 같은 한계다.
### 처음 판에서 미룬 것
메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기].
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1574,3 +1682,67 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- **앱 그림**의 모서리는 한 변의 1/4 이다(48 은 12, 52 는 13 — 보드 값).
- **폭 기준**: 설정의 QR 줄을 빼는 좁은 폭은 480 이하(공개 내려받기 화면의 좁은 폭과 같은 값), `/app` 의 넓은 화면은 그 반대(481 이상)다.
- 글자 크기 17(`/app` 의 앱 이름 · 받을 앱이 없을 때의 제목)은 토큰 `--font-size-title-md` 를 더했다(크기 · 밀도 예외 토큰은 아니다).
### 15단계 구현하며 정한 보완 (2026-11-09 — 캔버스에는 아직 없다)
메일함 · 차단 · 예약 쪽을 먼저, 읽기 화면 · 목록 · 상단 바 쪽을 뒤에 적는다.
- **메일함 나무 도우미**(`shared/utils/mailbox.ts`): 패널 차례로 편 나무 `mailboxTree`(깊이 · 위 메일함 · 하위가 있는지 · 묶음), 화면 낭독 이름 `mailboxSpokenName`("거래처 안의 2025" —
더 깊으면 "거래처 안의 2025 안의 정산"), 알림 이름 `mailboxDisplayName`(같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" — 위 메일함까지 같으면 가를 수 있을 때까지 올라간다, 맨 위의 메일함은 이름 그대로).
[이동 ▾] · 끌어다 놓기 · 옮기기 창이 함께 쓴다. 알림의 이름은 글자 모양으로 견주고(NFC — 대소문자는 가린다), 같은 자리 겹침은 대소문자를 가리지 않는다(엔진과 같다).
- **옮기기 창**
- 옮길 곳은 ⋯ 「하위 메일함 만들기」가 있는 메일함(받은편지함 · 내 메일함)과 맨 위다. 하위를 둘 수 없는 메일함(보낸편지함 등 · 보관함) 아래의 메일함은 그 위까지 모두 둘 수 있는 곳일 때만 넣는다 —
다른 메일 프로그램이 보낸편지함 아래에 만든 메일함은 옮길 곳에 없다.
- 화살표는 꺼진 줄에도 초점을 옮긴다(까닭을 읽게 — 고른 것은 그대로). 처음과 끝에서 돌고, Home · End 도 된다. 처음 초점은 고를 수 있는 첫 줄이다(로빙 tabindex).
- 맨 위에서 옮긴 것을 되돌린 글은 "‘2025’ 메일함을 다른 메일함 밖으로 되돌렸습니다."(보드에 없다 — 맨 위로 옮긴 글과 같은 결). [되돌리기]가 실패하면(그사이 원래 자리에 같은 이름 · 원래 상위가 지워짐) 사전 문구 알림만.
- 옮길 수 없는 자리였으면(400 `VAL_001` — 그사이 나무가 바뀜) 알림을 본 뒤 메일함 목록을 다시 받고 창은 둔다(다시 고를 수 있게). `REQ_001`(그 메일함을 정리하는 중) 등도 알림만 · 창은 그대로.
- 옮기는 동안 [옮기기]는 `aria-disabled` + `aria-busy`(누른 단추가 초점을 잃지 않게), 고르기 전에는 `disabled`.
- **하위까지 지우기**: 지우는 동안 나무의 모든 메일함(하위 포함)을 "지우는 중"으로 둔다 — 그 메뉴들이 꺼지고, 나무 안의 메일함을 따로 지우기 시작하지 않는다.
하위 메일함을 보고 있었어도 끝나면 받은편지함으로 간다. 하위가 없는 메일함은 지금처럼 `withChildren` 없이 보낸다.
- **차단할 때 받은 메일 옮기기**
- 「이 메일도 …」가 켜져 있으면 그 메일을 먼저 옮긴 뒤(지금 길 그대로 — 읽기 화면은 삭제처럼 바로 다음 메일로) 받은편지함 정리를 시작한다. 그 메일이 받은편지함에 있었으면 옮긴 수에 넣는다.
- 이 탭에서 받은편지함을 정리하는 중에 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](보드에 없다 — 필터 적용의 busy 와 같은 결).
- 그사이 차단을 풀었으면(404 `RES_001`) 진행 알림을 닫고 사전 문구만 보인다.
- 끝났거나 나아간 뒤 멈췄으면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다(필터 적용과 같다).
- 되풀이(cursor · 25초 · 나아가지 못한 응답 3번)는 필터 적용과 함께 쓴다(`stores/inbox-cleanup-run.ts`).
- 이 탭에서 받은편지함 정리(필터 적용 · 차단 정리)가 하나라도 도는 동안은 필터 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」도 끈다 — 차단 창의 새 칸과 같은 규칙(`inboxBusy`)이다.
두 칸의 까닭 글은 같은 공용 글 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."(Mailbox15 보드의 차단 칸 글)이고, 7단계 칸 글 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다."도
이 글로 바꿨다 — 칸을 끈 까닭이 필터가 아니라 차단 정리일 수도 있어서다(메인이 정함).
같은 까닭으로 필터 적용 알림의 [다시 시도]도 이 탭의 차단 정리를 보고(`inboxBusy`), 그때 글은 공용 글 "받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."다(7단계 "다른 필터로 …"에서 바꿨다).
- 이 탭에서 차단 정리가 도는 동안은 스팸메일함 비우기를 시작할 수 없다(메뉴 · 목록 머리 [비우기] · 확인 단추가 같은 판정 — 휴지통 비우기와 메일함 지우기를 함께 돌리지 않는 규칙과 같은 결).
옮겨 온 메일이 들어오자마자 영구 삭제되지 않게다. 반대로 이 탭에서 스팸메일함을 비우는 동안은 차단 창의 새 칸을 끄고(까닭 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.") 차단 정리를 시작하지 않는다 —
알림의 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](2026-11-09 재리뷰).
필터 적용은 스팸메일함에 넣지 않으므로 막지 않는다(`inboxBusy` 는 그대로이고 차단 정리만 `mail-block-run.store` 의 `startBlock` 을 본다).
다른 창과는 서버가 요청마다 스팸메일함 처리 잠금을 잡아 겹친 요청을 429 로 막지만, 잠금은 요청 하나씩이라 두 창의 비우기와 차단 정리가 요청 사이에 번갈아 돌 수 있다 —
차단 정리가 옮겨 넣은 메일을 다른 창의 다음 비우기 요청이 지울 수 있다(알려진 한계 — 다른 창끼리의 휴지통 비우기와 메일함 지우기와 같다, 계약 2절 "보낸 사람 차단").
- **지금 보내기**
- 서버가 혹시 409 `MAIL_014`(이미 보냄)로 답해도 보낸 것으로 다룬다("메일을 보냈습니다." — 약속은 200 이다).
- 503 `MAIL_001` · `SYS_001` 의 갈래(임시보관함 · 예약 그대로 · 예약만 풀림 · 다시 낸 발송이 기다림 · 다시 받지 못함)는 위 "예약한 메일 [지금 보내기]"에 적었다 — 사전 문구 알림은 끄고 화면 글 하나로 알린다.
판정은 `shared/utils/pending-send.ts` 의 `unsentReleaseState` 하나다(임시보관함을 먼저 보고, 다음으로 기다리는 발송의 종류). 다시 낸 발송이 보내기 취소를 기다리는 갈래는 리뷰에 없던 것이라 구현하며 정했다 —
같은 요청을 다시 보내도 서버가 그 상태로 200 을 주므로 보낸 것과 같게 알린다(받는 사람별 결과는 모르니 일부가 받지 못한 문장은 없다).
받는 사람이 모두 받지 않았거나(400 `MAIL_010` · 503 `MAIL_011`) 예약이 그사이 사라졌으면(404) 사전 문구 알림만 보이고 그 자리에 두며, 예약 표시 · 예약됨 수 · 보이는 목록을 서버 기준으로 다시 맞춘다.
- 보내기 취소 알림의 마감은 쓰기 화면과 같은 규칙이다(설정한 초를 알면 "지금 + 초 − 1초", 모르면 보낼 시각까지 · 30초를 넘지 않게 — `undoToastDeadline`).
- 안내 줄이 좁으면(글이 12rem 보다 좁아질 때) 단추 셋을 묶음째 다음 줄 오른쪽으로 보낸다 — 단추가 셋이 되며 360 폭에서 글이 한 글자 폭으로 눌리지 않게(보드는 넓은 폭만 그렸다).
**읽기 화면 · 목록 · 상단 바**
- **좁은 읽기 칸의 기준(보드에 없는 것)**: 컨테이너는 도구 줄 띠(`.reader__toolbar-bar` — 칸 전체 폭)다. 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 이름이 다 보이는 줄이 800(콘텐츠 열)보다 조금 넓다 —
그래서 목록 보기만 단추 줄의 최대 폭을 풀고 접는 폭을 850 · 휴지통 875 로 두었다(570 실측에 [목록으로] · 이전/다음 · 위치 글을 더한 어림 — 브라우저에서 다시 잰다). 화면 폭 기준은 tokens.css 머리에 한 줄씩 적었다.
- **메뉴 폭**: 옮길 메일함 메뉴는 이름 길이만큼(200 부터 320 까지 — 좁은 화면은 화면 폭 − 80 까지) 넓어지고 그보다 긴 이름은 줄을 바꾼다(보드는 줄을 바꾸지 않는 짧은 이름만 그렸다).
- **좌우 바꾸기**: 오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘칠 때도 같은 규칙으로 단추 왼쪽 끝에 맞춘다(360 에서 ⋯ 가 둘째 줄 맨 앞으로 내려간다). 양쪽 다 넘치면 덜 넘치는 쪽이다(위아래와 같은 결).
- **흐린 줄 더**: 하위 메일함이 있는 휴지통 · 임시보관함(다른 메일 프로그램이 지운 폴더를 휴지통 아래에 둔다)도 까닭 글 없이 흐린 줄로 남겨 그 하위 메일함을 고를 수 있게 한다.
흐린 줄 아래에 보일 하위 메일함이 하나도 없으면 그 줄도 뺀다.
- **전체 메일함 검색에서 [이동 ▾] · 끌기**: 고른 메일이 모두 든 메일함만 "메일이 있는 메일함"으로 친다(메일마다 다르면 아무 데나 옮길 수 있다 — 지금과 같다).
- **끌어다 놓기**
- 줄 전체가 끌린다(링크만이 아니라) — 목록 보기 표의 글은 마우스로 끌어 고를 수 없게 된다(두 번 눌러 고르기는 된다).
- 끄는 표는 메일 화면에 늘 그려 두는 화면 밖 요소를 브라우저가 찍은 것이다(setDragImage) — 운영체제 · 브라우저마다 투명도 · 커서 모양이 조금 다르다.
- 이 탭에서 시작한 끌기만 받는다(다른 탭 · 바깥에서 끌어 온 같은 꼴의 자료는 놓을 수 없다).
- 열어 둔 메일을 끌어 옮겼으면 목록 도구 줄로 옮길 때처럼 목록 주소로 바꿔 끼운다.
- 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지는 브라우저가 한다 — 브라우저에서 확인한다.
- **메일 화면 파일 놓기 무시**: 쓰기 화면도 메일 화면 안이라 두 창 처리기가 함께 걸린다 — 파일을 받는 화면(쓰기 화면 · 서명 설정)이 떠 있으면 무시하는 쪽이 아무것도 하지 않는다(어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면이 받는다).
- **상단 바**: 줄어든 이름 · 주소의 title 은 이름 · 주소 칸 전체에 둔다(넓은 폭에서 주소가 줄어도 같은 글).
- **같은 대화 [모두 펼치기]**
- 손으로 펼친 줄은 지금처럼 누른 그 자리에서 본문을 받고 받는 대로 읽음이다. [모두 펼치기]로 펼친 줄만 화면에 들어올 때 차례로 받는다 — 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛰고 다시 들어오면 다시 줄을 선다.
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
- 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다.
- 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다).
+107 -11
View File
@@ -411,8 +411,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| --- | --- | --- | --- |
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name?, parentId? }` — 하나 이상(`parentId` 는 15단계 "메일함 옮기기" — `null` 은 맨 위) | 200 `Mailbox` | 400 `VAL_001`(빈 본문 · 기본 메일함 · 만들 수 없는 자리 · 자기 자신 · 자기 하위 아래), 404 `RES_001`(없는 메일함 · 없는 새 상위), 409 `MAIL_009`, 429 `REQ_001`(옮길 때 — 그 메일함 · 새 상위의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
@@ -446,13 +446,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 429 `REQ_001`(같은 메일의 지금 보내기가 진행 중 — 15단계), 503 `MAIL_001` |
| `POST /api/scheduled-messages/{id}/release` | — 본문 없음(`id` 는 보낸 사본의 메일 id) | 200 `ReleasedSend` — 예약한 메일을 지금 보낸다(15단계, 아래 "보내기 취소 · 예약 발송"의 "지금 보내기"). 이미 보냈으면 200 `pendingSend: null` | 400 `MAIL_010`(받는 사람 모두 거절 — 임시보관함으로 되돌렸다), 404 `RES_001`(예약이 아닌 메일 · 형식이 틀린 id 포함), 429 `REQ_001`(같은 메일의 예약 취소 · 지금 보내기가 진행 중), 503 `MAIL_001`(임시보관함으로 되돌렸거나 · 결과를 몰라 보낸편지함에 그대로 — 그 메일을 다시 읽어 가른다) · `MAIL_011` |
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
| `PATCH /api/mail-settings` | `Partial<MailSettings>` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` |
| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` |
| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` |
| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` |
| `POST /api/mail-block-runs` | `{ sender, cursor }` — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로(15단계, 아래 "보낸 사람 차단"의 "받은편지함 정리") | 200 `MailBlockRun` | 400 `VAL_001`(형식 · 깨진 cursor · 다른 보낸 사람의 cursor), 404 `RES_001`(차단 목록에 없음), 429 `REQ_001`(같은 직원의 받은편지함 정리(필터 적용 포함) · 스팸메일함 비우기가 진행 중), 503 `MAIL_001`(스팸메일함 없음 포함) |
표에 없는 실패 (구현에서 확정):
@@ -1082,12 +1084,12 @@ interface MessageDeletionResult {
```ts
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxUpdate { name?: string; parentId?: string | null } // 하나 이상 — 보내지 않은 칸은 그대로. parentId: null = 맨 위 (15단계 — 4단계의 MailboxRename { name } 을 넓혔다)
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
deleted: boolean // true = 지웠다(하위까지면 나무 전체). false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함(하위까지면 나무)에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
remaining: number // 아직 남은 메일 수 (하위까지면 나무 전체에 — 여러 메일함에 든 메일도 한 번)
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
@@ -1102,14 +1104,35 @@ interface MailboxBulkResult {
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다 — 15단계부터는 아래 "하위 메일함까지 지우기"). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
- **옮기기**(15단계 — `PATCH /api/mailboxes/{id}` 의 `parentId`): `{ name?, parentId? }` 가운데 바꿀 것만 보낸다 — 둘 다 없으면 400 `VAL_001`, `parentId: null` 은 맨 위.
- 새 상위는 **만들기와 같은 자리**다 — 맨 위 · 받은편지함 · 보관함 · 내 메일함 아래(그 밖은 400 `VAL_001`, 없는 상위는 404 `RES_001`). 기본 메일함은 옮길 수 없다(400 `VAL_001`).
- 자기 자신 · 자기 하위(손주까지) 아래로는 400 `VAL_001` — 서버가 엔진에 묻기 전에 메일함 목록으로 본다. 엔진도 고리를 `invalidProperties`(parentId)로 막는다(2026-11-09 개발 엔진 확인) —
그 거절이 오면 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 400 `VAL_001`(그사이 생긴 고리), 없으면 404 `RES_001`(그사이 지워짐)이다.
- 새 자리에 같은 이름이 있으면 409 `MAIL_009` — **엔진은 대소문자를 가리지 않는다**(`p15-자식` 도 `P15-자식` 과 겹친다 — 같은 날 확인). 화면은 같은 이름이 있는 곳을 처음부터 고르지 못하게 한다(같은 판정).
- 하위 메일함과 안의 메일은 함께 간다 — 메일함 id 는 그대로라 그 메일함으로 옮기는 필터도 그대로 돈다. 깊이 한도는 두지 않는다(만들기와 같다 — 엔진은 15칸까지 막지 않았다).
- 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 200(그대로). 응답은 지금처럼 `Mailbox`(새 `parentId`).
- 옮길 때(`parentId` 를 보낼 때) 그 메일함이나 새 상위의 오래 걸리는 처리(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001` — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게. 이름만 바꾸기는 지금처럼 잡지 않는다.
- 로그: 실제로 옮겼을 때만, 메일함 id 와 새 상위 id 만(이름 없이 — 사용자가 정한 글이다).
- **하위 메일함까지 지우기**(15단계 — `DELETE /api/mailboxes/{id}?withChildren=true`): `withChildren=true` 일 때만 하위가 있어도 지운다 — 없으면 지금처럼 400 `VAL_001`(옛 화면이 모르고 하위를 지우지 않게).
값은 `true` 만 받는다(그 밖 · 모르는 쿼리는 400 `VAL_001`).
- 나무(그 메일함과 하위 모두)를 지금 지우기와 같은 규칙으로 — 나무의 메일을 한 조회(`inMailbox` 의 OR — 엔진이 겹치지 않게 센다)로 500통씩 읽어, 나무 안에만 있는 메일은 휴지통으로,
나무 밖의 메일함에도 있는 메일은 나무의 메일함에서만 뺀다(확인한 상태 그대로일 때만 — `ifInState`). 붙잡힌 메일은 그대로 둔다(9단계 — 남은 것이 그것뿐이면 `MAIL_015`).
- 비었으면 빈 메일함을 **깊은 것부터 한 요청에** 지운다(`onDestroyRemoveEmails: false`) — 엔진은 한 요청의 destroy 를 적은 차례대로 한다(2026-11-09 확인 — 위에서부터 적으면 맨 아래 하나만 지워진다).
그사이 메일이 들어와 거절되면 지운 하위는 나무에서 빼고 남은 나무의 메일을 다시 옮긴다. 그사이 나무 밖의 하위 메일함이 생기면(`mailboxHasChild`) 목록을 다시 읽어 그 메일함까지 지운다.
- 하위에 기본 메일함(역할이 있는 메일함 — 다른 프로그램이 만든 이상한 나무)이 있으면 400 `VAL_001`.
- 응답은 지금 `MailboxDeletion` 그대로 — 수는 나무 전체의 것이다(`deleted: true` 까지 다시 보낸다). 그 밖의 오류는 지금 지우기와 같다.
- 잠금: 나무의 메일함 모두(아래 "같은 직원 · 같은 메일함의 처리는 한 번에 하나") — 서버는 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 429 `REQ_001`)
메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 429 — 아무것도 바꾸지 않는다). 휴지통 비우기와 함께 돌지 않는 것은 지금처럼 화면이 막는다.
- **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만,
500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙).
- 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다.
클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
하위까지 지우기는 나무의 메일함 모두를(하위는 목록을 읽은 뒤), 옮기기는 그 메일함과 새 상위를 함께 잡는다(15단계 — 하나라도 처리 중이면 429).
차단할 때 받은편지함 정리(15단계 — 아래 "보낸 사람 차단")가 도는 동안은 그 직원의 스팸메일함도 잡혀 있다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429.
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
@@ -1480,6 +1503,45 @@ interface PendingSend {
- 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다).
- 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` —
그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
- 같은 메일의 지금 보내기(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(15단계).
- **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, `id` 는 보낸 사본의 메일 id, 본문 없음): 예약한 메일을 [보내기]를 누른 것처럼 보낸다.
엔진 사실(2026-11-09 개발 엔진): 기다리는 예약 제출을 취소(`undoStatus: canceled`)하고 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 —
취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 `cannotUnsend`). 이미 보냈거나 이미 취소한 제출을 취소해도 `cannotUnsend` 라 서버는 제출의 `undoStatus` 를 다시 읽어 가른다.
```ts
interface ReleasedSend { // 발송 응답(SentMessage)과 같은 칸
id: string // 같은 메일 id (보낸 사본)
rejectedRecipients: RejectedRecipient[] // 받는 사람별 결과 — 발송과 같은 규칙(아래 "받는 사람별 결과")
pendingSend: PendingSend | null // { kind: 'undo', sendAt } — 보내기 취소를 기다린다 · null — 바로 보냈다 · 이미 보냈다
}
```
- 차례: 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽고 → 다시 낼 envelope 을 먼저 정한 뒤(읽지 못하면 취소하지 않고 503 `MAIL_001`) →
기다리는 예약 제출을 취소한다(엔진이 취소를 확인한 것만 — 확인이 없거나 결과를 모르면 다시 읽어 취소됐을 때만 잇고, 아직 기다리면 다시 내지 않고 503 `MAIL_001`) →
**다른 요청으로** 같은 메일을 그 제출의 envelope 그대로(받는 사람 · 참조 · 숨은참조 · envelope 에서 뺀 주소까지 원래 제출의 것 — 머리글에서 다시 만들지 않는다) 다시 제출한다.
MAIL FROM 의 붙잡기 매개변수만 바꾼다 — 그 직원의 보내기 취소 설정(`undoSendSeconds`)만큼 `HOLDFOR`(0 이면 붙잡지 않는다 — 설정을 읽지 못하면 처음 값 10초).
성공하면 같은 요청에서 예약 표시(`$ara-scheduled`)를 뗀다 — 이제 보내기 취소를 기다리는 메일이다(취소는 지금처럼 `DELETE` — 쓰기 화면으로).
- **이미 보낸 메일**(예약 시각이 막 지나 제출이 `final` · 보낼 시각이 지나 배달 중 · 취소하려니 엔진이 `cannotUnsend`)은 오류가 아니다 → 200 `pendingSend: null`(보낸 것으로).
- **이미 지금 보내기를 한 메일**(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)도 다시 내지 않고 그 새 제출의 지금 상태로 200(기다리면 `pendingSend: { kind: 'undo' }`).
- 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일(`$ara-canceled`) · 제출이 없음 · 예약하지 않고 보낸 메일) · 없는 메일 · 형식이 틀린 id 는 404 `RES_001` — 아무것도 바꾸지 않는다.
- 앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시가 있고 취소 표시는 없는데 제출이 모두 `canceled`) 취소를 건너뛰고 다시 제출만 한다 — 보내지지도 예약돼 있지도 않은 메일이 남지 않게(구현하며 정함).
- 다시 제출이 실패하면 둘로 가른다(처음 발송과 같은 결 — 위 "발송 본문"의 "엔진이 받지 않는 주소"에서 요청째 거절과 결과를 모르는 실패를 가르는 것과 같다):
- **엔진이 분명히 거절했다**(SetError · 메서드 오류 · 요청을 실행하지 않았다는 4xx · 429 — 아무것도 나가지 않았다): 한 번 더 낸다. 그래도 거절이면 그 메일을 임시보관함으로 되돌리고
(예약 취소와 같은 되돌리기 — `$draft` · `$ara-canceled`, 대용량 첨부 링크도 취소한 발송처럼) 503 `MAIL_001`(warn — 메일은 임시보관함에 있다).
- **결과를 모른다**(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 — 나갔을 수 있다): 다시 내지도 · 임시보관함으로 옮기지도 않는다. 제출을 한 번 다시 읽어(남은 시간이 없어도 3초)
새 제출이 보이면 그 상태로 200, 아니면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 그대로다(링크도 그대로). 같은 요청을 다시 보내면 위의 "끊긴 지금 보내기"로 이어 낸다
(그때 새 제출이 보이면 "이미 지금 보내기를 한 메일"로 200).
- 제출마다 먼저 남은 시간을 본다 — 10초보다 적으면 내지 않고 503 `MAIL_001`(결과를 모를 때와 같다 — 메일은 보낸편지함에 그대로, 다시 보내면 이어 낸다).
- 임시보관함으로 되돌리기마저 실패하면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 남는다(위 예약 취소의 "옮기지 못하면"과 같다).
- 503 `MAIL_001` 은 응답만으로는 어느 쪽인지 모른다(응답 꼴은 그대로) — **클라이언트는 503 뒤에 그 메일을 다시 읽어**(`GET /api/messages/{id}`) 임시보관함에 있으면 되돌린 것으로,
보낸편지함에 있으면 [다시 시도](같은 `POST` — 이어 낸다)를 보인다.
- **알려진 한계**: 결과를 모르는 503 뒤에 다시 보내지 않으면 그 메일은 보내지 않은 채(예약도 취소된 채) 보낸편지함에 남는다 — 9단계 예약 취소의 "옮기지 못하면"과 같은 한계다(드묾, warn 로그).
- 받는 사람이 **모두** 받지 않으면(발송의 `MAIL_010` · `MAIL_011` 과 같은 판정) 보내지 않은 것이다 — 기다리는 새 제출을 취소하고 그 메일을 임시보관함으로 되돌린 뒤
400 `MAIL_010` · 503 `MAIL_011`(구현하며 정함 — 발송처럼 사본을 지우지 않는다: 쓰기 화면이 없는 메일이라 잃지 않게). 일부만 받지 않았으면 200 `rejectedRecipients`.
- 대용량 첨부(4절): 다시 제출한 뒤 그 발송이 만든 링크의 **보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 끝나는 날을 이미 적었다(앞당기면 받는 사람이 본 날짜보다 먼저 끝난다).
이미 보낸 메일이면 링크를 건드리지 않는다.
- 같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
서버는 54초 남짓 안에 답한다 — 엔진 호출은 45초 안에서 남은 시간만큼, 정리(새 제출 취소 · 임시보관함으로 되돌리기 · 결과를 모를 때 다시 읽기)는 남은 시간이 없어도 3초씩 더 쓴다.
앞단 nginx · 클라이언트의 60초 안이다(클라이언트는 발송과 같은 시간 한도로 부른다).
- 로그: 메일 id · 예약했던 시각 · 보낼 시각만(받는 사람 · 제목 없음 — 위 "로그"와 같다).
- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다.
서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다.
붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다.
@@ -1547,7 +1609,8 @@ interface MailBlockList {
```
- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다.
이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`).
이미 받은 메일은 저절로 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮기고(`PATCH /api/messages/{id}` 의 `isJunk`),
받은편지함에 있는 그 보낸 사람의 다른 메일은 고르면 아래 "받은편지함 정리"(15단계)로 옮긴다.
- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰),
도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다).
이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`).
@@ -1568,6 +1631,34 @@ interface MailBlockList {
- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체,
「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다.
스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다.
- **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`): 차단한 뒤 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다).
```ts
interface MailBlockRunRequest {
sender: string // 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" (BlockedSender.address — 대소문자 · 앞뒤 공백은 서버가 맞춘다)
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 — 늘 보낸다
}
interface MailBlockRun { // 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 스팸메일함으로 옮긴 수 (붙잡힌 메일은 옮기지 않아 세지 않는다)
remaining: number // 아직 확인하지 않은 수 — 어림
cursor: string | null // null = 끝
}
```
- 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) 404 `RES_001` — 요청마다 본다(도중에 풀면 다음 요청이 멈춘다). 보낸 사람의 모양은 차단하기와 같은 검사(400 `VAL_001`).
- 틀은 위 "이미 받은 메일에 적용"(7단계)과 같다: 받은편지함만(정리해 둔 메일함은 건드리지 않는다) · 받은 시각이 최근인 메일부터 · 첫 요청의 위 경계 뒤에 온 메일은 보지 않는다(새로 온 메일은 차단 규칙이 이미 옮겼다) ·
`cursor`(위 경계 · 마지막으로 남긴 메일과 그 받은 시각 · 보낸 사람의 지문 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 400 `VAL_001`) ·
한 요청 25초 · 다음 차례는 남은 시간 5초 넘을 때만 · 나아감 없는 차례 3번이면 멈춤 · 하나도 확인하지 못하면 503 `MAIL_001`. 옮기기는 읽은 상태 그대로일 때만(`ifInState`).
- **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금, 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
- 요청이 도는 동안 그 직원의 **스팸메일함**을 메일함 처리 잠금(위 "메일함 관리" — 같은 메일함의 처리는 한 번에 하나)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 전체 읽음이 함께 돌지 않게(그동안 그쪽이 429).
스팸메일함을 비우는 중이면 메일함 목록을 읽은 뒤 429 `REQ_001` — 받은편지함은 건드리지 않았다(비우기가 끝난 뒤 같은 `cursor` 로 다시 보낸다).
잠금은 **요청 하나씩**이다 — 다른 창에서 비우기와 차단 정리가 함께 진행되면 요청 사이에 번갈아 돌 수 있어, 차단 정리가 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다
(알려진 한계 — 휴지통 비우기와 메일함 지우기의 관계와 같다. 같은 탭에서는 화면이 둘을 함께 돌리지 않는다).
- **판정은 차단 규칙(위 "만든 Sieve")과 같다** — 주소는 From 의 주소 가운데 하나가 같을 때(`address :all :is`), 도메인은 From 주소의 `@` 뒤가 그 도메인과 같을 때(`address :domain :is` — 하위 도메인 · 더 긴 도메인은 아님),
대소문자는 ASCII 만 무시(`i;ascii-casemap`). 견주는 것은 **엔진이 푼 From 머리글의 주소**(`header:From:asAddresses:all` — 같은 이름의 머리글이 여럿이면 모두)다 — 표시 이름에 그 주소를 쓴 남의 메일은 걸리지 않는다.
엔진 검색(`Email/query` 의 `from`)은 쓰지 않는다 — 낱말 단위라 넓게 잡는다(2026-11-09 개발 엔진: `kim.lee@` · `@sub.` · `.example.net` · 표시 이름에만 그 주소를 쓴 남의 메일까지).
- 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). 스팸메일함이 없으면 503 `MAIL_001`. 되돌리기는 없다(스팸메일함에서 [스팸 아님]으로).
- 로그: 보낸 사람은 가린다(`maskEmail` — 도메인 차단은 도메인만). 확인 · 옮긴 수 · 차례 · 시간만.
- **클라이언트**: 차단 창의 「받은편지함에 있는 이 보낸 사람의 다른 메일도 옮기기」(처음 꺼짐)를 켜면 차단(`POST /api/mail-blocks`)이 성공한 뒤 이 요청을 `cursor` 가 null 이 될 때까지 보낸다 — 진행 · 결과 알림은 디자인 README "15단계".
### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계
@@ -1594,8 +1685,10 @@ interface MailSettings {
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
---
@@ -2128,6 +2221,9 @@ interface FileUploadCreate {
- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다.
- 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시).
예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다).
- 예약한 메일을 **지금 보내면**(15단계 — 2절 "보내기 취소 · 예약 발송"의 "지금 보내기") 보낸 때를 다시 제출한 때로 앞당긴다(조건부 UPDATE 한 문장 — 취소한 링크 · 이미 그보다 앞선 보낸 때는 그대로).
**끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 날짜를 이미 적었다(메일은 고칠 수 없다 — 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다). 파일의 보관 기간도 그대로다.
끝내 보내지 못해 임시보관함으로 **되돌렸을 때만** 예약 취소처럼 그 링크를 취소한 링크로 둔다 — 결과를 몰라 보낸편지함에 그대로 둔 503 은 링크도 그대로다(다시 보내면 이어 낸다).
- 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다).
- 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리),
텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다.