chore: 14단계(메일 검색 색인 · 검색 · 대화)를 앱 검색 브랜치에 합친다

앱 검색 화면(App1Search)은 계약 2절 "검색 — 14단계", 웹 글 원본과
백엔드 표본 표(backend/test/search-text-samples.ts)를 짝 시험으로 읽는다.
feat/mail-search-index 는 아직 main 에 없어서, 갈래점이 ba80956 인 앱 브랜치에
먼저 합친다. 14단계가 main 에 들어가면 이 브랜치를 main 위로 옮긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 09:30:37 +09:00
226 changed files with 30606 additions and 742 deletions
+177 -5
View File
@@ -260,8 +260,11 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` · 조건 `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`(14단계) — `mailboxId` 와 검색(`q` 또는 조건) 중 하나는 있어야 한다(검색만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색" · "검색 — 14단계") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함(14단계 색인 방식은 첫 쪽만 · 1,000까지 — 넘으면 `null`) | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") · 검색 쿼리 `q` · `from` · `to` · `subject` · `body`(선택 — 맞은 말 강조, 14단계) | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` |
| `GET /api/messages/{id}/conversation` | `cursor` · `limit`(기본 100, 최대 100) | 200 `{ items: MessageSummary[], pageInfo, basis }` — 그 메일의 같은 대화, 오래된 차례(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`, 503 `MAIL_001` |
| `GET /api/conversations` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) — 검색 쿼리는 받지 않는다 | 200 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함을 대화로 묶은 목록(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`(없는 메일함), 503 `MAIL_001` |
| `GET /api/search-index` | — | 200 `{ status, done, total }` — 그 직원의 검색 색인 준비 상태(아래 "검색 색인", 14단계) | — |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
@@ -668,7 +671,7 @@ interface MailState {
영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다.
조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고,
개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다.
- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
- **알려진 한계** (엔진 색인 때문 — 14단계부터는 그 직원의 자체 색인을 채우는 동안(`building`)의 검색에만 남는다, 아래 "검색 — 14단계"): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다).
화면은 결과가 없을 때 이 한계를 한 줄로 알린다.
- 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다.
@@ -682,6 +685,171 @@ interface MailState {
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 "대화 — 14단계")
14단계는 엔진 검색 대신 **서버의 자체 색인**(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다.
- **색인에 드는 것**: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계(Message-ID · In-Reply-To · References) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름 · 본문 글(앞 2만 자 — 잰 뒤 정한다).
메일 글이 DB 와 매일 백업에 한 벌 더 들어간다(사용자가 받아들였다). 퇴사 마무리는 이 표도 지운다. 언제나 로그인한 직원 주소로만 읽고 쓴다.
- **만드는 때**: 직원이 웹메일에 처음 로그인할 때(비밀번호가 만료된 로그인은 빼고 — 새 비밀번호를 정하면 만든다) 또는 아래 경로를 처음 물을 때 그 직원의 상태 줄을 `building` 으로 만든다. 로그인한 적 없는 계정은 색인하지 않는다.
- **지난 메일 채우기**: 밤에만 한 사람씩(KST 22:00 ~ 02:45 · 05:00 ~ 07:30 — 03:00 · 04:00 · 04:30 엔진 정리 · 03:30 백업을 피한다). 다 채우면 `ready`.
- **새로 온 · 바뀐 · 지운 메일**은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다.
비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다.
- `GET /api/search-index` → `{ status, done, total }`
- `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색 · 대화는 지금 방식(엔진)이고, 화면은 검색 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`.
- `done`: 채운 메일 수(`total` 을 넘지 않는다) · `total`: 채우기를 시작할 때의 메일 수(시작 전이면 `null`, `ready` 면 `done` 과 같다).
- **조회지만 상태 줄을 만들고 멈춤을 푼다** — 줄이 없던 직원은 이때 `building` 으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등).
- 로그인 필요(401 `AUTH_001`) · 제한된 세션은 403 `AUTH_010`(6절).
- 로그 가림: 검색 조건의 이름 `from` · `to` · `subject` · `body` 도 `q` · `cursor` 처럼 길이만 남긴다(`maskUrlForLog`). 개발 환경도 DB 쿼리 로그(매개변수)를 남기지 않는다 — 메일 글이 DB 에 있다.
### 검색 — 14단계 (자체 색인 · 조건 검색 · 강조)
- **방식**: 그 직원의 색인이 `ready` 면 색인으로, 아니면(`building`) 엔진으로 찾는다(위 3단계 절의 규칙 · 한계 그대로 — 조건은 엔진 필터로, 낱말이 통째로 같을 때만).
방식은 첫 쪽에서 정해지고 커서가 그 방식을 잇는다 — 쪽을 넘기는 중에 준비가 끝나도 그 검색은 처음 방식대로다. 색인 방식은 찾기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안 — 방금 온 메일도 찾아지게).
- **쿼리** (`GET /api/messages` — 3단계의 `mailboxId` · `q` · `cursor` · `limit` 에 더한다)
| 이름 | 찾는 곳 | 검사 |
| --- | --- | --- |
| `q` | 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 · 첨부 이름 | 3단계와 같다 |
| `from` | 보낸 사람(이름 · 주소) | 넷 모두 `q` 와 같은 검사 — 앞뒤 공백을 지우고 공백뿐이면 없는 것, 1~200자(UTF-16), 낱말 8개, 길이를 먼저 본다 |
| `to` | 받는 사람과 참조 | |
| `subject` | 제목 | |
| `body` | 본문 | |
| `after` · `before` | 받은 시각이 `after` 이상 · `before` 미만 | ISO 8601 UTC(`Z` · 소수 초 3자리까지 · 있는 날짜) |
| `hasAttachment` | 진짜 첨부가 있는 메일만(목록의 `hasAttachment` 와 같은 판정) | `true` 만 받는다 |
`q` 나 조건 하나라도 있으면 검색이다(검색 칸이 비어도 조건만으로). 메일함 범위는 3단계와 같고(`mailboxId` 가 없으면 휴지통 · 스팸메일함을 뺀 모두), `mailboxId` 도 검색도 없으면 400 `VAL_001`.
**`q` 와 조건 칸 넷(`from` · `to` · `subject` · `body`)을 합친 낱말은 8개까지**다(넘으면 400 `VAL_001` — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻기 때문에, 3단계에서 잰 비용(낱말 8개 · 조건 472개 · 0.16초) 안에 두려고. 화면은 보내기 전에 같은 수를 본다).
검색 글의 NUL(U+0000)은 색인과 같이 U+FFFD 로 바꿔 찾는다.
화면은 고른 날짜를 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시로 바꿔 보낸다(부재중과 같은 방식).
- **찾는 규칙 (색인 방식)**
- 부분 일치 — 낱말이 그 칸의 글 어디에 있어도 맞는다('계약' → 계약서 · 계약은 · 계약직, 제목 맨 앞의 [공지] · Re: 도). 영문 대소문자 · 전각 영숫자는 가리지 않는다(NFC).
- 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(3단계와 같다). 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 — 띄어쓰기 두 칸에는 맞지 않는다.
- **한 글자 낱말**: 두 글자 넘는 낱말과 함께면 모두 찾는다. 한 글자 낱말뿐이면 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾고 본문은 찾지 않는다(2026-11-05 사용자 확정 — 본문을 다 훑으면 메일이 많은 직원은 수 초. 화면은 결과 머리에 한 줄 알린다).
- **본문 강조**(목록 미리 보기 · 읽기 화면)는 본문을 찾은 두 글자 이상 낱말만이다. 한 글자 낱말뿐이면 본문에 강조하지 않고(제목 · 첨부 이름은 그대로), 두 글자 이상 낱말과 섞이면 그 낱말만 강조한다 —
함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다.
- 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계).
- **응답 (목록)**
- 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`.
위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다.
본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다).
① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지.
② 인용 안에서만 맞았고, 본문에서 맞은 말이 모두 검색어(`q`)의 낱말로 제목 · 보낸 사람 · 받는 사람과 참조 · 첨부 이름에서도 맞았으면 인용 밖의 첫 글 — 맞은 곳이 없으면 `ranges` 는 빈 배열, 인용 밖 글이 없으면 ③.
③ 그 밖에는 줄 앞 `>` 표시(겹친 것 · 사이 공백)를 뺀 인용 조각을 그 인용 안에서만 — `ranges` 는 보이는 글 기준.
내용 조건(`body`)의 낱말은 ②에 들지 않는다. `>` 없는 인용 꼴("-----Original Message-----" 등)과 인용 머리 줄("…님이 작성:")은 알아보지 않는다(알려진 한계).
- **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(색인 방식) — 한 요청이 확인하는 메일 수에 상한이 있어(3,000 — 1초 안에 답하려고) 그때까지 찾은 것과 이어 찾을 커서를 준다. 화면은 커서가 있으면 이어 묻는다(빈 쪽이 이어지면 몇 번까지만 — 화면 상수).
- `total`: 첫 쪽에서 셀 수 있으면 0~1,000, 1,000 을 넘거나 이 요청에서 다 세지 못했으면 `null`, 다음 쪽은 늘 `null`(엔진 방식은 3단계와 같다).
**화면은 `null` 이면 통 수를 보이지 않는다** — "1,000통 넘게"라고 쓰지 않는다(다 세지 못한 것일 수도 있다).
- 커서는 불투명한 문자열이다. 방식 · 메일함 · `q` · 모든 조건이 지문에 들어가 다른 검색에 쓰면 400 `VAL_001`. 3단계의 옛 커서는 그대로 통한다.
- **읽기 화면** (`GET /api/messages/{id}`)
- `images` 에 더해 `q` · `from` · `to` · `subject` · `body` 를 받는다(검사는 목록과 같다 — `from` · `to` 는 받기만 하고 강조하지 않는다). `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다(400 `VAL_001`) — 화면은 검색 주소의 조건 가운데 이 다섯만 넘긴다.
- 강조할 말이 있으면 `searchHighlight: { subject: Range[], attachments: { [첨부 id]: Range[] }(맞은 첨부만), textBody: Range[] }` 를 싣고, `subject` · 첨부 이름 · `textBody` 를 NFC 로 싣는다. 없으면 `null`.
- `htmlBody` 는 **정화한 뒤** 맞은 글 조각에 속성 없는 `<mark>` 를 넣는다 — 태그를 건너는 말 · 속성 · 주석 · `style` 안은 건드리지 않고, 한 통에 1,000개까지.
- 강조할 말: `q` 는 제목 · 본문 · 첨부 이름에, `subject` 는 제목에, `body` 는 본문에. `from` · `to` 는 강조하지 않는다(섞인 검색인지 정하는 데만 쓴다). 본문에는 목록과 같이 본문을 찾은 두 글자 이상 낱말만이다. 화면은 `<mark>` 를 노랑 `#FCE7A4` · 글자색 그대로로 그린다(디자인 README 14단계).
- **강조가 든 응답은 보이기만 한다** — 답장 · 전달의 원문 인용 · 인쇄 · 다른 화면의 메일 기억(목록 · 스토어)에는 검색 쿼리 없이 받은 응답을 쓴다(보내는 정화는 `mark` 를 지우지만, 인용 · 인쇄 모양이 검색에 따라 달라지지 않게).
- 한 글자 낱말뿐이라 본문을 찾지 않았다는 표시는 응답에 없다 — 화면은 서버와 같은 낱말 나누기(짝 — backend `shared/utils/search-text.ts` · 표본 표 `backend/test/search-text-samples.ts`)로 알아보고 결과 머리에 한 줄 알린다.
- 보낸 사람이 본문에 직접 쓴 `<mark>`(받은 메일 정화가 남긴다)도 같은 노랑으로 보여 검색 강조와 가르지 않는다(드물고 브라우저 기본도 노랑이다 — 알려진 한계).
- **클라이언트 — 화면 주소**(14단계 웹, 3단계 주소에 더한다): 조건도 검색어처럼 화면 주소에 싣는다(앞단 nginx 의 접근 로그는 쿼리를 남기지 않는다).
- 꼴: `/mail/search?q=…&in=<메일함 id | all>&from=…&to=…&subject=…&body=…&since=YYYY-MM-DD&until=YYYY-MM-DD&attachment=1`, 결과에서 연 메일은 `/mail/search/<메일 id>?…`(같은 쿼리).
빈 칸은 싣지 않는다(검색어가 비면 `q` 도 뺀다 — 조건만으로 찾는다). `in` 이 비면 `all`.
- `q` · `from` · `to` · `subject` · `body`: 앞뒤 공백을 지우고 200자에서 자른다. 같은 이름이 여럿이면 첫 값, 모르는 이름은 무시한다.
- `since` · `until`: 브라우저 시간대의 날짜이고 **양 끝을 포함한다** — API 에는 `after` = `since` 날 0시, `before` = `until` 다음 날 0시(UTC ISO)로 보낸다. "최근 1주" 같은 기간은 고른 순간 날짜로 바꿔 싣는다(7일 전 · 달은 말일을 넘지 않는다).
있는 날짜이고 1970-01-01 ~ 9999-12-30 이어야 한다(아니면 그 날짜만 버린다). `since` 가 `until` 보다 늦으면 기간을 통째로 버린다. 한쪽만 있어도 된다.
- `attachment`: `1` 일 때만 `hasAttachment=true` 다(다른 값은 무시).
- 검색어와 조건 칸 넷을 합친 낱말이 8개를 넘으면 목록을 묻지 않고 "찾을 말이 너무 많습니다"를 보인다(손으로 고친 주소 — 화면의 [찾기]는 보내기 전에 막는다). 그때 연 메일은 강조 쿼리 없이 받는다.
- 읽기 화면 API 에는 `q` · `from` · `to` · `subject` · `body` 다섯만 넘긴다([이미지 보기]도 같은 쿼리로).
- **클라이언트 — 이어 찾기**: `hasNext` 인데 쪽이 모자라면 한 번에(열 때 · [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]) 요청을 5번까지 보낸다(화면 상수 `MAIL_SEARCH_FILL_MAX_REQUESTS`) — 새로 20통이 차거나 `hasNext` 가 false 면 멈춘다.
그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다.
목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게).
- **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다.
- 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기)
읽기 화면은 그 메일의 같은 대화를 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다(디자인 README 「14단계 — 검색 · 대화 묶기」의 `Thread14` · `ListThreads14`). 검색 결과는 묶지 않는다.
- **대화의 기준(`basis`)** — 응답마다 싣는다.
- `index`(그 직원의 색인이 `ready`): 답장 관계(Message-ID · In-Reply-To · References)로 이어지고 **말머리를 뗀 제목이 같은** 메일이 한 대화다.
말머리는 앞의 `Re` · `RE` · `Fw` · `FW` · `Fwd` · `AW` · `WG` · `회신` · `답장` · `전달`(뒤의 `[2]` 같은 수 · 전각 쌍점 `:` 도) + 쌍점과 대괄호 꼬리표(`[공지]` 등)이고 여러 겹을 뗀다(짝 — backend `shared/utils/subject-base.ts` · frontend `shared/utils/search-match.ts`).
Message-ID 가 없는 메일은 혼자다. 원본이 늦게 와도 이어지고, 한 번 이어진 대화는 나뉘지 않는다(가운데 메일을 지워도). 답장 관계가 있어도 제목이 다르면 묶지 않는다(엔진과 같다).
- `engine`(색인을 채우는 동안): 엔진의 스레드(`threadId`). 두 기준의 묶음은 조금 다를 수 있다 — 준비가 끝나면 묶음이 바뀔 수 있다.
- 기준은 첫 쪽에서 정해지고 커서가 잇는다(검색과 같다). 색인 방식은 읽기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안). 첫 쪽에서 색인을 읽지 못하면(DB 장애) 엔진으로 답한다.
- **대화에 드는 메일**: 모든 메일함(보낸편지함 · 임시보관함 포함)의 메일 — 휴지통 · 스팸메일함에만 든 메일은 뺀다. 보내는 중인 내부 초안은 빼고, 붙잡힌 메일(보내기 취소 · 예약 — `pendingSend`)은 넣는다.
#### `GET /api/messages/{id}/conversation` — 같은 대화
- 쿼리 `cursor` · `limit`(1~100, 처음 100). 응답 `{ items: MessageSummary[], pageInfo, basis: 'index' | 'engine' }`.
- **오래된 차례**(받은 시각 — 같으면 id). 그 메일은 휴지통 · 스팸메일함에 있어도 늘 든다. 한 통뿐이면 그 메일 하나다(화면은 칸을 보이지 않는다).
- `total`: `index` 는 첫 쪽에서만(다음 쪽 `null`), `engine` 은 쪽마다(스레드가 1,000통을 넘으면 `null`).
- **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(`engine` — 한 요청이 커서 자리부터 1,000통까지만 훑는다. 검색과 같은 결 — 응답은 늘 커서 자리보다 앞으로 간다).
화면은 커서가 있으면 이어 묻되, 빈 쪽이 이어지면 몇 번까지만 묻고 [더 보기]로 넘긴다(검색의 이어 찾기와 같은 결 — 휴지통에만 든 메일이 많은 큰 스레드).
- 커서는 그 메일 · 그 기준에서만 통한다 — 다른 메일 · 꾸민 커서는 400 `VAL_001`(그 메일이 색인에 없어도 커서를 먼저 본다).
- 404 `RES_001`: 없는 메일 · 쪽을 넘기는 중에 색인에서 사라진 메일.
503 `MAIL_001`: 엔진 장애 · `engine` 기준이 한 요청의 시간(15초) 안에 1,000통 이하 스레드를 다 읽지 못하거나 더 큰 스레드의 첫 묶음도 읽지 못함.
- **클라이언트 — 읽기 화면의 같은 대화 칸**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 같은 대화")
- 한 번에 100통씩 받고, 쪽이 모자라면 한 번에 요청을 5번까지 이어 묻는다(화면 상수 `MAIL_CONVERSATION_FILL_MAX_REQUESTS` — 새로 100통이 차거나 `hasNext` 가 false 면 멈춘다). 남으면 [더 보기]. 화면의 시간 한도는 30초다.
- 칸 머리의 통 수: `total` → (다음 쪽이 없으면) 받은 수 → 모르면 수 없이 "같은 대화"(짐작한 수는 보이지 않는다).
- 처음 받기 · 다시 받기 · 펼친 본문은 알림 없이(`silent`) 칸 · 줄 안에 안내와 [다시 시도]를 둔다. 404 · 모양이 틀린 응답이면 칸이 없다(이 경로가 없는 옛 서버). [더 보기]만 인터셉터가 알린다.
- 다시 받는 때: 메일함들의 통 수(`totalCount`)가 바뀌었을 때(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다), 쓰기 화면에서 돌아올 때, 붙잡힌 메일이 보낼 시각 + 2초가 됐을 때.
받기가 끝난 때부터 10초 안에 온 소식은 모아 한 번 받고, 받는 중에 온 소식은 끝난 뒤 한 번 더 받는다. [더 보기]로 늘린 대화는 저절로 다시 받지 않는다(붙잡힌 메일만 `GET /api/messages/{id}` 로 고친다).
알려진 한계: 같은 메일함에서 한 통이 들어오며 한 통이 빠지면 통 수가 같아 그 소식으로는 다시 받지 않는다.
- 펼친 줄은 검색 쿼리 없이 `GET /api/messages/{id}` 로 받고, 받은 뒤에도 펼쳐 있으며 안 읽은 메일이면 `PATCH` 로 읽음으로 바꾼다(읽기 화면과 같은 길). 그 메일이 그사이 지워졌으면(404) 대화를 다시 받아 그 줄을 뺀다.
- 줄 차례: 받은 메일은 응답 차례 그대로, 붙잡힌 메일(`pendingSend` — 보내기 취소 · 예약)은 받은 줄 뒤에 `sendAt` 차례로 둔다. 줄의 미리 보기는 우리 원문 인용 머리 "> 원본 메일"부터 잘라 내고(남는 글이 없으면 제목), 펼친 줄은 첨부 목록 대신 첨부 수만 안내한다(2026-11-07).
#### `GET /api/conversations` — 대화로 묶어 보기
```ts
interface ConversationSummary {
id: string // 대화 id — 불투명(기준마다 다르다)
latest: MessageSummary // 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · pendingSend
participants: Address[] // 대화 전체의 보낸 사람(주소가 있는 사람만) — 받은 차례, 주소마다 한 번, 10명까지 · 이름 100자까지
messageCount: number // 대화 전체의 메일 수 — 위 "대화에 드는 메일"에 그 메일함의 메일을 더한 수(휴지통 · 스팸메일함을 묶어 볼 때)
messageIds: string[] // 그 메일함 안의 그 대화 메일 — 최신부터 500통까지
pendingSendIds: string[] // messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약 — pendingSend 가 있는 메일). 없으면 []
isRead: boolean // 그 메일함 안의 메일을 모두 읽었으면 true
isFlagged: boolean // 그 메일함 안에 중요 표시한 메일이 하나라도 있으면 true
hasAttachment: boolean // 그 메일함 안에 첨부가 있는 메일이 하나라도 있으면 true
}
```
- 쿼리 `mailboxId`(필수) · `cursor` · `limit`(1~100, 처음 20). 검색 쿼리(`q` · 조건)는 받지 않는다(400 `VAL_001`).
- 응답 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함 안의 마지막 받은 시각 최신부터.
- `total`: `index` 는 첫 쪽에서 그 메일함의 대화 수(다음 쪽 `null`), `engine` 은 엔진이 센 스레드 수(쪽마다).
- 커서는 그 메일함 · 그 기준에서만 통한다(다른 메일함 · 다른 목록 · 꾸민 커서는 400 `VAL_001`). 없는 메일함은 404 `RES_001`, 엔진 장애(메일함을 읽지 못함 포함)는 503 `MAIL_001`.
- **`engine` 기준의 상한**: 한 요청이 스레드마다 최신 500통 · 모두 2,000통까지 · 15초 안에서 읽는다(몫은 작은 스레드부터 다 주고 남는 것을 큰 스레드끼리 나눈다).
시간이 끝나도 503 이 아니라 그 쪽의 줄을 읽은 만큼으로 채워 답한다(커서는 보통의 다음 쪽 커서다).
상한에 걸린 줄(스레드마다 500 · 한 요청 2,000 · 15초 — 500통 이하 스레드도 한 쪽에 큰 스레드가 많으면 걸린다)은 `messageCount` 가 어림이다(읽지 못한 오래된 메일을 모두 센다 — 휴지통 · 스팸메일함에만 든 것도).
`participants` · `messageIds` · `isRead` · `isFlagged` · `hasAttachment` · `pendingSendIds` 는 읽은 최신 메일로만 정한다.
- 고르기 · 읽음 · 중요 · 이동 · 삭제는 그 줄의 `messageIds` 를 지금의 여러 통 API(`PATCH /api/messages` · `POST /api/message-deletions`)로 보낸다.
**`pendingSendIds` 는 빼고 보낸다** — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계 "보내기 취소 · 예약 발송" — 섞여 와도 서버가 `failedIds` · `skippedIds` 로 막는다).
줄의 메일이 모두 붙잡힌 메일이면 그 줄은 고를 수 없다(목록의 고르기 칸과 같은 결).
- 500통이 넘는 대화는 `messageIds` 가 최신 500통이다 — 그 줄에 한 일은 그만큼에만 하고, 남은 메일은 다음에 다시 맞춘 목록의 같은 줄로 남는다(드문 경우).
- **알려진 한계**: 쪽을 넘기는 사이 늦게 온 원본으로 두 대화가 합쳐지면 줄이 겹치거나 빠질 수 있다 — 처음부터 다시 받으면 맞는다.
마지막 메일이 그 메일함을 떠났는데 색인이 아직 맞추지 않았으면 그 줄이 한 번 빠진다.
- **클라이언트 — 목록을 대화로 묶어 보기**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기")
- 켜고 끄기: 목록 머리 [대화로 묶기]와 설정 › 메일 › 보내기 · 읽기의 한 줄이 같은 `groupByConversation` 을 `PATCH` 로 저장한다 — 화면을 먼저 바꾸고 끝내 실패하면 되돌린다(칸마다 저장은 한 번에 하나 — 아래 "보내기 · 읽기 설정").
메일함 목록만 묶는다 — 검색 결과 · 「예약됨」은 묶지 않는다.
- 첫 진입: 설정을 받기 전 · 받지 못했으면 이 브라우저가 그 직원에게서 마지막으로 안 값(localStorage — 직원마다, 처음이거나 읽지 못하면 끔)으로 연다. 받은 값이 다르면 바로 그 값으로 다시 연다
(짐작이 틀리면 버릴 첫 쪽 요청이 하나 나간다). 받지 못한 동안에도 [대화로 묶기]를 누를 수 있고, 저장 응답(설정 전체)을 받은 설정으로 삼는다 — 실패하면 누르기 전 짐작으로 돌아간다.
로그아웃 · 세션 만료는 짐작을 바꾸지 않고, 세션이 끝나면(제한된 세션 포함) 목록을 다시 부르지 않는다.
- 쪽은 20줄 · 받은 커서 그대로. 켜고 끔 · 메일함 바꿈 · 다른 목록으로 가면 앞 목록의 응답을 버리고 그 요청을 끊는다(`AbortSignal` — `engine` 기준은 한 요청이 15초까지). 끊긴 요청은 알리지 않는다.
- 새 메일 확인 · 실시간: 메일 목록 대신 불러온 줄 수만큼(최대 100줄) `silent` 로 처음 쪽부터 다시 받는다 — 요청 수는 메일 목록과 같다.
- 다른 탭 · PC 에서 바꾼 설정: 메일 화면은 탭이 다시 보일 때 설정을 마지막으로 받은 지 60초가 지났으면 `GET /api/mail-settings` 를 `silent` 로 다시 받는다 — 저장하는 중이면 받지 않고, 받는 사이 고른 값이 있으면 받은 값을 버리고, 세션이 끝났으면 받지 않는다. 받은 `groupByConversation` 이 다르면 그 값으로 목록을 다시 연다(2026-11-07).
- 줄에 하는 일: 할 일 메일 = `messageIds` − `pendingSendIds` − (마지막 메일이 붙잡힌 메일이면) 그 메일. 100통씩 나눠 보낸다.
- 화면을 먼저 바꾸는 것은 줄의 메일을 모두 바꿨을 때만 줄 전체(읽음 · 중요 해제)다 — 일부만이면 그대로 두고 다음 맞추기가 서버 값으로 고친다. 중요 해제는 붙잡힌 메일까지 바꿨을 때만이다(붙잡힌 메일의 중요 표시는 남는다).
- 줄의 별: 중요 표시가 하나라도 있으면 모두 풀고, 없으면 할 일 메일을 모두 표시한다. 붙잡힌 메일이 든 줄을 풀면 줄을 미리 풀지 않고 묶은 목록을 한 번 다시 받는다 — 그래도 중요 표시가 남으면 알린다.
- 읽기 화면의 별(한 통)이 실패하면 그 메일이 든 줄을 앞 값으로 되돌린다.
- 옮기기 · 삭제 뒤에는 묶은 목록을 한 번 다시 받는다(남은 줄의 마지막 메일 · 통 수를 맞추려고). 서버가 바꾸지 못한 메일(`failedIds` · `skippedIds`)이 든 줄은 앞 값으로 되돌린 뒤 다시 받는다. 안 읽은 수는 끝난 뒤 메일함 목록으로 맞춘다.
- 목록 머리의 대화 수는 `total` 이다 — `null` 이면 그 부분을 뺀다.
- 알려진 한계: 여러 통인 줄의 마지막 메일만 읽으면 다음 맞추기(실시간이면 곧, 아니면 1분)까지 그 줄이 굵은 채다 — 읽을 때마다 다시 받지 않는다(경로마다 1분 100회).
마지막 메일이 아닌 붙잡힌 메일은 보낼 시각을 몰라, 풀린 뒤에도 다음 맞추기까지 그 메일만 든 줄을 고를 수 없다.
#### 설정
- `groupByConversation`(아래 "보내기 · 읽기 설정") — 처음 `false`. 서버는 저장해 돌려주기만 하고, 묶은 목록을 부를지는 화면이 정한다.
새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
```ts
@@ -1140,7 +1308,7 @@ interface PendingSend {
- **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다.
- **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다).
일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=<draftId>` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.".
`MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다.
`MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다.
### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계
@@ -1228,19 +1396,23 @@ interface MailBlockList {
interface MailSettings {
undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10
externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask'
groupByConversation: boolean // 목록을 대화로 묶어 보기 — 처음 false (14단계 — 위 "대화 — 14단계")
}
```
- `groupByConversation` 은 서버가 저장해 돌려주기만 한다 — 묶은 목록(`GET /api/conversations`)을 부를지는 화면이 정한다. `PATCH` 는 boolean 만 받는다(`null` · `"true"` · `1` 은 400 `VAL_001`).
- 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다).
서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함).
- 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음).
칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰).
메일 화면은 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(14단계 — 위 "대화 — 14단계"의 묶어 보기 클라이언트 줄).
"저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다.
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).