docs: 계약 2절에 14단계 대화(같은 대화 · 묶은 목록) 규칙과 API 표를 적는다

- "대화 — 14단계" 절: 기준(basis — 색인 · 엔진), 대화에 드는 메일, 같은 대화 ·
  대화로 묶어 보기의 쿼리 · 응답(ConversationSummary — messageIds 최신 500 ·
  pendingSendIds), 엔진 방식의 상한과 어림이 되는 줄, 빈 쪽에도 커서가 오는 때, 알려진 한계
- 보내기 · 읽기 설정에 groupByConversation(처음 false — 서버는 저장만)
- API 표에 검색 조건 · 강조 쿼리, 같은 대화 · 묶은 목록 · 검색 색인 경로를 더한다
- nestjs 스킬의 메일 검색 색인 줄에 3차(대화 키 · 보낸 사람 칸 · 엔진 상한)를 적는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 07:46:50 +09:00
parent 425cbc0e29
commit 59375f1347
2 changed files with 71 additions and 5 deletions
File diff suppressed because one or more lines are too long
+70 -4
View File
@@ -230,8 +230,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`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
@@ -642,7 +645,7 @@ interface MailState {
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 다음 차례)
### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 "대화 — 14단계")
14단계는 엔진 검색 대신 **서버의 자체 색인**(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다.
@@ -653,7 +656,7 @@ interface MailState {
- **새로 온 · 바뀐 · 지운 메일**은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다.
비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다.
- `GET /api/search-index` → `{ status, done, total }`
- `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색은 지금 방식(엔진)이고, 화면은 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`.
- `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색 · 대화는 지금 방식(엔진)이고, 화면은 검색 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`.
- `done`: 채운 메일 수(`total` 을 넘지 않는다) · `total`: 채우기를 시작할 때의 메일 수(시작 전이면 `null`, `ready` 면 `done` 과 같다).
- **조회지만 상태 줄을 만들고 멈춤을 푼다** — 줄이 없던 직원은 이때 `building` 으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등).
- 로그인 필요(401 `AUTH_001`) · 제한된 세션은 403 `AUTH_010`(6절).
@@ -700,6 +703,66 @@ interface MailState {
- 한 글자 낱말뿐이라 본문을 찾지 않았다는 표시는 응답에 없다 — 화면은 서버와 같은 낱말 나누기(짝 — backend `shared/utils/search-text.ts` · 표본 표 `backend/test/search-text-samples.ts`)로 알아보고 결과 머리에 한 줄 알린다.
- 화면은 조건도 화면 주소에 싣는다(검색어처럼 — 앞단 nginx 의 접근 로그는 쿼리를 남기지 않는다). 새 에러 코드는 없다(`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통 이하 스레드를 다 읽지 못하거나 더 큰 스레드의 첫 묶음도 읽지 못함.
#### `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통이다 — 그 줄에 한 일은 그만큼에만 하고, 남은 메일은 다음에 다시 맞춘 목록의 같은 줄로 남는다(드문 경우).
- **알려진 한계**: 쪽을 넘기는 사이 늦게 온 원본으로 두 대화가 합쳐지면 줄이 겹치거나 빠질 수 있다 — 처음부터 다시 받으면 맞는다.
마지막 메일이 그 메일함을 떠났는데 색인이 아직 맞추지 않았으면 그 줄이 한 번 빠진다.
#### 설정
- `groupByConversation`(아래 "보내기 · 읽기 설정") — 처음 `false`. 서버는 저장해 돌려주기만 하고, 묶은 목록을 부를지는 화면이 정한다.
새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
```ts
@@ -1246,9 +1309,12 @@ 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` 하고 "저장했습니다"를 보인다(저장 단추 없음).