diff --git a/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts b/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts new file mode 100644 index 0000000..914095d --- /dev/null +++ b/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 — 메일함 범위 찾기 (14단계 실사용 시험, 2026-11-06). + * - 글자 두 개 묶음 GIN 에 메일함 칸을 더한다: GIN 하나 (lower("user_email"), "grams", "mailbox_ids") 가 직원 · 묶음 · 메일함을 한 번에 맞춘다. + * 주소 · 묶음 GIN(`IDX_mail_search_messages_user_grams`)과 메일함만 담은 GIN(`IDX_mail_search_messages_mailbox_ids`)은 지운다. + * - 까닭: 메일함 범위의 후보 쿼리(`"mailbox_ids" @> $n` — search-sql.ts 의 candidatesSql)에서 계획기가 메일함 GIN 만으로 그 메일함의 줄을 모두 읽고 + * 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 골랐다. 메일함 id 는 계정마다 같은 꼴(a · b …)이라 그 posting 은 회사 전체이고, 묶음 확인이 + * 주소 확인보다 먼저 돌아 다른 직원의 줄까지 풀었다 — 1만 통 메일함 안 1.1초, 그 메일함 id 가 남의 큰 메일함과 같은 직원은 빈 메일함에도 1.6초. + * 계획기는 TOAST 를 푸는 값을 모르고, 묶음 GIN 의 대기 목록(fastupdate)이 쌓이면 그 GIN 을 더 비싸게 보아 이 길로 넘어간다 — + * 그래서 고를 길을 하나만 남긴다(이 색인 말고 `lower("user_email")` · 묶음 · 메일함 조건을 받는 색인이 없다. 메일함만 담은 색인을 다시 만들지 않는다). + * - 같은 표의 흉내(10명 · 10만 줄 — 메일함 id 가 겹친다)에서 큰 메일함 29ms · 작은 메일함 1~7ms · 전체 13ms + * (전에는 큰 메일함 43~125ms · 작은 메일함 5~88ms — 메일함 GIN 과 함께 쓸 때와 쓰지 않을 때). 그 표의 몫(직원 주소 · 메일함 칸)은 묶음보다 훨씬 작다. + * 쓰기 비용은 지금과 같다 — 메일함이 바뀐 줄은 지금도 두 GIN 에 새로 들어간다(색인된 칸이라 HOT 이 아니다). 표의 칸 · 행은 그대로다. + * btree_gin 확장은 SearchIndexUserGrams 가 만들었다. 내릴 때는 두 색인을 다시 만든다. + * (손으로 적었다 — TypeORM 이 GIN 을 적지 못한다. 엔티티는 modules/mail-search/mail-search-message.entity.ts 에 synchronize: false 로 이름만) + */ +export class SearchIndexUserGramsMailboxes1793979304483 implements MigrationInterface { + name = 'SearchIndexUserGramsMailboxes1793979304483'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_user_grams_mailboxes" ON "mail_search_messages" USING GIN (lower("user_email"), "grams", "mailbox_ids")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_user_grams"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_user_grams_mailboxes"`, + ); + } +} diff --git a/backend/src/modules/mail-search/conversation-sql.ts b/backend/src/modules/mail-search/conversation-sql.ts index c26cc78..0dfb4b8 100644 --- a/backend/src/modules/mail-search/conversation-sql.ts +++ b/backend/src/modules/mail-search/conversation-sql.ts @@ -9,7 +9,8 @@ import type { SqlQuery } from './search-sql'; /** * 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다. * 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 — - * `mailbox_ids` GIN 은 회사 전체 posting 이라(메일함 id 가 계정마다 같은 꼴) 쓰지 않게(리뷰 — 그 직원의 줄에서만 거른다) + * 메일함의 posting 은 회사 전체라(메일함 id 가 계정마다 같은 꼴) GIN 으로 거르지 않고 그 직원의 줄에서만 거른다(리뷰). + * 메일함 칸을 담은 GIN 은 찾기 GIN(lower 식 · 묶음 · 메일함) 하나뿐이고 이 쿼리들은 그것을 쓰지 않는다 */ const TABLE = `"${SEARCH_MESSAGES_TABLE}"`; diff --git a/backend/src/modules/mail-search/mail-conversation-query.service.ts b/backend/src/modules/mail-search/mail-conversation-query.service.ts index b164b3c..3ddba32 100644 --- a/backend/src/modules/mail-search/mail-conversation-query.service.ts +++ b/backend/src/modules/mail-search/mail-conversation-query.service.ts @@ -97,7 +97,7 @@ function normalize(user: string): string { * - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread) * - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터 * CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지) - * 모든 쿼리는 그 직원 주소로 먼저 좁힌다 — 메일함 GIN(회사 전체 posting)을 쓰지 않는다(conversation-sql.ts) + * 모든 쿼리는 그 직원 주소(btree)로 먼저 좁히고 메일함은 그 줄에서 거른다 — 메일함의 GIN posting(회사 전체)을 쓰지 않는다(conversation-sql.ts) */ @Injectable() export class MailConversationQueryService { 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 7deb1d0..dc16e8f 100644 --- a/backend/src/modules/mail-search/mail-search-message.entity.ts +++ b/backend/src/modules/mail-search/mail-search-message.entity.ts @@ -15,11 +15,12 @@ import { * 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고 * GIN 으로 후보를 좁힌다. * - * 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) + * GIN 색인 둘((lower(user_email), grams, mailbox_ids) · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다 + * (migration:generate 가 지우지 않게). 찾기 GIN 은 직원 주소 · 묶음 · 메일함을 한 색인에 담는다(btree_gin — 14단계 2차 `SearchIndexUserGrams` → + * 실사용 시험 `SearchIndexUserGramsMailboxes`). GIN 의 posting 은 칸마다 따로라 흔한 묶음 · 메일함 id 는 **회사 전체** 줄의 posting 이다 + * (메일함 id 는 계정마다 같은 꼴(a · b …)이다) — 색인 하나가 그 직원 주소의 posting 과 맞추고 맞은 줄만 읽는다(10명 흉내에서 '계약' 7만여 줄 · 수 ms). + * 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. **메일함만 담은 GIN 을 다시 만들지 않는다** — 계획기가 그것만으로 + * 메일함의 줄을 모두 읽고 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 고를 수 있게 된다(2026-11-06 — 1만 통 메일함 안 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1.6초) */ @Entity({ name: SEARCH_MESSAGES_TABLE }) @Index('IDX_mail_search_messages_received', [ @@ -29,8 +30,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_user_grams', { synchronize: false }) -@Index('IDX_mail_search_messages_mailbox_ids', { synchronize: false }) +@Index('IDX_mail_search_messages_user_grams_mailboxes', { synchronize: false }) @Index('IDX_mail_search_messages_refs', { synchronize: false }) export class MailSearchMessageEntity { @PrimaryColumn({ 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 index 460276d..6fb7f56 100644 --- a/backend/src/modules/mail-search/mail-search-query.service.spec.ts +++ b/backend/src/modules/mail-search/mail-search-query.service.spec.ts @@ -463,10 +463,19 @@ describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', ( expect(recorded).toEqual([]); }); - it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에, id 가 없으면 묻지 않는다', async () => { + it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에(본문 밖의 접은 칸 넷도 — 미리 보기 고르기), id 가 없으면 묻지 않는다', async () => { const { service, recorded } = fakeDataSource({ highlight: () => [ - { email_id: 'm1', subject: '계약', body_text: '본문', preview: '미리' }, + { + email_id: 'm1', + subject: '계약', + body_text: '본문', + preview: '미리', + fold_from: '이도윤 ', + fold_to: '김아라 ', + fold_subject: '계약', + fold_attachment: '계약서.pdf', + }, ], }); @@ -476,7 +485,20 @@ describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', ( const texts = await service.highlightTexts(USER, ['m1', 'm2']); expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]); expect([...texts.entries()]).toEqual([ - ['m1', { subject: '계약', bodyText: '본문', preview: '미리' }], + [ + 'm1', + { + subject: '계약', + bodyText: '본문', + preview: '미리', + otherFields: [ + '이도윤 ', + '김아라 ', + '계약', + '계약서.pdf', + ], + }, + ], ]); }); diff --git a/backend/src/modules/mail-search/mail-search-query.service.ts b/backend/src/modules/mail-search/mail-search-query.service.ts index fc98423..7acf18d 100644 --- a/backend/src/modules/mail-search/mail-search-query.service.ts +++ b/backend/src/modules/mail-search/mail-search-query.service.ts @@ -49,6 +49,8 @@ export interface IndexHighlightText { readonly subject: string; readonly bodyText: string; readonly preview: string; + /** 본문 밖의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (미리 보기 고르기 — search-highlight.ts) */ + readonly otherFields: readonly string[]; } /** 쿼리의 줄 */ @@ -68,7 +70,9 @@ interface Found { /** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */ type PlannerHint = - /** 후보 — GIN 의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게) */ + /** + * 후보 — 찾기 GIN(주소 · 묶음 · 메일함 — 이것 말고 받을 색인이 없다)의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게) + */ | 'bitmap' /** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */ | 'keyset'; @@ -76,7 +80,10 @@ type PlannerHint = /** 한 스냅숏 안의 쿼리 하나 */ type Ask = (query: SqlQuery) => Promise; -const PLANNER_SETTINGS: Readonly> = { +/** 계획기 설정 — e2e 의 계획 시험(실제 PostgreSQL)도 같은 설정으로 본다 */ +export const PLANNER_SETTINGS: Readonly< + Record +> = { bitmap: [ 'SET LOCAL enable_seqscan = off', 'SET LOCAL enable_indexscan = off', @@ -131,7 +138,7 @@ function normalize(user: string): string { * 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다. * * - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로 - * - 묶음이 있으면: 후보를 주소 + 묶음(GIN)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음 + * - 묶음이 있으면: 후보를 주소 + 묶음 + 메일함(GIN 하나)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음 * - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과 * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다 * - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다. @@ -188,6 +195,10 @@ export class MailSearchQueryService { subject: string; body_text: string; preview: string; + fold_from: string; + fold_to: string; + fold_subject: string; + fold_attachment: string; }[] >(query.sql, query.params); return new Map( @@ -197,6 +208,12 @@ export class MailSearchQueryService { subject: row.subject, bodyText: row.body_text, preview: row.preview, + otherFields: [ + row.fold_from, + row.fold_to, + row.fold_subject, + row.fold_attachment, + ], }, ]), ); diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts index 1c6f1c3..ef50865 100644 --- a/backend/src/modules/mail-search/mail-search.constants.ts +++ b/backend/src/modules/mail-search/mail-search.constants.ts @@ -121,12 +121,14 @@ export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul'; export const INDEX_FORMAT = 4; /** - * 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다: - * - 후보: 주소 + 묶음(GIN — 마이그레이션 SearchIndexUserGrams 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다 - * (줄의 글을 풀지 않는다 — 1만 통에 10ms 안팎) + * 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼 · 2026-11-06 메일함 범위 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 + * 비싸 그 수를 묶는다: + * - 후보: 주소 + 묶음 + 메일함(GIN 하나 — 마이그레이션 SearchIndexUserGramsMailboxes 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 + * 읽는다(줄의 글을 풀지 않는다 — 1만 통 직원의 흔한 낱말이 전체 · 큰 메일함 10~30ms, 작은 메일함 1~7ms. 맞은 줄이 표에 흩어져 있으면 그 줄 수만큼 더 든다) * - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다 * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 - * SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초). + * SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초 — 캐시가 찬 때. + * 2026-11-06 1만 통 직원의 드문 구절로 3,000줄 0.51~0.58초). * 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext) * - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null */ diff --git a/backend/src/modules/mail-search/search-highlight.spec.ts b/backend/src/modules/mail-search/search-highlight.spec.ts index e131733..e8bdc1e 100644 --- a/backend/src/modules/mail-search/search-highlight.spec.ts +++ b/backend/src/modules/mail-search/search-highlight.spec.ts @@ -1,4 +1,5 @@ -import { matchRanges } from '@/shared/utils/search-text'; +import { startCpuTimer } from '@/../test/cpu-time'; +import { foldText, matchRanges } from '@/shared/utils/search-text'; import { SNIPPET_BOUNDARY_SLACK, SNIPPET_LEAD_CHARS, @@ -15,8 +16,20 @@ const terms = (...words: string[]): HighlightTerms => ({ subject: words, body: words, attachment: words, + query: words, }); +/** 색인의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (listHighlight 의 otherFields) */ +const otherFields = (fields: { + from?: string; + to?: string; + subject?: string; + attachment?: string; +}): string[] => + [fields.from, fields.to, fields.subject, fields.attachment].map((field) => + foldText(field ?? ''), + ); + /** 글에서 낱말이 처음 맞은 곳 */ const firstMatch = (body: string, word: string) => matchRanges(body, [word], 1)[0]; @@ -98,6 +111,7 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () preview: '엔진의 미리 보기', bodyText: '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + otherFields: null, }, terms('계약', '갱신'), ); @@ -124,6 +138,7 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () subject: '계약 안내', preview: '첨부한 계약서를\n확인해 주세요', bodyText: null, + otherFields: null, }, terms('계약'), ); @@ -133,7 +148,12 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () }); const notInBody = listHighlight( - { subject: '계약 안내', preview: '첨부 확인', bodyText: '첨부 확인' }, + { + subject: '계약 안내', + preview: '첨부 확인', + bodyText: '첨부 확인', + otherFields: otherFields({ subject: '계약 안내' }), + }, terms('계약'), ); expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] }); @@ -146,8 +166,9 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () subject: '견적 요청', preview: '견적 부탁드립니다', bodyText: '견적 부탁드립니다', + otherFields: null, }, - { subject: ['견적'], body: [], attachment: [] }, + { subject: ['견적'], body: [], attachment: [], query: [] }, ); expect(highlight).toEqual({ subject: [[0, 2]], @@ -158,7 +179,12 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => { const nfd = '계약 PROJECT'.normalize('NFD'); const highlight = listHighlight( - { subject: 'PROJECT 안내', preview: nfd, bodyText: null }, + { + subject: 'PROJECT 안내', + preview: nfd, + bodyText: null, + otherFields: null, + }, terms('project', '계약'), ); expect(highlight.subject).toEqual([[0, 7]]); @@ -175,4 +201,182 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () expect(highlightedSubject(nfd).length).toBe(5); }); }); + + describe('답장의 원문 인용(> 줄)과 미리 보기 (2026-11-06 사용자 관점 점검)', () => { + /** 미리 보기 — 본문 · 강조할 말 · 색인의 접은 칸(없으면 null) */ + const previewOf = ( + bodyText: string, + words: HighlightTerms, + fields: string[] | null = null, + ) => + listHighlight( + { + subject: '제목', + preview: '엔진의 미리 보기', + bodyText, + otherFields: fields, + }, + words, + ).preview; + + const REPLY_SUBJECT = 'RE: 해오름 프로젝트 견적 검토 요청'; + const REPLY_FIELDS = otherFields({ + from: '이도윤 ', + to: '김아라 ', + subject: REPLY_SUBJECT, + }); + + it('인용 밖과 안에서 모두 맞으면 인용 밖의 맞은 곳 — 본문에서 먼저 나온 인용 안의 맞은 곳보다 먼저', () => { + const preview = previewOf( + '> 견적 부탁드립니다.\n\n견적서 확인했습니다. 금요일까지 회신 드리겠습니다.', + terms('견적'), + ); + expect(preview).toEqual({ + text: '…견적서 확인했습니다. 금요일까지 회신 드리겠습니다.', + ranges: [[1, 3]], + }); + }); + + it('인용 밖에서 맞은 곳의 둘레는 인용 줄에 닿기 전까지 — 뒤따르는 인용은 싣지 않고 "…"', () => { + const preview = previewOf( + '금요일까지 견적서 회신 드리겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 견적 자료를 보냅니다.', + terms('견적'), + ); + expect(preview).toEqual({ + text: '금요일까지 견적서 회신 드리겠습니다.…', + ranges: [[6, 8]], + }); + }); + + it('인용 안에서만 맞고 그 말이 다른 칸(제목 · 사람 · 첨부 이름)에서도 맞으면 인용 앞의 글 — 그 글에 맞은 곳이 없으면 범위도 없다', () => { + const body = + '네, 확인했습니다. 다음 주에 뵙겠습니다.\n\n> 제목: 해오름 프로젝트 견적 검토 요청\n> > 도윤 님, 견적 부탁드립니다.'; + const expected = { + text: '네, 확인했습니다. 다음 주에 뵙겠습니다.…', + ranges: [], + }; + // 제목에서 맞음 + expect(previewOf(body, terms('견적'), REPLY_FIELDS)).toEqual(expected); + // 보낸 사람에서 맞음 + expect(previewOf(body, terms('도윤'), REPLY_FIELDS)).toEqual(expected); + // 첨부 이름에서 맞음 + expect( + previewOf( + body, + terms('검토'), + otherFields({ subject: '회신', attachment: '검토 의견.pdf' }), + ), + ).toEqual(expected); + }); + + it('인용 안에서만 맞고 다른 칸에서는 맞지 않으면 인용 조각 — 줄 앞의 > 를 뺀 글을 그 인용 안에서만, 위치는 보이는 글 기준', () => { + const preview = previewOf( + '네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.', + terms('R9A51'), + otherFields({ subject: 'RE: 해오름 프로젝트 안내' }), + ); + expect(preview).toEqual({ + text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ranges: [[16, 21]], + }); + expect(slices(preview.text, preview.ranges)).toEqual(['R9A51']); + }); + + it('본문에서 맞은 말 가운데 하나라도 다른 칸에서 맞지 않으면 인용 조각 — 내용 조건의 말은 본문에서만 찾으므로 다른 칸에 있어도 인용 조각', () => { + const body = + '네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.'; + const fields = otherFields({ subject: 'RE: 해오름 프로젝트 안내' }); + + const both = previewOf(body, terms('해오름', 'R9A51'), fields); + expect(both.text).toBe( + '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ); + expect(slices(both.text, both.ranges)).toEqual(['해오름', 'R9A51']); + + const bodyCondition = previewOf( + body, + { subject: [], body: ['해오름'], attachment: [], query: [] }, + fields, + ); + expect(bodyCondition.text).toBe( + '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ); + expect(slices(bodyCondition.text, bodyCondition.ranges)).toEqual([ + '해오름', + ]); + }); + + it('인용으로 시작하는 본문(인용 아래에 쓴 답장)은 인용 밖의 첫 글 — 본문이 모두 인용이면 다른 칸에서 맞아도 인용 조각', () => { + expect( + previewOf( + '> 해오름 프로젝트 견적 부탁드립니다.\n\n네, 금요일까지 보내 드리겠습니다.', + terms('견적'), + REPLY_FIELDS, + ), + ).toEqual({ text: '…네, 금요일까지 보내 드리겠습니다.', ranges: [] }); + + const quoteOnly = previewOf( + '> 해오름 프로젝트 견적 부탁드립니다.\n> 감사합니다.', + terms('견적'), + REPLY_FIELDS, + ); + expect(quoteOnly.text).toBe( + '해오름 프로젝트 견적 부탁드립니다. 감사합니다.', + ); + expect(slices(quoteOnly.text, quoteOnly.ranges)).toEqual(['견적']); + }); + + it('인용 줄 알아보기 — 앞 공백 · 탭 뒤의 > · 겹친 표시(>> · > >) · CRLF, 줄 가운데의 > 는 인용이 아니다', () => { + expect( + previewOf('a > b 견적 정리\r\n >> > 견적 원문', terms('견적')), + ).toEqual({ text: 'a > b 견적 정리…', ranges: [[6, 8]] }); + + expect( + previewOf(' >> > 견적 원문\r\n\t> 두 번째 견적', terms('견적')), + ).toEqual({ + text: '견적 원문 두 번째 견적', + ranges: [ + [0, 2], + [11, 13], + ], + }); + }); + + it('인용 조각도 서로게이트 쌍을 가르지 않고 위치는 NFC 글 기준 — NFD 로 적은 말도 맞는다', () => { + const body = `확인했습니다.\n\n> ${'😀'.repeat(15)} 계약서 보내 드립니다 ${'😀'.repeat(40)}`; + const preview = previewOf(body, terms('계약'.normalize('NFD'))); + + expect(preview.text).not.toContain('>'); + expect(() => encodeURIComponent(preview.text)).not.toThrow(); + expect(slices(preview.text, preview.ranges)).toEqual(['계약']); + expect(preview.text.startsWith('…')).toBe(true); + }); + + it('본문 길이에 비례한다 — 인용 줄이 많은 긴 본문(색인 본문 상한 남짓)에서 끝의 인용 조각을 골라도', () => { + const bodyOf = (lines: number) => + `확인했습니다.\n\n${Array.from( + { length: lines }, + (_, index) => `> > 인용 줄 ${index} 가나다라마바사`, + ).join('\n')}\n> 마지막 견적 줄`; + const run = (body: string) => { + for (let round = 0; round < 20; round += 1) { + previewOf(body, terms('견적')); + } + }; + const small = bodyOf(50); + const large = bodyOf(1_000); + expect(previewOf(large, terms('견적')).text).toContain('마지막 견적 줄'); + run(small); + + const smallCpu = startCpuTimer(); + run(small); + const smallMs = smallCpu(); + const largeCpu = startCpuTimer(); + run(large); + const largeMs = largeCpu(); + // 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts) + expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10); + expect(largeMs).toBeLessThan(5_000); + }); + }); }); diff --git a/backend/src/modules/mail-search/search-highlight.ts b/backend/src/modules/mail-search/search-highlight.ts index 4d8fcc6..9aa450d 100644 --- a/backend/src/modules/mail-search/search-highlight.ts +++ b/backend/src/modules/mail-search/search-highlight.ts @@ -1,5 +1,8 @@ import { + foldTerms, + foldText, isSpaceCode, + matchFoldedRanges, matchRanges, type MatchRange, } from '@/shared/utils/search-text'; @@ -14,12 +17,21 @@ import type { HighlightTerms } from './search-plan'; /** * 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄. * - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다) - * - 미리 보기: 본문(색인의 글 — NFC)에서 맞았으면 처음 맞은 말 둘레(앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — - * SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤가 잘렸으면 "…"), 아니면 엔진의 미리 보기. 공백은 하나로 줄인 한 줄이고 맞은 곳은 그 글 기준으로 다시 찾는다 + * - 미리 보기: 본문(색인의 글 — NFC)에서 맞은 곳을 아래 차례로 고르고(2026-11-06 사용자 관점 점검 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이라 + * 셋째 줄로 답장끼리 가를 수 없었다), 본문에서 맞지 않았으면 엔진의 미리 보기 + * 1. 인용 줄(앞 공백 뒤 `>` 로 시작하는 줄) 밖에서 처음 맞은 곳의 둘레 — 인용 줄에 닿기 전까지만 + * 2. 인용 안에서만 맞았고 본문에서 맞은 말이 모두 다른 칸(제목 · 사람 · 첨부 이름 — 검색어의 낱말)에서도 맞았으면 인용 밖의 첫 글(보통 인용 앞의 글) + * 3. 그 밖에는 인용 안에서 처음 맞은 곳의 둘레 — 줄 앞의 `>` 표시를 뺀 글로, 그 인용(이어진 인용 줄) 안에서만 + * 다른 메일 프로그램의 인용 꼴(`>` 없이 "-----Original Message-----" 아래에 두는 것 등)은 알아보지 않는다 + * - 둘레는 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤에 잘라 낸 글이 있으면 "…". + * 공백은 하나로 줄인 한 줄이고 맞은 곳은 보이는 그 글에서 다시 찾는다(그 글에 맞은 곳이 없으면 범위도 없다) * 위치는 UTF-16 [시작, 끝) */ const ELLIPSIS = '…'; +/** 인용 줄의 표시 `>` */ +const QUOTE_MARK_CODE = 0x3e; +const LINE_BREAK = '\n'; function isHighSurrogate(code: number): boolean { return code >= 0xd800 && code <= 0xdbff; @@ -78,29 +90,35 @@ function oneLine(text: string): string { /** * 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다. - * 앞 · 뒤에 잘라 낸 글(공백만이 아닌)이 있으면 "…" 를 붙이고 한 줄로 만든다 + * `bounds`([시작, 끝) — 처음 값은 글 전체) 밖으로는 나가지 않는다(인용 줄에 닿기 전까지 등). + * 앞 · 뒤에 잘라 낸 글(공백만이 아닌 — `bounds` 밖도 센다)이 있으면 "…" 를 붙이고 한 줄로 만든다 */ -export function snippetAround(body: string, first: MatchRange): string { +export function snippetAround( + body: string, + first: MatchRange, + bounds: MatchRange = [0, body.length], +): string { + const [low, high] = bounds; const [matchStart, matchEnd] = first; // 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리 - const lead = Math.max(0, matchStart - SNIPPET_LEAD_CHARS); + const lead = Math.max(low, matchStart - SNIPPET_LEAD_CHARS); let start = lead; - if (lead > 0) { + if (lead > low) { const before = findSpace( body, lead, - Math.max(0, lead - SNIPPET_BOUNDARY_SLACK), + Math.max(low, lead - SNIPPET_BOUNDARY_SLACK), ); const after = before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1; const space = before >= 0 ? before : after; if (space >= 0) start = space + 1; } - start = alignStart(body, start); + start = Math.max(low, 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) { + let end = Math.min(high, target); + if (end < high) { const back = findSpace( body, end, @@ -108,49 +126,213 @@ export function snippetAround(body: string, first: MatchRange): string { ); const forward = back < 0 - ? findSpace( - body, - end, - Math.min(body.length - 1, end + SNIPPET_BOUNDARY_SLACK), - ) + ? findSpace(body, end, Math.min(high - 1, end + SNIPPET_BOUNDARY_SLACK)) : -1; const space = back >= 0 ? back : forward; if (space >= 0) end = space; } - end = alignEnd(body, end); + end = Math.min(high, alignEnd(body, end)); const text = oneLine(body.slice(start, end)); const head = hasVisible(body, 0, start) ? ELLIPSIS : ''; const tail = hasVisible(body, end, body.length) ? ELLIPSIS : ''; return `${head}${text}${tail}`; } +/** 본문의 한 줄 — [start, end) (줄바꿈 글자는 빼고). 인용 줄이면 `quoteText` 가 표시 다음 글의 시작, 아니면 null */ +interface BodyLine { + readonly start: number; + readonly end: number; + readonly quoteText: number | null; +} + +/** 인용 줄이면 표시 다음 글의 시작 — 앞 공백 뒤 `>` 로 시작하는 줄 (`> > 글` 처럼 겹친 표시와 그 사이 · 뒤 공백을 모두 넘는다) */ +function quoteTextStart( + body: string, + start: number, + end: number, +): number | null { + let index = start; + while (index < end && isSpaceCode(body.charCodeAt(index))) index += 1; + if (index >= end || body.charCodeAt(index) !== QUOTE_MARK_CODE) return null; + while (index < end) { + const code = body.charCodeAt(index); + if (code !== QUOTE_MARK_CODE && !isSpaceCode(code)) break; + index += 1; + } + return index; +} + +/** 본문의 줄들 — 한 번 훑는다 */ +function bodyLines(body: string): BodyLine[] { + const lines: BodyLine[] = []; + for (let start = 0; ; ) { + const found = body.indexOf(LINE_BREAK, start); + const end = found < 0 ? body.length : found; + lines.push({ start, end, quoteText: quoteTextStart(body, start, end) }); + if (found < 0) return lines; + start = found + 1; + } +} + +/** 그 자리를 담은 줄의 차례 (줄은 시작 차례로 놓여 있다) */ +function lineIndexAt(lines: readonly BodyLine[], position: number): number { + let low = 0; + let high = lines.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (lines[middle].start <= position) low = middle; + else high = middle - 1; + } + return low; +} + +/** 인용 줄을 공백으로 덮은 글 — 위치 · 길이는 그대로 (인용 밖에서만 맞추려고) */ +function maskQuotes(text: string, lines: readonly BodyLine[]): string { + let masked = ''; + let from = 0; + for (const line of lines) { + if (line.quoteText === null) continue; + masked += text.slice(from, line.start) + ' '.repeat(line.end - line.start); + from = line.end; + } + return masked + text.slice(from); +} + +/** 그 줄과 같은 쪽(인용 · 인용 밖)인 줄들의 이어진 범위 [시작, 끝) — 미리 보기가 인용과 인용 밖을 섞지 않게 */ +function runAround(lines: readonly BodyLine[], index: number): MatchRange { + const quoted = lines[index].quoteText !== null; + const same = (line: BodyLine) => (line.quoteText !== null) === quoted; + let first = index; + while (first > 0 && same(lines[first - 1])) first -= 1; + let last = index; + while (last + 1 < lines.length && same(lines[last + 1])) last += 1; + return [lines[first].start, lines[last].end]; +} + +/** 인용 밖의 첫 글 — 보이는 글이 있는 첫 인용 밖 줄부터 다음 인용 줄 전까지 (없으면 null) */ +function firstPlainRun( + body: string, + lines: readonly BodyLine[], +): MatchRange | null { + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.quoteText === null && hasVisible(body, line.start, line.end)) { + return [line.start, runAround(lines, index)[1]]; + } + } + return null; +} + +/** 인용 표시를 뺀 글 — 줄은 그대로 잇고, 원래 글의 자리를 이 글의 자리로 바꾸는 함수를 함께 준다 */ +function stripQuoteMarks( + body: string, + lines: readonly BodyLine[], +): { readonly text: string; readonly at: (position: number) => number } { + const parts: string[] = []; + const starts: number[] = []; + let length = 0; + lines.forEach((line, index) => { + if (index > 0) { + parts.push(LINE_BREAK); + length += LINE_BREAK.length; + } + starts.push(length); + const from = line.quoteText ?? line.start; + parts.push(body.slice(from, line.end)); + length += line.end - from; + }); + return { + text: parts.join(''), + at: (position) => { + const index = lineIndexAt(lines, position); + const line = lines[index]; + const from = line.quoteText ?? line.start; + return ( + starts[index] + Math.min(Math.max(0, position - from), line.end - from) + ); + }, + }; +} + +/** + * 본문에서 맞은 말(`words` — 접은 말)이 모두 다른 칸에서도 맞았나 — 검색어의 낱말이고(내용 조건의 낱말은 본문에서만 찾는다) + * 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름) 가운데 하나에 든다. 칸을 모르면(null) 아니다 + */ +function matchedElsewhere( + folded: string, + words: readonly string[], + query: readonly string[], + otherFields: readonly string[] | null, +): boolean { + if (otherFields === null) return false; + const queryWords = new Set(foldTerms(query)); + return words + .filter((word) => folded.includes(word)) + .every( + (word) => + queryWords.has(word) && + otherFields.some((field) => field.includes(word)), + ); +} + +/** 본문에서 고른 미리 보기 한 줄 (위의 1 · 2 · 3) — 본문에서 맞지 않았으면 null */ +function bodySnippet( + text: string, + terms: HighlightTerms, + otherFields: readonly string[] | null, +): string | null { + // 접은 글은 NFC 본문과 위치가 같다 — 줄 · 인용은 본문에서, 맞추기는 접은 글에서 (색인의 본문은 이미 NFC 다 — 거의 그대로 돌아온다) + const body = text.normalize('NFC'); + const folded = foldText(body); + const words = foldTerms(terms.body); + const lines = bodyLines(body); + const [outside] = matchFoldedRanges(maskQuotes(folded, lines), words, 1); + if (outside) { + return snippetAround( + body, + outside, + runAround(lines, lineIndexAt(lines, outside[0])), + ); + } + const [inside] = matchFoldedRanges(folded, words, 1); + if (!inside) return null; + if (matchedElsewhere(folded, words, terms.query, otherFields)) { + const run = firstPlainRun(body, lines); + if (run) return snippetAround(body, [run[0], run[0]], run); + } + // 인용 조각 — 그 인용(이어진 인용 줄)에 닿기 전까지, `>` 표시를 뺀 글에서 + const stripped = stripQuoteMarks(body, lines); + const [runStart, runEnd] = runAround(lines, lineIndexAt(lines, inside[0])); + return snippetAround( + stripped.text, + [stripped.at(inside[0]), stripped.at(inside[1])], + [stripped.at(runStart), stripped.at(runEnd)], + ); +} + function toRanges(ranges: readonly MatchRange[]): SearchRange[] { return ranges.map(([start, end]) => [start, end] as const); } /** - * 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null) + * 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null), + * `otherFields` 는 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 — 없으면 null) */ export function listHighlight( input: { readonly subject: string; readonly preview: string; readonly bodyText: string | null; + readonly otherFields: readonly string[] | null; }, terms: HighlightTerms, ): MessageListHighlight { const subject = toRanges(matchRanges(input.subject, terms.subject)); - 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')); + const fromBody = + input.bodyText && terms.body.length > 0 + ? bodySnippet(input.bodyText, terms, input.otherFields) + : null; + const text = fromBody ?? oneLine(input.preview.normalize('NFC')); return { subject, preview: { text, ranges: toRanges(matchRanges(text, terms.body)) }, diff --git a/backend/src/modules/mail-search/search-plan.spec.ts b/backend/src/modules/mail-search/search-plan.spec.ts index 8353fc0..5a5b560 100644 --- a/backend/src/modules/mail-search/search-plan.spec.ts +++ b/backend/src/modules/mail-search/search-plan.spec.ts @@ -45,6 +45,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { subject: ['계약', 'PROJECT'], body: ['계약', 'PROJECT'], attachment: ['계약', 'PROJECT'], + query: ['계약', 'PROJECT'], }); }); @@ -78,6 +79,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { subject: ['견적'], body: ['단가 조정'], attachment: [], + query: [], }); }); @@ -99,6 +101,29 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { { term: '묵', fields: ALL_FOLD_FIELDS }, { term: '계약', fields: ALL_FOLD_FIELDS }, ]); + // 본문 강조는 두 글자 이상 낱말만 — 한 글자 낱말은 제목 · 첨부 이름에만 (2026-11-06 실사용 시험) + expect(plan.highlight).toEqual({ + subject: ['묵', '계약'], + body: ['계약'], + attachment: ['묵', '계약'], + query: ['묵', '계약'], + }); + }); + + it('본문 강조는 본문을 찾은 두 글자 이상 낱말만 — 내용 조건과 섞여도 · 묶음이 없는 구절(한 글자씩 띄운 것)도 한 글자 낱말과 같다', () => { + // 내용 조건의 두 글자 이상 낱말과 섞임 — 검색어의 한 글자 낱말은 본문에 강조하지 않는다 + expect( + planSearch(criteria({ text: '견', body: '계약서' })).highlight.body, + ).toEqual(['계약서']); + // 두 글자 이상 낱말이 강조하지 않는 칸(보낸 사람)에만 있어도 섞인 검색이다 — 본문에 강조할 말이 없다 + const withSender = planSearch(criteria({ text: '견', from: '김아라' })); + expect(withSender.bodySkipped).toBe(false); + expect(withSender.highlight.body).toEqual([]); + expect(withSender.highlight.subject).toEqual(['견']); + // 한 글자씩 띄운 구절은 묶음이 없다 + expect(planSearch(criteria({ text: '"a b" 계약' })).highlight.body).toEqual( + ['계약'], + ); }); it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => { @@ -119,8 +144,13 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { }, { term: '김', fields: ['fold_from'] }, ]); - // 강조는 본문에도 — 계획이 본문을 훑지 않았을 뿐 맞은 곳은 보인다 - expect(plan.highlight.body).toEqual(['묵', '뷁']); + // 본문을 찾지 않았으니 본문(미리 보기 · 읽기 화면)에는 강조하지 않는다 — 제목 · 첨부 이름에는 그대로 (2026-11-06 실사용 시험) + expect(plan.highlight).toEqual({ + subject: ['묵', '뷁'], + body: [], + attachment: ['묵', '뷁'], + query: ['묵', '뷁'], + }); }); it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => { @@ -217,6 +247,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { subject: ['계약', '견적'], body: ['계약', '단가 조정'], attachment: ['계약'], + query: ['계약'], }); }); @@ -224,6 +255,29 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { expect(highlightTermsOf({})).toBeNull(); expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull(); }); + + it('본문에는 목록과 같이 본문을 찾은 낱말만 — 한 글자 낱말뿐이면 제목 · 첨부 이름에만, 섞이면 두 글자 이상 낱말만 (보낸 사람 · 받는 사람 조건도 섞임을 정한다)', () => { + expect(highlightTermsOf({ text: '견' })).toEqual({ + subject: ['견'], + body: [], + attachment: ['견'], + query: ['견'], + }); + expect(highlightTermsOf({ text: '견 계약서' })?.body).toEqual(['계약서']); + expect(highlightTermsOf({ text: '견', to: '김아라' })?.body).toEqual([]); + // 한 글자 내용 조건뿐 — 찾을 곳이 없어 강조할 말도 없다 + expect(highlightTermsOf({ body: '견' })).toBeNull(); + // 목록의 계획과 같은 강조 + for (const given of [ + { text: '견' }, + { text: '견 계약서', subject: '회' }, + { text: '견', from: '김아라', body: '단가' }, + ]) { + expect(highlightTermsOf(given)).toEqual( + planSearch(criteria(given)).highlight, + ); + } + }); }); it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => { diff --git a/backend/src/modules/mail-search/search-plan.ts b/backend/src/modules/mail-search/search-plan.ts index 9e157ef..3b4ed10 100644 --- a/backend/src/modules/mail-search/search-plan.ts +++ b/backend/src/modules/mail-search/search-plan.ts @@ -15,6 +15,7 @@ import type { SearchCriteria } from './mail-search.types'; * - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다) * - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams). * 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다 + * - 본문(목록 미리 보기 · 읽기 화면)에는 본문을 찾은 낱말만 강조한다(2026-11-06 실사용 시험 — bodyHighlightOf). 제목 · 첨부 이름 강조는 낱말 그대로다 */ /** 확인하는 접기 칸 — 표의 열 이름 */ @@ -54,8 +55,14 @@ export interface TermCheck { /** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */ export interface HighlightTerms { readonly subject: readonly string[]; + /** 본문을 찾은 낱말만 (bodyHighlightOf) — 한 글자 낱말은 들지 않는다 */ readonly body: readonly string[]; readonly attachment: readonly string[]; + /** + * 검색어(q)의 낱말 — 다섯 칸 어디서든 찾는다. 목록 미리 보기가 본문의 인용 안에서만 맞은 말을 다른 칸(제목 · 사람 · 첨부 이름)에서도 + * 찾았는지 볼 때 쓴다(search-highlight.ts). 내용 조건(body)의 낱말은 본문에서만 찾으므로 들지 않는다 + */ + readonly query: readonly string[]; } export interface SearchPlan { @@ -110,6 +117,27 @@ function dateOf(value: string | undefined): Date | null { return Number.isNaN(time) ? null : new Date(time); } +/** 묶음이 있는 낱말인가 — 접어서 글자 두 개 묶음이 하나라도 나온다 (한 글자 낱말 · 한 글자씩 띄운 구절은 없다) */ +function hasGrams(raw: string): boolean { + return gramsOf(foldText(raw)).size > 0; +} + +/** + * 본문에 강조할 말 (2026-11-06 실사용 시험) — 본문을 찾은 낱말만이다. 검색어 · 내용 조건의 낱말(`raws`) 가운데 + * - 한 글자 낱말뿐이라 본문을 훑지 않았으면(bodySkipped) 없다 + * - 묶음이 있는 낱말(두 글자 이상)이 하나라도 있으면(`anyGrams` — 다섯 칸의 낱말 어디든) 그 낱말만 — 한 글자 낱말은 함께 찾아 본문도 확인했지만 + * 본문 곳곳에 맞아 미리 보기 · 읽기 화면의 강조가 흩어진다 + * - 묶음이 하나도 없는데 본문을 훑었으면(SINGLE_CHAR_TERMS_SKIP_BODY 를 끈 때) 모두 + */ +function bodyHighlightOf( + raws: readonly string[], + bodySkipped: boolean, + anyGrams: boolean, +): string[] { + if (bodySkipped) return []; + return anyGrams ? raws.filter(hasGrams) : [...raws]; +} + export function planSearch(criteria: SearchCriteria): SearchPlan { const query = termsOf(criteria.text); const conditions = CONDITION_NAMES.map((name) => ({ @@ -175,22 +203,29 @@ export function planSearch(criteria: SearchCriteria): SearchPlan { exactByGrams, highlight: { subject: [...query.raw, ...subjectTerms], - body: [...query.raw, ...bodyTerms], + body: bodyHighlightOf( + [...query.raw, ...bodyTerms], + bodySkipped, + grams.size > 0, + ), attachment: [...query.raw], + query: [...query.raw], }, }; } /** - * 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에. - * 보낸 사람 · 받는 사람 조건은 강조하지 않는다. 강조할 말이 하나도 없으면 null + * 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에 — 목록과 같은 계획(planSearch)에서 꺼낸다. + * 본문에는 본문을 찾은 낱말만(한 글자 낱말 — bodyHighlightOf). 보낸 사람 · 받는 사람 조건은 강조하지 않지만 받는다 — 두 글자 이상 낱말이 + * 그 칸에만 있어도 한 글자 낱말과 "섞인" 검색이다(목록의 찾기와 같은 판정). 강조할 말이 하나도 없으면 null */ export function highlightTermsOf( - criteria: Pick, + 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 }; + const { highlight } = planSearch({ ...criteria, hasAttachment: false }); + // 첨부 이름의 말(검색어)은 늘 제목의 말 안에 든다 + if (highlight.subject.length === 0 && highlight.body.length === 0) { + return null; + } + return highlight; } diff --git a/backend/src/modules/mail-search/search-sql.spec.ts b/backend/src/modules/mail-search/search-sql.spec.ts index ee44849..56528b0 100644 --- a/backend/src/modules/mail-search/search-sql.spec.ts +++ b/backend/src/modules/mail-search/search-sql.spec.ts @@ -30,7 +30,7 @@ const plan = (overrides: Partial) => const flat = (sql: string) => sql.replace(/\s+/g, ' '); describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { - it('후보 — 주소(묶음 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => { + it('후보 — 주소(찾기 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => { const query = candidatesSql( USER, ALL, @@ -56,7 +56,7 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { expect(query.sql).not.toContain('strpos'); }); - it('메일함 범위 · 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => { + it('메일함 범위는 찾기 GIN 이 함께 맞추는 꼴(@>) — = ANY 로 적으면 그 직원의 묶음 줄을 모두 읽고 메일함을 줄마다 거른다 (2026-11-06 실사용 시험), 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => { const after = { receivedAt: new Date('2026-11-01T00:00:00Z'), emailId: 'm9', @@ -78,6 +78,7 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { 'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' + 'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]', ); + expect(query.sql).not.toContain('ANY("mailbox_ids")'); expect(query.params).toEqual([ USER, ['a'], @@ -184,10 +185,11 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { } }); - it('강조에 쓰는 글 — 그 직원의 찾은 줄만', () => { + 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[])', + 'SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" ' + + 'FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])', ); expect(query.params).toEqual([USER, ['m1', 'm2']]); }); diff --git a/backend/src/modules/mail-search/search-sql.ts b/backend/src/modules/mail-search/search-sql.ts index 7a16064..210cbb8 100644 --- a/backend/src/modules/mail-search/search-sql.ts +++ b/backend/src/modules/mail-search/search-sql.ts @@ -6,8 +6,11 @@ import type { SearchPlan } from './search-plan'; * 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다. * * 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다: - * - 후보(candidatesSql): 주소 + 묶음을 GIN 에서만 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. + * - 후보(candidatesSql): 주소 + 묶음 + 메일함을 GIN 하나(`IDX_mail_search_messages_user_grams_mailboxes` — 마이그레이션 SearchIndexUserGramsMailboxes)에서만 + * 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. 메일함 범위는 `"mailbox_ids" @> $n` 그대로 둔다 — + * 같은 GIN 이 메일함까지 맞춰 맞은 줄만 읽는다(`= ANY` 로 적으면 그 직원의 그 묶음 줄을 모두 읽고 메일함을 줄마다 거른다). * 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms). + * 메일함만 담은 GIN 이 있던 때는 계획기가 그것만으로 메일함의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-06 실사용 시험 — 1만 통 메일함 안 1.1초). * 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다 * - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다 * 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는 @@ -44,7 +47,8 @@ class Params { } /** - * 직원을 거르는 길 — `gin`: 묶음 GIN 의 식(lower)으로 묶음과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키) + * 직원을 거르는 길 — `gin`: 찾기 GIN 의 식(lower)으로 묶음 · 메일함과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키 — + * 메일함 조건은 줄을 읽은 뒤 거른다. 이 길의 쿼리는 bitmap 을 끈다) * 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다 */ type UserMatch = 'gin' | 'btree'; @@ -106,7 +110,7 @@ function checkConditions(params: Params, plan: SearchPlan): string { .join(' AND '); } -/** 후보 — 주소 + 묶음(GIN)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */ +/** 후보 — 주소 + 묶음 + 메일함(GIN 하나)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */ export function candidatesSql( user: string, scope: SearchScope, @@ -260,13 +264,16 @@ export function plainCountSql( }; } -/** 강조에 쓰는 글 — 찾은 쪽의 줄만 */ +/** + * 강조에 쓰는 글 — 찾은 쪽의 줄만. 본문 밖의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름)은 미리 보기를 고를 때 + * 본문의 인용 안에서만 맞은 말을 그 칸에서도 찾았는지 본다(search-highlight.ts) + */ 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[])`, + sql: `SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, params: [user, [...ids]], }; } diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 25adc1d..40ef947 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -678,11 +678,14 @@ export class MailService { id: string, query: MessageDetailQueryDto = {}, ): Promise { - // 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다) + // 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다). + // 보낸 사람 · 받는 사람 조건은 강조하지 않지만 넘긴다 — 본문에 강조할 말(한 글자 낱말)을 목록과 같게 고른다 return highlightDetail( await this.readMessage(user, id, query), highlightTermsOf({ text: query.q, + from: query.from, + to: query.to, subject: query.subject, body: query.body, }), diff --git a/backend/src/modules/mail/message-highlight.spec.ts b/backend/src/modules/mail/message-highlight.spec.ts index e0c9f9c..3908d46 100644 --- a/backend/src/modules/mail/message-highlight.spec.ts +++ b/backend/src/modules/mail/message-highlight.spec.ts @@ -106,6 +106,34 @@ describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계 expect(bodyOnly.htmlBody).toContain('일정'); }); + it('한 글자 낱말뿐이면 본문은 강조하지 않는다 — 본문을 찾지 않았다, 제목 · 첨부 이름은 그대로 (2026-11-06 실사용 시험)', () => { + const source = detail({ + subject: '견적 안내', + htmlBody: '

이번 달 견적 안내

', + textBody: '이번 달 견적 안내', + }); + // '견' 은 제목 · 첨부 이름(견적.xlsx) · 본문에, '달' 은 본문에만 있다 + const single = highlightDetail(source, highlightTermsOf({ text: '달 견' })); + expect(single.searchHighlight).toEqual({ + subject: [[0, 1]], + attachments: { p2: [[0, 1]] }, + textBody: [], + }); + expect(single.htmlBody).toBe('

이번 달 견적 안내

'); + + // 두 글자 이상 낱말과 섞이면 그 낱말만 본문에 — 한 글자 낱말은 제목 · 첨부 이름에만 + const mixed = highlightDetail( + source, + highlightTermsOf({ text: '달 견적' }), + ); + expect(slices(mixed.textBody!, mixed.searchHighlight!.textBody)).toEqual([ + '견적', + ]); + expect(mixed.htmlBody).toBe('

이번 달 견적 안내

'); + expect(mixed.searchHighlight!.subject).toEqual([[0, 2]]); + expect(mixed.searchHighlight!.attachments).toEqual({ p2: [[0, 2]] }); + }); + it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => { const nfd = (text: string) => text.normalize('NFD'); const result = highlightDetail( diff --git a/backend/src/modules/mail/message-list.service.spec.ts b/backend/src/modules/mail/message-list.service.spec.ts index 65a3992..39fe63c 100644 --- a/backend/src/modules/mail/message-list.service.spec.ts +++ b/backend/src/modules/mail/message-list.service.spec.ts @@ -252,6 +252,7 @@ describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)' subject: '계약 갱신 안내', bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.', preview: '안녕하세요.', + otherFields: ['', '', '계약 갱신 안내', ''], }, ], ]), @@ -272,6 +273,42 @@ describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)' }); }); + it('색인 방식의 미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 색인의 다른 칸(제목)에서도 맞으면 인용 앞의 글 (2026-11-06 사용자 관점 점검)', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockResolvedValue({ + hits: [{ id: 'm1', receivedAt: new Date() }], + hasNext: false, + nextCursor: null, + total: 1, + }); + provider.getSummaries.mockResolvedValue([ + summary('m1', { + subject: 'RE: 해오름 견적', + preview: '네, 확인했습니다. > 견적서를 보내 드립니다.', + }), + ]); + searchQuery.highlightTexts.mockResolvedValue( + new Map([ + [ + 'm1', + { + subject: 'RE: 해오름 견적', + bodyText: '네, 확인했습니다.\n\n> 견적서를 보내 드립니다.', + preview: '네, 확인했습니다.', + otherFields: ['', '', 're: 해오름 견적', ''], + }, + ], + ]), + ); + + const page = await service.list(user, { limit: 20, q: '견적' }); + + expect(page.items[0].searchHighlight).toEqual({ + subject: [[8, 10]], + preview: { text: '네, 확인했습니다.…', ranges: [] }, + }); + }); + it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => { const { service, provider, searchQuery } = setup(); searchQuery.search.mockResolvedValue({ diff --git a/backend/src/modules/mail/message-list.service.ts b/backend/src/modules/mail/message-list.service.ts index f4b1319..fe8df08 100644 --- a/backend/src/modules/mail/message-list.service.ts +++ b/backend/src/modules/mail/message-list.service.ts @@ -188,6 +188,7 @@ export class MessageListService { const summary = byId.get(id); if (!summary || !inScope(summary, scope)) return []; const subject = highlightedSubject(summary.subject); + const text = texts.get(id); return [ { ...summary, @@ -196,7 +197,8 @@ export class MessageListService { { subject, preview: summary.preview, - bodyText: texts.get(id)?.bodyText ?? null, + bodyText: text?.bodyText ?? null, + otherFields: text?.otherFields ?? null, }, plan.highlight, ), diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 9156366..a79d7ff 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -130,8 +130,14 @@ import { import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service'; import type { SearchScope } from '@/modules/mail-search/mail-search.types'; import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository'; -import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants'; +import { PLANNER_SETTINGS } from '@/modules/mail-search/mail-search-query.service'; +import { + INDEX_FORMAT, + SEARCH_CHECK_ROWS_LIMIT, +} from '@/modules/mail-search/mail-search.constants'; import { toIndexRow } from '@/modules/mail-search/index-document'; +import { planSearch } from '@/modules/mail-search/search-plan'; +import { candidatesSql } from '@/modules/mail-search/search-sql'; import { SystemSettingsService, type SystemSettingsPatch, @@ -8082,6 +8088,16 @@ describe('응답 계약 (e2e)', () => { mailboxIds: [SEARCH_EXCLUDED_MAILBOX], receivedAt: '2026-11-01T01:00:00Z', }), + // 답장 — 내 글 아래에 원문 인용(> 줄)이 있다 (미리 보기 고르기 — 2026-11-06 사용자 관점 점검) + indexDocument({ + id: 'm-reply', + subject: 'RE: 해오름 프로젝트 안내', + bodyText: + '네, 받았습니다. 금요일까지 확인하겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.', + preview: + '네, 받았습니다. 금요일까지 확인하겠습니다. > 원본 메일 > 해오름 프로젝트 R9A51 자료를 보냅니다.', + receivedAt: '2026-10-30T01:00:00Z', + }), ]; interface ListItem { @@ -8202,6 +8218,102 @@ describe('응답 계약 (e2e)', () => { expect(await idsOf({ q: '층' })).toEqual(['m-floor']); // 두 글자 넘는 낱말과 함께면 본문에서도 확인한다 expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']); + // 본문을 찾지 않았으니 미리 보기에도 강조하지 않는다 — '공' 은 m-floor 의 제목과 본문에 있다 (2026-11-06 실사용 시험) + const found = dataOf>( + await list({ q: '공' }).expect(200), + ).items; + expect(found.map((item) => item.id)).toEqual(['m-contract', 'm-floor']); + expect(found[1].searchHighlight).toEqual({ + subject: [[3, 4]], + preview: { text: '', ranges: [] }, + }); + }); + + it('미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 제목에서도 맞으면 인용 앞의 글, 인용에만 있는 말은 > 를 뗀 인용 조각 (2026-11-06 사용자 관점 점검)', async () => { + const [byTitle] = dataOf>( + await list({ q: '해오름' }).expect(200), + ).items; + expect(byTitle.id).toBe('m-reply'); + expect(byTitle.searchHighlight).toEqual({ + subject: [[4, 7]], + preview: { + text: '네, 받았습니다. 금요일까지 확인하겠습니다.…', + ranges: [], + }, + }); + + const [byQuote] = dataOf>( + await list({ q: 'R9A51' }).expect(200), + ).items; + expect(byQuote.id).toBe('m-reply'); + expect(byQuote.searchHighlight?.preview).toEqual({ + text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ranges: [[16, 21]], + }); + }); + + it('메일함 범위의 후보는 찾기 GIN 하나로 — 주소 · 묶음 · 메일함을 색인 조건으로 맞추고, 메일함만으로 줄을 모아 묶음을 줄마다 풀지 않는다 (실제 PostgreSQL — 2026-11-06 실사용 시험)', async () => { + // 메일함만 담은 GIN 은 없다 — 있으면 계획기가 그것만으로 메일함의 줄(회사 전체 posting)을 읽고 묶음 배열을 줄마다 풀 수 있다 + const gins: { indexname: string }[] = await dataSource.query( + `SELECT indexname FROM pg_indexes WHERE tablename = 'mail_search_messages' AND indexdef ILIKE '%USING gin%' ORDER BY indexname`, + ); + expect(gins.map((row) => row.indexname)).toEqual([ + 'IDX_mail_search_messages_refs', + 'IDX_mail_search_messages_user_grams_mailboxes', + ]); + + interface PlanNode { + 'Index Name'?: string; + 'Index Cond'?: string; + Filter?: string; + 'Recheck Cond'?: string; + Plans?: PlanNode[]; + } + const nodesOf = (node: PlanNode): PlanNode[] => [ + node, + ...(node.Plans ?? []).flatMap(nodesOf), + ]; + const scope: SearchScope = { + mailboxId: 'inbox', + excludedMailboxIds: [], + excludedKeywords: ['$ara-sending'], + }; + for (const text of ['계약', '계약서 갱신']) { + const query = candidatesSql( + ME, + scope, + planSearch({ text, hasAttachment: false }), + null, + SEARCH_CHECK_ROWS_LIMIT + 1, + ); + // 서비스와 같은 계획기 설정(SET LOCAL — 트랜잭션 안)으로 + const rows: { 'QUERY PLAN': unknown }[] = await dataSource.transaction( + async (manager) => { + for (const setting of PLANNER_SETTINGS.bitmap) { + await manager.query(setting); + } + return manager.query( + `EXPLAIN (FORMAT JSON) ${query.sql}`, + query.params, + ); + }, + ); + const raw = rows[0]['QUERY PLAN']; + const [explained] = ( + typeof raw === 'string' ? JSON.parse(raw) : raw + ) as { Plan: PlanNode }[]; + const nodes = nodesOf(explained.Plan); + const scans = nodes.filter((node) => node['Index Name']); + expect(scans.map((node) => node['Index Name'])).toEqual([ + 'IDX_mail_search_messages_user_grams_mailboxes', + ]); + expect(scans[0]['Index Cond']).toContain('mailbox_ids'); + expect(scans[0]['Index Cond']).toContain('grams'); + // 묶음은 줄마다 보지 않는다 — 걸러 내기(Filter)에 묶음이 없다 + expect(nodes.map((node) => node.Filter ?? '').join(' ')).not.toContain( + 'grams', + ); + } }); it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => { @@ -8500,6 +8612,19 @@ describe('응답 계약 (e2e)', () => { expect(detail.htmlBody).not.toContain('로고'); }); + it('한 글자 낱말뿐이면 본문에는 강조하지 않는다 — 제목은 그대로 (2026-11-06 실사용 시험)', async () => { + // '든' 은 제목(그림이 든 메일)에, '안' 은 본문(안녕하세요)에 있다 + const detail = dataOf( + await read({ q: '든 안' }).expect(200), + ); + expect(detail.searchHighlight).toEqual({ + subject: [[4, 5]], + attachments: {}, + textBody: [], + }); + expect(detail.htmlBody).not.toContain(''); + }); + it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => { const detail = dataOf( await read({ images: 'show', subject: '그림', q: '안녕' }).expect(