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

Merged
ttipo merged 21 commits from feat/mail-search-index into main 2026-11-08 13:30:13 +09:00
227 changed files with 31229 additions and 742 deletions
+2 -1
View File
@@ -33,7 +33,8 @@
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · 조건 검색의 `from` · `to` · `subject` · `body`(14단계) · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
+5 -2
View File
@@ -180,7 +180,7 @@ sudo docker compose --env-file .env.production logs -f backend
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
**프록시가 다른 서버에 있으면** 주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다).
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`, 메일을 열 때마다 부르는 같은 대화 `GET /api/messages/{id}/conversation` — 14단계)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
- **백엔드의 바깥 연결**(운영 확인 항목, 9단계): 외부 이미지 중계는 backend 컨테이너가 바깥 80 · 443 으로 나가 이미지를 받아 온다 — 호스트 방화벽 · 사내 프록시가 막으면
이미지 자리가 비어 보인다(다른 기능은 영향 없음). 사설 · 내부 주소로는 백엔드가 스스로 나가지 않는다(SSRF 막기 — 계약 2절 "외부 이미지 보기").
@@ -484,7 +484,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`.
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 한 달에 한 번 `drill`(mail/README "백업 › 복원" 이 기준).
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
@@ -568,6 +568,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
- **db 메모리 한도는 1g · 공유 버퍼(`shared_buffers`) 256MB** (14단계 — 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있어야 1초 안이다, 캐시가 비면 3~8배).
공유 버퍼는 compose 의 db `command`(`postgres -c …` — 명령줄 값이 데이터 폴더의 설정보다 이긴다)이고 개발도 같은 값이다(dev 오버라이드가 덮어쓰지 않는다).
8GB 서버의 한도 합 · 운영에 올리는 차례는 mail/README.md "메모리 예산".
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -37,7 +37,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
@@ -46,13 +46,16 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
| 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" |
| 바탕화면 알림 (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절 "검색") |
| 여러 통 처리 · 삭제 | `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(바쁜 · 꺼진 단추는 건너뛴다) — 계약 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`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--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절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 표 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕, 설계 A).
* - mail_search_messages: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계 · 원문 글(NFC) · 접기 글(fold_*) ·
* 글자 두 개 묶음(grams) · 대화 키. 색인: PK, (직원, 받은 시각, id) · (직원, Message-ID) · (직원, 대화 키),
* GIN 셋(grams · mailbox_ids · refs — TypeORM 이 GIN 을 적지 못해 엔티티에는 synchronize: false 로 이름만 있다)
* - mail_search_states: 직원마다 한 줄 — 상태(building · ready) · 엔진 Email state · 채우기 자리 · 멈춘 까닭 · 실패 수
* - mail_settings.group_by_conversation: 목록을 대화로 묶어 보기(기본 끔 — API 는 14단계 3차)
* 기존 표의 칸 · 행은 지우지 않는다 — 표를 새로 만들고 칸 하나를 더하기만 한다.
* (자동 생성: npm run migration:generate — GIN 색인 셋만 손으로 더했다. 엔티티는 modules/mail-search/*.entity.ts ·
* modules/mail-user-settings/mail-user-settings.entity.ts)
*/
export class AddMailSearchIndex1793873715423 implements MigrationInterface {
name = 'AddMailSearchIndex1793873715423';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "mail_search_messages" ("user_email" character varying(254) NOT NULL, "email_id" character varying(64) NOT NULL, "mailbox_ids" text array NOT NULL, "keywords" text array NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, "size" integer NOT NULL, "message_id" text, "refs" text array NOT NULL, "subject" text NOT NULL, "subject_base" text NOT NULL, "from_text" text NOT NULL, "to_text" text NOT NULL, "cc_text" text NOT NULL, "attachment_names" text NOT NULL, "has_attachment" boolean NOT NULL, "preview" text NOT NULL, "body_text" text NOT NULL, "fold_from" text NOT NULL, "fold_to" text NOT NULL, "fold_subject" text NOT NULL, "fold_attachment" text NOT NULL, "fold_body" text NOT NULL, "grams" text array NOT NULL, "thread_key" character varying(64) NOT NULL, "engine_thread_id" character varying(64), "indexed_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_bdeada2a9bbda7dece469d81b6f" PRIMARY KEY ("user_email", "email_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_thread_key" ON "mail_search_messages" ("user_email", "thread_key") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_message_id" ON "mail_search_messages" ("user_email", "message_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_received" ON "mail_search_messages" ("user_email", "received_at", "email_id") `,
);
// GIN — 찾는 말의 글자 두 개 묶음이 모두 든 줄(grams @> …) · 메일함 범위(mailbox_ids && …) · 늦게 온 원본이 자식을 찾기(refs @> …)
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_refs" ON "mail_search_messages" USING GIN ("refs")`,
);
await queryRunner.query(
`CREATE TABLE "mail_search_states" ("user_email" character varying(254) NOT NULL, "status" character varying(32) NOT NULL, "email_state" text, "build_anchor" character varying(64), "build_done" integer NOT NULL DEFAULT '0', "build_total" integer, "build_started_at" TIMESTAMP WITH TIME ZONE, "ready_at" TIMESTAMP WITH TIME ZONE, "last_synced_at" TIMESTAMP WITH TIME ZONE, "paused_reason" character varying(32), "failure_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e8aa94c380c49ff05919e5177ab" PRIMARY KEY ("user_email"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_states_status" ON "mail_search_states" ("status") `,
);
await queryRunner.query(
`ALTER TABLE "mail_settings" ADD "group_by_conversation" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_settings" DROP COLUMN "group_by_conversation"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_states_status"`,
);
await queryRunner.query(`DROP TABLE "mail_search_states"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_refs"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_received"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_message_id"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_thread_key"`,
);
await queryRunner.query(`DROP TABLE "mail_search_messages"`);
}
}
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤).
* - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 —
* 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서
* 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정).
* 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다.
* `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로)
* - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다
* 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다).
* (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts)
*/
export class SearchIndexUserGrams1793882273224 implements MigrationInterface {
name = 'SearchIndexUserGrams1793882273224';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
);
}
}
@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자).
* - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 —
* 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람이 그 주소로 보이고, 400자에서 잘린 줄은 빠졌다
* - 둘 다 null 을 받는다(주소가 없는 보낸 사람 · 지금 있는 줄). 지금 있는 줄은 색인 판 4(INDEX_FORMAT)로 다시 채우며 채운다
* 기존 칸 · 행은 지우지 않는다. (자동 생성: npm run migration:generate — 엔티티는 modules/mail-search/mail-search-message.entity.ts)
*/
export class AddMailSearchSender1793898213073 implements MigrationInterface {
name = 'AddMailSearchSender1793898213073';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_messages" ADD "from_email" character varying(254)`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_messages" ADD "from_name" character varying(100)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`,
);
}
}
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 — 메일함 범위 찾기 (14단계 실사용 시험, 2026-11-06).
* - 글자 두 개 묶음 GIN 에 메일함 칸을 더한다: GIN 하나 (lower("user_email"), "grams", "mailbox_ids") 가 직원 · 묶음 · 메일함을 한 번에 맞춘다.
* 주소 · 묶음 GIN(`IDX_mail_search_messages_user_grams`)과 메일함만 담은 GIN(`IDX_mail_search_messages_mailbox_ids`)은 지운다.
* - 까닭: 메일함 범위의 후보 쿼리(`"mailbox_ids" @> $n` — search-sql.ts 의 candidatesSql)에서 계획기가 메일함 GIN 만으로 그 메일함의 줄을 모두 읽고
* 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 골랐다. 메일함 id 는 계정마다 같은 꼴(a · b …)이라 그 posting 은 회사 전체이고, 묶음 확인이
* 주소 확인보다 먼저 돌아 다른 직원의 줄까지 풀었다 — 1만 통 메일함 안 1.1초, 그 메일함 id 가 남의 큰 메일함과 같은 직원은 빈 메일함에도 1.6초.
* 계획기는 TOAST 를 푸는 값을 모르고, 묶음 GIN 의 대기 목록(fastupdate)이 쌓이면 그 GIN 을 더 비싸게 보아 이 길로 넘어간다 —
* 그래서 고를 길을 하나만 남긴다(이 색인 말고 `lower("user_email")` · 묶음 · 메일함 조건을 받는 색인이 없다. 메일함만 담은 색인을 다시 만들지 않는다).
* - 같은 표의 흉내(10명 · 10만 줄 — 메일함 id 가 겹친다)에서 큰 메일함 29ms · 작은 메일함 1~7ms · 전체 13ms
* (전에는 큰 메일함 43~125ms · 작은 메일함 5~88ms — 메일함 GIN 과 함께 쓸 때와 쓰지 않을 때). 그 표의 몫(직원 주소 · 메일함 칸)은 묶음보다 훨씬 작다.
* 쓰기 비용은 지금과 같다 — 메일함이 바뀐 줄은 지금도 두 GIN 에 새로 들어간다(색인된 칸이라 HOT 이 아니다). 표의 칸 · 행은 그대로다.
* btree_gin 확장은 SearchIndexUserGrams 가 만들었다. 내릴 때는 두 색인을 다시 만든다.
* (손으로 적었다 — TypeORM 이 GIN 을 적지 못한다. 엔티티는 modules/mail-search/mail-search-message.entity.ts 에 synchronize: false 로 이름만)
*/
export class SearchIndexUserGramsMailboxes1793979304483 implements MigrationInterface {
name = 'SearchIndexUserGramsMailboxes1793979304483';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_user_grams_mailboxes" ON "mail_search_messages" USING GIN (lower("user_email"), "grams", "mailbox_ids")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_user_grams_mailboxes"`,
);
}
}
+3
View File
@@ -6,6 +6,7 @@ import { PassportModule } from '@nestjs/passport';
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
import { MailModule } from '@/modules/mail/mail.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionModule } from './auth-session.module';
import { AuthSessionsController } from './auth-sessions.controller';
@@ -38,6 +39,8 @@ import { LoginAttemptModule } from './login-attempt.module';
MailDirectoryModule,
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
AccountSecurityModule,
// 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다)
MailSearchModule,
],
controllers: [AuthController, AuthSessionsController],
providers: [
@@ -15,6 +15,7 @@ import {
import type { CredentialCheck } from '@/modules/mail/mail.types';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import type { AuthSessionEntity } from './auth-session.entity';
import { hashRefreshToken } from './auth-session.policy';
import type {
@@ -259,6 +260,8 @@ describe('AuthService', () => {
sessionState: jest.Mock;
isEnabledInEngine: jest.Mock;
};
/** 검색 색인 대역 (14단계) — 로그인한 직원의 상태 줄 */
let searchIndex: { enrollAfterLogin: jest.Mock };
let jwt: JwtService;
let service: AuthService;
let logs: string[];
@@ -277,6 +280,7 @@ describe('AuthService', () => {
loginEvents as unknown as LoginEventsService,
appPasswords as unknown as AppPasswordsService,
twoFactorPolicy as unknown as TwoFactorPolicyService,
searchIndex as unknown as MailSearchIndexService,
);
const decode = (token: string) =>
@@ -329,6 +333,7 @@ describe('AuthService', () => {
.mockResolvedValue({ restriction: null, deadline: null }),
isEnabledInEngine: jest.fn().mockResolvedValue(false),
};
searchIndex = { enrollAfterLogin: jest.fn().mockResolvedValue(undefined) };
jwt = new JwtService({});
service = build();
@@ -395,6 +400,22 @@ describe('AuthService', () => {
expect(directory.recordLogin).not.toHaveBeenCalled();
});
it('로그인에 성공한 직원의 검색 색인 상태 줄을 만든다 (14단계) — 실패한 로그인 · 코드를 기다리는 로그인은 만들지 않는다', async () => {
await service.login(ARA, PASSWORD, CLIENT_IP);
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
searchIndex.enrollAfterLogin.mockClear();
mail.verifyCredentials.mockResolvedValue(INVALID);
expect(
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
).toBe('AUTH_004');
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
expect((await service.login(ARA, PASSWORD, CLIENT_IP)).status).toBe(
'twoFactorRequired',
);
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
});
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
directory.recordLogin.mockRejectedValue(new Error('db down'));
@@ -914,6 +935,20 @@ describe('AuthService', () => {
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
});
it('비밀번호가 바뀌면 검색 색인의 멈춤을 풀고 줄을 만든다 (14단계 — 만료로 멈춘 색인) · 바뀌지 않았으면 하지 않는다', async () => {
const current = await loginToken();
searchIndex.enrollAfterLogin.mockClear();
mail.changePassword.mockResolvedValueOnce('new-password-rejected');
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
'VAL_001',
);
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
});
it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => {
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
.tokens!.refreshToken;
@@ -1804,6 +1839,8 @@ describe('AuthService', () => {
expect(decode(result.tokens.accessToken).rst).toBe('passwordChange');
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
expect(directory.recordLogin).not.toHaveBeenCalled();
// 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계)
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect.objectContaining({ email: ARA, isSuccess: true }),
+8
View File
@@ -29,6 +29,7 @@ import type {
PasswordChangeOutcome,
} from '@/modules/mail/mail.types';
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { truncateUserAgent } from '@/shared/utils/user-agent';
@@ -183,6 +184,8 @@ export class AuthService {
private readonly appPasswords: AppPasswordsService,
/** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */
private readonly twoFactorPolicy: TwoFactorPolicyService,
/** 14단계 — 로그인한 직원의 검색 색인 상태 줄 (처음이면 만들고, 멈춰 있으면 푼다 — 던지지 않는다) */
private readonly searchIndex: MailSearchIndexService,
) {}
/**
@@ -300,6 +303,9 @@ export class AuthService {
const tokens = await this.startSession(profile, client, state);
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
if (!passwordExpired) await this.recordInDirectory(profile);
// 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다)
if (!passwordExpired)
await this.searchIndex.enrollAfterLogin(profile.email);
await this.loginEvents.recordWebmail({
email: profile.email,
isSuccess: true,
@@ -543,6 +549,8 @@ export class AuthService {
refreshToken,
twoFactorEnabled,
);
// 검색 색인(14단계) — 비밀번호가 바뀌었으니(만료도 풀렸다) 대리 접근이 다시 된다: 멈춘 줄을 풀고, 없던 줄은 만든다 (던지지 않는다)
await this.searchIndex.enrollAfterLogin(address);
} finally {
releaseLock?.();
}
@@ -0,0 +1,107 @@
import { BusinessException } from '@/common/errors/business.exception';
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
import {
conversationCursorMethod,
decodeConversationCursor,
encodeConversationCursor,
} from './conversation-cursor';
import { encodeIndexCursor } from './search-cursor';
function codeOf(run: () => unknown): string | null {
try {
run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('대화 커서 (conversation-cursor — 14단계 C3 · C4)', () => {
const position = { at: new Date('2026-11-02T01:23:45.678Z'), key: 'm9x' };
it('자리를 그대로 되돌린다 — 같은 대화는 그 메일, 묶은 목록은 그 메일함 범위에서만', () => {
const thread = encodeConversationCursor('thread', position, 'e1');
const list = encodeConversationCursor('list', position, 'inbox');
expect(thread).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeConversationCursor('thread', thread, 'e1')).toEqual(position);
expect(decodeConversationCursor('list', list, 'inbox')).toEqual(position);
// 다른 메일 · 다른 메일함 · 다른 종류의 커서는 VAL_001
expect(codeOf(() => decodeConversationCursor('thread', thread, 'e2'))).toBe(
'VAL_001',
);
expect(codeOf(() => decodeConversationCursor('list', list, 'sent'))).toBe(
'VAL_001',
);
expect(codeOf(() => decodeConversationCursor('list', thread, 'e1'))).toBe(
'VAL_001',
);
});
it('방식을 커서로 안다 — 그 종류의 색인 커서만 index, 엔진 · 검색 커서 · 알 수 없는 값은 engine', () => {
expect(
conversationCursorMethod(
encodeConversationCursor('thread', position, 'e1'),
'thread',
),
).toBe('index');
expect(
conversationCursorMethod(
encodeConversationCursor('list', position, 'inbox'),
'thread',
),
).toBe('engine');
expect(
conversationCursorMethod(
encodeCursor(
{ anchor: 'm1', position: 3 },
{ conditions: ['conversation', 'e1'] },
),
'thread',
),
).toBe('engine');
expect(
conversationCursorMethod(
encodeIndexCursor({ receivedAt: position.at, emailId: 'm1' }, 'scope'),
'list',
),
).toBe('engine');
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600)]) {
expect(conversationCursorMethod(cursor, 'list')).toBe('engine');
}
// `m` 만 맞은 꾸민 커서는 색인 커서로 본다 — 나머지 모양은 decode 가 VAL_001 로 (서비스 입구 — 리뷰 L1)
const onlyMethod = Buffer.from(JSON.stringify({ m: 'ti' })).toString(
'base64url',
);
expect(conversationCursorMethod(onlyMethod, 'thread')).toBe('index');
expect(
codeOf(() => decodeConversationCursor('thread', onlyMethod, 'e1')),
).toBe('VAL_001');
expect(conversationCursorMethod(onlyMethod, 'list')).toBe('engine');
});
it('모양이 틀린 커서는 VAL_001 — 시각이 날짜가 아님 · 이상한 키 · 너무 긴 값', () => {
const real = JSON.parse(
Buffer.from(
encodeConversationCursor('list', position, 'inbox'),
'base64url',
).toString('utf8'),
) as Record<string, unknown>;
const forge = (patch: Record<string, unknown>) =>
Buffer.from(JSON.stringify({ ...real, ...patch })).toString('base64url');
for (const cursor of [
forge({ t: 9e15 }),
forge({ t: -1 }),
forge({ k: '../x' }),
forge({ k: 'k'.repeat(65) }),
forge({ v: 2 }),
'not-json',
'a'.repeat(600),
]) {
expect(
codeOf(() => decodeConversationCursor('list', cursor, 'inbox')),
).toBe('VAL_001');
}
});
});
@@ -0,0 +1,122 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
import { SEARCH_ID_COLUMN_LENGTH } from './mail-search.constants';
/**
* 색인으로 본 대화의 커서 (14단계 설계 C3 · C4 — 클라이언트에게는 불투명 문자열).
* - 같은 대화(`m: 'ti'`): `{ v: 1, m, t: 받은 시각(ms), k: 메일 id, s }` — 그 줄 다음(오래된 차례 — 받은 시각 · id 가 더 큰 쪽)부터
* - 묶은 목록(`m: 'li'`): `{ v: 1, m, t: 그 메일함 안 마지막 받은 시각(ms), k: 대화 키, s }` — 그 대화 다음(최신 차례 — 더 작은 쪽)부터
* 방식(m)이 커서에 실려 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로 이어 간다 — 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로.
* `m` 이 그 종류의 방식이면 색인 커서로 보고 나머지 모양은 서비스 입구에서 decode 로 본다(엔진 · DB 에 묻기 전에 VAL_001 — 3차 리뷰 L1).
* 범위 지문(s)에는 그 메일 id(같은 대화) · 메일함 id(묶은 목록)가 든다 — 다른 대화 · 메일함의 커서를 섞어 쓰면 VAL_001
*/
const CURSOR_VERSION = 1;
const MAX_CURSOR_LENGTH = 512;
const SCOPE_HASH_LENGTH = 16;
/** 대화 키 · 메일 id 의 최대 길이 — 표의 열(thread_key · email_id) */
const MAX_KEY_LENGTH = SEARCH_ID_COLUMN_LENGTH;
/** 커서의 종류 */
export type ConversationCursorKind = 'thread' | 'list';
const METHOD: Readonly<Record<ConversationCursorKind, string>> = {
thread: 'ti',
list: 'li',
};
/** 이어 갈 자리 — 받은 시각과 그 줄(같은 대화는 메일 id, 묶은 목록은 대화 키) */
export interface ConversationPosition {
readonly at: Date;
readonly key: string;
}
interface EncodedCursor {
v: number;
m: string;
t: number;
k: string;
s: string;
}
function scopeHash(kind: ConversationCursorKind, scope: string): string {
return createHash('sha256')
.update(JSON.stringify([kind, scope]))
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-cursor:${reason}` },
});
}
/**
* 방식 — `m` 이 그 종류의 색인 방식이면 'index'(판 · 시각 · 키 · 지문은 decodeConversationCursor 가 본다 — 틀리면 VAL_001),
* 아니면 'engine'(엔진 쪽 커서 — 모양이 틀리면 엔진 쪽 decode 가 VAL_001)
*/
export function conversationCursorMethod(
cursor: string,
kind: ConversationCursorKind,
): 'index' | 'engine' {
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
try {
const parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
) as unknown;
return typeof parsed === 'object' &&
parsed !== null &&
(parsed as Partial<EncodedCursor>).m === METHOD[kind]
? 'index'
: 'engine';
} catch {
return 'engine';
}
}
export function encodeConversationCursor(
kind: ConversationCursorKind,
position: ConversationPosition,
scope: string,
): string {
const payload: EncodedCursor = {
v: CURSOR_VERSION,
m: METHOD[kind],
t: position.at.getTime(),
k: position.key,
s: scopeHash(kind, scope),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** 커서를 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 VAL_001 */
export function decodeConversationCursor(
kind: ConversationCursorKind,
cursor: string,
scope: string,
): ConversationPosition {
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, m, t, k, s } = (parsed ?? {}) as Partial<EncodedCursor>;
const valid =
v === CURSOR_VERSION &&
m === METHOD[kind] &&
typeof t === 'number' &&
Number.isSafeInteger(t) &&
t >= 0 &&
Number.isFinite(new Date(t).getTime()) &&
typeof k === 'string' &&
k.length <= MAX_KEY_LENGTH &&
MAIL_ID_PATTERN.test(k) &&
typeof s === 'string';
if (!valid) throw invalidCursor('shape');
if (s !== scopeHash(kind, scope)) throw invalidCursor('other-list');
return { at: new Date(t), key: k };
}
@@ -0,0 +1,154 @@
import {
conversationDetailsSql,
conversationPageSql,
conversationParticipantsSql,
mailboxConversationsSql,
} from './conversation-sql';
const USER = 'ara.kim@arasystem.test';
const EXCLUSIONS = {
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
};
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
describe('대화의 SQL (conversation-sql — 14단계 C3 · C4)', () => {
it('같은 대화 한 쪽 — 그 메일의 대화 키를 같은 문장에서 읽고(리뷰 L2), 오래된 차례 · 빼는 메일함에만 든 메일 · 내부 발송 초안은 빼되 지금 메일은 넣고, 이어 갈 자리 다음부터', () => {
const after = { at: new Date('2026-11-01T00:00:00Z'), key: 'm3' };
const query = conversationPageSql(USER, 'm5', EXCLUSIONS, after, 101);
expect(flat(query.sql)).toBe(
'SELECT "page"."email_id", "page"."received_at", "page"."total" ' +
'FROM (SELECT "thread_key" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = $2) AS "current" ' +
'LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ' +
'AND ((NOT ("mailbox_ids" <@ $3::text[]) AND NOT ("keywords" && $4::text[])) OR "email_id" = $2) ' +
'AND ("received_at", "email_id") > ($5::timestamptz, $6) ' +
'ORDER BY "received_at" ASC, "email_id" ASC LIMIT $7) AS "page" ON TRUE ' +
'ORDER BY "page"."received_at" ASC, "page"."email_id" ASC',
);
expect(query.params).toEqual([
USER,
'm5',
['b', 'c'],
['$ara-sending'],
after.at,
'm3',
101,
]);
// 빼는 키워드만 있어도 지금 메일은 넣는다
const keywordOnly = conversationPageSql(
USER,
'm5',
{ excludedMailboxIds: [], excludedKeywords: ['$ara-sending'] },
null,
10,
);
expect(flat(keywordOnly.sql)).toContain(
'AND ((NOT ("keywords" && $3::text[])) OR "email_id" = $2) ORDER BY',
);
});
it('묶은 목록 한 쪽 — 그 직원의 줄(주소 btree)에서 메일함은 GIN 을 못 쓰는 꼴(= ANY)로 거른 뒤 대화 키로 묶는다', () => {
const query = mailboxConversationsSql(USER, 'a', EXCLUSIONS, null, 21);
expect(flat(query.sql)).toBe(
'SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
'WHERE "user_email" = $1 AND $2 = ANY("mailbox_ids") AND NOT ("keywords" && $3::text[]) ' +
'GROUP BY "thread_key" ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT $4',
);
expect(query.params).toEqual([USER, 'a', ['$ara-sending'], 21]);
expect(query.sql).not.toContain('"mailbox_ids" @>');
const next = mailboxConversationsSql(
USER,
'a',
EXCLUSIONS,
{ at: new Date('2026-11-01T00:00:00Z'), key: 'k9' },
21,
);
expect(flat(next.sql)).toContain(
'GROUP BY "thread_key" HAVING (max("received_at"), "thread_key") < ($4::timestamptz, $5) ORDER BY',
);
});
it('묶은 목록의 자세히 — 그 쪽의 대화 키만, 통 수는 대화 전체(빼는 메일함에만 든 메일 빼고 — 그 메일함 것은 넣고), 그 메일함 안 id 는 최신부터 상한까지(리뷰 L4) · 보낸 사람 글은 끌어오지 않는다', () => {
const query = conversationDetailsSql(
USER,
'a',
['k1', 'k2'],
EXCLUSIONS,
500,
);
const sql = flat(query.sql);
expect(sql).toContain(
'WHERE "user_email" = $1 AND "thread_key" = ANY($2::text[]) AND NOT ("keywords" && $8::text[]) GROUP BY "thread_key"',
);
expect(sql).toContain(
'count(*) FILTER (WHERE (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")))::int AS "message_count"',
);
expect(sql).toContain(
'(array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE $3 = ANY("mailbox_ids")))[1:$7::int] AS "mailbox_message_ids"',
);
expect(sql).not.toContain('from_text');
expect(query.params).toEqual([
USER,
['k1', 'k2'],
'a',
['b', 'c'],
'$seen',
'$flagged',
500,
['$ara-sending'],
]);
});
it('묶은 목록의 참여자 — 첫 보낸 사람 칸에서 주소(소문자)마다 처음 보낸 메일 하나, 그 차례로 상한까지 · 대화마다 (리뷰 L3 · L4)', () => {
const query = conversationParticipantsSql(
USER,
'a',
['k1', 'k2'],
EXCLUSIONS,
10,
);
const sql = flat(query.sql);
expect(sql).toContain('FROM unnest($2::text[]) AS "keys"("thread_key")');
expect(sql).toContain(
'SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM "mail_search_messages" ' +
'WHERE "user_email" = $1 AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL ' +
'AND (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")) AND NOT ("keywords" && $5::text[]) ' +
'ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC',
);
expect(sql).toContain(
'ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT $6) AS "first"',
);
expect(sql).not.toContain('from_text');
expect(query.params).toEqual([
USER,
['k1', 'k2'],
'a',
['b', 'c'],
['$ara-sending'],
10,
]);
});
it('빼는 것이 없으면 조건도 없다 (지금 메일의 예외도 없다)', () => {
const none = { excludedMailboxIds: [], excludedKeywords: [] };
const page = conversationPageSql(USER, 'm5', none, null, 10);
expect(flat(page.sql)).toContain(
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ORDER BY',
);
expect(page.params).toEqual([USER, 'm5', 10]);
const details = conversationDetailsSql(USER, 'a', ['k1'], none, 500);
expect(flat(details.sql)).toContain(
'count(*) FILTER (WHERE TRUE)::int AS "message_count"',
);
expect(flat(details.sql)).toContain('AND TRUE GROUP BY "thread_key"');
});
});
@@ -0,0 +1,207 @@
import {
FLAGGED_KEYWORD,
SEARCH_MESSAGES_TABLE,
SEEN_KEYWORD,
} from './mail-search.constants';
import type { ConversationPosition } from './conversation-cursor';
import type { SqlQuery } from './search-sql';
/**
* 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다.
* 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 —
* 메일함의 posting 은 회사 전체라(메일함 id 가 계정마다 같은 꼴) GIN 으로 거르지 않고 그 직원의 줄에서만 거른다(리뷰).
* 메일함 칸을 담은 GIN 은 찾기 GIN(lower 식 · 묶음 · 메일함) 하나뿐이고 이 쿼리들은 그것을 쓰지 않는다
*/
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
/** 대화에서 빼는 것 — 빼는 메일함에만 든 메일 · 빼는 키워드(내부 발송 초안) */
export interface ConversationExclusions {
readonly excludedMailboxIds: readonly string[];
readonly excludedKeywords: readonly string[];
}
/** 매개변수를 차례로 쌓는다 */
class Params {
readonly values: unknown[] = [];
add(value: unknown): string {
this.values.push(value);
return `$${this.values.length}`;
}
}
/** 빼는 키워드 — 없으면 늘 참 */
function keywordCondition(
params: Params,
exclusions: ConversationExclusions,
): string {
return exclusions.excludedKeywords.length > 0
? `NOT ("keywords" && ${params.add([...exclusions.excludedKeywords])}::text[])`
: 'TRUE';
}
/** 빼는 메일함에만 든 메일이 아니다 — 없으면 늘 참 */
function mailboxCondition(
params: Params,
exclusions: ConversationExclusions,
): string {
return exclusions.excludedMailboxIds.length > 0
? `NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[])`
: 'TRUE';
}
/**
* 같은 대화 한 쪽 — 지금 보는 메일(`emailId`)의 대화 키를 같은 문장에서 읽어(3차 리뷰 L2 — 키를 읽은 뒤 그 키가 합쳐져 빈 쪽이 나오지 않게 · 왕복 하나)
* 그 키의 줄을 오래된 차례(받은 시각 · id)로, `after` 다음부터 `limit` 줄. 빼는 메일함에만 든 메일 · 빼는 키워드의 메일(내부 발송 초안)은 빼되
* 지금 보는 메일은 넣는다(엔진 방식 `jmap-conversations.ts` 의 inConversation 과 같다).
* - 그 메일이 색인에 없으면 줄이 없다. 있는데 쪽이 비면 `email_id` 가 null 인 줄 하나다(LEFT JOIN LATERAL) — 둘을 가른다
* - `total` 은 이 쪽의 조건(이어 갈 자리 빼고)에 맞는 줄 수 — 첫 쪽에서만 쓴다
*/
export function conversationPageSql(
user: string,
emailId: string,
exclusions: ConversationExclusions,
after: ConversationPosition | null,
limit: number,
): SqlQuery {
const params = new Params();
const user$ = params.add(user);
const email$ = params.add(emailId);
const where = [
`"user_email" = ${user$}`,
`"thread_key" = "current"."thread_key"`,
];
const kept = [
mailboxCondition(params, exclusions),
keywordCondition(params, exclusions),
].filter((condition) => condition !== 'TRUE');
if (kept.length > 0) {
where.push(`((${kept.join(' AND ')}) OR "email_id" = ${email$})`);
}
if (after !== null) {
where.push(
`("received_at", "email_id") > (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`,
);
}
return {
sql:
`SELECT "page"."email_id", "page"."received_at", "page"."total"` +
` FROM (SELECT "thread_key" FROM ${TABLE} WHERE "user_email" = ${user$} AND "email_id" = ${email$}) AS "current"` +
` LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM ${TABLE}` +
` WHERE ${where.join(' AND ')} ORDER BY "received_at" ASC, "email_id" ASC LIMIT ${params.add(limit)}) AS "page" ON TRUE` +
` ORDER BY "page"."received_at" ASC, "page"."email_id" ASC`,
params: params.values,
};
}
/**
* 묶은 목록 한 쪽 — 그 메일함 안의 대화(대화 키)마다 그 메일함 안 마지막 받은 시각, 최신부터 `limit` 개.
* 그 직원의 줄(주소 btree)에서만 메일함 · 키워드로 거른 뒤 대화 키로 묶는다. `after` 면 그 대화 다음부터(받은 시각 · 키가 더 작은 쪽).
* `total` 은 묶은 대화 수(이어 갈 자리 다음의 것) — 첫 쪽에서만 쓴다
*/
export function mailboxConversationsSql(
user: string,
mailboxId: string,
exclusions: ConversationExclusions,
after: ConversationPosition | null,
limit: number,
): SqlQuery {
const params = new Params();
const where = [
`"user_email" = ${params.add(user)}`,
`${params.add(mailboxId)} = ANY("mailbox_ids")`,
keywordCondition(params, exclusions),
];
const having =
after === null
? ''
: ` HAVING (max("received_at"), "thread_key") < (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`;
return {
sql: `SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM ${TABLE} WHERE ${where.join(' AND ')} GROUP BY "thread_key"${having} ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT ${params.add(limit)}`,
params: params.values,
};
}
/**
* 묶은 목록의 대화마다 자세히 — 그 쪽의 대화 키만(주소 + 대화 키 btree).
* - 대화 전체(빼는 메일함에만 든 메일은 빼되 그 메일함의 메일은 넣는다): 통 수
* - 그 메일함 안: 메일 id(최신부터 `maxIds` 통까지 — 첫째가 마지막 메일, 3차 리뷰 L4) · 모두 읽음 · 하나라도 중요 · 하나라도 첨부
* 참여자는 conversationParticipantsSql 이 따로 읽는다(주소마다 한 번 · 상한까지 — 대화 전체의 보낸 사람 글을 끌어오지 않는다)
*/
export function conversationDetailsSql(
user: string,
mailboxId: string,
threadKeys: readonly string[],
exclusions: ConversationExclusions,
maxIds: number,
): SqlQuery {
const params = new Params();
const user$ = params.add(user);
const keys$ = params.add([...threadKeys]);
const mailbox$ = params.add(mailboxId);
const inMailbox = `${mailbox$} = ANY("mailbox_ids")`;
const kept = keptInConversation(params, exclusions, inMailbox);
const seen$ = params.add(SEEN_KEYWORD);
const flagged$ = params.add(FLAGGED_KEYWORD);
const maxIds$ = params.add(maxIds);
const keywords = keywordCondition(params, exclusions);
return {
sql:
`SELECT "thread_key",` +
` count(*) FILTER (WHERE ${kept})::int AS "message_count",` +
` (array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE ${inMailbox}))[1:${maxIds$}::int] AS "mailbox_message_ids",` +
` coalesce(bool_and(${seen$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), true) AS "is_read",` +
` coalesce(bool_or(${flagged$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), false) AS "is_flagged",` +
` coalesce(bool_or("has_attachment") FILTER (WHERE ${inMailbox}), false) AS "has_attachment"` +
` FROM ${TABLE} WHERE "user_email" = ${user$} AND "thread_key" = ANY(${keys$}::text[]) AND ${keywords} GROUP BY "thread_key"`,
params: params.values,
};
}
/**
* 묶은 목록의 대화마다 참여자 — 첫 보낸 사람 칸(from_email · from_name — 3차 리뷰 L3)에서 주소(소문자)마다 처음 보낸 메일 하나,
* 그 차례(받은 시각 · id)로 `max` 명까지(대화마다 LATERAL — 주소 + 대화 키 btree). 대화의 범위는 자세히(통 수)와 같다
*/
export function conversationParticipantsSql(
user: string,
mailboxId: string,
threadKeys: readonly string[],
exclusions: ConversationExclusions,
max: number,
): SqlQuery {
const params = new Params();
const user$ = params.add(user);
const keys$ = params.add([...threadKeys]);
const mailbox$ = params.add(mailboxId);
const kept = keptInConversation(
params,
exclusions,
`${mailbox$} = ANY("mailbox_ids")`,
);
const keywords = keywordCondition(params, exclusions);
const max$ = params.add(max);
return {
sql:
`SELECT "keys"."thread_key", "first"."from_email", "first"."from_name"` +
` FROM unnest(${keys$}::text[]) AS "keys"("thread_key")` +
` CROSS JOIN LATERAL (SELECT "senders"."from_email", "senders"."from_name", "senders"."received_at", "senders"."email_id" FROM (` +
`SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM ${TABLE}` +
` WHERE "user_email" = ${user$} AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL AND ${kept} AND ${keywords}` +
` ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC) AS "senders"` +
` ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT ${max$}) AS "first"` +
` ORDER BY "keys"."thread_key", "first"."received_at" ASC, "first"."email_id" ASC`,
params: params.values,
};
}
/** 대화에 드는 줄 — 빼는 메일함에만 든 메일이 아니거나 `orCondition`(그 메일함의 메일) */
function keptInConversation(
params: Params,
exclusions: ConversationExclusions,
orCondition: string,
): string {
return exclusions.excludedMailboxIds.length > 0
? `(NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[]) OR ${orCondition})`
: 'TRUE';
}
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import type {
SearchIndexStatus,
SearchIndexStatusView,
} from '@/modules/mail-search/mail-search.types';
/** GET /api/search-index 의 응답 (14단계 — 화면의 "검색 준비 중" 줄) */
export class SearchIndexStatusDto implements SearchIndexStatusView {
@ApiProperty({
description:
'색인 상태 — building: 지난 메일을 채우는 중(그동안 검색은 엔진 방식 — 낱말이 통째로 같은 메일만) · ready: 다 만들었음',
enum: ['building', 'ready'],
example: 'building',
})
status!: SearchIndexStatus;
@ApiProperty({
description: '채운 메일 수 — 다 만들었으면 그때 색인에 든 수',
example: 1200,
})
done!: number;
@ApiProperty({
description:
'채울 메일 수(채우기를 시작할 때의 메일 수) — 아직 시작하지 않았으면 null. 다 만들었으면 done 과 같다',
type: Number,
nullable: true,
example: 4800,
})
total!: number | null;
}
@@ -0,0 +1,232 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
firstSender,
isIndexableId,
referencesOf,
storableText,
takeCodePoints,
toIndexRow,
} from './index-document';
import type { IndexDocument } from './mail-index-source';
import {
INDEX_ADDRESSES_MAX,
INDEX_BODY_MAX_CHARS,
INDEX_FROM_NAME_MAX_CHARS,
INDEX_REFS_MAX,
INDEX_SUBJECT_MAX_CHARS,
SEARCH_EMAIL_COLUMN_LENGTH,
} from './mail-search.constants';
function documentOf(overrides: Partial<IndexDocument> = {}): IndexDocument {
return {
id: 'e1',
threadId: 't1',
mailboxIds: ['inbox'],
keywords: ['$seen'],
receivedAt: '2026-11-05T01:02:03Z',
size: 1234,
messageIds: ['m1@example.com'],
inReplyTo: [],
references: [],
subject: 'Re: [공지] 11월 계약서',
from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
to: [{ name: null, email: 'ara.kim@arasystem.test' }],
cc: [{ name: 'Partner', email: 'partner@example.com' }],
attachmentNames: ['계약서.PDF'],
hasAttachment: true,
preview: '계약서를 보내 드립니다',
bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
...overrides,
};
}
describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
it('원문은 NFC 그대로, 접기 칸은 소문자 · 반각 — 받는 사람 칸은 받는 사람과 참조를 함께', () => {
const row = toIndexRow(documentOf());
expect(row).toMatchObject({
emailId: 'e1',
mailboxIds: ['inbox'],
keywords: ['$seen'],
receivedAt: new Date('2026-11-05T01:02:03Z'),
size: 1234,
messageId: 'm1@example.com',
refs: [],
subject: 'Re: [공지] 11월 계약서',
subjectBase: '11월 계약서',
fromText: '이도윤 <doyun.lee@arasystem.test>',
toText: 'ara.kim@arasystem.test',
ccText: 'Partner <partner@example.com>',
attachmentNames: '계약서.PDF',
hasAttachment: true,
preview: '계약서를 보내 드립니다',
bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
foldFrom: '이도윤 <doyun.lee@arasystem.test>',
// 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2)
foldTo: 'ara.kim@arasystem.test partner <partner@example.com>',
foldSubject: 're: [공지] 11월 계약서',
foldAttachment: '계약서.pdf',
foldBody: '안녕하세요. 계약서를 보내 드립니다.',
threadKey: 'e1',
engineThreadId: 't1',
});
// 묶음은 다섯 칸에서 — 칸 경계를 넘지 않는다 (보낸 사람 끝 + 받는 사람 처음 같은 묶음이 없다)
expect(row.grams).toEqual(
expect.arrayContaining(['계약', '약서', 're', '공지']),
);
expect(row.grams).not.toContain('>a');
expect(new Set(row.grams).size).toBe(row.grams.length);
});
it('PostgreSQL 에 넣을 수 없는 글자(NUL · 짝 없는 서로게이트)는 U+FFFD — 위치는 그대로', () => {
const row = toIndexRow(
documentOf({ subject: 'a\u0000b\ud800c', bodyText: '\udc00본문' }),
);
expect(row.subject).toBe('a\ufffdb\ufffdc');
expect(row.bodyText).toBe('\ufffd본문');
expect(row.foldSubject.length).toBe(row.subject.length);
});
it('풀어 쓴 한글(NFD)은 NFC 로 모은 원문을 담는다', () => {
const row = toIndexRow(
documentOf({ subject: '\u1112\u1161\u11ab\u1100\u1173\u11af 메일' }),
);
expect(row.subject).toBe('한글 메일');
});
it('본문은 앞 INDEX_BODY_MAX_CHARS 코드 포인트 · 제목도 상한까지 · 주소는 INDEX_ADDRESSES_MAX 개까지', () => {
const row = toIndexRow(
documentOf({
bodyText: '가'.repeat(INDEX_BODY_MAX_CHARS + 500),
subject: 'S'.repeat(INDEX_SUBJECT_MAX_CHARS * 2),
to: Array.from({ length: INDEX_ADDRESSES_MAX + 50 }, (_, index) => ({
name: null,
email: `p${index}@example.com`,
})),
}),
);
expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS);
expect(row.subject).toHaveLength(INDEX_SUBJECT_MAX_CHARS);
expect(row.toText.split('\n')).toHaveLength(INDEX_ADDRESSES_MAX);
});
it('주소의 이름 · 주소 가운데 있는 것만 · 빈 첨부 이름은 뺀다 · 키워드 · 메일함은 겹치지 않게', () => {
const row = toIndexRow(
documentOf({
from: [
{ name: ' ', email: 'a@example.com' },
{ name: '이름만', email: null },
{ name: null, email: null },
],
attachmentNames: ['', ' 보고서.hwp '],
keywords: ['$seen', '$seen', '$flagged'],
mailboxIds: ['inbox', 'inbox'],
}),
);
expect(row.fromText).toBe('a@example.com\n이름만');
expect(row.attachmentNames).toBe('보고서.hwp');
expect(row.keywords).toEqual(['$seen', '$flagged']);
expect(row.mailboxIds).toEqual(['inbox']);
});
it('첫 보낸 사람 칸(참여자 — 리뷰 L3) — 주소 · 이름을 따로, 이름 안의 `<주소>` 꼴은 주소가 되지 않는다', () => {
// 주소 없이 이름만 `대표 <ceo@…>` — 보낸 사람 글(조건 검색)에는 남지만 참여자 칸은 비운다
const nameOnly = toIndexRow(
documentOf({ from: [{ name: '대표 <ceo@example.com>', email: null }] }),
);
expect(nameOnly.fromText).toBe('대표 <ceo@example.com>');
expect(nameOnly.fromEmail).toBeNull();
expect(nameOnly.fromName).toBeNull();
// 이름이 `<다른 주소>` 를 품어도 칸의 주소는 엔진이 준 주소다
const disguised = toIndexRow(
documentOf({
from: [
{ name: ' 대표 <ceo@example.com> ', email: ' real@example.net ' },
{ name: '둘째', email: 'second@example.com' },
],
}),
);
expect(disguised.fromEmail).toBe('real@example.net');
expect(disguised.fromName).toBe('대표 <ceo@example.com>');
expect(toIndexRow(documentOf({ from: [] })).fromEmail).toBeNull();
});
it('첫 보낸 사람 — 이름은 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다) · 주소가 열보다 길면 담지 않는다', () => {
const long = firstSender([
{
name: `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀`,
email: 'a@example.com',
},
]);
expect([...(long.name ?? '')]).toHaveLength(INDEX_FROM_NAME_MAX_CHARS);
expect(long.name?.endsWith('😀')).toBe(true);
const tooLong = `${'a'.repeat(SEARCH_EMAIL_COLUMN_LENGTH)}@example.com`;
expect(firstSender([{ name: '긴 주소', email: tooLong }])).toEqual({
email: null,
name: null,
});
expect(firstSender([{ name: ' ', email: 'a@example.com' }])).toEqual({
email: 'a@example.com',
name: null,
});
});
it('받은 시각이 날짜가 아니면 1970-01-01 · 크기는 32비트 정수 안 · Message-ID 가 없으면 null · 긴 대화 id 는 null', () => {
const row = toIndexRow(
documentOf({
receivedAt: 'not-a-date',
size: Number.MAX_SAFE_INTEGER,
messageIds: [],
threadId: 'x'.repeat(100),
}),
);
expect(row.receivedAt.getTime()).toBe(0);
expect(row.size).toBe(2_147_483_647);
expect(row.messageId).toBeNull();
expect(row.engineThreadId).toBeNull();
});
it('1MiB 본문도 길이에 비례해 담는다 (자르기 전에 통째로 NFC 하지 않는다)', () => {
const body = '계약서를 보내 드립니다. Project plan '.repeat(40_000);
const cpu = startCpuTimer();
const row = toIndexRow(documentOf({ bodyText: body }));
expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS);
expect(cpu()).toBeLessThan(3_000);
});
});
describe('referencesOf (답장 관계)', () => {
it('In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게', () => {
expect(referencesOf(['c@x'], ['a@x', 'b@x', 'c@x'])).toEqual([
'c@x',
'b@x',
'a@x',
]);
});
it('앞 INDEX_REFS_MAX 개 · 빈 것 · 너무 긴 id 는 뺀다', () => {
const many = Array.from({ length: 80 }, (_, index) => `r${index}@x`);
const refs = referencesOf([], many);
expect(refs).toHaveLength(INDEX_REFS_MAX);
expect(refs[0]).toBe('r79@x');
expect(referencesOf(['', ' ', 'x'.repeat(300)], ['ok@x'])).toEqual([
'ok@x',
]);
});
});
describe('takeCodePoints · storableText · isIndexableId', () => {
it('서로게이트 쌍을 가르지 않는다', () => {
expect(takeCodePoints('😀😀😀', 2)).toBe('😀😀');
expect(takeCodePoints('ab', 5)).toBe('ab');
expect(storableText('😀😀😀', 1)).toBe('😀');
});
it('엔진 id 는 비지 않고 열 길이 안이어야 한다', () => {
expect(isIndexableId('abc')).toBe(true);
expect(isIndexableId('')).toBe(false);
expect(isIndexableId('x'.repeat(65))).toBe(false);
});
});
@@ -0,0 +1,247 @@
import { foldText, gramsOf } from '@/shared/utils/search-text';
import { subjectBase } from '@/shared/utils/subject-base';
import type { IndexAddress, IndexDocument } from './mail-index-source';
import {
INDEX_ADDRESSES_MAX,
INDEX_ADDRESS_LINE_MAX_CHARS,
INDEX_ATTACHMENT_NAMES_MAX,
INDEX_ATTACHMENT_NAME_MAX_CHARS,
INDEX_BODY_MAX_CHARS,
INDEX_FROM_NAME_MAX_CHARS,
INDEX_MESSAGE_ID_MAX_LENGTH,
INDEX_PREVIEW_MAX_CHARS,
INDEX_REFS_MAX,
INDEX_SUBJECT_MAX_CHARS,
SEARCH_EMAIL_COLUMN_LENGTH,
SEARCH_ID_COLUMN_LENGTH,
} from './mail-search.constants';
/**
* 엔진의 메일 한 통 → 색인 한 줄의 값 (순수 함수 — 14단계 설계 A1 · B1).
* - 글 칸은 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)으로 두고, 칸마다 길이 상한(mail-search.constants.ts)에서 자른다 — 코드 포인트로 세고 서로게이트 쌍을 가르지 않는다
* - PostgreSQL 의 text 는 NUL(U+0000)을 받지 않고 짝이 없는 서로게이트는 UTF-8 로 보낼 때 U+FFFD 가 된다 — 둘 다 먼저 U+FFFD 로 바꾼다
* (한 단위를 한 단위로 — 위치가 바뀌지 않는다. 접은 글 · 묶음이 저장된 원문과 같아지게)
* - 접기 글(fold_*)은 foldText — 원문과 위치가 같다. 받는 사람 칸은 받는 사람과 참조를 줄바꿈으로 이어 접는다
* - 묶음(grams)은 다섯 접기 칸에서 따로 뽑아 합친다(칸 경계를 넘지 않는다, 겹치지 않게)
* - 답장 관계(refs): In-Reply-To 다음 References 를 가까운 것(뒤)부터, 겹치지 않게 앞 INDEX_REFS_MAX 개. 너무 긴 id 는 뺀다
* - 첫 보낸 사람은 주소 · 이름을 따로도 둔다(from_email · from_name — 묶은 목록의 참여자, 3차 리뷰 L3). 주소가 없으면 null — 이름 안의 `<주소>` 꼴은 주소로 읽지 않는다
* - 대화 키는 넣을 때 그 메일 id 다 — 색인에 쓸 때 이어진 메일의 키와 합친다(3차 — 설계 B4, mail-search-index.repository.ts 의 linkThreads)
*/
/** 색인 한 줄의 값 — 표의 칸과 같다 (user_email · indexed_at 은 쓰는 쪽이 붙인다) */
export interface IndexRowValues {
readonly emailId: string;
readonly mailboxIds: string[];
readonly keywords: string[];
readonly receivedAt: Date;
readonly size: number;
readonly messageId: string | null;
readonly refs: string[];
readonly subject: string;
readonly subjectBase: string;
readonly fromText: string;
readonly fromEmail: string | null;
readonly fromName: string | null;
readonly toText: string;
readonly ccText: string;
readonly attachmentNames: string;
readonly hasAttachment: boolean;
readonly preview: string;
readonly bodyText: string;
readonly foldFrom: string;
readonly foldTo: string;
readonly foldSubject: string;
readonly foldAttachment: string;
readonly foldBody: string;
readonly grams: string[];
readonly threadKey: string;
readonly engineThreadId: string | null;
}
/** NUL · 짝이 없는 서로게이트 — PostgreSQL text 에 그대로 넣을 수 없는 글자 */
const UNSTORABLE =
// eslint-disable-next-line no-control-regex
/\u0000|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g;
const REPLACEMENT_CHARACTER = '\ufffd';
/** 자르기 전에 볼 원문의 길이 — 풀어 쓴 한글(NFD)은 NFC 로 모으면 셋이 하나가 되므로 넉넉히 */
const NFC_SLACK = 4;
/** 32비트 정수 열(size)의 상한 */
const MAX_INT32 = 2_147_483_647;
/** 앞에서 코드 포인트 `max` 개까지 (서로게이트 쌍을 가르지 않는다) */
export function takeCodePoints(text: string, max: number): string {
if (text.length <= max) return text;
let units = 0;
let points = 0;
while (units < text.length && points < max) {
const code = text.charCodeAt(units);
units +=
code >= 0xd800 &&
code <= 0xdbff &&
units + 1 < text.length &&
(text.charCodeAt(units + 1) & 0xfc00) === 0xdc00
? 2
: 1;
points += 1;
}
return text.slice(0, units);
}
/** 저장할 원문 — 넣을 수 없는 글자를 바꾸고, NFC 로 모으고, 코드 포인트 `max` 개까지 */
export function storableText(text: string, max: number): string {
// 아주 긴 글(본문 1MiB 등)을 통째로 NFC 하지 않게 먼저 넉넉히 자른다
const head =
text.length > max * NFC_SLACK ? text.slice(0, max * NFC_SLACK) : text;
return takeCodePoints(
head.replace(UNSTORABLE, REPLACEMENT_CHARACTER).normalize('NFC'),
max,
);
}
/** "이름 <주소>" — 이름이 없으면 주소, 주소가 없으면 이름 */
function addressLine(address: IndexAddress): string {
const name = (address.name ?? '').trim();
const email = (address.email ?? '').trim();
const line = name && email ? `${name} <${email}>` : name || email;
return storableText(line, INDEX_ADDRESS_LINE_MAX_CHARS);
}
/**
* 참여자 이름 — 앞뒤 공백을 지운 NFC, 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다), 비면 null.
* 색인의 첫 보낸 사람 칸(firstSender)과 엔진 방식 대화의 참여자(modules/mail/jmap/jmap-conversations.ts)가 같은 자르기를 쓴다
*/
export function participantName(
name: string | null | undefined,
): string | null {
const trimmed = storableText(
(name ?? '').trim(),
INDEX_FROM_NAME_MAX_CHARS,
).trim();
return trimmed === '' ? null : trimmed;
}
/**
* 첫 보낸 사람 — 주소(앞뒤 공백을 지운 NFC, SEARCH_EMAIL_COLUMN_LENGTH 를 넘거나 없으면 null)와 이름(participantName).
* 목록 요약(jmap.mapper.ts 의 toAddress — `from[0]`)과 같은 사람이다. 주소가 없으면 이름도 두지 않는다(참여자는 주소로 센다)
*/
export function firstSender(addresses: readonly IndexAddress[]): {
readonly email: string | null;
readonly name: string | null;
} {
const first = addresses[0];
const email = storableText(
(first?.email ?? '').trim(),
SEARCH_EMAIL_COLUMN_LENGTH + 1,
);
if (email === '' || email.length > SEARCH_EMAIL_COLUMN_LENGTH) {
return { email: null, name: null };
}
return { email, name: participantName(first?.name) };
}
function addressText(addresses: readonly IndexAddress[]): string {
return addresses
.slice(0, INDEX_ADDRESSES_MAX)
.map(addressLine)
.filter((line) => line !== '')
.join('\n');
}
/** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게 앞 INDEX_REFS_MAX 개 */
export function referencesOf(
inReplyTo: readonly string[],
references: readonly string[],
): string[] {
const refs: string[] = [];
const seen = new Set<string>();
for (const raw of [...inReplyTo, ...[...references].reverse()]) {
const ref = storableText(raw.trim(), INDEX_MESSAGE_ID_MAX_LENGTH + 1);
if (ref === '' || ref.length > INDEX_MESSAGE_ID_MAX_LENGTH) continue;
if (seen.has(ref)) continue;
seen.add(ref);
refs.push(ref);
if (refs.length >= INDEX_REFS_MAX) break;
}
return refs;
}
/** 첫 Message-ID — 없거나 너무 길면 null */
function firstMessageId(messageIds: readonly string[]): string | null {
const first = storableText(
(messageIds[0] ?? '').trim(),
INDEX_MESSAGE_ID_MAX_LENGTH + 1,
);
return first === '' || first.length > INDEX_MESSAGE_ID_MAX_LENGTH
? null
: first;
}
/** 열 길이 안의 엔진 id 인가 */
export function isIndexableId(id: string): boolean {
return id !== '' && id.length <= SEARCH_ID_COLUMN_LENGTH;
}
/** 엔진의 메일 한 통 → 색인 한 줄의 값. 받은 시각이 날짜가 아니면 1970-01-01 (목록과 같다) */
export function toIndexRow(document: IndexDocument): IndexRowValues {
const subject = storableText(document.subject, INDEX_SUBJECT_MAX_CHARS);
const fromText = addressText(document.from);
const sender = firstSender(document.from);
const toText = addressText(document.to);
const ccText = addressText(document.cc);
const attachmentNames = document.attachmentNames
.slice(0, INDEX_ATTACHMENT_NAMES_MAX)
.map((name) => storableText(name.trim(), INDEX_ATTACHMENT_NAME_MAX_CHARS))
.filter((name) => name !== '')
.join('\n');
const bodyText = storableText(document.bodyText, INDEX_BODY_MAX_CHARS);
const foldFrom = foldText(fromText);
const foldTo = foldText([toText, ccText].filter(Boolean).join('\n'));
const foldSubject = foldText(subject);
const foldAttachment = foldText(attachmentNames);
const foldBody = foldText(bodyText);
const grams = new Set<string>();
for (const fold of [
foldFrom,
foldTo,
foldSubject,
foldAttachment,
foldBody,
]) {
gramsOf(fold, grams);
}
const receivedAt = Date.parse(document.receivedAt);
const threadId = document.threadId ?? '';
return {
emailId: document.id,
mailboxIds: [...new Set(document.mailboxIds)],
keywords: [...new Set(document.keywords)],
receivedAt: new Date(Number.isNaN(receivedAt) ? 0 : receivedAt),
size: Math.min(MAX_INT32, Math.max(0, Math.trunc(document.size) || 0)),
messageId: firstMessageId(document.messageIds),
refs: referencesOf(document.inReplyTo, document.references),
subject,
subjectBase: subjectBase(subject),
fromText,
fromEmail: sender.email,
fromName: sender.name,
toText,
ccText,
attachmentNames,
hasAttachment: document.hasAttachment,
preview: storableText(document.preview, INDEX_PREVIEW_MAX_CHARS),
bodyText,
foldFrom,
foldTo,
foldSubject,
foldAttachment,
foldBody,
grams: [...grams],
// 처음 키는 그 메일 id — 색인에 쓸 때 이어진 메일의 키와 합친다(mail-search-index.repository.ts 의 linkThreads)
threadKey: document.id,
engineThreadId: isIndexableId(threadId) ? threadId : null,
};
}
@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import type { DataSource } from 'typeorm';
import {
encodeConversationCursor,
type ConversationPosition,
} from './conversation-cursor';
import {
conversationDetailsSql,
conversationPageSql,
conversationParticipantsSql,
mailboxConversationsSql,
type ConversationExclusions,
} from './conversation-sql';
import {
CONVERSATION_MESSAGE_IDS_MAX,
CONVERSATION_PARTICIPANTS_MAX,
} from './mail-search.constants';
import type { ConversationParticipant } from './mail-search.types';
/** 같은 대화 한 쪽 — 메일 id 만(요약은 부르는 쪽이 엔진에서 읽는다) */
export interface IndexConversationPage {
readonly ids: readonly string[];
readonly hasNext: boolean;
readonly nextCursor: string | null;
/** 첫 쪽에서만 — 다음 쪽은 null */
readonly total: number | null;
}
/** 묶은 목록의 대화 하나 (색인) — 마지막 메일의 요약은 부르는 쪽이 엔진에서 읽는다 */
export interface IndexConversation {
readonly key: string;
/** 그 메일함 안 마지막 메일 */
readonly latestId: string;
/** 그 메일함 안 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 */
readonly mailboxMessageIds: readonly string[];
readonly messageCount: number;
readonly participants: readonly ConversationParticipant[];
readonly isRead: boolean;
readonly isFlagged: boolean;
readonly hasAttachment: boolean;
}
export interface IndexConversationList {
readonly items: readonly IndexConversation[];
readonly hasNext: boolean;
readonly nextCursor: string | null;
readonly total: number | null;
}
/** 쪽을 읽는 자리 — 커서는 서비스 입구에서 읽어(VAL_001 — 엔진 · DB 에 묻기 전) 자리로 넘긴다 (3차 리뷰 L1) */
export interface ConversationPageRequest {
readonly limit: number;
readonly after: ConversationPosition | null;
}
interface PageRow {
/** 그 메일은 색인에 있는데 쪽이 비었으면 null (LEFT JOIN LATERAL) */
readonly email_id: string | null;
readonly received_at: Date | string | null;
readonly total: number | null;
}
interface ListRow {
readonly thread_key: string;
readonly latest_at: Date | string;
readonly total: number;
}
interface DetailRow {
readonly thread_key: string;
readonly message_count: number;
readonly mailbox_message_ids: string[] | null;
readonly is_read: boolean;
readonly is_flagged: boolean;
readonly has_attachment: boolean;
}
interface ParticipantRow {
readonly thread_key: string;
readonly from_email: string;
readonly from_name: string | null;
}
function dateOf(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
function normalize(user: string): string {
return user.trim().toLowerCase();
}
/**
* 색인으로 본 대화 (14단계 설계 C3 · C4) — 대화 키(B4)로 묶는다. 엔진을 모른다(빼는 메일함 · 키워드는 부르는 쪽이 엔진에서 받아 준다).
* - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread)
* - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터
* CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지)
* 모든 쿼리는 그 직원 주소(btree)로 먼저 좁히고 메일함은 그 줄에서 거른다 — 메일함의 GIN posting(회사 전체)을 쓰지 않는다(conversation-sql.ts)
*/
@Injectable()
export class MailConversationQueryService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
/**
* 같은 대화 한 쪽 — 오래된 차례. 지금 보는 메일(`emailId`)은 빼는 메일함에 있어도 넣는다.
* 그 메일이 색인에 없으면 null (부르는 쪽이 첫 쪽이면 엔진으로, 이어 가는 쪽이면 RES_001)
*/
async conversation(
user: string,
emailId: string,
exclusions: ConversationExclusions,
page: ConversationPageRequest,
): Promise<IndexConversationPage | null> {
const query = conversationPageSql(
normalize(user),
emailId,
exclusions,
page.after,
page.limit + 1,
);
const rows = await this.dataSource.query<PageRow[]>(
query.sql,
query.params,
);
if (rows.length === 0) return null;
const found = rows.flatMap((row) =>
row.email_id !== null && row.received_at !== null
? [
{
id: row.email_id,
at: dateOf(row.received_at),
total: row.total,
},
]
: [],
);
const shown = found.slice(0, page.limit);
const hasNext = found.length > page.limit;
const last = shown[shown.length - 1];
return {
ids: shown.map((row) => row.id),
hasNext,
nextCursor:
hasNext && last
? encodeConversationCursor(
'thread',
{ at: last.at, key: last.id },
emailId,
)
: null,
total: page.after === null ? Number(found[0]?.total ?? 0) : null,
};
}
/** 묶은 목록 한 쪽 — 그 메일함 안 마지막 받은 시각 최신부터 */
async mailboxConversations(
user: string,
mailboxId: string,
exclusions: ConversationExclusions,
page: ConversationPageRequest,
): Promise<IndexConversationList> {
const key = normalize(user);
const { after } = page;
// 쪽 · 자세히 · 참여자는 같은 스냅숏에서 — 그사이 온 메일로 대화 키가 합쳐져도 한 쪽 안에서는 어긋나지 않게
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const listQuery = mailboxConversationsSql(
key,
mailboxId,
exclusions,
after,
page.limit + 1,
);
const rows = await manager.query<ListRow[]>(
listQuery.sql,
listQuery.params,
);
const shown = rows.slice(0, page.limit);
const hasNext = rows.length > page.limit;
const keys = shown.map((row) => row.thread_key);
let detailRows: DetailRow[] = [];
let participantRows: ParticipantRow[] = [];
if (keys.length > 0) {
const detailQuery = conversationDetailsSql(
key,
mailboxId,
keys,
exclusions,
CONVERSATION_MESSAGE_IDS_MAX,
);
detailRows = await manager.query<DetailRow[]>(
detailQuery.sql,
detailQuery.params,
);
const participantQuery = conversationParticipantsSql(
key,
mailboxId,
keys,
exclusions,
CONVERSATION_PARTICIPANTS_MAX,
);
participantRows = await manager.query<ParticipantRow[]>(
participantQuery.sql,
participantQuery.params,
);
}
const details = new Map(detailRows.map((row) => [row.thread_key, row]));
const participants = new Map<string, ConversationParticipant[]>();
for (const row of participantRows) {
const list = participants.get(row.thread_key) ?? [];
list.push({ name: row.from_name, email: row.from_email });
participants.set(row.thread_key, list);
}
const items = shown.flatMap((row): IndexConversation[] => {
const detail = details.get(row.thread_key);
const ids = detail?.mailbox_message_ids ?? [];
if (!detail || ids.length === 0) return [];
return [
{
key: row.thread_key,
latestId: ids[0],
mailboxMessageIds: ids,
messageCount: Math.max(Number(detail.message_count), 1),
participants: participants.get(row.thread_key) ?? [],
isRead: detail.is_read,
isFlagged: detail.is_flagged,
hasAttachment: detail.has_attachment,
},
];
});
const last = shown[shown.length - 1];
return {
items,
hasNext,
nextCursor:
hasNext && last
? encodeConversationCursor(
'list',
{ at: dateOf(last.latest_at), key: last.thread_key },
mailboxId,
)
: null,
total: after === null ? Number(rows[0]?.total ?? 0) : null,
};
});
}
}
@@ -0,0 +1,132 @@
/**
* 메일 검색 색인이 메일 엔진에서 읽는 것 — 포트 (14단계 설계 B2). 엔진을 모르는 쪽(맞추기 · 채우기)은 이것만 안다.
* 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-index-source.ts`. 엔진을 바꿀 때는 mail-search.module.ts 의 useClass 만 바꾼다.
*
* 실패는 BusinessException 으로 던진다 — 대리 접근이 막힌 계정은 메일 포트와 같은 예외(PasswordExpiredException · AccountLoginDisabledException ·
* AUTH_001), 엔진 장애 MAIL_001, 엔진 한도 REQ_001.
*/
export const MAIL_INDEX_SOURCE = Symbol('MAIL_INDEX_SOURCE');
/** 한 번의 엔진 읽기의 끝 시각(epoch ms) — 넘으면 엔진 호출을 그만둔다 (호출마다 남은 시간만큼만 기다린다) */
export interface IndexReadOptions {
readonly deadline: number;
}
/**
* 차례의 끝 시각에 닿았다 — 남은 시간이 INDEX_CALL_MIN_MS 보다 적어 엔진 읽기를 시작하지 않았거나, 남은 시간으로 줄인 기다림이 끝나 끊었다.
* **실패가 아니다** — 맞추기 · 채우기는 쓴 데까지를 두고 다음 차례에 이어 한다(실패 수 · 경고 없음). 응답으로 나가지 않는다(배경 일에서만 쓴다)
*/
export class IndexDeadlineError extends Error {
constructor() {
super('검색 색인 차례의 끝 시각');
this.name = 'IndexDeadlineError';
}
}
/** 주소 하나 — 이름이 없으면 null */
export interface IndexAddress {
readonly name: string | null;
readonly email: string | null;
}
/** 색인에 넣을 메일 한 통 — 엔진이 준 그대로(자르기 · 접기는 index-document.ts 가 한다) */
export interface IndexDocument {
readonly id: string;
/** 엔진의 대화(threadId) — 없으면 null */
readonly threadId: string | null;
readonly mailboxIds: readonly string[];
/** 켜진 키워드 */
readonly keywords: readonly string[];
/** 받은 시각 (ISO 8601) */
readonly receivedAt: string;
readonly size: number;
/** 머리글 Message-ID 들 (꺾쇠 없이) */
readonly messageIds: readonly string[];
readonly inReplyTo: readonly string[];
readonly references: readonly string[];
readonly subject: string;
readonly from: readonly IndexAddress[];
readonly to: readonly IndexAddress[];
readonly cc: readonly IndexAddress[];
/** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 빠진다 */
readonly attachmentNames: readonly string[];
/** 목록과 같은 첨부 판정 */
readonly hasAttachment: boolean;
readonly preview: string;
/** 본문 글 — HTML 본문이면 글자만, 부분은 줄바꿈으로 잇는다. 엔진이 부분마다 앞쪽만 줄 수 있다 */
readonly bodyText: string;
}
/** 바뀔 수 있는 칸만 (JMAP 에서 메일의 글은 바뀌지 않는다 — 메일함 · 키워드뿐) */
export interface IndexMutable {
readonly id: string;
readonly mailboxIds: readonly string[];
readonly keywords: readonly string[];
}
/** 엔진 Email state 부터의 변화 — 만들어진 · 바뀐 · 없어진 메일 id */
export type IndexChanges =
| {
readonly kind: 'changes';
readonly created: readonly string[];
readonly updated: readonly string[];
readonly destroyed: readonly string[];
readonly newState: string;
/** 한 번에 다 주지 못했다 — newState 부터 다시 묻는다 */
readonly hasMore: boolean;
}
/** 엔진이 그 state 부터의 변화를 셀 수 없다 — 전체 대조로 */
| { readonly kind: 'cannotCalculate' };
/** 받은 차례(최신부터) 메일 id 의 한 쪽 */
export interface IndexIdPage {
readonly ids: readonly string[];
/** 이 쪽의 첫 메일 자리 (0 부터) */
readonly position: number;
/** 엔진이 센 모든 메일 수 — 세지 않았으면 null */
readonly total: number | null;
}
/** 엔진의 모든 메일 id 와 그것을 읽기 바로 전의 Email state (전체 대조) */
export interface IndexAllIds {
readonly state: string;
readonly ids: readonly string[];
}
export interface MailIndexSource {
/** 지금 Email state 와 모든 메일 수 (채우기를 시작할 때 — 싼 읽기) */
snapshot(
user: string,
options: IndexReadOptions,
): Promise<{ readonly state: string; readonly total: number | null }>;
/** sinceState 부터의 변화 — 한 번에 maxChanges 개까지 */
changes(
user: string,
sinceState: string,
maxChanges: number,
options: IndexReadOptions,
): Promise<IndexChanges>;
/** 메일 글 — 그사이 없어진 메일은 빠진다 (차례는 ids 를 따르지 않아도 된다) */
documents(
user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexDocument[]>;
/** 메일함 · 키워드만 — 그사이 없어진 메일은 빠진다 */
mutable(
user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexMutable[]>;
/**
* 받은 차례(최신부터) 한 쪽 — anchor(지난 쪽의 마지막 메일) 다음부터. anchor 가 없거나 그사이 지워졌으면 `position` 부터
*/
pageIds(
user: string,
from: { readonly anchor: string | null; readonly position: number },
limit: number,
options: IndexReadOptions,
): Promise<IndexIdPage>;
/** 모든 메일 id — 전체 대조 (그 바로 전의 state 와 함께) */
allIds(user: string, options: IndexReadOptions): Promise<IndexAllIds>;
}
@@ -0,0 +1,280 @@
import {
Inject,
Injectable,
Logger,
type OnApplicationBootstrap,
Optional,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { maskEmail } from '@/shared/utils/mask';
import { isIndexableId } from './index-document';
import {
IndexDeadlineError,
MAIL_INDEX_SOURCE,
type MailIndexSource,
} from './mail-index-source';
import { MailSearchIndexRepository } from './mail-search-index.repository';
import { MailSearchRuns } from './mail-search-runs';
import { MailSearchSyncService } from './mail-search-sync.service';
import {
MAIL_SEARCH_TIMING,
SYSTEM_SEARCH_TIMING,
type MailSearchTiming,
} from './mail-search-timing';
import {
BUILD_BATCH_PAUSE_MS,
BUILD_BATCH_SIZE,
BUILD_RUN_BUDGET_MS,
FINISH_MIN_REMAINING_MS,
INDEX_FORMAT,
SEARCH_CRON_TIME_ZONE,
STEP_MIN_REMAINING_MS,
} from './mail-search.constants';
import { isBuildWindow, isDueAfterFailures } from './search-index-schedule';
/** 밤 채우기 차례에서 한 번에 읽어 볼 직원 수 — 실패가 이어진 직원 · 다른 도메인 줄을 건너뛰고 고를 만큼 */
const BUILD_CANDIDATES_LIMIT = 20;
const MS_PER_MINUTE = 60_000;
/**
* 한 직원의 채우기 결과 — 다 만들었음 · 이어 하기(끝 시각 · 창이 닫힘 · 마무리를 새 차례로 넘김) · 할 일 없음(줄이 없음 · 이미 ready · 멈춤) ·
* 실패(상태 줄에 적었다 — 실패 수 · 멈춤)
*/
export type BuildOutcome = 'ready' | 'partial' | 'skipped' | 'failed';
/** 한 묶음의 결과 */
type StepOutcome = 'continue' | BuildOutcome;
/**
* 지난 메일 채우기 (14단계 설계 B3) — 직원이 처음 로그인하면 상태 줄이 `building` 으로 생기고, 밤에 한 사람씩 채운다.
*
* - 시작: 엔진의 지금 Email state(S0)와 메일 수를 적는다 — 그때부터 새로 온 메일 · 바뀐 메일은 맞추기(B2)가 바로 넣는다
* - 받은 차례 최신부터 BUILD_BATCH_SIZE 통씩 글을 읽어 넣고 마지막 메일(anchor) · 넣은 수를 적는다 — 한 묶음마다 직원의 차례(MailSearchRuns)를
* 한 번씩 잡고 놓는다(그사이 맞추기가 끼어든다). 묶음 뒤 BUILD_BATCH_PAUSE_MS 쉰다(엔진 메모리 1g)
* - anchor 가 지워졌으면 넣은 수의 위치로 이어 간다 — 몇 통 겹치거나 빠질 수 있어 마지막 대조가 채운다
* - 다 읽으면: 맞추기로 따라잡고 → 전체 대조(빠진 것 넣기 · 없어진 것 지우기) → `ready`. 이 마무리는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만
* (= 새 차례의 처음에) 시작한다 — 시간 안에 끝나지 않으면 이어 하기로 두고 다음 차례에 다시 한다(넣은 것은 남으니 메일함이 커도 끝내 ready 가 된다)
* - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸리면
* (IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기로 끝낸다(실패 수 · 경고 · 미루기 없음)
* - 밤에만: 1분마다 깨어 창(isBuildWindow — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30) 안이면 사내 도메인 직원 한 사람을 BUILD_RUN_BUDGET_MS 동안.
* 창이 닫히면 다음 묶음을 시작하지 않는다. 시험에서는 build() 를 바로 불러 창을 건너뛴다
* 다 만들기 전 그 직원의 검색 · 대화는 엔진 방식이다(14단계 2차 · 3차가 상태를 본다).
*
* ⚠️ 1분 차례의 겹침 막기(running)는 프로세스 메모리다 — 인스턴스가 둘이면 두 차례가 같은 직원을 함께 채운다 (nestjs 스킬의 Redis 목록)
*/
@Injectable()
export class MailSearchBuildService implements OnApplicationBootstrap {
private readonly logger = new Logger(MailSearchBuildService.name);
private running = false;
/** 판이 다른 줄을 되돌렸나 — 서버가 뜰 때 못 했으면 1분 차례마다 다시 해 본다 (그동안 상태 API · 검색은 판을 보고 building 으로 다룬다) */
private formatsReset = false;
private readonly timing: MailSearchTiming;
constructor(
@Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource,
private readonly repository: MailSearchIndexRepository,
private readonly runs: MailSearchRuns,
private readonly sync: MailSearchSyncService,
@Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming,
) {
this.timing = timing ?? SYSTEM_SEARCH_TIMING;
}
/**
* 서버가 뜰 때 — 판(INDEX_FORMAT)이 다른 사내 직원의 상태 줄을 building 으로 되돌린다(밤에 다시 채운다 — 그동안 검색은 엔진 방식).
* 되돌리지 못해도 서버는 뜬다 — 1분 차례가 다시 해 본다. 그동안 그 직원은 ready 여도 판이 달라 검색은 엔진 방식이고(isSearchReady)
* 상태 API 도 building 으로 보인다(toStatusView)
*/
async onApplicationBootstrap(): Promise<void> {
await this.resetOutdatedFormats();
}
/** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */
private async resetOutdatedFormats(): Promise<void> {
const suffix = this.sync.indexedAddressSuffix;
if (suffix === null || this.formatsReset) return;
try {
const reset = await this.repository.resetOutdatedFormats(
suffix,
INDEX_FORMAT,
new Date(this.timing.now()),
);
this.formatsReset = true;
if (reset > 0) {
// 배포마다 한 번뿐인 상태 변화 — 수만 (주소 없음)
this.logger.log(
`검색 색인의 판이 바뀌어 ${reset}명의 색인을 밤에 다시 채웁니다 (판 ${INDEX_FORMAT}).`,
);
}
} catch (error) {
this.logger.warn(
`[SYS_001] 판이 바뀐 검색 색인을 되돌리지 못했습니다(그 직원의 검색은 엔진 방식 — 1분 뒤 다시): ${error instanceof Error ? error.name : typeof error}`,
);
}
}
/** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */
@Cron('0 * * * * *', {
name: 'mail-search-build',
timeZone: SEARCH_CRON_TIME_ZONE,
})
async tick(): Promise<BuildOutcome | null> {
const startedAt = this.timing.now();
if (this.running || this.sync.isStopping) return null;
// 서버가 뜰 때 판을 되돌리지 못했으면 낮에도 다시 해 본다 (한 번 되면 하지 않는다)
await this.resetOutdatedFormats();
if (!isBuildWindow(new Date(startedAt))) return null;
this.running = true;
try {
const candidates = await this.repository.buildCandidates(
BUILD_CANDIDATES_LIMIT,
);
const next = candidates.find(
(state) =>
this.sync.isIndexedAddress(state.userEmail) &&
isDueAfterFailures(state.failureCount, state.updatedAt, startedAt),
);
if (!next) return null;
return await this.build(
next.userEmail,
startedAt + BUILD_RUN_BUDGET_MS,
() => isBuildWindow(new Date(this.timing.now())),
);
} catch (error) {
this.logger.error(
`[SYS_001] 검색 색인 채우기 차례를 읽지 못했습니다: ${error instanceof Error ? error.name : typeof error}`,
);
return null;
} finally {
this.running = false;
}
}
/**
* 한 직원의 지난 메일을 끝 시각까지 채운다 — `keepGoing` 이 false 가 되면(밤 창이 닫힘) 다음 묶음을 시작하지 않는다. 던지지 않는다
*/
async build(
user: string,
deadline: number,
keepGoing: () => boolean = () => true,
): Promise<BuildOutcome> {
const key = user.trim().toLowerCase();
for (;;) {
if (!this.hasTime(deadline) || !keepGoing()) return 'partial';
const step = await this.runs.run(key, () => this.step(key, deadline));
if (step !== 'continue') return step;
await this.timing.sleep(BUILD_BATCH_PAUSE_MS);
}
}
/** 한 묶음 — 그 직원의 차례 안에서 */
private async step(user: string, deadline: number): Promise<StepOutcome> {
try {
if (!this.hasTime(deadline)) return 'partial';
const state = await this.repository.findState(user);
if (
!state ||
state.status !== 'building' ||
state.pausedReason !== null
) {
return 'skipped';
}
const now = new Date(this.timing.now());
if (state.buildStartedAt === null || state.emailState === null) {
// 시작 — 지금 state(S0)부터는 맞추기가 넣는다. 처음 시작이면(판을 되돌린 직원 포함) 남은 색인 줄을 함께 지운다 — 옛 판 줄이 남지 않게
const snapshot = await this.source.snapshot(user, { deadline });
const result = await this.repository.write(
user,
{
clearIndex: state.buildStartedAt === null,
state: {
emailState: snapshot.state,
buildStartedAt: now,
buildTotal: snapshot.total,
buildDone: 0,
buildAnchor: null,
},
},
now,
);
return result.written ? 'continue' : 'skipped';
}
const page = await this.source.pageIds(
user,
{ anchor: state.buildAnchor, position: state.buildDone },
BUILD_BATCH_SIZE,
{ deadline },
);
if (page.ids.length === 0) {
// 다 읽었다 — 마무리는 새 차례의 처음에만 (남은 시간이 모자라면 다음 차례가 이것부터 한다)
if (this.timing.now() > deadline - FINISH_MIN_REMAINING_MS) {
return 'partial';
}
return await this.finish(user, deadline);
}
const indexable = page.ids.filter(isIndexableId);
const rows = await this.sync.readRows(user, indexable, deadline);
if (rows === null) return 'partial';
const result = await this.repository.write(
user,
{
upserts: rows,
state: {
// 다음 쪽의 자리 — 표에 넣을 수 있는 id 만(열 길이). 하나도 없으면 넣은 수의 위치로 잇는다
buildAnchor: indexable[indexable.length - 1] ?? null,
buildDone: page.position + page.ids.length,
},
},
new Date(this.timing.now()),
);
return result.written ? 'continue' : 'skipped';
} catch (error) {
// 차례의 끝 시각에 걸렸다 — 실패가 아니라 이어 하기 (이 묶음은 쓰지 않았다)
if (error instanceof IndexDeadlineError) return 'partial';
await this.sync.handleFailure(user, error, 'build');
return 'failed';
}
}
/** 다 읽었다 — 따라잡기 → 전체 대조(빠진 것 넣기) → ready (던진다 — step 이 받는다) */
private async finish(user: string, deadline: number): Promise<StepOutcome> {
if (!(await this.sync.catchUp(user, deadline))) return 'partial';
const startedAt = (await this.repository.findState(user))?.buildStartedAt;
let count = 0;
const done = await this.sync.reconcile(user, deadline, {
addMissing: true,
refreshMutable: false,
finalState: (indexed) => {
count = indexed;
return {
status: 'ready',
readyAt: new Date(this.timing.now()),
buildAnchor: null,
buildDone: indexed,
buildTotal: indexed,
};
},
});
if (!done) return 'partial';
const minutes = startedAt
? Math.round((this.timing.now() - startedAt.getTime()) / MS_PER_MINUTE)
: null;
// 직원마다 한 번뿐인 상태 변화라 info 로 남긴다 (메일 수 · 걸린 때만 — 글 · 주소 없음)
this.logger.log(
`검색 색인을 다 만들었습니다: user=${maskEmail(user)}, 메일 ${count}통${minutes === null ? '' : `, 시작부터 ${minutes}분`}`,
);
return 'ready';
}
/** 다음 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */
private hasTime(deadline: number): boolean {
return (
!this.sync.isStopping &&
this.timing.now() <= deadline - STEP_MIN_REMAINING_MS
);
}
}
@@ -0,0 +1,410 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import type { DataSource, EntityManager } from 'typeorm';
import type { IndexRowValues } from './index-document';
import type { IndexMutable } from './mail-index-source';
import { MailSearchMessageEntity } from './mail-search-message.entity';
import { MailSearchStateEntity } from './mail-search-state.entity';
import {
INDEX_FORMAT,
SEARCH_MESSAGES_TABLE,
SEARCH_STATES_TABLE,
} from './mail-search.constants';
import { mergeThreadKeys } from './thread-keys';
import type {
SearchIndexStatus,
SearchPausedReason,
} from './mail-search.types';
/** 상태 줄에 함께 쓰는 칸 (쓰기가 성공하면 실패 수는 0 으로) */
export interface SearchStatePatch {
readonly status?: SearchIndexStatus;
readonly emailState?: string | null;
readonly buildAnchor?: string | null;
readonly buildDone?: number;
readonly buildTotal?: number | null;
readonly buildStartedAt?: Date | null;
readonly readyAt?: Date | null;
readonly lastSyncedAt?: Date | null;
}
/** 한 트랜잭션에 쓰는 것 — (그 직원의 색인 줄 모두 지우기) → 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */
export interface SearchIndexWrite {
/**
* 그 직원의 색인 줄을 모두 지운다 — 채우기를 처음부터 시작할 때(판이 바뀌어 되돌린 직원 포함). 채우기는 위치로 건너뛸 수 있고 마지막 대조는
* 없는 줄만 넣으므로, 남겨 두면 옛 판(접기 규칙)의 줄이 그대로 남았다. 그동안(building) 검색은 엔진 방식이다
*/
readonly clearIndex?: boolean;
readonly deletes?: readonly string[];
readonly upserts?: readonly IndexRowValues[];
readonly updates?: readonly IndexMutable[];
readonly state?: SearchStatePatch;
}
/** 쓰기 결과 — 상태 줄이 없으면(퇴사 마무리가 지웠다) 아무것도 쓰지 않고 written false */
export interface SearchIndexWriteResult {
readonly written: boolean;
/** 메일함 · 키워드를 바꾼 메일 id — 색인에 없던 메일은 빠진다 */
readonly updatedIds: ReadonlySet<string>;
}
/**
* 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id)와 대화 키(thread_key) 밖의 모두.
* 대화 키는 이미 있는 줄이면 그대로 둔다 — 합친 대화를 다시 넣기(겹친 채우기 등)가 풀지 않게 (새 줄은 그 메일의 id 로 넣고 linkThreads 가 잇는다)
*/
const UPSERT_COLUMNS = [
'mailbox_ids',
'keywords',
'received_at',
'size',
'message_id',
'refs',
'subject',
'subject_base',
'from_text',
'from_email',
'from_name',
'to_text',
'cc_text',
'attachment_names',
'has_attachment',
'preview',
'body_text',
'fold_from',
'fold_to',
'fold_subject',
'fold_attachment',
'fold_body',
'grams',
'engine_thread_id',
'indexed_at',
];
/**
* 메일 검색 색인 표(`mail_search_messages` · `mail_search_states`) 접근 (14단계 설계 A).
* 색인 줄을 쓰는 일(write)은 모두 그 직원의 상태 줄을 잠그고(FOR UPDATE) 같은 트랜잭션에서 한다 — 상태 줄이 없으면 쓰지 않는다
* (퇴사 마무리가 상태 줄 → 색인 줄 차례로 지운다 — ops-data.service.ts. 그사이 끝난 맞추기가 줄을 되살리지 않게).
* 언제나 직원 주소(user_email)로 묻는다.
*/
@Injectable()
export class MailSearchIndexRepository {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
findState(userEmail: string): Promise<MailSearchStateEntity | null> {
return this.dataSource.manager.findOneBy(MailSearchStateEntity, {
userEmail,
});
}
/**
* 상태 줄이 없으면 `building` 으로 만들고, 멈춰 있으면 푼다(실패 수도 0) — 직원이 로그인했으니 대리 접근이 다시 될 것이다.
* 있고 멈추지 않았으면 쓰지 않는다(로그인 · 상태 묻기마다 줄을 고쳐 쓰지 않게).
*
* 퇴사 마무리 바로 뒤 남은 액세스 토큰으로 GET /api/search-index 가 오면 빈 상태 줄(building)이 다시 생길 수 있다 — 고치지 않는다:
* 엔진 계정이 먼저 지워졌으므로 채우기 · 맞추기는 대리 접근에서 막혀(AUTH_001) 그 줄을 멈출 뿐 메일 줄은 생기지 않고,
* 끊은 세션은 15초 안에 막힌다. 같은 주소를 새 사람이 쓰면 그 사람의 로그인이 그 줄로 처음부터 채운다(옛 메일 줄이 없다)
*/
async ensureState(
userEmail: string,
now: Date,
): Promise<MailSearchStateEntity> {
const existing = await this.findState(userEmail);
if (existing && existing.pausedReason === null) return existing;
if (existing) {
await this.dataSource.manager.update(
MailSearchStateEntity,
{ userEmail },
{ pausedReason: null, failureCount: 0, updatedAt: now },
);
} else {
await this.dataSource.manager
.createQueryBuilder()
.insert()
.into(MailSearchStateEntity)
.values({
userEmail,
status: 'building',
emailState: null,
buildAnchor: null,
buildDone: 0,
buildTotal: null,
buildStartedAt: null,
readyAt: null,
lastSyncedAt: null,
pausedReason: null,
failureCount: 0,
indexFormat: INDEX_FORMAT,
createdAt: now,
updatedAt: now,
})
.orIgnore()
.execute();
}
const saved = await this.findState(userEmail);
if (!saved) {
// 만든 바로 뒤 다른 곳(퇴사 마무리)이 지웠다 — 처음 상태로 본다
const fresh = new MailSearchStateEntity();
Object.assign(fresh, {
userEmail,
status: 'building',
emailState: null,
buildAnchor: null,
buildDone: 0,
buildTotal: null,
buildStartedAt: null,
readyAt: null,
lastSyncedAt: null,
pausedReason: null,
failureCount: 0,
indexFormat: INDEX_FORMAT,
createdAt: now,
updatedAt: now,
});
return fresh;
}
return saved;
}
/**
* 판이 다른 줄을 building 으로 되돌린다 (색인 판 — mail-search.constants.ts 의 INDEX_FORMAT) → 되돌린 줄 수.
* 맞출 기준 · 채우기 자리를 지워 밤에 처음부터 다시 채운다 — 옛 색인 줄은 채우기가 시작하는 첫 묶음에서 지운다(SearchIndexWrite 의 clearIndex).
* 그동안 그 직원의 검색은 엔진 방식이다. `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 (같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다)
*/
async resetOutdatedFormats(
addressSuffix: string,
format: number,
now: Date,
): Promise<number> {
const [rows] = await this.dataSource.query<[unknown[], number]>(
`UPDATE "${SEARCH_STATES_TABLE}"
SET "status" = 'building', "email_state" = NULL, "build_anchor" = NULL, "build_done" = 0, "build_total" = NULL,
"build_started_at" = NULL, "ready_at" = NULL, "failure_count" = 0, "index_format" = $2, "updated_at" = $3
WHERE "index_format" <> $2 AND right("user_email", length($1)) = $1
RETURNING "user_email"`,
[addressSuffix, format, now],
);
return rows.length;
}
/** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */
syncCandidates(): Promise<MailSearchStateEntity[]> {
return this.dataSource.manager
.createQueryBuilder(MailSearchStateEntity, 'state')
.where('state.email_state IS NOT NULL')
.andWhere('state.paused_reason IS NULL')
.orderBy('state.last_synced_at', 'ASC', 'NULLS FIRST')
.addOrderBy('state.user_email', 'ASC')
.getMany();
}
/** 밤 채우기의 차례 — building 이고 멈추지 않은 직원: 실패가 적은 · 먼저 시작한 · 먼저 만든 차례로 */
buildCandidates(limit: number): Promise<MailSearchStateEntity[]> {
return this.dataSource.manager
.createQueryBuilder(MailSearchStateEntity, 'state')
.where('state.status = :status', { status: 'building' })
.andWhere('state.paused_reason IS NULL')
.orderBy('state.failure_count', 'ASC')
.addOrderBy('state.build_started_at', 'ASC', 'NULLS LAST')
.addOrderBy('state.created_at', 'ASC')
.limit(limit)
.getMany();
}
/** 실패 한 번 → 이어진 실패 수 (줄이 없으면 0) */
async recordFailure(userEmail: string, now: Date): Promise<number> {
// UPDATE 의 결과는 [줄들, 바꾼 수] (TypeORM 의 postgres 드라이버)
const [rows] = await this.dataSource.query<
[{ failure_count: number }[], number]
>(
`UPDATE "${SEARCH_STATES_TABLE}" SET "failure_count" = "failure_count" + 1, "updated_at" = $2 WHERE "user_email" = $1 RETURNING "failure_count"`,
[userEmail, now],
);
return Number(rows[0]?.failure_count ?? 0);
}
/** 대리 접근이 막혀 멈춘다 (다음 로그인 때 ensureState 가 푼다) */
async pause(
userEmail: string,
reason: SearchPausedReason,
now: Date,
): Promise<void> {
await this.dataSource.manager.update(
MailSearchStateEntity,
{ userEmail },
{ pausedReason: reason, updatedAt: now },
);
}
/** 그 직원의 색인 줄 수 */
async countMessages(userEmail: string): Promise<number> {
const rows: { count: number }[] = await this.dataSource.query(
`SELECT count(*)::int AS "count" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
[userEmail],
);
return Number(rows[0]?.count ?? 0);
}
/** 주어진 id 가운데 그 직원의 색인에 있는 것 */
async existingIds(
userEmail: string,
ids: readonly string[],
): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows: { email_id: string }[] = await this.dataSource.query(
`SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
[userEmail, [...ids]],
);
return new Set(rows.map((row) => row.email_id));
}
/** 그 직원의 색인에 든 메일 id 모두 (전체 대조) */
async indexedIds(userEmail: string): Promise<string[]> {
const rows: { email_id: string }[] = await this.dataSource.query(
`SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
[userEmail],
);
return rows.map((row) => row.email_id);
}
/**
* 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), (모두 지우기) → 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0).
* `now` 는 넣은 줄의 indexed_at · 상태 줄의 updated_at
*/
async write(
userEmail: string,
change: SearchIndexWrite,
now: Date,
): Promise<SearchIndexWriteResult> {
return this.dataSource.transaction(async (manager) => {
const locked: unknown[] = await manager.query(
`SELECT 1 FROM "${SEARCH_STATES_TABLE}" WHERE "user_email" = $1 FOR UPDATE`,
[userEmail],
);
if (locked.length === 0) {
return { written: false, updatedIds: new Set<string>() };
}
if (change.clearIndex) {
await manager.query(
`DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
[userEmail],
);
}
const deletes = [...new Set(change.deletes ?? [])];
if (deletes.length > 0) {
await manager.query(
`DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
[userEmail, deletes],
);
}
const upserted = await this.upsertRows(
manager,
userEmail,
change.upserts ?? [],
now,
);
await this.linkThreads(manager, userEmail, upserted);
const updatedIds = await this.updateMutable(
manager,
userEmail,
change.updates ?? [],
);
if (change.state) {
await manager.update(
MailSearchStateEntity,
{ userEmail },
{ ...change.state, failureCount: 0, updatedAt: now },
);
}
return { written: true, updatedIds };
});
}
/** 넣기(있으면 대화 키 밖의 모든 칸을 바꾸기) → 넣은 id — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
private async upsertRows(
manager: EntityManager,
userEmail: string,
rows: readonly IndexRowValues[],
now: Date,
): Promise<string[]> {
if (rows.length === 0) return [];
const latest = new Map(rows.map((row) => [row.emailId, row]));
await manager
.createQueryBuilder()
.insert()
.into(MailSearchMessageEntity)
.values(
[...latest.values()].map((row) => ({
...row,
userEmail,
indexedAt: now,
})),
)
.orUpdate(UPSERT_COLUMNS, ['user_email', 'email_id'])
.execute();
return [...latest.keys()];
}
/**
* 대화 키 잇기 (14단계 설계 B4) — 넣은 메일 M 마다, M 의 답장 관계(refs)가 가리키는 메일과 M 을 가리키는 메일 가운데 말머리를 뗀 제목이 같은 것의
* 키를 M 의 키와 하나로(가장 작은 키 — thread-keys.ts). Message-ID 가 없는 메일은 잇지 않는다(혼자). 그 직원의 줄만 본다.
* 쿼리 둘: 이어진 키 쌍 읽기(주소 + Message-ID 색인 · refs GIN) → 바꿀 키를 한 문장으로 UPDATE(주소 + 대화 키 색인). 쓰기 트랜잭션 안이다(상태 줄 잠금 — 같은 직원은 차례로)
*/
private async linkThreads(
manager: EntityManager,
userEmail: string,
ids: readonly string[],
): Promise<void> {
if (ids.length === 0) return;
const links = await manager.query<{ a: string; b: string }[]>(
`SELECT "n"."thread_key" AS "a", "o"."thread_key" AS "b"
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
ON "o"."user_email" = "n"."user_email" AND "o"."message_id" = ANY("n"."refs") AND "o"."subject_base" = "n"."subject_base"
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
AND "o"."thread_key" <> "n"."thread_key"
UNION
SELECT "n"."thread_key", "o"."thread_key"
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
ON "o"."user_email" = "n"."user_email" AND "o"."refs" @> ARRAY["n"."message_id"]::text[] AND "o"."subject_base" = "n"."subject_base"
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
AND "o"."message_id" IS NOT NULL AND "o"."thread_key" <> "n"."thread_key"`,
[userEmail, [...ids]],
);
const renames = mergeThreadKeys(links.map((link) => [link.a, link.b]));
if (renames.size === 0) return;
await manager.query(
`UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message" SET "thread_key" = "rename"."to"
FROM unnest($2::text[], $3::text[]) AS "rename"("from", "to")
WHERE "message"."user_email" = $1 AND "message"."thread_key" = "rename"."from"`,
[userEmail, [...renames.keys()], [...renames.values()]],
);
}
/** 메일함 · 키워드만 바꾼다 (한 문장) → 바꾼 id */
private async updateMutable(
manager: EntityManager,
userEmail: string,
updates: readonly IndexMutable[],
): Promise<Set<string>> {
if (updates.length === 0) return new Set();
const values: Record<string, { m: string[]; k: string[] }> = {};
for (const update of updates) {
values[update.id] = {
m: [...new Set(update.mailboxIds)],
k: [...new Set(update.keywords)],
};
}
const [rows] = await manager.query<[{ email_id: string }[], number]>(
`UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message"
SET "mailbox_ids" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'm')),
"keywords" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'k'))
FROM jsonb_each($2::jsonb) AS "change"("key", "value")
WHERE "message"."user_email" = $1 AND "message"."email_id" = "change"."key"
RETURNING "message"."email_id"`,
[userEmail, JSON.stringify(values)],
);
return new Set(rows.map((row) => row.email_id));
}
}
@@ -0,0 +1,135 @@
import { Logger } from '@nestjs/common';
import type { MailSearchIndexRepository } from './mail-search-index.repository';
import {
MailSearchIndexService,
toStatusView,
} from './mail-search-index.service';
import { INDEX_FORMAT } from './mail-search.constants';
const ARA = 'ara.kim@arasystem.test';
describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
let ensureState: jest.Mock;
let findState: jest.Mock;
let service: MailSearchIndexService;
let warnings: string[];
beforeEach(() => {
ensureState = jest.fn().mockResolvedValue({
status: 'building',
buildDone: 0,
buildTotal: null,
indexFormat: INDEX_FORMAT,
});
findState = jest.fn().mockResolvedValue(null);
service = new MailSearchIndexService({
ensureState,
findState,
} as unknown as MailSearchIndexRepository);
warnings = [];
jest.spyOn(Logger.prototype, 'warn').mockImplementation((message) => {
warnings.push(String(message));
});
});
afterEach(() => jest.restoreAllMocks());
it('상태를 물으면 줄을 만들거나 멈춤을 풀고(소문자 주소로) 준비 상태를 준다', async () => {
expect(await service.status(' Ara.Kim@ARASYSTEM.test ')).toEqual({
status: 'building',
done: 0,
total: null,
});
expect(ensureState).toHaveBeenCalledWith(ARA, expect.any(Date));
});
it('로그인 뒤 줄 만들기가 실패해도 던지지 않는다 — 경고만 (주소 · DB 문장은 가려서)', async () => {
ensureState.mockRejectedValue(
Object.assign(new Error(`duplicate key (${ARA})`), { code: '23505' }),
);
await expect(service.enrollAfterLogin(ARA)).resolves.toBeUndefined();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('Error/23505');
expect(warnings[0]).not.toContain(ARA);
});
it('toStatusView — 다 만들었으면 채운 수 = 채울 수', () => {
expect(
toStatusView({
status: 'ready',
buildDone: 120,
buildTotal: 120,
indexFormat: INDEX_FORMAT,
}),
).toEqual({ status: 'ready', done: 120, total: 120 });
});
it('toStatusView — 채운 수는 채울 수를 넘지 않는다 (채우는 동안 온 메일 · 위치로 이어 간 겹침) · 시작 전에는 채울 수 null', () => {
expect(
toStatusView({
status: 'building',
buildDone: 130,
buildTotal: 120,
indexFormat: INDEX_FORMAT,
}),
).toEqual({ status: 'building', done: 120, total: 120 });
expect(
toStatusView({
status: 'building',
buildDone: 0,
buildTotal: null,
indexFormat: INDEX_FORMAT,
}),
).toEqual({ status: 'building', done: 0, total: null });
});
it('toStatusView — 판이 다른 줄은 되돌리기 전이어도 building · 채운 0 (검색 방식과 같은 판정)', () => {
expect(
toStatusView({
status: 'ready',
buildDone: 120,
buildTotal: 120,
indexFormat: INDEX_FORMAT - 1,
}),
).toEqual({ status: 'building', done: 0, total: null });
});
describe('색인으로 찾을 수 있나 (isSearchReady)', () => {
const ready = {
status: 'ready',
indexFormat: INDEX_FORMAT,
pausedReason: null,
};
it('다 만들었고 · 지금 판이고 · 멈추지 않았을 때만 (소문자 주소로 묻는다)', async () => {
findState.mockResolvedValue(ready);
await expect(
service.isSearchReady(' Ara.Kim@ARASYSTEM.test '),
).resolves.toBe(true);
expect(findState).toHaveBeenCalledWith(ARA);
});
it.each([
['줄 없음', null],
['채우는 중', { ...ready, status: 'building' }],
['판이 다름(되돌리기 전)', { ...ready, indexFormat: INDEX_FORMAT - 1 }],
['대리 접근이 막혀 멈춤', { ...ready, pausedReason: 'locked' }],
])('%s 이면 엔진 방식', async (_label, state) => {
findState.mockResolvedValue(state);
await expect(service.isSearchReady(ARA)).resolves.toBe(false);
});
it('상태를 읽지 못하면(DB 장애) 엔진 방식 — 경고만, 주소 · DB 문장은 가린다', async () => {
findState.mockRejectedValue(
Object.assign(new Error(`connection to ${ARA} lost`), {
code: '57P01',
}),
);
await expect(service.isSearchReady(ARA)).resolves.toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('Error/57P01');
expect(warnings[0]).not.toContain(ARA);
});
});
});
@@ -0,0 +1,94 @@
import { Injectable, Logger } from '@nestjs/common';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSearchIndexRepository } from './mail-search-index.repository';
import { INDEX_FORMAT } from './mail-search.constants';
import type { MailSearchStateEntity } from './mail-search-state.entity';
import type { SearchIndexStatusView } from './mail-search.types';
/**
* 상태 줄 → 화면의 준비 상태 (GET /api/search-index). 채운 수는 채울 수를 넘지 않게 묶는다 —
* 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다.
* 판(INDEX_FORMAT)이 다른 줄은 아직 되돌리지 못했어도 building · 채운 0 으로 보인다 — 검색 방식(isSearchReady)과 같은 판정이다
*/
export function toStatusView(
state: Pick<
MailSearchStateEntity,
'status' | 'buildDone' | 'buildTotal' | 'indexFormat'
>,
): SearchIndexStatusView {
if (state.indexFormat !== INDEX_FORMAT) {
return { status: 'building', done: 0, total: null };
}
const done = Math.max(0, state.buildDone);
const total =
state.buildTotal === null ? null : Math.max(0, state.buildTotal);
return {
status: state.status,
done: total === null ? done : Math.min(done, total),
total,
};
}
/** DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만 */
function errorKind(error: unknown): string {
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name}/${code}` : name;
}
/**
* 검색 색인의 직원 상태 (14단계 설계 A2 · C1) — 상태 줄 만들기 · 멈춤 풀기 · 준비 상태 알리기.
* 줄은 직원이 웹메일에 로그인할 때(AuthService) · 검색 준비 상태를 물을 때 만든다 — 로그인한 적 없는 계정은 색인하지 않는다.
* 대리 접근이 막혀 멈췄던 줄은 이때 푼다(로그인했으니 다시 될 것이다).
*/
@Injectable()
export class MailSearchIndexService {
private readonly logger = new Logger(MailSearchIndexService.name);
constructor(private readonly repository: MailSearchIndexRepository) {}
/** 로그인에 성공한 직원 — 상태 줄을 만들거나 멈춤을 푼다. 던지지 않는다(못 해도 로그인은 그대로 — 다음 로그인 · 검색 때 다시) */
async enrollAfterLogin(user: string): Promise<void> {
try {
await this.repository.ensureState(normalize(user), new Date());
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인의 상태 줄을 만들지 못했습니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${errorKind(error)}`,
);
}
}
/**
* 색인으로 찾을 수 있나 (14단계 — 검색 방식 고르기) — 다 만들었고(ready) · 지금 판(INDEX_FORMAT)이고 · 멈추지 않았을 때.
* 상태를 읽지 못하면(DB 장애) false — 엔진 방식으로 찾는다(검색을 실패로 만들지 않는다)
*/
async isSearchReady(user: string): Promise<boolean> {
try {
const state = await this.repository.findState(normalize(user));
return (
state !== null &&
state.status === 'ready' &&
state.indexFormat === INDEX_FORMAT &&
state.pausedReason === null
);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인 상태를 읽지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user)}, reason=${errorKind(error)}`,
);
return false;
}
}
/** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */
async status(user: string): Promise<SearchIndexStatusView> {
return toStatusView(
await this.repository.ensureState(normalize(user), new Date()),
);
}
}
function normalize(user: string): string {
return user.trim().toLowerCase();
}
@@ -0,0 +1,171 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
INDEX_FROM_NAME_MAX_CHARS,
SEARCH_EMAIL_COLUMN_LENGTH,
SEARCH_ID_COLUMN_LENGTH,
SEARCH_MESSAGES_TABLE,
} from './mail-search.constants';
/**
* 메일 검색 색인의 한 줄 — 직원 주소 + 엔진 메일 id 마다 (14단계 설계 A1).
* 언제나 로그인한 직원 주소(user_email)로만 묻는다 — 남의 줄을 읽는 길이 없다.
*
* 글 칸은 둘씩이다 — 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)과 접기 글(fold_* — shared/utils/search-text.ts 의 foldText, strpos 확인용).
* 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고
* GIN 으로 후보를 좁힌다.
*
* GIN 색인 둘((lower(user_email), grams, mailbox_ids) · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
* (migration:generate 가 지우지 않게). 찾기 GIN 은 직원 주소 · 묶음 · 메일함을 한 색인에 담는다(btree_gin — 14단계 2차 `SearchIndexUserGrams` →
* 실사용 시험 `SearchIndexUserGramsMailboxes`). GIN 의 posting 은 칸마다 따로라 흔한 묶음 · 메일함 id 는 **회사 전체** 줄의 posting 이다
* (메일함 id 는 계정마다 같은 꼴(a · b …)이다) — 색인 하나가 그 직원 주소의 posting 과 맞추고 맞은 줄만 읽는다(10명 흉내에서 '계약' 7만여 줄 · 수 ms).
* 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. **메일함만 담은 GIN 을 다시 만들지 않는다** — 계획기가 그것만으로
* 메일함의 줄을 모두 읽고 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 고를 수 있게 된다(2026-11-06 — 1만 통 메일함 안 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1.6초)
*/
@Entity({ name: SEARCH_MESSAGES_TABLE })
@Index('IDX_mail_search_messages_received', [
'userEmail',
'receivedAt',
'emailId',
])
@Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId'])
@Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey'])
@Index('IDX_mail_search_messages_user_grams_mailboxes', { synchronize: false })
@Index('IDX_mail_search_messages_refs', { synchronize: false })
export class MailSearchMessageEntity {
@PrimaryColumn({
name: 'user_email',
type: 'varchar',
length: SEARCH_EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
/** 엔진 메일 id */
@PrimaryColumn({
name: 'email_id',
type: 'varchar',
length: SEARCH_ID_COLUMN_LENGTH,
})
emailId!: string;
/** 든 메일함 id 들 — 메일함 범위 · 휴지통 · 스팸메일함 빼기 */
@Column({ name: 'mailbox_ids', type: 'text', array: true })
mailboxIds!: string[];
/** 켜진 키워드(`$seen` · `$flagged` · `$draft` · 우리 내부 키워드) — 엔진이 준 그대로 */
@Column({ type: 'text', array: true })
keywords!: string[];
/** 받은 시각 — 차례 · 기간 */
@Column({ name: 'received_at', type: 'timestamptz' })
receivedAt!: Date;
@Column({ type: 'integer' })
size!: number;
/** 첫 Message-ID (꺾쇠 없이) — 없으면 null */
@Column({ name: 'message_id', type: 'text', nullable: true })
messageId!: string | null;
/** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터(겹치지 않게, 앞 INDEX_REFS_MAX 개) */
@Column({ type: 'text', array: true })
refs!: string[];
/** 제목 원문(NFC) */
@Column({ type: 'text' })
subject!: string;
/** 말머리를 뗀 제목의 접기 글 — 대화 키를 정할 때 (shared/utils/subject-base.ts) */
@Column({ name: 'subject_base', type: 'text' })
subjectBase!: string;
/** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) — 보낸 사람 조건 검색(fold_from)의 바탕 */
@Column({ name: 'from_text', type: 'text' })
fromText!: string;
/**
* 첫 보낸 사람의 주소 (14단계 3차 리뷰 L3 — 묶은 목록의 참여자) — 엔진이 준 주소 그대로(앞뒤 공백만 지운 NFC). 주소가 없거나
* SEARCH_EMAIL_COLUMN_LENGTH 를 넘으면 null — 이름에 든 `<주소>` 꼴을 주소로 읽지 않는다 (참여자는 이 칸만 본다)
*/
@Column({
name: 'from_email',
type: 'varchar',
length: SEARCH_EMAIL_COLUMN_LENGTH,
nullable: true,
})
fromEmail!: string | null;
/** 첫 보낸 사람의 이름 — 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트, 없으면 null */
@Column({
name: 'from_name',
type: 'varchar',
length: INDEX_FROM_NAME_MAX_CHARS,
nullable: true,
})
fromName!: string | null;
@Column({ name: 'to_text', type: 'text' })
toText!: string;
@Column({ name: 'cc_text', type: 'text' })
ccText!: string;
/** 진짜 첨부(본문 그림이 아닌 것)의 이름을 줄바꿈으로 */
@Column({ name: 'attachment_names', type: 'text' })
attachmentNames!: string;
/** 목록과 같은 판정 (html/inline-images.ts 의 hasRealAttachment) */
@Column({ name: 'has_attachment', type: 'boolean' })
hasAttachment!: boolean;
/** 엔진의 미리 보기 */
@Column({ type: 'text' })
preview!: string;
/** 본문 글(HTML 이면 글자만) 앞 INDEX_BODY_MAX_CHARS 코드 포인트 */
@Column({ name: 'body_text', type: 'text' })
bodyText!: string;
@Column({ name: 'fold_from', type: 'text' })
foldFrom!: string;
/** 받는 사람과 참조를 함께 (조건 검색의 "받는 사람"은 참조를 포함한다) */
@Column({ name: 'fold_to', type: 'text' })
foldTo!: string;
@Column({ name: 'fold_subject', type: 'text' })
foldSubject!: string;
@Column({ name: 'fold_attachment', type: 'text' })
foldAttachment!: string;
@Column({ name: 'fold_body', type: 'text' })
foldBody!: string;
/** 다섯 접기 칸의 글자 두 개 묶음 — GIN (`grams @> 찾는 말의 묶음`) */
@Column({ type: 'text', array: true })
grams!: string[];
/**
* 우리 대화 키 (14단계 3차 — 설계 B4) — 넣을 때는 그 메일 id, 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 대화의 가장 작은 키로 합친다
* (mail-search-index.repository.ts 의 linkThreads). 다시 넣어도 이미 있는 줄의 키는 그대로다
*/
@Column({
name: 'thread_key',
type: 'varchar',
length: SEARCH_ID_COLUMN_LENGTH,
})
threadKey!: string;
/** 엔진의 threadId (참고용 — 색인을 다 만들기 전의 대화 · 묶기는 엔진에 바로 묻는다) */
@Column({
name: 'engine_thread_id',
type: 'varchar',
length: SEARCH_ID_COLUMN_LENGTH,
nullable: true,
})
engineThreadId!: string | null;
@Column({ name: 'indexed_at', type: 'timestamptz' })
indexedAt!: Date;
}
@@ -0,0 +1,573 @@
import type { DataSource } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
SEARCH_CANDIDATE_COUNT_LIMIT,
SEARCH_CHECK_ROWS_GROWTH,
SEARCH_CHECK_ROWS_LIMIT,
SEARCH_FIRST_CHECK_ROWS,
SEARCH_TOTAL_LIMIT,
} from './mail-search.constants';
import { MailSearchQueryService } from './mail-search-query.service';
import type { SearchCriteria, SearchScope } from './mail-search.types';
import {
decodeIndexCursor,
encodeIndexCursor,
searchScopeKey,
} from './search-cursor';
import { planSearch } from './search-plan';
const USER = 'ara.kim@arasystem.test';
const SCOPE: SearchScope = {
mailboxId: null,
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
};
/** 쿼리의 종류 — 쿼리마다 다른 모양으로 가른다 (search-sql.ts) */
type QueryKind =
| 'candidates'
| 'check'
| 'keyset'
| 'frontier'
| 'keysetCount'
| 'plainPage'
| 'plainCount'
| 'highlight';
function kindOf(sql: string): QueryKind {
if (sql.includes('lower("user_email")')) return 'candidates';
if (sql.includes('"body_text"')) return 'highlight';
if (sql.includes('AS "hits"')) return 'keysetCount';
if (sql.includes('AS "scanned"')) return 'frontier';
if (sql.includes('AS "matches"')) return 'plainCount';
if (sql.includes('AS "scan"')) return 'keyset';
if (sql.includes('= ANY(')) return 'check';
return 'plainPage';
}
interface Recorded {
readonly kind: QueryKind;
readonly sql: string;
readonly params: unknown[];
/** 그 쿼리 앞에 건 계획기 설정 (그 트랜잭션의 것) */
readonly settings: string[];
/** 몇 번째 트랜잭션의 쿼리인가 (트랜잭션 밖이면 null) · 그 격리 수준 */
readonly transaction: number | null;
readonly isolation: string | null;
}
interface Row {
readonly email_id: string;
readonly received_at: Date;
}
/** 받은 시각이 최신부터인 줄들 — id 는 m1, m2 … */
function rows(count: number, from = 0): Row[] {
return Array.from({ length: count }, (_, index) => ({
email_id: `m${from + index + 1}`,
received_at: new Date(
Date.UTC(2026, 10, 2, 0, 0, 0) - (from + index) * 60_000,
),
}));
}
/** 확인 쿼리의 id 매개변수 */
const idsOf = (query: Recorded) => query.params[1] as string[];
function fakeDataSource(
answers: Partial<Record<QueryKind, (params: unknown[]) => unknown[]>>,
) {
const recorded: Recorded[] = [];
let transactions = 0;
const answer = (
sql: string,
params: unknown[] | undefined,
context: {
settings: string[];
transaction: number | null;
isolation: string | null;
},
) => {
const kind = kindOf(sql);
recorded.push({ kind, sql, params: params ?? [], ...context });
const respond = answers[kind];
if (!respond) throw new Error(`준비하지 않은 쿼리: ${kind}`);
return Promise.resolve(respond(params ?? []));
};
const dataSource = {
transaction: jest.fn(
(
isolation: string,
run: (manager: {
query: (sql: string, params?: unknown[]) => Promise<unknown>;
}) => Promise<unknown>,
) => {
transactions += 1;
const context = {
settings: [] as string[],
transaction: transactions,
isolation,
};
return run({
query: (sql: string, params?: unknown[]) => {
if (sql.startsWith('SET LOCAL')) {
context.settings.push(sql);
return Promise.resolve([]);
}
return answer(sql, params, context);
},
});
},
),
query: jest.fn((sql: string, params?: unknown[]) =>
answer(sql, params, { settings: [], transaction: null, isolation: null }),
),
};
return {
recorded,
service: new MailSearchQueryService(dataSource as unknown as DataSource),
};
}
/** 확인 — 준 id 가운데 `matches` 를 만족하는 것만 맞았다고 답한다 */
const answerChecks =
(matches: (id: string) => boolean) =>
(params: unknown[]): unknown[] =>
(params[1] as string[]).filter(matches).map((email_id) => ({ email_id }));
const numberOf = (id: string) => Number(id.slice(1));
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
hasAttachment: false,
...overrides,
});
async function search(
service: MailSearchQueryService,
given: SearchCriteria,
page: { limit: number; cursor?: string } = { limit: 20 },
user = USER,
) {
return service.search(user, SCOPE, given, planSearch(given), page);
}
describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', () => {
const bitmapHint = [
'SET LOCAL enable_seqscan = off',
'SET LOCAL enable_indexscan = off',
];
const keysetHint = [
'SET LOCAL enable_bitmapscan = off',
'SET LOCAL enable_seqscan = off',
];
describe('묶음이 있는 찾기 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인', () => {
it(`후보는 GIN 의 bitmap 으로만 · 받은 시각 최신부터 확인 상한 + 1(${SEARCH_CHECK_ROWS_LIMIT + 1})줄까지 — 두 글자 낱말뿐이면 후보가 곧 결과(확인하지 않는다)`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(25),
});
const page = await search(service, criteria({ text: '계약' }));
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
expect(recorded[0].settings).toEqual(bitmapHint);
expect(recorded[0].params.at(-1)).toBe(SEARCH_CHECK_ROWS_LIMIT + 1);
expect(page.hits.map((hit) => hit.id)).toEqual(
rows(20).map((row) => row.email_id),
);
expect(page.hasNext).toBe(true);
expect(page.total).toBe(25);
// 다음 쪽 커서 — 이 쪽의 마지막 줄 다음부터, 같은 검색에서만
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: rows(20)[19].received_at,
emailId: 'm20',
});
});
it(`두 글자 낱말뿐인 찾기의 total — ${SEARCH_TOTAL_LIMIT}을 넘거나 후보가 상한 밖까지 있으면 null`, async () => {
for (const count of [
SEARCH_TOTAL_LIMIT + 1,
SEARCH_CHECK_ROWS_LIMIT + 1,
]) {
const { service } = fakeDataSource({ candidates: () => rows(count) });
const page = await search(service, criteria({ text: '계약 확인' }));
expect(page.total).toBeNull();
expect(page.hits).toHaveLength(20);
expect(page.hasNext).toBe(true);
}
});
it(`후보가 ${SEARCH_CANDIDATE_COUNT_LIMIT}개 이하면 한 번에 모두 확인한다 — 첫 쪽의 total 은 맞은 수`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(40),
check: answerChecks((id) => numberOf(id) % 4 !== 0),
});
const page = await search(service, criteria({ text: '계약서' }));
expect(recorded.map((query) => query.kind)).toEqual([
'candidates',
'check',
]);
expect(idsOf(recorded[1])).toHaveLength(40);
// 확인은 기본 키로 그 줄만 — 계획기 설정 없이
expect(recorded[1].settings).toEqual([]);
expect(page.hits).toHaveLength(20);
expect(page.hits.map((hit) => hit.id)).not.toContain('m4');
expect(page.hasNext).toBe(true);
expect(page.total).toBe(30);
});
it(`후보가 많으면 받은 차례로 ${SEARCH_FIRST_CHECK_ROWS}줄부터 ${SEARCH_CHECK_ROWS_GROWTH}배씩 확인하다 쪽이 차면 멈춘다 — total 은 모른다(null)`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(2_500),
check: answerChecks((id) => numberOf(id) % 100 === 0),
});
const page = await search(service, criteria({ text: '"계약 갱신"' }));
const checks = recorded.filter((query) => query.kind === 'check');
expect(checks.map((query) => idsOf(query).length)).toEqual([
SEARCH_FIRST_CHECK_ROWS,
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH,
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH ** 2,
]);
// 확인은 받은 차례 그대로 이어진다
expect(idsOf(checks[1])[0]).toBe('m101');
expect(page.hits).toHaveLength(20);
expect(page.hits[0].id).toBe('m100');
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
});
it('후보가 많아도 상한 안이면(상한 밖 후보 없음) 다 확인하고 끝 — 쪽을 못 채워도 hasNext 없음, 맞은 수를 안다', async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(2_500),
check: answerChecks((id) => ['m7', 'm2400'].includes(id)),
});
const page = await search(service, criteria({ text: '"계약 갱신"' }));
const checked = recorded
.filter((query) => query.kind === 'check')
.reduce((sum, query) => sum + idsOf(query).length, 0);
expect(checked).toBe(2_500);
expect(page).toMatchObject({
hasNext: false,
nextCursor: null,
total: 2,
});
expect(page.hits.map((hit) => hit.id)).toEqual(['m7', 'm2400']);
});
it('상한 밖에도 후보가 있고 상한까지 확인해도 쪽을 못 채우면 찾은 것까지와 이어 찾기 커서 — 쪽이 비어 있어도 hasNext', async () => {
const lastChecked = rows(1, SEARCH_CHECK_ROWS_LIMIT - 1)[0];
const { service, recorded } = fakeDataSource({
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
check: answerChecks((id) => id === 'm42'),
});
const page = await search(service, criteria({ text: '확인계약' }));
const checked = recorded
.filter((query) => query.kind === 'check')
.flatMap(idsOf);
expect(checked).toHaveLength(SEARCH_CHECK_ROWS_LIMIT);
expect(checked).not.toContain(`m${SEARCH_CHECK_ROWS_LIMIT + 1}`);
expect(page.hits.map((hit) => hit.id)).toEqual(['m42']);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const key = searchScopeKey(undefined, criteria({ text: '확인계약' }));
// 이어 찾을 자리는 마지막으로 **확인한** 후보 — 맞은 줄이 아니다
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: lastChecked.received_at,
emailId: lastChecked.email_id,
});
const empty = fakeDataSource({
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
check: () => [],
});
const emptyPage = await search(
empty.service,
criteria({ text: '확인계약' }),
);
expect(emptyPage.hits).toEqual([]);
expect(emptyPage.hasNext).toBe(true);
expect(emptyPage.nextCursor).not.toBeNull();
});
it('후보가 없으면 확인하지 않는다 — 빈 쪽 · total 0', async () => {
const { service, recorded } = fakeDataSource({ candidates: () => [] });
await expect(
search(service, criteria({ text: '뷁뷁뷁' })),
).resolves.toEqual({
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
});
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
});
it('직원 주소는 소문자로 맞춰 묻는다 (표의 주소는 늘 소문자)', async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(1),
});
await search(
service,
criteria({ text: '계약' }),
{ limit: 20 },
' Ara.Kim@ArASystem.test ',
);
expect(recorded[0].params[0]).toBe(USER);
});
});
it('묶음이 없으면(한 글자 낱말뿐) 받은 차례로 확인 상한까지 — 쪽이 차면 total 은 창 안에서 센다', async () => {
const { service, recorded } = fakeDataSource({
keyset: (params) => rows(Number(params.at(-1))),
keysetCount: () => [{ count: 120 }],
frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
});
const page = await search(service, criteria({ text: '묵' }));
expect(recorded.map((query) => query.kind)).toEqual([
'keyset',
'keysetCount',
'frontier',
]);
expect(recorded[0].settings).toEqual(keysetHint);
expect(recorded[0].sql).not.toContain('"grams"');
expect(recorded[0].sql).not.toContain('strpos("fold_body"');
expect(recorded[0].params.slice(-3)).toEqual([
SEARCH_CHECK_ROWS_LIMIT,
'묵',
21,
]);
expect(page.hits).toHaveLength(20);
expect(page.hasNext).toBe(true);
expect(page.total).toBe(120);
});
it('한 글자 낱말 — 창을 다 훑지 못하고 쪽을 못 채우면 이어 찾기, 다 훑었으면 끝', async () => {
const lastScanned = rows(1, 2_999)[0];
const unfinished = fakeDataSource({
keyset: () => rows(3),
frontier: () => [{ ...lastScanned, scanned: SEARCH_CHECK_ROWS_LIMIT }],
});
const page = await search(unfinished.service, criteria({ text: '뷁' }));
expect(page.hits).toHaveLength(3);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const key = searchScopeKey(undefined, criteria({ text: '뷁' }));
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: lastScanned.received_at,
emailId: lastScanned.email_id,
});
const done = fakeDataSource({
keyset: () => rows(7),
frontier: () => [{ ...rows(1, 1_499)[0], scanned: 1_500 }],
});
expect(await search(done.service, criteria({ text: '뷁' }))).toMatchObject({
hasNext: false,
nextCursor: null,
total: 7,
});
const over = fakeDataSource({
keyset: () => rows(21),
keysetCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
});
expect(
(await search(over.service, criteria({ text: '팀' }))).total,
).toBeNull();
});
it('낱말 확인이 없으면(기간 · 첨부만) 받은 차례로 바로 — total 은 상한 + 1 까지 세어 넘으면 null', async () => {
const { service, recorded } = fakeDataSource({
plainPage: (params) => rows(Number(params.at(-1))),
plainCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
});
const page = await search(service, criteria({ hasAttachment: true }), {
limit: 10,
});
expect(recorded.map((query) => query.kind)).toEqual([
'plainPage',
'plainCount',
]);
expect(recorded[0].params.at(-1)).toBe(11);
expect(recorded[1].params.at(-1)).toBe(SEARCH_TOTAL_LIMIT + 1);
expect(
recorded.every((query) => query.settings.join() === keysetHint.join()),
).toBe(true);
expect(page.hits).toHaveLength(10);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const few = fakeDataSource({
plainPage: () => rows(3),
plainCount: () => [{ count: 3 }],
});
expect(
await search(few.service, criteria({ hasAttachment: true })),
).toMatchObject({ hasNext: false, nextCursor: null, total: 3 });
});
it('다음 쪽은 커서의 자리부터 · total 은 늘 null — 다른 검색의 커서는 묻기 전에 VAL_001', async () => {
const given = criteria({ text: '계약' });
const position = {
receivedAt: new Date('2026-11-01T00:00:00Z'),
emailId: 'm40',
};
const cursor = encodeIndexCursor(
position,
searchScopeKey(undefined, given),
);
const { service, recorded } = fakeDataSource({
candidates: () => rows(3, 40),
});
const page = await search(service, given, { limit: 20, cursor });
expect(recorded[0].params).toEqual(
expect.arrayContaining([position.receivedAt, 'm40']),
);
expect(page.total).toBeNull();
expect(page.hasNext).toBe(false);
const other = fakeDataSource({});
await expect(
search(other.service, criteria({ text: '견적' }), { limit: 20, cursor }),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(other.recorded).toEqual([]);
});
it('아무것도 맞을 수 없는 계획이면 묻지 않는다 — 빈 쪽 · total 0', async () => {
const { service, recorded } = fakeDataSource({});
await expect(search(service, criteria({ text: '…' }))).resolves.toEqual({
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
});
await expect(
search(service, criteria({ body: '묵' })),
).resolves.toMatchObject({ hits: [], total: 0 });
expect(recorded).toEqual([]);
});
it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에(본문 밖의 접은 칸 넷도 — 미리 보기 고르기), id 가 없으면 묻지 않는다', async () => {
const { service, recorded } = fakeDataSource({
highlight: () => [
{
email_id: 'm1',
subject: '계약',
body_text: '본문',
preview: '미리',
fold_from: '이도윤 <doyun.lee@arasystem.test>',
fold_to: '김아라 <ara.kim@arasystem.test>',
fold_subject: '계약',
fold_attachment: '계약서.pdf',
},
],
});
await expect(service.highlightTexts(USER, [])).resolves.toEqual(new Map());
expect(recorded).toEqual([]);
const texts = await service.highlightTexts(USER, ['m1', 'm2']);
expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]);
expect([...texts.entries()]).toEqual([
[
'm1',
{
subject: '계약',
bodyText: '본문',
preview: '미리',
otherFields: [
'이도윤 <doyun.lee@arasystem.test>',
'김아라 <ara.kim@arasystem.test>',
'계약',
'계약서.pdf',
],
},
],
]);
});
it('DB 실패는 그대로 던진다 — 부르는 쪽(목록 서비스)이 첫 쪽이면 엔진 방식으로 찾는다', async () => {
const { service } = fakeDataSource({
candidates: () => {
throw new Error('db down');
},
});
const error = await search(service, criteria({ text: '계약' })).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(BusinessException);
});
it('다음 쪽에서는 후보가 적어도 모두 확인하지 않는다 — 쪽이 찰 때까지만 늘려 가며 (total 이 없다 — 리뷰 L3)', async () => {
const given = criteria({ text: '계약서' });
const cursor = encodeIndexCursor(
{ receivedAt: new Date('2026-11-01T00:00:00Z'), emailId: 'm9' },
searchScopeKey(undefined, given),
);
const { service, recorded } = fakeDataSource({
candidates: () => rows(1_500, 9),
check: answerChecks(() => true),
});
const page = await search(service, given, { limit: 20, cursor });
const checks = recorded.filter((query) => query.kind === 'check');
expect(checks.map((query) => idsOf(query).length)).toEqual([
SEARCH_FIRST_CHECK_ROWS,
]);
expect(page.hits).toHaveLength(20);
expect(page.total).toBeNull();
});
it('한 글자 낱말 길의 맞은 줄 · 창의 끝 · 수는 한 스냅숏(REPEATABLE READ 트랜잭션 하나)에서 — 후보 · 낱말 확인 없는 길도 (리뷰 L4)', async () => {
const keyset = fakeDataSource({
keyset: (params) => rows(Number(params.at(-1))),
keysetCount: () => [{ count: 120 }],
frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
});
await search(keyset.service, criteria({ text: '묵' }));
expect(new Set(keyset.recorded.map((query) => query.transaction))).toEqual(
new Set([1]),
);
expect(keyset.recorded.map((query) => query.isolation)).toEqual([
'REPEATABLE READ',
'REPEATABLE READ',
'REPEATABLE READ',
]);
const unfinished = fakeDataSource({
keyset: () => rows(3),
frontier: () => [
{ ...rows(1, 2_999)[0], scanned: SEARCH_CHECK_ROWS_LIMIT },
],
});
await search(unfinished.service, criteria({ text: '뷁' }));
expect(unfinished.recorded.map((query) => query.transaction)).toEqual([
1, 1,
]);
const plain = fakeDataSource({
plainPage: () => rows(3),
plainCount: () => [{ count: 3 }],
});
await search(plain.service, criteria({ hasAttachment: true }));
expect(plain.recorded.map((query) => query.transaction)).toEqual([1, 1]);
});
});
@@ -0,0 +1,429 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import type { DataSource } from 'typeorm';
import {
SEARCH_CANDIDATE_COUNT_LIMIT,
SEARCH_CHECK_ROWS_GROWTH,
SEARCH_CHECK_ROWS_LIMIT,
SEARCH_FIRST_CHECK_ROWS,
SEARCH_TOTAL_LIMIT,
} from './mail-search.constants';
import type { SearchCriteria, SearchScope } from './mail-search.types';
import {
decodeIndexCursor,
encodeIndexCursor,
searchScopeKey,
} from './search-cursor';
import type { SearchPlan } from './search-plan';
import {
candidatesSql,
checkHitsSql,
highlightTextsSql,
keysetCountSql,
keysetFrontierSql,
keysetHitsSql,
plainCountSql,
plainPageSql,
type SearchPosition,
type SqlQuery,
} from './search-sql';
/** 찾은 메일 하나 — 엔진 id 와 받은 시각 */
export interface IndexHit {
readonly id: string;
readonly receivedAt: Date;
}
/** 색인으로 찾은 한 쪽 */
export interface IndexSearchPage {
readonly hits: readonly IndexHit[];
readonly hasNext: boolean;
readonly nextCursor: string | null;
/** 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null, 다음 쪽은 늘 null */
readonly total: number | null;
}
/** 강조에 쓰는 색인의 글 */
export interface IndexHighlightText {
readonly subject: string;
readonly bodyText: string;
readonly preview: string;
/** 본문 밖의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (미리 보기 고르기 — search-highlight.ts) */
readonly otherFields: readonly string[];
}
/** 쿼리의 줄 */
interface HitRow {
readonly email_id: string;
readonly received_at: Date;
}
/** 한 쪽을 찾은 결과 — 커서는 search() 가 만든다 */
interface Found {
readonly hits: IndexHit[];
readonly hasNext: boolean;
/** 이어 찾을 자리 — hasNext 일 때만 */
readonly next: SearchPosition | null;
readonly total: number | null;
}
/** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */
type PlannerHint =
/**
* 후보 — 찾기 GIN(주소 · 묶음 · 메일함 — 이것 말고 받을 색인이 없다)의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게)
*/
| 'bitmap'
/** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */
| 'keyset';
/** 한 스냅숏 안의 쿼리 하나 */
type Ask = <T>(query: SqlQuery) => Promise<T>;
/** 계획기 설정 — e2e 의 계획 시험(실제 PostgreSQL)도 같은 설정으로 본다 */
export const PLANNER_SETTINGS: Readonly<
Record<PlannerHint, readonly string[]>
> = {
bitmap: [
'SET LOCAL enable_seqscan = off',
'SET LOCAL enable_indexscan = off',
],
keyset: [
'SET LOCAL enable_bitmapscan = off',
'SET LOCAL enable_seqscan = off',
],
};
function toHit(row: HitRow): IndexHit {
return {
id: row.email_id,
receivedAt:
row.received_at instanceof Date
? row.received_at
: new Date(row.received_at),
};
}
function positionOf(hit: IndexHit): SearchPosition {
return { receivedAt: hit.receivedAt, emailId: hit.id };
}
/** 첫 쪽의 수 — SEARCH_TOTAL_LIMIT 를 넘으면 null */
function capTotal(count: number): number | null {
return count > SEARCH_TOTAL_LIMIT ? null : count;
}
/** 맞은 줄(받은 시각 최신부터)에서 한 쪽 — 넘치면 다음 쪽이 있고, 이어 찾을 자리는 이 쪽의 마지막 줄 */
function pageOf(
hits: readonly IndexHit[],
limit: number,
total: number | null,
): Found {
const page = hits.slice(0, limit);
const hasNext = hits.length > limit;
return {
hits: page,
hasNext,
next: hasNext ? positionOf(page[page.length - 1]) : null,
total,
};
}
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
function normalize(user: string): string {
return user.trim().toLowerCase();
}
/**
* 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다.
*
* - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로
* - 묶음이 있으면: 후보를 주소 + 묶음 + 메일함(GIN 하나)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
* - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다
* - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다.
* SEARCH_CHECK_ROWS_LIMIT 줄을 확인하고도 쪽을 채우지 못하면 찾은 것까지와 마지막으로 확인한 줄의 커서를 준다(hasNext — 쪽이 비어 있을 수도 있다)
* - 묶음이 없으면(한 글자 낱말뿐 — 본문은 훑지 않아 확인이 값싸다): 받은 차례로 SEARCH_CHECK_ROWS_LIMIT 줄까지 확인한다(이어 찾기는 위와 같다)
* - total 은 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 — 넘거나 이 요청 안에서 다 세지 못하면 null
*/
@Injectable()
export class MailSearchQueryService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async search(
user: string,
scope: SearchScope,
criteria: SearchCriteria,
plan: SearchPlan,
page: { readonly limit: number; readonly cursor?: string },
): Promise<IndexSearchPage> {
const key = normalize(user);
const scopeKey = searchScopeKey(scope.mailboxId ?? undefined, criteria);
// 커서는 찾기 전에 본다 — 다른 검색의 커서면 VAL_001
const after = page.cursor ? decodeIndexCursor(page.cursor, scopeKey) : null;
if (plan.matchesNothing) {
return { hits: [], hasNext: false, nextCursor: null, total: 0 };
}
const firstPage = after === null;
const found =
plan.checks.length === 0
? await this.plain(key, scope, plan, after, page.limit, firstPage)
: plan.grams.length === 0
? await this.keyset(key, scope, plan, after, page.limit, firstPage)
: await this.byGrams(key, scope, plan, after, page.limit, firstPage);
return {
hits: found.hits,
hasNext: found.hasNext,
nextCursor:
found.hasNext && found.next
? encodeIndexCursor(found.next, scopeKey)
: null,
total: firstPage ? found.total : null,
};
}
/** 강조에 쓰는 글 — 찾은 쪽의 줄만 (없는 줄은 빠진다) */
async highlightTexts(
user: string,
ids: readonly string[],
): Promise<Map<string, IndexHighlightText>> {
if (ids.length === 0) return new Map();
const query = highlightTextsSql(normalize(user), ids);
const rows = await this.dataSource.query<
{
email_id: string;
subject: string;
body_text: string;
preview: string;
fold_from: string;
fold_to: string;
fold_subject: string;
fold_attachment: string;
}[]
>(query.sql, query.params);
return new Map(
rows.map((row) => [
row.email_id,
{
subject: row.subject,
bodyText: row.body_text,
preview: row.preview,
otherFields: [
row.fold_from,
row.fold_to,
row.fold_subject,
row.fold_attachment,
],
},
]),
);
}
/** 낱말 확인 없이 — 받은 차례로 (첫 쪽은 SEARCH_TOTAL_LIMIT + 1 까지 센다 — 쪽과 수는 같은 스냅숏에서) */
private plain(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
firstPage: boolean,
): Promise<Found> {
return this.snapshot('keyset', async (ask) => {
const rows = await ask<HitRow[]>(
plainPageSql(user, scope, plan, after, limit + 1),
);
let total: number | null = null;
if (firstPage) {
const counted = await ask<{ count: number }[]>(
plainCountSql(user, scope, plan, SEARCH_TOTAL_LIMIT + 1),
);
total = capTotal(Number(counted[0]?.count ?? 0));
}
return pageOf(rows.map(toHit), limit, total);
});
}
/** 묶음이 있다 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인한다 */
private async byGrams(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
firstPage: boolean,
): Promise<Found> {
const candidates = (
await this.snapshot('bitmap', (ask) =>
ask<HitRow[]>(
candidatesSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT + 1),
),
)
).map(toHit);
// 확인 상한 밖에도 후보가 있다 — 이 요청은 상한까지만 본다
const more = candidates.length > SEARCH_CHECK_ROWS_LIMIT;
const window = more
? candidates.slice(0, SEARCH_CHECK_ROWS_LIMIT)
: candidates;
// 두 글자 낱말뿐 — 후보가 곧 결과다
if (plan.exactByGrams) {
return pageOf(candidates, limit, more ? null : capTotal(window.length));
}
// 첫 쪽이고 후보가 적다 — 모두 확인해 total 을 안다 (다음 쪽은 total 이 없으니 쪽이 찰 때까지만 확인한다)
if (firstPage && !more && window.length <= SEARCH_CANDIDATE_COUNT_LIMIT) {
const matched = await this.check(user, plan, window);
const hits = window.filter((hit) => matched.has(hit.id));
return pageOf(hits, limit, capTotal(hits.length));
}
// 후보가 많다 — 받은 차례로 조금씩 확인하다 쪽이 차면 멈춘다
const hits: IndexHit[] = [];
let checked = 0;
let size = Math.max(SEARCH_FIRST_CHECK_ROWS, (limit + 1) * 2);
while (hits.length <= limit && checked < window.length) {
const slice = window.slice(checked, checked + size);
const matched = await this.check(user, plan, slice);
for (const hit of slice) if (matched.has(hit.id)) hits.push(hit);
checked += slice.length;
size *= SEARCH_CHECK_ROWS_GROWTH;
}
if (hits.length > limit) return pageOf(hits, limit, null);
// 후보를 다 확인했다 — 끝이고 맞은 수를 안다
if (!more) {
return { hits, hasNext: false, next: null, total: capTotal(hits.length) };
}
// 확인 상한에 닿았다 — 마지막으로 확인한 후보 다음부터 이어 찾는다
return {
hits,
hasNext: true,
next: positionOf(window[window.length - 1]),
total: null,
};
}
/** 후보 가운데 낱말이 모두 맞은 줄의 id */
private async check(
user: string,
plan: SearchPlan,
candidates: readonly IndexHit[],
): Promise<Set<string>> {
if (candidates.length === 0) return new Set();
const query = checkHitsSql(
user,
plan,
candidates.map((hit) => hit.id),
);
const rows = await this.dataSource.query<{ email_id: string }[]>(
query.sql,
query.params,
);
return new Set(rows.map((row) => row.email_id));
}
/**
* 묶음이 없다(한 글자 낱말뿐) — 받은 차례로 확인 상한까지 (확인하는 칸이 작아 값싸다).
* 맞은 줄 · 창의 끝 · 첫 쪽의 수는 **같은 스냅숏**에서 읽는다 — 그사이 온 메일로 창이 밀리면 이어 찾을 자리가 어긋나 같은 메일이 다음 쪽에 또 나왔다
*/
private keyset(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
firstPage: boolean,
): Promise<Found> {
return this.snapshot('keyset', async (ask) => {
const rows = await ask<HitRow[]>(
keysetHitsSql(
user,
scope,
plan,
after,
SEARCH_CHECK_ROWS_LIMIT,
limit + 1,
),
);
if (rows.length > limit) {
return pageOf(
rows.map(toHit),
limit,
firstPage ? await this.keysetTotal(ask, user, scope, plan) : null,
);
}
// 쪽을 채우지 못했다 — 확인한 창을 다 훑었나(그러면 끝), 아니면 그 창의 끝부터 이어 찾는다
const hits = rows.map(toHit);
const frontier = await this.frontier(ask, user, scope, plan, after);
if (frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT) {
return {
hits,
hasNext: false,
next: null,
total: capTotal(hits.length),
};
}
return { hits, hasNext: true, next: frontier.last, total: null };
});
}
/** 받은 차례로 훑는 창의 끝 — 그 창의 마지막 줄 · 줄 수 (창이 비었으면 null) */
private async frontier(
ask: Ask,
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
): Promise<{ last: SearchPosition; scanned: number } | null> {
const rows = await ask<(HitRow & { scanned: number })[]>(
keysetFrontierSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT),
);
const row = rows[0];
if (!row) return null;
return {
last: positionOf(toHit(row)),
scanned: Number(row.scanned),
};
}
/** 받은 차례 길의 첫 쪽 total — 창 안에서 SEARCH_TOTAL_LIMIT + 1 까지 센다. 넘거나 창을 다 훑지 못했으면 null */
private async keysetTotal(
ask: Ask,
user: string,
scope: SearchScope,
plan: SearchPlan,
): Promise<number | null> {
const counted = await ask<{ count: number }[]>(
keysetCountSql(
user,
scope,
plan,
SEARCH_CHECK_ROWS_LIMIT,
SEARCH_TOTAL_LIMIT + 1,
),
);
const count = Number(counted[0]?.count ?? 0);
if (count > SEARCH_TOTAL_LIMIT) return null;
const frontier = await this.frontier(ask, user, scope, plan, null);
return frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT
? count
: null;
}
/**
* 한 스냅숏(REPEATABLE READ — 읽기만 한다)에서 계획기 설정을 건 뒤 쿼리들을 묻는다 (SET LOCAL — 트랜잭션이 끝나면 돌아온다).
* 스냅숏은 설정 다음의 첫 쿼리에서 잡힌다
*/
private snapshot<T>(
hint: PlannerHint,
work: (ask: Ask) => Promise<T>,
): Promise<T> {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
for (const setting of PLANNER_SETTINGS[hint]) {
await manager.query(setting);
}
return work(<R>(query: SqlQuery) =>
manager.query<R>(query.sql, query.params),
);
});
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { runOutsideRequestContext } from '@/shared/logger/request-context';
/**
* 직원마다 색인 일을 한 번에 하나씩 (14단계 설계 B2 — "같은 직원은 한 번에 하나") — 맞추기 · 채우기 한 묶음 · 전체 대조가 같은 줄에 선다.
* 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게.
*
* ⚠️ 프로세스 안의 차례다 — 인스턴스를 늘리면 두 인스턴스가 같은 직원을 함께 맞춘다(쓰기는 상태 줄을 잠가 차례로 되지만 같은 일을 두 번 한다).
* 그때는 잠금(Redis `SET NX PX` 또는 `pg_advisory_lock`)으로 옮긴다 (nestjs 스킬의 Redis 목록)
*/
@Injectable()
export class MailSearchRuns {
private readonly tails = new Map<string, Promise<void>>();
/** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */
run<T>(user: string, task: () => Promise<T>): Promise<T> {
const key = user.toLowerCase();
const previous = this.tails.get(key) ?? Promise.resolve();
const start = () => runOutsideRequestContext(task);
const result = previous.then(start, start);
const tail = result.then(
() => undefined,
() => undefined,
);
this.tails.set(key, tail);
void tail.then(() => {
if (this.tails.get(key) === tail) this.tails.delete(key);
});
return result;
}
/** 그 직원의 일이 줄에 있거나 도는 중인가 */
isBusy(user: string): boolean {
return this.tails.has(user.toLowerCase());
}
}
/**
* 함께 도는 일의 수 — 넘는 일은 먼저 온 차례로 기다린다 (14단계 — 실시간 소식이 직원 수만큼 한꺼번에 와도 맞추기는 이만큼만 함께).
* 앞의 일이 실패해도 자리는 돌려준다.
*
* ⚠️ 프로세스 안의 셈이다 — 인스턴스를 늘리면 인스턴스마다 따로 센다 (nestjs 스킬의 Redis 목록)
*/
export class ConcurrencyGate {
private active = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly limit: number) {}
/** 지금 도는 일 · 기다리는 일의 수 (시험용) */
get load(): { readonly active: number; readonly waiting: number } {
return { active: this.active, waiting: this.waiting.length };
}
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.active >= this.limit) {
await new Promise<void>((resolve) => this.waiting.push(resolve));
} else {
this.active += 1;
}
try {
return await task();
} finally {
// 기다리는 일에 자리를 그대로 넘긴다 (자리 수는 그대로) — 없으면 돌려준다
const next = this.waiting.shift();
if (next) next();
else this.active -= 1;
}
}
}
@@ -0,0 +1,90 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
SEARCH_EMAIL_COLUMN_LENGTH,
SEARCH_ID_COLUMN_LENGTH,
SEARCH_STATES_TABLE,
SEARCH_WORD_COLUMN_LENGTH,
} from './mail-search.constants';
import type {
SearchIndexStatus,
SearchPausedReason,
} from './mail-search.types';
/**
* 직원마다 색인 상태 한 줄 (14단계 설계 A2) — 직원이 웹메일에 처음 로그인하거나 검색 준비 상태를 물을 때 `building` 으로 만든다.
* 로그인한 적 없는 계정은 색인하지 않는다.
*
* 색인 줄을 쓰는 일은 모두 이 줄을 잠그고(SELECT … FOR UPDATE) 같은 트랜잭션에서 한다 — 줄이 없으면(퇴사 마무리가 지웠다) 쓰지 않는다.
* 퇴사 마무리는 이 줄을 먼저 지우고 색인 줄을 지운다 (ops-data.service.ts — 그사이 끝난 쓰기가 남지 않게)
*/
@Entity({ name: SEARCH_STATES_TABLE })
@Index('IDX_mail_search_states_status', ['status'])
export class MailSearchStateEntity {
@PrimaryColumn({
name: 'user_email',
type: 'varchar',
length: SEARCH_EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
@Column({ type: 'varchar', length: SEARCH_WORD_COLUMN_LENGTH })
status!: SearchIndexStatus;
/** 엔진 Email state — 맞추기(Email/changes)의 기준. 채우기를 시작하기 전에는 null (그때는 맞추지 않는다) */
@Column({ name: 'email_state', type: 'text', nullable: true })
emailState!: string | null;
/** 채우기를 이어 할 자리 — 마지막으로 넣은 메일 id (받은 차례 최신부터) */
@Column({
name: 'build_anchor',
type: 'varchar',
length: SEARCH_ID_COLUMN_LENGTH,
nullable: true,
})
buildAnchor!: string | null;
/** 채운 메일 수 (anchor 가 지워졌을 때 이어 갈 위치이기도 하다) */
@Column({ name: 'build_done', type: 'integer', default: 0 })
buildDone!: number;
/** 채울 메일 수 — 채우기를 시작할 때의 엔진 메일 수 */
@Column({ name: 'build_total', type: 'integer', nullable: true })
buildTotal!: number | null;
@Column({ name: 'build_started_at', type: 'timestamptz', nullable: true })
buildStartedAt!: Date | null;
@Column({ name: 'ready_at', type: 'timestamptz', nullable: true })
readyAt!: Date | null;
/** 마지막으로 맞춘 때 */
@Column({ name: 'last_synced_at', type: 'timestamptz', nullable: true })
lastSyncedAt!: Date | null;
/** 대리 접근이 막혀 멈춘 까닭 — 없으면 null (mail-search.types.ts) */
@Column({
name: 'paused_reason',
type: 'varchar',
length: SEARCH_WORD_COLUMN_LENGTH,
nullable: true,
})
pausedReason!: SearchPausedReason | null;
/**
* 이 줄의 색인을 만든 판 (mail-search.constants.ts 의 INDEX_FORMAT) — 다르면 building 으로 돌려 다시 채운다(그동안 검색은 엔진 방식).
* 판을 적기 전에 만든 줄은 1
*/
@Column({ name: 'index_format', type: 'smallint', default: 1 })
indexFormat!: number;
/** 이어진 실패 수 — 성공하면 0. 많을수록 차례에서 뒤로 미룬다 */
@Column({ name: 'failure_count', type: 'integer', default: 0 })
failureCount!: number;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,971 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
AccountLoginDisabledException,
PasswordExpiredException,
} from '@/modules/mail/mail.provider';
import type { IndexRowValues } from './index-document';
import {
IndexDeadlineError,
type IndexChanges,
type IndexDocument,
type IndexIdPage,
type IndexMutable,
type IndexReadOptions,
type MailIndexSource,
} from './mail-index-source';
import { MailSearchBuildService } from './mail-search-build.service';
import type {
MailSearchIndexRepository,
SearchIndexWrite,
} from './mail-search-index.repository';
import { MailSearchRuns } from './mail-search-runs';
import type { MailSearchStateEntity } from './mail-search-state.entity';
import { MailSearchSyncService } from './mail-search-sync.service';
import type { MailSearchTiming } from './mail-search-timing';
import {
BUILD_BATCH_SIZE,
BUILD_RUN_BUDGET_MS,
INDEX_CALL_MIN_MS,
INDEX_FORMAT,
STEP_MIN_REMAINING_MS,
SYNC_CONCURRENCY,
SYNC_DEBOUNCE_MS,
} from './mail-search.constants';
const DOMAIN = 'arasystem.test';
const ARA = `ara.kim@${DOMAIN}`;
/** 한국 시각 밤 23시 — 채우기 창 안 */
const NIGHT = Date.parse('2026-11-05T23:00:00+09:00');
/** 넉넉한 끝 시각 (시계는 쉬기 · 엔진 읽기 값만큼만 간다) */
const LONG = 3_600_000;
/** 시험의 시계 — 엔진 대역과 서비스가 함께 본다 */
interface TestClock {
now(): number;
advance(ms: number): void;
}
type ChangeEntry = {
created: string[];
updated: string[];
destroyed: string[];
};
/**
* 대역 엔진 — 메일 · state · 변화 기록. 테스트가 메일을 넣고 · 바꾸고 · 지운다.
* 실제 어댑터처럼 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 읽지 않고 IndexDeadlineError, 읽을 때마다 callCostMs 만큼 시계가 간다
*/
class FakeEngine implements MailIndexSource {
readonly emails = new Map<
string,
{ receivedAt: number; mailboxIds: string[]; keywords: string[] }
>();
private stateNumber = 0;
/** state 번호 → 그 state 가 된 변화 */
private readonly log: ChangeEntry[] = [];
/** 이보다 오래된 state 는 셀 수 없다 */
forgetBefore = 0;
/** 다음 부름에 던질 실패 */
failNext: Error | null = null;
/** 엔진 읽기 한 번에 가는 시간 */
callCostMs = 0;
/** 있으면 changes 가 답을 만든 뒤 이것이 풀릴 때까지 기다린다 (맞추는 중인 때를 만든다) */
changesGate: Promise<void> | null = null;
/** 지금 답을 기다리는 changes 수 · 가장 많았던 수 */
inFlight = 0;
maxInFlight = 0;
readonly calls: string[] = [];
/** documents 에 실린 id 들 (부름마다) */
readonly documentRequests: string[][] = [];
constructor(private readonly clock: TestClock) {}
get state(): string {
return `s${this.stateNumber}`;
}
add(id: string, receivedAt: number, mailboxIds = ['inbox']): void {
this.emails.set(id, { receivedAt, mailboxIds, keywords: [] });
this.record({ created: [id], updated: [], destroyed: [] });
}
move(id: string, mailboxIds: string[]): void {
const email = this.emails.get(id);
if (email) email.mailboxIds = mailboxIds;
this.record({ created: [], updated: [id], destroyed: [] });
}
remove(id: string): void {
this.emails.delete(id);
this.record({ created: [], updated: [], destroyed: [id] });
}
/** 엔진이 같은 쪽에 만든 메일을 바뀐 메일로도 함께 알린다 (RFC 8620 은 둘 다 실을 수 있다) */
addReportedTwice(id: string, receivedAt: number): void {
this.emails.set(id, { receivedAt, mailboxIds: ['inbox'], keywords: [] });
this.record({ created: [id], updated: [id, id], destroyed: [] });
}
/** 변화 기록 없이 넣는다 (지난 메일) */
seed(count: number, prefix = 'old'): void {
for (let index = 0; index < count; index += 1) {
this.emails.set(`${prefix}${index}`, {
receivedAt: 1_000 + index,
mailboxIds: ['inbox'],
keywords: [],
});
}
}
private record(entry: ChangeEntry): void {
this.log.push(entry);
this.stateNumber += 1;
}
private check(name: string, options: IndexReadOptions): void {
this.calls.push(name);
if (this.failNext) {
const error = this.failNext;
this.failNext = null;
throw error;
}
if (options.deadline - this.clock.now() < INDEX_CALL_MIN_MS) {
throw new IndexDeadlineError();
}
this.clock.advance(this.callCostMs);
}
snapshot(_user: string, options: IndexReadOptions) {
this.check('snapshot', options);
return Promise.resolve({ state: this.state, total: this.emails.size });
}
async changes(
_user: string,
sinceState: string,
maxChanges: number,
options: IndexReadOptions,
): Promise<IndexChanges> {
this.check('changes', options);
const since = Number(sinceState.slice(1));
let result: IndexChanges;
if (!Number.isInteger(since) || since < this.forgetBefore) {
result = { kind: 'cannotCalculate' };
} else {
const entries = this.log.slice(since, since + maxChanges);
result = {
kind: 'changes',
created: entries.flatMap((entry) => entry.created),
updated: entries.flatMap((entry) => entry.updated),
destroyed: entries.flatMap((entry) => entry.destroyed),
newState: `s${since + entries.length}`,
hasMore: since + entries.length < this.stateNumber,
};
}
this.inFlight += 1;
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
try {
if (this.changesGate) await this.changesGate;
} finally {
this.inFlight -= 1;
}
return result;
}
documents(
_user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexDocument[]> {
this.check('documents', options);
this.documentRequests.push([...ids]);
return Promise.resolve(
ids.flatMap((id) => {
const email = this.emails.get(id);
if (!email) return [];
return [
{
id,
threadId: `t-${id}`,
mailboxIds: email.mailboxIds,
keywords: email.keywords,
receivedAt: new Date(email.receivedAt).toISOString(),
size: 10,
messageIds: [`${id}@example.com`],
inReplyTo: [],
references: [],
subject: `제목 ${id}`,
from: [{ name: null, email: 'partner@example.com' }],
to: [{ name: null, email: ARA }],
cc: [],
attachmentNames: [],
hasAttachment: false,
preview: '',
bodyText: `본문 ${id}`,
},
];
}),
);
}
mutable(
_user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexMutable[]> {
this.check('mutable', options);
return Promise.resolve(
ids.flatMap((id) => {
const email = this.emails.get(id);
return email
? [{ id, mailboxIds: email.mailboxIds, keywords: email.keywords }]
: [];
}),
);
}
newestFirst(): string[] {
return [...this.emails.entries()]
.sort((a, b) => b[1].receivedAt - a[1].receivedAt)
.map(([id]) => id);
}
pageIds(
_user: string,
from: { anchor: string | null; position: number },
limit: number,
options: IndexReadOptions,
): Promise<IndexIdPage> {
this.check('pageIds', options);
const all = this.newestFirst();
// anchor 가 지워졌으면 위치로 (실제 어댑터와 같다)
const anchorAt = from.anchor === null ? -1 : all.indexOf(from.anchor);
const start = anchorAt >= 0 ? anchorAt + 1 : from.position;
return Promise.resolve({
ids: all.slice(start, start + limit),
position: start,
total: all.length,
});
}
allIds(_user: string, options: IndexReadOptions) {
this.check('allIds', options);
return Promise.resolve({ state: this.state, ids: this.newestFirst() });
}
}
/** 대역 표 — 상태 줄 · 색인 줄을 메모리에 (색인 줄은 한 직원 것만 본다) */
class FakeRepository {
readonly states = new Map<string, MailSearchStateEntity>();
readonly rows = new Map<string, IndexRowValues>();
readonly writes: SearchIndexWrite[] = [];
/** 판 되돌리기 — 부른 수 · 앞으로 실패할 수(DB 장애 흉내) */
resetCalls = 0;
failResets = 0;
/** 실제 SQL(mail-search-index.repository.ts)과 같은 판정 — 판이 다르고 주소가 그 도메인으로 끝나는 줄만 */
resetOutdatedFormats(suffix: string, format: number, now: Date) {
this.resetCalls += 1;
if (this.failResets > 0) {
this.failResets -= 1;
return Promise.reject(new Error('db down'));
}
let count = 0;
for (const state of this.states.values()) {
if (state.indexFormat === format || !state.userEmail.endsWith(suffix)) {
continue;
}
Object.assign(state, {
status: 'building',
emailState: null,
buildAnchor: null,
buildDone: 0,
buildTotal: null,
buildStartedAt: null,
readyAt: null,
failureCount: 0,
indexFormat: format,
updatedAt: now,
});
count += 1;
}
return Promise.resolve(count);
}
addState(
overrides: Partial<MailSearchStateEntity> = {},
): MailSearchStateEntity {
const state: MailSearchStateEntity = {
userEmail: ARA,
status: 'building',
emailState: null,
buildAnchor: null,
buildDone: 0,
buildTotal: null,
buildStartedAt: null,
readyAt: null,
lastSyncedAt: null,
pausedReason: null,
failureCount: 0,
indexFormat: INDEX_FORMAT,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
this.states.set(state.userEmail, state);
return state;
}
findState(user: string) {
return Promise.resolve(this.states.get(user) ?? null);
}
syncCandidates() {
return Promise.resolve(
[...this.states.values()].filter(
(state) => state.emailState !== null && state.pausedReason === null,
),
);
}
buildCandidates() {
return Promise.resolve(
[...this.states.values()].filter(
(state) => state.status === 'building' && state.pausedReason === null,
),
);
}
recordFailure(user: string, now: Date) {
const state = this.states.get(user);
if (!state) return Promise.resolve(0);
state.failureCount += 1;
state.updatedAt = now;
return Promise.resolve(state.failureCount);
}
pause(user: string, reason: MailSearchStateEntity['pausedReason']) {
const state = this.states.get(user);
if (state) state.pausedReason = reason;
return Promise.resolve();
}
countMessages() {
return Promise.resolve(this.rows.size);
}
existingIds(_user: string, ids: readonly string[]) {
return Promise.resolve(new Set(ids.filter((id) => this.rows.has(id))));
}
indexedIds() {
return Promise.resolve([...this.rows.keys()]);
}
write(user: string, change: SearchIndexWrite, now: Date) {
const state = this.states.get(user);
if (!state) {
return Promise.resolve({ written: false, updatedIds: new Set<string>() });
}
this.writes.push(change);
if (change.clearIndex) this.rows.clear();
for (const id of change.deletes ?? []) this.rows.delete(id);
for (const row of change.upserts ?? []) this.rows.set(row.emailId, row);
const updatedIds = new Set<string>();
for (const update of change.updates ?? []) {
const row = this.rows.get(update.id);
if (!row) continue;
this.rows.set(update.id, {
...row,
mailboxIds: [...update.mailboxIds],
keywords: [...update.keywords],
});
updatedIds.add(update.id);
}
if (change.state) {
Object.assign(state, change.state, { failureCount: 0, updatedAt: now });
}
return Promise.resolve({ written: true, updatedIds });
}
}
describe('검색 색인 맞추기 · 채우기 (MailSearchSyncService · MailSearchBuildService)', () => {
let engine: FakeEngine;
let repository: FakeRepository;
let clock: number;
let timing: MailSearchTiming;
let sync: MailSearchSyncService;
let build: MailSearchBuildService;
let logs: string[];
const changesCalls = () =>
engine.calls.filter((call) => call === 'changes').length;
const failureLogs = () =>
logs.filter((line) => line.includes('못했습니다') || line.includes('실패'));
beforeEach(() => {
clock = NIGHT;
engine = new FakeEngine({
now: () => clock,
advance: (ms) => {
clock += ms;
},
});
repository = new FakeRepository();
timing = {
now: () => clock,
// 쉬는 동안 시계만 간다 — 진짜로 기다리지 않는다
sleep: (ms) => {
clock += ms;
return Promise.resolve();
},
};
const runs = new MailSearchRuns();
const repo = repository as unknown as MailSearchIndexRepository;
sync = new MailSearchSyncService(
engine,
repo,
runs,
{ domain: DOMAIN },
timing,
);
build = new MailSearchBuildService(engine, repo, runs, sync, timing);
logs = [];
const record = (message: unknown) => {
logs.push(String(message));
};
jest.spyOn(Logger.prototype, 'log').mockImplementation(record);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(record);
jest.spyOn(Logger.prototype, 'error').mockImplementation(record);
});
afterEach(() => {
sync.beforeApplicationShutdown();
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('채우기 (B3)', () => {
it('시작할 때 state(S0) · 메일 수를 적고, 최신부터 묶음씩 넣은 뒤 따라잡기 · 전체 대조 → ready', async () => {
engine.seed(BUILD_BATCH_SIZE * 2 + 7);
repository.addState();
const outcome = await build.build(ARA, clock + LONG);
expect(outcome).toBe('ready');
const state = repository.states.get(ARA)!;
expect(state).toMatchObject({
status: 'ready',
emailState: engine.state,
buildAnchor: null,
buildDone: BUILD_BATCH_SIZE * 2 + 7,
buildTotal: BUILD_BATCH_SIZE * 2 + 7,
});
expect(state.buildStartedAt).not.toBeNull();
expect(state.readyAt).not.toBeNull();
expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 2 + 7);
// 묶음 셋(50 · 50 · 7) + 빈 쪽 — 최신부터
expect(engine.calls.filter((call) => call === 'pageIds')).toHaveLength(4);
expect(
logs.some((line) => line.includes('검색 색인을 다 만들었습니다')),
).toBe(true);
expect(logs.join('\n')).not.toContain(ARA);
});
it('채우는 동안 온 메일 · 옮긴 메일 · 지운 메일은 맞추기가 넣는다 — 색인에 없던 옮긴 메일은 채우기에 맡긴다', async () => {
engine.seed(BUILD_BATCH_SIZE * 2);
repository.addState();
// 시작과 첫 묶음까지만 (시계는 묶음 사이 쉬는 동안만 간다 — 끝 시각을 짧게)
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
expect(repository.states.get(ARA)?.buildDone).toBe(BUILD_BATCH_SIZE);
engine.add('new1', 999_999);
engine.move('old0', ['archive']); // 아직 넣지 않은 지난 메일 (가장 오래된 것)
engine.remove(`old${BUILD_BATCH_SIZE * 2 - 1}`); // 이미 넣은 최신 메일
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
expect(repository.rows.has('new1')).toBe(true);
expect(repository.rows.has('old0')).toBe(false);
expect(repository.rows.has(`old${BUILD_BATCH_SIZE * 2 - 1}`)).toBe(false);
expect(await build.build(ARA, clock + LONG)).toBe('ready');
expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']);
expect(repository.rows.size).toBe(engine.emails.size);
});
it('anchor 가 지워지면 위치로 이어 가고(몇 통 건너뛸 수 있다) 마지막 전체 대조가 빠진 것을 채운다', async () => {
engine.seed(120);
repository.addState();
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
const first = repository.states.get(ARA)!;
expect(first.buildDone).toBe(BUILD_BATCH_SIZE);
// 첫 묶음은 old119 ~ old70 — 그 마지막(anchor)을 지운다. 위치 50 은 이제 old68 이라 old69 를 건너뛴다
expect(first.buildAnchor).toBe('old70');
engine.remove('old70');
expect(await build.build(ARA, clock + LONG)).toBe('ready');
expect(repository.rows.has('old70')).toBe(false);
expect(repository.rows.has('old69')).toBe(true);
expect([...repository.rows.keys()].sort()).toEqual(
engine.newestFirst().sort(),
);
});
it('판을 되돌리면 채우기의 첫 묶음이 옛 판 줄을 모두 지운다 — 위치로 건너뛴 메일도 마지막 대조가 새로 넣어 옛 판 줄이 남지 않는다 (리뷰 M1)', async () => {
engine.seed(120);
repository.addState();
expect(await build.build(ARA, clock + LONG)).toBe('ready');
// 옛 판(다른 접기 규칙)으로 만든 줄처럼 꾸민다
for (const [id, row] of repository.rows) {
repository.rows.set(id, { ...row, foldBody: 'old-format' });
}
repository.states.get(ARA)!.indexFormat = INDEX_FORMAT - 1;
await build.onApplicationBootstrap();
expect(repository.states.get(ARA)).toMatchObject({
status: 'building',
buildStartedAt: null,
emailState: null,
indexFormat: INDEX_FORMAT,
});
// 시작 + 첫 묶음(old119 ~ old70) → 그 anchor 를 지워 위치로 건너뛰게(old69 를 건너뛴다) → 끝까지
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
expect(repository.writes.some((write) => write.clearIndex)).toBe(true);
expect(repository.states.get(ARA)?.buildAnchor).toBe('old70');
engine.remove('old70');
expect(await build.build(ARA, clock + LONG)).toBe('ready');
const old = [...repository.rows.values()].filter(
(row) => row.foldBody === 'old-format',
);
expect(old).toEqual([]);
expect([...repository.rows.keys()].sort()).toEqual(
engine.newestFirst().sort(),
);
// 처음부터 시작할 때만 지운다 — 이어 하는 묶음은 지우지 않는다
expect(
repository.writes.filter((write) => write.clearIndex),
).toHaveLength(2);
});
it('서버가 뜰 때 판 되돌리기가 실패하면 1분 차례가 다시 해 본다(낮에도) — 한 번 되면 다시 하지 않고, 사내 도메인 줄만 (리뷰 L5)', async () => {
const state = repository.addState({
status: 'ready',
emailState: 's',
buildStartedAt: new Date(0),
indexFormat: INDEX_FORMAT - 1,
});
const other = repository.addState({
userEmail: 'search.index@e2e.arasystem.test',
status: 'ready',
indexFormat: INDEX_FORMAT - 1,
});
repository.failResets = 1;
await build.onApplicationBootstrap();
expect(state.status).toBe('ready');
expect(logs.some((line) => line.includes('되돌리지 못했습니다'))).toBe(
true,
);
clock = Date.parse('2026-11-05T12:00:00+09:00');
expect(await build.tick()).toBeNull();
expect(state).toMatchObject({
status: 'building',
buildStartedAt: null,
indexFormat: INDEX_FORMAT,
});
// 다른 도메인 줄은 그쪽 서버의 몫
expect(other).toMatchObject({
status: 'ready',
indexFormat: INDEX_FORMAT - 1,
});
expect(repository.resetCalls).toBe(2);
await build.tick();
expect(repository.resetCalls).toBe(2);
// 낮이라 채우지는 않는다
expect(engine.calls).toEqual([]);
});
it('채우는 중 엔진이 변화를 셀 수 없으면 대조하되 빠진 메일은 넣지 않는다(채우기가 넣는다) — 지운 것 · 옮긴 것은 맞춘다', async () => {
engine.seed(BUILD_BATCH_SIZE * 2);
repository.addState();
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
const newest = engine.newestFirst()[0];
repository.rows.set('ghost', {
...repository.rows.get(newest)!,
emailId: 'ghost',
});
engine.emails.get(newest)!.mailboxIds = ['trash'];
// 그사이 온 메일(가장 오래된 받은 시각 — 채우기의 끝에서 읽힌다) — 대조는 넣지 않고 state 만 앞으로
engine.add('late', 5);
engine.forgetBefore = Number.MAX_SAFE_INTEGER;
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
const state = repository.states.get(ARA)!;
expect(state.status).toBe('building');
expect(state.emailState).toBe(engine.state);
expect(repository.rows.has('ghost')).toBe(false);
expect(repository.rows.get(newest)?.mailboxIds).toEqual(['trash']);
// 아직 채우지 않은 쪽은 그대로 — 채우기가 넣는다
expect(repository.rows.size).toBe(BUILD_BATCH_SIZE);
engine.forgetBefore = 0;
expect(await build.build(ARA, clock + LONG)).toBe('ready');
expect(repository.rows.size).toBe(engine.emails.size);
expect(repository.rows.has('late')).toBe(true);
});
it('끝 시각 · 창이 닫힘에 닿으면 다음 묶음을 시작하지 않는다 — 다음 차례가 이어 한다', async () => {
engine.seed(BUILD_BATCH_SIZE * 3);
repository.addState();
let open = true;
const outcome = await build.build(ARA, clock + LONG, () => {
const keep = open;
open = false;
return keep;
});
expect(outcome).toBe('partial');
expect(repository.states.get(ARA)?.status).toBe('building');
expect(await build.build(ARA, clock + LONG)).toBe('ready');
expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 3);
});
it('표에 넣을 수 없는 긴 id 는 anchor 로 쓰지 않는다 — 넣을 수 있는 마지막 id', async () => {
const longId = 'x'.repeat(65);
engine.seed(3);
engine.emails.set(longId, {
receivedAt: 1,
mailboxIds: ['inbox'],
keywords: [],
});
repository.addState();
expect(await build.build(ARA, clock + LONG)).toBe('ready');
const anchors = repository.writes
.map((write) => write.state?.buildAnchor)
.filter((anchor): anchor is string => typeof anchor === 'string');
expect(anchors).toEqual(['old0']);
expect(repository.rows.has(longId)).toBe(false);
});
it('1분 차례는 밤 창 안 · 사내 도메인 주소만 — 낮에는 아무것도 하지 않는다', async () => {
engine.seed(3);
repository.addState({ userEmail: 'search.index@e2e.arasystem.test' });
repository.addState();
clock = Date.parse('2026-11-05T12:00:00+09:00');
expect(await build.tick()).toBeNull();
expect(engine.calls).toEqual([]);
clock = NIGHT;
expect(await build.tick()).toBe('ready');
expect(
repository.states.get('search.index@e2e.arasystem.test')
?.buildStartedAt,
).toBeNull();
});
it('상태 줄이 그사이 지워지면(퇴사 마무리) 쓰지 않고 멈춘다', async () => {
engine.seed(BUILD_BATCH_SIZE * 2);
repository.addState();
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
repository.states.delete(ARA);
expect(await build.build(ARA, clock + LONG)).toBe('skipped');
});
});
describe('시간 끝은 실패가 아니다 (이어 하기)', () => {
it('엔진 읽기가 끝 시각에 걸리면(IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기 — 실패 수 · 경고 없음', async () => {
engine.seed(BUILD_BATCH_SIZE);
repository.addState();
engine.failNext = new IndexDeadlineError();
expect(await build.build(ARA, clock + LONG)).toBe('partial');
expect(repository.states.get(ARA)?.failureCount).toBe(0);
repository.states.get(ARA)!.status = 'ready';
repository.states.get(ARA)!.emailState = engine.state;
engine.failNext = new IndexDeadlineError();
expect(await sync.syncUser(ARA, clock + LONG)).toBe(false);
expect(repository.states.get(ARA)?.failureCount).toBe(0);
expect(failureLogs()).toEqual([]);
});
it('남은 시간이 모자라면 다음 쪽 · 묶음을 시작하지 않는다 — 엔진이 느려도(읽기 4초) 끝 시각 전에 멈추고 쓴 데까지 남긴다', async () => {
repository.addState({ status: 'ready', emailState: engine.state });
for (let index = 0; index < 120; index += 1)
engine.add(`m${index}`, index);
engine.callCostMs = 4_000;
// 20초: changes(4초) → 글 50통 둘(8초) → 다음 쪽 changes(4초) → 그 쪽의 글은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적어 시작하지 않는다
expect(await sync.syncUser(ARA, clock + 20_000)).toBe(false);
expect(repository.states.get(ARA)?.failureCount).toBe(0);
expect(failureLogs()).toEqual([]);
// 끝 시각 전에 멈췄다 — 엔진 읽기가 끝 시각을 넘지 않았다
expect(engine.calls.filter((call) => call === 'documents')).toHaveLength(
2,
);
// 다음 차례들이 이어 해서 끝내 따라잡는다
let caught = false;
for (let round = 0; round < 10 && !caught; round += 1) {
caught = await sync.syncUser(ARA, clock + 60_000);
}
expect(caught).toBe(true);
expect(repository.rows.size).toBe(120);
expect(repository.states.get(ARA)?.failureCount).toBe(0);
});
it('마무리(따라잡기 · 전체 대조)는 새 차례의 처음에만 시작하고, 시간 안에 끝나지 않아도 다음 차례에 이어 해 끝내 ready', async () => {
engine.seed(300);
// 지난 메일을 다 읽었는데(빈 쪽) 색인에는 하나도 없다 — 마무리의 대조가 300통을 넣어야 한다
repository.addState({
emailState: engine.state,
buildStartedAt: new Date(clock),
buildAnchor: 'old0',
buildDone: 300,
buildTotal: 300,
});
engine.callCostMs = 10_000;
// 빈 쪽을 읽은 뒤 남은 시간이 마무리에 모자라면(차례의 끝 무렵) 시작하지 않는다
expect(await build.build(ARA, clock + 35_000)).toBe('partial');
expect(engine.calls).not.toContain('allIds');
const outcomes: string[] = [];
for (let run = 0; run < 8; run += 1) {
const outcome = await build.build(ARA, clock + BUILD_RUN_BUDGET_MS);
outcomes.push(outcome);
if (outcome === 'ready') break;
}
expect(outcomes[outcomes.length - 1]).toBe('ready');
expect(outcomes.length).toBeGreaterThan(1);
expect(
outcomes.slice(0, -1).every((outcome) => outcome === 'partial'),
).toBe(true);
expect(repository.rows.size).toBe(300);
expect(repository.states.get(ARA)?.failureCount).toBe(0);
expect(failureLogs()).toEqual([]);
});
});
describe('맞추기 (B2)', () => {
beforeEach(() => {
repository.addState({ status: 'ready', emailState: engine.state });
});
it('만든 · 바꾼 · 지운 메일을 넣고 state 를 함께 적는다 — 여러 쪽이면 이어 묻는다', async () => {
for (let index = 0; index < 150; index += 1) {
engine.add(`m${index}`, index);
}
engine.move('m0', ['archive']);
engine.remove('m1');
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
expect(repository.rows.size).toBe(149);
expect(repository.rows.get('m0')?.mailboxIds).toEqual(['archive']);
expect(repository.states.get(ARA)?.emailState).toBe(engine.state);
expect(changesCalls()).toBeGreaterThan(1);
});
it('한 쪽에 만든 메일이 바뀐 메일로도 함께 오면 글을 한 번만 읽는다', async () => {
engine.addReportedTwice('dup', 1);
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
expect(engine.documentRequests).toEqual([['dup']]);
expect(engine.calls).not.toContain('mutable');
expect(repository.rows.has('dup')).toBe(true);
});
it('다 만든 직원의 색인에 없던 바뀐 메일은 글째 넣는다', async () => {
engine.seed(1);
engine.move('old0', ['archive']);
await sync.syncUser(ARA, clock + LONG);
expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']);
});
it('엔진이 변화를 셀 수 없으면 전체 대조 — 없어진 것은 지우고 빠진 것은 넣고 메일함을 다시 읽는다', async () => {
engine.add('a', 1);
engine.add('b', 2);
await sync.syncUser(ARA, clock + LONG);
// 색인에만 있는 줄 · 엔진에서 옮긴 메일 · 색인에 없는 메일
repository.rows.set('ghost', {
...repository.rows.get('a')!,
emailId: 'ghost',
});
engine.emails.get('a')!.mailboxIds = ['trash'];
engine.seed(2);
engine.forgetBefore = Number.MAX_SAFE_INTEGER;
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
expect([...repository.rows.keys()].sort()).toEqual([
'a',
'b',
'old0',
'old1',
]);
expect(repository.rows.get('a')?.mailboxIds).toEqual(['trash']);
expect(repository.states.get(ARA)?.emailState).toBe(engine.state);
});
it('실시간 소식은 모아서 한 번 — 맞추는 중에 온 소식은 한 번 더(그사이 여러 번 와도 한 번), 끝나면 둘 다 들어 있다', async () => {
jest.useFakeTimers({
doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'],
});
const settle = async () => {
for (let round = 0; round < 10; round += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
};
let release: () => void = () => undefined;
engine.changesGate = new Promise<void>((resolve) => {
release = resolve;
});
engine.add('a', 1);
sync.requestSync(ARA);
sync.requestSync(ARA);
sync.requestSync(' Ara.Kim@arasystem.test ');
await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS);
await settle();
// 첫 맞추기가 엔진의 답을 기다리는 중 — 그사이 새 메일과 소식 셋
expect(changesCalls()).toBe(1);
engine.add('b', 2);
for (let index = 0; index < 3; index += 1) {
sync.requestSync(ARA);
await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS);
}
expect(changesCalls()).toBe(1);
engine.changesGate = null;
release();
await settle();
// 첫 맞추기(a 까지 본 답) 뒤 한 번 더 — b 까지
expect(changesCalls()).toBe(2);
expect(repository.rows.has('a')).toBe(true);
expect(repository.rows.has('b')).toBe(true);
});
it('서버 전체에서 함께 도는 맞추기는 SYNC_CONCURRENCY 개까지 — 나머지는 기다렸다 모두 맞춘다', async () => {
const users = Array.from(
{ length: SYNC_CONCURRENCY + 2 },
(_, index) => `user${index}@${DOMAIN}`,
);
for (const user of users) {
repository.addState({
userEmail: user,
status: 'ready',
emailState: engine.state,
});
}
let release: () => void = () => undefined;
engine.changesGate = new Promise<void>((resolve) => {
release = resolve;
});
const syncs = users.map((user) => sync.syncBeforeRead(user, LONG));
for (let round = 0; round < 10; round += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
expect(engine.inFlight).toBe(SYNC_CONCURRENCY);
engine.changesGate = null;
release();
expect(await Promise.all(syncs)).toEqual(users.map(() => true));
expect(engine.maxInFlight).toBe(SYNC_CONCURRENCY);
expect(changesCalls()).toBe(users.length);
});
it('검색 바로 전 맞추기는 기다리는 만큼만 — 기다리는 맞추기가 있으면 함께 기다린다', async () => {
engine.add('a', 1);
const [first, second] = await Promise.all([
sync.syncBeforeRead(ARA, 5_000),
sync.syncBeforeRead(ARA, 5_000),
]);
expect(first).toBe(true);
expect(second).toBe(true);
expect(changesCalls()).toBe(1);
});
it('5분 차례는 맞출 기준이 있는 사내 직원만, 실패가 이어진 직원은 때가 될 때까지 건너뛴다', async () => {
repository.addState({ userEmail: `new@${DOMAIN}` }); // 채우기를 시작하지 않음
repository.addState({
userEmail: `failing@${DOMAIN}`,
status: 'ready',
emailState: engine.state,
failureCount: 3,
updatedAt: new Date(clock),
});
// 같은 DB 의 다른 도메인 줄(e2e 등) — 맞추지 않는다
repository.addState({
userEmail: 'search.index@e2e.arasystem.test',
status: 'ready',
emailState: engine.state,
});
engine.add('a', 1);
await sync.syncAll();
expect(repository.rows.has('a')).toBe(true);
expect(changesCalls()).toBe(1);
expect(sync.isIndexedAddress('x@e2e.arasystem.test')).toBe(false);
expect(sync.isIndexedAddress(' Ara.Kim@ARASYSTEM.test ')).toBe(true);
});
});
describe('실패', () => {
beforeEach(() => {
repository.addState({ status: 'ready', emailState: engine.state });
engine.add('a', 1);
});
it.each([
[
'비밀번호 만료',
new PasswordExpiredException(ARA, 'ara.kim'),
'password-expired',
],
['잠김', new AccountLoginDisabledException(ARA), 'locked'],
[
'엔진에 없음',
new BusinessException('AUTH_001', {
context: { reason: 'impersonation-rejected' },
}),
'locked',
],
] as const)(
'대리 접근이 막히면(%s) 멈춘다 — 맞추기 차례에서 빠진다',
async (_name, error, reason) => {
engine.failNext = error;
expect(await sync.syncUser(ARA, clock + LONG)).toBe(false);
expect(repository.states.get(ARA)?.pausedReason).toBe(reason);
expect(await repository.syncCandidates()).toEqual([]);
expect(logs.join('\n')).not.toContain(ARA);
},
);
it('엔진 장애는 실패 수를 더하고 처음 한 번만 경고한다 — 성공하면 0 (엔진 설명 문장은 남기지 않는다)', async () => {
const outage = () =>
new BusinessException('MAIL_001', {
context: {
reason: 'http-error',
jmapType: 'serverFail',
description: '비밀 문장',
},
});
engine.failNext = outage();
await sync.syncUser(ARA, clock + LONG);
engine.failNext = outage();
await sync.syncUser(ARA, clock + LONG);
expect(repository.states.get(ARA)?.failureCount).toBe(2);
const warnings = logs.filter((line) =>
line.includes('맞추지 못했습니다'),
);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('MAIL_001/http-error/serverFail');
expect(logs.join('\n')).not.toContain('비밀 문장');
expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
expect(repository.states.get(ARA)?.failureCount).toBe(0);
});
});
});
@@ -0,0 +1,483 @@
import {
BeforeApplicationShutdown,
Inject,
Injectable,
Logger,
Optional,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { BusinessException } from '@/common/errors/business.exception';
import {
AccountLoginDisabledException,
PasswordExpiredException,
} from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { maskEmail } from '@/shared/utils/mask';
import { settledWithin } from '@/shared/utils/settle';
import {
isIndexableId,
toIndexRow,
type IndexRowValues,
} from './index-document';
import {
IndexDeadlineError,
MAIL_INDEX_SOURCE,
type IndexChanges,
type IndexMutable,
type MailIndexSource,
} from './mail-index-source';
import {
MailSearchIndexRepository,
type SearchStatePatch,
} from './mail-search-index.repository';
import { ConcurrencyGate, MailSearchRuns } from './mail-search-runs';
import {
MAIL_SEARCH_TIMING,
SYSTEM_SEARCH_TIMING,
type MailSearchTiming,
} from './mail-search-timing';
import {
DOCUMENT_READ_BATCH,
FAILURE_BACKOFF_STEPS_MAX,
MUTABLE_READ_BATCH,
PRE_READ_SYNC_WAIT_MS,
RECONCILE_BUDGET_MS,
RECONCILE_WRITE_BATCH,
SEARCH_CRON_TIME_ZONE,
STEP_MIN_REMAINING_MS,
SYNC_BUDGET_MS,
SYNC_CHANGES_PAGE,
SYNC_CONCURRENCY,
SYNC_DEBOUNCE_MS,
} from './mail-search.constants';
import type {
SearchIndexStatus,
SearchPausedReason,
} from './mail-search.types';
import { isDueAfterFailures } from './search-index-schedule';
/** 전체 대조에서 무엇을 하나 */
export interface ReconcileOptions {
/** 엔진에는 있는데 색인에 없는 메일을 넣는다 — 채우는 중(building)이면 채우기가 할 일이라 넣지 않는다 */
readonly addMissing: boolean;
/** 색인에 있는 메일의 메일함 · 키워드를 다시 읽는다 — 변화를 놓쳤을 때(cannotCalculateChanges) */
readonly refreshMutable: boolean;
/** 끝에 상태 줄에 함께 쓸 것 (채우기의 마무리 — ready) */
readonly finalState?: (indexed: number) => SearchStatePatch;
}
/** 줄을 나눈다 */
function chunks<T>(items: readonly T[], size: number): T[][] {
const out: T[][] = [];
for (let index = 0; index < items.length; index += size) {
out.push(items.slice(index, index + size));
}
return out;
}
/** 로그에 남길 실패 요약 — 코드 · 사유 · 엔진 오류 종류만 (엔진 설명 문장 · 주소 · 글은 넣지 않는다) */
function failureSummary(error: unknown): string {
if (error instanceof BusinessException) {
const reason = error.context?.reason;
const jmapType = error.context?.jmapType;
return [
error.code,
typeof reason === 'string' ? reason : null,
typeof jmapType === 'string' ? jmapType : null,
]
.filter(Boolean)
.join('/');
}
// DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name}/${code}` : name;
}
/** 대리 접근이 막힌 실패인가 → 멈출 까닭 (아니면 null) */
function pausedReasonOf(error: unknown): SearchPausedReason | null {
if (error instanceof PasswordExpiredException) return 'password-expired';
if (error instanceof AccountLoginDisabledException) return 'locked';
// 엔진에 그 계정이 없다(대리 접근 401) — 로그인할 수 없는 계정이라 멈춘다
if (error instanceof BusinessException && error.code === 'AUTH_001') {
return 'locked';
}
return null;
}
/**
* 검색 색인 맞추기 (14단계 설계 B2) — 엔진의 Email state 부터 변화(Email/changes)를 받아 색인에 넣는다. 엔진을 모른다(포트 MAIL_INDEX_SOURCE).
*
* 부르는 때: ① 실시간 연결의 state 소식(SYNC_DEBOUNCE_MS 모음 — mail-events.service.ts) ② 5분마다 모든 직원(한 사람씩 · SYNC_BUDGET_MS 안)
* ③ 검색 · 대화 · 묶은 목록 바로 전 한 번(PRE_READ_SYNC_WAIT_MS 까지 기다린다 — 넘으면 지금 색인으로 답하고 맞추기는 이어 한다 — 14단계 2차부터 부른다).
* 같은 직원은 한 번에 하나(MailSearchRuns) — 기다리는 맞추기가 있으면 새로 넣지 않고 그것을 함께 기다린다.
* 서버 전체에서는 SYNC_CONCURRENCY 개까지 함께 맞춘다(ConcurrencyGate) — 전 직원에게 메일 한 통이 와도 엔진에 한꺼번에 몰리지 않게.
* 맞추는 직원은 사내 도메인(MAIL_DOMAIN) 주소뿐이다 — 로그인할 수 있는 주소만 상태 줄이 생긴다(같은 DB 의 다른 도메인 줄 — e2e 등 — 은 건드리지 않는다).
*
* - 만든 메일 → 글을 읽어 넣는다. 바뀐 메일 → 메일함 · 키워드만(JMAP 에서 글은 바뀌지 않는다) — 색인에 없던 메일은 다 만든 직원(ready)이면 글을 읽어 넣고,
* 채우는 중이면 건너뛴다(채우기가 최신 글로 넣는다). 없어진 메일 → 지운다
* - 한 쪽(SYNC_CHANGES_PAGE)마다 그 쪽의 쓰기와 새 state 를 한 트랜잭션에 — 끝 시각에 닿으면 쓴 데까지가 맞춘 곳이다
* - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 쪽 · 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸려
* 멈추면(IndexDeadlineError) 쓴 데까지 두고 다음 차례에 이어 한다(실패 수 · 경고 · 미루기 없음)
* - 엔진이 셀 수 없다(cannotCalculateChanges) → 전체 대조(reconcile — 모든 id 를 견주어 없는 것은 지우고, 메일함 · 키워드를 다시 읽는다)
* - 대리 접근이 막혔다(비밀번호 만료 · 잠김 · 계정 없음) → 멈춘다(paused_reason — 다음 로그인 · 비밀번호 바꾸기 때 푼다). 그 밖의 실패는 실패 수를 더하고
* 다음 차례를 늦춘다(isDueAfterFailures). 로그는 처음 실패 · 오래 이어질 때만 — 코드 · 사유뿐이다(엔진 설명 문장 · 메일 글 · 주소 없음)
*
* ⚠️ 차례 · 모음 · 동시 수(debounces · queued · syncingAll · gate)는 프로세스 메모리다 — 인스턴스를 늘리면 옮긴다 (nestjs 스킬의 Redis 목록)
*/
@Injectable()
export class MailSearchSyncService implements BeforeApplicationShutdown {
private readonly logger = new Logger(MailSearchSyncService.name);
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** 줄에 넣었지만 아직 시작하지 않은 맞추기 — 같은 직원의 새 요청이 함께 기다린다 */
private readonly queued = new Map<string, Promise<boolean>>();
/** 서버 전체에서 함께 도는 맞추기 수 */
private readonly gate = new ConcurrencyGate(SYNC_CONCURRENCY);
private syncingAll = false;
private stopping = false;
private readonly timing: MailSearchTiming;
/** 맞추는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무도 맞추지 않는다) */
private readonly addressSuffix: string | null;
constructor(
@Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource,
private readonly repository: MailSearchIndexRepository,
private readonly runs: MailSearchRuns,
@Inject(MAIL_SETTINGS) settings: Pick<MailSettings, 'domain'>,
@Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming,
) {
this.timing = timing ?? SYSTEM_SEARCH_TIMING;
const domain = settings.domain?.trim().toLowerCase();
this.addressSuffix = domain ? `@${domain}` : null;
}
/** 색인하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null) — 판이 바뀐 줄 되돌리기가 쓴다 */
get indexedAddressSuffix(): string | null {
return this.addressSuffix;
}
/** 색인하는 주소인가 — 사내 도메인(MAIL_DOMAIN)의 주소만 (5분 맞추기 · 밤 채우기의 차례가 본다) */
isIndexedAddress(user: string): boolean {
return (
this.addressSuffix !== null &&
user.trim().toLowerCase().endsWith(this.addressSuffix)
);
}
/** 실시간 소식 — SYNC_DEBOUNCE_MS 동안 모아 맞추기 한 번 (던지지 않는다) */
requestSync(user: string): void {
const key = user.trim().toLowerCase();
if (this.stopping || key === '' || this.debounces.has(key)) return;
const timer = setTimeout(() => {
this.debounces.delete(key);
void this.enqueueSync(key);
}, SYNC_DEBOUNCE_MS);
timer.unref();
this.debounces.set(key, timer);
}
/**
* 검색 · 대화 · 묶은 목록 바로 전 — 맞추기를 `waitMs` 까지 기다린다 → 그 안에 다 맞췄으면 true.
* 넘으면 false 이고 맞추기는 뒤에서 이어 한다 (부른 쪽은 지금 색인으로 답한다). 던지지 않는다
*/
async syncBeforeRead(
user: string,
waitMs: number = PRE_READ_SYNC_WAIT_MS,
): Promise<boolean> {
const key = user.trim().toLowerCase();
if (this.stopping || key === '') return false;
const sync = this.enqueueSync(key);
const settled = await settledWithin(sync, waitMs);
return settled && (await sync);
}
/** 5분마다 — 맞출 기준이 있는 사내 직원을 한 사람씩 (실패가 이어진 직원은 때가 될 때까지 건너뛴다) */
@Cron('0 */5 * * * *', {
name: 'mail-search-sync',
timeZone: SEARCH_CRON_TIME_ZONE,
})
async syncAll(): Promise<void> {
if (this.syncingAll || this.stopping) return;
this.syncingAll = true;
try {
const candidates = await this.repository.syncCandidates();
for (const state of candidates) {
if (this.stopping) break;
if (
!this.isIndexedAddress(state.userEmail) ||
!isDueAfterFailures(
state.failureCount,
state.updatedAt,
this.timing.now(),
)
) {
continue;
}
await this.enqueueSync(state.userEmail);
}
} catch (error) {
this.logger.error(
`[SYS_001] 검색 색인 맞추기 차례를 읽지 못했습니다: ${failureSummary(error)}`,
);
} finally {
this.syncingAll = false;
}
}
beforeApplicationShutdown(): void {
this.stopping = true;
for (const timer of this.debounces.values()) clearTimeout(timer);
this.debounces.clear();
}
/** 서버가 닫히는 중인가 — 채우기도 멈춘다 */
get isStopping(): boolean {
return this.stopping;
}
/**
* 한 직원을 끝 시각까지 맞춘다 → 다 따라잡았으면 true. 던지지 않는다 — 실패는 상태 줄(멈춤 · 실패 수)과 로그로,
* 시간 끝(IndexDeadlineError)은 실패로 세지 않고 false 다(다음 차례에 이어 한다). 그 직원의 차례(MailSearchRuns) 안에서 부른다
*/
async syncUser(user: string, deadline: number): Promise<boolean> {
try {
return await this.catchUp(user, deadline);
} catch (error) {
if (!(error instanceof IndexDeadlineError)) {
await this.handleFailure(user, error, 'sync');
}
return false;
}
}
/**
* syncUser 의 던지는 판 — 채우기의 마무리가 쓴다. 상태 줄이 없거나 · 멈췄거나 · 맞출 기준(email_state)이 없으면 false.
* 끝 시각 · 서버 종료에 닿아도 false (쓴 데까지가 맞춘 곳이다). 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다
*/
async catchUp(user: string, deadline: number): Promise<boolean> {
const state = await this.repository.findState(user);
if (!state || state.pausedReason !== null || state.emailState === null) {
return false;
}
let since = state.emailState;
for (;;) {
if (!this.hasTime(deadline)) return false;
const changes = await this.source.changes(
user,
since,
SYNC_CHANGES_PAGE,
{ deadline },
);
if (changes.kind === 'cannotCalculate') {
// 변화를 놓쳤다 — 모든 메일을 견준다(더 긴 시간 안에서). 채우는 중이면 없는 메일은 채우기가 넣는다
return this.reconcile(
user,
Math.max(deadline, this.timing.now() + RECONCILE_BUDGET_MS),
{
addMissing: state.status === 'ready',
refreshMutable: true,
},
);
}
const applied = await this.applyChanges(
user,
state.status,
changes,
deadline,
);
if (!applied) return false;
since = changes.newState;
if (!changes.hasMore) return true;
}
}
/**
* 전체 대조 — 엔진의 모든 메일 id 와 색인을 견주어 색인에만 있는 것을 지우고, 고르면 없는 것을 넣고 · 메일함 · 키워드를 다시 읽는다.
* 끝에 그 대조를 시작하기 바로 전의 state 를 맞출 기준으로 적는다(그 뒤의 변화는 다음 맞추기가 다시 한다 — 넣기 · 지우기는 몇 번 해도 같다).
* 끝 시각에 닿으면 false 이고 기준은 그대로다 — 지운 것 · 넣은 것은 남으므로 다음 대조는 남은 것만 한다(메일함이 커도 끝내 끝난다). 던진다
*/
async reconcile(
user: string,
deadline: number,
options: ReconcileOptions,
): Promise<boolean> {
const engine = await this.source.allIds(user, { deadline });
const engineIds = engine.ids.filter(isIndexableId);
const engineSet = new Set(engineIds);
const indexed = new Set(await this.repository.indexedIds(user));
const stale = [...indexed].filter((id) => !engineSet.has(id));
for (const batch of chunks(stale, RECONCILE_WRITE_BATCH)) {
if (!(await this.write(user, { deletes: batch }))) return false;
}
if (options.addMissing) {
const missing = engineIds.filter((id) => !indexed.has(id));
for (const batch of chunks(missing, DOCUMENT_READ_BATCH)) {
const rows = await this.readRows(user, batch, deadline);
if (rows === null) return false;
if (!(await this.write(user, { upserts: rows }))) return false;
}
}
if (options.refreshMutable) {
const kept = engineIds.filter((id) => indexed.has(id));
for (const batch of chunks(kept, MUTABLE_READ_BATCH)) {
if (!this.hasTime(deadline)) return false;
const updates = await this.source.mutable(user, batch, { deadline });
if (!(await this.write(user, { updates }))) return false;
}
}
const indexedCount = await this.repository.countMessages(user);
return this.write(user, {
state: {
emailState: engine.state,
lastSyncedAt: new Date(this.timing.now()),
...(options.finalState ? options.finalState(indexedCount) : {}),
},
});
}
/**
* 실패 하나를 다룬다 — 대리 접근이 막혔으면 멈추고(warn), 아니면 실패 수를 더한다(처음 · FAILURE_BACKOFF_STEPS_MAX 번째에만 로그). 던지지 않는다.
* 시간 끝(IndexDeadlineError)은 실패가 아니라 아무것도 하지 않는다
*/
async handleFailure(
user: string,
error: unknown,
phase: 'sync' | 'build',
): Promise<void> {
if (error instanceof IndexDeadlineError) return;
const now = new Date(this.timing.now());
const summary = failureSummary(error);
try {
const reason = pausedReasonOf(error);
if (reason !== null) {
await this.repository.pause(user, reason, now);
this.logger.warn(
`[${error instanceof BusinessException ? error.code : 'MAIL_001'}] 대리 접근이 막혀 검색 색인을 멈춥니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${reason}`,
);
return;
}
const failures = await this.repository.recordFailure(user, now);
const code = error instanceof BusinessException ? error.code : 'SYS_001';
if (failures === 1) {
this.logger.warn(
`[${code}] 검색 색인을 ${phase === 'sync' ? '맞추지' : '채우지'} 못했습니다(뒤에 다시): user=${maskEmail(user)}, ${summary}`,
);
} else if (failures === FAILURE_BACKOFF_STEPS_MAX) {
this.logger.error(
`[${code}] 검색 색인 ${phase === 'sync' ? '맞추기' : '채우기'}가 ${failures}번 이어 실패했습니다: user=${maskEmail(user)}, ${summary}`,
);
}
} catch (recordError) {
this.logger.error(
`[SYS_001] 검색 색인의 실패를 적지 못했습니다: user=${maskEmail(user)}, ${summary}, record=${failureSummary(recordError)}`,
);
}
}
/**
* 줄에 맞추기를 넣는다 — 기다리는 맞추기(아직 엔진에 묻기 전 — 그 직원의 차례 · 서버 전체 자리를 기다리는 중)가 있으면 그것을 함께 기다린다
*/
private enqueueSync(user: string): Promise<boolean> {
const waiting = this.queued.get(user);
if (waiting) return waiting;
const sync = this.runs.run(user, () =>
this.gate.run(() => {
// 시작했다 — 이 뒤의 소식은 맞추기를 하나 더 넣는다 (이 맞추기가 놓칠 수 있다)
this.queued.delete(user);
if (this.stopping) return Promise.resolve(false);
return this.syncUser(user, this.timing.now() + SYNC_BUDGET_MS);
}),
);
this.queued.set(user, sync);
void sync.catch(() => this.queued.delete(user));
return sync;
}
/** 한 쪽의 변화를 한 트랜잭션에 → 썼으면 true (상태 줄이 없거나 끝 시각이면 false) */
private async applyChanges(
user: string,
status: SearchIndexStatus,
changes: Extract<IndexChanges, { kind: 'changes' }>,
deadline: number,
): Promise<boolean> {
const destroyed = new Set(changes.destroyed);
const created = new Set(
changes.created.filter((id) => isIndexableId(id) && !destroyed.has(id)),
);
// 만든 메일은 글째 넣으므로 바뀐 메일에서 뺀다 (한 쪽에 둘 다 오면 Email/get 에 두 번 싣지 않게)
const updated = [
...new Set(
changes.updated.filter(
(id) => isIndexableId(id) && !destroyed.has(id) && !created.has(id),
),
),
];
const existing = await this.repository.existingIds(user, updated);
// 색인에 없던 바뀐 메일 — 다 만든 직원이면 글째 넣는다 (채우는 중이면 채우기가 넣는다)
const unknown =
status === 'ready' ? updated.filter((id) => !existing.has(id)) : [];
const rows = await this.readRows(user, [...created, ...unknown], deadline);
if (rows === null) return false;
let updates: IndexMutable[] = [];
const known = updated.filter((id) => existing.has(id));
if (known.length > 0) {
if (!this.hasTime(deadline)) return false;
updates = await this.source.mutable(user, known, { deadline });
}
return this.write(user, {
deletes: [...destroyed],
upserts: rows,
updates,
state: {
emailState: changes.newState,
lastSyncedAt: new Date(this.timing.now()),
},
});
}
/**
* 메일 글을 DOCUMENT_READ_BATCH 씩 읽어 색인 줄로 (그사이 없어진 메일은 빠진다) — 다음 묶음을 시작할 시간(STEP_MIN_REMAINING_MS)이 없으면 null.
* 묶음 안의 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다
*/
async readRows(
user: string,
ids: readonly string[],
deadline: number,
): Promise<IndexRowValues[] | null> {
const rows: IndexRowValues[] = [];
for (const batch of chunks(ids, DOCUMENT_READ_BATCH)) {
if (!this.hasTime(deadline)) return null;
const documents = await this.source.documents(user, batch, { deadline });
for (const document of documents) {
if (isIndexableId(document.id)) rows.push(toIndexRow(document));
}
}
return rows;
}
private async write(
user: string,
change: Parameters<MailSearchIndexRepository['write']>[1],
): Promise<boolean> {
const result = await this.repository.write(
user,
change,
new Date(this.timing.now()),
);
return result.written;
}
/** 다음 쪽 · 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */
private hasTime(deadline: number): boolean {
return (
!this.stopping && this.timing.now() <= deadline - STEP_MIN_REMAINING_MS
);
}
}
@@ -0,0 +1,19 @@
/**
* 검색 색인의 시계 · 쉬기 (14단계) — 테스트가 바꿔 끼운다(고정 대기 없이 — testing 규칙).
* 앱은 Date.now · setTimeout(unref — 종료를 붙잡지 않는다)이다
*/
export const MAIL_SEARCH_TIMING = Symbol('MAIL_SEARCH_TIMING');
export interface MailSearchTiming {
now(): number;
sleep(ms: number): Promise<void>;
}
export const SYSTEM_SEARCH_TIMING: MailSearchTiming = {
now: () => Date.now(),
sleep: (ms) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref();
}),
};
@@ -0,0 +1,178 @@
/**
* 메일 검색 색인 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕) 의 상수.
* 묶음 크기 · 시간 한도는 개발 엔진에서 잰 뒤 바꾼다 (설계 E 의 2 — 1만 통 재기). 바꿀 때는 이 파일만 고친다.
*/
/** 표의 이름 — 마이그레이션 `AddMailSearchIndex` 와 같다 */
export const SEARCH_MESSAGES_TABLE = 'mail_search_messages';
export const SEARCH_STATES_TABLE = 'mail_search_states';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
export const SEARCH_EMAIL_COLUMN_LENGTH = 254;
/** 엔진 id(메일 · 대화)의 열 길이 — Stalwart 는 13자 안팎이다. 넘는 id 는 색인하지 않는다 (index-document.ts) */
export const SEARCH_ID_COLUMN_LENGTH = 64;
/** 짧은 낱말 열(상태 · 멈춘 까닭) */
export const SEARCH_WORD_COLUMN_LENGTH = 32;
/**
* 본문 글을 색인에 담는 길이 — 처음 이만큼의 코드 포인트 (설계 A1, 처음 값 20,000 — 잰 뒤 정한다).
* 그 뒤의 글로는 찾지 못한다 (긴 메일의 뒷부분)
*/
export const INDEX_BODY_MAX_CHARS = 20_000;
/** 제목을 담는 길이 (코드 포인트) — 바깥에서 온 메일의 아주 긴 제목이 줄을 키우지 않게 */
export const INDEX_SUBJECT_MAX_CHARS = 1_000;
/** 보낸 사람 · 받는 사람 · 참조 한 칸에 담는 주소 수 — 받는 사람이 수천인 메일이 줄을 키우지 않게 */
export const INDEX_ADDRESSES_MAX = 200;
/** 주소 한 줄("이름 <주소>")의 길이 (코드 포인트) */
export const INDEX_ADDRESS_LINE_MAX_CHARS = 400;
/**
* 첫 보낸 사람의 이름을 담는 길이 (코드 포인트 — 열 `from_name` 의 varchar 길이와 같다). 묶은 목록의 참여자 이름이다 (14단계 3차 리뷰 L3).
* 주소(`from_email`)는 SEARCH_EMAIL_COLUMN_LENGTH 까지 — 넘으면 담지 않는다
*/
export const INDEX_FROM_NAME_MAX_CHARS = 100;
/** 첨부 이름을 담는 수 · 이름 하나의 길이 (코드 포인트) */
export const INDEX_ATTACHMENT_NAMES_MAX = 100;
export const INDEX_ATTACHMENT_NAME_MAX_CHARS = 255;
/** 미리 보기(엔진 preview)의 길이 (코드 포인트) — 엔진은 256자 안쪽을 준다 */
export const INDEX_PREVIEW_MAX_CHARS = 512;
/** 답장 관계(In-Reply-To ∪ References)를 담는 수 · Message-ID 하나의 길이 — 넘는 것은 담지 않는다 */
export const INDEX_REFS_MAX = 50;
export const INDEX_MESSAGE_ID_MAX_LENGTH = 255;
/**
* 시간 끝 — 차례(맞추기 · 채우기)의 끝 시각에 닿아 멈추는 것은 실패가 아니라 "이어 하기"다 (실패 수 · 경고 · 미루기 없음).
* - 엔진 읽기 한 번은 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고(IndexDeadlineError), 한 번에 INDEX_CALL_TIMEOUT_MS 까지만 기다린다
* - 맞추기 한 쪽 · 채우기 한 묶음 · 대조의 한 걸음은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 시작하지 않는다 — 시작한 묶음이 짧은 시간에 잘리지 않게
* - 채우기의 마무리(따라잡기 · 전체 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만(= 새 차례의 처음) 시작한다
*/
export const INDEX_CALL_MIN_MS = 2_000;
export const INDEX_CALL_TIMEOUT_MS = 15_000;
export const STEP_MIN_REMAINING_MS = 5_000;
export const FINISH_MIN_REMAINING_MS = 30_000;
/** 전체 대조(엔진이 변화를 셀 수 없을 때)의 시간 — 모든 메일의 메일함 · 키워드를 다시 읽는다 */
export const RECONCILE_BUDGET_MS = 120_000;
/**
* 본문 값을 한 번에 읽는 메일의 크기 상한 — 메일 **전체** 크기(엔진의 size)의 합. 엔진은 값을 만들며 원문을 읽으므로,
* 큰 첨부가 든 메일 여럿을 한 요청에 묶으면 엔진(메모리 1g)이 한꺼번에 그만큼 읽는다. 이보다 큰 메일 한 통은 혼자 읽는다.
* (처음 값 — 큰 첨부 메일로 잰 뒤 다시 정한다)
*/
export const BODY_READ_BATCH_MESSAGE_BYTES = 32 * 1024 * 1024;
export const BODY_READ_SOLO_MESSAGE_BYTES = 16 * 1024 * 1024;
/** 지난 메일 채우기의 한 묶음 — 받은 차례(최신부터)로 이만큼씩 읽어 넣는다 (설계 B3, 잰 뒤 정한다) */
export const BUILD_BATCH_SIZE = 50;
/** 한 묶음을 넣은 뒤 쉬는 시간 — 엔진(메모리 1g)이 다른 요청을 받을 틈 */
export const BUILD_BATCH_PAUSE_MS = 200;
/** 밤 채우기 한 차례(1분마다 깨어 한 사람) 의 시간 */
export const BUILD_RUN_BUDGET_MS = 50_000;
/** 맞추기(Email/changes) 한 번에 받는 변화 수 — 만든 메일의 글을 같은 차례에 읽으므로 작게 */
export const SYNC_CHANGES_PAGE = 100;
/** 맞추기 한 차례의 시간 — 5분마다 한 사람씩 · 실시간 소식 (설계 B2) */
export const SYNC_BUDGET_MS = 20_000;
/** 실시간 소식(state)을 모으는 시간 — 잇달아 온 소식을 맞추기 한 번으로 */
export const SYNC_DEBOUNCE_MS = 300;
/** 검색 · 대화 · 묶은 목록 바로 전의 맞추기를 기다리는 시간 — 넘으면 지금 색인으로 답하고 맞추기는 뒤에서 이어 한다 */
export const PRE_READ_SYNC_WAIT_MS = 1_500;
/**
* 서버 전체에서 함께 도는 맞추기 수 — 전 직원에게 메일 한 통이 오면 실시간 소식이 직원 수만큼 한꺼번에 온다.
* 넘는 맞추기는 차례를 기다린다(그동안 같은 직원의 새 소식은 기다리는 맞추기에 합친다)
*/
export const SYNC_CONCURRENCY = 4;
/** 메일 글을 한 번에 읽는 수 (Email/get — 엔진의 maxObjectsInGet 500 이하, 본문은 따로 나눠 읽는다) */
export const DOCUMENT_READ_BATCH = 50;
/** 표시 · 메일함만 한 번에 읽는 수 (바뀐 메일 — maxObjectsInGet 500) */
export const MUTABLE_READ_BATCH = 500;
/** 전체 대조에서 엔진의 메일 id 를 한 번에 묻는 수 — 엔진이 줄이면 받은 만큼 이어 묻는다 */
export const ALL_IDS_PAGE = 5_000;
/** 전체 대조에서 우리 표에 한 번에 넣고 지우는 수 */
export const RECONCILE_WRITE_BATCH = 500;
/** 실패가 이만큼 이어지면 5분 맞추기 · 밤 채우기의 차례에서 뒤로 미룬다 (failure_count — 성공하면 0) */
export const FAILURE_BACKOFF_STEPS_MAX = 6;
/** 실패한 직원을 다시 해 보기까지 — 실패 수마다 두 배(5분 · 10분 · … 상한 FAILURE_BACKOFF_STEPS_MAX 단계) */
export const FAILURE_BACKOFF_BASE_MS = 5 * 60_000;
/**
* 밤 채우기의 창 (한국 시간, 하루의 분) — 22:00 ~ 02:45 · 05:00 ~ 07:30.
* 03:00 · 04:00 · 04:30 엔진 정리(휴지통 · 스팸메일함 비우기)와 03:30 백업(엔진을 잠깐 멈춘다)을 피한다 (설계 B3)
*/
export const BUILD_WINDOWS_KST: readonly (readonly [
startMinute: number,
endMinute: number,
])[] = [
[22 * 60, 24 * 60],
[0, 2 * 60 + 45],
[5 * 60, 7 * 60 + 30],
];
/** 실행 시각 표의 시간대 — 컨테이너는 UTC 로 돈다 */
export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul';
/**
* 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format).
* 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다.
* 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게)
* · 3: 대화 키를 잇는다(14단계 3차 — 답장 관계 + 말머리를 뗀 제목. 판 2 까지의 줄은 키가 메일마다 따로다)
* · 4: 첫 보낸 사람의 주소 · 이름 칸(from_email · from_name — 3차 리뷰 L3, 마이그레이션 AddMailSearchSender. 판 3 까지의 줄은 칸이 비었다)
*/
export const INDEX_FORMAT = 4;
/**
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼 · 2026-11-06 메일함 범위 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이
* 비싸 그 수를 묶는다:
* - 후보: 주소 + 묶음 + 메일함(GIN 하나 — 마이그레이션 SearchIndexUserGramsMailboxes 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지
* 읽는다(줄의 글을 풀지 않는다 — 1만 통 직원의 흔한 낱말이 전체 · 큰 메일함 10~30ms, 작은 메일함 1~7ms. 맞은 줄이 표에 흩어져 있으면 그 줄 수만큼 더 든다)
* - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터
* SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초 — 캐시가 찬 때.
* 2026-11-06 1만 통 직원의 드문 구절로 3,000줄 0.51~0.58초).
* 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext)
* - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null
*/
export const SEARCH_CANDIDATE_COUNT_LIMIT = 2_000;
export const SEARCH_CHECK_ROWS_LIMIT = 3_000;
export const SEARCH_FIRST_CHECK_ROWS = 100;
export const SEARCH_CHECK_ROWS_GROWTH = 4;
export const SEARCH_TOTAL_LIMIT = 1_000;
/**
* 한 글자 낱말(글자 두 개 묶음이 없는 낱말)만으로 찾을 때 본문은 훑지 않는다 — 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾는다.
* 두 글자 넘는 낱말과 함께면 한 글자 낱말은 확인에만 쓴다(본문 포함). false 로 두면 한 글자 낱말만이어도 본문까지 훑는다(확인 상한 안에서)
*/
export const SINGLE_CHAR_TERMS_SKIP_BODY = true;
/** 목록의 미리 보기 둘레 — 맞은 말 앞 글자 수 · 모두 글자 수(남짓 — 말 경계에서 자른다) · 말 경계를 찾는 여유 (UTF-16 — 서로게이트 쌍은 가르지 않는다) */
export const SNIPPET_LEAD_CHARS = 20;
export const SNIPPET_TOTAL_CHARS = 80;
export const SNIPPET_BOUNDARY_SLACK = 10;
/**
* 대화 (14단계 3차 — 설계 C3 · C4). 같은 대화 칸의 한 쪽(기본이자 최대 — 화면이 한 번에 보인다) · 묶은 목록 줄의 참여자 수 상한(보낸 사람 — 받은 차례로 앞에서부터)
* · 묶은 목록 줄의 messageIds 상한(그 메일함 안 — 최신부터, 3차 리뷰 L4. 넘는 오래된 메일은 줄의 여러 통 처리에 들지 않는다)
*/
export const CONVERSATION_PAGE_LIMIT = 100;
export const CONVERSATION_PARTICIPANTS_MAX = 10;
export const CONVERSATION_MESSAGE_IDS_MAX = 500;
/**
* 엔진 방식 대화(색인 전 · 첫 쪽 DB 장애의 대신 — modules/mail/jmap/jmap-conversations.ts)의 상한 (3차 리뷰 H1) — GitHub · Jira 알림처럼
* 수천 통인 스레드에서도 한 요청이 엔진을 크게 쓰지 않게.
* - 거르기(휴지통 · 스팸메일함에만 든 메일 · 내부 초안)는 값싼 속성만 읽고, 요약 속성은 내보낼 메일(같은 대화 — 한 쪽 · 묶은 목록 — 줄마다 마지막 메일)만 읽는다
* - 같은 대화: 한 요청이 훑는 메일 CONVERSATION_ENGINE_SCAN_MAX 통까지 — 스레드가 그 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 필요한 만큼만(total null)
* - 묶은 목록: 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통 · 한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통까지(작은 스레드부터 다 주고 남는 몫을 나눈다)
* - 한 요청의 시간 CONVERSATION_ENGINE_BUDGET_MS (메일함 처리의 BulkRun 과 같은 결) — 훑는 호출은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS"
* 중 짧은 쪽(마지막 요약 읽기 몫을 늘 남긴다 — 3차 재확인 N1), 마지막 읽기만 남은 시간을 다 쓴다. 남은 시간이 그 몫보다 적으면 더 훑지 않고,
* 훑던 호출이 시간 때문에 끊기면 읽은 데까지로 답한다(같은 대화: 다 세야 하는 작은 스레드 · 아무것도 못 읽음은 503 MAIL_001 / 묶은 목록: 어림 줄)
*/
export const CONVERSATION_ENGINE_SCAN_MAX = 1_000;
export const CONVERSATION_ENGINE_THREAD_SCAN_MAX = 500;
export const CONVERSATION_ENGINE_LIST_SCAN_MAX = 2_000;
export const CONVERSATION_ENGINE_BUDGET_MS = 15_000;
export const CONVERSATION_ENGINE_RESERVE_MS = 3_000;
/** 색인의 키워드(엔진이 준 그대로 — JMAP 표준) 가운데 묶은 목록이 보는 것 — 읽음 · 중요 표시 */
export const SEEN_KEYWORD = '$seen';
export const FLAGGED_KEYWORD = '$flagged';
@@ -0,0 +1,49 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JmapIndexSource } from '@/modules/mail/jmap/jmap-index-source';
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
import { MailConversationQueryService } from './mail-conversation-query.service';
import { MAIL_INDEX_SOURCE } from './mail-index-source';
import { MailSearchBuildService } from './mail-search-build.service';
import { MailSearchIndexRepository } from './mail-search-index.repository';
import { MailSearchIndexService } from './mail-search-index.service';
import { MailSearchQueryService } from './mail-search-query.service';
import { MailSearchMessageEntity } from './mail-search-message.entity';
import { MailSearchRuns } from './mail-search-runs';
import { MailSearchStateEntity } from './mail-search-state.entity';
import { MailSearchSyncService } from './mail-search-sync.service';
import { SearchIndexController } from './search-index.controller';
/**
* 메일 검색 색인 모듈 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕).
* 엔진을 바꿀 때는 `MAIL_INDEX_SOURCE` 의 구현(useClass)만 바꾼다 — 맞추기 · 채우기는 포트만 안다.
* 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/jmap-index-source.ts` 에 있다.
*
* 메일 모듈(실시간 소식 → 맞추기)과 인증 모듈(로그인 → 상태 줄)이 가져다 쓴다 — 이 모듈은 둘을 가져오지 않는다 (순환 없음).
* 운영 도구(서버와 다른 프로세스)는 이 모듈을 가져오지 않는다 — 퇴사 마무리는 표만 지운다 (ops-data.service.ts)
*/
@Module({
imports: [
TypeOrmModule.forFeature([MailSearchMessageEntity, MailSearchStateEntity]),
MailEngineModule,
],
controllers: [SearchIndexController],
providers: [
{ provide: MAIL_INDEX_SOURCE, useClass: JmapIndexSource },
MailSearchIndexRepository,
MailSearchRuns,
MailSearchSyncService,
MailSearchBuildService,
MailSearchIndexService,
MailSearchQueryService,
MailConversationQueryService,
],
exports: [
MailSearchIndexService,
MailSearchSyncService,
MailSearchQueryService,
MailConversationQueryService,
],
})
export class MailSearchModule {}
@@ -0,0 +1,79 @@
/**
* 메일 검색 색인의 도메인 타입 (14단계) — 엔진을 모른다.
*/
/** 색인 상태 — 지난 메일을 채우는 중 · 다 만들었음 (설계 A2) */
export type SearchIndexStatus = 'building' | 'ready';
/**
* 맞추기를 멈춘 까닭 — 대리 접근이 막혔다 (설계 B2). 다음 로그인(또는 검색 준비 상태를 물을 때) 푼다.
* - password-expired: 비밀번호가 만료됐다 (엔진이 메일 기능을 막는다 — 13단계)
* - locked: 로그인할 수 없는 계정 — 잠김(로그인 권한 없음) · 엔진에 없음
*/
export type SearchPausedReason = 'password-expired' | 'locked';
/** GET /api/search-index 의 응답 (설계 C1) — 화면의 "검색 준비 중" 줄 */
export interface SearchIndexStatusView {
readonly status: SearchIndexStatus;
/** 채운 메일 수 — 다 만들었으면 그때 색인에 든 수 */
readonly done: number;
/** 채울 메일 수(채우기를 시작할 때의 엔진 메일 수) — 아직 시작하지 않았으면 null */
readonly total: number | null;
}
/**
* 검색 조건 (GET /api/messages — 14단계 설계 C1) — DTO 가 검사한 값. 글 칸은 앞뒤 공백을 뺀 값이고 빈 글은 없는 것이다.
* after · before 는 ISO 8601 시각 — 받은 시각이 after 이상 · before 미만
*/
export interface SearchCriteria {
/** 검색어(q) — 보낸 사람 · 받는 사람 · 참조 · 제목 · 첨부 이름 · 본문 */
readonly text?: string;
readonly from?: string;
/** 받는 사람 — 참조도 */
readonly to?: string;
readonly subject?: string;
readonly body?: string;
readonly after?: string;
readonly before?: string;
/** true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정) */
readonly hasAttachment?: boolean;
}
/** 검색어 밖의 조건 — 엔진 방식 검색이 JMAP 필터로 쓴다 */
export type SearchConditions = Omit<SearchCriteria, 'text'>;
/**
* 찾는 범위 (엔진이 알려 준다 — 메일 포트의 searchScope): 메일함 하나, 또는 빼는 메일함(휴지통 · 스팸메일함)에만 있지 않은 메일.
* 목록에서 빼는 키워드(발송 중인 내부 초안)가 있는 메일은 찾지 않는다
*/
export interface SearchScope {
readonly mailboxId: string | null;
readonly excludedMailboxIds: readonly string[];
readonly excludedKeywords: readonly string[];
}
/** 맞은 곳 — [시작, 끝) UTF-16 */
export type SearchRange = readonly [start: number, end: number];
/** 목록 항목의 강조 (색인으로 찾았을 때만 — 엔진 방식이면 null) */
export interface MessageListHighlight {
readonly subject: SearchRange[];
/** 본문에서 맞았으면 맞은 말 둘레, 아니면 엔진의 미리 보기 */
readonly preview: { readonly text: string; readonly ranges: SearchRange[] };
}
/** 읽기 화면의 강조 — 첨부는 맞은 것만(첨부 id → 맞은 곳) */
export interface MessageDetailHighlight {
readonly subject: SearchRange[];
readonly attachments: Record<string, SearchRange[]>;
readonly textBody: SearchRange[];
}
/** 대화의 참여자 — 보낸 사람 (메일 모듈의 MailAddress 와 같은 모양) */
export interface ConversationParticipant {
readonly name: string | null;
readonly email: string;
}
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목 — 14단계 B4) · 엔진의 스레드(색인을 다 만들기 전) */
export type ConversationBasis = 'index' | 'engine';
@@ -0,0 +1,137 @@
import { BusinessException } from '@/common/errors/business.exception';
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
import type { SearchCriteria } from './mail-search.types';
import {
cursorMethod,
decodeIndexCursor,
encodeIndexCursor,
searchScopeKey,
} from './search-cursor';
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
hasAttachment: false,
...overrides,
});
function codeOf(run: () => unknown): string | null {
try {
run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('색인 검색의 커서 (search-cursor — 14단계 설계 C1)', () => {
const position = {
receivedAt: new Date('2026-11-02T01:23:45.678Z'),
emailId: 'm9x',
};
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
it('자리(받은 시각 ms · id)를 그대로 되돌린다 — URL 에 그대로 넣을 수 있는 글자만', () => {
const cursor = encodeIndexCursor(position, key);
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeIndexCursor(cursor, key)).toEqual(position);
});
it('방식을 커서로 안다 — 색인 커서는 index, 엔진 커서(1판) · 알 수 없는 값은 engine(엔진 쪽이 검사한다)', () => {
expect(cursorMethod(encodeIndexCursor(position, key))).toBe('index');
expect(
cursorMethod(
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
),
).toBe('engine');
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600), 'bnVsbA']) {
expect(cursorMethod(cursor)).toBe('engine');
}
});
it('범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 하나라도 다르면 다른 검색의 커서라 VAL_001', () => {
const base = criteria({
text: '계약',
from: '이도윤',
after: '2026-10-31T15:00:00.000Z',
});
const cursor = encodeIndexCursor(position, searchScopeKey('a', base));
expect(decodeIndexCursor(cursor, searchScopeKey('a', base))).toEqual(
position,
);
for (const [mailboxId, other] of [
[undefined, base],
['b', base],
['a', { ...base, text: '견적' }],
['a', { ...base, from: undefined }],
['a', { ...base, to: '이도윤' }],
['a', { ...base, subject: '계약' }],
['a', { ...base, body: '계약' }],
['a', { ...base, after: '2026-10-30T15:00:00.000Z' }],
['a', { ...base, before: '2026-11-30T15:00:00.000Z' }],
['a', { ...base, hasAttachment: true }],
] as const) {
expect(
codeOf(() =>
decodeIndexCursor(cursor, searchScopeKey(mailboxId, other)),
),
).toBe('VAL_001');
}
});
it('칸이 달라도 이은 글이 같아지지 않는다 — 같은 글을 다른 칸에 둔 검색은 다른 범위', () => {
expect(searchScopeKey(undefined, criteria({ from: '계약' }))).not.toBe(
searchScopeKey(undefined, criteria({ to: '계약' })),
);
expect(searchScopeKey('a', criteria({}))).not.toBe(
searchScopeKey(undefined, criteria({})),
);
});
it('모양이 틀린 커서는 VAL_001 — 엔진 커서 · 틀린 판 · 이상한 id · 음수 시각 · 너무 긴 값', () => {
const forge = (payload: object) =>
Buffer.from(JSON.stringify(payload)).toString('base64url');
for (const cursor of [
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
forge({ v: 3, m: 'i', t: 1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'e', t: 1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: -1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: 1.5, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: 1, id: '../x', s: 'x' }),
forge({ v: 2, m: 'i', t: 1, id: 'm1' }),
'not-json',
'a'.repeat(600),
]) {
expect(codeOf(() => decodeIndexCursor(cursor, key))).toBe('VAL_001');
}
});
it('Date 로 읽을 수 없는 시각(안전한 정수지만 ±8.64e15 ms 밖)의 커서는 VAL_001 — 범위 지문이 맞아도 500 이 아니다 (리뷰 L1)', () => {
// 이 검색의 진짜 커서에서 시각만 바꾼다 — 범위 지문(s)은 맞는다
const real = JSON.parse(
Buffer.from(encodeIndexCursor(position, key), 'base64url').toString(
'utf8',
),
) as Record<string, unknown>;
const withTime = (t: number) =>
Buffer.from(JSON.stringify({ ...real, t })).toString('base64url');
expect(Number.isSafeInteger(9e15)).toBe(true);
expect(codeOf(() => decodeIndexCursor(withTime(9e15), key))).toBe(
'VAL_001',
);
// 한도 끝(8.64e15)까지는 날짜다 — 그대로 읽는다
expect(decodeIndexCursor(withTime(8.64e15), key)).toEqual({
receivedAt: new Date(8.64e15),
emailId: position.emailId,
});
});
it('범위 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸을 흉내 내도 다른 범위 (리뷰 L7)', () => {
const NL = String.fromCharCode(10);
expect(searchScopeKey(undefined, criteria({ from: `a${NL}b` }))).not.toBe(
searchScopeKey(undefined, criteria({ from: 'a', to: 'b' })),
);
expect(searchScopeKey(undefined, criteria({ text: `계약${NL}` }))).not.toBe(
searchScopeKey(undefined, criteria({ text: '계약', from: '' })),
);
});
});
@@ -0,0 +1,122 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
import type { SearchCriteria } from './mail-search.types';
import type { SearchPosition } from './search-sql';
/**
* 색인으로 찾은 검색의 커서 (14단계 설계 C1 — 클라이언트에게는 불투명 문자열).
* `{ v: 2, m: 'i', t: 받은 시각(ms), id, s: 범위 지문 }` — 그 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 이어 찾는다.
* 다 찾지 못하고 확인 상한에 닿은 쪽(이어 찾기)도 같은 꼴이다 — 마지막으로 **훑은** 줄이 자리다.
*
* 방식(m)이 커서에 실려, 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 그 검색은 처음 방식대로 이어 간다 —
* 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로, 이 커서는 색인으로.
* 범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 다른 검색의 커서를 섞어 쓰면 VAL_001. 지문은 짧은 해시라 글을 담지 않는다
*/
const CURSOR_VERSION = 2;
const INDEX_METHOD = 'i';
const MAX_CURSOR_LENGTH = 512;
const SCOPE_HASH_LENGTH = 16;
/** "모든 메일함"(휴지통 · 스팸메일함 제외) 범위 — 메일함 id 형식에 없는 글자 */
const ALL_MAILBOXES_SCOPE = '*';
interface EncodedIndexCursor {
v: number;
m: string;
t: number;
id: string;
s: string;
}
/** 검색 방식 — 커서를 읽어 본다. 색인 커서가 아니면(엔진 방식 · 모양이 틀림) 'engine' — 엔진 쪽이 검사한다 */
export function cursorMethod(cursor: string): 'index' | 'engine' {
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
try {
const parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
) as Partial<EncodedIndexCursor> | null;
return parsed?.v === CURSOR_VERSION && parsed.m === INDEX_METHOD
? 'index'
: 'engine';
} catch {
return 'engine';
}
}
/**
* 범위 지문의 글 — 메일함 · 검색어 · 조건을 정해진 차례로 (빈 칸은 빈 글).
* 배열을 JSON 으로 잇는다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않게 (from 에 줄바꿈을 넣어 to 칸을 흉내 낼 수 없다)
*/
export function searchScopeKey(
mailboxId: string | undefined,
criteria: SearchCriteria,
): string {
return JSON.stringify([
mailboxId ?? ALL_MAILBOXES_SCOPE,
criteria.text ?? '',
criteria.from ?? '',
criteria.to ?? '',
criteria.subject ?? '',
criteria.body ?? '',
criteria.after ?? '',
criteria.before ?? '',
criteria.hasAttachment === true,
]);
}
function scopeHash(scopeKey: string): string {
return createHash('sha256')
.update(scopeKey)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-cursor:${reason}` },
});
}
export function encodeIndexCursor(
position: SearchPosition,
scopeKey: string,
): string {
const payload: EncodedIndexCursor = {
v: CURSOR_VERSION,
m: INDEX_METHOD,
t: position.receivedAt.getTime(),
id: position.emailId,
s: scopeHash(scopeKey),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** 커서를 읽는다 — 모양이 틀리거나 다른 검색의 커서면 VAL_001 */
export function decodeIndexCursor(
cursor: string,
scopeKey: string,
): SearchPosition {
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, m, t, id, s } = (parsed ?? {}) as Partial<EncodedIndexCursor>;
const valid =
v === CURSOR_VERSION &&
m === INDEX_METHOD &&
typeof t === 'number' &&
Number.isSafeInteger(t) &&
t >= 0 &&
// 날짜로 읽을 수 있는 시각만 — Date 의 한도(±8.64e15 ms) 밖이면 Invalid Date 라 쿼리 값으로 쓰면 500 이었다
Number.isFinite(new Date(t).getTime()) &&
typeof id === 'string' &&
MAIL_ID_PATTERN.test(id) &&
typeof s === 'string';
if (!valid) throw invalidCursor('shape');
if (s !== scopeHash(scopeKey)) throw invalidCursor('other-list');
return { receivedAt: new Date(t), emailId: id };
}
@@ -0,0 +1,382 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { foldText, matchRanges } from '@/shared/utils/search-text';
import {
SNIPPET_BOUNDARY_SLACK,
SNIPPET_LEAD_CHARS,
SNIPPET_TOTAL_CHARS,
} from './mail-search.constants';
import {
highlightedSubject,
listHighlight,
snippetAround,
} from './search-highlight';
import type { HighlightTerms } from './search-plan';
const terms = (...words: string[]): HighlightTerms => ({
subject: words,
body: words,
attachment: words,
query: words,
});
/** 색인의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (listHighlight 의 otherFields) */
const otherFields = (fields: {
from?: string;
to?: string;
subject?: string;
attachment?: string;
}): string[] =>
[fields.from, fields.to, fields.subject, fields.attachment].map((field) =>
foldText(field ?? ''),
);
/** 글에서 낱말이 처음 맞은 곳 */
const firstMatch = (body: string, word: string) =>
matchRanges(body, [word], 1)[0];
/** 범위가 가리키는 글 */
const slices = (text: string, ranges: readonly (readonly [number, number])[]) =>
ranges.map(([start, end]) => text.slice(start, end));
describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () => {
describe('맞은 말 둘레 (snippetAround)', () => {
it('짧은 본문은 그대로 — 잘라 낸 글이 없으면 "…" 도 없다', () => {
const body = '이번 달 계약 갱신 일정입니다.';
expect(snippetAround(body, firstMatch(body, '계약'))).toBe(body);
});
it(`맞은 곳 앞 ${SNIPPET_LEAD_CHARS}자 남짓부터 모두 ${SNIPPET_TOTAL_CHARS}자 남짓 — 낱말 경계(공백)에서 자르고 앞뒤에 "…"`, () => {
const before = Array.from({ length: 30 }, (_, i) => `앞말${i}`).join(' ');
const after = Array.from({ length: 30 }, (_, i) => `뒷말${i}`).join(' ');
const body = `${before} 계약서를 보내 드립니다 ${after}`;
const snippet = snippetAround(body, firstMatch(body, '계약'));
expect(snippet.startsWith('…')).toBe(true);
expect(snippet.endsWith('…')).toBe(true);
const inner = snippet.slice(1, -1);
expect(inner).toContain('계약서를');
// 낱말 가운데를 자르지 않았다 — 앞 · 뒤 조각이 본문의 낱말 그대로
const words = inner.split(' ');
for (const word of [words[0], words[words.length - 1]]) {
expect(body.split(' ')).toContain(word);
}
// 맞은 곳 앞은 SNIPPET_LEAD_CHARS + 여유 안, 모두 SNIPPET_TOTAL_CHARS + 여유 안
expect(inner.indexOf('계약')).toBeLessThanOrEqual(
SNIPPET_LEAD_CHARS + SNIPPET_BOUNDARY_SLACK,
);
expect(inner.length).toBeLessThanOrEqual(
SNIPPET_TOTAL_CHARS + SNIPPET_BOUNDARY_SLACK * 2,
);
});
it('공백이 없는 긴 글은 그 자리에서 자르고 "…" — 맞은 곳은 늘 담는다', () => {
const body = `${'가'.repeat(100)}계약${'나'.repeat(200)}`;
const snippet = snippetAround(body, firstMatch(body, '계약'));
expect(snippet).toBe(
`…${'가'.repeat(SNIPPET_LEAD_CHARS)}계약${'나'.repeat(SNIPPET_TOTAL_CHARS - SNIPPET_LEAD_CHARS - 2)}…`,
);
});
it('맞은 말이 쪽보다 길어도 다 담는다', () => {
const long = '계'.repeat(150);
const body = `안내 ${long} 끝`;
const snippet = snippetAround(body, [3, 3 + long.length]);
expect(snippet).toContain(long);
});
it('줄바꿈 · 탭 · 여러 칸 공백은 띄어쓰기 하나로 — 잘라 낸 쪽이 공백뿐이면 "…" 를 붙이지 않는다', () => {
const body = `${' '.repeat(30)}계약\n\n\t갱신 일정${'\n'.repeat(5)}`;
expect(snippetAround(body, firstMatch(body, '계약'))).toBe(
'계약 갱신 일정',
);
});
it('서로게이트 쌍(이모지) 가운데를 가르지 않는다', () => {
const body = `${'😀'.repeat(40)}계약${'😀'.repeat(60)}`;
const snippet = snippetAround(body, firstMatch(body, '계약'));
// 짝이 없는 서로게이트가 없다
expect(snippet).toBe(snippet.normalize());
expect(() => encodeURIComponent(snippet)).not.toThrow();
expect(snippet).toContain('계약');
});
});
describe('항목 하나의 강조 (listHighlight)', () => {
it('제목의 맞은 곳(합친 범위) · 본문에서 처음 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳', () => {
const highlight = listHighlight(
{
subject: '[공지] 계약 갱신 · 계약서',
preview: '엔진의 미리 보기',
bodyText:
'안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.',
otherFields: null,
},
terms('계약', '갱신'),
);
expect(slices('[공지] 계약 갱신 · 계약서', highlight.subject)).toEqual([
'계약',
'갱신',
'계약',
]);
expect(highlight.preview.text).toBe(
'안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.',
);
expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([
'계약',
'갱신',
'갱신',
'계약',
]);
});
it('본문에 맞은 곳이 없으면(제목에서만 맞음) 엔진의 미리 보기 — 한 줄로, 맞은 곳이 있으면 그 자리', () => {
const highlight = listHighlight(
{
subject: '계약 안내',
preview: '첨부한 계약서를\n확인해 주세요',
bodyText: null,
otherFields: null,
},
terms('계약'),
);
expect(highlight.preview).toEqual({
text: '첨부한 계약서를 확인해 주세요',
ranges: [[4, 6]],
});
const notInBody = listHighlight(
{
subject: '계약 안내',
preview: '첨부 확인',
bodyText: '첨부 확인',
otherFields: otherFields({ subject: '계약 안내' }),
},
terms('계약'),
);
expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] });
expect(notInBody.subject).toEqual([[0, 2]]);
});
it('본문에 강조할 말이 없으면(제목 조건만) 미리 보기는 맞은 곳 없이 엔진의 것', () => {
const highlight = listHighlight(
{
subject: '견적 요청',
preview: '견적 부탁드립니다',
bodyText: '견적 부탁드립니다',
otherFields: null,
},
{ subject: ['견적'], body: [], attachment: [], query: [] },
);
expect(highlight).toEqual({
subject: [[0, 2]],
preview: { text: '견적 부탁드립니다', ranges: [] },
});
});
it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => {
const nfd = '계약 PROJECT'.normalize('NFD');
const highlight = listHighlight(
{
subject: 'PROJECT 안내',
preview: nfd,
bodyText: null,
otherFields: null,
},
terms('project', '계약'),
);
expect(highlight.subject).toEqual([[0, 7]]);
expect(highlight.preview.text).toBe('계약 PROJECT');
expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([
'계약',
'PROJECT',
]);
});
it('응답의 제목은 NFC — 강조 위치의 기준', () => {
const nfd = '계약 안내'.normalize('NFD');
expect(highlightedSubject(nfd)).toBe('계약 안내');
expect(highlightedSubject(nfd).length).toBe(5);
});
});
describe('답장의 원문 인용(> 줄)과 미리 보기 (2026-11-06 사용자 관점 점검)', () => {
/** 미리 보기 — 본문 · 강조할 말 · 색인의 접은 칸(없으면 null) */
const previewOf = (
bodyText: string,
words: HighlightTerms,
fields: string[] | null = null,
) =>
listHighlight(
{
subject: '제목',
preview: '엔진의 미리 보기',
bodyText,
otherFields: fields,
},
words,
).preview;
const REPLY_SUBJECT = 'RE: 해오름 프로젝트 견적 검토 요청';
const REPLY_FIELDS = otherFields({
from: '이도윤 <doyun.lee@arasystem.test>',
to: '김아라 <ara.kim@arasystem.test>',
subject: REPLY_SUBJECT,
});
it('인용 밖과 안에서 모두 맞으면 인용 밖의 맞은 곳 — 본문에서 먼저 나온 인용 안의 맞은 곳보다 먼저', () => {
const preview = previewOf(
'> 견적 부탁드립니다.\n\n견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
terms('견적'),
);
expect(preview).toEqual({
text: '…견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
ranges: [[1, 3]],
});
});
it('인용 밖에서 맞은 곳의 둘레는 인용 줄에 닿기 전까지 — 뒤따르는 인용은 싣지 않고 "…"', () => {
const preview = previewOf(
'금요일까지 견적서 회신 드리겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 견적 자료를 보냅니다.',
terms('견적'),
);
expect(preview).toEqual({
text: '금요일까지 견적서 회신 드리겠습니다.…',
ranges: [[6, 8]],
});
});
it('인용 안에서만 맞고 그 말이 다른 칸(제목 · 사람 · 첨부 이름)에서도 맞으면 인용 앞의 글 — 그 글에 맞은 곳이 없으면 범위도 없다', () => {
const body =
'네, 확인했습니다. 다음 주에 뵙겠습니다.\n\n> 제목: 해오름 프로젝트 견적 검토 요청\n> > 도윤 님, 견적 부탁드립니다.';
const expected = {
text: '네, 확인했습니다. 다음 주에 뵙겠습니다.…',
ranges: [],
};
// 제목에서 맞음
expect(previewOf(body, terms('견적'), REPLY_FIELDS)).toEqual(expected);
// 보낸 사람에서 맞음
expect(previewOf(body, terms('도윤'), REPLY_FIELDS)).toEqual(expected);
// 첨부 이름에서 맞음
expect(
previewOf(
body,
terms('검토'),
otherFields({ subject: '회신', attachment: '검토 의견.pdf' }),
),
).toEqual(expected);
});
it('인용 안에서만 맞고 다른 칸에서는 맞지 않으면 인용 조각 — 줄 앞의 > 를 뺀 글을 그 인용 안에서만, 위치는 보이는 글 기준', () => {
const preview = previewOf(
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
terms('R9A51'),
otherFields({ subject: 'RE: 해오름 프로젝트 안내' }),
);
expect(preview).toEqual({
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
ranges: [[16, 21]],
});
expect(slices(preview.text, preview.ranges)).toEqual(['R9A51']);
});
it('본문에서 맞은 말 가운데 하나라도 다른 칸에서 맞지 않으면 인용 조각 — 내용 조건의 말은 본문에서만 찾으므로 다른 칸에 있어도 인용 조각', () => {
const body =
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.';
const fields = otherFields({ subject: 'RE: 해오름 프로젝트 안내' });
const both = previewOf(body, terms('해오름', 'R9A51'), fields);
expect(both.text).toBe(
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
);
expect(slices(both.text, both.ranges)).toEqual(['해오름', 'R9A51']);
const bodyCondition = previewOf(
body,
{ subject: [], body: ['해오름'], attachment: [], query: [] },
fields,
);
expect(bodyCondition.text).toBe(
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
);
expect(slices(bodyCondition.text, bodyCondition.ranges)).toEqual([
'해오름',
]);
});
it('인용으로 시작하는 본문(인용 아래에 쓴 답장)은 인용 밖의 첫 글 — 본문이 모두 인용이면 다른 칸에서 맞아도 인용 조각', () => {
expect(
previewOf(
'> 해오름 프로젝트 견적 부탁드립니다.\n\n네, 금요일까지 보내 드리겠습니다.',
terms('견적'),
REPLY_FIELDS,
),
).toEqual({ text: '…네, 금요일까지 보내 드리겠습니다.', ranges: [] });
const quoteOnly = previewOf(
'> 해오름 프로젝트 견적 부탁드립니다.\n> 감사합니다.',
terms('견적'),
REPLY_FIELDS,
);
expect(quoteOnly.text).toBe(
'해오름 프로젝트 견적 부탁드립니다. 감사합니다.',
);
expect(slices(quoteOnly.text, quoteOnly.ranges)).toEqual(['견적']);
});
it('인용 줄 알아보기 — 앞 공백 · 탭 뒤의 > · 겹친 표시(>> · > >) · CRLF, 줄 가운데의 > 는 인용이 아니다', () => {
expect(
previewOf('a > b 견적 정리\r\n >> > 견적 원문', terms('견적')),
).toEqual({ text: 'a > b 견적 정리…', ranges: [[6, 8]] });
expect(
previewOf(' >> > 견적 원문\r\n\t> 두 번째 견적', terms('견적')),
).toEqual({
text: '견적 원문 두 번째 견적',
ranges: [
[0, 2],
[11, 13],
],
});
});
it('인용 조각도 서로게이트 쌍을 가르지 않고 위치는 NFC 글 기준 — NFD 로 적은 말도 맞는다', () => {
const body = `확인했습니다.\n\n> ${'😀'.repeat(15)} 계약서 보내 드립니다 ${'😀'.repeat(40)}`;
const preview = previewOf(body, terms('계약'.normalize('NFD')));
expect(preview.text).not.toContain('>');
expect(() => encodeURIComponent(preview.text)).not.toThrow();
expect(slices(preview.text, preview.ranges)).toEqual(['계약']);
expect(preview.text.startsWith('…')).toBe(true);
});
it('본문 길이에 비례한다 — 인용 줄이 많은 긴 본문(색인 본문 상한 남짓)에서 끝의 인용 조각을 골라도', () => {
const bodyOf = (lines: number) =>
`확인했습니다.\n\n${Array.from(
{ length: lines },
(_, index) => `> > 인용 줄 ${index} 가나다라마바사`,
).join('\n')}\n> 마지막 견적 줄`;
const run = (body: string) => {
for (let round = 0; round < 20; round += 1) {
previewOf(body, terms('견적'));
}
};
const small = bodyOf(50);
const large = bodyOf(1_000);
expect(previewOf(large, terms('견적')).text).toContain('마지막 견적 줄');
run(small);
const smallCpu = startCpuTimer();
run(small);
const smallMs = smallCpu();
const largeCpu = startCpuTimer();
run(large);
const largeMs = largeCpu();
// 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts)
expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10);
expect(largeMs).toBeLessThan(5_000);
});
});
});
@@ -0,0 +1,345 @@
import {
foldTerms,
foldText,
isSpaceCode,
matchFoldedRanges,
matchRanges,
type MatchRange,
} from '@/shared/utils/search-text';
import {
SNIPPET_BOUNDARY_SLACK,
SNIPPET_LEAD_CHARS,
SNIPPET_TOTAL_CHARS,
} from './mail-search.constants';
import type { MessageListHighlight, SearchRange } from './mail-search.types';
import type { HighlightTerms } from './search-plan';
/**
* 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄.
* - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다)
* - 미리 보기: 본문(색인의 글 — NFC)에서 맞은 곳을 아래 차례로 고르고(2026-11-06 사용자 관점 점검 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이라
* 셋째 줄로 답장끼리 가를 수 없었다), 본문에서 맞지 않았으면 엔진의 미리 보기
* 1. 인용 줄(앞 공백 뒤 `>` 로 시작하는 줄) 밖에서 처음 맞은 곳의 둘레 — 인용 줄에 닿기 전까지만
* 2. 인용 안에서만 맞았고 본문에서 맞은 말이 모두 다른 칸(제목 · 사람 · 첨부 이름 — 검색어의 낱말)에서도 맞았으면 인용 밖의 첫 글(보통 인용 앞의 글)
* 3. 그 밖에는 인용 안에서 처음 맞은 곳의 둘레 — 줄 앞의 `>` 표시를 뺀 글로, 그 인용(이어진 인용 줄) 안에서만
* 다른 메일 프로그램의 인용 꼴(`>` 없이 "-----Original Message-----" 아래에 두는 것 등)은 알아보지 않는다
* - 둘레는 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤에 잘라 낸 글이 있으면 "…".
* 공백은 하나로 줄인 한 줄이고 맞은 곳은 보이는 그 글에서 다시 찾는다(그 글에 맞은 곳이 없으면 범위도 없다)
* 위치는 UTF-16 [시작, 끝)
*/
const ELLIPSIS = '…';
/** 인용 줄의 표시 `>` */
const QUOTE_MARK_CODE = 0x3e;
const LINE_BREAK = '\n';
function isHighSurrogate(code: number): boolean {
return code >= 0xd800 && code <= 0xdbff;
}
/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 앞으로 */
function alignStart(text: string, index: number): number {
if (index <= 0 || index >= text.length) return Math.max(0, index);
return isHighSurrogate(text.charCodeAt(index - 1)) ? index - 1 : index;
}
/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 뒤로 */
function alignEnd(text: string, index: number): number {
if (index <= 0 || index >= text.length) return Math.min(text.length, index);
return isHighSurrogate(text.charCodeAt(index - 1)) ? index + 1 : index;
}
/** `from` 에서 `to` 쪽으로 가며 처음 만나는 공백의 자리 — 없으면 -1 (from · to 를 포함) */
function findSpace(text: string, from: number, to: number): number {
const step = from <= to ? 1 : -1;
for (let index = from; step > 0 ? index <= to : index >= to; index += step) {
if (
index >= 0 &&
index < text.length &&
isSpaceCode(text.charCodeAt(index))
) {
return index;
}
}
return -1;
}
/** [from, to) 에 공백이 아닌 글자가 있나 — 잘라 낸 쪽에 보이는 글이 있을 때만 "…" 를 붙이려고 */
function hasVisible(text: string, from: number, to: number): boolean {
for (let index = from; index < to; index += 1) {
if (!isSpaceCode(text.charCodeAt(index))) return true;
}
return false;
}
/** 공백류의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (한 줄로 보이는 미리 보기) */
function oneLine(text: string): string {
let line = '';
let pendingSpace = false;
for (let index = 0; index < text.length; index += 1) {
if (isSpaceCode(text.charCodeAt(index))) {
pendingSpace = line !== '';
continue;
}
if (pendingSpace) line += ' ';
pendingSpace = false;
line += text[index];
}
return line;
}
/**
* 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다.
* `bounds`([시작, 끝) — 처음 값은 글 전체) 밖으로는 나가지 않는다(인용 줄에 닿기 전까지 등).
* 앞 · 뒤에 잘라 낸 글(공백만이 아닌 — `bounds` 밖도 센다)이 있으면 "…" 를 붙이고 한 줄로 만든다
*/
export function snippetAround(
body: string,
first: MatchRange,
bounds: MatchRange = [0, body.length],
): string {
const [low, high] = bounds;
const [matchStart, matchEnd] = first;
// 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리
const lead = Math.max(low, matchStart - SNIPPET_LEAD_CHARS);
let start = lead;
if (lead > low) {
const before = findSpace(
body,
lead,
Math.max(low, lead - SNIPPET_BOUNDARY_SLACK),
);
const after =
before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1;
const space = before >= 0 ? before : after;
if (space >= 0) start = space + 1;
}
start = Math.max(low, alignStart(body, start));
// 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다
const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS);
let end = Math.min(high, target);
if (end < high) {
const back = findSpace(
body,
end,
Math.max(matchEnd, end - SNIPPET_BOUNDARY_SLACK),
);
const forward =
back < 0
? findSpace(body, end, Math.min(high - 1, end + SNIPPET_BOUNDARY_SLACK))
: -1;
const space = back >= 0 ? back : forward;
if (space >= 0) end = space;
}
end = Math.min(high, alignEnd(body, end));
const text = oneLine(body.slice(start, end));
const head = hasVisible(body, 0, start) ? ELLIPSIS : '';
const tail = hasVisible(body, end, body.length) ? ELLIPSIS : '';
return `${head}${text}${tail}`;
}
/** 본문의 한 줄 — [start, end) (줄바꿈 글자는 빼고). 인용 줄이면 `quoteText` 가 표시 다음 글의 시작, 아니면 null */
interface BodyLine {
readonly start: number;
readonly end: number;
readonly quoteText: number | null;
}
/** 인용 줄이면 표시 다음 글의 시작 — 앞 공백 뒤 `>` 로 시작하는 줄 (`> > 글` 처럼 겹친 표시와 그 사이 · 뒤 공백을 모두 넘는다) */
function quoteTextStart(
body: string,
start: number,
end: number,
): number | null {
let index = start;
while (index < end && isSpaceCode(body.charCodeAt(index))) index += 1;
if (index >= end || body.charCodeAt(index) !== QUOTE_MARK_CODE) return null;
while (index < end) {
const code = body.charCodeAt(index);
if (code !== QUOTE_MARK_CODE && !isSpaceCode(code)) break;
index += 1;
}
return index;
}
/** 본문의 줄들 — 한 번 훑는다 */
function bodyLines(body: string): BodyLine[] {
const lines: BodyLine[] = [];
for (let start = 0; ; ) {
const found = body.indexOf(LINE_BREAK, start);
const end = found < 0 ? body.length : found;
lines.push({ start, end, quoteText: quoteTextStart(body, start, end) });
if (found < 0) return lines;
start = found + 1;
}
}
/** 그 자리를 담은 줄의 차례 (줄은 시작 차례로 놓여 있다) */
function lineIndexAt(lines: readonly BodyLine[], position: number): number {
let low = 0;
let high = lines.length - 1;
while (low < high) {
const middle = (low + high + 1) >> 1;
if (lines[middle].start <= position) low = middle;
else high = middle - 1;
}
return low;
}
/** 인용 줄을 공백으로 덮은 글 — 위치 · 길이는 그대로 (인용 밖에서만 맞추려고) */
function maskQuotes(text: string, lines: readonly BodyLine[]): string {
let masked = '';
let from = 0;
for (const line of lines) {
if (line.quoteText === null) continue;
masked += text.slice(from, line.start) + ' '.repeat(line.end - line.start);
from = line.end;
}
return masked + text.slice(from);
}
/** 그 줄과 같은 쪽(인용 · 인용 밖)인 줄들의 이어진 범위 [시작, 끝) — 미리 보기가 인용과 인용 밖을 섞지 않게 */
function runAround(lines: readonly BodyLine[], index: number): MatchRange {
const quoted = lines[index].quoteText !== null;
const same = (line: BodyLine) => (line.quoteText !== null) === quoted;
let first = index;
while (first > 0 && same(lines[first - 1])) first -= 1;
let last = index;
while (last + 1 < lines.length && same(lines[last + 1])) last += 1;
return [lines[first].start, lines[last].end];
}
/** 인용 밖의 첫 글 — 보이는 글이 있는 첫 인용 밖 줄부터 다음 인용 줄 전까지 (없으면 null) */
function firstPlainRun(
body: string,
lines: readonly BodyLine[],
): MatchRange | null {
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (line.quoteText === null && hasVisible(body, line.start, line.end)) {
return [line.start, runAround(lines, index)[1]];
}
}
return null;
}
/** 인용 표시를 뺀 글 — 줄은 그대로 잇고, 원래 글의 자리를 이 글의 자리로 바꾸는 함수를 함께 준다 */
function stripQuoteMarks(
body: string,
lines: readonly BodyLine[],
): { readonly text: string; readonly at: (position: number) => number } {
const parts: string[] = [];
const starts: number[] = [];
let length = 0;
lines.forEach((line, index) => {
if (index > 0) {
parts.push(LINE_BREAK);
length += LINE_BREAK.length;
}
starts.push(length);
const from = line.quoteText ?? line.start;
parts.push(body.slice(from, line.end));
length += line.end - from;
});
return {
text: parts.join(''),
at: (position) => {
const index = lineIndexAt(lines, position);
const line = lines[index];
const from = line.quoteText ?? line.start;
return (
starts[index] + Math.min(Math.max(0, position - from), line.end - from)
);
},
};
}
/**
* 본문에서 맞은 말(`words` — 접은 말)이 모두 다른 칸에서도 맞았나 — 검색어의 낱말이고(내용 조건의 낱말은 본문에서만 찾는다)
* 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름) 가운데 하나에 든다. 칸을 모르면(null) 아니다
*/
function matchedElsewhere(
folded: string,
words: readonly string[],
query: readonly string[],
otherFields: readonly string[] | null,
): boolean {
if (otherFields === null) return false;
const queryWords = new Set(foldTerms(query));
return words
.filter((word) => folded.includes(word))
.every(
(word) =>
queryWords.has(word) &&
otherFields.some((field) => field.includes(word)),
);
}
/** 본문에서 고른 미리 보기 한 줄 (위의 1 · 2 · 3) — 본문에서 맞지 않았으면 null */
function bodySnippet(
text: string,
terms: HighlightTerms,
otherFields: readonly string[] | null,
): string | null {
// 접은 글은 NFC 본문과 위치가 같다 — 줄 · 인용은 본문에서, 맞추기는 접은 글에서 (색인의 본문은 이미 NFC 다 — 거의 그대로 돌아온다)
const body = text.normalize('NFC');
const folded = foldText(body);
const words = foldTerms(terms.body);
const lines = bodyLines(body);
const [outside] = matchFoldedRanges(maskQuotes(folded, lines), words, 1);
if (outside) {
return snippetAround(
body,
outside,
runAround(lines, lineIndexAt(lines, outside[0])),
);
}
const [inside] = matchFoldedRanges(folded, words, 1);
if (!inside) return null;
if (matchedElsewhere(folded, words, terms.query, otherFields)) {
const run = firstPlainRun(body, lines);
if (run) return snippetAround(body, [run[0], run[0]], run);
}
// 인용 조각 — 그 인용(이어진 인용 줄)에 닿기 전까지, `>` 표시를 뺀 글에서
const stripped = stripQuoteMarks(body, lines);
const [runStart, runEnd] = runAround(lines, lineIndexAt(lines, inside[0]));
return snippetAround(
stripped.text,
[stripped.at(inside[0]), stripped.at(inside[1])],
[stripped.at(runStart), stripped.at(runEnd)],
);
}
function toRanges(ranges: readonly MatchRange[]): SearchRange[] {
return ranges.map(([start, end]) => [start, end] as const);
}
/**
* 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null),
* `otherFields` 는 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 — 없으면 null)
*/
export function listHighlight(
input: {
readonly subject: string;
readonly preview: string;
readonly bodyText: string | null;
readonly otherFields: readonly string[] | null;
},
terms: HighlightTerms,
): MessageListHighlight {
const subject = toRanges(matchRanges(input.subject, terms.subject));
const fromBody =
input.bodyText && terms.body.length > 0
? bodySnippet(input.bodyText, terms, input.otherFields)
: null;
const text = fromBody ?? oneLine(input.preview.normalize('NFC'));
return {
subject,
preview: { text, ranges: toRanges(matchRanges(text, terms.body)) },
};
}
/** 응답에 실을 제목 — 강조 위치의 기준인 NFC (화면에는 같게 보인다) */
export function highlightedSubject(subject: string): string {
return subject.normalize('NFC');
}
@@ -0,0 +1,91 @@
import { MailSearchRuns } from './mail-search-runs';
import { FAILURE_BACKOFF_BASE_MS } from './mail-search.constants';
import { isBuildWindow, isDueAfterFailures } from './search-index-schedule';
/** 한국 시각 'HH:MM' → 그 시각(2026-11-05 한국 날짜)의 Date */
function kst(time: string): Date {
return new Date(`2026-11-05T${time}:00+09:00`);
}
describe('isBuildWindow (밤 채우기의 창 — 한국 시각)', () => {
it.each([
['22:00', true],
['23:59', true],
['00:00', true],
['02:44', true],
['02:45', false],
['03:00', false],
['03:30', false],
['04:30', false],
['04:59', false],
['05:00', true],
['07:29', true],
['07:30', false],
['12:00', false],
['21:59', false],
])('%s → %s', (time, inside) => {
expect(isBuildWindow(kst(time))).toBe(inside);
});
});
describe('isDueAfterFailures (실패가 이어진 직원을 다시 해 볼 때)', () => {
const at = new Date('2026-11-05T00:00:00Z');
it('실패가 없으면 늘 된다', () => {
expect(isDueAfterFailures(0, at, at.getTime())).toBe(true);
});
it('실패 수마다 기다림이 두 배 — 상한 단계에서 멈춘다', () => {
expect(
isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS - 1),
).toBe(false);
expect(
isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS),
).toBe(true);
expect(
isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 3),
).toBe(false);
expect(
isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 4),
).toBe(true);
// 100번이어도 상한(6단계 — 32배)까지만 기다린다
expect(
isDueAfterFailures(100, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 32),
).toBe(true);
});
});
describe('MailSearchRuns (직원마다 한 번에 하나)', () => {
it('같은 직원의 일은 차례로, 다른 직원은 함께 — 앞 일이 실패해도 다음 일은 한다', async () => {
const runs = new MailSearchRuns();
const order: string[] = [];
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const first = runs.run('Ara@x', async () => {
order.push('ara-1 시작');
await gate;
order.push('ara-1 끝');
throw new Error('실패');
});
const second = runs.run('ara@x', () => {
order.push('ara-2');
return Promise.resolve(2);
});
const other = runs.run('doyun@x', () => {
order.push('doyun');
return Promise.resolve(3);
});
await other;
expect(runs.isBusy('ARA@x')).toBe(true);
release();
await expect(first).rejects.toThrow('실패');
await expect(second).resolves.toBe(2);
expect(order).toEqual(['ara-1 시작', 'doyun', 'ara-1 끝', 'ara-2']);
await new Promise((resolve) => setImmediate(resolve));
expect(runs.isBusy('ara@x')).toBe(false);
});
});
@@ -0,0 +1,33 @@
import { kstMinuteOfDay } from '@/shared/utils/kst-date';
import {
BUILD_WINDOWS_KST,
FAILURE_BACKOFF_BASE_MS,
FAILURE_BACKOFF_STEPS_MAX,
} from './mail-search.constants';
/**
* 검색 색인의 때 고르기 (순수 함수 — 14단계 설계 B2 · B3).
*/
/** 밤 채우기의 창 안인가 — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30 (끝 분은 들지 않는다) */
export function isBuildWindow(at: Date): boolean {
const minute = kstMinuteOfDay(at);
return BUILD_WINDOWS_KST.some(
([start, end]) => minute >= start && minute < end,
);
}
/**
* 실패가 이어진 직원을 다시 해 볼 때가 됐나 — 실패 수마다 두 배(5분 · 10분 · 20분 …, FAILURE_BACKOFF_STEPS_MAX 단계에서 멈춤).
* 실패가 없으면 늘 된다. `lastAttemptAt` 은 마지막 실패를 적은 때(상태 줄의 updated_at)
*/
export function isDueAfterFailures(
failureCount: number,
lastAttemptAt: Date,
now: number,
): boolean {
if (failureCount <= 0) return true;
const steps = Math.min(failureCount, FAILURE_BACKOFF_STEPS_MAX);
const wait = FAILURE_BACKOFF_BASE_MS * 2 ** (steps - 1);
return now - lastAttemptAt.getTime() >= wait;
}
@@ -0,0 +1,38 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { SearchIndexStatusDto } from './dto/search-index-status.dto';
import { MailSearchIndexService } from './mail-search-index.service';
/** 검색 준비 상태 라우트 (`/api/search-index`) — 14단계 */
export const SEARCH_INDEX_ROUTE = 'search-index';
// 검색 준비 상태 (14단계 — 자체 검색 색인). 줄이 없던 직원은 이때 만든다
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller(SEARCH_INDEX_ROUTE)
export class SearchIndexController {
constructor(private readonly searchIndex: MailSearchIndexService) {}
@Get()
@ApiOperation({
summary: '검색 준비 상태',
description:
'지난 메일의 검색 색인을 만드는 중(building)이면 그 직원의 검색은 엔진 방식이다 — 화면은 "검색 준비 중" 줄을 보인다. 색인은 밤에 한 사람씩 채운다. 처음 묻는 직원(상태 줄 없음)은 이때 building 으로 만든다.',
})
@ApiStandardResponse(SearchIndexStatusDto, { description: '지금 상태' })
status(@CurrentUser() user: AuthenticatedUser) {
return this.searchIndex.status(user.email);
}
}
@@ -0,0 +1,300 @@
import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants';
import type { SearchCriteria } from './mail-search.types';
import {
ALL_FOLD_FIELDS,
highlightTermsOf,
isSearchCriteria,
planSearch,
} from './search-plan';
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
hasAttachment: false,
...overrides,
});
describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
it('검색인가 — 검색어 · 글 조건 · 기간 · 첨부 가운데 하나라도 (빈 글 · 첨부 false 는 없는 것)', () => {
expect(isSearchCriteria(criteria({}))).toBe(false);
expect(isSearchCriteria(criteria({ text: '', subject: '' }))).toBe(false);
for (const overrides of [
{ text: '계약' },
{ from: '이도윤' },
{ to: 'ara' },
{ subject: '견적' },
{ body: '일정' },
{ after: '2026-10-31T15:00:00.000Z' },
{ before: '2026-11-30T15:00:00.000Z' },
{ hasAttachment: true },
]) {
expect(isSearchCriteria(criteria(overrides))).toBe(true);
}
});
it('검색어의 낱말은 다섯 칸 어디서든 — 접은 낱말로 확인하고 글자 두 개 묶음으로 후보를 좁힌다', () => {
const plan = planSearch(criteria({ text: '계약 PROJECT' }));
expect(plan.checks).toEqual([
{ term: '계약', fields: ALL_FOLD_FIELDS },
{ term: 'project', fields: ALL_FOLD_FIELDS },
]);
expect(plan.grams).toEqual(['계약', 'pr', 'ro', 'oj', 'je', 'ec', 'ct']);
expect(plan.matchesNothing).toBe(false);
expect(plan.bodySkipped).toBe(false);
// 강조는 접기 전 낱말 그대로 (맞추기가 접는다)
expect(plan.highlight).toEqual({
subject: ['계약', 'PROJECT'],
body: ['계약', 'PROJECT'],
attachment: ['계약', 'PROJECT'],
query: ['계약', 'PROJECT'],
});
});
it('큰따옴표 구절은 띄어쓰기까지 그대로 하나의 확인 — 묶음은 공백을 넘지 않는다, 앞뒤 문장부호를 뗀 낱말 · 같은 낱말은 한 번', () => {
const plan = planSearch(criteria({ text: '"계약 갱신" (계약), 계약' }));
expect(plan.checks.map((check) => check.term)).toEqual([
'계약 갱신',
'계약',
]);
expect(plan.grams).toEqual(['계약', '갱신']);
});
it('조건의 낱말은 그 칸에서만 — 받는 사람(to)은 받는 사람 · 참조를 담은 칸, 보낸 사람 · 받는 사람은 강조하지 않는다', () => {
const plan = planSearch(
criteria({
from: '이도윤',
to: 'ara.kim',
subject: '견적',
body: '"단가 조정"',
}),
);
expect(plan.checks).toEqual([
{ term: '이도윤', fields: ['fold_from'] },
{ term: 'ara.kim', fields: ['fold_to'] },
{ term: '견적', fields: ['fold_subject'] },
{ term: '단가 조정', fields: ['fold_body'] },
]);
expect(plan.highlight).toEqual({
subject: ['견적'],
body: ['단가 조정'],
attachment: [],
query: [],
});
});
it('같은 낱말이라도 찾는 칸이 다르면 따로 확인한다', () => {
const plan = planSearch(criteria({ text: '견적', subject: '견적' }));
expect(plan.checks).toEqual([
{ term: '견적', fields: ALL_FOLD_FIELDS },
{ term: '견적', fields: ['fold_subject'] },
]);
expect(plan.grams).toEqual(['견적']);
});
it('한 글자 낱말은 두 글자 넘는 낱말과 함께면 확인에만 쓴다 — 본문에서도 찾는다', () => {
const plan = planSearch(criteria({ text: '묵 계약' }));
expect(plan.bodySkipped).toBe(false);
expect(plan.grams).toEqual(['계약']);
expect(plan.checks).toEqual([
{ term: '묵', fields: ALL_FOLD_FIELDS },
{ term: '계약', fields: ALL_FOLD_FIELDS },
]);
// 본문 강조는 두 글자 이상 낱말만 — 한 글자 낱말은 제목 · 첨부 이름에만 (2026-11-06 실사용 시험)
expect(plan.highlight).toEqual({
subject: ['묵', '계약'],
body: ['계약'],
attachment: ['묵', '계약'],
query: ['묵', '계약'],
});
});
it('본문 강조는 본문을 찾은 두 글자 이상 낱말만 — 내용 조건과 섞여도 · 묶음이 없는 구절(한 글자씩 띄운 것)도 한 글자 낱말과 같다', () => {
// 내용 조건의 두 글자 이상 낱말과 섞임 — 검색어의 한 글자 낱말은 본문에 강조하지 않는다
expect(
planSearch(criteria({ text: '견', body: '계약서' })).highlight.body,
).toEqual(['계약서']);
// 두 글자 이상 낱말이 강조하지 않는 칸(보낸 사람)에만 있어도 섞인 검색이다 — 본문에 강조할 말이 없다
const withSender = planSearch(criteria({ text: '견', from: '김아라' }));
expect(withSender.bodySkipped).toBe(false);
expect(withSender.highlight.body).toEqual([]);
expect(withSender.highlight.subject).toEqual(['견']);
// 한 글자씩 띄운 구절은 묶음이 없다
expect(planSearch(criteria({ text: '"a b" 계약' })).highlight.body).toEqual(
['계약'],
);
});
it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => {
expect(SINGLE_CHAR_TERMS_SKIP_BODY).toBe(true);
const plan = planSearch(criteria({ text: '묵 뷁', from: '김' }));
expect(plan.bodySkipped).toBe(true);
expect(plan.grams).toEqual([]);
expect(plan.matchesNothing).toBe(false);
expect(plan.checks).toEqual([
{
term: '묵',
fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'],
},
{
term: '뷁',
fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'],
},
{ term: '김', fields: ['fold_from'] },
]);
// 본문을 찾지 않았으니 본문(미리 보기 · 읽기 화면)에는 강조하지 않는다 — 제목 · 첨부 이름에는 그대로 (2026-11-06 실사용 시험)
expect(plan.highlight).toEqual({
subject: ['묵', '뷁'],
body: [],
attachment: ['묵', '뷁'],
query: ['묵', '뷁'],
});
});
it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => {
expect(planSearch(criteria({ body: '묵' })).matchesNothing).toBe(true);
expect(
planSearch(criteria({ text: '묵', body: '뷁' })).matchesNothing,
).toBe(true);
// 두 글자 넘는 낱말과 함께면 한 글자 내용 조건도 확인한다
const together = planSearch(criteria({ text: '계약', body: '묵' }));
expect(together.matchesNothing).toBe(false);
expect(together.checks).toContainEqual({
term: '묵',
fields: ['fold_body'],
});
});
it('글을 준 칸에 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다', () => {
for (const overrides of [
{ text: '…' },
{ text: '""' },
{ from: '-' },
{ text: '계약', subject: '—' },
]) {
expect(planSearch(criteria(overrides)).matchesNothing).toBe(true);
}
});
it('기간 · 첨부만의 검색은 확인도 묶음도 없다 — 받은 시각 · 첨부 표시로만', () => {
const plan = planSearch(
criteria({
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
}),
);
expect(plan.checks).toEqual([]);
expect(plan.grams).toEqual([]);
expect(plan.matchesNothing).toBe(false);
expect(plan.after?.toISOString()).toBe('2026-10-31T15:00:00.000Z');
expect(plan.before?.toISOString()).toBe('2026-11-30T15:00:00.000Z');
expect(plan.hasAttachment).toBe(true);
});
it('전각 · 대문자 · 공백류는 접어서 확인한다 — 구절 안의 줄바꿈도 띄어쓰기 하나와 같다', () => {
const plan = planSearch(criteria({ text: '"ERP\u3000도입"' }));
expect(plan.checks.map((check) => check.term)).toEqual(['erp 도입']);
expect(plan.grams).toEqual(['er', 'rp', '도입']);
});
it('두 글자 낱말뿐인 검색어는 묶음만으로 정해진다(exactByGrams) — 세 글자 · 구절 · 한 글자 · 조건 칸이 섞이면 확인한다', () => {
// 서로게이트 쌍 글자(CJK 확장 B — 글자)도 코드 포인트 하나로 센다
const astralAndLetter = `${String.fromCodePoint(0x20000)}가`;
for (const text of [
'계약',
'계약 확인',
'ab',
'AB',
astralAndLetter,
'(계약),',
]) {
expect(planSearch(criteria({ text })).exactByGrams).toBe(true);
}
expect(
planSearch(
criteria({
text: '계약',
after: '2026-10-31T15:00:00.000Z',
hasAttachment: true,
}),
).exactByGrams,
).toBe(true);
for (const overrides of [
{ text: '계약서' },
{ text: '"계약 갱신"' },
{ text: '묵 계약' },
{ text: '계약', subject: '견적' },
{ subject: '계약' },
{ hasAttachment: true },
]) {
expect(planSearch(criteria(overrides)).exactByGrams).toBe(false);
}
});
describe('읽기 화면의 강조할 말 (highlightTermsOf — 설계 C2)', () => {
it('검색어는 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에', () => {
expect(
highlightTermsOf({
text: '계약',
subject: '견적',
body: '"단가 조정"',
}),
).toEqual({
subject: ['계약', '견적'],
body: ['계약', '단가 조정'],
attachment: ['계약'],
query: ['계약'],
});
});
it('강조할 말이 없으면 null — 검색어 · 제목 · 내용 조건이 없거나 문장부호뿐', () => {
expect(highlightTermsOf({})).toBeNull();
expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull();
});
it('본문에는 목록과 같이 본문을 찾은 낱말만 — 한 글자 낱말뿐이면 제목 · 첨부 이름에만, 섞이면 두 글자 이상 낱말만 (보낸 사람 · 받는 사람 조건도 섞임을 정한다)', () => {
expect(highlightTermsOf({ text: '견' })).toEqual({
subject: ['견'],
body: [],
attachment: ['견'],
query: ['견'],
});
expect(highlightTermsOf({ text: '견 계약서' })?.body).toEqual(['계약서']);
expect(highlightTermsOf({ text: '견', to: '김아라' })?.body).toEqual([]);
// 한 글자 내용 조건뿐 — 찾을 곳이 없어 강조할 말도 없다
expect(highlightTermsOf({ body: '견' })).toBeNull();
// 목록의 계획과 같은 강조
for (const given of [
{ text: '견' },
{ text: '견 계약서', subject: '회' },
{ text: '견', from: '김아라', body: '단가' },
]) {
expect(highlightTermsOf(given)).toEqual(
planSearch(criteria(given)).highlight,
);
}
});
});
it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => {
const NUL = String.fromCharCode(0);
const FFFD = String.fromCharCode(0xfffd);
const plan = planSearch(criteria({ text: `계${NUL}약`, body: `a${NUL}b` }));
expect(plan.checks.map((check) => check.term)).toEqual([
`계${FFFD}약`,
`a${FFFD}b`,
]);
expect(plan.grams).toEqual([
`계${FFFD}`,
`${FFFD}약`,
`a${FFFD}`,
`${FFFD}b`,
]);
expect(JSON.stringify(plan)).not.toContain(NUL);
expect(plan.highlight.body).toEqual([`계${FFFD}약`, `a${FFFD}b`]);
});
});
@@ -0,0 +1,231 @@
import { parseSearchTerms } from '@/shared/utils/search-query';
import { foldText, gramsOf, isSpaceCode } from '@/shared/utils/search-text';
import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants';
import type { SearchCriteria } from './mail-search.types';
/**
* 검색 조건 → 색인에서 찾을 계획 (순수 함수 — 14단계 설계 C1 · G).
*
* - 검색어(q)의 낱말 · 구절은 다섯 칸(보낸 사람 · 받는 사람(참조 포함) · 제목 · 첨부 이름 · 본문) 가운데 어디든, 조건 칸(from · to · subject · body)의
* 낱말 · 구절은 그 칸에서 — 모두 맞아야 한다(AND). 낱말 · 구절 나누기는 엔진 검색과 같다(parseSearchTerms — 큰따옴표 구절 · 앞뒤 문장부호 떼기)
* - 낱말마다 접기(foldText) 뒤 부분 일치(strpos)로 확인하고, 모든 낱말의 글자 두 개 묶음(gramsOf)으로 후보를 좁힌다(GIN `grams @>`) —
* 낱말이 한 칸 안에 이어 있으니 그 묶음은 늘 그 줄의 묶음 안에 있다
* - 한 글자 낱말(묶음이 없는 낱말): 묶음이 있는 낱말과 함께면 확인에만 쓴다. 한 글자 낱말뿐이면(SINGLE_CHAR_TERMS_SKIP_BODY) 본문은 훑지 않는다 —
* 검색어의 낱말은 제목 · 사람 · 첨부 이름에서만, 내용(body) 조건의 낱말은 찾을 곳이 없어 아무것도 맞지 않는다
* - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다)
* - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams).
* 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다
* - 본문(목록 미리 보기 · 읽기 화면)에는 본문을 찾은 낱말만 강조한다(2026-11-06 실사용 시험 — bodyHighlightOf). 제목 · 첨부 이름 강조는 낱말 그대로다
*/
/** 확인하는 접기 칸 — 표의 열 이름 */
export type FoldField =
| 'fold_from'
| 'fold_to'
| 'fold_subject'
| 'fold_attachment'
| 'fold_body';
/** 검색어(q)의 낱말을 찾는 칸 — 다섯 칸 모두 */
export const ALL_FOLD_FIELDS: readonly FoldField[] = [
'fold_from',
'fold_to',
'fold_subject',
'fold_attachment',
'fold_body',
];
/** 조건 칸 → 그 칸의 접기 열 */
const CONDITION_FIELDS = {
from: 'fold_from',
to: 'fold_to',
subject: 'fold_subject',
body: 'fold_body',
} as const satisfies Record<string, FoldField>;
type ConditionName = keyof typeof CONDITION_FIELDS;
const CONDITION_NAMES = Object.keys(CONDITION_FIELDS) as ConditionName[];
/** 낱말 하나의 확인 — 접은 낱말이 그 칸들 가운데 하나에 들어 있어야 한다 */
export interface TermCheck {
readonly term: string;
readonly fields: readonly FoldField[];
}
/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */
export interface HighlightTerms {
readonly subject: readonly string[];
/** 본문을 찾은 낱말만 (bodyHighlightOf) — 한 글자 낱말은 들지 않는다 */
readonly body: readonly string[];
readonly attachment: readonly string[];
/**
* 검색어(q)의 낱말 — 다섯 칸 어디서든 찾는다. 목록 미리 보기가 본문의 인용 안에서만 맞은 말을 다른 칸(제목 · 사람 · 첨부 이름)에서도
* 찾았는지 볼 때 쓴다(search-highlight.ts). 내용 조건(body)의 낱말은 본문에서만 찾으므로 들지 않는다
*/
readonly query: readonly string[];
}
export interface SearchPlan {
/** 낱말 확인 — 모두 맞아야 한다. 비었으면 범위 · 기간 · 첨부만으로 찾는다 */
readonly checks: readonly TermCheck[];
/** 후보를 좁히는 글자 두 개 묶음 — 비었으면 GIN 을 쓰지 못한다 */
readonly grams: readonly string[];
readonly after: Date | null;
readonly before: Date | null;
readonly hasAttachment: boolean;
/** 아무것도 맞을 수 없다 — 묻지 않고 빈 결과 */
readonly matchesNothing: boolean;
/** 한 글자 낱말뿐이라 본문을 훑지 않았다 (SINGLE_CHAR_TERMS_SKIP_BODY) */
readonly bodySkipped: boolean;
/** 묶음만으로 정해진다 — 검색어(다섯 칸)의 두 글자 낱말뿐이라 확인(strpos)이 늘 참이다 */
readonly exactByGrams: boolean;
readonly highlight: HighlightTerms;
}
/** 검색인가 — 검색어나 조건이 하나라도 있으면 (빈 글은 없는 것) */
export function isSearchCriteria(criteria: SearchCriteria): boolean {
return (
Boolean(criteria.text) ||
CONDITION_NAMES.some((name) => Boolean(criteria[name])) ||
criteria.after !== undefined ||
criteria.before !== undefined ||
criteria.hasAttachment === true
);
}
/** 글을 낱말 · 구절로 — 접기 전 글(강조)과 접은 글(확인) */
function termsOf(text: string | undefined): {
readonly given: boolean;
readonly raw: string[];
} {
if (!text) return { given: false, raw: [] };
return { given: true, raw: parseSearchTerms(text).map((term) => term.text) };
}
/** 두 글자 낱말인가 — 코드 포인트 둘 · 공백 없음 (그 낱말의 묶음 하나가 곧 그 낱말이다) */
function isGramWord(term: string): boolean {
const points = [...term];
return (
points.length === 2 &&
points.every((point) => !isSpaceCode(point.charCodeAt(0)))
);
}
function dateOf(value: string | undefined): Date | null {
if (value === undefined) return null;
const time = Date.parse(value);
return Number.isNaN(time) ? null : new Date(time);
}
/** 묶음이 있는 낱말인가 — 접어서 글자 두 개 묶음이 하나라도 나온다 (한 글자 낱말 · 한 글자씩 띄운 구절은 없다) */
function hasGrams(raw: string): boolean {
return gramsOf(foldText(raw)).size > 0;
}
/**
* 본문에 강조할 말 (2026-11-06 실사용 시험) — 본문을 찾은 낱말만이다. 검색어 · 내용 조건의 낱말(`raws`) 가운데
* - 한 글자 낱말뿐이라 본문을 훑지 않았으면(bodySkipped) 없다
* - 묶음이 있는 낱말(두 글자 이상)이 하나라도 있으면(`anyGrams` — 다섯 칸의 낱말 어디든) 그 낱말만 — 한 글자 낱말은 함께 찾아 본문도 확인했지만
* 본문 곳곳에 맞아 미리 보기 · 읽기 화면의 강조가 흩어진다
* - 묶음이 하나도 없는데 본문을 훑었으면(SINGLE_CHAR_TERMS_SKIP_BODY 를 끈 때) 모두
*/
function bodyHighlightOf(
raws: readonly string[],
bodySkipped: boolean,
anyGrams: boolean,
): string[] {
if (bodySkipped) return [];
return anyGrams ? raws.filter(hasGrams) : [...raws];
}
export function planSearch(criteria: SearchCriteria): SearchPlan {
const query = termsOf(criteria.text);
const conditions = CONDITION_NAMES.map((name) => ({
name,
...termsOf(criteria[name]),
}));
// 글을 준 칸에 찾을 낱말이 없다 — 아무것도 맞지 않는다
let matchesNothing =
(query.given && query.raw.length === 0) ||
conditions.some(
(condition) => condition.given && condition.raw.length === 0,
);
const grams = new Set<string>();
const pending: { term: string; fields: readonly FoldField[] }[] = [];
const add = (raw: string, fields: readonly FoldField[]) => {
const term = foldText(raw);
gramsOf(term, grams);
pending.push({ term, fields });
};
for (const raw of query.raw) add(raw, ALL_FOLD_FIELDS);
for (const condition of conditions) {
for (const raw of condition.raw)
add(raw, [CONDITION_FIELDS[condition.name]]);
}
// 한 글자 낱말뿐 — 본문은 훑지 않는다 (제목 · 사람 · 첨부 이름만)
const bodySkipped =
SINGLE_CHAR_TERMS_SKIP_BODY && pending.length > 0 && grams.size === 0;
const seen = new Set<string>();
const checks: TermCheck[] = [];
for (const check of pending) {
const fields = bodySkipped
? check.fields.filter((field) => field !== 'fold_body')
: check.fields;
if (fields.length === 0) {
matchesNothing = true;
continue;
}
const key = `${check.term}\n${fields.join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
checks.push({ term: check.term, fields });
}
const exactByGrams =
checks.length > 0 &&
checks.every(
(check) =>
check.fields.length === ALL_FOLD_FIELDS.length &&
isGramWord(check.term),
);
const subjectTerms = conditions.find((c) => c.name === 'subject')?.raw ?? [];
const bodyTerms = conditions.find((c) => c.name === 'body')?.raw ?? [];
return {
checks,
grams: [...grams],
after: dateOf(criteria.after),
before: dateOf(criteria.before),
hasAttachment: criteria.hasAttachment === true,
matchesNothing,
bodySkipped,
exactByGrams,
highlight: {
subject: [...query.raw, ...subjectTerms],
body: bodyHighlightOf(
[...query.raw, ...bodyTerms],
bodySkipped,
grams.size > 0,
),
attachment: [...query.raw],
query: [...query.raw],
},
};
}
/**
* 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에 — 목록과 같은 계획(planSearch)에서 꺼낸다.
* 본문에는 본문을 찾은 낱말만(한 글자 낱말 — bodyHighlightOf). 보낸 사람 · 받는 사람 조건은 강조하지 않지만 받는다 — 두 글자 이상 낱말이
* 그 칸에만 있어도 한 글자 낱말과 "섞인" 검색이다(목록의 찾기와 같은 판정). 강조할 말이 하나도 없으면 null
*/
export function highlightTermsOf(
criteria: Pick<SearchCriteria, 'text' | 'from' | 'to' | 'subject' | 'body'>,
): HighlightTerms | null {
const { highlight } = planSearch({ ...criteria, hasAttachment: false });
// 첨부 이름의 말(검색어)은 늘 제목의 말 안에 든다
if (highlight.subject.length === 0 && highlight.body.length === 0) {
return null;
}
return highlight;
}
@@ -0,0 +1,196 @@
import type { SearchCriteria, SearchScope } from './mail-search.types';
import { planSearch } from './search-plan';
import {
candidatesSql,
checkHitsSql,
highlightTextsSql,
keysetCountSql,
keysetFrontierSql,
keysetHitsSql,
plainCountSql,
plainPageSql,
} from './search-sql';
const USER = 'ara.kim@arasystem.test';
const ALL: SearchScope = {
mailboxId: null,
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
};
const INBOX: SearchScope = {
mailboxId: 'a',
excludedMailboxIds: [],
excludedKeywords: [],
};
const plan = (overrides: Partial<SearchCriteria>) =>
planSearch({ hasAttachment: false, ...overrides });
/** SQL 의 공백을 하나로 (비교 편하게) */
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
it('후보 — 주소(찾기 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => {
const query = candidatesSql(
USER,
ALL,
plan({ text: '계약 갱신' }),
null,
3_001,
);
expect(flat(query.sql)).toBe(
'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE lower("user_email") = $1 ' +
'AND NOT ("mailbox_ids" <@ $2::text[]) AND NOT ("keywords" && $3::text[]) AND "grams" @> $4::text[] ' +
'ORDER BY "received_at" DESC, "email_id" DESC LIMIT $5',
);
expect(query.params).toEqual([
USER,
['b', 'c'],
['$ara-sending'],
['계약', '갱신'],
3_001,
]);
// 주소 그대로("user_email" = …)는 btree 로 그 직원의 줄을 다 읽게 둔다 — 후보 쿼리에는 쓰지 않는다
expect(query.sql).not.toContain('"user_email" =');
expect(query.sql).not.toContain('strpos');
});
it('메일함 범위는 찾기 GIN 이 함께 맞추는 꼴(@>) — = ANY 로 적으면 그 직원의 묶음 줄을 모두 읽고 메일함을 줄마다 거른다 (2026-11-06 실사용 시험), 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => {
const after = {
receivedAt: new Date('2026-11-01T00:00:00Z'),
emailId: 'm9',
};
const query = candidatesSql(
USER,
INBOX,
plan({
text: '계약',
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
}),
after,
10,
);
expect(flat(query.sql)).toContain(
'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' +
'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]',
);
expect(query.sql).not.toContain('ANY("mailbox_ids")');
expect(query.params).toEqual([
USER,
['a'],
new Date('2026-10-31T15:00:00.000Z'),
new Date('2026-11-30T15:00:00.000Z'),
after.receivedAt,
'm9',
['계약'],
10,
]);
});
it('확인 — 후보 id 의 줄만 기본 키로 읽어 낱말마다 그 칸들 가운데 하나의 strpos', () => {
const query = checkHitsSql(USER, plan({ text: '계약', from: '이도윤' }), [
'm1',
'm2',
]);
expect(flat(query.sql)).toBe(
'SELECT "email_id" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[]) ' +
'AND (strpos("fold_from", $3) > 0 OR strpos("fold_to", $3) > 0 OR strpos("fold_subject", $3) > 0 ' +
'OR strpos("fold_attachment", $3) > 0 OR strpos("fold_body", $3) > 0) AND (strpos("fold_from", $4) > 0)',
);
expect(query.params).toEqual([USER, ['m1', 'm2'], '계약', '이도윤']);
});
it('묶음이 없는 찾기(한 글자 낱말뿐) — 안쪽에서 확인 상한만큼 받은 차례로 자른 뒤 바깥에서 확인하고 쪽 크기에서 멈춘다, 묶음은 보지 않는다', () => {
const query = keysetHitsSql(
USER,
INBOX,
plan({ text: '묵' }),
null,
3_000,
21,
);
const sql = flat(query.sql);
expect(sql).toMatch(
/^SELECT "email_id", "received_at" FROM \(SELECT "email_id", "received_at", "fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body" FROM "mail_search_messages" WHERE "user_email" = \$1 AND "mailbox_ids" @> \$2::text\[\] ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$3\) AS "scan" WHERE \(strpos\("fold_from", \$4\) > 0 .+\) ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$5$/,
);
expect(sql).not.toContain('"grams"');
// 본문은 훑지 않는다 (SINGLE_CHAR_TERMS_SKIP_BODY)
expect(sql).not.toContain('strpos("fold_body"');
expect(query.params.slice(2)).toEqual([3_000, '묵', 21]);
});
it('훑은 창의 끝 · 창 안의 맞은 수 — 끝은 확인 없이 그 창의 가장 오래된 줄과 줄 수, 수는 countLimit 까지', () => {
const frontier = keysetFrontierSql(
USER,
INBOX,
plan({ text: '묵' }),
null,
3_000,
);
expect(flat(frontier.sql)).toMatch(
/^SELECT "email_id", "received_at", count\(\*\) OVER \(\)::int AS "scanned" FROM \(SELECT "email_id", "received_at" FROM .+ LIMIT \$3\) AS "scan" ORDER BY "received_at" ASC, "email_id" ASC LIMIT 1$/,
);
expect(frontier.sql).not.toContain('strpos');
const count = keysetCountSql(
USER,
INBOX,
plan({ text: '묵' }),
3_000,
1_001,
);
expect(flat(count.sql)).toMatch(
/^SELECT count\(\*\)::int AS "count" FROM \(SELECT 1 FROM \(SELECT .+ LIMIT \$3\) AS "scan" WHERE .+strpos.+ LIMIT \$5\) AS "hits"$/,
);
expect(count.params.slice(2)).toEqual([3_000, '묵', 1_001]);
});
it('낱말 확인이 없을 때(기간 · 첨부만) — 묶음 · strpos 없이 받은 차례로 · 수도 같은 조건으로', () => {
const only = plan({ hasAttachment: true });
const page = plainPageSql(USER, ALL, only, null, 21);
expect(flat(page.sql)).toBe(
'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE "user_email" = $1 AND NOT ("mailbox_ids" <@ $2::text[]) ' +
'AND NOT ("keywords" && $3::text[]) AND "has_attachment" ORDER BY "received_at" DESC, "email_id" DESC LIMIT $4',
);
const count = plainCountSql(USER, ALL, only, 1_001);
expect(flat(count.sql)).toContain(
'AND "has_attachment" LIMIT $4) AS "matches"',
);
expect(count.params).toEqual([USER, ['b', 'c'], ['$ara-sending'], 1_001]);
});
it('검색 글은 SQL 에 이어 붙이지 않는다 — 따옴표 · 세미콜론이 든 낱말도 매개변수로만', () => {
const hostile = `x'); DROP TABLE users; --`;
const checked = [
checkHitsSql(USER, plan({ text: hostile }), ['m1']),
keysetHitsSql(USER, ALL, plan({ subject: hostile }), null, 10, 10),
];
for (const query of [
...checked,
candidatesSql(USER, ALL, plan({ body: hostile }), null, 10),
]) {
expect(query.sql).not.toMatch(/drop|users|x'/i);
}
// 낱말은 접은 글로 매개변수에만 (앞뒤 문장부호를 뗀 x · drop · table · users)
for (const query of checked) {
expect(query.params).toEqual(
expect.arrayContaining(['x', 'drop', 'table', 'users']),
);
}
});
it('강조에 쓰는 글 — 그 직원의 찾은 줄만 (본문 밖의 접은 칸 넷도 — 미리 보기가 인용 안에서만 맞은 말을 다른 칸에서 찾았는지 본다)', () => {
const query = highlightTextsSql(USER, ['m1', 'm2']);
expect(flat(query.sql)).toBe(
'SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" ' +
'FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])',
);
expect(query.params).toEqual([USER, ['m1', 'm2']]);
});
});
@@ -0,0 +1,279 @@
import { SEARCH_MESSAGES_TABLE } from './mail-search.constants';
import type { SearchScope } from './mail-search.types';
import type { SearchPlan } from './search-plan';
/**
* 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다.
*
* 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다:
* - 후보(candidatesSql): 주소 + 묶음 + 메일함을 GIN 하나(`IDX_mail_search_messages_user_grams_mailboxes` — 마이그레이션 SearchIndexUserGramsMailboxes)에서만
* 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. 메일함 범위는 `"mailbox_ids" @> $n` 그대로 둔다 —
* 같은 GIN 이 메일함까지 맞춰 맞은 줄만 읽는다(`= ANY` 로 적으면 그 직원의 그 묶음 줄을 모두 읽고 메일함을 줄마다 거른다).
* 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms).
* 메일함만 담은 GIN 이 있던 때는 계획기가 그것만으로 메일함의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-06 실사용 시험 — 1만 통 메일함 안 1.1초).
* 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다
* - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다
* 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는
* 받은 차례로 바로 읽는다.
*/
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
/** 받은 시각 최신부터 (같은 시각은 id 로) — 색인 IDX_mail_search_messages_received 의 거꾸로 */
const NEWEST_FIRST = '"received_at" DESC, "email_id" DESC';
const OLDEST_FIRST = '"received_at" ASC, "email_id" ASC';
/** 확인에 쓰는 접기 칸 — 안쪽 쿼리가 바깥으로 넘긴다 */
const FOLD_COLUMNS =
'"fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body"';
/** 이어 찾을 자리 — 이 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 */
export interface SearchPosition {
readonly receivedAt: Date;
readonly emailId: string;
}
export interface SqlQuery {
readonly sql: string;
readonly params: unknown[];
}
/** 매개변수를 차례로 쌓는다 */
class Params {
readonly values: unknown[] = [];
add(value: unknown): string {
this.values.push(value);
return `$${this.values.length}`;
}
}
/**
* 직원을 거르는 길 — `gin`: 찾기 GIN 의 식(lower)으로 묶음 · 메일함과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키 —
* 메일함 조건은 줄을 읽은 뒤 거른다. 이 길의 쿼리는 bitmap 을 끈다)
* 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다
*/
type UserMatch = 'gin' | 'btree';
/** 값싼 조건 — 직원 · 범위 · 기간 · 첨부 · 빼는 키워드 · 이어 찾을 자리 (`gin` 이면 묶음도) */
function cheapConditions(
params: Params,
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
userMatch: UserMatch,
): string {
const where = [
userMatch === 'gin'
? `lower("user_email") = ${params.add(user)}`
: `"user_email" = ${params.add(user)}`,
];
if (scope.mailboxId !== null) {
where.push(`"mailbox_ids" @> ${params.add([scope.mailboxId])}::text[]`);
}
if (scope.excludedMailboxIds.length > 0) {
// 빼는 메일함에만 든 메일을 뺀다 — 다른 메일함에도 있으면 찾는다 (엔진의 inMailboxOtherThan 과 같다)
where.push(
`NOT ("mailbox_ids" <@ ${params.add([...scope.excludedMailboxIds])}::text[])`,
);
}
if (scope.excludedKeywords.length > 0) {
where.push(
`NOT ("keywords" && ${params.add([...scope.excludedKeywords])}::text[])`,
);
}
if (plan.after !== null) {
where.push(`"received_at" >= ${params.add(plan.after)}::timestamptz`);
}
if (plan.before !== null) {
where.push(`"received_at" < ${params.add(plan.before)}::timestamptz`);
}
if (plan.hasAttachment) where.push('"has_attachment"');
if (after !== null) {
where.push(
`("received_at", "email_id") < (${params.add(after.receivedAt)}::timestamptz, ${params.add(after.emailId)})`,
);
}
if (userMatch === 'gin') {
where.push(`"grams" @> ${params.add([...plan.grams])}::text[]`);
}
return where.join(' AND ');
}
/** 낱말 확인 — 낱말마다 그 칸들 가운데 하나에 들어 있어야 한다 (모두 AND, 확인할 낱말이 없으면 늘 참) */
function checkConditions(params: Params, plan: SearchPlan): string {
if (plan.checks.length === 0) return 'TRUE';
return plan.checks
.map((check) => {
const term = params.add(check.term);
return `(${check.fields.map((field) => `strpos("${field}", ${term}) > 0`).join(' OR ')})`;
})
.join(' AND ');
}
/** 후보 — 주소 + 묶음 + 메일함(GIN 하나)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */
export function candidatesSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
): SqlQuery {
const params = new Params();
const where = cheapConditions(params, user, scope, plan, after, 'gin');
return {
sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(limit)}`,
params: params.values,
};
}
/** 확인 — 후보 id 가운데 낱말이 모두 맞은 줄의 id (기본 키로 그 줄만 읽는다) */
export function checkHitsSql(
user: string,
plan: SearchPlan,
ids: readonly string[],
): SqlQuery {
const params = new Params();
const where = `"user_email" = ${params.add(user)} AND "email_id" = ANY(${params.add([...ids])}::text[])`;
const checks = checkConditions(params, plan);
return {
sql: `SELECT "email_id" FROM ${TABLE} WHERE ${where} AND ${checks}`,
params: params.values,
};
}
/** 받은 차례 쿼리의 안쪽 — 값싼 조건을 지난 줄을 최신부터 `scanLimit` 까지 (묶음은 보지 않는다) */
function scanSubquery(
params: Params,
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
scanLimit: number,
columns: string,
): string {
const where = cheapConditions(params, user, scope, plan, after, 'btree');
return `SELECT ${columns} FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(scanLimit)}`;
}
/**
* 묶음이 없는 찾기(한 글자 낱말뿐) — 받은 차례로 `scanLimit` 줄까지 확인하며 맞은 줄을 `pageLimit` 개까지 (차면 멈춘다).
* 묶음 배열을 줄마다 풀지 않게 묶음이 있는 계획에는 쓰지 않는다(candidatesSql)
*/
export function keysetHitsSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
scanLimit: number,
pageLimit: number,
): SqlQuery {
const params = new Params();
const scan = scanSubquery(
params,
user,
scope,
plan,
after,
scanLimit,
`"email_id", "received_at", ${FOLD_COLUMNS}`,
);
const checks = checkConditions(params, plan);
return {
sql: `SELECT "email_id", "received_at" FROM (${scan}) AS "scan" WHERE ${checks} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`,
params: params.values,
};
}
/** 받은 차례로 훑은 창의 끝 — 그 창의 마지막 줄과 줄 수 (확인하지 않는다 — 다 훑었는지 · 이어 찾을 자리) */
export function keysetFrontierSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
scanLimit: number,
): SqlQuery {
const params = new Params();
const scan = scanSubquery(
params,
user,
scope,
plan,
after,
scanLimit,
'"email_id", "received_at"',
);
return {
sql: `SELECT "email_id", "received_at", count(*) OVER ()::int AS "scanned" FROM (${scan}) AS "scan" ORDER BY ${OLDEST_FIRST} LIMIT 1`,
params: params.values,
};
}
/** 받은 차례로 훑은 창 안의 맞은 줄 수 — `countLimit` 까지 */
export function keysetCountSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
scanLimit: number,
countLimit: number,
): SqlQuery {
const params = new Params();
const scan = scanSubquery(
params,
user,
scope,
plan,
null,
scanLimit,
`"email_id", "received_at", ${FOLD_COLUMNS}`,
);
const checks = checkConditions(params, plan);
return {
sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM (${scan}) AS "scan" WHERE ${checks} LIMIT ${params.add(countLimit)}) AS "hits"`,
params: params.values,
};
}
/** 낱말 확인이 없을 때(기간 · 첨부 · 메일함만) — 받은 차례로 `pageLimit` 개 */
export function plainPageSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
pageLimit: number,
): SqlQuery {
const params = new Params();
const where = cheapConditions(params, user, scope, plan, after, 'btree');
return {
sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`,
params: params.values,
};
}
/** 낱말 확인이 없을 때의 수 — `countLimit` 까지 */
export function plainCountSql(
user: string,
scope: SearchScope,
plan: SearchPlan,
countLimit: number,
): SqlQuery {
const params = new Params();
const where = cheapConditions(params, user, scope, plan, null, 'btree');
return {
sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM ${TABLE} WHERE ${where} LIMIT ${params.add(countLimit)}) AS "matches"`,
params: params.values,
};
}
/**
* 강조에 쓰는 글 — 찾은 쪽의 줄만. 본문 밖의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름)은 미리 보기를 고를 때
* 본문의 인용 안에서만 맞은 말을 그 칸에서도 찾았는지 본다(search-highlight.ts)
*/
export function highlightTextsSql(
user: string,
ids: readonly string[],
): SqlQuery {
return {
sql: `SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
params: [user, [...ids]],
};
}
@@ -0,0 +1,59 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { mergeThreadKeys } from './thread-keys';
describe('대화 키 합치기 (mergeThreadKeys — 14단계 설계 B4)', () => {
it('이어진 두 키는 작은 키로 — 바뀌는 쪽만 돌려준다', () => {
expect([...mergeThreadKeys([['m9', 'm2']])]).toEqual([['m9', 'm2']]);
expect([...mergeThreadKeys([['m2', 'm9']])]).toEqual([['m9', 'm2']]);
});
it('잇닿은 쌍은 하나로 — 대화 전체가 가장 작은 키 (늦게 온 원본이 두 답장을 잇는 꼴)', () => {
const renames = mergeThreadKeys([
['reply-b', 'orig-z'],
['reply-a', 'orig-z'],
['reply-c', 'reply-b'],
]);
// 키는 글자 차례로 견준다 — 'orig-z' < 'reply-a'
expect(Object.fromEntries(renames)).toEqual({
'reply-a': 'orig-z',
'reply-b': 'orig-z',
'reply-c': 'orig-z',
});
});
it('따로인 대화는 따로 — 쌍의 차례와 상관없이 같은 결과', () => {
const links: [string, string][] = [
['b', 'a'],
['y', 'x'],
['c', 'b'],
['z', 'y'],
];
const expected = { b: 'a', c: 'a', y: 'x', z: 'x' };
expect(Object.fromEntries(mergeThreadKeys(links))).toEqual(expected);
expect(Object.fromEntries(mergeThreadKeys([...links].reverse()))).toEqual(
expected,
);
});
it('같은 키끼리 · 빈 쌍은 바꿀 것이 없다', () => {
expect(mergeThreadKeys([])).toEqual(new Map());
expect(mergeThreadKeys([['a', 'a']])).toEqual(new Map());
});
it('긴 사슬도 쌍 수에 비례한다 (CPU 시간)', () => {
const links = Array.from(
{ length: 50_000 },
(_, index) =>
[
`k${String(index + 1).padStart(6, '0')}`,
`k${String(index).padStart(6, '0')}`,
] as const,
);
const cpuMs = startCpuTimer();
const renames = mergeThreadKeys(links);
// 지금 수십 ms — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(1_000);
expect(renames.size).toBe(50_000);
expect(new Set(renames.values())).toEqual(new Set(['k000000']));
});
});
@@ -0,0 +1,46 @@
/**
* 대화 키 합치기 (순수 함수 — 14단계 설계 B4).
* 색인 줄마다 대화 키(thread_key)가 있다 — 처음에는 그 메일의 id 다. 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면
* 두 키의 줄을 모두 **가장 작은 키**로 바꾼다. 이미 합친 대화는 한 키를 나눠 쓰므로, 새로 들어온 메일과 바로 이어진 메일의 키만 합치면
* 대화 전체가 한 키가 된다(늦게 온 원본이 둘로 갈려 있던 답장들을 잇는 것도 같다).
*
* `links` 는 [키, 키] 쌍들 — 이어진 두 메일의 지금 키. 돌려주는 것은 바꿀 키 → 새 키(가장 작은 키)이다. 바꿀 것이 없으면 빈 Map.
* 쌍 수에 거의 비례한다(경로 줄이기 · 크기로 잇기).
*/
export function mergeThreadKeys(
links: readonly (readonly [string, string])[],
): Map<string, string> {
const parent = new Map<string, string>();
const find = (key: string): string => {
let root = key;
for (;;) {
const up = parent.get(root);
if (up === undefined || up === root) break;
root = up;
}
// 지나온 키를 뿌리에 바로 잇는다 (다음 찾기가 짧게)
let node = key;
while (node !== root) {
const up = parent.get(node) as string;
parent.set(node, root);
node = up;
}
return root;
};
for (const [a, b] of links) {
if (!parent.has(a)) parent.set(a, a);
if (!parent.has(b)) parent.set(b, b);
const rootA = find(a);
const rootB = find(b);
if (rootA === rootB) continue;
// 가장 작은 키가 뿌리가 되게 — 합친 대화의 키는 늘 그 대화의 가장 작은 키다
if (rootA < rootB) parent.set(rootB, rootA);
else parent.set(rootA, rootB);
}
const renames = new Map<string, string>();
for (const key of parent.keys()) {
const root = find(key);
if (root !== key) renames.set(key, root);
}
return renames;
}
@@ -46,4 +46,15 @@ describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', (
'whitelistValidation',
]);
});
it('대화로 묶어 보기(14단계)는 참 · 거짓만 — 글로 쓴 값 · 숫자 · null 은 형식 오류', async () => {
for (const groupByConversation of [true, false]) {
expect(await constraintsOf({ groupByConversation })).toEqual([]);
}
for (const groupByConversation of ['true', 1, 0, null, 'on']) {
expect(await constraintsOf({ groupByConversation })).toEqual([
'isBoolean',
]);
}
});
});
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, ValidateIf } from 'class-validator';
import { IsBoolean, IsIn, ValidateIf } from 'class-validator';
import {
EXTERNAL_IMAGE_MODES,
@@ -16,6 +16,8 @@ const whenSent = (_object: object, value: unknown) => value !== undefined;
const UNDO_DESCRIPTION =
`보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` +
'예약 발송에는 더하지 않는다';
const GROUP_DESCRIPTION =
'목록을 대화로 묶어 보기(14단계) — true 면 화면이 메일함 목록을 GET /api/conversations 로 받는다. 처음 false. 검색 결과는 묶지 않는다';
const IMAGES_DESCRIPTION =
'외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' +
'스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)';
@@ -35,6 +37,9 @@ export class MailUserSettingsDto implements MailUserSettings {
example: 'ask',
})
externalImages!: ExternalImageMode;
@ApiProperty({ description: GROUP_DESCRIPTION, example: false })
groupByConversation!: boolean;
}
/**
@@ -63,4 +68,9 @@ export class MailUserSettingsPatchDto implements MailUserSettingsPatch {
message: '외부 이미지 설정을 다시 골라 주세요.',
})
externalImages?: ExternalImageMode;
@ApiPropertyOptional({ description: GROUP_DESCRIPTION, example: true })
@ValidateIf(whenSent)
@IsBoolean({ message: '대화로 묶어 보기를 다시 골라 주세요.' })
groupByConversation?: boolean;
}
@@ -33,7 +33,7 @@ export class MailUserSettingsController {
@ApiOperation({
summary: '보내기 · 읽기 설정',
description:
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.',
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask", groupByConversation: false } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다. groupByConversation 은 화면만 본다(14단계).',
})
@ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' })
get(@CurrentUser() user: AuthenticatedUser) {
@@ -49,7 +49,7 @@ export class MailUserSettingsController {
@ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' })
@ApiErrorResponse(
'VAL_001',
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드',
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, 대화로 묶기가 true · false 아님, null) · 모르는 필드',
)
update(
@CurrentUser() user: AuthenticatedUser,
@@ -58,6 +58,7 @@ export class MailUserSettingsController {
return this.settings.update(user.email, {
undoSendSeconds: dto.undoSendSeconds,
externalImages: dto.externalImages,
groupByConversation: dto.groupByConversation,
});
}
}
@@ -32,6 +32,13 @@ export class MailUserSettingsEntity {
})
externalImages!: string;
/**
* 목록을 대화로 묶어 보기 (14단계 — 기본 끔). 열은 14단계 1차의 마이그레이션(AddMailSearchIndex)이 만들고,
* 읽고 바꾸는 API(GET · PATCH /api/mail-settings 의 groupByConversation)는 3차에 더한다
*/
@Column({ name: 'group_by_conversation', type: 'boolean', default: false })
groupByConversation!: boolean;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -12,6 +12,7 @@ import type {
const COLUMNS: Readonly<Record<keyof MailUserSettings, string>> = {
undoSendSeconds: 'undo_send_seconds',
externalImages: 'external_images',
groupByConversation: 'group_by_conversation',
};
/** RETURNING 한 줄 (열 이름이 키다) */
@@ -19,6 +20,7 @@ interface ReturnedRow {
readonly user_email: string;
readonly undo_send_seconds: number;
readonly external_images: string;
readonly group_by_conversation: boolean;
readonly updated_at: Date;
}
@@ -58,6 +60,8 @@ export class MailUserSettingsRepository {
userEmail,
undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds,
externalImages: patch.externalImages ?? base.externalImages,
groupByConversation:
patch.groupByConversation ?? base.groupByConversation,
updatedAt: now,
})
.orUpdate([...changed, 'updated_at'], ['user_email'])
@@ -66,6 +70,7 @@ export class MailUserSettingsRepository {
'userEmail',
'undoSendSeconds',
'externalImages',
'groupByConversation',
'updatedAt',
])
.execute();
@@ -74,6 +79,7 @@ export class MailUserSettingsRepository {
saved.userEmail = row?.user_email ?? userEmail;
saved.undoSendSeconds = Number(row?.undo_send_seconds);
saved.externalImages = row?.external_images ?? '';
saved.groupByConversation = row?.group_by_conversation === true;
saved.updatedAt = row?.updated_at ?? now;
return saved;
}
@@ -20,11 +20,13 @@ function row(
userEmail: string,
undoSendSeconds: number,
externalImages: string,
groupByConversation = false,
): MailUserSettingsEntity {
const entity = new MailUserSettingsEntity();
entity.userEmail = userEmail;
entity.undoSendSeconds = undoSendSeconds;
entity.externalImages = externalImages;
entity.groupByConversation = groupByConversation;
entity.updatedAt = new Date('2026-10-29T00:00:00.000Z');
return entity;
}
@@ -48,6 +50,9 @@ function fakeRepository() {
current?.undoSendSeconds ??
base.undoSendSeconds,
patch.externalImages ?? current?.externalImages ?? base.externalImages,
patch.groupByConversation ??
current?.groupByConversation ??
base.groupByConversation,
);
rows.set(userEmail, saved);
return Promise.resolve(saved);
@@ -94,10 +99,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: false,
});
expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: false,
});
expect(fake.upsert).not.toHaveBeenCalled();
});
@@ -111,10 +118,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 10,
externalImages: 'always',
groupByConversation: false,
});
await expect(service.get('b@arasystem.test')).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
groupByConversation: false,
});
});
@@ -141,7 +150,11 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await expect(
service.update(ARA, { externalImages: 'always' }),
).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' });
).resolves.toEqual({
undoSendSeconds: 20,
externalImages: 'always',
groupByConversation: false,
});
expect(fake.upsert).toHaveBeenCalledWith(
ARA,
{ externalImages: 'always' },
@@ -151,6 +164,7 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 20,
externalImages: 'always',
groupByConversation: false,
});
expect(fake.find).toHaveBeenCalledTimes(1);
@@ -190,10 +204,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await expect(reading).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
groupByConversation: false,
});
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
groupByConversation: false,
});
expect(fake.find).toHaveBeenCalledTimes(1);
});
@@ -215,4 +231,41 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
await service.get('first@arasystem.test');
expect(fake.find).toHaveBeenCalledTimes(1);
});
it('대화로 묶어 보기(14단계) — 처음 끔, 보낸 칸만 바꾸고 다른 칸은 그대로 · 로그에 켬/끔', async () => {
const fake = fakeRepository();
fake.rows.set(ARA, row(ARA, 20, 'trusted'));
const service = new MailUserSettingsService(fake.repository);
await expect(service.get(ARA)).resolves.toMatchObject({
groupByConversation: false,
});
await expect(
service.update(ARA, { groupByConversation: true }),
).resolves.toEqual({
undoSendSeconds: 20,
externalImages: 'trusted',
groupByConversation: true,
});
expect(fake.upsert).toHaveBeenCalledWith(
ARA,
{ groupByConversation: true },
DEFAULT_MAIL_USER_SETTINGS,
expect.any(Date),
);
const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n');
expect(lines).toContain('대화로 묶기 켬');
});
it('표의 대화로 묶기 값이 참 · 거짓이 아니면(손으로 고친 값) 처음 값(끔)으로 읽는다', async () => {
const fake = fakeRepository();
const odd = row(ARA, 20, 'trusted');
(odd as unknown as { groupByConversation: unknown }).groupByConversation =
'yes';
fake.rows.set(ARA, odd);
const service = new MailUserSettingsService(fake.repository);
await expect(service.get(ARA)).resolves.toMatchObject({
groupByConversation: false,
});
});
});
@@ -39,11 +39,16 @@ function toSettings(row: MailUserSettingsEntity | null): MailUserSettings {
externalImages: isExternalImageMode(row.externalImages)
? row.externalImages
: DEFAULT_MAIL_USER_SETTINGS.externalImages,
groupByConversation:
typeof row.groupByConversation === 'boolean'
? row.groupByConversation
: DEFAULT_MAIL_USER_SETTINGS.groupByConversation,
};
}
/**
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계 · 14단계의 대화로 묶기) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
* 대화로 묶기는 화면만 본다(서버는 GET /api/conversations 를 부르면 묶는다 — 설정과 상관없이).
* - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다
* - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다
*
@@ -83,7 +88,8 @@ export class MailUserSettingsService {
): Promise<MailUserSettings> {
if (
patch.undoSendSeconds === undefined &&
patch.externalImages === undefined
patch.externalImages === undefined &&
patch.groupByConversation === undefined
) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-mail-settings-patch' },
@@ -99,7 +105,7 @@ export class MailUserSettingsService {
);
this.remember(cacheKey(userEmail), saved);
this.logger.log(
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`,
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}, 대화로 묶기 ${saved.groupByConversation ? '켬' : '끔'}`,
);
return saved;
}
@@ -17,15 +17,18 @@ export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number];
export interface MailUserSettings {
readonly undoSendSeconds: UndoSendSeconds;
readonly externalImages: ExternalImageMode;
/** 목록을 대화로 묶어 보기 (14단계 — 처음 끔: 전환 직후 보던 목록을 바꾸지 않는다) */
readonly groupByConversation: boolean;
}
/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */
export type MailUserSettingsPatch = Partial<MailUserSettings>;
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기 · 대화로 묶지 않음) */
export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: false,
});
/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */
@@ -0,0 +1,89 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
import { MailConversationService } from './conversation.service';
import {
ConversationListQueryDto,
ConversationQueryDto,
} from './dto/conversation-query.dto';
import {
ConversationListPageDto,
ConversationPageDto,
} from './dto/mail-response.dto';
import { MESSAGES_ROUTE } from './mail.constants';
import { MailIdPipe } from './mail-id.pipe';
/**
* 대화 (14단계 — 계약 2절 "같은 대화" · "대화로 묶어 보기"). 같은 대화는 메일 자원 아래(`/api/messages/{id}/conversation`),
* 묶은 목록은 따로(`/api/conversations`) — 검색은 묶지 않는다
*/
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors(
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@Controller()
export class ConversationController {
constructor(private readonly conversations: MailConversationService) {}
@Get(`${MESSAGES_ROUTE}/:id/conversation`)
@ApiOperation({
summary: '같은 대화 — 그 메일과 답장 · 전달로 이어진 메일 (오래된 차례)',
description:
'보낸편지함을 포함한 모든 메일함에서 — 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나다. ' +
'기준(basis): 그 직원의 색인이 준비됐으면 index — 답장 관계(References · In-Reply-To)가 있고 말머리(Re · Fw · 회신 · 답장 · 전달 · [꼬리표] — 여러 겹)를 뗀 제목이 같은 메일, ' +
'준비 중이면 engine — 엔진의 스레드. 쪽을 넘기는 중에는 커서의 기준대로 이어 간다. ' +
`limit 은 처음 값이자 최대가 ${CONVERSATION_PAGE_LIMIT} 다. 항목은 목록과 같은 요약(기다리는 발송 pendingSend 포함)이다.`,
})
@ApiStandardResponse(ConversationPageDto, { description: '같은 대화' })
@ApiErrorResponse(
'VAL_001',
`limit 범위(1~${CONVERSATION_PAGE_LIMIT}) 밖 · 다른 대화의 커서 · 모르는 쿼리`,
)
@ApiErrorResponse('RES_001', '없는 메일 (남의 메일 포함)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
conversation(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Query() query: ConversationQueryDto,
) {
return this.conversations.conversation(user, id, query);
}
@Get('conversations')
@ApiOperation({
summary:
'대화로 묶은 목록 — 그 메일함의 대화 하나가 한 줄 (마지막 받은 시각 최신부터)',
description:
'그 메일함 안의 메일을 대화로 묶는다 — 줄마다 그 메일함 안 마지막 메일(latest) · 보낸 사람들 · 대화 전체의 통 수 · 그 메일함 안 메일 id(여러 통 처리에) · 읽음 · 중요 · 첨부. ' +
'기준(basis)은 같은 대화와 같다(색인 준비 전에는 엔진의 스레드 — collapseThreads). 발송 중인 내부 초안은 빠지고, 붙잡힌 메일(보내기 취소 · 예약 — pendingSend)은 목록처럼 보인다. ' +
'검색은 묶지 않는다(GET /api/messages).',
})
@ApiStandardResponse(ConversationListPageDto, {
description: '대화로 묶은 목록',
})
@ApiErrorResponse(
'VAL_001',
'mailboxId 없음 · 형식이 틀림 · limit 범위 밖 · 다른 메일함의 커서 · 모르는 쿼리',
)
@ApiErrorResponse('RES_001', '없는 메일함')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: ConversationListQueryDto,
) {
return this.conversations.list(user, query);
}
}
@@ -0,0 +1,342 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { encodeConversationCursor } from '@/modules/mail-search/conversation-cursor';
import type { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import { MailConversationService } from './conversation.service';
import { encodeCursor } from './jmap/jmap-cursor';
import type { MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const SCOPE = {
mailboxId: null,
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
};
function summary(id: string, mailboxIds = ['a']): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds,
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-02T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 1,
pendingSend: null,
};
}
const forged = (payload: Record<string, unknown>) =>
Buffer.from(JSON.stringify(payload)).toString('base64url');
function setup(ready = true) {
const summaries = (ids: readonly string[]) =>
ids
.filter((id) => id !== 'gone')
.map((id) => summary(id, id === 'moved' ? ['e'] : ['a']));
const provider = {
conversationScope: jest.fn().mockResolvedValue(SCOPE),
getSummaries: jest.fn((_user: string, ids: readonly string[]) =>
Promise.resolve(summaries(ids)),
),
getSummariesWithHeld: jest.fn((_user: string, ids: readonly string[]) =>
Promise.resolve({
summaries: summaries(ids),
heldIds: new Set(['m1', 'elsewhere']),
}),
),
getConversation: jest.fn().mockResolvedValue({
items: [summary('e1')],
hasNext: false,
nextCursor: null,
total: 1,
}),
listConversations: jest.fn().mockResolvedValue({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
}),
};
const searchIndex = { isSearchReady: jest.fn().mockResolvedValue(ready) };
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
const conversations = {
conversation: jest.fn().mockResolvedValue({
ids: ['m1', 'm2', 'gone'],
hasNext: true,
nextCursor: 'next',
total: 3,
}),
mailboxConversations: jest.fn().mockResolvedValue({
items: [
{
key: 'k1',
latestId: 'm2',
mailboxMessageIds: ['m2', 'm1'],
messageCount: 4,
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
isRead: false,
isFlagged: true,
hasAttachment: false,
},
{
key: 'k2',
latestId: 'moved',
mailboxMessageIds: ['moved'],
messageCount: 1,
participants: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
},
],
hasNext: false,
nextCursor: null,
total: 2,
}),
};
const service = new MailConversationService(
provider as unknown as MailProvider,
searchIndex as unknown as MailSearchIndexService,
searchSync as unknown as MailSearchSyncService,
conversations as unknown as MailConversationQueryService,
);
return { service, provider, searchIndex, searchSync, conversations };
}
describe('MailConversationService (같은 대화 · 묶은 목록 — 14단계 C3 · C4)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => warnSpy.mockRestore());
it('색인이 준비됐으면 색인 — 바로 전 맞추기 → 범위 → 색인의 쪽(그 메일의 키와 한 문장) → 요약은 엔진에서(없는 메일은 뺀다), basis index', async () => {
const { service, provider, searchSync, conversations } = setup();
const result = await service.conversation(user, 'm2', { limit: 100 });
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
expect(provider.conversationScope).toHaveBeenCalledWith(user.email);
expect(conversations.conversation).toHaveBeenCalledWith(
user.email,
'm2',
SCOPE,
{ limit: 100, after: null },
);
expect(result.items.map((item) => item.id)).toEqual(['m1', 'm2']);
expect(result.pageInfo).toEqual({
hasNext: true,
nextCursor: 'next',
total: 3,
});
expect(result.basis).toBe('index');
expect(provider.getConversation).not.toHaveBeenCalled();
});
it('색인이 준비 중 · 그 메일이 색인에 아직 없으면 엔진의 스레드 — basis engine', async () => {
const notReady = setup(false);
const engine = await notReady.service.conversation(user, 'm2', {
limit: 100,
});
expect(engine.basis).toBe('engine');
expect(notReady.provider.getConversation).toHaveBeenCalledWith(
user.email,
'm2',
{ limit: 100, cursor: undefined },
);
const notIndexed = setup();
notIndexed.conversations.conversation.mockResolvedValue(null);
const fallback = await notIndexed.service.conversation(user, 'new', {
limit: 100,
});
expect(fallback.basis).toBe('engine');
expect(notIndexed.provider.getSummaries).not.toHaveBeenCalled();
});
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 입구에서 읽어 자리로 넘기고, 그 메일이 색인에서 사라졌으면 404 RES_001', async () => {
const at = new Date('2026-11-02T01:00:00.000Z');
const indexCursor = encodeConversationCursor(
'thread',
{ at, key: 'm1' },
'm2',
);
const notReady = setup(false);
await notReady.service.conversation(user, 'm2', {
limit: 100,
cursor: indexCursor,
});
expect(notReady.conversations.conversation).toHaveBeenCalledWith(
user.email,
'm2',
SCOPE,
{ limit: 100, after: { at, key: 'm1' } },
);
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
const ready = setup();
await ready.service.conversation(user, 'm2', {
limit: 100,
cursor: encodeCursor(
{ anchor: 'm1', position: 1 },
{ conditions: ['conversation', 'm2'] },
),
});
expect(ready.provider.getConversation).toHaveBeenCalled();
expect(ready.conversations.conversation).not.toHaveBeenCalled();
const vanished = setup();
vanished.conversations.conversation.mockResolvedValue(null);
await expect(
vanished.service.conversation(user, 'm2', {
limit: 100,
cursor: indexCursor,
}),
).rejects.toMatchObject({ code: 'RES_001' });
});
it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음 · 다른 메일의 커서)는 맞추기 · 엔진 · DB 에 묻기 전에 400 VAL_001 — 두 경로 모두 (리뷰 L1)', async () => {
const at = new Date('2026-11-02T01:00:00.000Z');
const threadCursors = [
forged({ m: 'ti' }),
forged({ v: 1, m: 'ti', t: 'x', k: 'm1', s: 'zz' }),
encodeConversationCursor('thread', { at, key: 'm1' }, 'other'),
];
for (const cursor of threadCursors) {
const { service, provider, searchSync, conversations } = setup();
await expect(
service.conversation(user, 'm2', { limit: 100, cursor }),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
expect(provider.conversationScope).not.toHaveBeenCalled();
expect(conversations.conversation).not.toHaveBeenCalled();
}
const listCursors = [
forged({ m: 'li' }),
forged({ v: 1, m: 'li', t: -5, k: 'k1', s: 'zz' }),
encodeConversationCursor('list', { at, key: 'k1' }, 'sent'),
];
for (const cursor of listCursors) {
const { service, provider, searchSync, conversations } = setup();
await expect(
service.list(user, { mailboxId: 'a', limit: 20, cursor }),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
expect(provider.conversationScope).not.toHaveBeenCalled();
expect(conversations.mailboxConversations).not.toHaveBeenCalled();
}
});
it('첫 쪽의 색인 읽기가 DB 장애면 엔진으로 — 코드가 있는 실패(RES_001 · MAIL_001)는 그대로', async () => {
const broken = setup();
broken.conversations.conversation.mockRejectedValue(new Error('db down'));
const result = await broken.service.conversation(user, 'm2', {
limit: 100,
});
expect(result.basis).toBe('engine');
expect(warnSpy).toHaveBeenCalledTimes(1);
const [line] = warnSpy.mock.calls[0] as [string];
expect(line).toContain('[SYS_001]');
expect(line).not.toContain(user.email);
const coded = setup();
coded.provider.conversationScope.mockRejectedValue(
new BusinessException('MAIL_001'),
);
await expect(
coded.service.conversation(user, 'm2', { limit: 100 }),
).rejects.toMatchObject({ code: 'MAIL_001' });
expect(coded.provider.getConversation).not.toHaveBeenCalled();
});
it('묶은 목록 — 색인이면 그 메일함의 범위(있는지 본다) → 색인의 쪽 → 마지막 메일의 요약과 붙잡힌 메일을 한 번에, 줄의 pendingSendIds · 그 메일함을 떠난 대화는 뺀다 (리뷰 L6)', async () => {
const { service, provider, conversations } = setup();
const result = await service.list(user, { mailboxId: 'a', limit: 20 });
expect(provider.conversationScope).toHaveBeenCalledWith(user.email, 'a');
expect(conversations.mailboxConversations).toHaveBeenCalledWith(
user.email,
'a',
SCOPE,
{ limit: 20, after: null },
);
expect(provider.getSummariesWithHeld).toHaveBeenCalledTimes(1);
expect(provider.getSummariesWithHeld).toHaveBeenCalledWith(user.email, [
'm2',
'moved',
]);
expect(provider.getSummaries).not.toHaveBeenCalled();
expect(result.basis).toBe('index');
expect(result.items).toEqual([
{
id: 'k1',
latest: summary('m2'),
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: 4,
messageIds: ['m2', 'm1'],
pendingSendIds: ['m1'],
isRead: false,
isFlagged: true,
hasAttachment: false,
},
]);
expect(result.pageInfo.total).toBe(2);
// 빈 쪽이면 엔진에 요약 · 제출을 묻지 않는다
const empty = setup();
empty.conversations.mailboxConversations.mockResolvedValue({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
const none = await empty.service.list(user, { mailboxId: 'a', limit: 20 });
expect(none.items).toEqual([]);
expect(empty.provider.getSummariesWithHeld).not.toHaveBeenCalled();
});
it('묶은 목록 — 준비 중이면 엔진(collapseThreads), 커서의 방식은 이어 간다', async () => {
const notReady = setup(false);
const engine = await notReady.service.list(user, {
mailboxId: 'a',
limit: 20,
});
expect(engine.basis).toBe('engine');
expect(notReady.provider.listConversations).toHaveBeenCalledWith(
user.email,
{ mailboxId: 'a', limit: 20, cursor: undefined },
);
const at = new Date('2026-11-02T01:00:00.000Z');
const indexCursor = encodeConversationCursor(
'list',
{ at, key: 'k9' },
'a',
);
const resumed = setup(false);
const next = await resumed.service.list(user, {
mailboxId: 'a',
limit: 20,
cursor: indexCursor,
});
expect(next.basis).toBe('index');
expect(resumed.conversations.mailboxConversations).toHaveBeenCalledWith(
user.email,
'a',
SCOPE,
{ limit: 20, after: { at, key: 'k9' } },
);
});
});
@@ -0,0 +1,249 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
conversationCursorMethod,
decodeConversationCursor,
type ConversationPosition,
} from '@/modules/mail-search/conversation-cursor';
import { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type { ConversationBasis } from '@/modules/mail-search/mail-search.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type {
ConversationListQueryDto,
ConversationQueryDto,
} from './dto/conversation-query.dto';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { ConversationSummary, MessageSummary } from './mail.types';
/** 대화 응답 — 목록 모양에 기준(basis)을 더한다 */
export type ConversationResult<T> = PaginatedResult<T> & {
readonly basis: ConversationBasis;
};
function errorKind(error: unknown): string {
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name}/${code}` : name;
}
/**
* 대화 (14단계 설계 C3 · C4 — 디자인 README "같은 대화" · "대화로 묶어 보기").
* - 기준: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 준비가 끝나거나 다시 채우기에 들어가도 처음 방식대로), 없으면 그 직원의 색인이 준비됐으면(ready · 지금 판)
* 우리 색인(대화 키 — 답장 관계 + 말머리를 뗀 제목), 아니면 엔진의 스레드. 응답에 기준(basis)을 싣는다.
* 색인 커서는 입구에서 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 맞추기 · 엔진 · DB 에 묻기 전에 VAL_001 (3차 리뷰 L1)
* - 색인 방식: 바로 전 맞추기(1.5초 안) → 엔진에 범위(휴지통 · 스팸메일함 · 내부 초안 키워드 — 메일함이 있는지) → 색인 → 보일 메일의 요약은 엔진에서(목록과 같은 모양 · 기다리는 발송)
* 엔진에 없는 메일(그사이 지움)과 그 메일함을 떠난 마지막 메일의 대화는 뺀다. 첫 쪽의 색인 읽기가 DB 장애면 엔진 방식으로 답한다
* - 같은 대화: 그 메일이 색인에 없으면(방금 와 아직 넣지 않음) 엔진 방식 — 엔진에도 없으면 RES_001. 색인 커서로 이어 가는데 없으면 RES_001
* - 묶은 목록의 붙잡힌 메일(pendingSendIds): 마지막 메일의 요약을 읽는 요청에서 계정의 기다리는 제출을 한 번 읽어 줄의 messageIds 와 맞춘다(getSummariesWithHeld)
*/
@Injectable()
export class MailConversationService {
private readonly logger = new Logger(MailConversationService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
private readonly searchIndex: MailSearchIndexService,
private readonly searchSync: MailSearchSyncService,
private readonly conversations: MailConversationQueryService,
) {}
/** GET /api/messages/{id}/conversation */
async conversation(
user: AuthenticatedUser,
emailId: string,
query: ConversationQueryDto,
): Promise<ConversationResult<MessageSummary>> {
if (query.cursor) {
if (conversationCursorMethod(query.cursor, 'thread') === 'index') {
const after = decodeConversationCursor('thread', query.cursor, emailId);
return this.conversationFromIndex(user, emailId, query.limit, after);
}
return this.conversationFromEngine(user, emailId, query);
}
if (!(await this.searchIndex.isSearchReady(user.email))) {
return this.conversationFromEngine(user, emailId, query);
}
try {
return await this.conversationFromIndex(user, emailId, query.limit, null);
} catch (error) {
if (error instanceof BusinessException) throw error;
this.warnFallback(user, error);
return this.conversationFromEngine(user, emailId, query);
}
}
/** GET /api/conversations */
async list(
user: AuthenticatedUser,
query: ConversationListQueryDto,
): Promise<ConversationResult<ConversationSummary>> {
if (query.cursor) {
if (conversationCursorMethod(query.cursor, 'list') === 'index') {
const after = decodeConversationCursor(
'list',
query.cursor,
query.mailboxId,
);
return this.listFromIndex(user, query, after);
}
return this.listFromEngine(user, query);
}
if (!(await this.searchIndex.isSearchReady(user.email))) {
return this.listFromEngine(user, query);
}
try {
return await this.listFromIndex(user, query, null);
} catch (error) {
if (error instanceof BusinessException) throw error;
this.warnFallback(user, error);
return this.listFromEngine(user, query);
}
}
private async conversationFromEngine(
user: AuthenticatedUser,
emailId: string,
query: ConversationQueryDto,
): Promise<ConversationResult<MessageSummary>> {
const page = await this.provider.getConversation(user.email, emailId, {
limit: query.limit,
cursor: query.cursor,
});
return {
...toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
}),
basis: 'engine',
};
}
private async conversationFromIndex(
user: AuthenticatedUser,
emailId: string,
limit: number,
after: ConversationPosition | null,
): Promise<ConversationResult<MessageSummary>> {
await this.searchSync.syncBeforeRead(user.email);
const scope = await this.provider.conversationScope(user.email);
const page = await this.conversations.conversation(
user.email,
emailId,
scope,
{ limit, after },
);
if (page === null) {
// 색인에 없다 — 첫 쪽이면 엔진의 스레드로(방금 온 메일), 이어 가는 쪽이면 그 메일이 그사이 사라졌다
if (after !== null) {
throw new BusinessException('RES_001', {
context: { reason: 'conversation-message-not-indexed' },
});
}
return this.conversationFromEngine(user, emailId, { limit });
}
const summaries =
page.ids.length > 0
? await this.provider.getSummaries(user.email, page.ids)
: [];
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
const items = page.ids.flatMap((id) => {
const summary = byId.get(id);
return summary ? [summary] : [];
});
return {
...toPaginatedResult(items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
}),
basis: 'index',
};
}
private async listFromEngine(
user: AuthenticatedUser,
query: ConversationListQueryDto,
): Promise<ConversationResult<ConversationSummary>> {
const page = await this.provider.listConversations(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
});
return {
...toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
}),
basis: 'engine',
};
}
private async listFromIndex(
user: AuthenticatedUser,
query: ConversationListQueryDto,
after: ConversationPosition | null,
): Promise<ConversationResult<ConversationSummary>> {
await this.searchSync.syncBeforeRead(user.email);
const scope = await this.provider.conversationScope(
user.email,
query.mailboxId,
);
const page = await this.conversations.mailboxConversations(
user.email,
query.mailboxId,
scope,
{ limit: query.limit, after },
);
const latestIds = page.items.map((item) => item.latestId);
// 마지막 메일의 요약과 계정의 붙잡힌 메일 — 기다리는 제출은 이 쪽에서 한 번만 읽는다 (줄이 없으면 묻지 않는다)
const { summaries, heldIds } =
latestIds.length > 0
? await this.provider.getSummariesWithHeld(user.email, latestIds)
: { summaries: [], heldIds: new Set<string>() };
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
const items = page.items.flatMap((item): ConversationSummary[] => {
const latest = byId.get(item.latestId);
// 엔진에 없거나(그사이 지움) 그 메일함을 떠났다 — 맞추기가 곧 고친다
if (!latest || !latest.mailboxIds.includes(query.mailboxId)) return [];
const messageIds = [...item.mailboxMessageIds];
return [
{
id: item.key,
latest,
participants: [...item.participants],
messageCount: item.messageCount,
messageIds,
pendingSendIds: messageIds.filter((id) => heldIds.has(id)),
isRead: item.isRead,
isFlagged: item.isFlagged,
hasAttachment: item.hasAttachment,
},
];
});
return {
...toPaginatedResult(items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
}),
basis: 'index',
};
}
private warnFallback(user: AuthenticatedUser, error: unknown): void {
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인으로 대화를 읽지 못해 엔진의 스레드로 답합니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
);
}
}
@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Max,
Min,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
/**
* GET /api/messages/{id}/conversation — 같은 대화의 쪽 (14단계). limit 의 처음 값이자 최대가 CONVERSATION_PAGE_LIMIT 다(화면은 한 번에 보인다)
*/
export class ConversationQueryDto {
@ApiPropertyOptional({
description:
'이전 응답의 nextCursor 값 — 그 메일의 대화에서만 쓴다(다르면 400 VAL_001). 첫 쪽은 생략한다',
example: 'eyJ2IjoxfQ',
})
@IsOptional()
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
cursor?: string;
@ApiPropertyOptional({
description: `한 번에 가져올 메일 수 (1~${CONVERSATION_PAGE_LIMIT})`,
default: CONVERSATION_PAGE_LIMIT,
example: CONVERSATION_PAGE_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(CONVERSATION_PAGE_LIMIT, {
message: `조회 개수는 ${CONVERSATION_PAGE_LIMIT} 이하여야 합니다.`,
})
limit: number = CONVERSATION_PAGE_LIMIT;
}
/**
* GET /api/conversations — 대화로 묶은 목록 (14단계). 메일함이 꼭 있어야 한다(검색은 묶지 않는다)
*/
export class ConversationListQueryDto extends CursorPaginationQueryDto {
@ApiProperty({
description:
'메일함 id — 그 메일함의 대화를 묶는다. 없는 메일함은 404 RES_001',
example: 'a',
})
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
mailboxId!: string;
}
@@ -15,7 +15,18 @@ import {
type ContactEmailLabel,
} from '@/modules/contacts/contacts.types';
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
import {
CONVERSATION_MESSAGE_IDS_MAX,
CONVERSATION_PARTICIPANTS_MAX,
} from '@/modules/mail-search/mail-search.constants';
import type {
ConversationBasis,
MessageDetailHighlight,
MessageListHighlight,
SearchRange,
} from '@/modules/mail-search/mail-search.types';
import type {
ConversationSummary,
Draft,
MailAddress,
MailAttachment,
@@ -29,6 +40,7 @@ import type {
MessageDeletionResult,
MessageDetail,
MessageLargeFile,
MessageListItem,
MessagesPatchResult,
MessageSummary,
PendingSend,
@@ -311,6 +323,90 @@ export class MessageSummaryDto implements MessageSummary {
pendingSend!: PendingSendDto | null;
}
/** 맞은 곳 [시작, 끝) — UTF-16, 끝은 들지 않는다 (14단계 검색 강조) */
const RANGE_LIST_SCHEMA = {
type: 'array',
items: {
type: 'array',
items: { type: 'integer', minimum: 0 },
minItems: 2,
maxItems: 2,
},
} as const;
/** 목록 항목의 미리 보기 강조 */
export class MessageListPreviewHighlightDto {
@ApiProperty({
description:
'보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(앞 20자 남짓 · 모두 80자 남짓, 말 경계에서 자르고 잘린 쪽에 "…"), 아니면 엔진의 미리 보기',
example: '…지난주 보내 드린 계약서를 다시 확인해 주세요',
})
text!: string;
@ApiProperty({
...RANGE_LIST_SCHEMA,
description: 'text 안의 맞은 곳 [시작, 끝) — UTF-16',
example: [[12, 14]],
})
ranges!: SearchRange[];
}
/** 목록 항목의 검색 강조 (색인으로 찾았을 때만) */
export class MessageListHighlightDto implements MessageListHighlight {
@ApiProperty({
...RANGE_LIST_SCHEMA,
description:
'제목의 맞은 곳 [시작, 끝) — UTF-16. 강조가 있으면 subject 는 NFC 로 맞춘 글이다(위치가 그 글 기준)',
example: [[0, 2]],
})
subject!: SearchRange[];
@ApiProperty({ type: MessageListPreviewHighlightDto })
preview!: MessageListPreviewHighlightDto;
}
/** 목록 항목 (GET /api/messages) — 요약 + 검색 강조 */
export class MessageListItemDto
extends MessageSummaryDto
implements MessageListItem
{
@ApiProperty({
description:
'검색 강조 (14단계) — 색인으로 찾은 검색 결과에만, 목록 · 엔진 방식 검색(색인 준비 중)이면 null',
type: MessageListHighlightDto,
nullable: true,
})
searchHighlight!: MessageListHighlightDto | null;
}
/** 읽기 화면의 검색 강조 */
export class MessageDetailHighlightDto implements MessageDetailHighlight {
@ApiProperty({
...RANGE_LIST_SCHEMA,
description:
'제목의 맞은 곳 [시작, 끝) — UTF-16 (subject 는 NFC 로 맞춘 글이다)',
example: [[0, 2]],
})
subject!: SearchRange[];
@ApiProperty({
description:
'첨부 이름의 맞은 곳 — 첨부 id → [시작, 끝) 목록. 맞은 첨부만 든다 (이름은 NFC 로 맞춘 글이다)',
type: 'object',
additionalProperties: RANGE_LIST_SCHEMA,
example: { blob1: [[0, 3]] },
})
attachments!: Record<string, SearchRange[]>;
@ApiProperty({
...RANGE_LIST_SCHEMA,
description:
'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 <mark> 가 들어 있다',
example: [[120, 122]],
})
textBody!: SearchRange[];
}
export class MessageDetailDto
extends MessageSummaryDto
implements MessageDetail
@@ -406,6 +502,14 @@ export class MessageDetailDto
type: () => [MessageLargeFileDto],
})
largeFiles!: MessageLargeFileDto[];
@ApiProperty({
description:
'검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 <mark> 가 있다',
type: MessageDetailHighlightDto,
nullable: true,
})
searchHighlight!: MessageDetailHighlightDto | null;
}
const SHARED_FILE_STATUSES: SharedFileStatus[] = [
@@ -475,13 +579,132 @@ export class MessageLargeFileDto implements MessageLargeFile {
export class MessagePageDto {
@ApiProperty({
type: [MessageSummaryDto],
type: [MessageListItemDto],
description: '받은 시각 내림차순',
})
items!: MessageListItemDto[];
@ApiProperty({
type: PageInfoDto,
description:
'total — 목록 · 엔진 방식 검색은 늘, 색인 방식 검색은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). 색인 방식은 쪽이 limit 보다 적거나 비어도 hasNext 일 수 있다(이어 찾기)',
})
pageInfo!: PageInfoDto;
}
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목) 또는 엔진의 스레드(색인을 다 만들기 전) — 14단계 */
const CONVERSATION_BASES: ConversationBasis[] = ['index', 'engine'];
/** 같은 대화 (GET /api/messages/{id}/conversation — 14단계) */
export class ConversationPageDto {
@ApiProperty({
type: [MessageSummaryDto],
description:
'그 메일의 대화 — 오래된 차례(받은 시각). 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나',
})
items!: MessageSummaryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함' })
@ApiProperty({
type: PageInfoDto,
description:
'total — 대화의 메일 수. 색인 방식은 첫 쪽에서만, 엔진 방식은 스레드를 모두 읽었을 때(상한 이하)만 쪽마다 — 넘으면 null (쪽이 limit 보다 적어도 hasNext 면 이어 읽는다)',
})
pageInfo!: PageInfoDto;
@ApiProperty({
description:
'대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전)',
enum: CONVERSATION_BASES,
example: 'index',
})
basis!: ConversationBasis;
}
/** 묶은 목록의 대화 하나 (GET /api/conversations — 14단계) */
export class ConversationSummaryDto implements ConversationSummary {
@ApiProperty({
description:
'대화 — 불투명 문자열(색인이면 대화 키, 엔진이면 스레드 id). 한 목록 안에서 대화를 가리는 데만 쓴다',
example: 'eaaaaab',
})
id!: string;
@ApiProperty({
type: MessageSummaryDto,
description: '그 메일함 안 마지막 메일 — 줄을 누르면 연다',
})
latest!: MessageSummaryDto;
@ApiProperty({
type: [MailAddressDto],
description: `보낸 사람들 — 대화 전체에서 받은 차례, 주소마다 한 번, ${CONVERSATION_PARTICIPANTS_MAX}명까지 (화면은 내 주소를 "나"로). 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만`,
})
participants!: MailAddressDto[];
@ApiProperty({
description:
'대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다',
example: 4,
})
messageCount!: number;
@ApiProperty({
type: [String],
description: `그 메일함 안 그 대화의 메일 — 최신부터 ${CONVERSATION_MESSAGE_IDS_MAX}통까지(넘는 오래된 메일은 빠진다). 고르기 · 읽음 · 중요 · 이동 · 삭제는 이 id 들로 여러 통 API(PATCH /api/messages 등)에 — pendingSendIds 는 뺀다`,
example: ['eaaaaab', 'eaaaaaa'],
})
messageIds!: string[];
@ApiProperty({
type: [String],
description:
'messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일)의 id — 없으면 빈 배열. 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(여러 통 처리에서 뺀다)',
example: [],
})
pendingSendIds!: string[];
@ApiProperty({
description: '그 메일함 안 메일을 모두 읽었다',
example: false,
})
isRead!: boolean;
@ApiProperty({
description: '그 메일함 안 메일 가운데 하나라도 중요 표시',
example: false,
})
isFlagged!: boolean;
@ApiProperty({
description:
'그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 hasAttachment 와 같은 판정)',
example: true,
})
hasAttachment!: boolean;
}
/** 대화로 묶은 목록 (GET /api/conversations — 14단계) */
export class ConversationListPageDto {
@ApiProperty({
type: [ConversationSummaryDto],
description: '그 메일함의 대화 — 그 메일함 안 마지막 받은 시각 최신부터',
})
items!: ConversationSummaryDto[];
@ApiProperty({
type: PageInfoDto,
description:
'total — 그 메일함의 대화 수(색인 방식은 첫 쪽에서만 · 엔진 방식은 엔진이 센 스레드 수)',
})
pageInfo!: PageInfoDto;
@ApiProperty({
description:
'묶은 기준 — index 우리 색인 · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전 — 통 수 · 묶음이 엔진 규칙이다)',
enum: CONVERSATION_BASES,
example: 'index',
})
basis!: ConversationBasis;
}
const REJECTION_REASONS: RecipientRejectionReason[] = [
@@ -25,3 +25,72 @@ describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 image
expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']);
});
});
describe('메일 한 통 읽기 쿼리 — 검색 강조 (14단계 q · from · to · subject · body)', () => {
it('목록의 검색 칸과 같은 검사 — 앞뒤 공백을 지우고, 길이 · 낱말 수를 본다', async () => {
for (const field of ['q', 'from', 'to', 'subject', 'body']) {
expect(await constraintsOf({ [field]: ' 계약 ' })).toEqual([]);
expect(await constraintsOf({ [field]: '가'.repeat(201) })).toEqual([
'maxCodeUnits',
]);
expect(
await constraintsOf({
[field]: Array.from({ length: 9 }, (_, i) => `w${i}`).join(' '),
}),
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect(await constraintsOf({ [field]: ['a', 'b'] })).toContain(
'isString',
);
}
});
it('images=show 와 함께 받는다 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다', async () => {
expect(await constraintsOf({ images: 'show', q: '계약' })).toEqual([]);
expect(await constraintsOf({ after: '2026-10-31T15:00:00.000Z' })).toEqual([
'whitelistValidation',
]);
expect(await constraintsOf({ hasAttachment: 'true' })).toEqual([
'whitelistValidation',
]);
});
});
describe('메일 한 통 읽기 쿼리 — 받는 칸은 계약과 같다 (14단계 리뷰 M2 · M3)', () => {
it('q · from · to · subject · body 의 낱말은 합쳐 8개까지 — 목록과 같다', async () => {
const words = (prefix: string, count: number) =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
' ',
);
expect(
await constraintsOf({ q: words('q', 4), subject: words('s', 4) }),
).toEqual([]);
expect(
await constraintsOf({ q: words('q', 4), body: words('b', 5) }),
).toEqual(['maxCombinedSearchWords']);
});
it('images · q · from · to · subject · body 밖의 이름(cursor · limit · mailboxId · after · before · hasAttachment)은 400', async () => {
expect(
await constraintsOf({
images: 'show',
q: '계약',
from: '이도윤',
to: 'ara',
subject: '견적',
body: '일정',
}),
).toEqual([]);
for (const name of [
'cursor',
'limit',
'mailboxId',
'after',
'before',
'hasAttachment',
]) {
expect(await constraintsOf({ [name]: '1' })).toEqual([
'whitelistValidation',
]);
}
});
});
@@ -1,12 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
} from '@/modules/mail/mail.constants';
import { SearchTextField } from './search-query-fields';
/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */
export const SHOW_IMAGES = 'show';
/** 강조에 쓰는 검색 칸의 설명 꼬리 */
const HIGHLIGHT_RULE = `검색 결과에서 연 메일을 강조할 때 목록과 같은 값을 보낸다 — 검사는 목록의 검색어와 같다(다섯 칸의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지)`;
/**
* GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도).
* 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001
* 없으면 그 직원의 설정(externalImages)을 따른다. 검색 칸(q · from · to · subject · body — 14단계)을 받으면 맞은 곳을 강조한다
* (searchHighlight · htmlBody 의 `<mark>`). from · to 는 받기만 하고 강조하지 않는다. 모르는 값 · 모르는 쿼리는 400 VAL_001 —
* 목록에만 있는 cursor · limit · mailboxId · after · before · hasAttachment 도 받지 않는다(계약 2절 "검색 — 14단계" — 화면은 다섯 칸만 넘긴다)
*/
export class MessageDetailQueryDto {
@ApiPropertyOptional({
@@ -18,4 +29,39 @@ export class MessageDetailQueryDto {
@IsOptional()
@IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' })
images?: typeof SHOW_IMAGES;
@ApiPropertyOptional({
description: `검색어 — 제목 · 본문 · 첨부 이름의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('검색어', '검색어는')
q?: string;
@ApiPropertyOptional({
description: `보낸 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('보낸 사람', '보낸 사람은')
from?: string;
@ApiPropertyOptional({
description: `받는 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('받는 사람', '받는 사람은')
to?: string;
@ApiPropertyOptional({
description: `제목 조건 — 제목의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('제목', '제목은')
subject?: string;
@ApiPropertyOptional({
description: `내용 조건 — 본문의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('내용', '내용은')
body?: string;
}
@@ -1,6 +1,9 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { createValidationPipe } from '@/app.setup';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
@@ -63,10 +66,10 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
);
expect(
(await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints,
).toEqual(['maxSearchWords']);
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect(
(await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints,
).toEqual(['maxSearchWords']);
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
});
it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => {
@@ -75,3 +78,154 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
);
});
});
describe('MessageListQueryDto — 조건 검색 (14단계)', () => {
const TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const;
it('글 조건(from · to · subject · body)은 검색어와 같다 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것', async () => {
for (const field of TEXT_FIELDS) {
const trimmed = await check({ [field]: ' 이도윤 계약 ' });
expect(trimmed.constraints).toEqual([]);
expect(trimmed.dto[field]).toBe('이도윤 계약');
const blank = await check({ [field]: ' \u3000 ' });
expect(blank.constraints).toEqual([]);
expect(blank.dto[field]).toBeUndefined();
}
});
it(`글 조건도 앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지, 글이 아니면 형식 오류`, async () => {
const words = (count: number) =>
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
for (const field of TEXT_FIELDS) {
expect(
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH) })).constraints,
).toEqual([]);
expect(
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH + 1) }))
.constraints,
).toEqual(['maxCodeUnits']);
expect(
(await check({ [field]: words(MAX_SEARCH_WORDS) })).constraints,
).toEqual([]);
expect(
(await check({ [field]: words(MAX_SEARCH_WORDS + 1) })).constraints,
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect((await check({ [field]: ['a', 'b'] })).constraints).toContain(
'isString',
);
}
});
it('기간(after · before)은 ISO 8601 UTC(Z)만 — 없는 날짜 · 시간대 표기 · 날짜만 · 빈 값은 형식 오류', async () => {
expect(
(
await check({
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00Z',
})
).constraints,
).toEqual([]);
for (const value of [
'',
'2026-11-01',
'2026-11-01T00:00:00+09:00',
'2026-02-30T00:00:00.000Z',
'2026-11-01T00:00:00.0000Z',
'yesterday',
]) {
expect((await check({ after: value })).constraints).toEqual([
'isUtcDateTime',
]);
expect((await check({ before: value })).constraints).toEqual([
'isUtcDateTime',
]);
}
});
it('첨부 조건은 true 만 — false · 1 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => {
expect((await check({ hasAttachment: 'true' })).constraints).toEqual([]);
for (const hasAttachment of ['false', '1', 'TRUE', '']) {
expect((await check({ hasAttachment })).constraints).toEqual(['isIn']);
}
expect((await check({ cc: 'ara' })).constraints).toEqual([
'whitelistValidation',
]);
});
it('전역 검사 그대로(stopAtFirstError) — 긴 글은 길이에서 멈춘다: 낱말을 나누지 않고 싸게 400 (CPU 시간)', async () => {
const pipe = createValidationPipe();
// 낱말 10만 개 · 30만 자 — 길이(MaxCodeUnits — 길이만 본다)에서 걸려 낱말 수 검사(나누기)까지 가지 않는다
const huge = 'ab '.repeat(100_000);
for (const field of ['q', ...TEXT_FIELDS]) {
const cpuMs = startCpuTimer();
const error = await pipe
.transform(
{ [field]: huge },
{ type: 'query', metatype: MessageListQueryDto },
)
.then(
() => null,
(caught: unknown) => caught,
);
// 지금 수 ms — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(500);
expect(error).toBeInstanceOf(BadRequestException);
const { message } = (error as BadRequestException).getResponse() as {
message: string[];
};
expect(message).toHaveLength(1);
expect(message[0]).toContain(`${MAX_SEARCH_LENGTH}자 이하`);
}
});
});
describe('MessageListQueryDto — 검색어와 조건을 합친 낱말 수 (14단계 리뷰 M2)', () => {
const words = (prefix: string, count: number) =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' ');
it(`q 와 조건 칸 넷(from · to · subject · body)의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지 — 넘으면 한 번만 알린다`, async () => {
expect(
(
await check({
q: words('q', 2),
from: words('f', 2),
to: words('t', 2),
subject: words('s', 1),
body: words('b', 1),
})
).constraints,
).toEqual([]);
expect(
(await check({ q: words('q', 4), to: words('t', 5) })).constraints,
).toEqual(['maxCombinedSearchWords']);
// 검색어 없이 조건만이어도 같다
expect(
(await check({ from: words('f', 5), body: words('b', 4) })).constraints,
).toEqual(['maxCombinedSearchWords']);
});
it('기간 · 첨부 · 메일함은 세지 않는다 · 따옴표 안의 낱말은 센다', async () => {
expect(
(
await check({
q: words('q', MAX_SEARCH_WORDS),
after: '2026-10-31T15:00:00.000Z',
hasAttachment: 'true',
mailboxId: 'a',
})
).constraints,
).toEqual([]);
expect(
(await check({ q: `"${words('q', 5)}"`, subject: words('s', 4) }))
.constraints,
).toEqual(['maxCombinedSearchWords']);
});
it('칸 하나가 길이를 넘으면 그 칸의 검사만 — 합친 수를 세려고 긴 글을 나누지 않는다', async () => {
// (이 검사는 stopAtFirstError 를 뺀 것이라 그 칸의 규칙이 모두 보인다 — 합친 수는 보지 않았다)
expect(
(await check({ q: '계약', to: 'ab '.repeat(100) })).constraints,
).toEqual(['maxCodeUnits', 'maxSearchWords']);
});
});
@@ -1,61 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, type TransformFnParams } from 'class-transformer';
import {
IsOptional,
IsString,
Matches,
ValidateBy,
type ValidationOptions,
} from 'class-validator';
import { IsIn, IsOptional, Matches } from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import {
MAIL_ID_PATTERN,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
SEARCH_WORD_SEPARATOR,
} from '@/modules/mail/mail.constants';
import { IsUtcDateTime } from './mail-vacation.dto';
import { SearchTextField } from './search-query-fields';
/** 첨부 있음 조건의 값 — 이것만 받는다 (없으면 첨부와 상관없이) */
export const HAS_ATTACHMENT_ONLY = 'true';
/** 글 칸의 설명 꼬리 — 검사는 검색어와 같다 */
const TEXT_FIELD_RULE = `앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지(검색어와 같은 검사 — q 와 조건 넷의 낱말을 합쳐서도 ${MAX_SEARCH_WORDS}개까지), 공백뿐이면 없는 것으로 본다. 큰따옴표 구절 · 앞뒤 문장부호 떼기 · NUL 을 U+FFFD 로 바꾸기도 검색어와 같다`;
/**
* 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색").
* 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다.
*/
function normalizeSearchQuery({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
}
/** 최대 낱말 수 */
function MaxSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxSearchWords',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && countSearchWords(value) <= max,
},
},
options,
);
}
/**
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어.
* mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages)
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 + 조건(14단계).
* mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (message-list.service.ts)
*/
export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)',
'메일함 id — 없으면 검색(q 나 조건)이 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (둘 다 없으면 400 VAL_001)',
example: 'a',
})
@IsOptional()
@@ -64,21 +32,76 @@ export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` +
'보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
'한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' +
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다',
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지(조건 from · to · subject · body 의 낱말과 합쳐서도 ${MAX_SEARCH_WORDS}개까지). 공백뿐이면 없는 것으로 본다. ` +
'보낸 사람·받는 사람·참조·제목·첨부 이름·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
'색인이 준비됐으면(GET /api/search-index 의 ready) 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 없이), ' +
'준비 중이면 낱말이 통째로 같을 때만 맞고 한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다. ' +
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·조건·범위로만 쓴다',
example: '서버 이전',
maxLength: MAX_SEARCH_LENGTH,
})
@Transform(normalizeSearchQuery)
@IsOptional()
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_SEARCH_LENGTH, {
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
})
@MaxSearchWords(MAX_SEARCH_WORDS, {
message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
})
@SearchTextField('검색어', '검색어는')
q?: string;
@ApiPropertyOptional({
description: `보낸 사람 조건 — 보낸 사람의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
example: '이도윤',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('보낸 사람', '보낸 사람은')
from?: string;
@ApiPropertyOptional({
description: `받는 사람 조건 — 받는 사람과 참조의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
example: 'ara.kim',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('받는 사람', '받는 사람은')
to?: string;
@ApiPropertyOptional({
description: `제목 조건. ${TEXT_FIELD_RULE}`,
example: '견적',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('제목', '제목은')
subject?: string;
@ApiPropertyOptional({
description: `내용 조건 — 본문에서 찾는다(첨부 파일 안의 글은 찾지 않는다). ${TEXT_FIELD_RULE}`,
example: '계약',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('내용', '내용은')
body?: string;
@ApiPropertyOptional({
description:
'받은 시각이 이 시각 이후(이상)인 메일만 — ISO 8601 UTC(Z). 화면은 브라우저 시간대의 시작 날짜 0시를 UTC 로 바꿔 보낸다',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' })
after?: string;
@ApiPropertyOptional({
description:
'받은 시각이 이 시각 앞(미만)인 메일만 — ISO 8601 UTC(Z). 화면은 끝 날짜의 다음 날 0시를 보낸다',
example: '2026-11-30T15:00:00.000Z',
})
@IsOptional()
@IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' })
before?: string;
@ApiPropertyOptional({
description:
'true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정 — 본문 그림만 있는 메일은 빠진다). 다른 값은 400 VAL_001',
enum: [HAS_ATTACHMENT_ONLY],
example: HAS_ATTACHMENT_ONLY,
})
@IsOptional()
@IsIn([HAS_ATTACHMENT_ONLY], {
message: '첨부 조건이 올바르지 않습니다.',
})
hasAttachment?: typeof HAS_ATTACHMENT_ONLY;
}
@@ -0,0 +1,121 @@
import { applyDecorators } from '@nestjs/common';
import { Transform, type TransformFnParams } from 'class-transformer';
import {
IsOptional,
IsString,
ValidateBy,
type ValidationArguments,
type ValidationOptions,
} from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
SEARCH_WORD_SEPARATOR,
} from '@/modules/mail/mail.constants';
/**
* 검색 글 칸의 검사 (계약 2절 "검색" · "검색 — 14단계" — 검색어 q 와 조건 검색의 from · to · subject · body 가 같이 쓴다).
* 앞뒤 공백을 지우고 공백뿐이면 없는 것으로 본다. 앞뒤 공백을 뺀 1~MAX_SEARCH_LENGTH 자(JS 문자열 길이) · 낱말 MAX_SEARCH_WORDS 개까지,
* 그리고 **다섯 칸을 합친 낱말도 MAX_SEARCH_WORDS 개까지** — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻는다(3단계에서 잰 비용 안에 두려고).
* 검사는 적은 차례대로 돈다(applyDecorators — 8단계 DTO 와 같다): 형식 → 길이 → 낱말 수 → 합친 낱말 수. 길이에서 걸리면 낱말을 나누지 않는다
*/
/** 낱말 수를 합쳐 세는 검색 글 칸 — 목록(GET /api/messages)과 읽기(GET /api/messages/{id})의 같은 이름 */
export const SEARCH_TEXT_FIELDS = [
'q',
'from',
'to',
'subject',
'body',
] as const;
/** 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 — 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다 */
function normalizeSearchText({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/** 검색 글의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
export function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
}
/** 최대 낱말 수 */
function MaxSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxSearchWords',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && countSearchWords(value) <= max,
},
},
options,
);
}
/**
* 다섯 칸을 합친 낱말 수 — 값이 있는 첫 칸에서만 센다(한 번만 알린다). 다른 칸이 글이 아니거나 길이를 넘으면 세지 않는다 —
* 그 칸의 검사가 막는다(긴 글을 나누지 않게)
*/
export function combinedSearchWords(
object: Readonly<Record<string, unknown>>,
): number | null {
let total = 0;
for (const field of SEARCH_TEXT_FIELDS) {
const value = object[field];
if (value === undefined) continue;
if (typeof value !== 'string' || value.length > MAX_SEARCH_LENGTH) {
return null;
}
total += countSearchWords(value);
}
return total;
}
function MaxCombinedSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxCombinedSearchWords',
constraints: [max],
validator: {
validate: (_value: unknown, args?: ValidationArguments) => {
const object = (args?.object ?? {}) as Record<string, unknown>;
const first = SEARCH_TEXT_FIELDS.find(
(field) => object[field] !== undefined,
);
if (first !== args?.property) return true;
const total = combinedSearchWords(object);
return total === null || total <= max;
},
},
},
options,
);
}
/**
* 검색 글 칸 하나 — `label` 은 검사 글의 이름(검색어 · 보낸 사람 …), `topic` 은 조사를 붙인 꼴(검색어는 · 보낸 사람은 …).
* 검사 글은 로그 · 응답에 나가지 않는다(응답은 늘 VAL_001 의 사전 문구 — 계약 머리말)
*/
export function SearchTextField(label: string, topic: string) {
return applyDecorators(
Transform(normalizeSearchText),
IsOptional(),
IsString({ message: `${label} 형식이 올바르지 않습니다.` }),
MaxCodeUnits(MAX_SEARCH_LENGTH, {
message: `${topic} ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
}),
MaxSearchWords(MAX_SEARCH_WORDS, {
message: `${topic} 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
}),
MaxCombinedSearchWords(MAX_SEARCH_WORDS, {
message: `검색어와 조건은 낱말을 모두 합쳐 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
}),
);
}
@@ -0,0 +1,176 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { MAX_HTML_MARKS, markHtmlText } from './html-mark';
import { sanitizeMailHtml } from './sanitize-mail-html';
const marked = (html: string, ...terms: string[]) =>
markHtmlText(html, terms).html;
const countMarks = (html: string) => html.split('<mark>').length - 1;
describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => {
it('글 조각의 맞은 곳에 <mark> — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => {
expect(marked('<p>계약 갱신 안내</p>', '계약')).toBe(
'<p><mark>계약</mark> 갱신 안내</p>',
);
expect(marked('<p>ERP 와 Erp 와 ERP</p>', 'erp')).toBe(
'<p><mark>ERP</mark> 와 <mark>Erp</mark> 와 <mark>ERP</mark></p>',
);
expect(markHtmlText('<p>계약 계약</p>', ['계약']).marks).toBe(2);
});
it('겹치거나 맞닿은 맞은 곳은 하나로 — 여러 말이 한 자리에 맞아도 <mark> 는 겹치지 않는다', () => {
expect(marked('<p>계약서 갱신</p>', '계약', '약서')).toBe(
'<p><mark>계약서</mark> 갱신</p>',
);
});
it('태그 안(이름 · 속성 · style · 주소)은 건드리지 않는다', () => {
const html =
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약">계약</a><img alt="계약" src="cid:x">';
expect(marked(html, '계약')).toBe(
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약"><mark>계약</mark></a><img alt="계약" src="cid:x">',
);
});
it('주석 · 글을 그대로 두는 요소(style · script · textarea · title)의 안은 그대로', () => {
expect(
marked(
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p>계약</p><!-- 닫히지 않은 계약',
'계약',
),
).toBe(
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p><mark>계약</mark></p><!-- 닫히지 않은 계약',
);
expect(marked('<SCRIPT>계약</Script >계약', '계약')).toBe(
'<SCRIPT>계약</Script ><mark>계약</mark>',
);
});
it('문자 참조를 풀어 맞추고 다시 적는다 — &amp; · &lt; · &gt; · &nbsp; · 10진 · 16진', () => {
expect(marked('<p>A&amp;B 계약 &lt;중요&gt;</p>', 'a&b', '<중요>')).toBe(
'<p><mark>A&amp;B</mark> 계약 <mark>&lt;중요&gt;</mark></p>',
);
// &nbsp; 는 띄어쓰기와 같이 접힌다 — 구절이 맞는다 (다시 적을 때는 글자 그대로)
expect(marked('<p>계약&nbsp;갱신</p>', '계약 갱신')).toBe(
'<p><mark>계약\u00a0갱신</mark></p>',
);
expect(marked('<p>&#44228;&#xC57D; 건</p>', '계약')).toBe(
'<p><mark>계약</mark> 건</p>',
);
// 맞은 곳이 없는 조각은 원래 글자 그대로 (참조를 풀어 다시 적지 않는다)
expect(marked('<p>&#44228; &quot;x&quot;</p>', '없는말')).toBe(
'<p>&#44228; &quot;x&quot;</p>',
);
});
it('모르는 참조 · 닫히지 않은 & 가 있는 조각은 그대로 둔다 (정화 결과에는 나오지 않는다)', () => {
expect(marked('<p>&copy; 계약</p>', '계약')).toBe('<p>&copy; 계약</p>');
expect(marked('<p>R&D 계약</p><p>계약</p>', '계약')).toBe(
'<p>R&D 계약</p><p><mark>계약</mark></p>',
);
});
it('줄바꿈 · 탭으로 갈린 구절도 맞는다 — 태그를 건너는 말은 강조하지 않는다', () => {
expect(marked('<p>계약\n갱신</p>', '계약 갱신')).toBe(
'<p><mark>계약\n갱신</mark></p>',
);
expect(marked('<p>계<b>약</b></p>', '계약')).toBe('<p>계<b>약</b></p>');
});
it('NFD 로 적은 글도 맞춘다 — 맞은 조각은 NFC 로 다시 적는다', () => {
const nfd = '계약 안내'.normalize('NFD');
expect(marked(`<p>${nfd}</p>`, '계약')).toBe(
'<p><mark>계약</mark> 안내</p>',
);
expect(marked(`<p>${nfd}</p>`, '없는말')).toBe(`<p>${nfd}</p>`);
});
it('강조할 말이 없거나 빈 본문이면 그대로', () => {
expect(markHtmlText('<p>계약</p>', [])).toEqual({
html: '<p>계약</p>',
marks: 0,
});
expect(markHtmlText('<p>계약</p>', ['', ' '])).toEqual({
html: '<p>계약</p>',
marks: 0,
});
expect(markHtmlText('', ['계약'])).toEqual({ html: '', marks: 0 });
});
it(`<mark> 는 ${MAX_HTML_MARKS}개까지 — 흔한 말이 수만 번 나와도`, () => {
const html = `<p>${'가 '.repeat(5_000)}</p><p>${'가 '.repeat(5_000)}</p>`;
const result = markHtmlText(html, ['가']);
expect(result.marks).toBe(MAX_HTML_MARKS);
expect(countMarks(result.html)).toBe(MAX_HTML_MARKS);
});
it('정화한 HTML 그대로 — 속성 값의 > · 따옴표 · 링크가 깨지지 않고, 넣은 <mark> 는 받은 메일 정화를 다시 지나도 남는다', () => {
const { html } = sanitizeMailHtml(
'<div title="a > 계약 &quot;b&quot;"><a href="https://example.com/?q=계약&x=1">계약 바로가기</a> 계약&amp;견적 <b>중요</b></div>',
);
const result = markHtmlText(html, ['계약']);
expect(result.marks).toBe(2);
// 속성은 정화 결과 그대로
expect(result.html).toContain('title="a &gt; 계약 &quot;b&quot;"');
expect(result.html).toContain('href="https://example.com/?q=계약&amp;x=1"');
expect(result.html).toContain('<mark>계약</mark> 바로가기');
expect(result.html).toContain('<mark>계약</mark>&amp;견적');
// <mark> 는 받은 메일 정화의 허용 태그다 — 다시 정화해도 같다(클라이언트가 그대로 그린다)
expect(sanitizeMailHtml(result.html).html).toBe(result.html);
});
it('깊게 중첩된 HTML 도 한 번 훑는다 — 길이에 비례 (CPU 시간)', () => {
const depth = 100_000;
const html = `${'<div>'.repeat(depth)}계약 안내${'</div>'.repeat(depth)}`;
const cpuMs = startCpuTimer();
const result = markHtmlText(html, ['계약']);
// 지금 30ms 안팎 — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(1_000);
expect(result.marks).toBe(1);
expect(result.html.length).toBe(html.length + '<mark></mark>'.length);
});
it('긴 글(2MB · 조각 수만 개) · 낱말 8개도 길이에 비례한다 (CPU 시간)', () => {
const paragraph = `<p>이번 분기 계약 갱신과 단가 조정 일정을 공유합니다 &amp; 확인 부탁드립니다.</p>`;
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
const terms = [
'계약',
'갱신',
'단가',
'조정',
'일정',
'공유',
'확인',
'"부탁드립니다."',
];
const cpuMs = startCpuTimer();
const result = markHtmlText(html, terms);
expect(cpuMs()).toBeLessThan(3_000);
expect(result.marks).toBe(MAX_HTML_MARKS);
});
it('맞는 말이 없는 2MB 도 한 번 훑고 원래 글 그대로 (CPU 시간 — 리뷰 L6)', () => {
const paragraph =
'<p>이번 분기 일정을 공유합니다 &amp; 확인 부탁드립니다.<br><b>굵게</b> <a href="https://example.com/a?b=1&amp;c=2">링크</a></p>';
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
const cpuMs = startCpuTimer();
const result = markHtmlText(html, ['없는말', '"계약 갱신"', 'zzz']);
// 지금 0.1초 안팎 — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(2_000);
expect(result).toEqual({ html, marks: 0 });
});
it('검색 글의 NUL(U+FFFD 로 바꾼 말)은 본문의 U+FFFD 에 맞는다 — 색인과 같은 글자 (리뷰 L2)', () => {
const FFFD = String.fromCharCode(0xfffd);
expect(marked(`<p>계${FFFD}약 건</p>`, `계${FFFD}약`)).toBe(
`<p><mark>계${FFFD}약</mark> 건</p>`,
);
});
});
+175
View File
@@ -0,0 +1,175 @@
import {
foldTerms,
foldText,
matchFoldedRanges,
} from '@/shared/utils/search-text';
/**
* 정화한 메일 HTML 의 글 조각에 `<mark>` 를 넣는다 (순수 함수 — 14단계 설계 C2, 읽기 화면의 검색 강조).
* **정화한 뒤에만** 부른다(sanitize-mail-html.ts 의 결과 — 태그가 닫혀 있고 속성 값의 `>` 는 `&gt;` 다). 다시 정화하지 않는다 — 넣는 것은 속성 없는 `<mark>` 뿐이다
* (mark 는 받은 메일 정화의 허용 태그다).
*
* - 태그 안(이름 · 속성 · style)은 건드리지 않는다 — `<` 부터 `>` 까지는 그대로 옮긴다. 주석(`<!-- … -->`)과 글을 그대로 두어야 하는 요소
* (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로
* - 글 조각마다 문자 참조를 풀어(&amp; · &lt; · &gt; · &quot; · &apos; · &nbsp; · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다
* (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(&amp; · &lt; · &gt; 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다
* - 태그를 건너는 말(`계<b>약</b>`)은 강조하지 않는다 — 글 조각 안에서만
* - 넣는 `<mark>` 는 `limit`(MAX_HTML_MARKS)개까지
* 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다)
*/
/** 한 본문에 넣는 `<mark>` 의 수 */
export const MAX_HTML_MARKS = 1_000;
/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */
const MAX_REFERENCE_LENGTH = 32;
/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 &amp; · &lt; · &gt; 만 다시 적는다)과 흔한 것 */
const NAMED_REFERENCES: Readonly<Record<string, string>> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: '\u00a0',
};
/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */
const RAW_TEXT_TAGS: ReadonlySet<string> = new Set([
'script',
'style',
'textarea',
'title',
'xmp',
]);
const COMMENT_OPEN = '<!--';
const COMMENT_CLOSE = '-->';
const MAX_CODE_POINT = 0x10ffff;
const REPLACEMENT_CHARACTER = '\ufffd';
/** 태그 이름 (`<style …>` → style, 닫는 태그 · 이름 없음은 null) — 소문자 */
const OPEN_TAG_NAME = /^<([A-Za-z][A-Za-z0-9-]*)/;
function decodeReference(reference: string): string | null {
if (reference.startsWith('#')) {
const hex = reference[1] === 'x' || reference[1] === 'X';
const digits = reference.slice(hex ? 2 : 1);
if (!(hex ? /^[0-9A-Fa-f]{1,8}$/ : /^[0-9]{1,8}$/).test(digits)) {
return null;
}
const point = Number.parseInt(digits, hex ? 16 : 10);
if (
point <= 0 ||
point > MAX_CODE_POINT ||
(point >= 0xd800 && point <= 0xdfff)
) {
return REPLACEMENT_CHARACTER;
}
return String.fromCodePoint(point);
}
return NAMED_REFERENCES[reference] ?? null;
}
/** 글 조각의 문자 참조를 푼다 — 모르는 참조 · 닫히지 않은 `&` 가 있으면 null(그 조각은 그대로 둔다) */
function decodeText(text: string): string | null {
if (!text.includes('&')) return text;
let decoded = '';
let index = 0;
for (;;) {
const amp = text.indexOf('&', index);
if (amp < 0) return decoded + text.slice(index);
decoded += text.slice(index, amp);
const limit = Math.min(text.length, amp + 1 + MAX_REFERENCE_LENGTH + 1);
const semi = text.indexOf(';', amp + 1);
if (semi < 0 || semi >= limit) return null;
const value = decodeReference(text.slice(amp + 1, semi));
if (value === null) return null;
decoded += value;
index = semi + 1;
}
}
/** 글을 HTML 글 조각으로 — `&` · `<` · `>` 만 적는다 */
function escapeText(text: string): string {
return text.replace(/[&<>]/g, (char) =>
char === '&' ? '&amp;' : char === '<' ? '&lt;' : '&gt;',
);
}
/** 글 조각 하나 — 맞은 곳에 `<mark>` 를 넣은 조각과 넣은 수 (맞은 곳이 없으면 원래 조각 그대로) */
function markText(
source: string,
terms: readonly string[],
limit: number,
): { readonly html: string; readonly marks: number } {
const decoded = decodeText(source);
if (decoded === null) return { html: source, marks: 0 };
const text = decoded.normalize('NFC');
const ranges = matchFoldedRanges(foldText(text), terms, limit);
if (ranges.length === 0) return { html: source, marks: 0 };
let html = '';
let last = 0;
for (const [start, end] of ranges) {
html += `${escapeText(text.slice(last, start))}<mark>${escapeText(text.slice(start, end))}</mark>`;
last = end;
}
return { html: html + escapeText(text.slice(last)), marks: ranges.length };
}
/** `name` 의 닫는 태그 자리 (대소문자 없이) — 없으면 끝 */
function closingTagAt(html: string, name: string, from: number): number {
const pattern = new RegExp(`</${name}[\\s/>]`, 'gi');
pattern.lastIndex = from;
const found = pattern.exec(html);
return found ? found.index : html.length;
}
export function markHtmlText(
html: string,
terms: readonly string[],
limit: number = MAX_HTML_MARKS,
): { readonly html: string; readonly marks: number } {
const folded = foldTerms(terms);
if (folded.length === 0 || html === '') return { html, marks: 0 };
const out: string[] = [];
let marks = 0;
let index = 0;
while (index < html.length) {
const lt = html.indexOf('<', index);
const textEnd = lt < 0 ? html.length : lt;
if (textEnd > index) {
const text = html.slice(index, textEnd);
if (marks < limit) {
const marked = markText(text, folded, limit - marks);
out.push(marked.html);
marks += marked.marks;
} else {
out.push(text);
}
}
if (lt < 0) break;
if (html.startsWith(COMMENT_OPEN, lt)) {
const close = html.indexOf(COMMENT_CLOSE, lt + COMMENT_OPEN.length);
const end = close < 0 ? html.length : close + COMMENT_CLOSE.length;
out.push(html.slice(lt, end));
index = end;
continue;
}
const gt = html.indexOf('>', lt + 1);
if (gt < 0) {
out.push(html.slice(lt));
break;
}
const tag = html.slice(lt, gt + 1);
out.push(tag);
index = gt + 1;
const name = OPEN_TAG_NAME.exec(tag)?.[1].toLowerCase();
if (name !== undefined && RAW_TEXT_TAGS.has(name)) {
const close = closingTagAt(html, name, index);
out.push(html.slice(index, close));
index = close;
}
}
return { html: out.join(''), marks };
}
@@ -0,0 +1,909 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { firstSender } from '@/modules/mail-search/index-document';
import {
CONVERSATION_ENGINE_LIST_SCAN_MAX,
CONVERSATION_ENGINE_SCAN_MAX,
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
CONVERSATION_MESSAGE_IDS_MAX,
INDEX_FROM_NAME_MAX_CHARS,
} from '@/modules/mail-search/mail-search.constants';
import type { JmapClient } from './jmap.client';
import { JmapConversations, shareScan } from './jmap-conversations';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import type { JmapInvocation } from './jmap.types';
const ARA = 'ara.kim@arasystem.test';
/** 내보낼 메일만 읽어야 하는 요약 속성의 표 — 미리 보기 */
const SUMMARY_ONLY = 'preview';
const FUTURE = '2099-01-01T00:00:00Z';
/** 호출 한 번이 시작될 때 — 손으로 돌리는 시계를 넘기거나, Error 를 돌려주면 그 호출을 그 오류로 끝낸다 */
type CallHook = (
index: number,
options: { timeoutMs?: number } | undefined,
) => unknown;
/** 엔진 대역 — 받은 호출을 적고 준비한 응답을 차례로 */
function fakeClient(responses: JmapInvocation[][], onCall?: CallHook) {
const recorded: {
calls: JmapInvocation[];
options: { timeoutMs?: number } | undefined;
}[] = [];
const queue = [...responses];
const call = jest.fn(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options?: { timeoutMs?: number },
) => {
recorded.push({ calls: build('d'), options });
const failure = onCall?.(recorded.length - 1, options);
if (failure instanceof Error) return Promise.reject(failure);
const next = queue.shift();
if (!next) return Promise.reject(new Error('준비한 응답이 없습니다'));
return Promise.resolve(next);
},
);
return {
recorded,
call,
reader: new JmapConversations({ call } as unknown as JmapClient),
};
}
const MAILBOX_LIST = [
{ id: 'a', role: 'inbox' },
{ id: 'b', role: 'trash' },
{ id: 'c', role: 'junk' },
{ id: 'e', role: 'sent' },
];
const mailboxes: JmapInvocation = ['Mailbox/get', { list: MAILBOX_LIST }, 'm'];
/** 메일 하나 (엔진이 주는 꼴 — 요청한 속성과 상관없이 다 싣는다) */
function email(
id: string,
receivedAt: string,
extra: Record<string, unknown> = {},
) {
return {
id,
threadId: 't1',
mailboxIds: { a: true },
keywords: { $seen: true },
from: [{ name: `보낸 ${id}`, email: `${id}@example.com` }],
to: [{ name: null, email: ARA }],
subject: `제목 ${id}`,
preview: '미리 보기',
receivedAt,
attachments: [],
size: 100,
...extra,
};
}
const noHeld: JmapInvocation[] = [
['EmailSubmission/query', { ids: [], total: 0, position: 0 }, 'pq'],
['EmailSubmission/get', { list: [] }, 'pg'],
];
/** 한 요청에서 그 호출 id 의 인자 */
function argsOf(calls: JmapInvocation[], id: string): Record<string, unknown> {
return calls.find(([, , callId]) => callId === id)?.[1] ?? {};
}
/** 그 요청의 Email/get 이 요약 속성(미리 보기)을 읽었나 */
function readsSummary(calls: JmapInvocation[]): boolean {
return calls.some(
([method, args]) =>
method === 'Email/get' &&
Array.isArray(args.properties) &&
(args.properties as string[]).includes(SUMMARY_ONLY),
);
}
/** 그 요청의 Email/get 이 묻는 id (back-reference 면 빈 배열) */
function idsOf(calls: JmapInvocation[]): string[] {
return calls.flatMap(([method, args]) =>
method === 'Email/get' && Array.isArray(args.ids)
? (args.ids as string[])
: [],
);
}
interface FakeMail {
readonly id: string;
readonly threadId: string;
readonly receivedAt: string;
readonly mailboxIds: Record<string, boolean>;
readonly keywords?: Record<string, boolean>;
}
/**
* 메일을 들고 요청마다 답하는 엔진 대역 (큰 스레드 — 3차 리뷰 H1). Email/get · Thread/get · Mailbox/get · collapseThreads 의 Email/query ·
* 제출 조회를 안다(back-reference 는 /ids · /list/[*]/threadId 만)
*/
function fakeEngine(mails: readonly FakeMail[], onCall?: CallHook) {
const byId = new Map(mails.map((mail) => [mail.id, mail]));
const threads = new Map<string, string[]>();
for (const mail of [...mails].sort((a, b) =>
a.receivedAt.localeCompare(b.receivedAt),
)) {
threads.set(mail.threadId, [
...(threads.get(mail.threadId) ?? []),
mail.id,
]);
}
const requests: JmapInvocation[][] = [];
/** 호출마다 받은 제한 시간 */
const timeouts: (number | undefined)[] = [];
const full = (mail: FakeMail) => email(mail.id, mail.receivedAt, { ...mail });
const call = jest.fn(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options?: { timeoutMs?: number },
) => {
const invocations = build('d');
requests.push(invocations);
timeouts.push(options?.timeoutMs);
const failure = onCall?.(requests.length - 1, options);
if (failure instanceof Error) return Promise.reject(failure);
const results = new Map<string, Record<string, unknown>>();
const resolve = (args: Record<string, unknown>): string[] => {
const ref = args['#ids'] as
| { resultOf: string; path: string }
| undefined;
if (!ref) return (args.ids as string[] | null) ?? [];
const source = results.get(ref.resultOf) ?? {};
if (ref.path === '/ids') return (source.ids as string[]) ?? [];
return ((source.list as { threadId: string }[]) ?? []).map(
(item) => item.threadId,
);
};
const responses: JmapInvocation[] = invocations.map(
([method, args, id]) => {
let result: Record<string, unknown>;
if (method === 'Email/get') {
const ids = resolve(args);
result = {
list: ids.flatMap((emailId) => {
const mail = byId.get(emailId);
return mail ? [full(mail)] : [];
}),
notFound: ids.filter((emailId) => !byId.has(emailId)),
};
} else if (method === 'Thread/get') {
result = {
list: resolve(args).map((threadId) => ({
id: threadId,
emailIds: threads.get(threadId) ?? [],
})),
};
} else if (method === 'Mailbox/get') {
result = { list: MAILBOX_LIST };
} else if (method === 'Email/query') {
const filter = args.filter as { inMailbox: string };
const seen = new Set<string>();
const ids = [...mails]
.filter((mail) => mail.mailboxIds[filter.inMailbox])
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
.filter((mail) => {
if (seen.has(mail.threadId)) return false;
seen.add(mail.threadId);
return true;
})
.map((mail) => mail.id);
const position = (args.position as number | undefined) ?? 0;
result = {
ids: ids.slice(position, position + (args.limit as number)),
position,
total: ids.length,
};
} else if (method === 'EmailSubmission/query') {
result = { ids: [], total: 0, position: 0 };
} else {
result = { list: [] };
}
results.set(id, result);
return [method, result, id];
},
);
return Promise.resolve(responses);
},
);
return {
requests,
timeouts,
call,
reader: new JmapConversations({ call } as unknown as JmapClient),
};
}
/** 받은 시각 — n 초 뒤 (차례가 id 차례와 같게) */
const second = (n: number) =>
new Date(Date.UTC(2026, 10, 1) + n * 1000).toISOString();
describe('엔진의 스레드로 본 대화 (JmapConversations — 14단계, 색인 전)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
describe('같은 대화 (conversation)', () => {
const lookup = (emailIds: string[]): JmapInvocation[] => [
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
['Thread/get', { list: [{ id: 't1', emailIds }] }, 't'],
mailboxes,
];
it('그 메일의 스레드 → 값싼 속성으로 걸러 오래된 차례 — 휴지통 · 스팸에만 든 메일 · 내부 초안은 빼되 지금 메일은 넣고, 요약 속성은 내보낼 메일만', async () => {
const { reader, recorded } = fakeClient([
lookup(['m1', 'm2', 'm3', 'm4', 'm5']),
[
[
'Email/get',
{
list: [
email('m1', '2026-11-01T00:00:00Z', {
mailboxIds: { e: true },
}),
email('m2', '2026-11-02T00:00:00Z', {
mailboxIds: { c: true },
}),
email('m3', '2026-11-03T00:00:00Z', {
mailboxIds: { b: true },
}),
email('m4', '2026-11-04T00:00:00Z', {
keywords: { $draft: true, '$ara-sending': true },
}),
email('m5', '2026-11-05T00:00:00Z', {
mailboxIds: { b: true, a: true },
}),
],
},
'g',
],
],
[
[
'Email/get',
{
list: ['m1', 'm2', 'm5'].map((id, index) =>
email(id, `2026-11-0${index + 1}T00:00:00Z`),
),
},
'g',
],
...noHeld,
],
]);
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
// m3(휴지통에만) · m4(내부 초안) 빠짐, m2(스팸이지만 지금 메일) · m5(휴지통과 받은편지함) 듦
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm5']);
expect(page.total).toBe(3);
expect(page.hasNext).toBe(false);
expect(recorded[0].options).toEqual({
invalidRequestCode: 'RES_001',
timeoutMs: expect.any(Number) as number,
});
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'Email/get',
'Thread/get',
'Mailbox/get',
]);
// 거르기는 값싼 속성만, 요약은 내보낼 세 통만 (기다리는 발송과 함께)
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
'id',
'mailboxIds',
'keywords',
'receivedAt',
]);
expect(readsSummary(recorded[1].calls)).toBe(false);
expect(idsOf(recorded[2].calls)).toEqual(['m1', 'm2', 'm5']);
expect(recorded[2].calls.map(([method]) => method)).toEqual([
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
]);
});
it('쪽을 넘긴다 — 커서는 그 메일의 대화에서만(다른 메일의 커서는 엔진에 묻기 전에 VAL_001), 스레드 안 위치 · 마지막 메일이 빠졌으면 위치로', async () => {
const cheap: JmapInvocation[] = [
[
'Email/get',
{
list: ['m1', 'm2', 'm3'].map((id, index) =>
email(id, `2026-11-0${index + 1}T00:00:00Z`),
),
},
'g',
],
];
const summaries = (ids: string[]): JmapInvocation[] => [
[
'Email/get',
{ list: ids.map((id) => email(id, '2026-11-01T00:00:00Z')) },
'g',
],
...noHeld,
];
const first = fakeClient([
lookup(['m1', 'm2', 'm3']),
cheap,
summaries(['m1', 'm2']),
]);
const page = await first.reader.conversation(ARA, 'm2', { limit: 2 });
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
expect(page.hasNext).toBe(true);
expect(page.total).toBe(3);
expect(
decodeCursor(page.nextCursor!, { conditions: ['conversation', 'm2'] }),
).toEqual({ anchor: 'm2', position: 2 });
const next = fakeClient([
lookup(['m1', 'm2', 'm3']),
cheap,
summaries(['m3']),
]);
const second = await next.reader.conversation(ARA, 'm2', {
limit: 2,
cursor: page.nextCursor!,
});
expect(second.items.map((item) => item.id)).toEqual(['m3']);
expect(second.hasNext).toBe(false);
expect(idsOf(next.recorded[2].calls)).toEqual(['m3']);
const other = fakeClient([]);
await expect(
other.reader.conversation(ARA, 'm9', {
limit: 2,
cursor: page.nextCursor!,
}),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(other.call).not.toHaveBeenCalled();
const gone = encodeCursor(
{ anchor: 'm-gone', position: 1 },
{ conditions: ['conversation', 'm2'] },
);
const resumed = fakeClient([
lookup(['m1', 'm2', 'm3']),
cheap,
summaries(['m2', 'm3']),
]);
const fromPosition = await resumed.reader.conversation(ARA, 'm2', {
limit: 5,
cursor: gone,
});
expect(fromPosition.items.map((item) => item.id)).toEqual(['m2', 'm3']);
});
it('그 메일이 없으면(남의 메일 · 지움) 404 RES_001', async () => {
const { reader } = fakeClient([
[
['Email/get', { list: [], notFound: ['zz'] }, 'e'],
['Thread/get', { list: [] }, 't'],
mailboxes,
],
]);
const error = await reader
.conversation(ARA, 'zz', { limit: 100 })
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).code).toBe('RES_001');
});
it('메일함 역할을 읽지 못하면 빼지 않고 이어 간다 — 경고는 코드 · 가린 주소만 (리뷰 L5)', async () => {
const { reader } = fakeClient([
[
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
['Thread/get', { list: [{ id: 't1', emailIds: ['m2'] }] }, 't'],
['error', { type: 'serverFail' }, 'm'],
],
[['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g']],
[
['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g'],
// 기다리는 제출을 다 주지 못했다 — 목록과 같은 경고
[
'EmailSubmission/query',
{ ids: ['s1'], total: 600, position: 0 },
'pq',
],
['EmailSubmission/get', { list: [] }, 'pg'],
],
]);
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
expect(page.items.map((item) => item.id)).toEqual(['m2']);
const lines = warnSpy.mock.calls.map(([line]) => String(line));
expect(lines).toHaveLength(2);
for (const line of lines) {
expect(line).toMatch(/^\[MAIL_001\]/);
expect(line).toContain('user=a***@arasystem.test');
expect(line).not.toContain(ARA);
}
});
it('2,000통짜리 스레드 — 거르기는 커서 자리부터 상한까지만, 요약 속성은 한 쪽(100통)만 · total 은 null (리뷰 H1)', async () => {
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
id: `m${String(index).padStart(4, '0')}`,
threadId: 't1',
receivedAt: second(index),
mailboxIds: { a: true },
}));
const engine = fakeEngine(mails);
const page = await engine.reader.conversation(ARA, 'm1999', {
limit: 100,
});
expect(page.items).toHaveLength(100);
expect(page.items[0].id).toBe('m0000');
expect(page.total).toBeNull();
expect(page.hasNext).toBe(true);
expect(
decodeCursor(page.nextCursor!, {
conditions: ['conversation', 'm1999'],
}),
).toEqual({ anchor: 'm0099', position: 100 });
// 엔진 요청: 찾기 · 거르기 한 번(500통) · 요약 한 번
expect(engine.call).toHaveBeenCalledTimes(3);
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
expect(summaryIds).toHaveLength(100);
const scanned = engine.requests
.filter((request) => !readsSummary(request))
.flatMap(idsOf)
.filter((id) => id !== 'm1999');
expect(scanned.length).toBeLessThanOrEqual(CONVERSATION_ENGINE_SCAN_MAX);
});
it('거를 메일이 많은 큰 스레드 — 한 요청은 상한까지만 훑고 빈 쪽이어도 이어 갈 자리를 준다, 다음 요청에서 지금 메일이 든다', async () => {
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
id: `m${String(index).padStart(4, '0')}`,
threadId: 't1',
receivedAt: second(index),
// 지금 메일(맨 끝) 말고는 모두 휴지통에만
mailboxIds: { [index === 1_999 ? 'a' : 'b']: true },
}));
const engine = fakeEngine(mails);
const page = await engine.reader.conversation(ARA, 'm1999', {
limit: 100,
});
expect(page.items).toEqual([]);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
expect(
decodeCursor(page.nextCursor!, {
conditions: ['conversation', 'm1999'],
}),
).toEqual({
anchor: 'm0999',
position: CONVERSATION_ENGINE_SCAN_MAX,
});
// 찾기 · 거르기 두 번(500 + 500) — 요약은 읽지 않는다
expect(engine.call).toHaveBeenCalledTimes(3);
expect(engine.requests.some(readsSummary)).toBe(false);
const next = await engine.reader.conversation(ARA, 'm1999', {
limit: 100,
cursor: page.nextCursor!,
});
expect(next.items.map((item) => item.id)).toEqual(['m1999']);
expect(next.hasNext).toBe(false);
});
});
describe('묶은 목록 (list)', () => {
it('collapseThreads 로 그 메일함 안 스레드마다 마지막 메일(요약은 이것만) → 스레드의 나머지 메일을 값싼 속성으로 — 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일 (리뷰 L6)', async () => {
const { reader, recorded } = fakeClient([
[
mailboxes,
['Email/query', { ids: ['m3', 'x1'], position: 0, total: 2 }, 'q'],
[
'Email/get',
{
list: [
email('m3', '2026-11-03T00:00:00Z', {
keywords: {},
threadId: 't1',
}),
email('x1', '2026-11-02T00:00:00Z', { threadId: 't2' }),
],
},
'g',
],
[
'Thread/get',
{
list: [
{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] },
{ id: 't2', emailIds: ['x1'] },
],
},
't',
],
[
'EmailSubmission/query',
{ ids: ['s1'], total: 1, position: 0 },
'pq',
],
[
'EmailSubmission/get',
{
list: [
{
id: 's1',
emailId: 'm2',
undoStatus: 'pending',
sendAt: FUTURE,
},
],
},
'pg',
],
],
[
[
'Email/get',
{
list: [
email('m1', '2026-11-01T00:00:00Z', {
mailboxIds: { e: true },
from: [{ name: '김아라', email: ARA }],
}),
email('m2', '2026-11-02T00:00:00Z', {
attachments: [
{
type: 'application/pdf',
disposition: 'attachment',
size: 10,
},
],
}),
email('m4', '2026-11-04T00:00:00Z', {
mailboxIds: { b: true },
}),
],
},
'g',
],
],
]);
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
expect(argsOf(recorded[0].calls, 'q')).toMatchObject({
filter: { inMailbox: 'a', notKeyword: '$ara-sending' },
collapseThreads: true,
calculateTotal: true,
sort: [{ property: 'receivedAt', isAscending: false }],
});
// 마지막 메일(m3 · x1)은 다시 읽지 않는다 — 나머지는 값싼 속성 + 보낸 사람 · 첨부 부분
expect(recorded).toHaveLength(2);
expect(idsOf(recorded[1].calls)).toEqual(['m1', 'm2', 'm4']);
expect(readsSummary(recorded[1].calls)).toBe(false);
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
'id',
'mailboxIds',
'keywords',
'receivedAt',
'from',
'attachments',
]);
expect(page.total).toBe(2);
const [thread, single] = page.items;
expect(thread).toMatchObject({
id: 't1',
messageCount: 3, // m4 는 휴지통에만
messageIds: ['m3', 'm2'], // 받은편지함 안 — 최신부터
pendingSendIds: ['m2'],
isRead: false,
isFlagged: false,
hasAttachment: true,
});
expect(thread.latest.id).toBe('m3');
expect(thread.participants.map((p) => p.email)).toEqual([
ARA,
'm2@example.com',
'm3@example.com',
]);
expect(single).toMatchObject({
id: 't2',
messageCount: 1,
messageIds: ['x1'],
pendingSendIds: [],
isRead: true,
});
});
it('메일함 목록을 엔진이 거절하면(메서드 오류) 503 MAIL_001 — 없는 메일함 404 와 가른다 (리뷰 M1)', async () => {
const { reader } = fakeClient([
[
['error', { type: 'serverFail' }, 'm'],
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
['Email/get', { list: [] }, 'g'],
['Thread/get', { list: [] }, 't'],
...noHeld,
],
]);
await expect(
reader.list(ARA, { mailboxId: 'a', limit: 20 }),
).rejects.toMatchObject({ code: 'MAIL_001' });
});
it('없는 메일함이면 404 RES_001 · 커서는 그 메일함의 묶은 목록에서만', async () => {
const missing = fakeClient([
[
mailboxes,
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
['Email/get', { list: [] }, 'g'],
['Thread/get', { list: [] }, 't'],
...noHeld,
],
]);
await expect(
missing.reader.list(ARA, { mailboxId: 'zz', limit: 20 }),
).rejects.toMatchObject({ code: 'RES_001' });
const cursor = encodeCursor(
{ anchor: 'm3', position: 20 },
{ mailboxId: 'a', conditions: ['conversations'] },
);
const other = fakeClient([]);
for (const query of [
{ mailboxId: 'e', limit: 20, cursor },
// 메일 목록의 커서는 쓸 수 없다
{
mailboxId: 'a',
limit: 20,
cursor: encodeCursor(
{ anchor: 'm3', position: 20 },
{ mailboxId: 'a' },
),
},
]) {
await expect(other.reader.list(ARA, query)).rejects.toMatchObject({
code: 'VAL_001',
});
}
expect(other.call).not.toHaveBeenCalled();
});
it('2,000통짜리 스레드가 든 쪽 — 스레드마다 최신 상한까지 · 요약 속성은 줄마다 마지막 메일만, 상한에 걸린 줄의 통 수는 읽지 못한 메일을 거르지 않고 더한다 (리뷰 H1)', async () => {
const big: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
id: `g${String(index).padStart(4, '0')}`,
threadId: 'tg',
receivedAt: second(index),
// 오래된 절반은 휴지통에만 — 읽지 못한 쪽이라 통 수에는 그대로 든다
mailboxIds: { [index < 1_000 ? 'b' : 'a']: true },
}));
const small: FakeMail[] = ['s1', 's2'].map((id, index) => ({
id,
threadId: 'ts',
receivedAt: second(5_000 + index),
mailboxIds: { a: true },
}));
const engine = fakeEngine([...big, ...small]);
const page = await engine.reader.list(ARA, {
mailboxId: 'a',
limit: 20,
});
expect(page.items.map((row) => row.id)).toEqual(['ts', 'tg']);
// 엔진 요청: 목록 한 번 · 스레드 메일 한 번
expect(engine.call).toHaveBeenCalledTimes(2);
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
expect(summaryIds).toEqual([]); // 마지막 메일은 back-reference 로 — 다시 읽지 않는다
const memberIds = engine.requests.slice(1).flatMap(idsOf);
expect(memberIds).toHaveLength(
CONVERSATION_ENGINE_THREAD_SCAN_MAX - 1 + 1,
);
expect(memberIds.length).toBeLessThanOrEqual(
CONVERSATION_ENGINE_LIST_SCAN_MAX,
);
const [smallRow, bigRow] = page.items;
expect(smallRow).toMatchObject({
messageCount: 2,
messageIds: ['s2', 's1'],
});
// 최신 500통을 읽었고(모두 받은편지함) 오래된 1,500통은 읽지 않고 센다
expect(bigRow.messageCount).toBe(2_000);
expect(bigRow.messageIds).toHaveLength(
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
);
expect(bigRow.messageIds[0]).toBe('g1999');
expect(bigRow.messageIds.length).toBeLessThanOrEqual(
CONVERSATION_MESSAGE_IDS_MAX,
);
});
});
describe('한 요청의 시간 (3차 재확인 N1 · N2 — Date.now 를 손으로 돌린다)', () => {
const START = Date.UTC(2026, 10, 6, 1, 0, 0);
let clock = START;
beforeEach(() => {
clock = START;
jest.spyOn(Date, 'now').mockImplementation(() => clock);
});
/** 스레드 하나 — `mailbox(i)` 메일함에 든 메일 `count` 통 (받은 시각 차례) */
const thread = (
count: number,
mailbox: (index: number) => string = () => 'a',
): FakeMail[] =>
Array.from({ length: count }, (_, index) => ({
id: `m${String(index).padStart(4, '0')}`,
threadId: 't1',
receivedAt: second(index),
mailboxIds: { [mailbox(index)]: true },
}));
it('1,000통 이하 스레드를 시간 안에 다 읽지 못하면 503 MAIL_001 — 요약은 읽지 않는다', async () => {
// 찾기 7초 · 첫 묶음은 제한 시간(5초 — 남은 8초에서 요약 몫 3초를 뺀)을 다 쓰고 돌아온다 → 남은 3초는 더 훑지 않는다
const engine = fakeEngine(thread(800), (index, options) => {
clock += index === 0 ? 7_000 : (options?.timeoutMs ?? 0);
});
await expect(
engine.reader.conversation(ARA, 'm0799', { limit: 100 }),
).rejects.toMatchObject({ code: 'MAIL_001' });
expect(engine.timeouts).toEqual([10_000, 5_000]);
expect(engine.requests.some(readsSummary)).toBe(false);
});
it('1,000통이 넘는 스레드에서 한 묶음 뒤 시간이 끝나면 읽은 데까지 답하고 앞으로 간 커서를 준다 — 요약 읽기는 남은 3초를 받는다', async () => {
// 앞 세 통만 받은편지함, 나머지는 휴지통에만(지금 메일은 맨 끝) — 첫 묶음으로는 쪽이 차지 않는다
const engine = fakeEngine(
thread(2_000, (index) => (index < 3 || index === 1_999 ? 'a' : 'b')),
(index, options) => {
clock += index === 0 ? 2_000 : (options?.timeoutMs ?? 0);
},
);
const page = await engine.reader.conversation(ARA, 'm1999', {
limit: 100,
});
expect(page.items.map((item) => item.id)).toEqual([
'm0000',
'm0001',
'm0002',
]);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
expect(
decodeCursor(page.nextCursor!, {
conditions: ['conversation', 'm1999'],
}),
).toEqual({ anchor: 'm0499', position: 500 });
// 찾기 10초 · 묶음 10초(남은 13초 − 3초) · 요약 3초(남은 시간을 다)
expect(engine.timeouts).toEqual([10_000, 10_000, 3_000]);
});
it('훑는 호출은 마지막 요약 읽기 몫을 빼고 받는다 — 3.5초 남아 시작한 느린 묶음 뒤에도 요약 읽기가 3초를 받는다 (N1)', async () => {
const engine = fakeEngine(thread(2_000), (index, options) => {
clock += index === 0 ? 11_500 : (options?.timeoutMs ?? 0);
});
const page = await engine.reader.conversation(ARA, 'm1999', {
limit: 100,
});
expect(page.items).toHaveLength(100);
// 남은 3.5초에서 시작한 묶음은 0.5초만 받고(전에는 3.5초를 받아 요약 읽기가 1ms 였다), 요약 읽기는 3초
expect(engine.timeouts).toEqual([10_000, 500, 3_000]);
});
describe('묶은 목록 — 시간이 끝나면 어림 줄로 답한다(503 이 아니다)', () => {
const first: JmapInvocation[] = [
mailboxes,
['Email/query', { ids: ['m3'], position: 0, total: 1 }, 'q'],
[
'Email/get',
{ list: [email('m3', '2026-11-03T00:00:00Z', { keywords: {} })] },
'g',
],
[
'Thread/get',
{ list: [{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] }] },
't',
],
...noHeld,
];
/** 스레드의 다른 메일을 읽지 못한 줄 — 통 수는 읽지 못한 세 통을 거르지 않고 더하고, 나머지는 마지막 메일로만 */
const approximate = {
id: 't1',
messageCount: 4,
messageIds: ['m3'],
pendingSendIds: [],
participants: [{ name: '보낸 m3', email: 'm3@example.com' }],
isRead: false,
isFlagged: false,
hasAttachment: false,
};
it('첫 요청이 늦어 남은 시간이 요약 몫뿐이면 스레드 메일을 읽지 않는다', async () => {
const { reader, recorded } = fakeClient([first], () => {
clock += 13_000;
});
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
expect(recorded).toHaveLength(1);
expect(recorded[0].options?.timeoutMs).toBe(10_000);
expect(page.items).toHaveLength(1);
expect(page.items[0]).toMatchObject(approximate);
});
it('스레드 메일 읽기가 시간 때문에 끊기면 그 메일은 읽지 못한 것으로 — 시간이 남았는데 엔진이 실패하면 그대로 503', async () => {
const cut = fakeClient([first], (index, options) => {
if (index === 0) {
clock += 2_000;
return undefined;
}
// 제한 시간(남은 13초 − 요약 몫 3초)을 다 쓰고 끊겼다
clock += options?.timeoutMs ?? 0;
return new BusinessException('MAIL_001', {
context: { reason: 'timeout' },
});
});
const page = await cut.reader.list(ARA, { mailboxId: 'a', limit: 20 });
expect(cut.recorded).toHaveLength(2);
expect(cut.recorded[1].options?.timeoutMs).toBe(10_000);
expect(page.items[0]).toMatchObject(approximate);
const broken = fakeClient([first], (index) =>
index === 0 ? undefined : new BusinessException('MAIL_001'),
);
await expect(
broken.reader.list(ARA, { mailboxId: 'a', limit: 20 }),
).rejects.toMatchObject({ code: 'MAIL_001' });
});
});
});
it('엔진 방식 참여자의 이름도 색인과 같은 상한 · 같은 자르기 (INDEX_FROM_NAME_MAX_CHARS 코드 포인트 — N3)', async () => {
const longName = `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀 끝`;
const { reader } = fakeClient([
[
mailboxes,
['Email/query', { ids: ['x1'], position: 0, total: 1 }, 'q'],
[
'Email/get',
{
list: [
email('x1', '2026-11-02T00:00:00Z', {
threadId: 't2',
from: [{ name: longName, email: 'long@example.com' }],
}),
],
},
'g',
],
['Thread/get', { list: [{ id: 't2', emailIds: ['x1'] }] }, 't'],
...noHeld,
],
]);
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
const [participant] = page.items[0].participants;
expect([...(participant.name ?? '')]).toHaveLength(
INDEX_FROM_NAME_MAX_CHARS,
);
expect(participant.name).toBe(
firstSender([{ name: longName, email: 'long@example.com' }]).name,
);
expect(participant.name?.endsWith('😀')).toBe(true);
// 목록의 마지막 메일 요약(보낸 사람)은 자르지 않는다
expect(page.items[0].latest.from?.name).toBe(longName);
});
it('읽을 몫 나누기 — 작은 스레드는 다 주고 남는 몫을 큰 스레드끼리 똑같이 (모두 상한 안)', () => {
expect(shareScan([3, 500, 1, 500], 600)).toEqual([3, 298, 1, 298]);
expect(shareScan([2, 2], 2_000)).toEqual([2, 2]);
expect(shareScan([500, 500, 500, 500, 500], 2_000)).toEqual([
400, 400, 400, 400, 400,
]);
expect(shareScan([], 2_000)).toEqual([]);
});
});
@@ -0,0 +1,622 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
CONVERSATION_ENGINE_BUDGET_MS,
CONVERSATION_ENGINE_LIST_SCAN_MAX,
CONVERSATION_ENGINE_RESERVE_MS,
CONVERSATION_ENGINE_SCAN_MAX,
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
CONVERSATION_MESSAGE_IDS_MAX,
CONVERSATION_PARTICIPANTS_MAX,
} from '@/modules/mail-search/mail-search.constants';
import { participantName } from '@/modules/mail-search/index-document';
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
import type {
ConversationListQuery,
ConversationSummary,
ConversationSummaryPage,
MailAddress,
MessagePage,
} from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
JMAP_REQUEST_TIMEOUT_MS,
JmapClient,
type JmapCallOptions,
} from './jmap.client';
import { decodeCursor, encodeCursor, type CursorScope } from './jmap-cursor';
import { expectResult, findResult } from './jmap-results';
import {
SUMMARY_BODY_PART_PROPERTIES,
SUMMARY_PROPERTIES,
toAddress,
toSummary,
} from './jmap.mapper';
import type {
JmapEmail,
JmapGetResult,
JmapInvocation,
JmapMailbox,
JmapQueryResult,
} from './jmap.types';
import { excludedMailboxIdsOf, INTERNAL_SEND_KEYWORD } from './mail-scope';
import {
heldSubmissionFilter,
pendingSendOf,
readHeldSendTimes,
submissionCalls,
warnIfHeldQueryFull,
} from './pending-send';
/** 한 번에 읽는 대화의 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet 500) 안 */
const MEMBER_BATCH_SIZE = 500;
/** 기다리는 발송을 묻는 호출 id 앞머리 (pending-send.ts) */
const HELD_CALLS = 'p';
/** 같은 대화에서 거르기에 읽는 값싼 속성 — 요약 속성(미리 보기 · 첨부 부분 …)은 내보낼 메일만 읽는다 */
const FILTER_PROPERTIES = ['id', 'mailboxIds', 'keywords', 'receivedAt'];
/** 묶은 목록에서 스레드의 메일마다 읽는 속성 — 거르기 + 참여자(보낸 사람) · 첨부 판정(부분은 SUMMARY_BODY_PART_PROPERTIES 만) */
const MEMBER_PROPERTIES = [...FILTER_PROPERTIES, 'from', 'attachments'];
/** 엔진의 스레드 — emailIds 는 받은 시각 오래된 차례(RFC 8621 §3.1 — 같은 시각이면 엔진이 정한 차례 그대로) */
interface JmapThread {
readonly id: string;
readonly emailIds?: string[];
}
/** 시간 때문에 끊긴 훑기로 볼 여유 — 제한 시간이 다 된 호출이 끝난 뒤 남은 시간이 마지막 요약 읽기 몫을 조금 넘을 수 있다 */
const CUT_OFF_SLACK_MS = 100;
/** 엔진 호출 한 번의 제한 시간 — 기본 제한 시간과 `ms` 중 짧은 쪽 (1ms 이상) */
function timeoutOf(ms: number): number {
return Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, ms));
}
/**
* 한 요청의 시간 (CONVERSATION_ENGINE_BUDGET_MS — 메일함 처리의 BulkRun 과 같은 결).
* - 훑는 호출(같은 대화의 찾기 · 거르기, 묶은 목록의 스레드 메일)은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS" 중 짧은 쪽 —
* 마지막 요약 읽기 몫을 늘 남긴다(3차 재확인 N1 — 3.5초 남아 시작한 느린 묶음이 요약 읽기를 1ms 로 만들지 않게)
* - 마지막 읽기(같은 대화의 요약 · 묶은 목록의 첫 요청)만 남은 시간을 다 쓴다
* - 남은 시간이 그 몫보다 적으면 더 훑지 않는다. 훑던 호출이 시간 때문에 끊겼으면(cutOff) 거기까지로 답한다
* 시계는 Date.now 다 (시험은 spy 로 돌린다)
*/
class ReadBudget {
private readonly deadline = Date.now() + CONVERSATION_ENGINE_BUDGET_MS;
canScan(): boolean {
return this.left() > CONVERSATION_ENGINE_RESERVE_MS;
}
scanOptions(options: JmapCallOptions = {}): JmapCallOptions {
return {
...options,
timeoutMs: timeoutOf(this.left() - CONVERSATION_ENGINE_RESERVE_MS),
};
}
finalOptions(options: JmapCallOptions = {}): JmapCallOptions {
return { ...options, timeoutMs: timeoutOf(this.left()) };
}
/** 훑던 호출이 시간 때문에 끊겼나 — 엔진 장애(MAIL_001)이고 남은 시간이 마지막 요약 읽기 몫 남짓뿐이다 */
cutOff(error: unknown): boolean {
return (
error instanceof BusinessException &&
error.code === 'MAIL_001' &&
this.left() <= CONVERSATION_ENGINE_RESERVE_MS + CUT_OFF_SLACK_MS
);
}
private left(): number {
return this.deadline - Date.now();
}
}
function chunks<T>(items: readonly T[], size: number): T[][] {
const out: T[][] = [];
for (let index = 0; index < items.length; index += size) {
out.push(items.slice(index, index + size));
}
return out;
}
/** 대화에 드는 메일인가 — 발송 중인 내부 초안이 아니고, 빼는 메일함(휴지통 · 스팸메일함)에만 들어 있지 않다 (`keep` 이면 늘 든다) */
function inConversation(
email: JmapEmail,
excluded: ReadonlySet<string>,
keep: (email: JmapEmail) => boolean,
): boolean {
if (keep(email)) return true;
if (email.keywords?.[INTERNAL_SEND_KEYWORD]) return false;
const mailboxes = Object.keys(email.mailboxIds ?? {});
return mailboxes.length === 0 || mailboxes.some((id) => !excluded.has(id));
}
/** 보낸 사람들 — 주어진 차례(받은 차례), 주소마다 한 번, `max` 명까지 · 이름은 INDEX_FROM_NAME_MAX_CHARS 코드 포인트까지 */
function participantsOf(
emails: readonly JmapEmail[],
max: number,
): MailAddress[] {
const seen = new Set<string>();
const participants: MailAddress[] = [];
for (const email of emails) {
if (participants.length >= max) break;
const from = toAddress(email.from?.[0]);
if (!from) continue;
const key = from.email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
// 이름은 색인 방식의 참여자와 같은 상한 · 같은 자르기 (index-document.ts 의 participantName)
participants.push({ email: from.email, name: participantName(from.name) });
}
return participants;
}
/**
* 한 요청에 읽을 몫 — 스레드마다 `needs[i]` 까지, 모두 `budget` 까지. 작은 스레드부터 다 주고 남는 몫을 큰 스레드끼리 똑같이 나눈다
* (큰 스레드 하나가 쪽의 다른 줄을 비우지 않게)
*/
export function shareScan(needs: readonly number[], budget: number): number[] {
const shares = needs.map(() => 0);
const order = needs
.map((need, index) => ({ need: Math.max(0, need), index }))
.sort((a, b) => a.need - b.need);
let left = Math.max(0, budget);
order.forEach(({ need, index }, position) => {
const give = Math.min(need, Math.floor(left / (order.length - position)));
shares[index] = give;
left -= give;
});
return shares;
}
/**
* 엔진의 스레드로 본 대화 (14단계 — 자체 색인을 다 만들기 전 · 첫 쪽 DB 장애의 대신, 설계 C3 · C4). 스레드는 엔진의 규칙(답장 관계 + 엔진이 정규화한 제목)이다.
* 거르기는 값싼 속성만 읽고 요약 속성은 내보낼 메일만 읽는다. 상한 · 시간은 mail-search.constants.ts 의 CONVERSATION_ENGINE_* (3차 리뷰 H1).
* - 같은 대화: 그 메일의 스레드(오래된 차례) — 스레드가 CONVERSATION_ENGINE_SCAN_MAX 통 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 쪽이 찰 때까지만
* (한 요청 그 상한까지 · 시간 안 — 쪽이 limit 보다 적어도 이어 갈 자리가 있으면 hasNext, total 은 null). 쪽은 스레드 안 위치(anchor · position) 커서
* - 묶은 목록: `Email/query` 의 collapseThreads(그 메일함 안 스레드마다 마지막 메일 — 요약 속성은 이것만) → 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통
* (한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통 — shareScan)으로 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일.
* **상한에 걸린 줄**(읽지 못한 오래된 메일이 있는 스레드 — 몫 · 시간): 통 수는 읽은 메일 가운데 대화에 드는 것 + 읽지 못한 메일 수(거르지 않고 더한다),
* 참여자 · messageIds · 읽음 · 중요 · 첨부 · pendingSendIds 는 읽은 메일(최신 쪽)로만 정한다
* 커서는 jmap-cursor.ts 의 모양(1판)에 범위를 따로 둔다 — 목록 · 검색의 커서와 섞이지 않게
*/
@Injectable()
export class JmapConversations {
private readonly logger = new Logger(JmapConversations.name);
constructor(private readonly jmap: JmapClient) {}
async conversation(
user: string,
emailId: string,
page: { readonly limit: number; readonly cursor?: string },
): Promise<MessagePage> {
const scope: CursorScope = { conditions: ['conversation', emailId] };
// 커서는 엔진에 묻기 전에 본다 — 다른 대화의 커서면 VAL_001
const resume = page.cursor ? decodeCursor(page.cursor, scope) : null;
const budget = new ReadBudget();
const lookup = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{ accountId, ids: [emailId], properties: ['id', 'threadId'] },
'e',
],
[
'Thread/get',
{
accountId,
'#ids': {
resultOf: 'e',
name: 'Email/get',
path: '/list/*/threadId',
},
},
't',
],
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
],
budget.scanOptions({ invalidRequestCode: 'RES_001' }),
);
const { list: found = [] } = expectResult<JmapGetResult<JmapEmail>>(
lookup,
'e',
'Email/get',
);
if (found.length === 0) {
throw new BusinessException('RES_001', {
context: { reason: 'conversation-message-not-found' },
});
}
const thread = expectResult<JmapGetResult<JmapThread>>(
lookup,
't',
'Thread/get',
).list?.[0];
const excluded = new Set(
excludedMailboxIdsOf(this.mailboxesOf(lookup, user)),
);
const order = thread?.emailIds?.length ? thread.emailIds : [emailId];
// 이어 갈 자리 — 스레드 안 위치. 마지막으로 본 메일이 그사이 빠졌으면 위치로
let start = 0;
if (resume) {
const anchorAt = order.indexOf(resume.anchor);
start =
anchorAt >= 0 ? anchorAt + 1 : Math.min(resume.position, order.length);
}
// 상한 이하면 모두 읽어 센다(total), 넘으면 이어 갈 자리부터 쪽이 찰 때까지만
const countAll = order.length <= CONVERSATION_ENGINE_SCAN_MAX;
const scanEnd = countAll
? order.length
: Math.min(order.length, start + CONVERSATION_ENGINE_SCAN_MAX);
const keep = (email: JmapEmail) => email.id === emailId;
/** 대화에 드는 메일의 스레드 안 위치 */
const kept: number[] = [];
let scanned = countAll ? 0 : start;
let shownCount = 0;
while (scanned < scanEnd && (countAll || shownCount <= page.limit)) {
if (!budget.canScan()) break;
const batch = order.slice(
scanned,
Math.min(scanEnd, scanned + MEMBER_BATCH_SIZE),
);
let byId: Map<string, JmapEmail>;
try {
byId = await this.readEmails(user, batch, FILTER_PROPERTIES, budget);
} catch (error) {
// 시간 때문에 끊긴 묶음 — 읽은 데까지로 답한다(아래에서 다 읽어야 하는 작은 스레드 · 아무것도 못 읽음은 MAIL_001)
if (budget.cutOff(error)) break;
throw error;
}
batch.forEach((id, offset) => {
const email = byId.get(id);
if (!email || !inConversation(email, excluded, keep)) return;
kept.push(scanned + offset);
if (scanned + offset >= start) shownCount += 1;
});
scanned += batch.length;
}
const readAll = countAll && scanned >= order.length;
if (countAll && !readAll) {
// 작은 스레드를 시간 안에 다 읽지 못했다 — 엔진이 느리다
throw new BusinessException('MAIL_001', {
context: { reason: 'conversation-budget', scanned },
});
}
if (!countAll && scanned === start && start < order.length) {
throw new BusinessException('MAIL_001', {
context: { reason: 'conversation-budget', scanned },
});
}
const candidates = kept.filter((index) => index >= start);
const shown = candidates.slice(0, page.limit);
let next: number | null = null;
if (candidates.length > page.limit) {
next = shown[shown.length - 1] + 1;
} else if (scanned < order.length && !readAll) {
next = scanned;
}
const items = await this.summaries(
user,
shown.map((index) => order[index]),
budget,
);
return {
items,
hasNext: next !== null,
nextCursor:
next !== null
? encodeCursor({ anchor: order[next - 1], position: next }, scope)
: null,
total: readAll ? kept.length : null,
};
}
async list(
user: string,
query: ConversationListQuery,
): Promise<ConversationSummaryPage> {
const scope: CursorScope = {
mailboxId: query.mailboxId,
conditions: ['conversations'],
};
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
const budget = new ReadBudget();
const now = Date.now();
const ask = (start: { anchor: string } | { position: number }) =>
this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
[
'Email/query',
{
accountId,
filter: {
inMailbox: query.mailboxId,
notKeyword: INTERNAL_SEND_KEYWORD,
},
sort: [{ property: 'receivedAt', isAscending: false }],
collapseThreads: true,
...('anchor' in start
? { anchor: start.anchor, anchorOffset: 1 }
: { position: start.position }),
limit: query.limit,
calculateTotal: true,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: SUMMARY_PROPERTIES,
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
},
'g',
],
[
'Thread/get',
{
accountId,
'#ids': {
resultOf: 'g',
name: 'Email/get',
path: '/list/*/threadId',
},
},
't',
],
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
],
budget.finalOptions({ invalidRequestCode: 'RES_001' }),
);
let responses = await ask(
resume ? { anchor: resume.anchor } : { position: 0 },
);
// 마지막으로 본 메일이 그사이 지워지거나 옮겨졌으면 위치로 이어 읽는다
const first = findResult(responses, 'q', 'Email/query');
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
responses = await ask({ position: resume.position });
}
// 메일함 목록은 꼭 읽는다 — 엔진 장애(메서드 오류)는 503 MAIL_001, 없는 메일함은 404 RES_001 (3차 리뷰 M1)
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
if (!mailboxes.some((mailbox) => mailbox.id === query.mailboxId)) {
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
const excluded = new Set(excludedMailboxIdsOf(mailboxes));
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
const { list: latestEmails = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const { list: threads = [] } = expectResult<JmapGetResult<JmapThread>>(
responses,
't',
'Thread/get',
);
const held = readHeldSendTimes(responses, HELD_CALLS, now);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
const latestById = new Map(latestEmails.map((email) => [email.id, email]));
const threadById = new Map(threads.map((thread) => [thread.id, thread]));
const ids = result.ids ?? [];
const rows = ids.flatMap((id) => {
const latest = latestById.get(id);
if (!latest) return [];
const members = latest.threadId
? threadById.get(latest.threadId)?.emailIds
: undefined;
return [{ latest, order: members?.length ? members : [latest.id] }];
});
// 스레드마다 최신 쪽의 몫 — 한 요청 상한 안에서 작은 스레드부터
const shares = shareScan(
rows.map((row) =>
Math.min(row.order.length, CONVERSATION_ENGINE_THREAD_SCAN_MAX),
),
CONVERSATION_ENGINE_LIST_SCAN_MAX,
);
const windows = rows.map(
(row, index) =>
new Set(row.order.slice(row.order.length - shares[index])),
);
const toRead = [
...new Set(windows.flatMap((window) => [...window])),
].filter((id) => !latestById.has(id));
const { emails: members, attempted } = await this.readMembers(
user,
toRead,
budget,
);
const items = rows.map(({ latest, order }, index): ConversationSummary => {
const window = windows[index];
/** 읽은 메일 — 몫 안에서 엔진에 물은 것(그사이 지운 것은 빠진다)과 마지막 메일 */
const wasRead = (id: string) =>
id === latest.id || (window.has(id) && attempted.has(id));
const readEmails = order.flatMap((id) => {
if (!wasRead(id)) return [];
const email = id === latest.id ? latest : members.get(id);
return email ? [email] : [];
});
if (!order.includes(latest.id)) readEmails.push(latest);
const notRead = order.filter((id) => !wasRead(id)).length;
const inMailbox = (email: JmapEmail) =>
Boolean(email.mailboxIds?.[query.mailboxId]) &&
!email.keywords?.[INTERNAL_SEND_KEYWORD];
const kept = readEmails.filter((email) =>
inConversation(email, excluded, inMailbox),
);
const here = kept.filter(inMailbox);
const messageIds = (
here.length > 0 ? here.map((email) => email.id).reverse() : [latest.id]
).slice(0, CONVERSATION_MESSAGE_IDS_MAX);
const shownHere = here.length > 0 ? here : [latest];
return {
id: latest.threadId ?? latest.id,
latest: toSummary(latest, pendingSendOf(latest, held)),
participants: participantsOf(kept, CONVERSATION_PARTICIPANTS_MAX),
messageCount: Math.max(kept.length + notRead, 1),
messageIds,
pendingSendIds: messageIds.filter((id) => held.has(id)),
isRead: shownHere.every((email) => email.keywords?.$seen === true),
isFlagged: shownHere.some((email) => email.keywords?.$flagged === true),
hasAttachment: shownHere.some((email) =>
hasRealAttachment(email.attachments ?? []),
),
};
});
const start = result.position ?? 0;
const total = typeof result.total === 'number' ? result.total : null;
const hasNext =
total !== null ? start + ids.length < total : ids.length === query.limit;
const lastId = ids.at(-1);
return {
items,
hasNext,
nextCursor:
hasNext && lastId
? encodeCursor(
{ anchor: lastId, position: start + ids.length },
scope,
)
: null,
total,
};
}
/** 메일함 목록(역할) — 같은 대화에서만: 읽지 못하면 빈 목록(빼는 메일함 없이 — 대화가 넓어질 뿐, 경고를 남긴다) */
private mailboxesOf(
responses: JmapInvocation[],
user: string,
): JmapMailbox[] {
const found = findResult(responses, 'm', 'Mailbox/get');
if (!found.ok) {
this.logger.warn(
`[MAIL_001]${traceTag()} 대화의 메일함 역할을 읽지 못해 휴지통 · 스팸메일함을 빼지 못했습니다: user=${maskEmail(user)}, type=${found.error.type}`,
);
return [];
}
return (found.args as JmapGetResult<JmapMailbox>).list ?? [];
}
/** 메일 여러 통을 값싼 속성으로 — 훑는 호출(마지막 요약 읽기 몫을 남긴 제한 시간), 엔진 장애는 그대로 던진다 */
private async readEmails(
user: string,
ids: readonly string[],
properties: readonly string[],
budget: ReadBudget,
): Promise<Map<string, JmapEmail>> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [...ids],
properties: [...properties],
...(properties.includes('attachments')
? { bodyProperties: SUMMARY_BODY_PART_PROPERTIES }
: {}),
},
'g',
],
],
budget.scanOptions(),
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
return new Map(list.map((email) => [email.id, email]));
}
/** 묶은 목록의 스레드 메일 — 500통씩, 시간이 남은 동안만(물은 id 를 함께 준다 — 묻지 못한 것은 상한에 걸린 줄의 읽지 못한 메일이다) */
private async readMembers(
user: string,
ids: readonly string[],
budget: ReadBudget,
): Promise<{ emails: Map<string, JmapEmail>; attempted: Set<string> }> {
const emails = new Map<string, JmapEmail>();
const attempted = new Set<string>();
for (const batch of chunks(ids, MEMBER_BATCH_SIZE)) {
if (!budget.canScan()) break;
let read: Map<string, JmapEmail>;
try {
read = await this.readEmails(user, batch, MEMBER_PROPERTIES, budget);
} catch (error) {
// 시간 때문에 끊긴 묶음 — 그 메일은 읽지 못한 것으로(어림 줄 — 503 이 아니다)
if (budget.cutOff(error)) break;
throw error;
}
for (const id of batch) attempted.add(id);
for (const [id, email] of read) emails.set(id, email);
}
return { emails, attempted };
}
/** 내보낼 메일의 요약 — 마지막 읽기(남은 시간을 다 쓴다). 요약 속성은 이 메일들만, 같은 요청에서 기다리는 발송도 (주어진 차례 · 없는 메일은 빠진다) */
private async summaries(
user: string,
ids: readonly string[],
budget: ReadBudget,
): Promise<MessagePage['items']> {
if (ids.length === 0) return [];
const now = Date.now();
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [...ids],
properties: SUMMARY_PROPERTIES,
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
},
'g',
],
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
],
budget.finalOptions(),
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const held = readHeldSendTimes(responses, HELD_CALLS, now);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
const byId = new Map(list.map((email) => [email.id, email]));
return ids.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
});
}
}
@@ -85,4 +85,56 @@ describe('메일 목록 커서', () => {
expect(codeOf(() => decodeCursor(cursor, inbox))).toBe('VAL_001');
}
});
it('조건 검색(14단계)의 조건도 범위다 — 조건이 다르면 다른 목록, 조건이 없으면 지문은 예전과 같다', () => {
const position = { anchor: 'eaaaaab', position: 20 };
const conditions = ['이도윤', '', '', '', '', '', false];
const withConditions = encodeCursor(position, { text: '회의', conditions });
expect(
decodeCursor(withConditions, {
text: '회의',
conditions: [...conditions],
}),
).toEqual(position);
for (const scope of [
{ text: '회의' },
{ text: '회의', conditions: ['김아라', '', '', '', '', '', false] },
{ text: '회의', conditions: ['이도윤', '', '', '', '', '', true] },
]) {
expect(codeOf(() => decodeCursor(withConditions, scope))).toBe('VAL_001');
}
// 조건이 없으면 예전 지문 — 3단계의 커서가 그대로 쓰인다
const plain = encodeCursor(position, { text: '회의' });
expect(decodeCursor(plain, { text: '회의' })).toEqual(position);
});
it('조건의 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸 · 검색어를 흉내 내도 다른 목록이다', () => {
const position = { anchor: 'eaaaaab', position: 20 };
const NL = String.fromCharCode(10);
const fromTwoLines = encodeCursor(position, {
text: '회의',
conditions: [`a${NL}b`, '', '', '', '', '', false],
});
expect(
codeOf(() =>
decodeCursor(fromTwoLines, {
text: '회의',
conditions: ['a', 'b', '', '', '', '', false],
}),
),
).toBe('VAL_001');
// 검색어 끝에 줄바꿈 + 조건처럼 보이는 글을 넣어도 조건이 있는 검색과 섞이지 않는다
const textOnly = encodeCursor(position, {
text: `회의${NL}${JSON.stringify(['a', '', '', '', '', '', false])}`,
});
expect(
codeOf(() =>
decodeCursor(textOnly, {
text: '회의',
conditions: ['a', '', '', '', '', '', false],
}),
),
).toBe('VAL_001');
});
});
+13 -1
View File
@@ -30,6 +30,11 @@ export interface CursorScope {
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함 */
readonly mailboxId?: string;
readonly text?: string;
/**
* 조건 검색의 조건(14단계 — 정해진 차례의 값들). 없으면(조건 없는 검색 · 목록) 지문은 예전과 같다 — 배포 전에 받은 커서도 그대로 쓴다.
* 있으면 지문은 [메일함, 검색어, 조건] 의 JSON 이다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않는다
*/
readonly conditions?: readonly (string | boolean)[];
}
export interface CursorPosition {
@@ -47,8 +52,15 @@ interface EncodedCursor {
}
function scopeHash(scope: CursorScope): string {
const mailbox = scope.mailboxId ?? ALL_MAILBOXES_SCOPE;
const text = scope.text ?? '';
// 조건이 없으면 예전 모양("<메일함>\n<검색어>") — 메일함 id 에는 줄바꿈이 없어 섞이지 않는다. 조건이 있으면 JSON(늘 "[" 로 시작 — 메일함 id 와 겹치지 않는다)
const material =
scope.conditions === undefined
? `${mailbox}\n${text}`
: JSON.stringify([mailbox, text, scope.conditions]);
return createHash('sha256')
.update(`${scope.mailboxId ?? ALL_MAILBOXES_SCOPE}\n${scope.text ?? ''}`)
.update(material)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
@@ -71,8 +71,11 @@ const BODY_ROUND_SIZE = 100;
export const FILTER_RUN_BODY_VALUE_BYTES = 262_144;
export const FILTER_RUN_MESSAGE_BODY_BYTES = 1_048_576;
/** 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게) */
const BODY_FETCH_BUDGET_BYTES = 4_000_000;
/**
* 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게).
* 검색 색인의 본문 읽기(jmap-index-source.ts — 14단계)도 같은 상한을 쓴다
*/
export const BODY_FETCH_BUDGET_BYTES = 4_000_000;
/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
const MAX_IDS_PER_GET = 500;
@@ -0,0 +1,434 @@
import { BusinessException } from '@/common/errors/business.exception';
import { IndexDeadlineError } from '@/modules/mail-search/mail-index-source';
import {
BODY_READ_BATCH_MESSAGE_BYTES,
BODY_READ_SOLO_MESSAGE_BYTES,
INDEX_CALL_MIN_MS,
INDEX_CALL_TIMEOUT_MS,
} from '@/modules/mail-search/mail-search.constants';
import type { JmapClient, JmapCallOptions } from './jmap.client';
import {
JmapIndexSource,
indexAttachmentNames,
indexBodyBatches,
} from './jmap-index-source';
import type { JmapEmail, JmapInvocation } from './jmap.types';
const ARA = 'ara.kim@arasystem.test';
type Responder = (
method: string,
args: Record<string, unknown>,
callId: string,
) => JmapInvocation;
/** 대역 엔진 — 메서드마다 답을 정한다. 받은 요청을 모은다 */
class FakeJmap {
readonly requests: { calls: JmapInvocation[]; options: JmapCallOptions }[] =
[];
responders = new Map<string, Responder>();
/** 요청 전체를 이 예외로 거절한다 (한 번) */
rejectNext: Error | null = null;
readonly client = {
call: jest.fn(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options: JmapCallOptions = {},
) => {
const calls = build('acc');
this.requests.push({ calls, options });
if (this.rejectNext) {
const error = this.rejectNext;
this.rejectNext = null;
return Promise.reject(error);
}
return Promise.resolve(
calls.map(([method, args, callId]) => {
const responder = this.responders.get(method);
if (!responder) throw new Error(`답이 없는 메서드: ${method}`);
return responder(method, args, callId);
}),
);
},
),
};
on(method: string, responder: Responder): void {
this.responders.set(method, responder);
}
}
describe('JmapIndexSource (검색 색인의 엔진 읽기)', () => {
let engine: FakeJmap;
let source: JmapIndexSource;
const options = () => ({ deadline: Date.now() + 30_000 });
beforeEach(() => {
engine = new FakeJmap();
source = new JmapIndexSource(engine.client as unknown as JmapClient);
});
describe('changes', () => {
it('만든 · 바꾼 · 없어진 id 와 새 state · 더 있는지', async () => {
engine.on('Email/changes', (method, args, callId) => {
expect(args).toMatchObject({ sinceState: 's1', maxChanges: 100 });
return [
method,
{
newState: 's2',
hasMoreChanges: true,
created: ['a'],
updated: ['b'],
destroyed: ['c', 7],
},
callId,
];
});
expect(await source.changes(ARA, 's1', 100, options())).toEqual({
kind: 'changes',
created: ['a'],
updated: ['b'],
destroyed: ['c'],
newState: 's2',
hasMore: true,
});
// 호출마다 남은 시간을 넘긴다 (한 번은 15초까지)
expect(engine.requests[0].options.timeoutMs).toBeLessThanOrEqual(15_000);
});
it.each(['cannotCalculateChanges', 'invalidArguments'])(
'엔진이 셀 수 없다(%s) → cannotCalculate',
async (type) => {
engine.on('Email/changes', (_method, _args, callId) => [
'error',
{ type, description: '엔진 문장' },
callId,
]);
expect(await source.changes(ARA, 'old', 100, options())).toEqual({
kind: 'cannotCalculate',
});
},
);
it('요청 전체를 거절해도(모르는 state 모양) cannotCalculate — 그 밖의 오류는 코드로 던진다', async () => {
engine.rejectNext = new BusinessException('VAL_001', {
context: { reason: 'request-rejected', jmapType: 'notRequest' },
});
expect(await source.changes(ARA, '###', 100, options())).toEqual({
kind: 'cannotCalculate',
});
engine.on('Email/changes', (_method, _args, callId) => [
'error',
{ type: 'serverFail', description: '엔진 문장' },
callId,
]);
const error = (await source
.changes(ARA, 's1', 100, options())
.catch((caught: unknown) => caught)) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(JSON.stringify(error.context)).not.toContain('엔진 문장');
});
it('끝 시각까지 INDEX_CALL_MIN_MS 보다 적게 남았으면 엔진에 묻지 않고 IndexDeadlineError (실패가 아니라 이어 하기)', async () => {
for (const deadline of [
Date.now() - 1,
Date.now() + INDEX_CALL_MIN_MS - 500,
]) {
await expect(
source.changes(ARA, 's1', 100, { deadline }),
).rejects.toBeInstanceOf(IndexDeadlineError);
}
expect(engine.requests).toHaveLength(0);
});
it('남은 시간으로 줄인 기다림이 끝나 끊기면 IndexDeadlineError — 끝 시각이 멀어 줄이지 않았으면 엔진 장애(MAIL_001) 그대로', async () => {
const timeout = () =>
new BusinessException('MAIL_001', {
context: { reason: 'timeout', call: 'api' },
});
engine.rejectNext = timeout();
await expect(
source.changes(ARA, 's1', 100, { deadline: Date.now() + 5_000 }),
).rejects.toBeInstanceOf(IndexDeadlineError);
expect(engine.requests[0].options.timeoutMs).toBeLessThanOrEqual(5_000);
engine.rejectNext = timeout();
await expect(
source.changes(ARA, 's1', 100, {
deadline: Date.now() + INDEX_CALL_TIMEOUT_MS * 2,
}),
).rejects.toMatchObject({ code: 'MAIL_001' });
expect(engine.requests[1].options.timeoutMs).toBe(INDEX_CALL_TIMEOUT_MS);
});
});
describe('documents', () => {
const emails: JmapEmail[] = [
{
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true, old: false },
keywords: { $seen: true, $flagged: false },
receivedAt: '2026-11-05T01:00:00Z',
size: 900,
messageId: ['m1@x'],
inReplyTo: ['p@x'],
references: ['r@x', 'p@x'],
subject: '계약서',
from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
to: [{ email: ARA }],
cc: null,
preview: '미리 보기',
textBody: [{ partId: '1', type: 'text/html', size: 100 }],
attachments: [
// 진짜 첨부 — 이름이 있음 · 이름 없음
{
partId: '2',
type: 'application/pdf',
name: ' 계약서.pdf ',
size: 10,
},
{ partId: '3', type: 'application/zip', size: 10 },
// 본문 그림(서명 로고) — 첨부가 아니다
{
partId: '4',
type: 'image/png',
name: 'logo.png',
cid: 'logo',
disposition: 'inline',
size: 1000,
},
// 텍스트 첨부 — 안의 글은 읽지 않는다
{ partId: '5', type: 'text/plain', name: '메모.txt', size: 10 },
],
},
{
id: 'e2',
mailboxIds: { inbox: true },
receivedAt: '2026-11-05T02:00:00Z',
subject: null,
textBody: [
{ partId: '1', type: 'text/plain', size: 20 },
{ partId: '2', type: 'image/jpeg', size: 20 },
],
attachments: [],
},
];
it('머리글을 읽고 textBody 의 글자 부분만 값을 읽는다 — HTML 은 글자만, 첨부 이름은 진짜 첨부만', async () => {
engine.on('Email/get', (method, args, callId) => {
const ids = args.ids as string[];
if (args.fetchTextBodyValues === true) {
// 몫 — 글자 부분 하나면 256KiB
expect(args.maxBodyValueBytes).toBe(262_144);
expect(args.fetchAllBodyValues).toBeUndefined();
return [
method,
{
list: ids.map((id) =>
id === 'e1'
? {
id,
textBody: [{ partId: '1', type: 'text/html' }],
bodyValues: {
'1': {
value:
'<p>계약서를<b>보내</b> 드립니다</p><style>x{}</style>',
},
},
}
: {
id,
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: '글 본문' } },
},
),
},
callId,
];
}
return [
method,
{ list: emails.filter((email) => ids.includes(email.id)) },
callId,
];
});
const documents = await source.documents(
ARA,
['e1', 'e2', 'gone'],
options(),
);
expect(documents).toHaveLength(2);
expect(documents[0]).toEqual({
id: 'e1',
threadId: 't1',
mailboxIds: ['inbox'],
keywords: ['$seen'],
receivedAt: '2026-11-05T01:00:00Z',
size: 900,
messageIds: ['m1@x'],
inReplyTo: ['p@x'],
references: ['r@x', 'p@x'],
subject: '계약서',
from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
to: [{ name: null, email: ARA }],
cc: [],
attachmentNames: ['계약서.pdf', '메모.txt'],
hasAttachment: true,
preview: '미리 보기',
bodyText: '계약서를보내 드립니다\n',
});
expect(documents[1]).toMatchObject({
id: 'e2',
subject: '',
threadId: null,
bodyText: '글 본문',
hasAttachment: false,
attachmentNames: [],
});
});
});
it('indexBodyBatches — 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘으면 나누고, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자', () => {
const part = { partId: '1', type: 'text/plain', size: 1_000 };
const third = Math.floor(BODY_READ_BATCH_MESSAGE_BYTES / 3);
const batches = indexBodyBatches([
// 큰 첨부가 든 메일 넷 — 크기 합이 상한을 넘기 전에 나눈다
{ id: 'a', parts: [part], size: third },
{ id: 'b', parts: [part], size: third },
{ id: 'c', parts: [part], size: third },
{ id: 'd', parts: [part], size: third },
// 혼자 읽는 메일 — 그 앞뒤 묶음과 섞지 않는다
{ id: 'huge', parts: [part], size: BODY_READ_SOLO_MESSAGE_BYTES + 1 },
{ id: 'small', parts: [part], size: 10 },
]);
expect(batches.map((batch) => batch.ids)).toEqual([
['a', 'b', 'c'],
['huge'],
['d', 'small'],
]);
});
it('indexBodyBatches — 몫이 같은 메일끼리, 어림 크기가 상한을 넘으면 나눈다 · 글자 부분이 없으면 읽지 않는다', () => {
const big = { partId: '1', type: 'text/plain', size: 1_000_000 };
const batches = indexBodyBatches([
{ id: 'a', parts: [big], size: 1_000_000 },
{ id: 'b', parts: [big], size: 1_000_000 },
{ id: 'c', parts: [big, { ...big, partId: '2' }], size: 2_000_000 },
{ id: 'none', parts: [], size: 0 },
]);
// 한 부분 몫 256KiB — 어림 256KiB 씩이라 a · b 는 한 묶음, c 는 부분이 둘이라 몫이 같은(256KiB) 묶음에
expect(batches.flatMap((batch) => batch.ids).sort()).toEqual([
'a',
'b',
'c',
]);
expect(batches.every((batch) => batch.quota === 262_144)).toBe(true);
});
it('indexAttachmentNames — 본문 그림을 빼고 이름이 있는 것만, 경로 · 제어 문자는 다듬는다', () => {
expect(
indexAttachmentNames([
{ name: '../보고서.hwp', type: 'application/octet-stream' },
{ name: 'logo.png', type: 'image/png', cid: 'c1', size: 10 },
{ name: '', type: 'application/pdf' },
]),
).toEqual(['보고서.hwp']);
});
describe('pageIds · allIds · snapshot', () => {
it('anchor 다음부터 최신 차례 — anchor 가 지워졌으면 위치로', async () => {
const seen: Record<string, unknown>[] = [];
engine.on('Email/query', (method, args, callId) => {
seen.push(args);
if (args.anchor === 'gone') {
return ['error', { type: 'anchorNotFound' }, callId];
}
return [method, { ids: ['x', 'y'], position: 50, total: 120 }, callId];
});
expect(
await source.pageIds(
ARA,
{ anchor: 'gone', position: 50 },
2,
options(),
),
).toEqual({ ids: ['x', 'y'], position: 50, total: 120 });
expect(seen[0]).toMatchObject({
anchor: 'gone',
anchorOffset: 1,
sort: [{ property: 'receivedAt', isAscending: false }],
calculateTotal: true,
});
expect(seen[1]).toMatchObject({ position: 50, limit: 2 });
expect(seen[1]).not.toHaveProperty('anchor');
});
it('전체 대조는 오래된 차례로 anchor 로 이어 묻고 빈 쪽에서 끝난다 — anchor 가 지워지면 처음부터', async () => {
const pages = [
{ anchor: undefined, ids: ['a', 'b'] },
{ anchor: 'b', error: 'anchorNotFound' },
{ anchor: undefined, ids: ['a', 'c'] },
{ anchor: 'c', ids: ['d'] },
{ anchor: 'd', ids: [] },
];
engine.on('Email/get', (method, _args, callId) => [
method,
{ state: 'st', list: [] },
callId,
]);
engine.on('Email/query', (method, args, callId) => {
if (args.limit === 1) return [method, { ids: [], total: 3 }, callId];
const page = pages.shift()!;
expect(args.anchor).toBe(page.anchor);
expect(args.sort).toEqual([
{ property: 'receivedAt', isAscending: true },
]);
if (page.error) return ['error', { type: page.error }, callId];
return [method, { ids: page.ids, total: 3 }, callId];
});
expect(await source.allIds(ARA, options())).toEqual({
state: 'st',
ids: ['a', 'c', 'd'],
});
expect(pages).toEqual([]);
});
it('snapshot — 빈 ids 의 Email/get 으로 state, 조회의 total 로 메일 수', async () => {
engine.on('Email/get', (method, args, callId) => {
expect(args.ids).toEqual([]);
return [method, { state: 'st9', list: [] }, callId];
});
engine.on('Email/query', (method, _args, callId) => [
method,
{ ids: ['z'], total: 42 },
callId,
]);
expect(await source.snapshot(ARA, options())).toEqual({
state: 'st9',
total: 42,
});
});
it('state 가 없으면 MAIL_001', async () => {
engine.on('Email/get', (method, _args, callId) => [
method,
{ list: [] },
callId,
]);
engine.on('Email/query', (method, _args, callId) => [
method,
{ ids: [] },
callId,
]);
await expect(source.snapshot(ARA, options())).rejects.toMatchObject({
code: 'MAIL_001',
});
});
});
});
@@ -0,0 +1,622 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
import { htmlToText } from '@/modules/mail/html/html-text';
import {
hasRealAttachment,
isBodyImagePart,
} from '@/modules/mail/html/inline-images';
import {
IndexDeadlineError,
type IndexAddress,
type IndexAllIds,
type IndexChanges,
type IndexDocument,
type IndexIdPage,
type IndexMutable,
type IndexReadOptions,
type MailIndexSource,
} from '@/modules/mail-search/mail-index-source';
import {
ALL_IDS_PAGE,
BODY_READ_BATCH_MESSAGE_BYTES,
BODY_READ_SOLO_MESSAGE_BYTES,
INDEX_CALL_MIN_MS,
INDEX_CALL_TIMEOUT_MS,
} from '@/modules/mail-search/mail-search.constants';
import { BODY_FETCH_BUDGET_BYTES, bodyValueQuota } from './jmap-filter-run';
import { JmapClient, type JmapCallOptions } from './jmap.client';
import { jmapFailure } from './jmap.errors';
import { expectResult, findResult, isState } from './jmap-results';
import type {
JmapBodyPart,
JmapChangesResult,
JmapEmail,
JmapEmailAddress,
JmapGetResult,
JmapInvocation,
JmapQueryResult,
} from './jmap.types';
/**
* 메일 검색 색인이 엔진에서 읽는 것 — Stalwart(JMAP) 어댑터 (14단계 설계 B2, 포트는 modules/mail-search/mail-index-source.ts).
* 직원 자격(게이트웨이 대리 접근 — JmapClient.call)으로 읽기만 한다 — 이 파일에는 Email/set 이 없다.
*
* - 글: 머리글 · 받는 사람 · 첨부 부분은 한 번에(MAX_IDS_PER_GET 씩), 본문 값은 textBody 의 글자 부분만 따로 읽는다(fetchTextBodyValues).
* textBody 는 엔진이 고른 글자 쪽 표현(text/plain, 없으면 text/html)이라 같은 글을 두 번 담지 않는다. 첨부 파일 안의 글은 읽지 않는다(설계 — 첨부 이름만 찾는다).
* 본문 값은 부분마다 256KiB · 한 통 1MiB 까지(이미 받은 메일에 필터 적용과 같은 몫 — jmap-filter-run.ts 의 bodyValueQuota),
* 몫이 같은 메일끼리 어림 크기 BODY_FETCH_BUDGET_BYTES 로 나눠 읽는다 — 바깥 메일 한 통으로 서버 · 엔진 메모리를 크게 쓰지 않게.
* 묶음은 메일 **전체** 크기(size)의 합도 BODY_READ_BATCH_MESSAGE_BYTES 까지, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다 —
* 엔진은 값을 만들며 원문을 읽으므로 큰 첨부 메일 여럿이 한 요청에 들면 엔진(1g) 메모리가 한꺼번에 튄다
* - 시간: 엔진 읽기마다 차례의 끝 시각까지 남은 시간을 본다 — INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고, 남은 시간으로 줄인 기다림이
* 끝나 끊긴 것도 IndexDeadlineError(실패가 아니라 이어 하기)로 알린다. 한 번은 INDEX_CALL_TIMEOUT_MS 까지만 기다린다
* - 지난 메일 쪽: 받은 시각 최신부터 anchor 다음 — anchor 가 지워졌으면(anchorNotFound) 위치로
* - 전체 대조: 받은 시각 오래된 것부터 anchor 로 이어 묻는다(위치로 넘기면 그사이 지운 메일만큼 건너뛴다) — anchor 가 지워지면 처음부터 다시
* 엔진 오류의 설명 문장은 남기지 않는다(jmapFailure — type · 속성 이름만)
*/
/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
const MAX_IDS_PER_GET = 500;
/** 전체 대조에서 anchor 가 지워져 처음부터 다시 묻는 횟수 */
const ALL_IDS_RESTARTS = 3;
/** 머리글을 읽을 때의 속성 · 부분 속성 (본문 값은 따로) */
const DOCUMENT_PROPERTIES = [
'id',
'threadId',
'mailboxIds',
'keywords',
'receivedAt',
'size',
'messageId',
'inReplyTo',
'references',
'subject',
'from',
'to',
'cc',
'preview',
'attachments',
'textBody',
];
const DOCUMENT_PART_PROPERTIES = [
'partId',
'type',
'size',
'name',
'disposition',
'cid',
];
const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }];
const OLDEST_FIRST = [{ property: 'receivedAt', isAscending: true }];
/** JMAP 메서드 오류 — 그 state 부터의 변화를 셀 수 없다 · 인자가 맞지 않는다(모르는 state 모양) · anchor 가 결과에 없다 */
const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges';
const INVALID_ARGUMENTS = 'invalidArguments';
const ANCHOR_NOT_FOUND = 'anchorNotFound';
const HTML_TYPE = 'text/html';
function chunks<T>(items: readonly T[], size: number): T[][] {
const out: T[][] = [];
for (let index = 0; index < items.length; index += size) {
out.push(items.slice(index, index + size));
}
return out;
}
function trueKeys(record: Record<string, boolean> | undefined): string[] {
return Object.entries(record ?? {})
.filter(([, value]) => value === true)
.map(([key]) => key);
}
function strings(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function toAddresses(
addresses: JmapEmailAddress[] | null | undefined,
): IndexAddress[] {
return (addresses ?? []).map((address) => ({
name: typeof address.name === 'string' ? address.name : null,
email: typeof address.email === 'string' ? address.email : null,
}));
}
/** textBody 의 글자 부분 (text/* — 같은 partId 는 한 번) */
export function indexTextParts(email: JmapEmail): JmapBodyPart[] {
const seen = new Set<string>();
return (email.textBody ?? []).filter((part) => {
const { partId } = part;
if (!partId || seen.has(partId)) return false;
seen.add(partId);
return (part.type ?? '').toLowerCase().startsWith('text/');
});
}
/** 본문 글 — 글자 부분마다(HTML 은 글자만), 줄바꿈으로 잇는다. 값을 받지 못한 부분은 뺀다 */
export function indexBodyText(
parts: readonly JmapBodyPart[],
values: JmapEmail['bodyValues'],
): string {
return parts
.flatMap((part) => {
const value = values?.[part.partId ?? '']?.value;
if (typeof value !== 'string') return [];
return [
(part.type ?? '').toLowerCase() === HTML_TYPE
? htmlToText(value)
: value,
];
})
.join('\n');
}
/** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 뺀다 (목록 · 읽기 화면과 같은 이름 다듬기) */
export function indexAttachmentNames(
attachments: readonly JmapBodyPart[],
): string[] {
return attachments
.filter((part) => !isBodyImagePart(part))
.flatMap((part) => {
const name = part.name?.trim();
return name ? [sanitizeFileName(name)] : [];
});
}
/** 본문 값을 받을 때 응답 크기의 어림 (바이트) — 부분마다 몫까지, UTF-8 로 바꾸며 늘 수 있어 두 배 (크기를 모르면 몫) */
function estimatedBytes(parts: readonly JmapBodyPart[], quota: number): number {
return parts.reduce(
(sum, part) =>
sum +
(typeof part.size === 'number' && part.size >= 0
? Math.min(quota, part.size * 2)
: quota),
0,
);
}
/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */
interface BodyBatch {
readonly quota: number;
readonly ids: string[];
}
/** 본문 값을 읽을 메일 한 통 — 글자 부분 · 메일 전체 크기(엔진의 size, 모르면 0) */
export interface BodyReadCandidate {
readonly id: string;
readonly parts: readonly JmapBodyPart[];
readonly size: number;
}
/**
* 본문 값을 읽을 묶음 — 몫(bodyValueQuota)이 같은 메일끼리. 이럴 때 나눈다: 값의 어림 크기가 BODY_FETCH_BUDGET_BYTES 를 넘는다 ·
* 한 번 읽기 한도(MAX_IDS_PER_GET)에 닿는다 · 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘는다.
* 전체 크기가 BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다. 글자 부분이 없는 메일은 읽지 않는다
*/
export function indexBodyBatches(
emails: readonly BodyReadCandidate[],
): BodyBatch[] {
const groups = new Map<number, BodyReadCandidate[]>();
for (const email of emails) {
if (email.parts.length === 0) continue;
const quota = bodyValueQuota(email.parts.length);
const group = groups.get(quota);
if (group) group.push(email);
else groups.set(quota, [email]);
}
const batches: BodyBatch[] = [];
for (const [quota, group] of groups) {
let ids: string[] = [];
let valueBytes = 0;
let messageBytes = 0;
for (const email of group) {
const size = Number.isFinite(email.size) ? Math.max(0, email.size) : 0;
if (size > BODY_READ_SOLO_MESSAGE_BYTES) {
batches.push({ quota, ids: [email.id] });
continue;
}
const estimate = estimatedBytes(email.parts, quota);
if (
ids.length > 0 &&
(valueBytes + estimate > BODY_FETCH_BUDGET_BYTES ||
messageBytes + size > BODY_READ_BATCH_MESSAGE_BYTES ||
ids.length >= MAX_IDS_PER_GET)
) {
batches.push({ quota, ids });
ids = [];
valueBytes = 0;
messageBytes = 0;
}
ids.push(email.id);
valueBytes += estimate;
messageBytes += size;
}
if (ids.length > 0) batches.push({ quota, ids });
}
return batches;
}
/** 엔진 호출이 기다림 끝(시간 초과)으로 끊겼나 — JmapClient 가 붙이는 사유 */
function isTimeout(error: unknown): boolean {
return (
error instanceof BusinessException &&
error.code === 'MAIL_001' &&
error.context?.reason === 'timeout'
);
}
@Injectable()
export class JmapIndexSource implements MailIndexSource {
constructor(private readonly jmap: JmapClient) {}
async snapshot(
user: string,
options: IndexReadOptions,
): Promise<{ state: string; total: number | null }> {
const responses = await this.call(
user,
(accountId) => [
['Email/get', { accountId, ids: [], properties: ['id'] }, 's'],
[
'Email/query',
{ accountId, position: 0, limit: 1, calculateTotal: true },
'q',
],
],
options,
);
const { state } = expectResult<JmapGetResult<JmapEmail>>(
responses,
's',
'Email/get',
);
const query = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
return {
state: this.requireState(state),
total: typeof query.total === 'number' ? query.total : null,
};
}
async changes(
user: string,
sinceState: string,
maxChanges: number,
options: IndexReadOptions,
): Promise<IndexChanges> {
let responses: JmapInvocation[];
try {
responses = await this.call(
user,
(accountId) => [
['Email/changes', { accountId, sinceState, maxChanges }, 'c'],
],
options,
// 엔진이 state 모양을 받지 않아 요청 전체를 거절해도 셀 수 없는 것으로 본다 (아래 catch)
{ invalidRequestCode: 'VAL_001' },
);
} catch (error) {
if (
error instanceof BusinessException &&
error.code === 'VAL_001' &&
error.context?.reason === 'request-rejected'
) {
return { kind: 'cannotCalculate' };
}
throw error;
}
const result = findResult(responses, 'c', 'Email/changes');
if (!result.ok) {
const type = result.error.type;
if (type === CANNOT_CALCULATE_CHANGES || type === INVALID_ARGUMENTS) {
return { kind: 'cannotCalculate' };
}
throw jmapFailure(result.error, { method: 'Email/changes' });
}
const changes = result.args as JmapChangesResult;
return {
kind: 'changes',
created: strings(changes.created),
updated: strings(changes.updated),
destroyed: strings(changes.destroyed),
newState: this.requireState(changes.newState),
hasMore: changes.hasMoreChanges === true,
};
}
async documents(
user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexDocument[]> {
const documents: IndexDocument[] = [];
for (const chunk of chunks(ids, MAX_IDS_PER_GET)) {
const emails = await this.readEmails(
user,
chunk,
DOCUMENT_PROPERTIES,
DOCUMENT_PART_PROPERTIES,
options,
);
const withParts = emails.map((email) => ({
id: email.id,
parts: indexTextParts(email),
size: typeof email.size === 'number' ? email.size : 0,
}));
const bodies = new Map<string, string>();
for (const batch of indexBodyBatches(withParts)) {
for (const email of await this.readBodies(user, batch, options)) {
bodies.set(
email.id,
indexBodyText(indexTextParts(email), email.bodyValues),
);
}
}
for (const email of emails) {
documents.push(this.toDocument(email, bodies.get(email.id) ?? ''));
}
}
return documents;
}
async mutable(
user: string,
ids: readonly string[],
options: IndexReadOptions,
): Promise<IndexMutable[]> {
const found: IndexMutable[] = [];
for (const chunk of chunks(ids, MAX_IDS_PER_GET)) {
const emails = await this.readEmails(
user,
chunk,
['id', 'mailboxIds', 'keywords'],
null,
options,
);
for (const email of emails) {
found.push({
id: email.id,
mailboxIds: trueKeys(email.mailboxIds),
keywords: trueKeys(email.keywords),
});
}
}
return found;
}
async pageIds(
user: string,
from: { anchor: string | null; position: number },
limit: number,
options: IndexReadOptions,
): Promise<IndexIdPage> {
if (from.anchor !== null) {
const anchored = await this.query(
user,
NEWEST_FIRST,
{ anchor: from.anchor },
limit,
options,
);
if (anchored !== null) return anchored;
}
// anchor 가 없거나 그사이 지워졌다 — 위치로
const page = await this.query(
user,
NEWEST_FIRST,
{ position: Math.max(0, from.position) },
limit,
options,
);
return page ?? { ids: [], position: from.position, total: null };
}
async allIds(user: string, options: IndexReadOptions): Promise<IndexAllIds> {
for (let attempt = 0; attempt <= ALL_IDS_RESTARTS; attempt += 1) {
const { state } = await this.snapshot(user, options);
const ids: string[] = [];
let anchor: string | null = null;
let restarted = false;
for (;;) {
const page = await this.query(
user,
OLDEST_FIRST,
anchor === null ? { position: 0 } : { anchor },
ALL_IDS_PAGE,
options,
);
if (page === null) {
restarted = true;
break;
}
if (page.ids.length === 0) break;
ids.push(...page.ids);
anchor = page.ids[page.ids.length - 1];
}
if (!restarted) return { state, ids };
}
throw new BusinessException('MAIL_001', {
context: { reason: 'index-all-ids-anchor-lost' },
});
}
/**
* 받은 시각 차례로 한 쪽 — anchor 가 결과에 없으면(anchorNotFound) null (부른 쪽이 위치로 · 처음부터).
* 위치 · anchor 둘 다 total 을 센다
*/
private async query(
user: string,
sort: readonly { property: string; isAscending: boolean }[],
start: { anchor: string } | { position: number },
limit: number,
options: IndexReadOptions,
): Promise<IndexIdPage | null> {
const responses = await this.call(
user,
(accountId) => [
[
'Email/query',
{
accountId,
sort,
...('anchor' in start
? { anchor: start.anchor, anchorOffset: 1 }
: { position: start.position }),
limit,
calculateTotal: true,
},
'q',
],
],
options,
);
const result = findResult(responses, 'q', 'Email/query');
if (!result.ok) {
if (result.error.type === ANCHOR_NOT_FOUND && 'anchor' in start) {
return null;
}
throw jmapFailure(result.error, { method: 'Email/query' });
}
const query = result.args as JmapQueryResult;
return {
ids: strings(query.ids),
position:
typeof query.position === 'number'
? query.position
: 'position' in start
? start.position
: 0,
total: typeof query.total === 'number' ? query.total : null,
};
}
private async readEmails(
user: string,
ids: readonly string[],
properties: readonly string[],
bodyProperties: readonly string[] | null,
options: IndexReadOptions,
): Promise<JmapEmail[]> {
if (ids.length === 0) return [];
const responses = await this.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [...ids],
properties: [...properties],
...(bodyProperties ? { bodyProperties: [...bodyProperties] } : {}),
},
'g',
],
],
options,
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
return list.filter(
(email): email is JmapEmail =>
typeof email?.id === 'string' && email.id !== '',
);
}
/** 본문 값 — textBody 의 글자 부분, 부분마다 그 몫까지 */
private async readBodies(
user: string,
batch: BodyBatch,
options: IndexReadOptions,
): Promise<JmapEmail[]> {
const responses = await this.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: batch.ids,
properties: ['id', 'textBody', 'bodyValues'],
bodyProperties: ['partId', 'type'],
fetchTextBodyValues: true,
maxBodyValueBytes: batch.quota,
},
'b',
],
],
options,
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'b',
'Email/get',
);
return list.filter((email) => typeof email?.id === 'string');
}
private toDocument(email: JmapEmail, bodyText: string): IndexDocument {
const attachments = email.attachments ?? [];
return {
id: email.id,
threadId: typeof email.threadId === 'string' ? email.threadId : null,
mailboxIds: trueKeys(email.mailboxIds),
keywords: trueKeys(email.keywords),
receivedAt: typeof email.receivedAt === 'string' ? email.receivedAt : '',
size: typeof email.size === 'number' ? email.size : 0,
messageIds: strings(email.messageId),
inReplyTo: strings(email.inReplyTo),
references: strings(email.references),
subject: typeof email.subject === 'string' ? email.subject : '',
from: toAddresses(email.from),
to: toAddresses(email.to),
cc: toAddresses(email.cc),
attachmentNames: indexAttachmentNames(attachments),
hasAttachment: hasRealAttachment(attachments),
preview: typeof email.preview === 'string' ? email.preview : '',
bodyText,
};
}
/**
* 엔진 읽기 한 번 — 차례의 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않는다(IndexDeadlineError).
* 기다림은 남은 시간 · INDEX_CALL_TIMEOUT_MS 가운데 짧은 쪽이다 — 남은 시간으로 줄인 기다림이 끝나 끊겼으면 그것도 IndexDeadlineError
* (엔진이 느린 것이 아니라 차례가 끝난 것이다). 그 밖의 실패는 그대로 던진다
*/
private async call(
user: string,
build: (accountId: string) => JmapInvocation[],
options: IndexReadOptions,
extra: Omit<JmapCallOptions, 'timeoutMs'> = {},
): Promise<JmapInvocation[]> {
const remaining = options.deadline - Date.now();
if (remaining < INDEX_CALL_MIN_MS) throw new IndexDeadlineError();
const cappedByDeadline = remaining < INDEX_CALL_TIMEOUT_MS;
try {
return await this.jmap.call(user, build, {
...extra,
timeoutMs: Math.min(remaining, INDEX_CALL_TIMEOUT_MS),
});
} catch (error) {
if (cappedByDeadline && isTimeout(error)) throw new IndexDeadlineError();
throw error;
}
}
private requireState(state: unknown): string {
if (!isState(state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'index-state-missing' },
});
}
return state;
}
}
@@ -9,6 +9,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_BODY_VALUE_BYTES,
MAX_ENVELOPE_RESUBMITS,
MAX_SEARCH_WORDS,
} from '@/modules/mail/mail.constants';
import { SendNotStartedException } from '@/modules/mail/mail.provider';
import type { MailSettings } from '@/modules/mail/mail.settings';
@@ -29,6 +30,7 @@ import {
} from './jmap.client';
import type { JmapClient, JmapSession } from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapConversations } from './jmap-conversations';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
@@ -38,7 +40,8 @@ import {
parseLargeFileIds,
} from './jmap-mail.provider';
import { MAILBOX_PROPERTIES } from './jmap.mapper';
import type { JmapInvocation } from './jmap.types';
import type { JmapEmailFilter, JmapInvocation } from './jmap.types';
import { particleForms } from './search-terms';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
@@ -194,6 +197,7 @@ function fakeClient(
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
new JmapFilterRunner(client as unknown as JmapClient),
new JmapConversations(client as unknown as JmapClient),
),
};
}
@@ -871,6 +875,298 @@ describe('JmapMailProvider', () => {
});
});
describe('조건 검색 · 색인 검색의 엔진 쪽 (14단계)', () => {
const searchResponses = (
ids: string[],
total: number,
): JmapInvocation[] => [
['Email/query', { ids, position: 0, total, queryState: 's' }, 'q'],
[
'Email/get',
{
list: ids.map((id) => summaryEmail(id, '2026-10-26T09:00:00Z')),
notFound: [],
},
'g',
],
];
const mailboxRoles: JmapInvocation[] = [
[
'Mailbox/get',
{ list: stalwartMailboxes.map(({ id, role }) => ({ id, role })) },
'm',
],
];
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses(['m1'], 1),
]);
const page = await provider.listMessages(ARA, {
limit: 20,
conditions: {
from: '이도윤',
to: 'ara',
subject: '"계약 갱신"',
body: '견적',
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
},
});
const { filter } = argsOf(recorded[1]) as {
filter: { operator: string; conditions: unknown[] };
};
expect(filter.operator).toBe('AND');
expect(filter.conditions[0]).toEqual({
inMailboxOtherThan: ['b', 'c'],
notKeyword: '$ara-sending',
});
expect(filter.conditions.slice(1)).toEqual([
{
operator: 'OR',
conditions: particleForms('이도윤').map((from) => ({ from })),
},
{
operator: 'OR',
conditions: particleForms('ara').flatMap((form) => [
{ to: form },
{ cc: form },
]),
},
{ subject: '"계약 갱신"' },
{
operator: 'OR',
conditions: particleForms('견적').map((body) => ({ body })),
},
{ after: '2026-10-31T15:00:00Z' },
{ before: '2026-11-30T15:00:00Z' },
{ hasAttachment: true },
]);
expect(page.items.map((item) => item.id)).toEqual(['m1']);
});
it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => {
const conditions = { from: '이도윤', hasAttachment: true as const };
const first = fakeClient([
mailboxRoles,
searchResponses(['m1', 'm2'], 3),
]);
const page = await first.provider.listMessages(ARA, {
limit: 2,
text: '회의',
conditions,
});
const { filter } = argsOf(first.recorded[1]) as {
filter: { conditions: unknown[] };
};
// 범위 · 검색어 · 보낸 사람 · 첨부
expect(filter.conditions).toHaveLength(4);
const cursor = page.nextCursor as string;
expect(cursor).not.toBeNull();
for (const query of [
{ limit: 2, text: '회의', cursor },
{ limit: 2, text: '회의', cursor, conditions: { from: '이도윤' } },
{
limit: 2,
text: '회의',
cursor,
conditions: { ...conditions, before: '2026-11-30T15:00:00.000Z' },
},
]) {
const other = fakeClient([]);
expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
'VAL_001',
);
expect(other.client.call).not.toHaveBeenCalled();
}
const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
await next.provider.listMessages(ARA, {
limit: 2,
text: '회의',
cursor,
conditions,
});
expect(argsOf(next.recorded[1])).toMatchObject({ anchor: 'm2' });
});
it('조건만 있는 검색(검색어 없음)도 모든 메일함에서 · 조건 없는 검색의 커서 지문은 예전과 같다', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses([], 0),
]);
await provider.listMessages(ARA, {
limit: 20,
conditions: { hasAttachment: true },
});
const { filter } = argsOf(recorded[1]) as {
filter: { conditions: unknown[] };
};
expect(filter.conditions).toEqual([
{ inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
{ hasAttachment: true },
]);
const plainCursor = encodeCursor(
{ anchor: 'm2', position: 2 },
{ text: '회의' },
);
const plain = fakeClient([mailboxRoles, searchResponses([], 0)]);
await expect(
plain.provider.listMessages(ARA, {
limit: 2,
text: '회의',
cursor: plainCursor,
conditions: {},
}),
).resolves.toMatchObject({ items: [] });
});
it(`엔진 방식의 가장 큰 필터를 고정한다 — 합친 낱말 ${MAX_SEARCH_WORDS}개가 모두 받는 사람(to · cc 둘로 묻는다)이고 영문 · 숫자로 끝나면(형태가 가장 많다) 잎 948개 (리뷰 M2)`, async () => {
/** 필터의 잎(연산자가 아닌 조건) 수 */
const leaves = (filter: JmapEmailFilter): number =>
'operator' in filter
? filter.conditions.reduce((sum, child) => sum + leaves(child), 0)
: 1;
const words = Array.from(
{ length: MAX_SEARCH_WORDS },
(_, index) => `word${index}`,
).join(' ');
const filterOf = async (query: {
text?: string;
conditions?: Record<string, unknown>;
}) => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses([], 0),
]);
await provider.listMessages(ARA, { limit: 20, ...query });
return (argsOf(recorded[1]) as { filter: JmapEmailFilter }).filter;
};
const forms = particleForms('word0').length;
expect(forms).toBe(59);
const worst = await filterOf({
conditions: {
to: words,
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
},
});
// 낱말 8 × 형태 59 × 칸 2(to · cc) + 범위 1 + 기간 2 + 첨부 1
expect(leaves(worst)).toBe(MAX_SEARCH_WORDS * forms * 2 + 4);
expect(leaves(worst)).toBe(948);
// 같은 낱말을 검색어로 두면 칸이 하나 — 3단계에서 잰 472 + 범위
expect(leaves(await filterOf({ text: words }))).toBe(
MAX_SEARCH_WORDS * forms + 1,
);
});
it('글 조건에 찾을 낱말이 하나도 없으면(문장부호뿐) 엔진에 묻지 않고 빈 결과', async () => {
for (const conditions of [{ from: '…' }, { body: '""' }, { to: '-' }]) {
const { provider, client } = fakeClient([]);
await expect(
provider.listMessages(ARA, { limit: 20, conditions }),
).resolves.toEqual({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
expect(client.call).not.toHaveBeenCalled();
}
});
it('색인 검색의 범위 (searchScope) — 모든 메일함이면 휴지통 · 스팸메일함을 빼는 메일함으로, 내부 초안 키워드는 늘 뺀다', async () => {
const { provider, recorded } = fakeClient([mailboxRoles]);
await expect(provider.searchScope(ARA)).resolves.toEqual({
mailboxId: null,
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
});
expect(argsOf(recorded[0])).toEqual({
accountId: 'd',
ids: null,
properties: ['id', 'role'],
});
expect(recorded[0].options).toEqual({ invalidRequestCode: 'MAIL_001' });
});
it('색인 검색의 범위 — 메일함 범위면 그 메일함이 있는지만 본다(없으면 404 RES_001 · 엔진이 해석하지 못하는 id 도 RES_001)', async () => {
const found = fakeClient([
[['Mailbox/get', { list: [{ id: 'a', role: 'inbox' }] }, 'm']],
]);
await expect(found.provider.searchScope(ARA, 'a')).resolves.toEqual({
mailboxId: 'a',
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
});
expect(argsOf(found.recorded[0])).toMatchObject({ ids: ['a'] });
expect(found.recorded[0].options).toEqual({
invalidRequestCode: 'RES_001',
});
const missing = fakeClient([
[['Mailbox/get', { list: [], notFound: ['zz'] }, 'm']],
]);
expect(await codeOf(missing.provider.searchScope(ARA, 'zz'))).toBe(
'RES_001',
);
});
it('찾은 쪽의 요약 (getSummaries) — 목록과 같은 속성 · 기다리는 발송을 한 요청에, 준 차례대로 · 없는 메일 · 형식이 틀린 id 는 빠진다', async () => {
const { provider, recorded } = fakeClient([
[
[
'Email/get',
{
list: [
summaryEmail('m2', '2026-10-26T08:00:00Z'),
summaryEmail('m1', '2026-10-26T09:00:00Z'),
],
notFound: ['m3'],
},
'g',
],
...heldGuard(),
],
]);
const summaries = await provider.getSummaries(ARA, [
'm1',
'm3',
'../x',
'm2',
'm1',
]);
expect(summaries.map((summary) => summary.id)).toEqual(['m1', 'm2']);
expect(summaries[0]).toMatchObject({
subject: '제목 m1',
pendingSend: null,
});
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
]);
expect(argsOf(recorded[0])).toMatchObject({ ids: ['m1', 'm3', 'm2'] });
});
it('요약할 id 가 없으면 엔진에 묻지 않는다', async () => {
const { provider, client } = fakeClient([]);
await expect(provider.getSummaries(ARA, [])).resolves.toEqual([]);
await expect(provider.getSummaries(ARA, ['../x'])).resolves.toEqual([]);
expect(client.call).not.toHaveBeenCalled();
});
});
describe('메일 상세 (getMessage)', () => {
const detailEmail = {
...summaryEmail('m1', '2026-10-26T05:12:00Z', { hasAttachment: true }),
@@ -16,9 +16,15 @@ import {
type MailProvider,
} from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
SearchConditions,
SearchScope,
} from '@/modules/mail-search/mail-search.types';
import type {
AttachmentContent,
BlockedSender,
ConversationListQuery,
ConversationSummaryPage,
Correspondence,
CredentialCheck,
DraftContent,
@@ -62,6 +68,7 @@ import type {
SendHold,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
TwoFactorLoginResult,
UploadedFile,
} from '@/modules/mail/mail.types';
@@ -91,6 +98,7 @@ import {
submissionEnvelope,
} from './envelope';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapConversations } from './jmap-conversations';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import {
@@ -126,6 +134,7 @@ import {
selectAccountQuota,
toAddresses,
toDetailSource,
toJmapUtcDate,
toMailbox,
toSummary,
toUsage,
@@ -148,14 +157,13 @@ import type {
JmapSetResult,
} from './jmap.types';
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
import { INTERNAL_SEND_KEYWORD, excludedMailboxIdsOf } from './mail-scope';
import {
CANCELED_SEND_KEYWORD,
HELD_SUBMISSIONS_MAX,
SCHEDULED_SEND_KEYWORD,
heldAmong,
heldEmailsFilter,
heldOverLimit,
heldQueryFull,
heldSendTimes,
heldSubmissionFilter,
holdParameters,
@@ -164,11 +172,17 @@ import {
readHeldEmails,
readHeldSendTimes,
submissionCalls,
warnIfHeldQueryFull,
submittedPendingSend,
type HeldEmails,
type HeldLookup,
} from './pending-send';
import { searchTextConditions, withSearchConditions } from './search-terms';
import {
searchFieldConditions,
searchTextConditions,
withSearchConditions,
type SearchFieldProperty,
} from './search-terms';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
@@ -182,15 +196,6 @@ const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
/**
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 —
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
*/
const INTERNAL_SEND_KEYWORD = '$ara-sending';
/**
* 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장").
* 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다.
@@ -273,8 +278,60 @@ const BULK_BATCH_SIZE = 500;
const SOURCE_TYPE = 'message/rfc822';
const SOURCE_DOWNLOAD_NAME = 'message.eml';
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
/** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */
const CONDITION_PROPERTIES: readonly (readonly [
keyof Pick<SearchConditions, 'from' | 'to' | 'subject' | 'body'>,
readonly SearchFieldProperty[],
])[] = [
['from', ['from']],
['to', ['to', 'cc']],
['subject', ['subject']],
['body', ['body']],
];
/** 조건 → 커서 지문에 넣는 값들 (정해진 차례 — jmap-cursor.ts 가 JSON 으로 잇는다) — 조건이 없으면 undefined (지문이 예전과 같다) */
function conditionsKey(
conditions: SearchConditions | undefined,
): (string | boolean)[] | undefined {
if (!conditions) return undefined;
const parts = [
conditions.from ?? '',
conditions.to ?? '',
conditions.subject ?? '',
conditions.body ?? '',
conditions.after ?? '',
conditions.before ?? '',
conditions.hasAttachment === true,
];
return parts.some((part) => part !== '' && part !== false)
? parts
: undefined;
}
/**
* 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정.
* 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과)
*/
function conditionFilters(
conditions: SearchConditions | undefined,
): JmapEmailFilter[] | null {
if (!conditions) return [];
const filters: JmapEmailFilter[] = [];
for (const [name, properties] of CONDITION_PROPERTIES) {
const value = conditions[name];
if (!value) continue;
const found = searchFieldConditions(value, properties);
if (found.length === 0) return null;
filters.push(...found);
}
if (conditions.after)
filters.push({ after: toJmapUtcDate(conditions.after) });
if (conditions.before) {
filters.push({ before: toJmapUtcDate(conditions.before) });
}
if (conditions.hasAttachment === true) filters.push({ hasAttachment: true });
return filters;
}
/**
* 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
@@ -807,6 +864,8 @@ export class JmapMailProvider implements MailProvider {
private readonly filterScripts: JmapFilterScriptStore,
/** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */
private readonly filterRuns: JmapFilterRunner,
/** 엔진의 스레드로 본 대화 (jmap-conversations.ts — 14단계, 자체 색인을 다 만들기 전) */
private readonly conversations: JmapConversations,
) {}
verifyCredentials(email: string, password: string): Promise<CredentialCheck> {
@@ -1419,15 +1478,23 @@ export class JmapMailProvider implements MailProvider {
user: string,
query: MessageListQuery,
): Promise<MessagePage> {
const scope = { mailboxId: query.mailboxId, text: query.text };
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
const scope = {
mailboxId: query.mailboxId,
text: query.text,
conditions: conditionsKey(query.conditions),
};
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어·조건의 커서면 VAL_001
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
// 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
// 검색어 · 조건의 글에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
// 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
const textConditions =
query.text !== undefined ? searchTextConditions(query.text) : [];
if (query.text !== undefined && textConditions.length === 0) {
const fieldConditions = conditionFilters(query.conditions);
if (
(query.text !== undefined && textConditions.length === 0) ||
fieldConditions === null
) {
return { items: [], hasNext: false, nextCursor: null, total: 0 };
}
@@ -1439,7 +1506,7 @@ export class JmapMailProvider implements MailProvider {
: await this.searchableMailboxes(user);
const filter = withSearchConditions(
{ ...range, notKeyword: INTERNAL_SEND_KEYWORD },
textConditions,
[...textConditions, ...fieldConditions],
);
// 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts)
@@ -1489,7 +1556,7 @@ export class JmapMailProvider implements MailProvider {
const start = result.position ?? 0;
const total = typeof result.total === 'number' ? result.total : null;
const held = readHeldSendTimes(responses, HELD_CALLS, now);
this.warnIfHeldQueryFull(responses, user);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
const byId = new Map(list.map((email) => [email.id, email]));
@@ -1516,6 +1583,160 @@ export class JmapMailProvider implements MailProvider {
};
}
/**
* 자체 색인으로 찾을 범위 (14단계) — 메일함 범위면 그 메일함이 있는지(없으면 RES_001 — 엔진이 해석하지 못하는 id 도), 모든 메일함이면 빼는 메일함(휴지통 · 스팸메일함).
* 발송 중인 내부 초안의 키워드는 늘 뺀다 (목록과 같다). 엔진 요청 한 번
*/
async searchScope(user: string, mailboxId?: string): Promise<SearchScope> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{
accountId,
ids: mailboxId === undefined ? null : [mailboxId],
properties: ['id', 'role'],
},
'm',
],
],
{ invalidRequestCode: mailboxId === undefined ? 'MAIL_001' : 'RES_001' },
);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
if (mailboxId !== undefined) {
if (!mailboxes.some((mailbox) => mailbox.id === mailboxId)) {
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
return {
mailboxId,
excludedMailboxIds: [],
excludedKeywords: [INTERNAL_SEND_KEYWORD],
};
}
return {
mailboxId: null,
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
excludedKeywords: [INTERNAL_SEND_KEYWORD],
};
}
/**
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다(대화의 통 수 · 같은 대화).
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001 — 엔진이 해석하지 못하는 id 도). 엔진 요청 한 번
*/
async conversationScope(
user: string,
mailboxId?: string,
): Promise<SearchScope> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
],
{ invalidRequestCode: 'MAIL_001' },
);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
if (
mailboxId !== undefined &&
!mailboxes.some((mailbox) => mailbox.id === mailboxId)
) {
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
return {
mailboxId: mailboxId ?? null,
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
excludedKeywords: [INTERNAL_SEND_KEYWORD],
};
}
getConversation(
user: string,
emailId: string,
page: { readonly limit: number; readonly cursor?: string },
): Promise<MessagePage> {
return this.conversations.conversation(user, emailId, page);
}
listConversations(
user: string,
query: ConversationListQuery,
): Promise<ConversationSummaryPage> {
return this.conversations.list(user, query);
}
/**
* 요약 여러 통 (14단계 — 색인으로 찾은 쪽) — 목록과 같은 속성 · 기다리는 발송. `ids` 차례대로, 없는 메일은 빠진다.
* 엔진이 해석하지 못하는 id 는 받지 않는다(색인의 id 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다)
*/
async getSummaries(
user: string,
ids: readonly string[],
): Promise<MessageSummary[]> {
if (!ids.some((id) => MAIL_ID_PATTERN.test(id))) return [];
return (await this.getSummariesWithHeld(user, ids)).summaries;
}
/**
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식) — 같은 요청에서 계정의 기다리는 제출을 한 번 읽는다(보이기 경로 —
* readHeldSendTimes, 읽지 못하면 빈 표). 요약이 없어도(ids 가 비거나 모두 형식이 틀림) 기다리는 제출은 읽는다
*/
async getSummariesWithHeld(
user: string,
ids: readonly string[],
): Promise<SummariesWithHeld> {
const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id));
const now = Date.now();
const responses = await this.jmap.call(user, (accountId) => [
...(valid.length > 0
? ([
[
'Email/get',
{
accountId,
ids: valid,
properties: SUMMARY_PROPERTIES,
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
},
'g',
],
] satisfies JmapInvocation[])
: []),
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
]);
const list =
valid.length > 0
? (expectResult<JmapGetResult<JmapEmail>>(responses, 'g', 'Email/get')
.list ?? [])
: [];
const held = readHeldSendTimes(responses, HELD_CALLS, now);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
const byId = new Map(list.map((email) => [email.id, email]));
return {
summaries: valid.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
}),
heldIds: new Set(held.keys()),
};
}
/**
* 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다.
* 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다
@@ -2605,7 +2826,7 @@ export class JmapMailProvider implements MailProvider {
'g',
'Email/get',
);
this.warnIfHeldQueryFull(responses, user);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
const times = heldSendTimes(submissions, now);
return emails
.flatMap((email) => {
@@ -3615,11 +3836,7 @@ export class JmapMailProvider implements MailProvider {
'm',
'Mailbox/get',
);
const excluded = mailboxes
.filter((mailbox) =>
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
)
.map((mailbox) => mailbox.id);
const excluded = excludedMailboxIdsOf(mailboxes);
return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {};
}
@@ -3878,17 +4095,6 @@ export class JmapMailProvider implements MailProvider {
);
}
/**
* 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull)
* — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX)
*/
private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void {
if (!heldQueryFull(responses, HELD_CALLS)) return;
this.logger.warn(
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
);
}
/** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */
private async submissionsOf(
user: string,
@@ -221,8 +221,18 @@ export interface JmapEmailFilterCondition {
notKeyword?: string;
/** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */
before?: string;
/** 받은 시각이 이 이후(이상)인 메일 — UTCDate (14단계 조건 검색의 기간) */
after?: string;
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
text?: string;
/** 칸마다의 전문 검색 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — 낱말 통째 일치는 text 와 같다 */
from?: string;
to?: string;
cc?: string;
subject?: string;
body?: string;
/** 엔진의 첨부 판정 (본문 그림만 있어도 true 일 수 있다 — 색인 방식과 조금 다르다) */
hasAttachment?: boolean;
}
/** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */
@@ -0,0 +1,27 @@
import type { JmapMailbox } from './jmap.types';
/**
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록 · 검색 · 대화는 이 키워드가 있는 메일을 보이지 않는다 —
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
*/
export const INTERNAL_SEND_KEYWORD = '$ara-sending';
/** 메일함을 정하지 않은 검색 · 같은 대화 · 대화의 통 수에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
export const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'trash',
'junk',
]);
/** 빼는 역할(휴지통 · 스팸메일함)의 메일함 id */
export function excludedMailboxIdsOf(
mailboxes: readonly Pick<JmapMailbox, 'id' | 'role'>[],
): string[] {
return mailboxes
.filter((mailbox) =>
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
)
.map((mailbox) => mailbox.id);
}
@@ -1,5 +1,9 @@
import type { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { PendingSend, SendHold } from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { toJmapUtcDate, toUtcIso } from './jmap.mapper';
import { expectResult, findResult } from './jmap-results';
import type {
@@ -212,6 +216,22 @@ export function heldQueryFull(
}
}
/**
* 보이기 경로(목록 · 예약 목록 · 대화)의 계정 전체 제출 조회가 다 주지 못했으면(heldQueryFull) 경고만 남긴다 — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다.
* 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX). 로그는 코드 · 추적 ID · 가린 주소만
*/
export function warnIfHeldQueryFull(
logger: Logger,
responses: JmapInvocation[],
prefix: string,
user: string,
): void {
if (!heldQueryFull(responses, prefix)) return;
logger.warn(
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
);
}
/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */
export interface HeldEmails {
/** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */
@@ -2,6 +2,7 @@ import {
SEARCH_PARTICLES,
parseSearchTerms,
particleForms,
searchFieldConditions,
searchTextConditions,
withSearchConditions,
} from './search-terms';
@@ -365,4 +366,57 @@ describe('검색어 → 엔진 조건 (search-terms)', () => {
);
});
});
describe('칸 조건 (searchFieldConditions — 14단계 조건 검색의 엔진 방식)', () => {
it('칸 하나 — 낱말은 그 칸의 형태 OR, 구절은 그 칸의 따옴표 구절', () => {
const [word, phrase] = searchFieldConditions('계약 "갱신 일정"', [
'subject',
]);
expect(word).toEqual({
operator: 'OR',
conditions: particleForms('계약').map((subject) => ({ subject })),
});
expect(phrase).toEqual({ subject: '"갱신 일정"' });
});
it('칸이 여럿(받는 사람 = to · cc)이면 형태마다 두 칸의 OR — 낱말끼리는 AND(목록의 차례)', () => {
const conditions = searchFieldConditions('ara 東京', ['to', 'cc']);
expect(conditions).toHaveLength(2);
expect(conditions[0]).toEqual({
operator: 'OR',
conditions: particleForms('ara').flatMap((form) => [
{ to: form },
{ cc: form },
]),
});
// 형태가 하나뿐이어도 칸이 둘이면 OR
expect(conditions[1]).toEqual({
operator: 'OR',
conditions: [{ to: '東京' }, { cc: '東京' }],
});
});
it('검색어 조건(searchTextConditions)은 text 칸 하나의 칸 조건과 같다 · 찾을 낱말이 없으면 빈 목록', () => {
expect(searchTextConditions('서버 "이전 계획"')).toEqual(
searchFieldConditions('서버 "이전 계획"', ['text']),
);
expect(searchFieldConditions('… -', ['body'])).toEqual([]);
});
});
it('검색 글의 NUL 은 U+FFFD 로 1:1 바꾼다 — 색인의 저장과 같고 엔진 방식도 같은 낱말로 묻는다 (리뷰 L2)', () => {
const NUL = String.fromCharCode(0);
const FFFD = String.fromCharCode(0xfffd);
expect(parseSearchTerms(`계${NUL}약 "a${NUL}b"`)).toEqual([
{ kind: 'word', text: `계${FFFD}약` },
{ kind: 'phrase', text: `a${FFFD}b` },
]);
// 엔진에 보내는 조건(JSON)에 NUL 이 없다 — 이스케이프한 꼴도 없다
const sent = JSON.stringify(searchTextConditions(`계${NUL}약`));
expect(sent.includes(NUL)).toBe(false);
expect(sent).not.toContain(`${String.fromCharCode(92)}u0000`);
expect(sent).toContain(`계${FFFD}약`);
// NUL 만이면 찾을 글자가 없다 — 빈 목록
expect(parseSearchTerms(NUL.repeat(3))).toEqual([]);
});
});
+40 -72
View File
@@ -1,4 +1,4 @@
import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants';
import { parseSearchTerms } from '@/shared/utils/search-query';
import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
/**
@@ -14,6 +14,9 @@ import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
* 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다.
*/
/** 검색어 나누기는 엔진과 상관없어 shared/utils/search-query.ts 로 옮겼다(14단계 — 색인 찾기와 함께 쓴다) */
export { parseSearchTerms, type SearchTerm } from '@/shared/utils/search-query';
/**
* 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절).
* 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다
@@ -89,6 +92,9 @@ export const SEARCH_PARTICLES = {
],
} as const;
/** 큰따옴표 — 구절 조건(`{ text: '"…"' }`)을 감싼다 */
const QUOTE = '"';
/** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */
const HANGUL_SYLLABLE_FIRST = 0xac00;
const HANGUL_SYLLABLE_LAST = 0xd7a3;
@@ -102,29 +108,6 @@ const EURO = '으로';
/** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */
const ASCII_ALNUM_END = /[A-Za-z0-9]$/;
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
const QUOTE = '"';
/**
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
*/
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
/**
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
*/
const WORD_EDGE_PUNCTUATION =
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
function coreWord(word: string): string {
return word.replace(WORD_EDGE_PUNCTUATION, '');
}
/** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */
type WordEnding =
/** 받침 없는 한글 */
@@ -186,61 +169,46 @@ export function particleForms(word: string): string[] {
]);
}
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
export interface SearchTerm {
readonly kind: 'word' | 'phrase';
readonly text: string;
}
function splitWords(text: string): string[] {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
}
/**
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
*/
export function parseSearchTerms(query: string): SearchTerm[] {
const segments = query.normalize('NFC').split(QUOTE);
const terms: SearchTerm[] = [];
const seen = new Set<string>();
const add = (kind: SearchTerm['kind'], text: string) => {
const key = `${kind}\n${text}`;
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
seen.add(key);
terms.push({ kind, text });
};
segments.forEach((segment, index) => {
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
const isPhrase = index % 2 === 1 && index < segments.length - 1;
if (isPhrase) {
add('phrase', splitWords(segment).join(' '));
return;
}
for (const word of splitWords(segment)) add('word', coreWord(word));
});
return terms;
}
/**
* 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다).
* 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나).
* 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다.
*/
export function searchTextConditions(query: string): JmapEmailFilter[] {
return searchFieldConditions(query, ['text']);
}
/** 글로 찾는 엔진 필터 칸 — text(모든 칸) · 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 */
export type SearchFieldProperty =
| 'text'
| 'from'
| 'to'
| 'cc'
| 'subject'
| 'body';
/**
* 칸 조건 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — searchTextConditions 와 같은 낱말 · 구절 · 조사 형태를 그 칸에서 찾는다.
* 칸이 여럿이면(받는 사람 = to · cc) 그 가운데 하나에 있으면 된다 — 낱말마다 (형태 × 칸)의 OR, 낱말끼리는 AND.
* 찾을 낱말이 하나도 없으면 빈 목록
*/
export function searchFieldConditions(
query: string,
properties: readonly SearchFieldProperty[],
): JmapEmailFilter[] {
return parseSearchTerms(query).map((term): JmapEmailFilter => {
if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` };
const forms = particleForms(term.text);
return forms.length === 1
? { text: forms[0] }
: { operator: 'OR', conditions: forms.map((text) => ({ text })) };
const forms =
term.kind === 'phrase'
? [`${QUOTE}${term.text}${QUOTE}`]
: particleForms(term.text);
const conditions = forms.flatMap((form) =>
properties.map(
(property): JmapEmailFilterCondition => ({ [property]: form }),
),
);
return conditions.length === 1
? conditions[0]
: { operator: 'OR', conditions };
});
}
@@ -15,6 +15,7 @@ import {
type MailEventSink,
} from './mail-events.service';
import type { RevokedSessions } from '@/modules/auth/revoked-sessions';
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type { MailProvider } from './mail.provider';
import type {
MailLiveState,
@@ -132,6 +133,8 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
let service: MailEventsService;
/** 끊은 세션 기억 대역 — 테스트가 넣는다 */
let revokedIds: Set<string>;
/** 검색 색인 맞추기 대역 (14단계) — 맞춰 달라는 직원을 모은다 */
let searchSync: { requestSync: jest.Mock };
const connect = (
user: string,
@@ -171,9 +174,11 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
const revoked = {
isRevoked: (sessionId: string) => revokedIds.has(sessionId),
};
searchSync = { requestSync: jest.fn() };
service = new MailEventsService(
fake.provider,
revoked as unknown as RevokedSessions,
searchSync as unknown as MailSearchSyncService,
);
});
@@ -207,6 +212,9 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
}
expect(other.events).toHaveLength(1);
// 메일이 바뀐 소식 — 그 직원의 검색 색인을 맞춰 달라고 한다 (14단계 — 직원마다 한 번, 모으기는 맞추기 쪽이 한다)
expect(searchSync.requestSync.mock.calls).toEqual([[ARA]]);
// 나중에 들어온 연결은 마지막으로 알게 된 상태로 시작한다 (엔진에 다시 묻지 않는다)
const late = await connected(ARA);
expect(late.events).toEqual([
@@ -8,6 +8,7 @@ import {
import { BusinessException } from '@/common/errors/business.exception';
import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants';
import { RevokedSessions } from '@/modules/auth/revoked-sessions';
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import {
runOutsideRequestContext,
traceTag,
@@ -149,6 +150,8 @@ export class MailEventsService implements BeforeApplicationShutdown {
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
private readonly revokedSessions: RevokedSessions,
/** 14단계 — 엔진의 state 소식이 오면 그 직원의 검색 색인을 맞춘다 (300ms 모아서 — 던지지 않는다) */
private readonly searchSync: MailSearchSyncService,
) {}
/**
@@ -312,6 +315,8 @@ export class MailEventsService implements BeforeApplicationShutdown {
state: (live) => {
hub.live = live;
this.broadcast(hub, { type: 'state', data: live });
// 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있는 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다)
this.searchSync.requestSync(hub.user);
},
newMail: (notice) =>
this.broadcast(hub, { type: 'new-mail', data: notice }),
+2 -2
View File
@@ -56,8 +56,8 @@ export const MAX_SEARCH_LENGTH = 200;
/** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */
export const MAX_SEARCH_WORDS = 8;
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */
export const SEARCH_WORD_SEPARATOR = /\s+/u;
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 (shared/utils/search-query.ts) */
export { SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-query';
/**
* 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 —
+13
View File
@@ -10,6 +10,7 @@ import { AuthSessionModule } from '@/modules/auth/auth-session.module';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
import { SystemMailModule } from '@/modules/system-mail/system-mail.module';
@@ -27,6 +28,7 @@ import {
DnsMailImageResolver,
NodeMailImageTransport,
} from './images/node-image-transport';
import { JmapConversations } from './jmap/jmap-conversations';
import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
@@ -55,6 +57,9 @@ import {
MailVacationController,
} from './mailbox.controller';
import { MessageDeletionController } from './message-deletion.controller';
import { MessageListService } from './message-list.service';
import { ConversationController } from './conversation.controller';
import { MailConversationService } from './conversation.service';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
@@ -127,6 +132,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
AuthSessionModule,
// 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다
SystemMailModule,
// 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만)
MailSearchModule,
],
controllers: [
MailboxController,
@@ -138,6 +145,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailFilterRunController,
MailBlockController,
MessageController,
// 같은 대화 · 대화로 묶은 목록 (14단계)
ConversationController,
MessageDeletionController,
ScheduledMessageController,
MailAttachmentController,
@@ -162,8 +171,12 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
JmapFilterScriptStore,
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
JmapFilterRunner,
JmapConversations,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 메일 목록 · 검색 (14단계 — 색인 방식 · 엔진 방식을 고르고 강조를 단다)
MessageListService,
MailConversationService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
MailEventsService,
// 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file)
+49
View File
@@ -1,7 +1,10 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import type {
AttachmentContent,
BlockedSender,
ConversationListQuery,
ConversationSummaryPage,
Correspondence,
CredentialCheck,
DraftContent,
@@ -40,6 +43,7 @@ import type {
SavedDraft,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
TwoFactorLoginResult,
UploadedFile,
} from './mail.types';
@@ -254,6 +258,51 @@ export interface MailProvider {
*/
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/**
* 자체 색인으로 찾을 범위 (14단계 — 색인 방식 검색) — 메일함 하나(없으면 RES_001), 또는 휴지통 · 스팸메일함만 뺀 모든 메일함과
* 목록에서 빼는 키워드(발송 중인 내부 초안). 엔진의 메일함 역할 · 키워드 이름은 어댑터만 안다
*/
searchScope(user: string, mailboxId?: string): Promise<SearchScope>;
/**
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다.
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001)
*/
conversationScope(user: string, mailboxId?: string): Promise<SearchScope>;
/**
* 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때)
*/
getSummaries(user: string, ids: readonly string[]): Promise<MessageSummary[]>;
/**
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식: 줄의 messageIds 가운데 붙잡힌 메일 pendingSendIds).
* getSummaries 와 같은 요청 하나 — 기다리는 제출은 한 번만 읽는다
*/
getSummariesWithHeld(
user: string,
ids: readonly string[],
): Promise<SummariesWithHeld>;
/**
* 같은 대화 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되
* 지금 보는 메일(`emailId`)은 넣는다. 그 메일이 없으면 RES_001. 커서는 이 메일의 대화에서만 쓴다(다르면 VAL_001)
*/
getConversation(
user: string,
emailId: string,
page: { readonly limit: number; readonly cursor?: string },
): Promise<MessagePage>;
/**
* 대화로 묶은 목록 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 그 메일함 안 마지막 받은 시각 최신부터. 없는 메일함은 RES_001.
* 커서는 그 메일함의 묶은 목록에서만 쓴다(다르면 VAL_001)
*/
listConversations(
user: string,
query: ConversationListQuery,
): Promise<ConversationSummaryPage>;
/**
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
*/
+16 -56
View File
@@ -99,6 +99,12 @@ function fakeProvider(): ProviderMocks {
getMailState: mockOf<'getMailState'>(),
watchMail: mockOf<'watchMail'>(),
listMessages: mockOf<'listMessages'>(),
searchScope: mockOf<'searchScope'>(),
conversationScope: mockOf<'conversationScope'>(),
getSummaries: mockOf<'getSummaries'>(),
getSummariesWithHeld: mockOf<'getSummariesWithHeld'>(),
getConversation: mockOf<'getConversation'>(),
listConversations: mockOf<'listConversations'>(),
getMessage: mockOf<'getMessage'>(),
updateMessage: mockOf<'updateMessage'>(),
updateMessages: mockOf<'updateMessages'>(),
@@ -281,9 +287,11 @@ interface NinthStepMocks {
function fakeNinthStep(): NinthStepMocks {
return {
userSettings: {
get: jest
.fn<Promise<MailUserSettings>, [string]>()
.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
get: jest.fn<Promise<MailUserSettings>, [string]>().mockResolvedValue({
undoSendSeconds: 0,
externalImages: 'ask',
groupByConversation: false,
}),
},
contacts: {
hasEmail: jest
@@ -452,59 +460,7 @@ describe('MailService', () => {
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
});
it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => {
provider.listMessages.mockResolvedValue({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' });
await service.listMessages(user, {
mailboxId: 'a',
limit: 20,
q: ' 이전 ',
});
expect(provider.listMessages.mock.calls[0][1]).toMatchObject({
text: undefined,
});
expect(provider.listMessages.mock.calls[1][1]).toMatchObject({
text: '이전',
});
});
describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => {
const emptyPage = {
items: [],
hasNext: false,
nextCursor: null,
total: 0,
};
it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => {
provider.listMessages.mockResolvedValue(emptyPage);
await service.listMessages(user, { limit: 20, q: ' 회의 ' });
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '회의',
});
});
it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => {
for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) {
expect(await codeOf(() => service.listMessages(user, query))).toBe(
'VAL_001',
);
}
expect(provider.listMessages).not.toHaveBeenCalled();
});
describe('새 메일 확인 · 여러 통 (3단계 — 목록 · 검색은 message-list.service.spec.ts)', () => {
it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => {
provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' });
@@ -2866,6 +2822,7 @@ describe('MailService', () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 20,
externalImages: 'ask',
groupByConversation: false,
});
await service.sendMessage(user, sendDto());
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
@@ -2876,6 +2833,7 @@ describe('MailService', () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 0,
externalImages: 'ask',
groupByConversation: false,
});
await service.sendMessage(user, sendDto());
expect(sentMessage()).not.toHaveProperty('hold');
@@ -2901,6 +2859,7 @@ describe('MailService', () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: false,
});
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const first = sentMessage();
@@ -2983,6 +2942,7 @@ describe('MailService', () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 10,
externalImages,
groupByConversation: false,
});
/** 본문의 중계 주소 → 표를 푼 원래 주소 */
const proxied = (html: string | null) =>
+25 -30
View File
@@ -42,7 +42,6 @@ import {
SHOW_IMAGES,
type MessageDetailQueryDto,
} from './dto/message-detail-query.dto';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type {
ComposeContentDto,
DraftBodyDto,
@@ -58,6 +57,7 @@ import {
selectInlineImages,
toDataUri,
} from './html/inline-images';
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
@@ -118,6 +118,7 @@ import type {
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { highlightDetail } from './message-highlight';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import {
idempotentMessageId,
@@ -660,34 +661,6 @@ export class MailService {
};
}
/**
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
*/
async listMessages(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageSummary>> {
const text = query.q?.trim() || undefined;
if (query.mailboxId === undefined && text === undefined) {
throw new BusinessException('VAL_001', {
context: { reason: 'mailbox-or-search-required' },
});
}
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text,
});
return toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
});
}
/**
* 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
* 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
@@ -705,6 +678,26 @@ export class MailService {
id: string,
query: MessageDetailQueryDto = {},
): Promise<MessageDetail> {
// 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다).
// 보낸 사람 · 받는 사람 조건은 강조하지 않지만 넘긴다 — 본문에 강조할 말(한 글자 낱말)을 목록과 같게 고른다
return highlightDetail(
await this.readMessage(user, id, query),
highlightTermsOf({
text: query.q,
from: query.from,
to: query.to,
subject: query.subject,
body: query.body,
}),
);
}
/** getMessage 의 본체 — 강조 전의 상세 */
private async readMessage(
user: AuthenticatedUser,
id: string,
query: MessageDetailQueryDto,
): Promise<Omit<MessageDetail, 'searchHighlight'>> {
const {
rawHtmlBody,
isRawHtmlBodyTruncated,
@@ -724,7 +717,9 @@ export class MailService {
}));
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
const withoutHtml = (
isHtmlBodyOmitted: boolean,
): Omit<MessageDetail, 'searchHighlight'> => ({
...detail,
attachments: markInline(NO_INLINE_ATTACHMENTS),
htmlBody: null,
+67
View File
@@ -6,6 +6,11 @@ import type {
LargeFile,
MissingLargeFile,
} from '@/modules/file-storage/file-storage.types';
import type {
MessageDetailHighlight,
MessageListHighlight,
SearchConditions,
} from '@/modules/mail-search/mail-search.types';
/**
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
@@ -49,6 +54,11 @@ export interface PendingSend {
readonly kind: PendingSendKind;
}
/** 목록 항목 (GET /api/messages — 14단계) — 요약 + 검색 강조. 색인으로 찾았을 때만 강조가 있고, 목록 · 엔진 방식 검색이면 null */
export interface MessageListItem extends MessageSummary {
readonly searchHighlight: MessageListHighlight | null;
}
export interface MessageSummary {
readonly id: string;
readonly threadId: string;
@@ -137,6 +147,11 @@ export interface MessageDetail extends MessageSummary {
* 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다
*/
readonly largeFiles: MessageLargeFile[];
/**
* 검색 강조 (14단계 — 같은 검색 쿼리로 열었을 때) — 제목 · 첨부 이름(맞은 것만) · 글 본문의 맞은 곳. htmlBody 는 글 조각에 `<mark>` 를 넣어 준다.
* 검색 쿼리가 없으면 null
*/
readonly searchHighlight: MessageDetailHighlight | null;
}
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
@@ -165,6 +180,7 @@ export interface MessageDetailSource extends Omit<
| 'isBodyTruncated'
| 'attachments'
| 'largeFiles'
| 'searchHighlight'
> {
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
readonly attachments: MailAttachment[];
@@ -189,6 +205,8 @@ export interface MessageListQuery {
readonly cursor?: string;
/** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */
readonly text?: string;
/** 조건 검색의 조건 (14단계 — 엔진 방식은 JMAP 필터로 · 낱말 통째 일치) */
readonly conditions?: SearchConditions;
}
export interface MessagePage {
@@ -198,6 +216,55 @@ export interface MessagePage {
readonly total: number | null;
}
/**
* 묶은 목록의 대화 하나 (14단계 — GET /api/conversations). 고르기 · 읽음 · 중요 · 이동 · 삭제는 `messageIds`(그 메일함 안)에 여러 통 API 로 한다
*/
export interface ConversationSummary {
/** 대화 — 불투명 (색인이면 대화 키, 엔진이면 스레드 id) */
readonly id: string;
/** 그 메일함 안 마지막 메일 */
readonly latest: MessageSummary;
/**
* 보낸 사람들 — 받은 차례, 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지 (대화 전체 — 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만)
*/
readonly participants: MailAddress[];
/**
* 대화 전체의 메일 수 — 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다
*/
readonly messageCount: number;
/** 그 메일함 안 그 대화의 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 (엔진 방식에서 상한을 넘은 스레드는 읽은 최신 메일에서만) */
readonly messageIds: string[];
/** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일) — 없으면 빈 배열. 화면은 여러 통 처리에서 뺀다 */
readonly pendingSendIds: string[];
/** 그 메일함 안 메일을 모두 읽었다 */
readonly isRead: boolean;
/** 그 메일함 안 메일 가운데 하나라도 중요 표시 */
readonly isFlagged: boolean;
/** 그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 첨부 표시와 같은 판정) */
readonly hasAttachment: boolean;
}
/** 요약 여러 통과 계정의 붙잡힌 메일 (14단계 3차 — 묶은 목록의 색인 방식, 기다리는 제출은 한 번만 읽는다) */
export interface SummariesWithHeld {
readonly summaries: MessageSummary[];
/** 지금 붙잡힌 메일 id — 보이기 경로의 판정(보낼 시각이 아직 · pendingSend 와 같다), 계정 전체에서 보낼 시각이 이른 것부터 HELD_SUBMISSIONS_MAX 개까지 */
readonly heldIds: ReadonlySet<string>;
}
export interface ConversationSummaryPage {
readonly items: ConversationSummary[];
readonly hasNext: boolean;
readonly nextCursor: string | null;
readonly total: number | null;
}
/** 묶은 목록 쿼리 (엔진 방식) */
export interface ConversationListQuery {
readonly mailboxId: string;
readonly limit: number;
readonly cursor?: string;
}
export interface MessageUpdate {
readonly isRead?: boolean;
readonly isFlagged?: boolean;
@@ -0,0 +1,175 @@
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import type { MessageDetail } from './mail.types';
import { highlightDetail } from './message-highlight';
type Detail = Omit<MessageDetail, 'searchHighlight'>;
function detail(overrides: Partial<Detail> = {}): Detail {
return {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
cc: [],
bcc: [],
replyTo: [],
subject: '[공지] 계약 갱신 안내',
preview: '계약 갱신 일정',
receivedAt: '2026-11-02T01:00:00.000Z',
sentAt: '2026-11-02T01:00:00.000Z',
messageId: 'x@example.com',
references: [],
isRead: true,
isFlagged: false,
hasAttachment: true,
size: 2048,
pendingSend: null,
htmlBody: '<p>이번 달 <b>계약</b> 갱신 일정입니다.</p>',
textBody: '이번 달 계약 갱신 일정입니다.',
hasBlockedImages: false,
imagesShown: false,
isHtmlBodyOmitted: false,
isBodyTruncated: false,
attachments: [
{
id: 'p1',
name: '계약서_초안.pdf',
type: 'application/pdf',
size: 100,
isInline: false,
},
{
id: 'p2',
name: '견적.xlsx',
type: 'application/vnd.ms-excel',
size: 100,
isInline: false,
},
],
largeFiles: [],
...overrides,
};
}
const slices = (text: string, ranges: readonly (readonly [number, number])[]) =>
ranges.map(([start, end]) => text.slice(start, end));
describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계 C2)', () => {
it('검색 쿼리가 없으면 searchHighlight 는 null 이고 나머지는 그대로', () => {
const source = detail();
expect(highlightDetail(source, highlightTermsOf({}))).toEqual({
...source,
searchHighlight: null,
});
});
it('검색어는 제목 · 본문(textBody 위치 · htmlBody 의 <mark>) · 맞은 첨부 이름에', () => {
const result = highlightDetail(
detail(),
highlightTermsOf({ text: '계약' }),
);
expect(slices(result.subject, result.searchHighlight!.subject)).toEqual([
'계약',
]);
expect(result.searchHighlight!.attachments).toEqual({ p1: [[0, 2]] });
expect(slices(result.textBody!, result.searchHighlight!.textBody)).toEqual([
'계약',
]);
expect(result.htmlBody).toBe(
'<p>이번 달 <b><mark>계약</mark></b> 갱신 일정입니다.</p>',
);
});
it('제목 조건은 제목에만, 내용 조건은 본문에만 — 첨부 이름은 검색어로만', () => {
const subjectOnly = highlightDetail(
detail(),
highlightTermsOf({ subject: '갱신' }),
);
expect(subjectOnly.searchHighlight).toEqual({
subject: [[8, 10]],
attachments: {},
textBody: [],
});
expect(subjectOnly.htmlBody).toBe(detail().htmlBody);
const bodyOnly = highlightDetail(
detail(),
highlightTermsOf({ body: '일정' }),
);
expect(bodyOnly.searchHighlight!.subject).toEqual([]);
expect(bodyOnly.searchHighlight!.attachments).toEqual({});
expect(
slices(bodyOnly.textBody!, bodyOnly.searchHighlight!.textBody),
).toEqual(['일정']);
expect(bodyOnly.htmlBody).toContain('<mark>일정</mark>');
});
it('한 글자 낱말뿐이면 본문은 강조하지 않는다 — 본문을 찾지 않았다, 제목 · 첨부 이름은 그대로 (2026-11-06 실사용 시험)', () => {
const source = detail({
subject: '견적 안내',
htmlBody: '<p>이번 달 견적 안내</p>',
textBody: '이번 달 견적 안내',
});
// '견' 은 제목 · 첨부 이름(견적.xlsx) · 본문에, '달' 은 본문에만 있다
const single = highlightDetail(source, highlightTermsOf({ text: '달 견' }));
expect(single.searchHighlight).toEqual({
subject: [[0, 1]],
attachments: { p2: [[0, 1]] },
textBody: [],
});
expect(single.htmlBody).toBe('<p>이번 달 견적 안내</p>');
// 두 글자 이상 낱말과 섞이면 그 낱말만 본문에 — 한 글자 낱말은 제목 · 첨부 이름에만
const mixed = highlightDetail(
source,
highlightTermsOf({ text: '달 견적' }),
);
expect(slices(mixed.textBody!, mixed.searchHighlight!.textBody)).toEqual([
'견적',
]);
expect(mixed.htmlBody).toBe('<p>이번 달 <mark>견적</mark> 안내</p>');
expect(mixed.searchHighlight!.subject).toEqual([[0, 2]]);
expect(mixed.searchHighlight!.attachments).toEqual({ p2: [[0, 2]] });
});
it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => {
const nfd = (text: string) => text.normalize('NFD');
const result = highlightDetail(
detail({
subject: nfd('계약 안내'),
textBody: nfd('계약 내용'),
attachments: [
{
id: 'p1',
name: nfd('계약서.pdf'),
type: 'application/pdf',
size: 1,
isInline: false,
},
],
}),
highlightTermsOf({ text: '계약' }),
);
expect(result.subject).toBe('계약 안내');
expect(result.textBody).toBe('계약 내용');
expect(result.attachments[0].name).toBe('계약서.pdf');
expect(result.searchHighlight).toEqual({
subject: [[0, 2]],
attachments: { p1: [[0, 2]] },
textBody: [[0, 2]],
});
});
it('본문이 없거나 HTML 을 비웠으면(isHtmlBodyOmitted) 있는 쪽에만', () => {
const result = highlightDetail(
detail({ htmlBody: null, isHtmlBodyOmitted: true, textBody: null }),
highlightTermsOf({ text: '계약' }),
);
expect(result.htmlBody).toBeNull();
expect(result.textBody).toBeNull();
expect(result.searchHighlight!.textBody).toEqual([]);
});
});
@@ -0,0 +1,62 @@
import type {
MessageDetailHighlight,
SearchRange,
} from '@/modules/mail-search/mail-search.types';
import type { HighlightTerms } from '@/modules/mail-search/search-plan';
import { matchRanges } from '@/shared/utils/search-text';
import { markHtmlText } from './html/html-mark';
import type { MessageDetail } from './mail.types';
/**
* 읽기 화면의 검색 강조 (순수 함수 — 14단계 설계 C2) — 검색에서 연 메일을 같은 검색 쿼리로 받으면 맞은 곳을 알려 준다.
* - 제목: 검색어 · 제목 조건의 말 / 본문(textBody 위치 · htmlBody 의 `<mark>`): 검색어 · 내용 조건의 말 / 첨부 이름: 검색어의 말(맞은 첨부만)
* - 위치는 NFC 로 맞춘 글 기준이라 강조가 있으면 subject · 첨부 이름 · textBody 를 NFC 로 바꿔 싣는다(화면에는 같게 보인다)
* - htmlBody 는 **정화한 뒤** 의 HTML 에만 `<mark>` 를 넣는다(html/html-mark.ts — 태그 · 속성은 건드리지 않는다)
* 강조할 말이 없으면 searchHighlight 는 null 이고 나머지는 그대로다
*/
export function highlightDetail(
detail: Omit<MessageDetail, 'searchHighlight'>,
terms: HighlightTerms | null,
): MessageDetail {
if (terms === null) return { ...detail, searchHighlight: null };
const subject = detail.subject.normalize('NFC');
const attachments = detail.attachments.map((attachment) => ({
...attachment,
name: attachment.name.normalize('NFC'),
}));
const attachmentRanges: Record<string, SearchRange[]> = {};
if (terms.attachment.length > 0) {
for (const attachment of attachments) {
const ranges = matchRanges(attachment.name, terms.attachment);
if (ranges.length > 0) {
attachmentRanges[attachment.id] = ranges.map(
([start, end]) => [start, end] as const,
);
}
}
}
const textBody = detail.textBody?.normalize('NFC') ?? null;
const highlight: MessageDetailHighlight = {
subject: matchRanges(subject, terms.subject).map(
([start, end]) => [start, end] as const,
),
attachments: attachmentRanges,
textBody:
textBody !== null && terms.body.length > 0
? matchRanges(textBody, terms.body).map(
([start, end]) => [start, end] as const,
)
: [],
};
return {
...detail,
subject,
attachments,
textBody,
htmlBody:
detail.htmlBody === null || terms.body.length === 0
? detail.htmlBody
: markHtmlText(detail.htmlBody, terms.body).html,
searchHighlight: highlight,
};
}
@@ -0,0 +1,442 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import type {
IndexHighlightText,
IndexSearchPage,
MailSearchQueryService,
} from '@/modules/mail-search/mail-search-query.service';
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import { encodeIndexCursor } from '@/modules/mail-search/search-cursor';
import { encodeCursor } from './jmap/jmap-cursor';
import type { MailProvider } from './mail.provider';
import type { MessagePage, MessageSummary } from './mail.types';
import { MessageListService } from './message-list.service';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const ALL_SCOPE: SearchScope = {
mailboxId: null,
excludedMailboxIds: ['trash', 'junk'],
excludedKeywords: ['$ara-sending'],
};
function summary(
id: string,
extra: Partial<MessageSummary> = {},
): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: user.email }],
subject: `${id} 계약 갱신`,
preview: '계약 갱신 일정을 알려 드립니다',
receivedAt: '2026-11-02T01:00:00.000Z',
isRead: false,
isFlagged: false,
hasAttachment: false,
size: 1200,
pendingSend: null,
...extra,
};
}
const emptyEnginePage: MessagePage = {
items: [],
hasNext: false,
nextCursor: null,
total: 0,
};
function setup(options: { ready?: boolean } = {}) {
const provider = {
listMessages: jest.fn().mockResolvedValue(emptyEnginePage),
searchScope: jest.fn().mockResolvedValue(ALL_SCOPE),
getSummaries: jest.fn().mockResolvedValue([]),
};
const searchIndex = {
isSearchReady: jest.fn().mockResolvedValue(options.ready ?? true),
};
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
const indexPage: IndexSearchPage = {
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
};
const searchQuery = {
search: jest.fn().mockResolvedValue(indexPage),
highlightTexts: jest
.fn()
.mockResolvedValue(new Map<string, IndexHighlightText>()),
};
const service = new MessageListService(
provider as unknown as MailProvider,
searchIndex as unknown as MailSearchIndexService,
searchSync as unknown as MailSearchSyncService,
searchQuery as unknown as MailSearchQueryService,
);
return { service, provider, searchIndex, searchSync, searchQuery };
}
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
try {
await run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => {
warnSpy.mockRestore();
});
it('메일함도 검색도 없으면(공백뿐인 검색어 포함) 엔진 · 색인에 묻지 않고 VAL_001', async () => {
const { service, provider, searchIndex } = setup();
for (const query of [
{ limit: 20 },
{ limit: 20, q: ' ' },
{ limit: 20, from: ' ', subject: '' },
]) {
expect(await codeOf(() => service.list(user, query))).toBe('VAL_001');
}
expect(provider.listMessages).not.toHaveBeenCalled();
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
});
it('검색이 아닌 목록은 늘 엔진 — 색인 상태를 묻지 않고, 항목의 searchHighlight 는 null', async () => {
const { service, provider, searchIndex } = setup();
provider.listMessages.mockResolvedValue({
items: [summary('m1')],
hasNext: true,
nextCursor: 'next',
total: 9,
});
const page = await service.list(user, {
mailboxId: 'inbox',
limit: 20,
q: ' ',
});
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
mailboxId: 'inbox',
limit: 20,
cursor: undefined,
text: undefined,
conditions: undefined,
});
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
expect(page.items).toEqual([{ ...summary('m1'), searchHighlight: null }]);
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: 'next',
total: 9,
});
});
it('색인이 준비되지 않았으면 엔진 방식 — 검색어 앞뒤 공백을 지우고, 준 조건만 넘긴다', async () => {
const { service, provider, searchQuery } = setup({ ready: false });
await service.list(user, { limit: 20, q: ' 이전 ' });
await service.list(user, {
limit: 20,
from: ' 이도윤 ',
hasAttachment: 'true',
after: '2026-10-31T15:00:00.000Z',
});
expect(provider.listMessages).toHaveBeenNthCalledWith(1, user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '이전',
conditions: undefined,
});
expect(provider.listMessages).toHaveBeenNthCalledWith(2, user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: undefined,
conditions: {
from: '이도윤',
after: '2026-10-31T15:00:00.000Z',
hasAttachment: true,
},
});
expect(searchQuery.search).not.toHaveBeenCalled();
});
it('색인이 준비됐으면 색인 방식 — 바로 전 맞추기 → 엔진의 범위 → 색인 찾기 → 찾은 쪽의 요약은 엔진에서, 찾은 차례대로', async () => {
const { service, provider, searchSync, searchQuery } = setup();
const order: string[] = [];
searchSync.syncBeforeRead.mockImplementation(() => {
order.push('sync');
return Promise.resolve(true);
});
provider.searchScope.mockImplementation(() => {
order.push('scope');
return Promise.resolve(ALL_SCOPE);
});
searchQuery.search.mockImplementation(() => {
order.push('search');
return Promise.resolve({
hits: [
{ id: 'm2', receivedAt: new Date('2026-11-02T02:00:00Z') },
{ id: 'm1', receivedAt: new Date('2026-11-02T01:00:00Z') },
],
hasNext: true,
nextCursor: 'index-next',
total: 42,
});
});
// 엔진은 차례를 보장하지 않는다
provider.getSummaries.mockResolvedValue([summary('m1'), summary('m2')]);
const page = await service.list(user, { limit: 2, q: '계약' });
expect(order).toEqual(['sync', 'scope', 'search']);
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
expect(provider.searchScope).toHaveBeenCalledWith(user.email, undefined);
const [, scope, criteria, plan, pageOptions] = searchQuery.search.mock
.calls[0] as unknown[];
expect(scope).toBe(ALL_SCOPE);
expect(criteria).toMatchObject({ text: '계약', hasAttachment: false });
expect(plan).toMatchObject({ grams: ['계약'] });
expect(pageOptions).toEqual({ limit: 2, cursor: undefined });
expect(provider.getSummaries).toHaveBeenCalledWith(user.email, [
'm2',
'm1',
]);
expect(page.items.map((item) => item.id)).toEqual(['m2', 'm1']);
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: 'index-next',
total: 42,
});
expect(provider.listMessages).not.toHaveBeenCalled();
});
it('색인 방식의 강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기(색인의 본문), 본문이 없으면 엔진의 미리 보기', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockResolvedValue({
hits: [
{ id: 'm1', receivedAt: new Date() },
{ id: 'm2', receivedAt: new Date() },
],
hasNext: false,
nextCursor: null,
total: 2,
});
provider.getSummaries.mockResolvedValue([
summary('m1', { subject: '계약 갱신 안내' }),
summary('m2', { subject: '안내', preview: '갱신 계약서 초안' }),
]);
searchQuery.highlightTexts.mockResolvedValue(
new Map([
[
'm1',
{
subject: '계약 갱신 안내',
bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
preview: '안녕하세요.',
otherFields: ['', '', '계약 갱신 안내', ''],
},
],
]),
);
const page = await service.list(user, { limit: 20, q: '계약' });
expect(page.items[0].searchHighlight).toEqual({
subject: [[0, 2]],
preview: {
text: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
ranges: [[12, 14]],
},
});
expect(page.items[1].searchHighlight).toEqual({
subject: [],
preview: { text: '갱신 계약서 초안', ranges: [[3, 5]] },
});
});
it('색인 방식의 미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 색인의 다른 칸(제목)에서도 맞으면 인용 앞의 글 (2026-11-06 사용자 관점 점검)', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockResolvedValue({
hits: [{ id: 'm1', receivedAt: new Date() }],
hasNext: false,
nextCursor: null,
total: 1,
});
provider.getSummaries.mockResolvedValue([
summary('m1', {
subject: 'RE: 해오름 견적',
preview: '네, 확인했습니다. > 견적서를 보내 드립니다.',
}),
]);
searchQuery.highlightTexts.mockResolvedValue(
new Map([
[
'm1',
{
subject: 'RE: 해오름 견적',
bodyText: '네, 확인했습니다.\n\n> 견적서를 보내 드립니다.',
preview: '네, 확인했습니다.',
otherFields: ['', '', 're: 해오름 견적', ''],
},
],
]),
);
const page = await service.list(user, { limit: 20, q: '견적' });
expect(page.items[0].searchHighlight).toEqual({
subject: [[8, 10]],
preview: { text: '네, 확인했습니다.…', ranges: [] },
});
});
it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockResolvedValue({
hits: ['m1', 'm2', 'm3'].map((id) => ({ id, receivedAt: new Date() })),
hasNext: false,
nextCursor: null,
total: 3,
});
provider.getSummaries.mockResolvedValue([
summary('m1', { mailboxIds: ['trash'] }),
summary('m2', { mailboxIds: ['trash', 'project'] }),
]);
const all = await service.list(user, { limit: 20, q: '계약' });
expect(all.items.map((item) => item.id)).toEqual(['m2']);
provider.searchScope.mockResolvedValue({
mailboxId: 'project',
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
});
provider.getSummaries.mockResolvedValue([
summary('m1', { mailboxIds: ['inbox'] }),
summary('m2', { mailboxIds: ['project'] }),
]);
const inProject = await service.list(user, {
mailboxId: 'project',
limit: 20,
q: '계약',
});
expect(provider.searchScope).toHaveBeenLastCalledWith(
user.email,
'project',
);
expect(inProject.items.map((item) => item.id)).toEqual(['m2']);
});
it('찾은 것이 없으면 엔진에 요약을 묻지 않는다', async () => {
const { service, provider, searchQuery } = setup();
const page = await service.list(user, { limit: 20, subject: '없는말' });
expect(provider.getSummaries).not.toHaveBeenCalled();
expect(searchQuery.highlightTexts).not.toHaveBeenCalled();
expect(page).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
});
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 색인으로(준비 전이어도), 엔진 커서는 엔진으로(준비됐어도). 색인 상태를 묻지 않는다', async () => {
const indexCursor = encodeIndexCursor(
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
'scope',
);
const engineCursor = encodeCursor(
{ anchor: 'm9', position: 20 },
{ text: '계약' },
);
const notReady = setup({ ready: false });
await notReady.service.list(user, {
limit: 20,
q: '계약',
cursor: indexCursor,
});
expect(notReady.searchQuery.search).toHaveBeenCalledTimes(1);
expect(notReady.provider.listMessages).not.toHaveBeenCalled();
const ready = setup({ ready: true });
await ready.service.list(user, {
limit: 20,
q: '계약',
cursor: engineCursor,
});
expect(ready.provider.listMessages).toHaveBeenCalledTimes(1);
expect(ready.searchQuery.search).not.toHaveBeenCalled();
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
expect(ready.searchIndex.isSearchReady).not.toHaveBeenCalled();
});
it('첫 쪽의 색인 찾기가 실패하면(DB 장애) 엔진 방식으로 찾는다 — 경고에 검색 글 · 주소 원문을 남기지 않는다', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockRejectedValue(
Object.assign(new Error('connection terminated'), { code: '57P01' }),
);
provider.listMessages.mockResolvedValue({
items: [summary('m1')],
hasNext: false,
nextCursor: null,
total: 1,
});
const page = await service.list(user, { limit: 20, q: '비밀 계약' });
expect(page.items.map((item) => item.searchHighlight)).toEqual([null]);
expect(provider.listMessages).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
const [line] = warnSpy.mock.calls[0] as [string];
expect(line).toContain('[SYS_001]');
expect(line).toContain('Error/57P01');
expect(line).not.toContain('비밀');
expect(line).not.toContain('계약');
expect(line).not.toContain(user.email);
});
it('코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001)는 그대로 — 엔진 방식으로 다시 찾지 않는다', async () => {
for (const code of ['RES_001', 'MAIL_001'] as const) {
const { service, provider } = setup();
provider.searchScope.mockRejectedValue(new BusinessException(code));
expect(
await codeOf(() =>
service.list(user, { mailboxId: 'zz', limit: 20, q: '계약' }),
),
).toBe(code);
expect(provider.listMessages).not.toHaveBeenCalled();
}
});
it('다음 쪽(색인 커서)의 색인 찾기가 실패하면 그대로 실패한다 — 엔진 커서와 섞지 않는다', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockRejectedValue(new Error('db down'));
const cursor = encodeIndexCursor(
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
'scope',
);
expect(
await codeOf(() => service.list(user, { limit: 20, q: '계약', cursor })),
).toBe('NOT_BUSINESS');
expect(provider.listMessages).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,214 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import {
MailSearchQueryService,
type IndexHighlightText,
} from '@/modules/mail-search/mail-search-query.service';
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type {
SearchConditions,
SearchCriteria,
SearchScope,
} from '@/modules/mail-search/mail-search.types';
import { cursorMethod } from '@/modules/mail-search/search-cursor';
import {
highlightedSubject,
listHighlight,
} from '@/modules/mail-search/search-highlight';
import {
isSearchCriteria,
planSearch,
} from '@/modules/mail-search/search-plan';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
HAS_ATTACHMENT_ONLY,
type MessageListQueryDto,
} from './dto/message-list-query.dto';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { MessageListItem, MessageSummary } from './mail.types';
/** 앞뒤 공백을 지운 글 — 비면 없는 것 (DTO 가 이미 지우지만 HTTP 를 거치지 않는 호출도 같게) */
function trimmed(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}
/** 검색 쿼리 → 검색 조건 */
export function criteriaOf(query: MessageListQueryDto): SearchCriteria {
return {
text: trimmed(query.q),
from: trimmed(query.from),
to: trimmed(query.to),
subject: trimmed(query.subject),
body: trimmed(query.body),
after: query.after,
before: query.before,
hasAttachment: query.hasAttachment === HAS_ATTACHMENT_ONLY,
};
}
/** 검색어 밖의 조건 — 하나도 없으면 undefined (엔진 방식 검색의 필터 · 커서 지문이 예전과 같다) */
function conditionsOf(criteria: SearchCriteria): SearchConditions | undefined {
const conditions: SearchConditions = {
...(criteria.from ? { from: criteria.from } : {}),
...(criteria.to ? { to: criteria.to } : {}),
...(criteria.subject ? { subject: criteria.subject } : {}),
...(criteria.body ? { body: criteria.body } : {}),
...(criteria.after ? { after: criteria.after } : {}),
...(criteria.before ? { before: criteria.before } : {}),
...(criteria.hasAttachment === true ? { hasAttachment: true } : {}),
};
return Object.keys(conditions).length > 0 ? conditions : undefined;
}
/** 엔진이 지금 그 메일을 범위 안에 두나 — 색인이 몇 초 늦을 수 있어 보이기 전에 엔진의 메일함으로 한 번 더 본다 */
function inScope(summary: MessageSummary, scope: SearchScope): boolean {
if (scope.mailboxId !== null) {
return summary.mailboxIds.includes(scope.mailboxId);
}
const excluded = new Set(scope.excludedMailboxIds);
return summary.mailboxIds.some((mailboxId) => !excluded.has(mailboxId));
}
function errorKind(error: unknown): string {
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name}/${code}` : name;
}
/**
* 메일 목록 · 검색 (GET /api/messages — 계약 2절 "검색", 14단계 설계 C1).
* - mailboxId 와 검색(검색어 q · 조건 하나라도) 가운데 하나는 있어야 한다 — 둘 다 없으면 VAL_001. 검색 글 · 조건은 어떤 로그에도 남기지 않는다
* - 검색 방식: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로), 없으면 색인이 준비됐을 때(ready · 지금 판) 색인,
* 아니면 엔진(낱말 통째 일치 · 조건은 JMAP 필터)
* - 색인 방식: 바로 전 맞추기(PRE_READ_SYNC_WAIT_MS 안 — 넘으면 지금 색인으로) → 엔진에 범위(메일함 · 빼는 메일함) → 색인 찾기 → 찾은 쪽의 요약은 엔진에서
* (목록과 같은 모양 · 지금 상태) → 엔진이 범위 밖으로 둔 메일(그사이 옮김)은 뺀다 → 제목 · 미리 보기 강조. 첫 쪽의 색인 찾기가 DB 장애로 실패하면 엔진 방식으로 찾는다
* 목록 항목은 늘 searchHighlight 를 싣는다 — 색인 방식 검색에서만 값이 있고 그 밖에는 null
*/
@Injectable()
export class MessageListService {
private readonly logger = new Logger(MessageListService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
private readonly searchIndex: MailSearchIndexService,
private readonly searchSync: MailSearchSyncService,
private readonly searchQuery: MailSearchQueryService,
) {}
async list(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageListItem>> {
const criteria = criteriaOf(query);
const searching = isSearchCriteria(criteria);
if (query.mailboxId === undefined && !searching) {
throw new BusinessException('VAL_001', {
context: { reason: 'mailbox-or-search-required' },
});
}
if (!searching) return this.fromEngine(user, query, criteria);
if (query.cursor) {
return cursorMethod(query.cursor) === 'index'
? this.fromIndex(user, query, criteria)
: this.fromEngine(user, query, criteria);
}
if (!(await this.searchIndex.isSearchReady(user.email))) {
return this.fromEngine(user, query, criteria);
}
try {
return await this.fromIndex(user, query, criteria);
} catch (error) {
// 코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001 등)는 그대로 — 색인(DB)이 실패했으면 엔진으로 찾는다
if (error instanceof BusinessException) throw error;
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인으로 찾지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
);
return this.fromEngine(user, query, criteria);
}
}
/** 엔진 방식 — 목록 · 색인 준비 전의 검색 (강조 없음) */
private async fromEngine(
user: AuthenticatedUser,
query: MessageListQueryDto,
criteria: SearchCriteria,
): Promise<PaginatedResult<MessageListItem>> {
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text: criteria.text,
conditions: conditionsOf(criteria),
});
return toPaginatedResult(
page.items.map((item) => ({ ...item, searchHighlight: null })),
{
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
},
);
}
/** 색인 방식 — 부분 일치 · 조건 · 강조 */
private async fromIndex(
user: AuthenticatedUser,
query: MessageListQueryDto,
criteria: SearchCriteria,
): Promise<PaginatedResult<MessageListItem>> {
// 방금 온 메일도 찾게 — 기다리는 시간을 넘으면 지금 색인으로 답한다 (맞추기는 뒤에서 이어 한다)
await this.searchSync.syncBeforeRead(user.email);
const scope = await this.provider.searchScope(user.email, query.mailboxId);
const plan = planSearch(criteria);
const found = await this.searchQuery.search(
user.email,
scope,
criteria,
plan,
{ limit: query.limit, cursor: query.cursor },
);
const ids = found.hits.map((hit) => hit.id);
const [summaries, texts] =
ids.length > 0
? await Promise.all([
this.provider.getSummaries(user.email, ids),
this.searchQuery.highlightTexts(user.email, ids),
])
: [[], new Map<string, IndexHighlightText>()];
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
const items = ids.flatMap((id): MessageListItem[] => {
const summary = byId.get(id);
if (!summary || !inScope(summary, scope)) return [];
const subject = highlightedSubject(summary.subject);
const text = texts.get(id);
return [
{
...summary,
subject,
searchHighlight: listHighlight(
{
subject,
preview: summary.preview,
bodyText: text?.bodyText ?? null,
otherFields: text?.otherFields ?? null,
},
plan.highlight,
),
},
];
});
return toPaginatedResult(items, {
hasNext: found.hasNext,
nextCursor: found.nextCursor,
total: found.total,
});
}
}
+18 -9
View File
@@ -68,6 +68,7 @@ import { UpdateMessageDto } from './dto/update-message.dto';
import { MailIdPipe } from './mail-id.pipe';
import { MailLargeFilesService } from './mail-large-files.service';
import { MailService } from './mail.service';
import { MessageListService } from './message-list.service';
import type { AttachmentContent } from './mail.types';
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
@@ -94,24 +95,27 @@ export class MessageController {
constructor(
private readonly mail: MailService,
private readonly largeFiles: MailLargeFilesService,
private readonly messageList: MessageListService,
) {}
@Get()
@ApiOperation({
summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)',
description:
'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
'엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' +
'(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' +
'낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' +
'붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.',
'mailboxId 와 검색(q 나 조건 from · to · subject · body · after · before · hasAttachment 하나라도) 가운데 하나는 있어야 한다. 메일함 없이 검색하면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
'검색 방식(14단계): 그 직원의 색인이 준비됐으면(GET /api/search-index 의 ready) 자체 색인으로 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 · 전각 없이, 줄바꿈으로 갈린 구절도) — 항목에 searchHighlight 가 있다. ' +
'한 글자 낱말뿐이면 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름에서만 찾는다(본문은 훑지 않는다 — 두 글자 넘는 낱말과 함께면 본문도). ' +
'색인 방식은 한 요청이 확인하는 메일 수에 상한이 있어, 쪽이 limit 보다 적거나 비어도 hasNext · nextCursor 가 있으면 이어 찾는다. total 은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). ' +
'준비 중이면 엔진 검색 — 낱말이 통째로 같을 때만 맞고, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다(조건 칸도 같다), searchHighlight 는 null. ' +
'커서에는 방식이 실려 쪽을 넘기는 중에 준비가 끝나도 처음 방식대로 이어 간다. 낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). ' +
'문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 글을 준 칸에 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). 발송 중인 내부 초안은 빠진다.',
})
@ApiStandardResponse(MessagePageDto, {
description: '메일 목록 (total 포함)',
})
@ApiErrorResponse(
'VAL_001',
`mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`,
`mailboxId 와 검색이 둘 다 없음 · 검색 글 칸(q · from · to · subject · body)이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과 · after · before 가 ISO 8601 UTC 가 아님 · hasAttachment 가 true 가 아님 · 다른 메일함·검색어·조건의 커서 · limit 범위 초과`,
)
@ApiErrorResponse(
'RES_001',
@@ -122,7 +126,7 @@ export class MessageController {
@CurrentUser() user: AuthenticatedUser,
@Query() query: MessageListQueryDto,
) {
return this.mail.listMessages(user, query);
return this.messageList.list(user, query);
}
@Patch()
@@ -162,10 +166,15 @@ export class MessageController {
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` +
'images=show 면 외부 이미지(<img> 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' +
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).',
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계). ' +
'검색 쿼리(q · subject · body — 14단계)를 함께 보내면 searchHighlight 에 제목 · 첨부 이름 · textBody 의 맞은 곳을 싣고, htmlBody 의 맞은 글 조각에 <mark> 를 넣는다(목록과 같은 접기 · 부분 일치, 태그를 건너는 말은 강조하지 않는다). ' +
'검색 쿼리의 검사는 목록과 같다 — from · to 는 받기만 하고 강조하지 않는다.',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리')
@ApiErrorResponse(
'VAL_001',
`모르는 images 값 · 모르는 쿼리(cursor · limit · after · before · hasAttachment 포함) · 검색 글 칸이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과`,
)
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
get(
+2
View File
@@ -52,6 +52,8 @@ const ROW_LABELS: Record<keyof AddressRows, string> = {
directory: '사내 목록 기록',
files: '대용량 파일',
shareLinks: '대용량 첨부 링크',
searchStates: '검색 색인 상태',
searchMessages: '검색 색인(메일)',
};
/** 남은 줄을 보이는 글 */
@@ -55,6 +55,8 @@ const NO_ROWS: AddressRows = {
directory: 0,
files: 0,
shareLinks: 0,
searchStates: 0,
searchMessages: 0,
};
function employee(overrides: Partial<EngineEmployee> = {}): EngineEmployee {
+13 -2
View File
@@ -13,6 +13,8 @@ import {
} from '@/modules/file-storage/file-blob-store';
import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity';
import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity';
import { MailSearchMessageEntity } from '@/modules/mail-search/mail-search-message.entity';
import { MailSearchStateEntity } from '@/modules/mail-search/mail-search-state.entity';
import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity';
import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity';
import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage';
@@ -45,6 +47,9 @@ export interface AddressRows {
readonly directory: number;
readonly files: number;
readonly shareLinks: number;
/** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */
readonly searchStates: number;
readonly searchMessages: number;
}
/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */
@@ -246,7 +251,7 @@ export class OpsDataService {
/**
* 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기).
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기).
* 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다
*/
async purgeAddress(email: string): Promise<AddressPurge> {
@@ -369,7 +374,9 @@ export class OpsDataService {
| typeof MailUserSettingsEntity
| typeof MailDirectoryEntryEntity
| typeof StoredFileEntity
| typeof ShareLinkEntity,
| typeof ShareLinkEntity
| typeof MailSearchStateEntity
| typeof MailSearchMessageEntity,
where: Record<string, string>,
): Promise<number> => {
if (mode === 'count') return manager.countBy(entity, where);
@@ -393,6 +400,10 @@ export class OpsDataService {
files: await run(StoredFileEntity, { ownerEmail: email }),
// 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다
shareLinks: await run(ShareLinkEntity, { ownerEmail: email }),
// 검색 색인(14단계) — 상태 줄을 먼저 지운다: 서버의 색인 쓰기는 상태 줄을 잠그고 하므로(없으면 쓰지 않는다) 그사이 끝난 쓰기가
// 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다)
searchStates: await run(MailSearchStateEntity, { userEmail: email }),
searchMessages: await run(MailSearchMessageEntity, { userEmail: email }),
};
}
}
@@ -0,0 +1,17 @@
import { databaseLogging } from './database.config';
describe('databaseLogging (TypeORM 로그)', () => {
it('운영 · 시험은 끈다', () => {
expect(databaseLogging(true, 'production')).toBe(false);
expect(databaseLogging(true, 'development')).toBe(false);
expect(databaseLogging(false, 'test')).toBe(false);
});
it('개발은 오류 · 경고 · 마이그레이션만 — 쿼리(매개변수에 메일 글 · 주소가 든다)는 남기지 않는다 (14단계)', () => {
for (const nodeEnv of ['development', undefined]) {
const logging = databaseLogging(false, nodeEnv);
expect(logging).toEqual(['error', 'warn', 'migration']);
expect(logging).not.toContain('query');
}
});
});
+15 -2
View File
@@ -57,6 +57,19 @@ function resolvePaths(baseDir: string) {
};
}
/**
* TypeORM 로그 — 운영 · 시험은 끄고, 개발은 오류 · 경고 · 마이그레이션만 남긴다(쿼리는 남기지 않는다).
* 쿼리 로그는 매개변수째 찍는다 — 14단계부터 DB 에 메일 글 · 제목 · 주소(검색 색인)가 있어 개발 로그에도 남기지 않는다 (.claude/rules/logging.md).
* ⚠️ 'error' 는 실패한 쿼리를 매개변수째 찍는다(TypeORM 의 logQueryError) — 실패한 색인 쓰기는 개발 로그에 글이 남을 수 있다
*/
export function databaseLogging(
isProduction: boolean,
nodeEnv: string | undefined,
): DataSourceOptions['logging'] {
if (isProduction || nodeEnv === 'test') return false;
return ['error', 'warn', 'migration'];
}
export function buildDatabaseOptions(): DataSourceOptions {
const isProduction = isProductionEnv();
const paths = resolvePaths(__dirname);
@@ -71,8 +84,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
// SQL 로그는 개발에서만 — 테스트(e2e·CI)는 수천 줄의 쿼리·파라미터(주소 등)가 출력을 덮는다
logging: !isProduction && process.env.NODE_ENV !== 'test',
// 개발도 쿼리는 남기지 않는다(매개변수에 메일 글 · 주소가 든다) — 오류 · 경고 · 마이그레이션만. 운영 · 시험은 끈다
logging: databaseLogging(isProduction, process.env.NODE_ENV),
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,

Some files were not shown because too many files have changed in this diff Show More