feat: 메일 14단계 — 자체 검색 색인 · 조건 검색 · 강조 · 대화 묶기 #18

Merged
ttipo merged 21 commits from feat/mail-search-index into main 2026-11-08 13:30:13 +09:00
3 changed files with 12 additions and 4 deletions
Showing only changes of commit b9336b6db5 - Show all commits
+2 -2
View File
@@ -52,8 +52,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 바탕화면 알림 (5단계) | `src/composables/useNewMailNotifier.ts` · `useDesktopNotification.ts` · `stores/mail-notify.store.ts`(브라우저 · 사용자마다 localStorage) · `shared/utils/desktop-notify.ts` · 목록 위 권하기 `components/mail/MailNotifyOffer.vue` · 설정 `pages/settings/MailNotifyPage.vue`(+ `components/settings/NotifySettingsForm.vue` · `RealtimeLinkStatus.vue`) · 아이콘 `assets/brand/notification-icon.png` · 문구 `shared/constants/mail-notify.ts` | 연결한 탭이 `new-mail` 을 받고 설정이 켜져 있고 허락됐고 **웹메일을 보고 있는 탭이 없을 때만**(보이고 초점이 있음 — `visibilityState === 'visible' && document.hasFocus()`, 다른 탭에 짧게 묻는다) 띄운다 — tag 하나로 앞 알림을 바꿔 쓰고, 사용자가 돌아오면 닫는다. 허락을 묻는 동안 단추는 `disabled` 가 아니라 `aria-disabled`(초점을 잃지 않게). 허락은 사용자가 누른 뒤에만(`Notification.requestPermission()`). 누르면 그 메일로(`useMailNavigation` 의 `openFromNotification` — 세션이 바뀌었으면 이동하지 않는다) |
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝 |
| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel` |
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) |
| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel` |
| 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 다섯 칸만) — 계약 1절 · 2절 "부재중 자동 응답" |
+9 -2
View File
@@ -1074,6 +1074,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
### 처음 판에서 미룬 것
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
@@ -1385,7 +1386,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 단추의 title "조건으로 찾기", 조건이 걸려 있으면 화면 낭독에 ", 조건이 걸려 있음"을 붙인다.
- 칸 안내는 "이름이나 메일 주소"(보낸 사람 · 받는 사람) · "제목에 든 말" · "내용에 든 말"(칸 이름 "내용"과 같게 — 2026-11-07)이고, 날짜 칸은 "시작 날짜" · "끝 날짜"다.
- 날짜가 거꾸로면 알림 대신 칸 아래 "끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.", 고를 수 있는 날짜 밖이면 "날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요."(날짜 고르기는 이미 막고, 해를 손으로 칠 때만 보인다).
- 패널의 [조건 지우기]는 보드대로 패널을 닫고 걸린 조건을 뺀다. 조건만 걸린 검색에서 조건을 지우면 검색을 닫는다.
- 패널의 [조건 지우기]는 패널 안의 칸만 비운다 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간(전체 기간으로) · 날짜 · 첨부 체크와 칸 아래 알림까지(2026-11-07 사용자 확정 — 보드와 다르다).
패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다. 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
비운 채 [찾기]를 누르면 검색어가 있으면 조건만 빼고 다시 찾고, 검색어도 없으면(조건만 걸린 검색) 검색을 닫는다. 걸린 조건을 바로 빼는 것은 결과의 조건 줄 [조건 지우기]다(조건만 걸린 검색이면 검색을 닫는다).
- 패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다.
- **기간**: "최근 N"은 고른 순간 날짜로 바꾼다 — 그만큼 앞의 같은 날부터 오늘까지(1주는 7일 전, 달은 말일을 넘지 않는다).
- **조건 줄 글**: "조건: " 뒤에 쉼표로 잇는다 — "{이름} 님이 보낸 메일" · "{이름} 님이 받은 메일" · "제목에 ‘{말}’이/가 든 메일" · "내용에 ‘{말}’이/가 든 메일" · "최근 1달" 또는 "2026. 10. 1.부터 11. 5.까지"(같은 해면 끝 날짜의 해를 뺀다) · "…부터" · "…까지" · "첨부 있음". 시작과 끝이 같은 날이면 "2026. 11. 6. 하루", 보낸 사람 · 받는 사람 값이 내 이름이나 주소와 통째로 같으면(앞뒤 공백 · 대소문자 무시) "내가 보낸 메일" · "내가 받은 메일"(2026-11-07).
@@ -1401,6 +1404,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
하나도 못 찾았으면 "찾는 중"을 이어 보이고, 하나라도 찾으면 먼저 보이면서 뒤에서 잇는다. 목록 보기의 [다음]은 이어 받는 동안 기다렸다가 넘기고, 그동안 꺼짐 · `aria-busy` 다.
- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다.
- **강조 색**: 노랑 `#FCE7A4` 는 색 토큰 `--color-search-mark` 하나로 두고, 본문 틀 안 CSS 는 같은 값을 직접 적는다(짝). 보낸 사람이 본문에 쓴 `<mark>` 도 같은 노랑으로 보인다(드물어 가르지 않는다).
미리 보기 줄(회색 `--color-text-muted`)의 맞은 말만 글자를 `--color-text-secondary`(#4A5363)로 진하게 한다 — 회색 그대로면 노랑 위 대비가 4.05:1 로 4.5:1 에 못 미친다(2026-11-07 사용자 확정 — 보드와 다르다, 앱도 같다). 제목 · 읽기 화면 · 본문 틀의 강조는 진한 글 위라 글자색 그대로다.
- 긴 안내 글(결과 없음 · [더 찾기] · 안내 줄)은 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all`).
### 14단계 구현하며 정한 보완 — 같은 대화 (2026-11-06 — 캔버스에는 아직 없다)
@@ -1425,11 +1429,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 임시 저장본 줄은 [답장]이 없고 [이 메일만 열기]가 쓰기 화면으로 연다. 예약한 메일은 [답장]이 없다(보내기 취소를 기다리는 메일은 읽기 화면처럼 있다).
- 붙잡힌 메일의 시각 자리는 목록과 같은 보내기 취소 · 예약 표시(청록 굵게)다.
- **펼치기**: 안 읽은 메일을 펼치면 읽음이 된다(읽기 화면에서 연 것과 같다). 접힌 줄의 받아 둔 본문은 최근 10통까지만 기억한다. 펼친 본문 안에서 Ctrl+P 는 그 펼친 메일의 인쇄 화면을 연다.
- **안 읽은 줄**(2026-11-07 사용자 확정 — 보드와 다르다): 목록 줄과 같은 굵기 · 색이다 — 이름 650, 미리 보기 · 시각 600(붙잡힌 메일의 시각은 청록 그대로). 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ". 임시 저장본은 목록처럼 읽음 여부를 보이지 않는다.
이 탭에서 바꾼 읽음(줄을 펼침 · 읽기 화면에서 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 메일함 ⋯ › 모두 읽음)은 줄에 바로 보이고, 바꾸지 못해 되돌리면 줄도 되돌린다. 받는 동안 바꾼 것과 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다.
다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다(그 줄을 펼치면 받은 본문대로 바로 풀린다). 읽은 줄의 이름(600)과 안 읽은 줄(650)의 차이는 작아 눈에 띄는 차이는 미리 보기 · 시각이다 — 읽은 줄 이름을 낮추는 것은 보드를 바꾸는 일이라 두었다.
- **[이 메일만 열기]**: 분할 보기는 방문 기록을 쌓고(목록 줄을 누른 것과 같다), 목록 보기는 바꿔 끼운다(이전 · 다음과 같다 — [목록으로]가 기록을 쌓지 않게).
지금 목록에 든 메일은 그 목록 안에서, 검색 결과 · 예약됨은 받아 둔 줄에 있을 때만 그 목록 안에서, 그 밖은 그 메일이 든 메일함에서 연다.
- **초점**: 누른 단추가 사라지면(칸의 [다시 시도]가 성공 · [더 보기]로 끝까지 받음 · 다시 받아 줄이 빠짐) 그 줄의 펼침 단추 → 새 [다시 시도] → 칸 제목 차례로 옮기고, 칸이 통째로 사라지면 본문으로 옮긴다.
[이 메일만 열기] 뒤에는 삭제 뒤 다음 메일로 갈 때처럼 따로 옮기지 않는다(두 길을 함께 고칠 거리).
- **다시 받기**: 새 메일 · 지움 · 옮김으로 메일함 통 수가 바뀔 때와 쓰기 화면에서 돌아올 때만 다시 받는다 — 읽음 표시만 바뀐 소식으로는 받지 않는다(칸은 읽음을 그리지 않는다).
- **다시 받기**: 새 메일 · 지움 · 옮김으로 메일함 통 수가 바뀔 때와 쓰기 화면에서 돌아올 때만 다시 받는다 — 읽음 표시만 바뀐 소식으로는 받지 않는다(받으면 메일을 열 때마다 대화를 한 번 더 받는다). 칸의 읽음은 위 "안 읽은 줄"처럼 이 탭에서 바꾼 것만 바로 적는다.
### 14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기 (2026-11-06 — 캔버스에는 아직 없다)
+1
View File
@@ -754,6 +754,7 @@ interface MailState {
받기가 끝난 때부터 10초 안에 온 소식은 모아 한 번 받고, 받는 중에 온 소식은 끝난 뒤 한 번 더 받는다. [더 보기]로 늘린 대화는 저절로 다시 받지 않는다(붙잡힌 메일만 `GET /api/messages/{id}` 로 고친다).
알려진 한계: 같은 메일함에서 한 통이 들어오며 한 통이 빠지면 통 수가 같아 그 소식으로는 다시 받지 않는다.
- 펼친 줄은 검색 쿼리 없이 `GET /api/messages/{id}` 로 받고, 받은 뒤에도 펼쳐 있으며 안 읽은 메일이면 `PATCH` 로 읽음으로 바꾼다(읽기 화면과 같은 길). 그 메일이 그사이 지워졌으면(404) 대화를 다시 받아 그 줄을 뺀다.
- 줄의 읽음(2026-11-07): 안 읽은 줄을 목록처럼 굵게 그린다. 이 탭이 바꾼 읽음은 줄에 바로 적고, 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 앞세운다(받은 값은 그 바꿈이 엔진에 닿기 전일 수 있다). 다른 탭 · 기기의 읽음은 다음 받기 때 보인다(읽음만 바뀐 소식으로는 다시 받지 않는다).
- 줄 차례: 받은 메일은 응답 차례 그대로, 붙잡힌 메일(`pendingSend` — 보내기 취소 · 예약)은 받은 줄 뒤에 `sendAt` 차례로 둔다. 줄의 미리 보기는 우리 원문 인용 머리 "> 원본 메일"부터 잘라 내고(남는 글이 없으면 제목), 펼친 줄은 첨부 목록 대신 첨부 수만 안내한다(2026-11-07).
#### `GET /api/conversations` — 대화로 묶어 보기