From f9fbf8a20f84961cae2e47244d63dcdb64bff444 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 00:18:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=A9=94=EC=9D=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=84=20=EC=9E=90=EC=B2=B4=20=EC=83=89=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EA=B2=80=EC=83=89=20=C2=B7=20=EA=B0=95=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼 엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다). - 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지 - 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정) - 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 + 이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초 - 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글 조각에 · 제목 · 첨부 이름 · 글 본문 위치 - 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의 색인을 지우고 밤에 다시 채운다 리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션, 다른 직원 줄 격리 e2e. Co-Authored-By: Claude Opus 5.5 --- .../1793882273224-SearchIndexUserGrams.ts | 41 ++ .../mail-search/index-document.spec.ts | 5 +- .../mail-search/mail-search-build.service.ts | 60 +- .../mail-search-index.repository.ts | 39 +- .../mail-search-index.service.spec.ts | 75 ++- .../mail-search/mail-search-index.service.ts | 33 +- .../mail-search/mail-search-message.entity.ts | 9 +- .../mail-search-query.service.spec.ts | 551 +++++++++++++++++ .../mail-search/mail-search-query.service.ts | 412 +++++++++++++ .../mail-search/mail-search-state.entity.ts | 7 + .../mail-search-sync.service.spec.ts | 112 ++++ .../mail-search/mail-search-sync.service.ts | 5 + .../mail-search/mail-search.constants.ts | 34 ++ .../modules/mail-search/mail-search.module.ts | 8 +- .../modules/mail-search/mail-search.types.ts | 48 ++ .../modules/mail-search/search-cursor.spec.ts | 137 +++++ .../src/modules/mail-search/search-cursor.ts | 122 ++++ .../mail-search/search-highlight.spec.ts | 178 ++++++ .../modules/mail-search/search-highlight.ts | 163 +++++ .../modules/mail-search/search-plan.spec.ts | 246 ++++++++ .../src/modules/mail-search/search-plan.ts | 196 ++++++ .../modules/mail-search/search-sql.spec.ts | 194 ++++++ backend/src/modules/mail-search/search-sql.ts | 272 +++++++++ .../src/modules/mail/dto/mail-response.dto.ts | 108 +++- .../mail/dto/message-detail-query.dto.spec.ts | 69 +++ .../mail/dto/message-detail-query.dto.ts | 48 +- .../mail/dto/message-list-query.dto.spec.ts | 158 ++++- .../mail/dto/message-list-query.dto.ts | 137 +++-- .../modules/mail/dto/search-query-fields.ts | 121 ++++ .../src/modules/mail/html/html-mark.spec.ts | 176 ++++++ backend/src/modules/mail/html/html-mark.ts | 175 ++++++ .../src/modules/mail/jmap/jmap-cursor.spec.ts | 52 ++ backend/src/modules/mail/jmap/jmap-cursor.ts | 14 +- .../mail/jmap/jmap-mail.provider.spec.ts | 296 ++++++++- .../modules/mail/jmap/jmap-mail.provider.ts | 171 +++++- backend/src/modules/mail/jmap/jmap.types.ts | 10 + .../modules/mail/jmap/search-terms.spec.ts | 54 ++ backend/src/modules/mail/jmap/search-terms.ts | 112 ++-- backend/src/modules/mail/mail.constants.ts | 4 +- backend/src/modules/mail/mail.module.ts | 3 + backend/src/modules/mail/mail.provider.ts | 12 + backend/src/modules/mail/mail.service.spec.ts | 56 +- backend/src/modules/mail/mail.service.ts | 52 +- backend/src/modules/mail/mail.types.ts | 18 + .../modules/mail/message-highlight.spec.ts | 147 +++++ backend/src/modules/mail/message-highlight.ts | 62 ++ .../modules/mail/message-list.service.spec.ts | 405 +++++++++++++ .../src/modules/mail/message-list.service.ts | 212 +++++++ .../src/modules/mail/message.controller.ts | 27 +- backend/src/shared/utils/search-query.ts | 84 +++ backend/src/shared/utils/search-text.ts | 36 +- backend/test/app.e2e-spec.ts | 567 ++++++++++++++++++ backend/test/search-text-samples.ts | 9 +- 53 files changed, 6073 insertions(+), 269 deletions(-) create mode 100644 backend/src/migrations/1793882273224-SearchIndexUserGrams.ts create mode 100644 backend/src/modules/mail-search/mail-search-query.service.spec.ts create mode 100644 backend/src/modules/mail-search/mail-search-query.service.ts create mode 100644 backend/src/modules/mail-search/search-cursor.spec.ts create mode 100644 backend/src/modules/mail-search/search-cursor.ts create mode 100644 backend/src/modules/mail-search/search-highlight.spec.ts create mode 100644 backend/src/modules/mail-search/search-highlight.ts create mode 100644 backend/src/modules/mail-search/search-plan.spec.ts create mode 100644 backend/src/modules/mail-search/search-plan.ts create mode 100644 backend/src/modules/mail-search/search-sql.spec.ts create mode 100644 backend/src/modules/mail-search/search-sql.ts create mode 100644 backend/src/modules/mail/dto/search-query-fields.ts create mode 100644 backend/src/modules/mail/html/html-mark.spec.ts create mode 100644 backend/src/modules/mail/html/html-mark.ts create mode 100644 backend/src/modules/mail/message-highlight.spec.ts create mode 100644 backend/src/modules/mail/message-highlight.ts create mode 100644 backend/src/modules/mail/message-list.service.spec.ts create mode 100644 backend/src/modules/mail/message-list.service.ts create mode 100644 backend/src/shared/utils/search-query.ts diff --git a/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts new file mode 100644 index 0000000..b4af38e --- /dev/null +++ b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts @@ -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 { + 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 { + 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"`, + ); + } +} diff --git a/backend/src/modules/mail-search/index-document.spec.ts b/backend/src/modules/mail-search/index-document.spec.ts index e6fced8..67929f5 100644 --- a/backend/src/modules/mail-search/index-document.spec.ts +++ b/backend/src/modules/mail-search/index-document.spec.ts @@ -58,10 +58,11 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => { preview: '계약서를 보내 드립니다', bodyText: '안녕하세요.\n계약서를 보내 드립니다.', foldFrom: '이도윤 ', - foldTo: 'ara.kim@arasystem.test\npartner ', + // 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2) + foldTo: 'ara.kim@arasystem.test partner ', foldSubject: 're: [공지] 11월 계약서', foldAttachment: '계약서.pdf', - foldBody: '안녕하세요.\n계약서를 보내 드립니다.', + foldBody: '안녕하세요. 계약서를 보내 드립니다.', threadKey: 'e1', engineThreadId: 't1', }); diff --git a/backend/src/modules/mail-search/mail-search-build.service.ts b/backend/src/modules/mail-search/mail-search-build.service.ts index e1975a3..71d01fa 100644 --- a/backend/src/modules/mail-search/mail-search-build.service.ts +++ b/backend/src/modules/mail-search/mail-search-build.service.ts @@ -1,4 +1,10 @@ -import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + type OnApplicationBootstrap, + Optional, +} from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { maskEmail } from '@/shared/utils/mask'; @@ -21,6 +27,7 @@ import { 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'; @@ -57,9 +64,11 @@ type StepOutcome = 'continue' | BuildOutcome; * ⚠️ 1분 차례의 겹침 막기(running)는 프로세스 메모리다 — 인스턴스가 둘이면 두 차례가 같은 직원을 함께 채운다 (nestjs 스킬의 Redis 목록) */ @Injectable() -export class MailSearchBuildService { +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( @@ -72,6 +81,39 @@ export class MailSearchBuildService { this.timing = timing ?? SYSTEM_SEARCH_TIMING; } + /** + * 서버가 뜰 때 — 판(INDEX_FORMAT)이 다른 사내 직원의 상태 줄을 building 으로 되돌린다(밤에 다시 채운다 — 그동안 검색은 엔진 방식). + * 되돌리지 못해도 서버는 뜬다 — 1분 차례가 다시 해 본다. 그동안 그 직원은 ready 여도 판이 달라 검색은 엔진 방식이고(isSearchReady) + * 상태 API 도 building 으로 보인다(toStatusView) + */ + async onApplicationBootstrap(): Promise { + await this.resetOutdatedFormats(); + } + + /** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */ + private async resetOutdatedFormats(): Promise { + 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', @@ -79,13 +121,10 @@ export class MailSearchBuildService { }) async tick(): Promise { const startedAt = this.timing.now(); - if ( - this.running || - this.sync.isStopping || - !isBuildWindow(new Date(startedAt)) - ) { - return null; - } + 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( @@ -144,11 +183,12 @@ export class MailSearchBuildService { const now = new Date(this.timing.now()); if (state.buildStartedAt === null || state.emailState === null) { - // 시작 — 지금 state(S0)부터는 맞추기가 넣는다 + // 시작 — 지금 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, diff --git a/backend/src/modules/mail-search/mail-search-index.repository.ts b/backend/src/modules/mail-search/mail-search-index.repository.ts index a1d4fe4..ded96a1 100644 --- a/backend/src/modules/mail-search/mail-search-index.repository.ts +++ b/backend/src/modules/mail-search/mail-search-index.repository.ts @@ -7,6 +7,7 @@ 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'; @@ -27,8 +28,13 @@ export interface SearchStatePatch { readonly lastSyncedAt?: Date | null; } -/** 한 트랜잭션에 쓰는 것 — 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */ +/** 한 트랜잭션에 쓰는 것 — (그 직원의 색인 줄 모두 지우기) → 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */ export interface SearchIndexWrite { + /** + * 그 직원의 색인 줄을 모두 지운다 — 채우기를 처음부터 시작할 때(판이 바뀌어 되돌린 직원 포함). 채우기는 위치로 건너뛸 수 있고 마지막 대조는 + * 없는 줄만 넣으므로, 남겨 두면 옛 판(접기 규칙)의 줄이 그대로 남았다. 그동안(building) 검색은 엔진 방식이다 + */ + readonly clearIndex?: boolean; readonly deletes?: readonly string[]; readonly upserts?: readonly IndexRowValues[]; readonly updates?: readonly IndexMutable[]; @@ -123,6 +129,7 @@ export class MailSearchIndexRepository { lastSyncedAt: null, pausedReason: null, failureCount: 0, + indexFormat: INDEX_FORMAT, createdAt: now, updatedAt: now, }) @@ -145,6 +152,7 @@ export class MailSearchIndexRepository { lastSyncedAt: null, pausedReason: null, failureCount: 0, + indexFormat: INDEX_FORMAT, createdAt: now, updatedAt: now, }); @@ -153,6 +161,27 @@ export class MailSearchIndexRepository { return saved; } + /** + * 판이 다른 줄을 building 으로 되돌린다 (색인 판 — mail-search.constants.ts 의 INDEX_FORMAT) → 되돌린 줄 수. + * 맞출 기준 · 채우기 자리를 지워 밤에 처음부터 다시 채운다 — 옛 색인 줄은 채우기가 시작하는 첫 묶음에서 지운다(SearchIndexWrite 의 clearIndex). + * 그동안 그 직원의 검색은 엔진 방식이다. `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 (같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다) + */ + async resetOutdatedFormats( + addressSuffix: string, + format: number, + now: Date, + ): Promise { + 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 { return this.dataSource.manager @@ -234,7 +263,7 @@ export class MailSearchIndexRepository { } /** - * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0). + * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), (모두 지우기) → 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0). * `now` 는 넣은 줄의 indexed_at · 상태 줄의 updated_at */ async write( @@ -250,6 +279,12 @@ export class MailSearchIndexRepository { if (locked.length === 0) { return { written: false, updatedIds: new Set() }; } + 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( diff --git a/backend/src/modules/mail-search/mail-search-index.service.spec.ts b/backend/src/modules/mail-search/mail-search-index.service.spec.ts index 9214ff5..9c29424 100644 --- a/backend/src/modules/mail-search/mail-search-index.service.spec.ts +++ b/backend/src/modules/mail-search/mail-search-index.service.spec.ts @@ -5,11 +5,13 @@ 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[]; @@ -18,9 +20,12 @@ describe('MailSearchIndexService (검색 색인의 직원 상태)', () => { 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) => { @@ -51,16 +56,80 @@ describe('MailSearchIndexService (검색 색인의 직원 상태)', () => { it('toStatusView — 다 만들었으면 채운 수 = 채울 수', () => { expect( - toStatusView({ status: 'ready', buildDone: 120, buildTotal: 120 }), + 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 }), + 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 }), + 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); + }); + }); }); diff --git a/backend/src/modules/mail-search/mail-search-index.service.ts b/backend/src/modules/mail-search/mail-search-index.service.ts index eca8c5f..9107daf 100644 --- a/backend/src/modules/mail-search/mail-search-index.service.ts +++ b/backend/src/modules/mail-search/mail-search-index.service.ts @@ -3,16 +3,24 @@ 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, + 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); @@ -52,6 +60,27 @@ export class MailSearchIndexService { } } + /** + * 색인으로 찾을 수 있나 (14단계 — 검색 방식 고르기) — 다 만들었고(ready) · 지금 판(INDEX_FORMAT)이고 · 멈추지 않았을 때. + * 상태를 읽지 못하면(DB 장애) false — 엔진 방식으로 찾는다(검색을 실패로 만들지 않는다) + */ + async isSearchReady(user: string): Promise { + 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 { return toStatusView( diff --git a/backend/src/modules/mail-search/mail-search-message.entity.ts b/backend/src/modules/mail-search/mail-search-message.entity.ts index 43784cd..7325133 100644 --- a/backend/src/modules/mail-search/mail-search-message.entity.ts +++ b/backend/src/modules/mail-search/mail-search-message.entity.ts @@ -14,8 +14,11 @@ import { * 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고 * GIN 으로 후보를 좁힌다. * - * GIN 색인 셋(grams · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다 - * (migration:generate 가 지우지 않게). + * GIN 색인 셋((lower(user_email), grams) · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다 + * (migration:generate 가 지우지 않게). 묶음 GIN 은 직원 주소와 함께다(btree_gin — 14단계 2차 `SearchIndexUserGrams`). 다만 GIN 의 posting 은 칸마다 따로라 + * 흔한 묶음은 **회사 전체** 줄의 posting 을 읽어 그 직원 주소의 posting 과 맞춘다(그 직원의 줄만 읽는 것이 아니다 — 10명 흉내에서 '계약' 7만여 줄이 34쪽 · 수 ms). + * 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. mailbox_ids GIN 도 회사 전체다 — 메일함 id 는 계정마다 같은 꼴(a · b …)이다. + * 계획기는 그 메일함 id 가 회사 전체에서도 적을 때만 묶음 GIN 과 함께(BitmapAnd) 쓴다(2026-11-06 10명 흉내 — 큰 메일함은 쓰지 않고 13~20ms, 작은 메일함은 함께 써 4~9ms) */ @Entity({ name: SEARCH_MESSAGES_TABLE }) @Index('IDX_mail_search_messages_received', [ @@ -25,7 +28,7 @@ import { ]) @Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId']) @Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey']) -@Index('IDX_mail_search_messages_grams', { synchronize: false }) +@Index('IDX_mail_search_messages_user_grams', { synchronize: false }) @Index('IDX_mail_search_messages_mailbox_ids', { synchronize: false }) @Index('IDX_mail_search_messages_refs', { synchronize: false }) export class MailSearchMessageEntity { diff --git a/backend/src/modules/mail-search/mail-search-query.service.spec.ts b/backend/src/modules/mail-search/mail-search-query.service.spec.ts new file mode 100644 index 0000000..460276d --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-query.service.spec.ts @@ -0,0 +1,551 @@ +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 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; + }) => Promise, + ) => { + 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 => ({ + 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: '미리' }, + ], + }); + + 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: '미리' }], + ]); + }); + + 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]); + }); +}); diff --git a/backend/src/modules/mail-search/mail-search-query.service.ts b/backend/src/modules/mail-search/mail-search-query.service.ts new file mode 100644 index 0000000..fc98423 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-query.service.ts @@ -0,0 +1,412 @@ +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; +} + +/** 쿼리의 줄 */ +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 = (query: SqlQuery) => Promise; + +const PLANNER_SETTINGS: Readonly> = { + 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 { + 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> { + 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; + }[] + >(query.sql, query.params); + return new Map( + rows.map((row) => [ + row.email_id, + { + subject: row.subject, + bodyText: row.body_text, + preview: row.preview, + }, + ]), + ); + } + + /** 낱말 확인 없이 — 받은 차례로 (첫 쪽은 SEARCH_TOTAL_LIMIT + 1 까지 센다 — 쪽과 수는 같은 스냅숏에서) */ + private plain( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + 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 { + const candidates = ( + await this.snapshot('bitmap', (ask) => + ask( + 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> { + 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 { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + 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 { + 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( + hint: PlannerHint, + work: (ask: Ask) => Promise, + ): Promise { + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + for (const setting of PLANNER_SETTINGS[hint]) { + await manager.query(setting); + } + return work((query: SqlQuery) => + manager.query(query.sql, query.params), + ); + }); + } +} diff --git a/backend/src/modules/mail-search/mail-search-state.entity.ts b/backend/src/modules/mail-search/mail-search-state.entity.ts index f97c2f1..46e43f2 100644 --- a/backend/src/modules/mail-search/mail-search-state.entity.ts +++ b/backend/src/modules/mail-search/mail-search-state.entity.ts @@ -71,6 +71,13 @@ export class MailSearchStateEntity { }) 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; diff --git a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts index 9cdcb94..8431f07 100644 --- a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts +++ b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts @@ -28,6 +28,7 @@ import { BUILD_BATCH_SIZE, BUILD_RUN_BUDGET_MS, INDEX_CALL_MIN_MS, + INDEX_FORMAT, STEP_MIN_REMAINING_MS, SYNC_CONCURRENCY, SYNC_DEBOUNCE_MS, @@ -260,6 +261,38 @@ class FakeRepository { readonly states = new Map(); readonly rows = new Map(); 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 = {}, @@ -276,6 +309,7 @@ class FakeRepository { lastSyncedAt: null, pausedReason: null, failureCount: 0, + indexFormat: INDEX_FORMAT, createdAt: new Date(0), updatedAt: new Date(0), ...overrides, @@ -336,6 +370,7 @@ class FakeRepository { return Promise.resolve({ written: false, updatedIds: new Set() }); } 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(); @@ -477,6 +512,83 @@ describe('검색 색인 맞추기 · 채우기 (MailSearchSyncService · MailSea ); }); + 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(); diff --git a/backend/src/modules/mail-search/mail-search-sync.service.ts b/backend/src/modules/mail-search/mail-search-sync.service.ts index 15f5160..bae36c8 100644 --- a/backend/src/modules/mail-search/mail-search-sync.service.ts +++ b/backend/src/modules/mail-search/mail-search-sync.service.ts @@ -152,6 +152,11 @@ export class MailSearchSyncService implements BeforeApplicationShutdown { this.addressSuffix = domain ? `@${domain}` : null; } + /** 색인하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null) — 판이 바뀐 줄 되돌리기가 쓴다 */ + get indexedAddressSuffix(): string | null { + return this.addressSuffix; + } + /** 색인하는 주소인가 — 사내 도메인(MAIL_DOMAIN)의 주소만 (5분 맞추기 · 밤 채우기의 차례가 본다) */ isIndexedAddress(user: string): boolean { return ( diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts index b95a2de..68ea781 100644 --- a/backend/src/modules/mail-search/mail-search.constants.ts +++ b/backend/src/modules/mail-search/mail-search.constants.ts @@ -105,3 +105,37 @@ export const BUILD_WINDOWS_KST: readonly (readonly [ /** 실행 시각 표의 시간대 — 컨테이너는 UTC 로 돈다 */ export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul'; + +/** + * 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format). + * 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다. + * 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게) + */ +export const INDEX_FORMAT = 2; + +/** + * 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다: + * - 후보: 주소 + 묶음(GIN — 마이그레이션 SearchIndexUserGrams 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다 + * (줄의 글을 풀지 않는다 — 1만 통에 10ms 안팎) + * - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다 + * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 + * SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초). + * 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 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; diff --git a/backend/src/modules/mail-search/mail-search.module.ts b/backend/src/modules/mail-search/mail-search.module.ts index e6a0e7a..5487d08 100644 --- a/backend/src/modules/mail-search/mail-search.module.ts +++ b/backend/src/modules/mail-search/mail-search.module.ts @@ -7,6 +7,7 @@ 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'; @@ -34,7 +35,12 @@ import { SearchIndexController } from './search-index.controller'; MailSearchSyncService, MailSearchBuildService, MailSearchIndexService, + MailSearchQueryService, + ], + exports: [ + MailSearchIndexService, + MailSearchSyncService, + MailSearchQueryService, ], - exports: [MailSearchIndexService, MailSearchSyncService], }) export class MailSearchModule {} diff --git a/backend/src/modules/mail-search/mail-search.types.ts b/backend/src/modules/mail-search/mail-search.types.ts index 3bd5702..2cceb08 100644 --- a/backend/src/modules/mail-search/mail-search.types.ts +++ b/backend/src/modules/mail-search/mail-search.types.ts @@ -20,3 +20,51 @@ export interface SearchIndexStatusView { /** 채울 메일 수(채우기를 시작할 때의 엔진 메일 수) — 아직 시작하지 않았으면 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; + +/** + * 찾는 범위 (엔진이 알려 준다 — 메일 포트의 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; + readonly textBody: SearchRange[]; +} diff --git a/backend/src/modules/mail-search/search-cursor.spec.ts b/backend/src/modules/mail-search/search-cursor.spec.ts new file mode 100644 index 0000000..ab9f27b --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.spec.ts @@ -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 => ({ + 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; + 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: '' })), + ); + }); +}); diff --git a/backend/src/modules/mail-search/search-cursor.ts b/backend/src/modules/mail-search/search-cursor.ts new file mode 100644 index 0000000..6d796b7 --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.ts @@ -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 | 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; + 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 }; +} diff --git a/backend/src/modules/mail-search/search-highlight.spec.ts b/backend/src/modules/mail-search/search-highlight.spec.ts new file mode 100644 index 0000000..e131733 --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.spec.ts @@ -0,0 +1,178 @@ +import { 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, +}); + +/** 글에서 낱말이 처음 맞은 곳 */ +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: + '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + }, + 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, + }, + terms('계약'), + ); + expect(highlight.preview).toEqual({ + text: '첨부한 계약서를 확인해 주세요', + ranges: [[4, 6]], + }); + + const notInBody = listHighlight( + { subject: '계약 안내', preview: '첨부 확인', bodyText: '첨부 확인' }, + terms('계약'), + ); + expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] }); + expect(notInBody.subject).toEqual([[0, 2]]); + }); + + it('본문에 강조할 말이 없으면(제목 조건만) 미리 보기는 맞은 곳 없이 엔진의 것', () => { + const highlight = listHighlight( + { + subject: '견적 요청', + preview: '견적 부탁드립니다', + bodyText: '견적 부탁드립니다', + }, + { subject: ['견적'], body: [], attachment: [] }, + ); + 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 }, + 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); + }); + }); +}); diff --git a/backend/src/modules/mail-search/search-highlight.ts b/backend/src/modules/mail-search/search-highlight.ts new file mode 100644 index 0000000..4d8fcc6 --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.ts @@ -0,0 +1,163 @@ +import { + isSpaceCode, + 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)에서 맞았으면 처음 맞은 말 둘레(앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — + * SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤가 잘렸으면 "…"), 아니면 엔진의 미리 보기. 공백은 하나로 줄인 한 줄이고 맞은 곳은 그 글 기준으로 다시 찾는다 + * 위치는 UTF-16 [시작, 끝) + */ + +const ELLIPSIS = '…'; + +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 남짓을 말 경계에서 자른다. + * 앞 · 뒤에 잘라 낸 글(공백만이 아닌)이 있으면 "…" 를 붙이고 한 줄로 만든다 + */ +export function snippetAround(body: string, first: MatchRange): string { + const [matchStart, matchEnd] = first; + // 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리 + const lead = Math.max(0, matchStart - SNIPPET_LEAD_CHARS); + let start = lead; + if (lead > 0) { + const before = findSpace( + body, + lead, + Math.max(0, 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 = alignStart(body, start); + // 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다 + const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS); + let end = Math.min(body.length, target); + if (end < body.length) { + const back = findSpace( + body, + end, + Math.max(matchEnd, end - SNIPPET_BOUNDARY_SLACK), + ); + const forward = + back < 0 + ? findSpace( + body, + end, + Math.min(body.length - 1, end + SNIPPET_BOUNDARY_SLACK), + ) + : -1; + const space = back >= 0 ? back : forward; + if (space >= 0) end = space; + } + end = 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}`; +} + +function toRanges(ranges: readonly MatchRange[]): SearchRange[] { + return ranges.map(([start, end]) => [start, end] as const); +} + +/** + * 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null) + */ +export function listHighlight( + input: { + readonly subject: string; + readonly preview: string; + readonly bodyText: string | null; + }, + terms: HighlightTerms, +): MessageListHighlight { + const subject = toRanges(matchRanges(input.subject, terms.subject)); + if (input.bodyText && terms.body.length > 0) { + const inBody = matchRanges(input.bodyText, terms.body, 1); + if (inBody.length > 0) { + const text = snippetAround(input.bodyText, inBody[0]); + return { + subject, + preview: { text, ranges: toRanges(matchRanges(text, terms.body)) }, + }; + } + } + const text = 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'); +} diff --git a/backend/src/modules/mail-search/search-plan.spec.ts b/backend/src/modules/mail-search/search-plan.spec.ts new file mode 100644 index 0000000..8353fc0 --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.spec.ts @@ -0,0 +1,246 @@ +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 => ({ + 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'], + }); + }); + + 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: [], + }); + }); + + 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 }, + ]); + }); + + 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'] }, + ]); + // 강조는 본문에도 — 계획이 본문을 훑지 않았을 뿐 맞은 곳은 보인다 + expect(plan.highlight.body).toEqual(['묵', '뷁']); + }); + + 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: ['계약'], + }); + }); + + it('강조할 말이 없으면 null — 검색어 · 제목 · 내용 조건이 없거나 문장부호뿐', () => { + expect(highlightTermsOf({})).toBeNull(); + expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull(); + }); + }); + + 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`]); + }); +}); diff --git a/backend/src/modules/mail-search/search-plan.ts b/backend/src/modules/mail-search/search-plan.ts new file mode 100644 index 0000000..9e157ef --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.ts @@ -0,0 +1,196 @@ +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) — 그래서 확인하지 않아도 된다 + */ + +/** 확인하는 접기 칸 — 표의 열 이름 */ +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; + +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[]; + readonly body: readonly string[]; + readonly attachment: 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); +} + +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(); + 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(); + 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: [...query.raw, ...bodyTerms], + attachment: [...query.raw], + }, + }; +} + +/** + * 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에. + * 보낸 사람 · 받는 사람 조건은 강조하지 않는다. 강조할 말이 하나도 없으면 null + */ +export function highlightTermsOf( + criteria: Pick, +): HighlightTerms | null { + const query = termsOf(criteria.text).raw; + const subject = [...query, ...termsOf(criteria.subject).raw]; + const body = [...query, ...termsOf(criteria.body).raw]; + if (subject.length === 0 && body.length === 0) return null; + return { subject, body, attachment: query }; +} diff --git a/backend/src/modules/mail-search/search-sql.spec.ts b/backend/src/modules/mail-search/search-sql.spec.ts new file mode 100644 index 0000000..ee44849 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.spec.ts @@ -0,0 +1,194 @@ +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) => + 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('메일함 범위 · 기간 · 첨부 · 이어 찾을 자리(받은 시각 · 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.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" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])', + ); + expect(query.params).toEqual([USER, ['m1', 'm2']]); + }); +}); diff --git a/backend/src/modules/mail-search/search-sql.ts b/backend/src/modules/mail-search/search-sql.ts new file mode 100644 index 0000000..7a16064 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.ts @@ -0,0 +1,272 @@ +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 에서만 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. + * 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms). + * 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다 + * - 확인(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`: 주소 그대로(받은 차례 · 기본 키) + * 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다 + */ +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, + }; +} + +/** 강조에 쓰는 글 — 찾은 쪽의 줄만 */ +export function highlightTextsSql( + user: string, + ids: readonly string[], +): SqlQuery { + return { + sql: `SELECT "email_id", "subject", "body_text", "preview" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, + params: [user, [...ids]], + }; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index e2d67c2..8f78063 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -15,6 +15,11 @@ import { type ContactEmailLabel, } from '@/modules/contacts/contacts.types'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; +import type { + MessageDetailHighlight, + MessageListHighlight, + SearchRange, +} from '@/modules/mail-search/mail-search.types'; import type { Draft, MailAddress, @@ -29,6 +34,7 @@ import type { MessageDeletionResult, MessageDetail, MessageLargeFile, + MessageListItem, MessagesPatchResult, MessageSummary, PendingSend, @@ -311,6 +317,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; + + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + 'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 가 들어 있다', + example: [[120, 122]], + }) + textBody!: SearchRange[]; +} + export class MessageDetailDto extends MessageSummaryDto implements MessageDetail @@ -406,6 +496,14 @@ export class MessageDetailDto type: () => [MessageLargeFileDto], }) largeFiles!: MessageLargeFileDto[]; + + @ApiProperty({ + description: + '검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 가 있다', + type: MessageDetailHighlightDto, + nullable: true, + }) + searchHighlight!: MessageDetailHighlightDto | null; } const SHARED_FILE_STATUSES: SharedFileStatus[] = [ @@ -475,12 +573,16 @@ export class MessageLargeFileDto implements MessageLargeFile { export class MessagePageDto { @ApiProperty({ - type: [MessageSummaryDto], + type: [MessageListItemDto], description: '받은 시각 내림차순', }) - items!: MessageSummaryDto[]; + items!: MessageListItemDto[]; - @ApiProperty({ type: PageInfoDto, description: 'total 포함' }) + @ApiProperty({ + type: PageInfoDto, + description: + 'total — 목록 · 엔진 방식 검색은 늘, 색인 방식 검색은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). 색인 방식은 쪽이 limit 보다 적거나 비어도 hasNext 일 수 있다(이어 찾기)', + }) pageInfo!: PageInfoDto; } diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts index 0ef15c5..287854b 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts @@ -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', + ]); + } + }); +}); diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.ts b/backend/src/modules/mail/dto/message-detail-query.dto.ts index 6c24fb1..aa00ca3 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.ts @@ -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 의 ``). 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; } diff --git a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts index 7bd7b15..21c3524 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts @@ -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']); + }); +}); diff --git a/backend/src/modules/mail/dto/message-list-query.dto.ts b/backend/src/modules/mail/dto/message-list-query.dto.ts index 8fa807a..c8e3b54 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.ts @@ -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; } diff --git a/backend/src/modules/mail/dto/search-query-fields.ts b/backend/src/modules/mail/dto/search-query-fields.ts new file mode 100644 index 0000000..2ad2f53 --- /dev/null +++ b/backend/src/modules/mail/dto/search-query-fields.ts @@ -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>, +): 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; + 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}개까지 입력해 주세요.`, + }), + ); +} diff --git a/backend/src/modules/mail/html/html-mark.spec.ts b/backend/src/modules/mail/html/html-mark.spec.ts new file mode 100644 index 0000000..4e22f13 --- /dev/null +++ b/backend/src/modules/mail/html/html-mark.spec.ts @@ -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('').length - 1; + +describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => { + it('글 조각의 맞은 곳에 — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => { + expect(marked('

계약 갱신 안내

', '계약')).toBe( + '

계약 갱신 안내

', + ); + expect(marked('

ERP 와 Erp 와 ERP

', 'erp')).toBe( + '

ERP 와 Erp 와 ERP

', + ); + expect(markHtmlText('

계약 계약

', ['계약']).marks).toBe(2); + }); + + it('겹치거나 맞닿은 맞은 곳은 하나로 — 여러 말이 한 자리에 맞아도 는 겹치지 않는다', () => { + expect(marked('

계약서 갱신

', '계약', '약서')).toBe( + '

계약서 갱신

', + ); + }); + + it('태그 안(이름 · 속성 · style · 주소)은 건드리지 않는다', () => { + const html = + '계약계약'; + expect(marked(html, '계약')).toBe( + '계약계약', + ); + }); + + it('주석 · 글을 그대로 두는 요소(style · script · textarea · title)의 안은 그대로', () => { + expect( + marked( + '계약

계약

계약

계약

`)과 글을 그대로 두어야 하는 요소 + * (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로 + * - 글 조각마다 문자 참조를 풀어(& · < · > · " · ' ·   · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다 + * (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(& · < · > 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다 + * - 태그를 건너는 말(`계약`)은 강조하지 않는다 — 글 조각 안에서만 + * - 넣는 `` 는 `limit`(MAX_HTML_MARKS)개까지 + * 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다) + */ + +/** 한 본문에 넣는 `` 의 수 */ +export const MAX_HTML_MARKS = 1_000; + +/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */ +const MAX_REFERENCE_LENGTH = 32; + +/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 & · < · > 만 다시 적는다)과 흔한 것 */ +const NAMED_REFERENCES: Readonly> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', +}; + +/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */ +const RAW_TEXT_TAGS: ReadonlySet = new Set([ + 'script', + 'style', + 'textarea', + 'title', + 'xmp', +]); + +const COMMENT_OPEN = ''; +const MAX_CODE_POINT = 0x10ffff; +const REPLACEMENT_CHARACTER = '\ufffd'; + +/** 태그 이름 (`