feat(backend): 메일 검색을 자체 색인으로 바꾸고 조건 검색 · 강조를 더한다

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 의 글
  조각에 <mark> · 제목 · 첨부 이름 · 글 본문 위치
- 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의
  색인을 지우고 밤에 다시 채운다

리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션,
다른 직원 줄 격리 e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:18:41 +09:00
parent d57e6bfe6c
commit f9fbf8a20f
53 changed files with 6073 additions and 269 deletions
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤).
* - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 —
* 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서
* 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정).
* 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다.
* `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로)
* - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다
* 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다).
* (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts)
*/
export class SearchIndexUserGrams1793882273224 implements MigrationInterface {
name = 'SearchIndexUserGrams1793882273224';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
);
}
}
@@ -58,10 +58,11 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
preview: '계약서를 보내 드립니다',
bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
foldFrom: '이도윤 <doyun.lee@arasystem.test>',
foldTo: 'ara.kim@arasystem.test\npartner <partner@example.com>',
// 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2)
foldTo: 'ara.kim@arasystem.test partner <partner@example.com>',
foldSubject: 're: [공지] 11월 계약서',
foldAttachment: '계약서.pdf',
foldBody: '안녕하세요.\n계약서를 보내 드립니다.',
foldBody: '안녕하세요. 계약서를 보내 드립니다.',
threadKey: 'e1',
engineThreadId: 't1',
});
@@ -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<void> {
await this.resetOutdatedFormats();
}
/** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */
private async resetOutdatedFormats(): Promise<void> {
const suffix = this.sync.indexedAddressSuffix;
if (suffix === null || this.formatsReset) return;
try {
const reset = await this.repository.resetOutdatedFormats(
suffix,
INDEX_FORMAT,
new Date(this.timing.now()),
);
this.formatsReset = true;
if (reset > 0) {
// 배포마다 한 번뿐인 상태 변화 — 수만 (주소 없음)
this.logger.log(
`검색 색인의 판이 바뀌어 ${reset}명의 색인을 밤에 다시 채웁니다 (판 ${INDEX_FORMAT}).`,
);
}
} catch (error) {
this.logger.warn(
`[SYS_001] 판이 바뀐 검색 색인을 되돌리지 못했습니다(그 직원의 검색은 엔진 방식 — 1분 뒤 다시): ${error instanceof Error ? error.name : typeof error}`,
);
}
}
/** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */
@Cron('0 * * * * *', {
name: 'mail-search-build',
@@ -79,13 +121,10 @@ export class MailSearchBuildService {
})
async tick(): Promise<BuildOutcome | null> {
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,
@@ -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<number> {
const [rows] = await this.dataSource.query<[unknown[], number]>(
`UPDATE "${SEARCH_STATES_TABLE}"
SET "status" = 'building', "email_state" = NULL, "build_anchor" = NULL, "build_done" = 0, "build_total" = NULL,
"build_started_at" = NULL, "ready_at" = NULL, "failure_count" = 0, "index_format" = $2, "updated_at" = $3
WHERE "index_format" <> $2 AND right("user_email", length($1)) = $1
RETURNING "user_email"`,
[addressSuffix, format, now],
);
return rows.length;
}
/** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */
syncCandidates(): Promise<MailSearchStateEntity[]> {
return this.dataSource.manager
@@ -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<string>() };
}
if (change.clearIndex) {
await manager.query(
`DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
[userEmail],
);
}
const deletes = [...new Set(change.deletes ?? [])];
if (deletes.length > 0) {
await manager.query(
@@ -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);
});
});
});
@@ -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<MailSearchStateEntity, 'status' | 'buildDone' | 'buildTotal'>,
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<boolean> {
try {
const state = await this.repository.findState(normalize(user));
return (
state !== null &&
state.status === 'ready' &&
state.indexFormat === INDEX_FORMAT &&
state.pausedReason === null
);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인 상태를 읽지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user)}, reason=${errorKind(error)}`,
);
return false;
}
}
/** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */
async status(user: string): Promise<SearchIndexStatusView> {
return toStatusView(
@@ -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 {
@@ -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<Record<QueryKind, (params: unknown[]) => unknown[]>>,
) {
const recorded: Recorded[] = [];
let transactions = 0;
const answer = (
sql: string,
params: unknown[] | undefined,
context: {
settings: string[];
transaction: number | null;
isolation: string | null;
},
) => {
const kind = kindOf(sql);
recorded.push({ kind, sql, params: params ?? [], ...context });
const respond = answers[kind];
if (!respond) throw new Error(`준비하지 않은 쿼리: ${kind}`);
return Promise.resolve(respond(params ?? []));
};
const dataSource = {
transaction: jest.fn(
(
isolation: string,
run: (manager: {
query: (sql: string, params?: unknown[]) => Promise<unknown>;
}) => Promise<unknown>,
) => {
transactions += 1;
const context = {
settings: [] as string[],
transaction: transactions,
isolation,
};
return run({
query: (sql: string, params?: unknown[]) => {
if (sql.startsWith('SET LOCAL')) {
context.settings.push(sql);
return Promise.resolve([]);
}
return answer(sql, params, context);
},
});
},
),
query: jest.fn((sql: string, params?: unknown[]) =>
answer(sql, params, { settings: [], transaction: null, isolation: null }),
),
};
return {
recorded,
service: new MailSearchQueryService(dataSource as unknown as DataSource),
};
}
/** 확인 — 준 id 가운데 `matches` 를 만족하는 것만 맞았다고 답한다 */
const answerChecks =
(matches: (id: string) => boolean) =>
(params: unknown[]): unknown[] =>
(params[1] as string[]).filter(matches).map((email_id) => ({ email_id }));
const numberOf = (id: string) => Number(id.slice(1));
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
hasAttachment: false,
...overrides,
});
async function search(
service: MailSearchQueryService,
given: SearchCriteria,
page: { limit: number; cursor?: string } = { limit: 20 },
user = USER,
) {
return service.search(user, SCOPE, given, planSearch(given), page);
}
describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', () => {
const bitmapHint = [
'SET LOCAL enable_seqscan = off',
'SET LOCAL enable_indexscan = off',
];
const keysetHint = [
'SET LOCAL enable_bitmapscan = off',
'SET LOCAL enable_seqscan = off',
];
describe('묶음이 있는 찾기 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인', () => {
it(`후보는 GIN 의 bitmap 으로만 · 받은 시각 최신부터 확인 상한 + 1(${SEARCH_CHECK_ROWS_LIMIT + 1})줄까지 — 두 글자 낱말뿐이면 후보가 곧 결과(확인하지 않는다)`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(25),
});
const page = await search(service, criteria({ text: '계약' }));
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
expect(recorded[0].settings).toEqual(bitmapHint);
expect(recorded[0].params.at(-1)).toBe(SEARCH_CHECK_ROWS_LIMIT + 1);
expect(page.hits.map((hit) => hit.id)).toEqual(
rows(20).map((row) => row.email_id),
);
expect(page.hasNext).toBe(true);
expect(page.total).toBe(25);
// 다음 쪽 커서 — 이 쪽의 마지막 줄 다음부터, 같은 검색에서만
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: rows(20)[19].received_at,
emailId: 'm20',
});
});
it(`두 글자 낱말뿐인 찾기의 total — ${SEARCH_TOTAL_LIMIT}을 넘거나 후보가 상한 밖까지 있으면 null`, async () => {
for (const count of [
SEARCH_TOTAL_LIMIT + 1,
SEARCH_CHECK_ROWS_LIMIT + 1,
]) {
const { service } = fakeDataSource({ candidates: () => rows(count) });
const page = await search(service, criteria({ text: '계약 확인' }));
expect(page.total).toBeNull();
expect(page.hits).toHaveLength(20);
expect(page.hasNext).toBe(true);
}
});
it(`후보가 ${SEARCH_CANDIDATE_COUNT_LIMIT}개 이하면 한 번에 모두 확인한다 — 첫 쪽의 total 은 맞은 수`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(40),
check: answerChecks((id) => numberOf(id) % 4 !== 0),
});
const page = await search(service, criteria({ text: '계약서' }));
expect(recorded.map((query) => query.kind)).toEqual([
'candidates',
'check',
]);
expect(idsOf(recorded[1])).toHaveLength(40);
// 확인은 기본 키로 그 줄만 — 계획기 설정 없이
expect(recorded[1].settings).toEqual([]);
expect(page.hits).toHaveLength(20);
expect(page.hits.map((hit) => hit.id)).not.toContain('m4');
expect(page.hasNext).toBe(true);
expect(page.total).toBe(30);
});
it(`후보가 많으면 받은 차례로 ${SEARCH_FIRST_CHECK_ROWS}줄부터 ${SEARCH_CHECK_ROWS_GROWTH}배씩 확인하다 쪽이 차면 멈춘다 — total 은 모른다(null)`, async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(2_500),
check: answerChecks((id) => numberOf(id) % 100 === 0),
});
const page = await search(service, criteria({ text: '"계약 갱신"' }));
const checks = recorded.filter((query) => query.kind === 'check');
expect(checks.map((query) => idsOf(query).length)).toEqual([
SEARCH_FIRST_CHECK_ROWS,
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH,
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH ** 2,
]);
// 확인은 받은 차례 그대로 이어진다
expect(idsOf(checks[1])[0]).toBe('m101');
expect(page.hits).toHaveLength(20);
expect(page.hits[0].id).toBe('m100');
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
});
it('후보가 많아도 상한 안이면(상한 밖 후보 없음) 다 확인하고 끝 — 쪽을 못 채워도 hasNext 없음, 맞은 수를 안다', async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(2_500),
check: answerChecks((id) => ['m7', 'm2400'].includes(id)),
});
const page = await search(service, criteria({ text: '"계약 갱신"' }));
const checked = recorded
.filter((query) => query.kind === 'check')
.reduce((sum, query) => sum + idsOf(query).length, 0);
expect(checked).toBe(2_500);
expect(page).toMatchObject({
hasNext: false,
nextCursor: null,
total: 2,
});
expect(page.hits.map((hit) => hit.id)).toEqual(['m7', 'm2400']);
});
it('상한 밖에도 후보가 있고 상한까지 확인해도 쪽을 못 채우면 찾은 것까지와 이어 찾기 커서 — 쪽이 비어 있어도 hasNext', async () => {
const lastChecked = rows(1, SEARCH_CHECK_ROWS_LIMIT - 1)[0];
const { service, recorded } = fakeDataSource({
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
check: answerChecks((id) => id === 'm42'),
});
const page = await search(service, criteria({ text: '확인계약' }));
const checked = recorded
.filter((query) => query.kind === 'check')
.flatMap(idsOf);
expect(checked).toHaveLength(SEARCH_CHECK_ROWS_LIMIT);
expect(checked).not.toContain(`m${SEARCH_CHECK_ROWS_LIMIT + 1}`);
expect(page.hits.map((hit) => hit.id)).toEqual(['m42']);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const key = searchScopeKey(undefined, criteria({ text: '확인계약' }));
// 이어 찾을 자리는 마지막으로 **확인한** 후보 — 맞은 줄이 아니다
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: lastChecked.received_at,
emailId: lastChecked.email_id,
});
const empty = fakeDataSource({
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
check: () => [],
});
const emptyPage = await search(
empty.service,
criteria({ text: '확인계약' }),
);
expect(emptyPage.hits).toEqual([]);
expect(emptyPage.hasNext).toBe(true);
expect(emptyPage.nextCursor).not.toBeNull();
});
it('후보가 없으면 확인하지 않는다 — 빈 쪽 · total 0', async () => {
const { service, recorded } = fakeDataSource({ candidates: () => [] });
await expect(
search(service, criteria({ text: '뷁뷁뷁' })),
).resolves.toEqual({
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
});
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
});
it('직원 주소는 소문자로 맞춰 묻는다 (표의 주소는 늘 소문자)', async () => {
const { service, recorded } = fakeDataSource({
candidates: () => rows(1),
});
await search(
service,
criteria({ text: '계약' }),
{ limit: 20 },
' Ara.Kim@ArASystem.test ',
);
expect(recorded[0].params[0]).toBe(USER);
});
});
it('묶음이 없으면(한 글자 낱말뿐) 받은 차례로 확인 상한까지 — 쪽이 차면 total 은 창 안에서 센다', async () => {
const { service, recorded } = fakeDataSource({
keyset: (params) => rows(Number(params.at(-1))),
keysetCount: () => [{ count: 120 }],
frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
});
const page = await search(service, criteria({ text: '묵' }));
expect(recorded.map((query) => query.kind)).toEqual([
'keyset',
'keysetCount',
'frontier',
]);
expect(recorded[0].settings).toEqual(keysetHint);
expect(recorded[0].sql).not.toContain('"grams"');
expect(recorded[0].sql).not.toContain('strpos("fold_body"');
expect(recorded[0].params.slice(-3)).toEqual([
SEARCH_CHECK_ROWS_LIMIT,
'묵',
21,
]);
expect(page.hits).toHaveLength(20);
expect(page.hasNext).toBe(true);
expect(page.total).toBe(120);
});
it('한 글자 낱말 — 창을 다 훑지 못하고 쪽을 못 채우면 이어 찾기, 다 훑었으면 끝', async () => {
const lastScanned = rows(1, 2_999)[0];
const unfinished = fakeDataSource({
keyset: () => rows(3),
frontier: () => [{ ...lastScanned, scanned: SEARCH_CHECK_ROWS_LIMIT }],
});
const page = await search(unfinished.service, criteria({ text: '뷁' }));
expect(page.hits).toHaveLength(3);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const key = searchScopeKey(undefined, criteria({ text: '뷁' }));
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
receivedAt: lastScanned.received_at,
emailId: lastScanned.email_id,
});
const done = fakeDataSource({
keyset: () => rows(7),
frontier: () => [{ ...rows(1, 1_499)[0], scanned: 1_500 }],
});
expect(await search(done.service, criteria({ text: '뷁' }))).toMatchObject({
hasNext: false,
nextCursor: null,
total: 7,
});
const over = fakeDataSource({
keyset: () => rows(21),
keysetCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
});
expect(
(await search(over.service, criteria({ text: '팀' }))).total,
).toBeNull();
});
it('낱말 확인이 없으면(기간 · 첨부만) 받은 차례로 바로 — total 은 상한 + 1 까지 세어 넘으면 null', async () => {
const { service, recorded } = fakeDataSource({
plainPage: (params) => rows(Number(params.at(-1))),
plainCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
});
const page = await search(service, criteria({ hasAttachment: true }), {
limit: 10,
});
expect(recorded.map((query) => query.kind)).toEqual([
'plainPage',
'plainCount',
]);
expect(recorded[0].params.at(-1)).toBe(11);
expect(recorded[1].params.at(-1)).toBe(SEARCH_TOTAL_LIMIT + 1);
expect(
recorded.every((query) => query.settings.join() === keysetHint.join()),
).toBe(true);
expect(page.hits).toHaveLength(10);
expect(page.hasNext).toBe(true);
expect(page.total).toBeNull();
const few = fakeDataSource({
plainPage: () => rows(3),
plainCount: () => [{ count: 3 }],
});
expect(
await search(few.service, criteria({ hasAttachment: true })),
).toMatchObject({ hasNext: false, nextCursor: null, total: 3 });
});
it('다음 쪽은 커서의 자리부터 · total 은 늘 null — 다른 검색의 커서는 묻기 전에 VAL_001', async () => {
const given = criteria({ text: '계약' });
const position = {
receivedAt: new Date('2026-11-01T00:00:00Z'),
emailId: 'm40',
};
const cursor = encodeIndexCursor(
position,
searchScopeKey(undefined, given),
);
const { service, recorded } = fakeDataSource({
candidates: () => rows(3, 40),
});
const page = await search(service, given, { limit: 20, cursor });
expect(recorded[0].params).toEqual(
expect.arrayContaining([position.receivedAt, 'm40']),
);
expect(page.total).toBeNull();
expect(page.hasNext).toBe(false);
const other = fakeDataSource({});
await expect(
search(other.service, criteria({ text: '견적' }), { limit: 20, cursor }),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(other.recorded).toEqual([]);
});
it('아무것도 맞을 수 없는 계획이면 묻지 않는다 — 빈 쪽 · total 0', async () => {
const { service, recorded } = fakeDataSource({});
await expect(search(service, criteria({ text: '…' }))).resolves.toEqual({
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
});
await expect(
search(service, criteria({ body: '묵' })),
).resolves.toMatchObject({ hits: [], total: 0 });
expect(recorded).toEqual([]);
});
it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에, id 가 없으면 묻지 않는다', async () => {
const { service, recorded } = fakeDataSource({
highlight: () => [
{ email_id: 'm1', subject: '계약', body_text: '본문', preview: '미리' },
],
});
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]);
});
});
@@ -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 = <T>(query: SqlQuery) => Promise<T>;
const PLANNER_SETTINGS: Readonly<Record<PlannerHint, readonly string[]>> = {
bitmap: [
'SET LOCAL enable_seqscan = off',
'SET LOCAL enable_indexscan = off',
],
keyset: [
'SET LOCAL enable_bitmapscan = off',
'SET LOCAL enable_seqscan = off',
],
};
function toHit(row: HitRow): IndexHit {
return {
id: row.email_id,
receivedAt:
row.received_at instanceof Date
? row.received_at
: new Date(row.received_at),
};
}
function positionOf(hit: IndexHit): SearchPosition {
return { receivedAt: hit.receivedAt, emailId: hit.id };
}
/** 첫 쪽의 수 — SEARCH_TOTAL_LIMIT 를 넘으면 null */
function capTotal(count: number): number | null {
return count > SEARCH_TOTAL_LIMIT ? null : count;
}
/** 맞은 줄(받은 시각 최신부터)에서 한 쪽 — 넘치면 다음 쪽이 있고, 이어 찾을 자리는 이 쪽의 마지막 줄 */
function pageOf(
hits: readonly IndexHit[],
limit: number,
total: number | null,
): Found {
const page = hits.slice(0, limit);
const hasNext = hits.length > limit;
return {
hits: page,
hasNext,
next: hasNext ? positionOf(page[page.length - 1]) : null,
total,
};
}
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
function normalize(user: string): string {
return user.trim().toLowerCase();
}
/**
* 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다.
*
* - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로
* - 묶음이 있으면: 후보를 주소 + 묶음(GIN)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
* - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다
* - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다.
* SEARCH_CHECK_ROWS_LIMIT 줄을 확인하고도 쪽을 채우지 못하면 찾은 것까지와 마지막으로 확인한 줄의 커서를 준다(hasNext — 쪽이 비어 있을 수도 있다)
* - 묶음이 없으면(한 글자 낱말뿐 — 본문은 훑지 않아 확인이 값싸다): 받은 차례로 SEARCH_CHECK_ROWS_LIMIT 줄까지 확인한다(이어 찾기는 위와 같다)
* - total 은 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 — 넘거나 이 요청 안에서 다 세지 못하면 null
*/
@Injectable()
export class MailSearchQueryService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async search(
user: string,
scope: SearchScope,
criteria: SearchCriteria,
plan: SearchPlan,
page: { readonly limit: number; readonly cursor?: string },
): Promise<IndexSearchPage> {
const key = normalize(user);
const scopeKey = searchScopeKey(scope.mailboxId ?? undefined, criteria);
// 커서는 찾기 전에 본다 — 다른 검색의 커서면 VAL_001
const after = page.cursor ? decodeIndexCursor(page.cursor, scopeKey) : null;
if (plan.matchesNothing) {
return { hits: [], hasNext: false, nextCursor: null, total: 0 };
}
const firstPage = after === null;
const found =
plan.checks.length === 0
? await this.plain(key, scope, plan, after, page.limit, firstPage)
: plan.grams.length === 0
? await this.keyset(key, scope, plan, after, page.limit, firstPage)
: await this.byGrams(key, scope, plan, after, page.limit, firstPage);
return {
hits: found.hits,
hasNext: found.hasNext,
nextCursor:
found.hasNext && found.next
? encodeIndexCursor(found.next, scopeKey)
: null,
total: firstPage ? found.total : null,
};
}
/** 강조에 쓰는 글 — 찾은 쪽의 줄만 (없는 줄은 빠진다) */
async highlightTexts(
user: string,
ids: readonly string[],
): Promise<Map<string, IndexHighlightText>> {
if (ids.length === 0) return new Map();
const query = highlightTextsSql(normalize(user), ids);
const rows = await this.dataSource.query<
{
email_id: string;
subject: string;
body_text: string;
preview: string;
}[]
>(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<Found> {
return this.snapshot('keyset', async (ask) => {
const rows = await ask<HitRow[]>(
plainPageSql(user, scope, plan, after, limit + 1),
);
let total: number | null = null;
if (firstPage) {
const counted = await ask<{ count: number }[]>(
plainCountSql(user, scope, plan, SEARCH_TOTAL_LIMIT + 1),
);
total = capTotal(Number(counted[0]?.count ?? 0));
}
return pageOf(rows.map(toHit), limit, total);
});
}
/** 묶음이 있다 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인한다 */
private async byGrams(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
firstPage: boolean,
): Promise<Found> {
const candidates = (
await this.snapshot('bitmap', (ask) =>
ask<HitRow[]>(
candidatesSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT + 1),
),
)
).map(toHit);
// 확인 상한 밖에도 후보가 있다 — 이 요청은 상한까지만 본다
const more = candidates.length > SEARCH_CHECK_ROWS_LIMIT;
const window = more
? candidates.slice(0, SEARCH_CHECK_ROWS_LIMIT)
: candidates;
// 두 글자 낱말뿐 — 후보가 곧 결과다
if (plan.exactByGrams) {
return pageOf(candidates, limit, more ? null : capTotal(window.length));
}
// 첫 쪽이고 후보가 적다 — 모두 확인해 total 을 안다 (다음 쪽은 total 이 없으니 쪽이 찰 때까지만 확인한다)
if (firstPage && !more && window.length <= SEARCH_CANDIDATE_COUNT_LIMIT) {
const matched = await this.check(user, plan, window);
const hits = window.filter((hit) => matched.has(hit.id));
return pageOf(hits, limit, capTotal(hits.length));
}
// 후보가 많다 — 받은 차례로 조금씩 확인하다 쪽이 차면 멈춘다
const hits: IndexHit[] = [];
let checked = 0;
let size = Math.max(SEARCH_FIRST_CHECK_ROWS, (limit + 1) * 2);
while (hits.length <= limit && checked < window.length) {
const slice = window.slice(checked, checked + size);
const matched = await this.check(user, plan, slice);
for (const hit of slice) if (matched.has(hit.id)) hits.push(hit);
checked += slice.length;
size *= SEARCH_CHECK_ROWS_GROWTH;
}
if (hits.length > limit) return pageOf(hits, limit, null);
// 후보를 다 확인했다 — 끝이고 맞은 수를 안다
if (!more) {
return { hits, hasNext: false, next: null, total: capTotal(hits.length) };
}
// 확인 상한에 닿았다 — 마지막으로 확인한 후보 다음부터 이어 찾는다
return {
hits,
hasNext: true,
next: positionOf(window[window.length - 1]),
total: null,
};
}
/** 후보 가운데 낱말이 모두 맞은 줄의 id */
private async check(
user: string,
plan: SearchPlan,
candidates: readonly IndexHit[],
): Promise<Set<string>> {
if (candidates.length === 0) return new Set();
const query = checkHitsSql(
user,
plan,
candidates.map((hit) => hit.id),
);
const rows = await this.dataSource.query<{ email_id: string }[]>(
query.sql,
query.params,
);
return new Set(rows.map((row) => row.email_id));
}
/**
* 묶음이 없다(한 글자 낱말뿐) — 받은 차례로 확인 상한까지 (확인하는 칸이 작아 값싸다).
* 맞은 줄 · 창의 끝 · 첫 쪽의 수는 **같은 스냅숏**에서 읽는다 — 그사이 온 메일로 창이 밀리면 이어 찾을 자리가 어긋나 같은 메일이 다음 쪽에 또 나왔다
*/
private keyset(
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
limit: number,
firstPage: boolean,
): Promise<Found> {
return this.snapshot('keyset', async (ask) => {
const rows = await ask<HitRow[]>(
keysetHitsSql(
user,
scope,
plan,
after,
SEARCH_CHECK_ROWS_LIMIT,
limit + 1,
),
);
if (rows.length > limit) {
return pageOf(
rows.map(toHit),
limit,
firstPage ? await this.keysetTotal(ask, user, scope, plan) : null,
);
}
// 쪽을 채우지 못했다 — 확인한 창을 다 훑었나(그러면 끝), 아니면 그 창의 끝부터 이어 찾는다
const hits = rows.map(toHit);
const frontier = await this.frontier(ask, user, scope, plan, after);
if (frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT) {
return {
hits,
hasNext: false,
next: null,
total: capTotal(hits.length),
};
}
return { hits, hasNext: true, next: frontier.last, total: null };
});
}
/** 받은 차례로 훑는 창의 끝 — 그 창의 마지막 줄 · 줄 수 (창이 비었으면 null) */
private async frontier(
ask: Ask,
user: string,
scope: SearchScope,
plan: SearchPlan,
after: SearchPosition | null,
): Promise<{ last: SearchPosition; scanned: number } | null> {
const rows = await ask<(HitRow & { scanned: number })[]>(
keysetFrontierSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT),
);
const row = rows[0];
if (!row) return null;
return {
last: positionOf(toHit(row)),
scanned: Number(row.scanned),
};
}
/** 받은 차례 길의 첫 쪽 total — 창 안에서 SEARCH_TOTAL_LIMIT + 1 까지 센다. 넘거나 창을 다 훑지 못했으면 null */
private async keysetTotal(
ask: Ask,
user: string,
scope: SearchScope,
plan: SearchPlan,
): Promise<number | null> {
const counted = await ask<{ count: number }[]>(
keysetCountSql(
user,
scope,
plan,
SEARCH_CHECK_ROWS_LIMIT,
SEARCH_TOTAL_LIMIT + 1,
),
);
const count = Number(counted[0]?.count ?? 0);
if (count > SEARCH_TOTAL_LIMIT) return null;
const frontier = await this.frontier(ask, user, scope, plan, null);
return frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT
? count
: null;
}
/**
* 한 스냅숏(REPEATABLE READ — 읽기만 한다)에서 계획기 설정을 건 뒤 쿼리들을 묻는다 (SET LOCAL — 트랜잭션이 끝나면 돌아온다).
* 스냅숏은 설정 다음의 첫 쿼리에서 잡힌다
*/
private snapshot<T>(
hint: PlannerHint,
work: (ask: Ask) => Promise<T>,
): Promise<T> {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
for (const setting of PLANNER_SETTINGS[hint]) {
await manager.query(setting);
}
return work(<R>(query: SqlQuery) =>
manager.query<R>(query.sql, query.params),
);
});
}
}
@@ -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;
@@ -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<string, MailSearchStateEntity>();
readonly rows = new Map<string, IndexRowValues>();
readonly writes: SearchIndexWrite[] = [];
/** 판 되돌리기 — 부른 수 · 앞으로 실패할 수(DB 장애 흉내) */
resetCalls = 0;
failResets = 0;
/** 실제 SQL(mail-search-index.repository.ts)과 같은 판정 — 판이 다르고 주소가 그 도메인으로 끝나는 줄만 */
resetOutdatedFormats(suffix: string, format: number, now: Date) {
this.resetCalls += 1;
if (this.failResets > 0) {
this.failResets -= 1;
return Promise.reject(new Error('db down'));
}
let count = 0;
for (const state of this.states.values()) {
if (state.indexFormat === format || !state.userEmail.endsWith(suffix)) {
continue;
}
Object.assign(state, {
status: 'building',
emailState: null,
buildAnchor: null,
buildDone: 0,
buildTotal: null,
buildStartedAt: null,
readyAt: null,
failureCount: 0,
indexFormat: format,
updatedAt: now,
});
count += 1;
}
return Promise.resolve(count);
}
addState(
overrides: Partial<MailSearchStateEntity> = {},
@@ -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<string>() });
}
this.writes.push(change);
if (change.clearIndex) this.rows.clear();
for (const id of change.deletes ?? []) this.rows.delete(id);
for (const row of change.upserts ?? []) this.rows.set(row.emailId, row);
const updatedIds = new Set<string>();
@@ -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();
@@ -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 (
@@ -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;
@@ -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 {}
@@ -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<SearchCriteria, 'text'>;
/**
* 찾는 범위 (엔진이 알려 준다 — 메일 포트의 searchScope): 메일함 하나, 또는 빼는 메일함(휴지통 · 스팸메일함)에만 있지 않은 메일.
* 목록에서 빼는 키워드(발송 중인 내부 초안)가 있는 메일은 찾지 않는다
*/
export interface SearchScope {
readonly mailboxId: string | null;
readonly excludedMailboxIds: readonly string[];
readonly excludedKeywords: readonly string[];
}
/** 맞은 곳 — [시작, 끝) UTF-16 */
export type SearchRange = readonly [start: number, end: number];
/** 목록 항목의 강조 (색인으로 찾았을 때만 — 엔진 방식이면 null) */
export interface MessageListHighlight {
readonly subject: SearchRange[];
/** 본문에서 맞았으면 맞은 말 둘레, 아니면 엔진의 미리 보기 */
readonly preview: { readonly text: string; readonly ranges: SearchRange[] };
}
/** 읽기 화면의 강조 — 첨부는 맞은 것만(첨부 id → 맞은 곳) */
export interface MessageDetailHighlight {
readonly subject: SearchRange[];
readonly attachments: Record<string, SearchRange[]>;
readonly textBody: SearchRange[];
}
@@ -0,0 +1,137 @@
import { BusinessException } from '@/common/errors/business.exception';
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
import type { SearchCriteria } from './mail-search.types';
import {
cursorMethod,
decodeIndexCursor,
encodeIndexCursor,
searchScopeKey,
} from './search-cursor';
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
hasAttachment: false,
...overrides,
});
function codeOf(run: () => unknown): string | null {
try {
run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('색인 검색의 커서 (search-cursor — 14단계 설계 C1)', () => {
const position = {
receivedAt: new Date('2026-11-02T01:23:45.678Z'),
emailId: 'm9x',
};
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
it('자리(받은 시각 ms · id)를 그대로 되돌린다 — URL 에 그대로 넣을 수 있는 글자만', () => {
const cursor = encodeIndexCursor(position, key);
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeIndexCursor(cursor, key)).toEqual(position);
});
it('방식을 커서로 안다 — 색인 커서는 index, 엔진 커서(1판) · 알 수 없는 값은 engine(엔진 쪽이 검사한다)', () => {
expect(cursorMethod(encodeIndexCursor(position, key))).toBe('index');
expect(
cursorMethod(
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
),
).toBe('engine');
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600), 'bnVsbA']) {
expect(cursorMethod(cursor)).toBe('engine');
}
});
it('범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 하나라도 다르면 다른 검색의 커서라 VAL_001', () => {
const base = criteria({
text: '계약',
from: '이도윤',
after: '2026-10-31T15:00:00.000Z',
});
const cursor = encodeIndexCursor(position, searchScopeKey('a', base));
expect(decodeIndexCursor(cursor, searchScopeKey('a', base))).toEqual(
position,
);
for (const [mailboxId, other] of [
[undefined, base],
['b', base],
['a', { ...base, text: '견적' }],
['a', { ...base, from: undefined }],
['a', { ...base, to: '이도윤' }],
['a', { ...base, subject: '계약' }],
['a', { ...base, body: '계약' }],
['a', { ...base, after: '2026-10-30T15:00:00.000Z' }],
['a', { ...base, before: '2026-11-30T15:00:00.000Z' }],
['a', { ...base, hasAttachment: true }],
] as const) {
expect(
codeOf(() =>
decodeIndexCursor(cursor, searchScopeKey(mailboxId, other)),
),
).toBe('VAL_001');
}
});
it('칸이 달라도 이은 글이 같아지지 않는다 — 같은 글을 다른 칸에 둔 검색은 다른 범위', () => {
expect(searchScopeKey(undefined, criteria({ from: '계약' }))).not.toBe(
searchScopeKey(undefined, criteria({ to: '계약' })),
);
expect(searchScopeKey('a', criteria({}))).not.toBe(
searchScopeKey(undefined, criteria({})),
);
});
it('모양이 틀린 커서는 VAL_001 — 엔진 커서 · 틀린 판 · 이상한 id · 음수 시각 · 너무 긴 값', () => {
const forge = (payload: object) =>
Buffer.from(JSON.stringify(payload)).toString('base64url');
for (const cursor of [
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
forge({ v: 3, m: 'i', t: 1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'e', t: 1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: -1, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: 1.5, id: 'm1', s: 'x' }),
forge({ v: 2, m: 'i', t: 1, id: '../x', s: 'x' }),
forge({ v: 2, m: 'i', t: 1, id: 'm1' }),
'not-json',
'a'.repeat(600),
]) {
expect(codeOf(() => decodeIndexCursor(cursor, key))).toBe('VAL_001');
}
});
it('Date 로 읽을 수 없는 시각(안전한 정수지만 ±8.64e15 ms 밖)의 커서는 VAL_001 — 범위 지문이 맞아도 500 이 아니다 (리뷰 L1)', () => {
// 이 검색의 진짜 커서에서 시각만 바꾼다 — 범위 지문(s)은 맞는다
const real = JSON.parse(
Buffer.from(encodeIndexCursor(position, key), 'base64url').toString(
'utf8',
),
) as Record<string, unknown>;
const withTime = (t: number) =>
Buffer.from(JSON.stringify({ ...real, t })).toString('base64url');
expect(Number.isSafeInteger(9e15)).toBe(true);
expect(codeOf(() => decodeIndexCursor(withTime(9e15), key))).toBe(
'VAL_001',
);
// 한도 끝(8.64e15)까지는 날짜다 — 그대로 읽는다
expect(decodeIndexCursor(withTime(8.64e15), key)).toEqual({
receivedAt: new Date(8.64e15),
emailId: position.emailId,
});
});
it('범위 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸을 흉내 내도 다른 범위 (리뷰 L7)', () => {
const NL = String.fromCharCode(10);
expect(searchScopeKey(undefined, criteria({ from: `a${NL}b` }))).not.toBe(
searchScopeKey(undefined, criteria({ from: 'a', to: 'b' })),
);
expect(searchScopeKey(undefined, criteria({ text: `계약${NL}` }))).not.toBe(
searchScopeKey(undefined, criteria({ text: '계약', from: '' })),
);
});
});
@@ -0,0 +1,122 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
import type { SearchCriteria } from './mail-search.types';
import type { SearchPosition } from './search-sql';
/**
* 색인으로 찾은 검색의 커서 (14단계 설계 C1 — 클라이언트에게는 불투명 문자열).
* `{ v: 2, m: 'i', t: 받은 시각(ms), id, s: 범위 지문 }` — 그 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 이어 찾는다.
* 다 찾지 못하고 확인 상한에 닿은 쪽(이어 찾기)도 같은 꼴이다 — 마지막으로 **훑은** 줄이 자리다.
*
* 방식(m)이 커서에 실려, 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 그 검색은 처음 방식대로 이어 간다 —
* 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로, 이 커서는 색인으로.
* 범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 다른 검색의 커서를 섞어 쓰면 VAL_001. 지문은 짧은 해시라 글을 담지 않는다
*/
const CURSOR_VERSION = 2;
const INDEX_METHOD = 'i';
const MAX_CURSOR_LENGTH = 512;
const SCOPE_HASH_LENGTH = 16;
/** "모든 메일함"(휴지통 · 스팸메일함 제외) 범위 — 메일함 id 형식에 없는 글자 */
const ALL_MAILBOXES_SCOPE = '*';
interface EncodedIndexCursor {
v: number;
m: string;
t: number;
id: string;
s: string;
}
/** 검색 방식 — 커서를 읽어 본다. 색인 커서가 아니면(엔진 방식 · 모양이 틀림) 'engine' — 엔진 쪽이 검사한다 */
export function cursorMethod(cursor: string): 'index' | 'engine' {
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
try {
const parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
) as Partial<EncodedIndexCursor> | null;
return parsed?.v === CURSOR_VERSION && parsed.m === INDEX_METHOD
? 'index'
: 'engine';
} catch {
return 'engine';
}
}
/**
* 범위 지문의 글 — 메일함 · 검색어 · 조건을 정해진 차례로 (빈 칸은 빈 글).
* 배열을 JSON 으로 잇는다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않게 (from 에 줄바꿈을 넣어 to 칸을 흉내 낼 수 없다)
*/
export function searchScopeKey(
mailboxId: string | undefined,
criteria: SearchCriteria,
): string {
return JSON.stringify([
mailboxId ?? ALL_MAILBOXES_SCOPE,
criteria.text ?? '',
criteria.from ?? '',
criteria.to ?? '',
criteria.subject ?? '',
criteria.body ?? '',
criteria.after ?? '',
criteria.before ?? '',
criteria.hasAttachment === true,
]);
}
function scopeHash(scopeKey: string): string {
return createHash('sha256')
.update(scopeKey)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-cursor:${reason}` },
});
}
export function encodeIndexCursor(
position: SearchPosition,
scopeKey: string,
): string {
const payload: EncodedIndexCursor = {
v: CURSOR_VERSION,
m: INDEX_METHOD,
t: position.receivedAt.getTime(),
id: position.emailId,
s: scopeHash(scopeKey),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** 커서를 읽는다 — 모양이 틀리거나 다른 검색의 커서면 VAL_001 */
export function decodeIndexCursor(
cursor: string,
scopeKey: string,
): SearchPosition {
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, m, t, id, s } = (parsed ?? {}) as Partial<EncodedIndexCursor>;
const valid =
v === CURSOR_VERSION &&
m === INDEX_METHOD &&
typeof t === 'number' &&
Number.isSafeInteger(t) &&
t >= 0 &&
// 날짜로 읽을 수 있는 시각만 — Date 의 한도(±8.64e15 ms) 밖이면 Invalid Date 라 쿼리 값으로 쓰면 500 이었다
Number.isFinite(new Date(t).getTime()) &&
typeof id === 'string' &&
MAIL_ID_PATTERN.test(id) &&
typeof s === 'string';
if (!valid) throw invalidCursor('shape');
if (s !== scopeHash(scopeKey)) throw invalidCursor('other-list');
return { receivedAt: new Date(t), emailId: id };
}
@@ -0,0 +1,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);
});
});
});
@@ -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');
}
@@ -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>): 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`]);
});
});
@@ -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<string, FoldField>;
type ConditionName = keyof typeof CONDITION_FIELDS;
const CONDITION_NAMES = Object.keys(CONDITION_FIELDS) as ConditionName[];
/** 낱말 하나의 확인 — 접은 낱말이 그 칸들 가운데 하나에 들어 있어야 한다 */
export interface TermCheck {
readonly term: string;
readonly fields: readonly FoldField[];
}
/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */
export interface HighlightTerms {
readonly subject: readonly string[];
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<string>();
const pending: { term: string; fields: readonly FoldField[] }[] = [];
const add = (raw: string, fields: readonly FoldField[]) => {
const term = foldText(raw);
gramsOf(term, grams);
pending.push({ term, fields });
};
for (const raw of query.raw) add(raw, ALL_FOLD_FIELDS);
for (const condition of conditions) {
for (const raw of condition.raw)
add(raw, [CONDITION_FIELDS[condition.name]]);
}
// 한 글자 낱말뿐 — 본문은 훑지 않는다 (제목 · 사람 · 첨부 이름만)
const bodySkipped =
SINGLE_CHAR_TERMS_SKIP_BODY && pending.length > 0 && grams.size === 0;
const seen = new Set<string>();
const checks: TermCheck[] = [];
for (const check of pending) {
const fields = bodySkipped
? check.fields.filter((field) => field !== 'fold_body')
: check.fields;
if (fields.length === 0) {
matchesNothing = true;
continue;
}
const key = `${check.term}\n${fields.join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
checks.push({ term: check.term, fields });
}
const exactByGrams =
checks.length > 0 &&
checks.every(
(check) =>
check.fields.length === ALL_FOLD_FIELDS.length &&
isGramWord(check.term),
);
const subjectTerms = conditions.find((c) => c.name === 'subject')?.raw ?? [];
const bodyTerms = conditions.find((c) => c.name === 'body')?.raw ?? [];
return {
checks,
grams: [...grams],
after: dateOf(criteria.after),
before: dateOf(criteria.before),
hasAttachment: criteria.hasAttachment === true,
matchesNothing,
bodySkipped,
exactByGrams,
highlight: {
subject: [...query.raw, ...subjectTerms],
body: [...query.raw, ...bodyTerms],
attachment: [...query.raw],
},
};
}
/**
* 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에.
* 보낸 사람 · 받는 사람 조건은 강조하지 않는다. 강조할 말이 하나도 없으면 null
*/
export function highlightTermsOf(
criteria: Pick<SearchCriteria, 'text' | 'subject' | 'body'>,
): 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 };
}
@@ -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<SearchCriteria>) =>
planSearch({ hasAttachment: false, ...overrides });
/** SQL 의 공백을 하나로 (비교 편하게) */
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
it('후보 — 주소(묶음 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => {
const query = candidatesSql(
USER,
ALL,
plan({ text: '계약 갱신' }),
null,
3_001,
);
expect(flat(query.sql)).toBe(
'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE lower("user_email") = $1 ' +
'AND NOT ("mailbox_ids" <@ $2::text[]) AND NOT ("keywords" && $3::text[]) AND "grams" @> $4::text[] ' +
'ORDER BY "received_at" DESC, "email_id" DESC LIMIT $5',
);
expect(query.params).toEqual([
USER,
['b', 'c'],
['$ara-sending'],
['계약', '갱신'],
3_001,
]);
// 주소 그대로("user_email" = …)는 btree 로 그 직원의 줄을 다 읽게 둔다 — 후보 쿼리에는 쓰지 않는다
expect(query.sql).not.toContain('"user_email" =');
expect(query.sql).not.toContain('strpos');
});
it('메일함 범위 · 기간 · 첨부 · 이어 찾을 자리(받은 시각 · 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']]);
});
});
@@ -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]],
};
}
@@ -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<string, SearchRange[]>;
@ApiProperty({
...RANGE_LIST_SCHEMA,
description:
'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 <mark> 가 들어 있다',
example: [[120, 122]],
})
textBody!: SearchRange[];
}
export class MessageDetailDto
extends MessageSummaryDto
implements MessageDetail
@@ -406,6 +496,14 @@ export class MessageDetailDto
type: () => [MessageLargeFileDto],
})
largeFiles!: MessageLargeFileDto[];
@ApiProperty({
description:
'검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 <mark> 가 있다',
type: MessageDetailHighlightDto,
nullable: true,
})
searchHighlight!: MessageDetailHighlightDto | null;
}
const SHARED_FILE_STATUSES: SharedFileStatus[] = [
@@ -475,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;
}
@@ -25,3 +25,72 @@ describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 image
expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']);
});
});
describe('메일 한 통 읽기 쿼리 — 검색 강조 (14단계 q · from · to · subject · body)', () => {
it('목록의 검색 칸과 같은 검사 — 앞뒤 공백을 지우고, 길이 · 낱말 수를 본다', async () => {
for (const field of ['q', 'from', 'to', 'subject', 'body']) {
expect(await constraintsOf({ [field]: ' 계약 ' })).toEqual([]);
expect(await constraintsOf({ [field]: '가'.repeat(201) })).toEqual([
'maxCodeUnits',
]);
expect(
await constraintsOf({
[field]: Array.from({ length: 9 }, (_, i) => `w${i}`).join(' '),
}),
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect(await constraintsOf({ [field]: ['a', 'b'] })).toContain(
'isString',
);
}
});
it('images=show 와 함께 받는다 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다', async () => {
expect(await constraintsOf({ images: 'show', q: '계약' })).toEqual([]);
expect(await constraintsOf({ after: '2026-10-31T15:00:00.000Z' })).toEqual([
'whitelistValidation',
]);
expect(await constraintsOf({ hasAttachment: 'true' })).toEqual([
'whitelistValidation',
]);
});
});
describe('메일 한 통 읽기 쿼리 — 받는 칸은 계약과 같다 (14단계 리뷰 M2 · M3)', () => {
it('q · from · to · subject · body 의 낱말은 합쳐 8개까지 — 목록과 같다', async () => {
const words = (prefix: string, count: number) =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
' ',
);
expect(
await constraintsOf({ q: words('q', 4), subject: words('s', 4) }),
).toEqual([]);
expect(
await constraintsOf({ q: words('q', 4), body: words('b', 5) }),
).toEqual(['maxCombinedSearchWords']);
});
it('images · q · from · to · subject · body 밖의 이름(cursor · limit · mailboxId · after · before · hasAttachment)은 400', async () => {
expect(
await constraintsOf({
images: 'show',
q: '계약',
from: '이도윤',
to: 'ara',
subject: '견적',
body: '일정',
}),
).toEqual([]);
for (const name of [
'cursor',
'limit',
'mailboxId',
'after',
'before',
'hasAttachment',
]) {
expect(await constraintsOf({ [name]: '1' })).toEqual([
'whitelistValidation',
]);
}
});
});
@@ -1,12 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
} from '@/modules/mail/mail.constants';
import { SearchTextField } from './search-query-fields';
/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */
export const SHOW_IMAGES = 'show';
/** 강조에 쓰는 검색 칸의 설명 꼬리 */
const HIGHLIGHT_RULE = `검색 결과에서 연 메일을 강조할 때 목록과 같은 값을 보낸다 — 검사는 목록의 검색어와 같다(다섯 칸의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지)`;
/**
* GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도).
* 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001
* 없으면 그 직원의 설정(externalImages)을 따른다. 검색 칸(q · from · to · subject · body — 14단계)을 받으면 맞은 곳을 강조한다
* (searchHighlight · htmlBody 의 `<mark>`). from · to 는 받기만 하고 강조하지 않는다. 모르는 값 · 모르는 쿼리는 400 VAL_001 —
* 목록에만 있는 cursor · limit · mailboxId · after · before · hasAttachment 도 받지 않는다(계약 2절 "검색 — 14단계" — 화면은 다섯 칸만 넘긴다)
*/
export class MessageDetailQueryDto {
@ApiPropertyOptional({
@@ -18,4 +29,39 @@ export class MessageDetailQueryDto {
@IsOptional()
@IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' })
images?: typeof SHOW_IMAGES;
@ApiPropertyOptional({
description: `검색어 — 제목 · 본문 · 첨부 이름의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('검색어', '검색어는')
q?: string;
@ApiPropertyOptional({
description: `보낸 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('보낸 사람', '보낸 사람은')
from?: string;
@ApiPropertyOptional({
description: `받는 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('받는 사람', '받는 사람은')
to?: string;
@ApiPropertyOptional({
description: `제목 조건 — 제목의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('제목', '제목은')
subject?: string;
@ApiPropertyOptional({
description: `내용 조건 — 본문의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('내용', '내용은')
body?: string;
}
@@ -1,6 +1,9 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { createValidationPipe } from '@/app.setup';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
@@ -63,10 +66,10 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
);
expect(
(await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints,
).toEqual(['maxSearchWords']);
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect(
(await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints,
).toEqual(['maxSearchWords']);
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
});
it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => {
@@ -75,3 +78,154 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
);
});
});
describe('MessageListQueryDto — 조건 검색 (14단계)', () => {
const TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const;
it('글 조건(from · to · subject · body)은 검색어와 같다 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것', async () => {
for (const field of TEXT_FIELDS) {
const trimmed = await check({ [field]: ' 이도윤 계약 ' });
expect(trimmed.constraints).toEqual([]);
expect(trimmed.dto[field]).toBe('이도윤 계약');
const blank = await check({ [field]: ' \u3000 ' });
expect(blank.constraints).toEqual([]);
expect(blank.dto[field]).toBeUndefined();
}
});
it(`글 조건도 앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지, 글이 아니면 형식 오류`, async () => {
const words = (count: number) =>
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
for (const field of TEXT_FIELDS) {
expect(
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH) })).constraints,
).toEqual([]);
expect(
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH + 1) }))
.constraints,
).toEqual(['maxCodeUnits']);
expect(
(await check({ [field]: words(MAX_SEARCH_WORDS) })).constraints,
).toEqual([]);
expect(
(await check({ [field]: words(MAX_SEARCH_WORDS + 1) })).constraints,
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
expect((await check({ [field]: ['a', 'b'] })).constraints).toContain(
'isString',
);
}
});
it('기간(after · before)은 ISO 8601 UTC(Z)만 — 없는 날짜 · 시간대 표기 · 날짜만 · 빈 값은 형식 오류', async () => {
expect(
(
await check({
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00Z',
})
).constraints,
).toEqual([]);
for (const value of [
'',
'2026-11-01',
'2026-11-01T00:00:00+09:00',
'2026-02-30T00:00:00.000Z',
'2026-11-01T00:00:00.0000Z',
'yesterday',
]) {
expect((await check({ after: value })).constraints).toEqual([
'isUtcDateTime',
]);
expect((await check({ before: value })).constraints).toEqual([
'isUtcDateTime',
]);
}
});
it('첨부 조건은 true 만 — false · 1 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => {
expect((await check({ hasAttachment: 'true' })).constraints).toEqual([]);
for (const hasAttachment of ['false', '1', 'TRUE', '']) {
expect((await check({ hasAttachment })).constraints).toEqual(['isIn']);
}
expect((await check({ cc: 'ara' })).constraints).toEqual([
'whitelistValidation',
]);
});
it('전역 검사 그대로(stopAtFirstError) — 긴 글은 길이에서 멈춘다: 낱말을 나누지 않고 싸게 400 (CPU 시간)', async () => {
const pipe = createValidationPipe();
// 낱말 10만 개 · 30만 자 — 길이(MaxCodeUnits — 길이만 본다)에서 걸려 낱말 수 검사(나누기)까지 가지 않는다
const huge = 'ab '.repeat(100_000);
for (const field of ['q', ...TEXT_FIELDS]) {
const cpuMs = startCpuTimer();
const error = await pipe
.transform(
{ [field]: huge },
{ type: 'query', metatype: MessageListQueryDto },
)
.then(
() => null,
(caught: unknown) => caught,
);
// 지금 수 ms — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(500);
expect(error).toBeInstanceOf(BadRequestException);
const { message } = (error as BadRequestException).getResponse() as {
message: string[];
};
expect(message).toHaveLength(1);
expect(message[0]).toContain(`${MAX_SEARCH_LENGTH}자 이하`);
}
});
});
describe('MessageListQueryDto — 검색어와 조건을 합친 낱말 수 (14단계 리뷰 M2)', () => {
const words = (prefix: string, count: number) =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' ');
it(`q 와 조건 칸 넷(from · to · subject · body)의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지 — 넘으면 한 번만 알린다`, async () => {
expect(
(
await check({
q: words('q', 2),
from: words('f', 2),
to: words('t', 2),
subject: words('s', 1),
body: words('b', 1),
})
).constraints,
).toEqual([]);
expect(
(await check({ q: words('q', 4), to: words('t', 5) })).constraints,
).toEqual(['maxCombinedSearchWords']);
// 검색어 없이 조건만이어도 같다
expect(
(await check({ from: words('f', 5), body: words('b', 4) })).constraints,
).toEqual(['maxCombinedSearchWords']);
});
it('기간 · 첨부 · 메일함은 세지 않는다 · 따옴표 안의 낱말은 센다', async () => {
expect(
(
await check({
q: words('q', MAX_SEARCH_WORDS),
after: '2026-10-31T15:00:00.000Z',
hasAttachment: 'true',
mailboxId: 'a',
})
).constraints,
).toEqual([]);
expect(
(await check({ q: `"${words('q', 5)}"`, subject: words('s', 4) }))
.constraints,
).toEqual(['maxCombinedSearchWords']);
});
it('칸 하나가 길이를 넘으면 그 칸의 검사만 — 합친 수를 세려고 긴 글을 나누지 않는다', async () => {
// (이 검사는 stopAtFirstError 를 뺀 것이라 그 칸의 규칙이 모두 보인다 — 합친 수는 보지 않았다)
expect(
(await check({ q: '계약', to: 'ab '.repeat(100) })).constraints,
).toEqual(['maxCodeUnits', 'maxSearchWords']);
});
});
@@ -1,61 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, type TransformFnParams } from 'class-transformer';
import {
IsOptional,
IsString,
Matches,
ValidateBy,
type ValidationOptions,
} from 'class-validator';
import { IsIn, IsOptional, Matches } from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import {
MAIL_ID_PATTERN,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
SEARCH_WORD_SEPARATOR,
} from '@/modules/mail/mail.constants';
import { IsUtcDateTime } from './mail-vacation.dto';
import { SearchTextField } from './search-query-fields';
/** 첨부 있음 조건의 값 — 이것만 받는다 (없으면 첨부와 상관없이) */
export const HAS_ATTACHMENT_ONLY = 'true';
/** 글 칸의 설명 꼬리 — 검사는 검색어와 같다 */
const TEXT_FIELD_RULE = `앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지(검색어와 같은 검사 — q 와 조건 넷의 낱말을 합쳐서도 ${MAX_SEARCH_WORDS}개까지), 공백뿐이면 없는 것으로 본다. 큰따옴표 구절 · 앞뒤 문장부호 떼기 · NUL 을 U+FFFD 로 바꾸기도 검색어와 같다`;
/**
* 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색").
* 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다.
*/
function normalizeSearchQuery({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
}
/** 최대 낱말 수 */
function MaxSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxSearchWords',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && countSearchWords(value) <= max,
},
},
options,
);
}
/**
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어.
* mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages)
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 + 조건(14단계).
* mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (message-list.service.ts)
*/
export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)',
'메일함 id — 없으면 검색(q 나 조건)이 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (둘 다 없으면 400 VAL_001)',
example: 'a',
})
@IsOptional()
@@ -64,21 +32,76 @@ export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` +
'보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
'한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' +
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다',
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지(조건 from · to · subject · body 의 낱말과 합쳐서도 ${MAX_SEARCH_WORDS}개까지). 공백뿐이면 없는 것으로 본다. ` +
'보낸 사람·받는 사람·참조·제목·첨부 이름·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
'색인이 준비됐으면(GET /api/search-index 의 ready) 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 없이), ' +
'준비 중이면 낱말이 통째로 같을 때만 맞고 한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다. ' +
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·조건·범위로만 쓴다',
example: '서버 이전',
maxLength: MAX_SEARCH_LENGTH,
})
@Transform(normalizeSearchQuery)
@IsOptional()
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_SEARCH_LENGTH, {
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
})
@MaxSearchWords(MAX_SEARCH_WORDS, {
message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
})
@SearchTextField('검색어', '검색어는')
q?: string;
@ApiPropertyOptional({
description: `보낸 사람 조건 — 보낸 사람의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
example: '이도윤',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('보낸 사람', '보낸 사람은')
from?: string;
@ApiPropertyOptional({
description: `받는 사람 조건 — 받는 사람과 참조의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
example: 'ara.kim',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('받는 사람', '받는 사람은')
to?: string;
@ApiPropertyOptional({
description: `제목 조건. ${TEXT_FIELD_RULE}`,
example: '견적',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('제목', '제목은')
subject?: string;
@ApiPropertyOptional({
description: `내용 조건 — 본문에서 찾는다(첨부 파일 안의 글은 찾지 않는다). ${TEXT_FIELD_RULE}`,
example: '계약',
maxLength: MAX_SEARCH_LENGTH,
})
@SearchTextField('내용', '내용은')
body?: string;
@ApiPropertyOptional({
description:
'받은 시각이 이 시각 이후(이상)인 메일만 — ISO 8601 UTC(Z). 화면은 브라우저 시간대의 시작 날짜 0시를 UTC 로 바꿔 보낸다',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' })
after?: string;
@ApiPropertyOptional({
description:
'받은 시각이 이 시각 앞(미만)인 메일만 — ISO 8601 UTC(Z). 화면은 끝 날짜의 다음 날 0시를 보낸다',
example: '2026-11-30T15:00:00.000Z',
})
@IsOptional()
@IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' })
before?: string;
@ApiPropertyOptional({
description:
'true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정 — 본문 그림만 있는 메일은 빠진다). 다른 값은 400 VAL_001',
enum: [HAS_ATTACHMENT_ONLY],
example: HAS_ATTACHMENT_ONLY,
})
@IsOptional()
@IsIn([HAS_ATTACHMENT_ONLY], {
message: '첨부 조건이 올바르지 않습니다.',
})
hasAttachment?: typeof HAS_ATTACHMENT_ONLY;
}
@@ -0,0 +1,121 @@
import { applyDecorators } from '@nestjs/common';
import { Transform, type TransformFnParams } from 'class-transformer';
import {
IsOptional,
IsString,
ValidateBy,
type ValidationArguments,
type ValidationOptions,
} from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
SEARCH_WORD_SEPARATOR,
} from '@/modules/mail/mail.constants';
/**
* 검색 글 칸의 검사 (계약 2절 "검색" · "검색 — 14단계" — 검색어 q 와 조건 검색의 from · to · subject · body 가 같이 쓴다).
* 앞뒤 공백을 지우고 공백뿐이면 없는 것으로 본다. 앞뒤 공백을 뺀 1~MAX_SEARCH_LENGTH 자(JS 문자열 길이) · 낱말 MAX_SEARCH_WORDS 개까지,
* 그리고 **다섯 칸을 합친 낱말도 MAX_SEARCH_WORDS 개까지** — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻는다(3단계에서 잰 비용 안에 두려고).
* 검사는 적은 차례대로 돈다(applyDecorators — 8단계 DTO 와 같다): 형식 → 길이 → 낱말 수 → 합친 낱말 수. 길이에서 걸리면 낱말을 나누지 않는다
*/
/** 낱말 수를 합쳐 세는 검색 글 칸 — 목록(GET /api/messages)과 읽기(GET /api/messages/{id})의 같은 이름 */
export const SEARCH_TEXT_FIELDS = [
'q',
'from',
'to',
'subject',
'body',
] as const;
/** 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 — 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다 */
function normalizeSearchText({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/** 검색 글의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
export function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
}
/** 최대 낱말 수 */
function MaxSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxSearchWords',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && countSearchWords(value) <= max,
},
},
options,
);
}
/**
* 다섯 칸을 합친 낱말 수 — 값이 있는 첫 칸에서만 센다(한 번만 알린다). 다른 칸이 글이 아니거나 길이를 넘으면 세지 않는다 —
* 그 칸의 검사가 막는다(긴 글을 나누지 않게)
*/
export function combinedSearchWords(
object: Readonly<Record<string, unknown>>,
): number | null {
let total = 0;
for (const field of SEARCH_TEXT_FIELDS) {
const value = object[field];
if (value === undefined) continue;
if (typeof value !== 'string' || value.length > MAX_SEARCH_LENGTH) {
return null;
}
total += countSearchWords(value);
}
return total;
}
function MaxCombinedSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxCombinedSearchWords',
constraints: [max],
validator: {
validate: (_value: unknown, args?: ValidationArguments) => {
const object = (args?.object ?? {}) as Record<string, unknown>;
const first = SEARCH_TEXT_FIELDS.find(
(field) => object[field] !== undefined,
);
if (first !== args?.property) return true;
const total = combinedSearchWords(object);
return total === null || total <= max;
},
},
},
options,
);
}
/**
* 검색 글 칸 하나 — `label` 은 검사 글의 이름(검색어 · 보낸 사람 …), `topic` 은 조사를 붙인 꼴(검색어는 · 보낸 사람은 …).
* 검사 글은 로그 · 응답에 나가지 않는다(응답은 늘 VAL_001 의 사전 문구 — 계약 머리말)
*/
export function SearchTextField(label: string, topic: string) {
return applyDecorators(
Transform(normalizeSearchText),
IsOptional(),
IsString({ message: `${label} 형식이 올바르지 않습니다.` }),
MaxCodeUnits(MAX_SEARCH_LENGTH, {
message: `${topic} ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
}),
MaxSearchWords(MAX_SEARCH_WORDS, {
message: `${topic} 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
}),
MaxCombinedSearchWords(MAX_SEARCH_WORDS, {
message: `검색어와 조건은 낱말을 모두 합쳐 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
}),
);
}
@@ -0,0 +1,176 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { MAX_HTML_MARKS, markHtmlText } from './html-mark';
import { sanitizeMailHtml } from './sanitize-mail-html';
const marked = (html: string, ...terms: string[]) =>
markHtmlText(html, terms).html;
const countMarks = (html: string) => html.split('<mark>').length - 1;
describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => {
it('글 조각의 맞은 곳에 <mark> — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => {
expect(marked('<p>계약 갱신 안내</p>', '계약')).toBe(
'<p><mark>계약</mark> 갱신 안내</p>',
);
expect(marked('<p>ERP 와 Erp 와 ERP</p>', 'erp')).toBe(
'<p><mark>ERP</mark> 와 <mark>Erp</mark> 와 <mark>ERP</mark></p>',
);
expect(markHtmlText('<p>계약 계약</p>', ['계약']).marks).toBe(2);
});
it('겹치거나 맞닿은 맞은 곳은 하나로 — 여러 말이 한 자리에 맞아도 <mark> 는 겹치지 않는다', () => {
expect(marked('<p>계약서 갱신</p>', '계약', '약서')).toBe(
'<p><mark>계약서</mark> 갱신</p>',
);
});
it('태그 안(이름 · 속성 · style · 주소)은 건드리지 않는다', () => {
const html =
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약">계약</a><img alt="계약" src="cid:x">';
expect(marked(html, '계약')).toBe(
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약"><mark>계약</mark></a><img alt="계약" src="cid:x">',
);
});
it('주석 · 글을 그대로 두는 요소(style · script · textarea · title)의 안은 그대로', () => {
expect(
marked(
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p>계약</p><!-- 닫히지 않은 계약',
'계약',
),
).toBe(
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p><mark>계약</mark></p><!-- 닫히지 않은 계약',
);
expect(marked('<SCRIPT>계약</Script >계약', '계약')).toBe(
'<SCRIPT>계약</Script ><mark>계약</mark>',
);
});
it('문자 참조를 풀어 맞추고 다시 적는다 — &amp; · &lt; · &gt; · &nbsp; · 10진 · 16진', () => {
expect(marked('<p>A&amp;B 계약 &lt;중요&gt;</p>', 'a&b', '<중요>')).toBe(
'<p><mark>A&amp;B</mark> 계약 <mark>&lt;중요&gt;</mark></p>',
);
// &nbsp; 는 띄어쓰기와 같이 접힌다 — 구절이 맞는다 (다시 적을 때는 글자 그대로)
expect(marked('<p>계약&nbsp;갱신</p>', '계약 갱신')).toBe(
'<p><mark>계약\u00a0갱신</mark></p>',
);
expect(marked('<p>&#44228;&#xC57D; 건</p>', '계약')).toBe(
'<p><mark>계약</mark> 건</p>',
);
// 맞은 곳이 없는 조각은 원래 글자 그대로 (참조를 풀어 다시 적지 않는다)
expect(marked('<p>&#44228; &quot;x&quot;</p>', '없는말')).toBe(
'<p>&#44228; &quot;x&quot;</p>',
);
});
it('모르는 참조 · 닫히지 않은 & 가 있는 조각은 그대로 둔다 (정화 결과에는 나오지 않는다)', () => {
expect(marked('<p>&copy; 계약</p>', '계약')).toBe('<p>&copy; 계약</p>');
expect(marked('<p>R&D 계약</p><p>계약</p>', '계약')).toBe(
'<p>R&D 계약</p><p><mark>계약</mark></p>',
);
});
it('줄바꿈 · 탭으로 갈린 구절도 맞는다 — 태그를 건너는 말은 강조하지 않는다', () => {
expect(marked('<p>계약\n갱신</p>', '계약 갱신')).toBe(
'<p><mark>계약\n갱신</mark></p>',
);
expect(marked('<p>계<b>약</b></p>', '계약')).toBe('<p>계<b>약</b></p>');
});
it('NFD 로 적은 글도 맞춘다 — 맞은 조각은 NFC 로 다시 적는다', () => {
const nfd = '계약 안내'.normalize('NFD');
expect(marked(`<p>${nfd}</p>`, '계약')).toBe(
'<p><mark>계약</mark> 안내</p>',
);
expect(marked(`<p>${nfd}</p>`, '없는말')).toBe(`<p>${nfd}</p>`);
});
it('강조할 말이 없거나 빈 본문이면 그대로', () => {
expect(markHtmlText('<p>계약</p>', [])).toEqual({
html: '<p>계약</p>',
marks: 0,
});
expect(markHtmlText('<p>계약</p>', ['', ' '])).toEqual({
html: '<p>계약</p>',
marks: 0,
});
expect(markHtmlText('', ['계약'])).toEqual({ html: '', marks: 0 });
});
it(`<mark> 는 ${MAX_HTML_MARKS}개까지 — 흔한 말이 수만 번 나와도`, () => {
const html = `<p>${'가 '.repeat(5_000)}</p><p>${'가 '.repeat(5_000)}</p>`;
const result = markHtmlText(html, ['가']);
expect(result.marks).toBe(MAX_HTML_MARKS);
expect(countMarks(result.html)).toBe(MAX_HTML_MARKS);
});
it('정화한 HTML 그대로 — 속성 값의 > · 따옴표 · 링크가 깨지지 않고, 넣은 <mark> 는 받은 메일 정화를 다시 지나도 남는다', () => {
const { html } = sanitizeMailHtml(
'<div title="a > 계약 &quot;b&quot;"><a href="https://example.com/?q=계약&x=1">계약 바로가기</a> 계약&amp;견적 <b>중요</b></div>',
);
const result = markHtmlText(html, ['계약']);
expect(result.marks).toBe(2);
// 속성은 정화 결과 그대로
expect(result.html).toContain('title="a &gt; 계약 &quot;b&quot;"');
expect(result.html).toContain('href="https://example.com/?q=계약&amp;x=1"');
expect(result.html).toContain('<mark>계약</mark> 바로가기');
expect(result.html).toContain('<mark>계약</mark>&amp;견적');
// <mark> 는 받은 메일 정화의 허용 태그다 — 다시 정화해도 같다(클라이언트가 그대로 그린다)
expect(sanitizeMailHtml(result.html).html).toBe(result.html);
});
it('깊게 중첩된 HTML 도 한 번 훑는다 — 길이에 비례 (CPU 시간)', () => {
const depth = 100_000;
const html = `${'<div>'.repeat(depth)}계약 안내${'</div>'.repeat(depth)}`;
const cpuMs = startCpuTimer();
const result = markHtmlText(html, ['계약']);
// 지금 30ms 안팎 — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(1_000);
expect(result.marks).toBe(1);
expect(result.html.length).toBe(html.length + '<mark></mark>'.length);
});
it('긴 글(2MB · 조각 수만 개) · 낱말 8개도 길이에 비례한다 (CPU 시간)', () => {
const paragraph = `<p>이번 분기 계약 갱신과 단가 조정 일정을 공유합니다 &amp; 확인 부탁드립니다.</p>`;
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
const terms = [
'계약',
'갱신',
'단가',
'조정',
'일정',
'공유',
'확인',
'"부탁드립니다."',
];
const cpuMs = startCpuTimer();
const result = markHtmlText(html, terms);
expect(cpuMs()).toBeLessThan(3_000);
expect(result.marks).toBe(MAX_HTML_MARKS);
});
it('맞는 말이 없는 2MB 도 한 번 훑고 원래 글 그대로 (CPU 시간 — 리뷰 L6)', () => {
const paragraph =
'<p>이번 분기 일정을 공유합니다 &amp; 확인 부탁드립니다.<br><b>굵게</b> <a href="https://example.com/a?b=1&amp;c=2">링크</a></p>';
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
const cpuMs = startCpuTimer();
const result = markHtmlText(html, ['없는말', '"계약 갱신"', 'zzz']);
// 지금 0.1초 안팎 — 부하를 견디게 10배 넘게
expect(cpuMs()).toBeLessThan(2_000);
expect(result).toEqual({ html, marks: 0 });
});
it('검색 글의 NUL(U+FFFD 로 바꾼 말)은 본문의 U+FFFD 에 맞는다 — 색인과 같은 글자 (리뷰 L2)', () => {
const FFFD = String.fromCharCode(0xfffd);
expect(marked(`<p>계${FFFD}약 건</p>`, `계${FFFD}약`)).toBe(
`<p><mark>계${FFFD}약</mark> 건</p>`,
);
});
});
+175
View File
@@ -0,0 +1,175 @@
import {
foldTerms,
foldText,
matchFoldedRanges,
} from '@/shared/utils/search-text';
/**
* 정화한 메일 HTML 의 글 조각에 `<mark>` 를 넣는다 (순수 함수 — 14단계 설계 C2, 읽기 화면의 검색 강조).
* **정화한 뒤에만** 부른다(sanitize-mail-html.ts 의 결과 — 태그가 닫혀 있고 속성 값의 `>` 는 `&gt;` 다). 다시 정화하지 않는다 — 넣는 것은 속성 없는 `<mark>` 뿐이다
* (mark 는 받은 메일 정화의 허용 태그다).
*
* - 태그 안(이름 · 속성 · style)은 건드리지 않는다 — `<` 부터 `>` 까지는 그대로 옮긴다. 주석(`<!-- … -->`)과 글을 그대로 두어야 하는 요소
* (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로
* - 글 조각마다 문자 참조를 풀어(&amp; · &lt; · &gt; · &quot; · &apos; · &nbsp; · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다
* (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(&amp; · &lt; · &gt; 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다
* - 태그를 건너는 말(`계<b>약</b>`)은 강조하지 않는다 — 글 조각 안에서만
* - 넣는 `<mark>` 는 `limit`(MAX_HTML_MARKS)개까지
* 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다)
*/
/** 한 본문에 넣는 `<mark>` 의 수 */
export const MAX_HTML_MARKS = 1_000;
/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */
const MAX_REFERENCE_LENGTH = 32;
/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 &amp; · &lt; · &gt; 만 다시 적는다)과 흔한 것 */
const NAMED_REFERENCES: Readonly<Record<string, string>> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: '\u00a0',
};
/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */
const RAW_TEXT_TAGS: ReadonlySet<string> = new Set([
'script',
'style',
'textarea',
'title',
'xmp',
]);
const COMMENT_OPEN = '<!--';
const COMMENT_CLOSE = '-->';
const MAX_CODE_POINT = 0x10ffff;
const REPLACEMENT_CHARACTER = '\ufffd';
/** 태그 이름 (`<style …>` → style, 닫는 태그 · 이름 없음은 null) — 소문자 */
const OPEN_TAG_NAME = /^<([A-Za-z][A-Za-z0-9-]*)/;
function decodeReference(reference: string): string | null {
if (reference.startsWith('#')) {
const hex = reference[1] === 'x' || reference[1] === 'X';
const digits = reference.slice(hex ? 2 : 1);
if (!(hex ? /^[0-9A-Fa-f]{1,8}$/ : /^[0-9]{1,8}$/).test(digits)) {
return null;
}
const point = Number.parseInt(digits, hex ? 16 : 10);
if (
point <= 0 ||
point > MAX_CODE_POINT ||
(point >= 0xd800 && point <= 0xdfff)
) {
return REPLACEMENT_CHARACTER;
}
return String.fromCodePoint(point);
}
return NAMED_REFERENCES[reference] ?? null;
}
/** 글 조각의 문자 참조를 푼다 — 모르는 참조 · 닫히지 않은 `&` 가 있으면 null(그 조각은 그대로 둔다) */
function decodeText(text: string): string | null {
if (!text.includes('&')) return text;
let decoded = '';
let index = 0;
for (;;) {
const amp = text.indexOf('&', index);
if (amp < 0) return decoded + text.slice(index);
decoded += text.slice(index, amp);
const limit = Math.min(text.length, amp + 1 + MAX_REFERENCE_LENGTH + 1);
const semi = text.indexOf(';', amp + 1);
if (semi < 0 || semi >= limit) return null;
const value = decodeReference(text.slice(amp + 1, semi));
if (value === null) return null;
decoded += value;
index = semi + 1;
}
}
/** 글을 HTML 글 조각으로 — `&` · `<` · `>` 만 적는다 */
function escapeText(text: string): string {
return text.replace(/[&<>]/g, (char) =>
char === '&' ? '&amp;' : char === '<' ? '&lt;' : '&gt;',
);
}
/** 글 조각 하나 — 맞은 곳에 `<mark>` 를 넣은 조각과 넣은 수 (맞은 곳이 없으면 원래 조각 그대로) */
function markText(
source: string,
terms: readonly string[],
limit: number,
): { readonly html: string; readonly marks: number } {
const decoded = decodeText(source);
if (decoded === null) return { html: source, marks: 0 };
const text = decoded.normalize('NFC');
const ranges = matchFoldedRanges(foldText(text), terms, limit);
if (ranges.length === 0) return { html: source, marks: 0 };
let html = '';
let last = 0;
for (const [start, end] of ranges) {
html += `${escapeText(text.slice(last, start))}<mark>${escapeText(text.slice(start, end))}</mark>`;
last = end;
}
return { html: html + escapeText(text.slice(last)), marks: ranges.length };
}
/** `name` 의 닫는 태그 자리 (대소문자 없이) — 없으면 끝 */
function closingTagAt(html: string, name: string, from: number): number {
const pattern = new RegExp(`</${name}[\\s/>]`, 'gi');
pattern.lastIndex = from;
const found = pattern.exec(html);
return found ? found.index : html.length;
}
export function markHtmlText(
html: string,
terms: readonly string[],
limit: number = MAX_HTML_MARKS,
): { readonly html: string; readonly marks: number } {
const folded = foldTerms(terms);
if (folded.length === 0 || html === '') return { html, marks: 0 };
const out: string[] = [];
let marks = 0;
let index = 0;
while (index < html.length) {
const lt = html.indexOf('<', index);
const textEnd = lt < 0 ? html.length : lt;
if (textEnd > index) {
const text = html.slice(index, textEnd);
if (marks < limit) {
const marked = markText(text, folded, limit - marks);
out.push(marked.html);
marks += marked.marks;
} else {
out.push(text);
}
}
if (lt < 0) break;
if (html.startsWith(COMMENT_OPEN, lt)) {
const close = html.indexOf(COMMENT_CLOSE, lt + COMMENT_OPEN.length);
const end = close < 0 ? html.length : close + COMMENT_CLOSE.length;
out.push(html.slice(lt, end));
index = end;
continue;
}
const gt = html.indexOf('>', lt + 1);
if (gt < 0) {
out.push(html.slice(lt));
break;
}
const tag = html.slice(lt, gt + 1);
out.push(tag);
index = gt + 1;
const name = OPEN_TAG_NAME.exec(tag)?.[1].toLowerCase();
if (name !== undefined && RAW_TEXT_TAGS.has(name)) {
const close = closingTagAt(html, name, index);
out.push(html.slice(index, close));
index = close;
}
}
return { html: out.join(''), marks };
}
@@ -85,4 +85,56 @@ describe('메일 목록 커서', () => {
expect(codeOf(() => decodeCursor(cursor, inbox))).toBe('VAL_001');
}
});
it('조건 검색(14단계)의 조건도 범위다 — 조건이 다르면 다른 목록, 조건이 없으면 지문은 예전과 같다', () => {
const position = { anchor: 'eaaaaab', position: 20 };
const conditions = ['이도윤', '', '', '', '', '', false];
const withConditions = encodeCursor(position, { text: '회의', conditions });
expect(
decodeCursor(withConditions, {
text: '회의',
conditions: [...conditions],
}),
).toEqual(position);
for (const scope of [
{ text: '회의' },
{ text: '회의', conditions: ['김아라', '', '', '', '', '', false] },
{ text: '회의', conditions: ['이도윤', '', '', '', '', '', true] },
]) {
expect(codeOf(() => decodeCursor(withConditions, scope))).toBe('VAL_001');
}
// 조건이 없으면 예전 지문 — 3단계의 커서가 그대로 쓰인다
const plain = encodeCursor(position, { text: '회의' });
expect(decodeCursor(plain, { text: '회의' })).toEqual(position);
});
it('조건의 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸 · 검색어를 흉내 내도 다른 목록이다', () => {
const position = { anchor: 'eaaaaab', position: 20 };
const NL = String.fromCharCode(10);
const fromTwoLines = encodeCursor(position, {
text: '회의',
conditions: [`a${NL}b`, '', '', '', '', '', false],
});
expect(
codeOf(() =>
decodeCursor(fromTwoLines, {
text: '회의',
conditions: ['a', 'b', '', '', '', '', false],
}),
),
).toBe('VAL_001');
// 검색어 끝에 줄바꿈 + 조건처럼 보이는 글을 넣어도 조건이 있는 검색과 섞이지 않는다
const textOnly = encodeCursor(position, {
text: `회의${NL}${JSON.stringify(['a', '', '', '', '', '', false])}`,
});
expect(
codeOf(() =>
decodeCursor(textOnly, {
text: '회의',
conditions: ['a', '', '', '', '', '', false],
}),
),
).toBe('VAL_001');
});
});
+13 -1
View File
@@ -30,6 +30,11 @@ export interface CursorScope {
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함 */
readonly mailboxId?: string;
readonly text?: string;
/**
* 조건 검색의 조건(14단계 — 정해진 차례의 값들). 없으면(조건 없는 검색 · 목록) 지문은 예전과 같다 — 배포 전에 받은 커서도 그대로 쓴다.
* 있으면 지문은 [메일함, 검색어, 조건] 의 JSON 이다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않는다
*/
readonly conditions?: readonly (string | boolean)[];
}
export interface CursorPosition {
@@ -47,8 +52,15 @@ interface EncodedCursor {
}
function scopeHash(scope: CursorScope): string {
const mailbox = scope.mailboxId ?? ALL_MAILBOXES_SCOPE;
const text = scope.text ?? '';
// 조건이 없으면 예전 모양("<메일함>\n<검색어>") — 메일함 id 에는 줄바꿈이 없어 섞이지 않는다. 조건이 있으면 JSON(늘 "[" 로 시작 — 메일함 id 와 겹치지 않는다)
const material =
scope.conditions === undefined
? `${mailbox}\n${text}`
: JSON.stringify([mailbox, text, scope.conditions]);
return createHash('sha256')
.update(`${scope.mailboxId ?? ALL_MAILBOXES_SCOPE}\n${scope.text ?? ''}`)
.update(material)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
@@ -9,6 +9,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_BODY_VALUE_BYTES,
MAX_ENVELOPE_RESUBMITS,
MAX_SEARCH_WORDS,
} from '@/modules/mail/mail.constants';
import { SendNotStartedException } from '@/modules/mail/mail.provider';
import type { MailSettings } from '@/modules/mail/mail.settings';
@@ -38,7 +39,8 @@ import {
parseLargeFileIds,
} from './jmap-mail.provider';
import { MAILBOX_PROPERTIES } from './jmap.mapper';
import type { JmapInvocation } from './jmap.types';
import type { JmapEmailFilter, JmapInvocation } from './jmap.types';
import { particleForms } from './search-terms';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
@@ -871,6 +873,298 @@ describe('JmapMailProvider', () => {
});
});
describe('조건 검색 · 색인 검색의 엔진 쪽 (14단계)', () => {
const searchResponses = (
ids: string[],
total: number,
): JmapInvocation[] => [
['Email/query', { ids, position: 0, total, queryState: 's' }, 'q'],
[
'Email/get',
{
list: ids.map((id) => summaryEmail(id, '2026-10-26T09:00:00Z')),
notFound: [],
},
'g',
],
];
const mailboxRoles: JmapInvocation[] = [
[
'Mailbox/get',
{ list: stalwartMailboxes.map(({ id, role }) => ({ id, role })) },
'm',
],
];
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses(['m1'], 1),
]);
const page = await provider.listMessages(ARA, {
limit: 20,
conditions: {
from: '이도윤',
to: 'ara',
subject: '"계약 갱신"',
body: '견적',
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
},
});
const { filter } = argsOf(recorded[1]) as {
filter: { operator: string; conditions: unknown[] };
};
expect(filter.operator).toBe('AND');
expect(filter.conditions[0]).toEqual({
inMailboxOtherThan: ['b', 'c'],
notKeyword: '$ara-sending',
});
expect(filter.conditions.slice(1)).toEqual([
{
operator: 'OR',
conditions: particleForms('이도윤').map((from) => ({ from })),
},
{
operator: 'OR',
conditions: particleForms('ara').flatMap((form) => [
{ to: form },
{ cc: form },
]),
},
{ subject: '"계약 갱신"' },
{
operator: 'OR',
conditions: particleForms('견적').map((body) => ({ body })),
},
{ after: '2026-10-31T15:00:00Z' },
{ before: '2026-11-30T15:00:00Z' },
{ hasAttachment: true },
]);
expect(page.items.map((item) => item.id)).toEqual(['m1']);
});
it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => {
const conditions = { from: '이도윤', hasAttachment: true as const };
const first = fakeClient([
mailboxRoles,
searchResponses(['m1', 'm2'], 3),
]);
const page = await first.provider.listMessages(ARA, {
limit: 2,
text: '회의',
conditions,
});
const { filter } = argsOf(first.recorded[1]) as {
filter: { conditions: unknown[] };
};
// 범위 · 검색어 · 보낸 사람 · 첨부
expect(filter.conditions).toHaveLength(4);
const cursor = page.nextCursor as string;
expect(cursor).not.toBeNull();
for (const query of [
{ limit: 2, text: '회의', cursor },
{ limit: 2, text: '회의', cursor, conditions: { from: '이도윤' } },
{
limit: 2,
text: '회의',
cursor,
conditions: { ...conditions, before: '2026-11-30T15:00:00.000Z' },
},
]) {
const other = fakeClient([]);
expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
'VAL_001',
);
expect(other.client.call).not.toHaveBeenCalled();
}
const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
await next.provider.listMessages(ARA, {
limit: 2,
text: '회의',
cursor,
conditions,
});
expect(argsOf(next.recorded[1])).toMatchObject({ anchor: 'm2' });
});
it('조건만 있는 검색(검색어 없음)도 모든 메일함에서 · 조건 없는 검색의 커서 지문은 예전과 같다', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses([], 0),
]);
await provider.listMessages(ARA, {
limit: 20,
conditions: { hasAttachment: true },
});
const { filter } = argsOf(recorded[1]) as {
filter: { conditions: unknown[] };
};
expect(filter.conditions).toEqual([
{ inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
{ hasAttachment: true },
]);
const plainCursor = encodeCursor(
{ anchor: 'm2', position: 2 },
{ text: '회의' },
);
const plain = fakeClient([mailboxRoles, searchResponses([], 0)]);
await expect(
plain.provider.listMessages(ARA, {
limit: 2,
text: '회의',
cursor: plainCursor,
conditions: {},
}),
).resolves.toMatchObject({ items: [] });
});
it(`엔진 방식의 가장 큰 필터를 고정한다 — 합친 낱말 ${MAX_SEARCH_WORDS}개가 모두 받는 사람(to · cc 둘로 묻는다)이고 영문 · 숫자로 끝나면(형태가 가장 많다) 잎 948개 (리뷰 M2)`, async () => {
/** 필터의 잎(연산자가 아닌 조건) 수 */
const leaves = (filter: JmapEmailFilter): number =>
'operator' in filter
? filter.conditions.reduce((sum, child) => sum + leaves(child), 0)
: 1;
const words = Array.from(
{ length: MAX_SEARCH_WORDS },
(_, index) => `word${index}`,
).join(' ');
const filterOf = async (query: {
text?: string;
conditions?: Record<string, unknown>;
}) => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses([], 0),
]);
await provider.listMessages(ARA, { limit: 20, ...query });
return (argsOf(recorded[1]) as { filter: JmapEmailFilter }).filter;
};
const forms = particleForms('word0').length;
expect(forms).toBe(59);
const worst = await filterOf({
conditions: {
to: words,
after: '2026-10-31T15:00:00.000Z',
before: '2026-11-30T15:00:00.000Z',
hasAttachment: true,
},
});
// 낱말 8 × 형태 59 × 칸 2(to · cc) + 범위 1 + 기간 2 + 첨부 1
expect(leaves(worst)).toBe(MAX_SEARCH_WORDS * forms * 2 + 4);
expect(leaves(worst)).toBe(948);
// 같은 낱말을 검색어로 두면 칸이 하나 — 3단계에서 잰 472 + 범위
expect(leaves(await filterOf({ text: words }))).toBe(
MAX_SEARCH_WORDS * forms + 1,
);
});
it('글 조건에 찾을 낱말이 하나도 없으면(문장부호뿐) 엔진에 묻지 않고 빈 결과', async () => {
for (const conditions of [{ from: '…' }, { body: '""' }, { to: '-' }]) {
const { provider, client } = fakeClient([]);
await expect(
provider.listMessages(ARA, { limit: 20, conditions }),
).resolves.toEqual({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
expect(client.call).not.toHaveBeenCalled();
}
});
it('색인 검색의 범위 (searchScope) — 모든 메일함이면 휴지통 · 스팸메일함을 빼는 메일함으로, 내부 초안 키워드는 늘 뺀다', async () => {
const { provider, recorded } = fakeClient([mailboxRoles]);
await expect(provider.searchScope(ARA)).resolves.toEqual({
mailboxId: null,
excludedMailboxIds: ['b', 'c'],
excludedKeywords: ['$ara-sending'],
});
expect(argsOf(recorded[0])).toEqual({
accountId: 'd',
ids: null,
properties: ['id', 'role'],
});
expect(recorded[0].options).toEqual({ invalidRequestCode: 'MAIL_001' });
});
it('색인 검색의 범위 — 메일함 범위면 그 메일함이 있는지만 본다(없으면 404 RES_001 · 엔진이 해석하지 못하는 id 도 RES_001)', async () => {
const found = fakeClient([
[['Mailbox/get', { list: [{ id: 'a', role: 'inbox' }] }, 'm']],
]);
await expect(found.provider.searchScope(ARA, 'a')).resolves.toEqual({
mailboxId: 'a',
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
});
expect(argsOf(found.recorded[0])).toMatchObject({ ids: ['a'] });
expect(found.recorded[0].options).toEqual({
invalidRequestCode: 'RES_001',
});
const missing = fakeClient([
[['Mailbox/get', { list: [], notFound: ['zz'] }, 'm']],
]);
expect(await codeOf(missing.provider.searchScope(ARA, 'zz'))).toBe(
'RES_001',
);
});
it('찾은 쪽의 요약 (getSummaries) — 목록과 같은 속성 · 기다리는 발송을 한 요청에, 준 차례대로 · 없는 메일 · 형식이 틀린 id 는 빠진다', async () => {
const { provider, recorded } = fakeClient([
[
[
'Email/get',
{
list: [
summaryEmail('m2', '2026-10-26T08:00:00Z'),
summaryEmail('m1', '2026-10-26T09:00:00Z'),
],
notFound: ['m3'],
},
'g',
],
...heldGuard(),
],
]);
const summaries = await provider.getSummaries(ARA, [
'm1',
'm3',
'../x',
'm2',
'm1',
]);
expect(summaries.map((summary) => summary.id)).toEqual(['m1', 'm2']);
expect(summaries[0]).toMatchObject({
subject: '제목 m1',
pendingSend: null,
});
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
]);
expect(argsOf(recorded[0])).toMatchObject({ ids: ['m1', 'm3', 'm2'] });
});
it('요약할 id 가 없으면 엔진에 묻지 않는다', async () => {
const { provider, client } = fakeClient([]);
await expect(provider.getSummaries(ARA, [])).resolves.toEqual([]);
await expect(provider.getSummaries(ARA, ['../x'])).resolves.toEqual([]);
expect(client.call).not.toHaveBeenCalled();
});
});
describe('메일 상세 (getMessage)', () => {
const detailEmail = {
...summaryEmail('m1', '2026-10-26T05:12:00Z', { hasAttachment: true }),
@@ -16,6 +16,10 @@ import {
type MailProvider,
} from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
SearchConditions,
SearchScope,
} from '@/modules/mail-search/mail-search.types';
import type {
AttachmentContent,
BlockedSender,
@@ -126,6 +130,7 @@ import {
selectAccountQuota,
toAddresses,
toDetailSource,
toJmapUtcDate,
toMailbox,
toSummary,
toUsage,
@@ -168,7 +173,12 @@ import {
type HeldEmails,
type HeldLookup,
} from './pending-send';
import { searchTextConditions, withSearchConditions } from './search-terms';
import {
searchFieldConditions,
searchTextConditions,
withSearchConditions,
type SearchFieldProperty,
} from './search-terms';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
@@ -276,6 +286,61 @@ const SOURCE_DOWNLOAD_NAME = 'message.eml';
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
/** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */
const CONDITION_PROPERTIES: readonly (readonly [
keyof Pick<SearchConditions, 'from' | 'to' | 'subject' | 'body'>,
readonly SearchFieldProperty[],
])[] = [
['from', ['from']],
['to', ['to', 'cc']],
['subject', ['subject']],
['body', ['body']],
];
/** 조건 → 커서 지문에 넣는 값들 (정해진 차례 — jmap-cursor.ts 가 JSON 으로 잇는다) — 조건이 없으면 undefined (지문이 예전과 같다) */
function conditionsKey(
conditions: SearchConditions | undefined,
): (string | boolean)[] | undefined {
if (!conditions) return undefined;
const parts = [
conditions.from ?? '',
conditions.to ?? '',
conditions.subject ?? '',
conditions.body ?? '',
conditions.after ?? '',
conditions.before ?? '',
conditions.hasAttachment === true,
];
return parts.some((part) => part !== '' && part !== false)
? parts
: undefined;
}
/**
* 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정.
* 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과)
*/
function conditionFilters(
conditions: SearchConditions | undefined,
): JmapEmailFilter[] | null {
if (!conditions) return [];
const filters: JmapEmailFilter[] = [];
for (const [name, properties] of CONDITION_PROPERTIES) {
const value = conditions[name];
if (!value) continue;
const found = searchFieldConditions(value, properties);
if (found.length === 0) return null;
filters.push(...found);
}
if (conditions.after)
filters.push({ after: toJmapUtcDate(conditions.after) });
if (conditions.before) {
filters.push({ before: toJmapUtcDate(conditions.before) });
}
if (conditions.hasAttachment === true) filters.push({ hasAttachment: true });
return filters;
}
/**
* 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
* (백엔드가 먼저 답해야 사용자가 같은 키로 다시 보낼 수 있다 — jmap.client.ts 의 JMAP_SEND_TIMEOUT_MS 주석).
@@ -1419,15 +1484,23 @@ export class JmapMailProvider implements MailProvider {
user: string,
query: MessageListQuery,
): Promise<MessagePage> {
const scope = { mailboxId: query.mailboxId, text: query.text };
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
const scope = {
mailboxId: query.mailboxId,
text: query.text,
conditions: conditionsKey(query.conditions),
};
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어·조건의 커서면 VAL_001
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
// 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
// 검색어 · 조건의 글에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
// 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
const textConditions =
query.text !== undefined ? searchTextConditions(query.text) : [];
if (query.text !== undefined && textConditions.length === 0) {
const fieldConditions = conditionFilters(query.conditions);
if (
(query.text !== undefined && textConditions.length === 0) ||
fieldConditions === null
) {
return { items: [], hasNext: false, nextCursor: null, total: 0 };
}
@@ -1439,7 +1512,7 @@ export class JmapMailProvider implements MailProvider {
: await this.searchableMailboxes(user);
const filter = withSearchConditions(
{ ...range, notKeyword: INTERNAL_SEND_KEYWORD },
textConditions,
[...textConditions, ...fieldConditions],
);
// 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts)
@@ -1516,6 +1589,92 @@ export class JmapMailProvider implements MailProvider {
};
}
/**
* 자체 색인으로 찾을 범위 (14단계) — 메일함 범위면 그 메일함이 있는지(없으면 RES_001 — 엔진이 해석하지 못하는 id 도), 모든 메일함이면 빼는 메일함(휴지통 · 스팸메일함).
* 발송 중인 내부 초안의 키워드는 늘 뺀다 (목록과 같다). 엔진 요청 한 번
*/
async searchScope(user: string, mailboxId?: string): Promise<SearchScope> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{
accountId,
ids: mailboxId === undefined ? null : [mailboxId],
properties: ['id', 'role'],
},
'm',
],
],
{ invalidRequestCode: mailboxId === undefined ? 'MAIL_001' : 'RES_001' },
);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
if (mailboxId !== undefined) {
if (!mailboxes.some((mailbox) => mailbox.id === mailboxId)) {
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
return {
mailboxId,
excludedMailboxIds: [],
excludedKeywords: [INTERNAL_SEND_KEYWORD],
};
}
return {
mailboxId: null,
excludedMailboxIds: mailboxes
.filter((mailbox) =>
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
)
.map((mailbox) => mailbox.id),
excludedKeywords: [INTERNAL_SEND_KEYWORD],
};
}
/**
* 요약 여러 통 (14단계 — 색인으로 찾은 쪽) — 목록과 같은 속성 · 기다리는 발송. `ids` 차례대로, 없는 메일은 빠진다.
* 엔진이 해석하지 못하는 id 는 받지 않는다(색인의 id 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다)
*/
async getSummaries(
user: string,
ids: readonly string[],
): Promise<MessageSummary[]> {
const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id));
if (valid.length === 0) return [];
const now = Date.now();
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/get',
{
accountId,
ids: valid,
properties: SUMMARY_PROPERTIES,
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
},
'g',
],
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
]);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const held = readHeldSendTimes(responses, HELD_CALLS, now);
this.warnIfHeldQueryFull(responses, user);
const byId = new Map(list.map((email) => [email.id, email]));
return valid.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
});
}
/**
* 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다.
* 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다
@@ -221,8 +221,18 @@ export interface JmapEmailFilterCondition {
notKeyword?: string;
/** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */
before?: string;
/** 받은 시각이 이 이후(이상)인 메일 — UTCDate (14단계 조건 검색의 기간) */
after?: string;
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
text?: string;
/** 칸마다의 전문 검색 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — 낱말 통째 일치는 text 와 같다 */
from?: string;
to?: string;
cc?: string;
subject?: string;
body?: string;
/** 엔진의 첨부 판정 (본문 그림만 있어도 true 일 수 있다 — 색인 방식과 조금 다르다) */
hasAttachment?: boolean;
}
/** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */
@@ -2,6 +2,7 @@ import {
SEARCH_PARTICLES,
parseSearchTerms,
particleForms,
searchFieldConditions,
searchTextConditions,
withSearchConditions,
} from './search-terms';
@@ -365,4 +366,57 @@ describe('검색어 → 엔진 조건 (search-terms)', () => {
);
});
});
describe('칸 조건 (searchFieldConditions — 14단계 조건 검색의 엔진 방식)', () => {
it('칸 하나 — 낱말은 그 칸의 형태 OR, 구절은 그 칸의 따옴표 구절', () => {
const [word, phrase] = searchFieldConditions('계약 "갱신 일정"', [
'subject',
]);
expect(word).toEqual({
operator: 'OR',
conditions: particleForms('계약').map((subject) => ({ subject })),
});
expect(phrase).toEqual({ subject: '"갱신 일정"' });
});
it('칸이 여럿(받는 사람 = to · cc)이면 형태마다 두 칸의 OR — 낱말끼리는 AND(목록의 차례)', () => {
const conditions = searchFieldConditions('ara 東京', ['to', 'cc']);
expect(conditions).toHaveLength(2);
expect(conditions[0]).toEqual({
operator: 'OR',
conditions: particleForms('ara').flatMap((form) => [
{ to: form },
{ cc: form },
]),
});
// 형태가 하나뿐이어도 칸이 둘이면 OR
expect(conditions[1]).toEqual({
operator: 'OR',
conditions: [{ to: '東京' }, { cc: '東京' }],
});
});
it('검색어 조건(searchTextConditions)은 text 칸 하나의 칸 조건과 같다 · 찾을 낱말이 없으면 빈 목록', () => {
expect(searchTextConditions('서버 "이전 계획"')).toEqual(
searchFieldConditions('서버 "이전 계획"', ['text']),
);
expect(searchFieldConditions('… -', ['body'])).toEqual([]);
});
});
it('검색 글의 NUL 은 U+FFFD 로 1:1 바꾼다 — 색인의 저장과 같고 엔진 방식도 같은 낱말로 묻는다 (리뷰 L2)', () => {
const NUL = String.fromCharCode(0);
const FFFD = String.fromCharCode(0xfffd);
expect(parseSearchTerms(`계${NUL}약 "a${NUL}b"`)).toEqual([
{ kind: 'word', text: `계${FFFD}약` },
{ kind: 'phrase', text: `a${FFFD}b` },
]);
// 엔진에 보내는 조건(JSON)에 NUL 이 없다 — 이스케이프한 꼴도 없다
const sent = JSON.stringify(searchTextConditions(`계${NUL}약`));
expect(sent.includes(NUL)).toBe(false);
expect(sent).not.toContain(`${String.fromCharCode(92)}u0000`);
expect(sent).toContain(`계${FFFD}약`);
// NUL 만이면 찾을 글자가 없다 — 빈 목록
expect(parseSearchTerms(NUL.repeat(3))).toEqual([]);
});
});
+40 -72
View File
@@ -1,4 +1,4 @@
import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants';
import { parseSearchTerms } from '@/shared/utils/search-query';
import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
/**
@@ -14,6 +14,9 @@ import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
* 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다.
*/
/** 검색어 나누기는 엔진과 상관없어 shared/utils/search-query.ts 로 옮겼다(14단계 — 색인 찾기와 함께 쓴다) */
export { parseSearchTerms, type SearchTerm } from '@/shared/utils/search-query';
/**
* 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절).
* 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다
@@ -89,6 +92,9 @@ export const SEARCH_PARTICLES = {
],
} as const;
/** 큰따옴표 — 구절 조건(`{ text: '"…"' }`)을 감싼다 */
const QUOTE = '"';
/** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */
const HANGUL_SYLLABLE_FIRST = 0xac00;
const HANGUL_SYLLABLE_LAST = 0xd7a3;
@@ -102,29 +108,6 @@ const EURO = '으로';
/** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */
const ASCII_ALNUM_END = /[A-Za-z0-9]$/;
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
const QUOTE = '"';
/**
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
*/
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
/**
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
*/
const WORD_EDGE_PUNCTUATION =
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
function coreWord(word: string): string {
return word.replace(WORD_EDGE_PUNCTUATION, '');
}
/** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */
type WordEnding =
/** 받침 없는 한글 */
@@ -186,61 +169,46 @@ export function particleForms(word: string): string[] {
]);
}
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
export interface SearchTerm {
readonly kind: 'word' | 'phrase';
readonly text: string;
}
function splitWords(text: string): string[] {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
}
/**
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
*/
export function parseSearchTerms(query: string): SearchTerm[] {
const segments = query.normalize('NFC').split(QUOTE);
const terms: SearchTerm[] = [];
const seen = new Set<string>();
const add = (kind: SearchTerm['kind'], text: string) => {
const key = `${kind}\n${text}`;
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
seen.add(key);
terms.push({ kind, text });
};
segments.forEach((segment, index) => {
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
const isPhrase = index % 2 === 1 && index < segments.length - 1;
if (isPhrase) {
add('phrase', splitWords(segment).join(' '));
return;
}
for (const word of splitWords(segment)) add('word', coreWord(word));
});
return terms;
}
/**
* 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다).
* 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나).
* 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다.
*/
export function searchTextConditions(query: string): JmapEmailFilter[] {
return searchFieldConditions(query, ['text']);
}
/** 글로 찾는 엔진 필터 칸 — text(모든 칸) · 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 */
export type SearchFieldProperty =
| 'text'
| 'from'
| 'to'
| 'cc'
| 'subject'
| 'body';
/**
* 칸 조건 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — searchTextConditions 와 같은 낱말 · 구절 · 조사 형태를 그 칸에서 찾는다.
* 칸이 여럿이면(받는 사람 = to · cc) 그 가운데 하나에 있으면 된다 — 낱말마다 (형태 × 칸)의 OR, 낱말끼리는 AND.
* 찾을 낱말이 하나도 없으면 빈 목록
*/
export function searchFieldConditions(
query: string,
properties: readonly SearchFieldProperty[],
): JmapEmailFilter[] {
return parseSearchTerms(query).map((term): JmapEmailFilter => {
if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` };
const forms = particleForms(term.text);
return forms.length === 1
? { text: forms[0] }
: { operator: 'OR', conditions: forms.map((text) => ({ text })) };
const forms =
term.kind === 'phrase'
? [`${QUOTE}${term.text}${QUOTE}`]
: particleForms(term.text);
const conditions = forms.flatMap((form) =>
properties.map(
(property): JmapEmailFilterCondition => ({ [property]: form }),
),
);
return conditions.length === 1
? conditions[0]
: { operator: 'OR', conditions };
});
}
+2 -2
View File
@@ -56,8 +56,8 @@ export const MAX_SEARCH_LENGTH = 200;
/** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */
export const MAX_SEARCH_WORDS = 8;
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */
export const SEARCH_WORD_SEPARATOR = /\s+/u;
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 (shared/utils/search-query.ts) */
export { SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-query';
/**
* 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 —
+3
View File
@@ -56,6 +56,7 @@ import {
MailVacationController,
} from './mailbox.controller';
import { MessageDeletionController } from './message-deletion.controller';
import { MessageListService } from './message-list.service';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
@@ -167,6 +168,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
JmapFilterRunner,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 메일 목록 · 검색 (14단계 — 색인 방식 · 엔진 방식을 고르고 강조를 단다)
MessageListService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
MailEventsService,
// 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file)
+12
View File
@@ -1,4 +1,5 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import type {
AttachmentContent,
BlockedSender,
@@ -254,6 +255,17 @@ export interface MailProvider {
*/
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/**
* 자체 색인으로 찾을 범위 (14단계 — 색인 방식 검색) — 메일함 하나(없으면 RES_001), 또는 휴지통 · 스팸메일함만 뺀 모든 메일함과
* 목록에서 빼는 키워드(발송 중인 내부 초안). 엔진의 메일함 역할 · 키워드 이름은 어댑터만 안다
*/
searchScope(user: string, mailboxId?: string): Promise<SearchScope>;
/**
* 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때)
*/
getSummaries(user: string, ids: readonly string[]): Promise<MessageSummary[]>;
/**
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
*/
+3 -53
View File
@@ -99,6 +99,8 @@ function fakeProvider(): ProviderMocks {
getMailState: mockOf<'getMailState'>(),
watchMail: mockOf<'watchMail'>(),
listMessages: mockOf<'listMessages'>(),
searchScope: mockOf<'searchScope'>(),
getSummaries: mockOf<'getSummaries'>(),
getMessage: mockOf<'getMessage'>(),
updateMessage: mockOf<'updateMessage'>(),
updateMessages: mockOf<'updateMessages'>(),
@@ -452,59 +454,7 @@ describe('MailService', () => {
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
});
it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => {
provider.listMessages.mockResolvedValue({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' });
await service.listMessages(user, {
mailboxId: 'a',
limit: 20,
q: ' 이전 ',
});
expect(provider.listMessages.mock.calls[0][1]).toMatchObject({
text: undefined,
});
expect(provider.listMessages.mock.calls[1][1]).toMatchObject({
text: '이전',
});
});
describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => {
const emptyPage = {
items: [],
hasNext: false,
nextCursor: null,
total: 0,
};
it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => {
provider.listMessages.mockResolvedValue(emptyPage);
await service.listMessages(user, { limit: 20, q: ' 회의 ' });
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '회의',
});
});
it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => {
for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) {
expect(await codeOf(() => service.listMessages(user, query))).toBe(
'VAL_001',
);
}
expect(provider.listMessages).not.toHaveBeenCalled();
});
describe('새 메일 확인 · 여러 통 (3단계 — 목록 · 검색은 message-list.service.spec.ts)', () => {
it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => {
provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' });
+22 -30
View File
@@ -42,7 +42,6 @@ import {
SHOW_IMAGES,
type MessageDetailQueryDto,
} from './dto/message-detail-query.dto';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type {
ComposeContentDto,
DraftBodyDto,
@@ -58,6 +57,7 @@ import {
selectInlineImages,
toDataUri,
} from './html/inline-images';
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
@@ -118,6 +118,7 @@ import type {
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { highlightDetail } from './message-highlight';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import {
idempotentMessageId,
@@ -660,34 +661,6 @@ export class MailService {
};
}
/**
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
*/
async listMessages(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageSummary>> {
const text = query.q?.trim() || undefined;
if (query.mailboxId === undefined && text === undefined) {
throw new BusinessException('VAL_001', {
context: { reason: 'mailbox-or-search-required' },
});
}
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text,
});
return toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
});
}
/**
* 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
* 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
@@ -705,6 +678,23 @@ export class MailService {
id: string,
query: MessageDetailQueryDto = {},
): Promise<MessageDetail> {
// 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다)
return highlightDetail(
await this.readMessage(user, id, query),
highlightTermsOf({
text: query.q,
subject: query.subject,
body: query.body,
}),
);
}
/** getMessage 의 본체 — 강조 전의 상세 */
private async readMessage(
user: AuthenticatedUser,
id: string,
query: MessageDetailQueryDto,
): Promise<Omit<MessageDetail, 'searchHighlight'>> {
const {
rawHtmlBody,
isRawHtmlBodyTruncated,
@@ -724,7 +714,9 @@ export class MailService {
}));
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
const withoutHtml = (
isHtmlBodyOmitted: boolean,
): Omit<MessageDetail, 'searchHighlight'> => ({
...detail,
attachments: markInline(NO_INLINE_ATTACHMENTS),
htmlBody: null,
+18
View File
@@ -6,6 +6,11 @@ import type {
LargeFile,
MissingLargeFile,
} from '@/modules/file-storage/file-storage.types';
import type {
MessageDetailHighlight,
MessageListHighlight,
SearchConditions,
} from '@/modules/mail-search/mail-search.types';
/**
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
@@ -49,6 +54,11 @@ export interface PendingSend {
readonly kind: PendingSendKind;
}
/** 목록 항목 (GET /api/messages — 14단계) — 요약 + 검색 강조. 색인으로 찾았을 때만 강조가 있고, 목록 · 엔진 방식 검색이면 null */
export interface MessageListItem extends MessageSummary {
readonly searchHighlight: MessageListHighlight | null;
}
export interface MessageSummary {
readonly id: string;
readonly threadId: string;
@@ -137,6 +147,11 @@ export interface MessageDetail extends MessageSummary {
* 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다
*/
readonly largeFiles: MessageLargeFile[];
/**
* 검색 강조 (14단계 — 같은 검색 쿼리로 열었을 때) — 제목 · 첨부 이름(맞은 것만) · 글 본문의 맞은 곳. htmlBody 는 글 조각에 `<mark>` 를 넣어 준다.
* 검색 쿼리가 없으면 null
*/
readonly searchHighlight: MessageDetailHighlight | null;
}
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
@@ -165,6 +180,7 @@ export interface MessageDetailSource extends Omit<
| 'isBodyTruncated'
| 'attachments'
| 'largeFiles'
| 'searchHighlight'
> {
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
readonly attachments: MailAttachment[];
@@ -189,6 +205,8 @@ export interface MessageListQuery {
readonly cursor?: string;
/** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */
readonly text?: string;
/** 조건 검색의 조건 (14단계 — 엔진 방식은 JMAP 필터로 · 낱말 통째 일치) */
readonly conditions?: SearchConditions;
}
export interface MessagePage {
@@ -0,0 +1,147 @@
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import type { MessageDetail } from './mail.types';
import { highlightDetail } from './message-highlight';
type Detail = Omit<MessageDetail, 'searchHighlight'>;
function detail(overrides: Partial<Detail> = {}): Detail {
return {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
cc: [],
bcc: [],
replyTo: [],
subject: '[공지] 계약 갱신 안내',
preview: '계약 갱신 일정',
receivedAt: '2026-11-02T01:00:00.000Z',
sentAt: '2026-11-02T01:00:00.000Z',
messageId: 'x@example.com',
references: [],
isRead: true,
isFlagged: false,
hasAttachment: true,
size: 2048,
pendingSend: null,
htmlBody: '<p>이번 달 <b>계약</b> 갱신 일정입니다.</p>',
textBody: '이번 달 계약 갱신 일정입니다.',
hasBlockedImages: false,
imagesShown: false,
isHtmlBodyOmitted: false,
isBodyTruncated: false,
attachments: [
{
id: 'p1',
name: '계약서_초안.pdf',
type: 'application/pdf',
size: 100,
isInline: false,
},
{
id: 'p2',
name: '견적.xlsx',
type: 'application/vnd.ms-excel',
size: 100,
isInline: false,
},
],
largeFiles: [],
...overrides,
};
}
const slices = (text: string, ranges: readonly (readonly [number, number])[]) =>
ranges.map(([start, end]) => text.slice(start, end));
describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계 C2)', () => {
it('검색 쿼리가 없으면 searchHighlight 는 null 이고 나머지는 그대로', () => {
const source = detail();
expect(highlightDetail(source, highlightTermsOf({}))).toEqual({
...source,
searchHighlight: null,
});
});
it('검색어는 제목 · 본문(textBody 위치 · htmlBody 의 <mark>) · 맞은 첨부 이름에', () => {
const result = highlightDetail(
detail(),
highlightTermsOf({ text: '계약' }),
);
expect(slices(result.subject, result.searchHighlight!.subject)).toEqual([
'계약',
]);
expect(result.searchHighlight!.attachments).toEqual({ p1: [[0, 2]] });
expect(slices(result.textBody!, result.searchHighlight!.textBody)).toEqual([
'계약',
]);
expect(result.htmlBody).toBe(
'<p>이번 달 <b><mark>계약</mark></b> 갱신 일정입니다.</p>',
);
});
it('제목 조건은 제목에만, 내용 조건은 본문에만 — 첨부 이름은 검색어로만', () => {
const subjectOnly = highlightDetail(
detail(),
highlightTermsOf({ subject: '갱신' }),
);
expect(subjectOnly.searchHighlight).toEqual({
subject: [[8, 10]],
attachments: {},
textBody: [],
});
expect(subjectOnly.htmlBody).toBe(detail().htmlBody);
const bodyOnly = highlightDetail(
detail(),
highlightTermsOf({ body: '일정' }),
);
expect(bodyOnly.searchHighlight!.subject).toEqual([]);
expect(bodyOnly.searchHighlight!.attachments).toEqual({});
expect(
slices(bodyOnly.textBody!, bodyOnly.searchHighlight!.textBody),
).toEqual(['일정']);
expect(bodyOnly.htmlBody).toContain('<mark>일정</mark>');
});
it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => {
const nfd = (text: string) => text.normalize('NFD');
const result = highlightDetail(
detail({
subject: nfd('계약 안내'),
textBody: nfd('계약 내용'),
attachments: [
{
id: 'p1',
name: nfd('계약서.pdf'),
type: 'application/pdf',
size: 1,
isInline: false,
},
],
}),
highlightTermsOf({ text: '계약' }),
);
expect(result.subject).toBe('계약 안내');
expect(result.textBody).toBe('계약 내용');
expect(result.attachments[0].name).toBe('계약서.pdf');
expect(result.searchHighlight).toEqual({
subject: [[0, 2]],
attachments: { p1: [[0, 2]] },
textBody: [[0, 2]],
});
});
it('본문이 없거나 HTML 을 비웠으면(isHtmlBodyOmitted) 있는 쪽에만', () => {
const result = highlightDetail(
detail({ htmlBody: null, isHtmlBodyOmitted: true, textBody: null }),
highlightTermsOf({ text: '계약' }),
);
expect(result.htmlBody).toBeNull();
expect(result.textBody).toBeNull();
expect(result.searchHighlight!.textBody).toEqual([]);
});
});
@@ -0,0 +1,62 @@
import type {
MessageDetailHighlight,
SearchRange,
} from '@/modules/mail-search/mail-search.types';
import type { HighlightTerms } from '@/modules/mail-search/search-plan';
import { matchRanges } from '@/shared/utils/search-text';
import { markHtmlText } from './html/html-mark';
import type { MessageDetail } from './mail.types';
/**
* 읽기 화면의 검색 강조 (순수 함수 — 14단계 설계 C2) — 검색에서 연 메일을 같은 검색 쿼리로 받으면 맞은 곳을 알려 준다.
* - 제목: 검색어 · 제목 조건의 말 / 본문(textBody 위치 · htmlBody 의 `<mark>`): 검색어 · 내용 조건의 말 / 첨부 이름: 검색어의 말(맞은 첨부만)
* - 위치는 NFC 로 맞춘 글 기준이라 강조가 있으면 subject · 첨부 이름 · textBody 를 NFC 로 바꿔 싣는다(화면에는 같게 보인다)
* - htmlBody 는 **정화한 뒤** 의 HTML 에만 `<mark>` 를 넣는다(html/html-mark.ts — 태그 · 속성은 건드리지 않는다)
* 강조할 말이 없으면 searchHighlight 는 null 이고 나머지는 그대로다
*/
export function highlightDetail(
detail: Omit<MessageDetail, 'searchHighlight'>,
terms: HighlightTerms | null,
): MessageDetail {
if (terms === null) return { ...detail, searchHighlight: null };
const subject = detail.subject.normalize('NFC');
const attachments = detail.attachments.map((attachment) => ({
...attachment,
name: attachment.name.normalize('NFC'),
}));
const attachmentRanges: Record<string, SearchRange[]> = {};
if (terms.attachment.length > 0) {
for (const attachment of attachments) {
const ranges = matchRanges(attachment.name, terms.attachment);
if (ranges.length > 0) {
attachmentRanges[attachment.id] = ranges.map(
([start, end]) => [start, end] as const,
);
}
}
}
const textBody = detail.textBody?.normalize('NFC') ?? null;
const highlight: MessageDetailHighlight = {
subject: matchRanges(subject, terms.subject).map(
([start, end]) => [start, end] as const,
),
attachments: attachmentRanges,
textBody:
textBody !== null && terms.body.length > 0
? matchRanges(textBody, terms.body).map(
([start, end]) => [start, end] as const,
)
: [],
};
return {
...detail,
subject,
attachments,
textBody,
htmlBody:
detail.htmlBody === null || terms.body.length === 0
? detail.htmlBody
: markHtmlText(detail.htmlBody, terms.body).html,
searchHighlight: highlight,
};
}
@@ -0,0 +1,405 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import type {
IndexHighlightText,
IndexSearchPage,
MailSearchQueryService,
} from '@/modules/mail-search/mail-search-query.service';
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import { encodeIndexCursor } from '@/modules/mail-search/search-cursor';
import { encodeCursor } from './jmap/jmap-cursor';
import type { MailProvider } from './mail.provider';
import type { MessagePage, MessageSummary } from './mail.types';
import { MessageListService } from './message-list.service';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const ALL_SCOPE: SearchScope = {
mailboxId: null,
excludedMailboxIds: ['trash', 'junk'],
excludedKeywords: ['$ara-sending'],
};
function summary(
id: string,
extra: Partial<MessageSummary> = {},
): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: user.email }],
subject: `${id} 계약 갱신`,
preview: '계약 갱신 일정을 알려 드립니다',
receivedAt: '2026-11-02T01:00:00.000Z',
isRead: false,
isFlagged: false,
hasAttachment: false,
size: 1200,
pendingSend: null,
...extra,
};
}
const emptyEnginePage: MessagePage = {
items: [],
hasNext: false,
nextCursor: null,
total: 0,
};
function setup(options: { ready?: boolean } = {}) {
const provider = {
listMessages: jest.fn().mockResolvedValue(emptyEnginePage),
searchScope: jest.fn().mockResolvedValue(ALL_SCOPE),
getSummaries: jest.fn().mockResolvedValue([]),
};
const searchIndex = {
isSearchReady: jest.fn().mockResolvedValue(options.ready ?? true),
};
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
const indexPage: IndexSearchPage = {
hits: [],
hasNext: false,
nextCursor: null,
total: 0,
};
const searchQuery = {
search: jest.fn().mockResolvedValue(indexPage),
highlightTexts: jest
.fn()
.mockResolvedValue(new Map<string, IndexHighlightText>()),
};
const service = new MessageListService(
provider as unknown as MailProvider,
searchIndex as unknown as MailSearchIndexService,
searchSync as unknown as MailSearchSyncService,
searchQuery as unknown as MailSearchQueryService,
);
return { service, provider, searchIndex, searchSync, searchQuery };
}
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
try {
await run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => {
warnSpy.mockRestore();
});
it('메일함도 검색도 없으면(공백뿐인 검색어 포함) 엔진 · 색인에 묻지 않고 VAL_001', async () => {
const { service, provider, searchIndex } = setup();
for (const query of [
{ limit: 20 },
{ limit: 20, q: ' ' },
{ limit: 20, from: ' ', subject: '' },
]) {
expect(await codeOf(() => service.list(user, query))).toBe('VAL_001');
}
expect(provider.listMessages).not.toHaveBeenCalled();
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
});
it('검색이 아닌 목록은 늘 엔진 — 색인 상태를 묻지 않고, 항목의 searchHighlight 는 null', async () => {
const { service, provider, searchIndex } = setup();
provider.listMessages.mockResolvedValue({
items: [summary('m1')],
hasNext: true,
nextCursor: 'next',
total: 9,
});
const page = await service.list(user, {
mailboxId: 'inbox',
limit: 20,
q: ' ',
});
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
mailboxId: 'inbox',
limit: 20,
cursor: undefined,
text: undefined,
conditions: undefined,
});
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
expect(page.items).toEqual([{ ...summary('m1'), searchHighlight: null }]);
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: 'next',
total: 9,
});
});
it('색인이 준비되지 않았으면 엔진 방식 — 검색어 앞뒤 공백을 지우고, 준 조건만 넘긴다', async () => {
const { service, provider, searchQuery } = setup({ ready: false });
await service.list(user, { limit: 20, q: ' 이전 ' });
await service.list(user, {
limit: 20,
from: ' 이도윤 ',
hasAttachment: 'true',
after: '2026-10-31T15:00:00.000Z',
});
expect(provider.listMessages).toHaveBeenNthCalledWith(1, user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '이전',
conditions: undefined,
});
expect(provider.listMessages).toHaveBeenNthCalledWith(2, user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: undefined,
conditions: {
from: '이도윤',
after: '2026-10-31T15:00:00.000Z',
hasAttachment: true,
},
});
expect(searchQuery.search).not.toHaveBeenCalled();
});
it('색인이 준비됐으면 색인 방식 — 바로 전 맞추기 → 엔진의 범위 → 색인 찾기 → 찾은 쪽의 요약은 엔진에서, 찾은 차례대로', async () => {
const { service, provider, searchSync, searchQuery } = setup();
const order: string[] = [];
searchSync.syncBeforeRead.mockImplementation(() => {
order.push('sync');
return Promise.resolve(true);
});
provider.searchScope.mockImplementation(() => {
order.push('scope');
return Promise.resolve(ALL_SCOPE);
});
searchQuery.search.mockImplementation(() => {
order.push('search');
return Promise.resolve({
hits: [
{ id: 'm2', receivedAt: new Date('2026-11-02T02:00:00Z') },
{ id: 'm1', receivedAt: new Date('2026-11-02T01:00:00Z') },
],
hasNext: true,
nextCursor: 'index-next',
total: 42,
});
});
// 엔진은 차례를 보장하지 않는다
provider.getSummaries.mockResolvedValue([summary('m1'), summary('m2')]);
const page = await service.list(user, { limit: 2, q: '계약' });
expect(order).toEqual(['sync', 'scope', 'search']);
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
expect(provider.searchScope).toHaveBeenCalledWith(user.email, undefined);
const [, scope, criteria, plan, pageOptions] = searchQuery.search.mock
.calls[0] as unknown[];
expect(scope).toBe(ALL_SCOPE);
expect(criteria).toMatchObject({ text: '계약', hasAttachment: false });
expect(plan).toMatchObject({ grams: ['계약'] });
expect(pageOptions).toEqual({ limit: 2, cursor: undefined });
expect(provider.getSummaries).toHaveBeenCalledWith(user.email, [
'm2',
'm1',
]);
expect(page.items.map((item) => item.id)).toEqual(['m2', 'm1']);
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: 'index-next',
total: 42,
});
expect(provider.listMessages).not.toHaveBeenCalled();
});
it('색인 방식의 강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기(색인의 본문), 본문이 없으면 엔진의 미리 보기', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockResolvedValue({
hits: [
{ id: 'm1', receivedAt: new Date() },
{ id: 'm2', receivedAt: new Date() },
],
hasNext: false,
nextCursor: null,
total: 2,
});
provider.getSummaries.mockResolvedValue([
summary('m1', { subject: '계약 갱신 안내' }),
summary('m2', { subject: '안내', preview: '갱신 계약서 초안' }),
]);
searchQuery.highlightTexts.mockResolvedValue(
new Map([
[
'm1',
{
subject: '계약 갱신 안내',
bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
preview: '안녕하세요.',
},
],
]),
);
const page = await service.list(user, { limit: 20, q: '계약' });
expect(page.items[0].searchHighlight).toEqual({
subject: [[0, 2]],
preview: {
text: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
ranges: [[12, 14]],
},
});
expect(page.items[1].searchHighlight).toEqual({
subject: [],
preview: { text: '갱신 계약서 초안', ranges: [[3, 5]] },
});
});
it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockResolvedValue({
hits: ['m1', 'm2', 'm3'].map((id) => ({ id, receivedAt: new Date() })),
hasNext: false,
nextCursor: null,
total: 3,
});
provider.getSummaries.mockResolvedValue([
summary('m1', { mailboxIds: ['trash'] }),
summary('m2', { mailboxIds: ['trash', 'project'] }),
]);
const all = await service.list(user, { limit: 20, q: '계약' });
expect(all.items.map((item) => item.id)).toEqual(['m2']);
provider.searchScope.mockResolvedValue({
mailboxId: 'project',
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
});
provider.getSummaries.mockResolvedValue([
summary('m1', { mailboxIds: ['inbox'] }),
summary('m2', { mailboxIds: ['project'] }),
]);
const inProject = await service.list(user, {
mailboxId: 'project',
limit: 20,
q: '계약',
});
expect(provider.searchScope).toHaveBeenLastCalledWith(
user.email,
'project',
);
expect(inProject.items.map((item) => item.id)).toEqual(['m2']);
});
it('찾은 것이 없으면 엔진에 요약을 묻지 않는다', async () => {
const { service, provider, searchQuery } = setup();
const page = await service.list(user, { limit: 20, subject: '없는말' });
expect(provider.getSummaries).not.toHaveBeenCalled();
expect(searchQuery.highlightTexts).not.toHaveBeenCalled();
expect(page).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
});
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 색인으로(준비 전이어도), 엔진 커서는 엔진으로(준비됐어도). 색인 상태를 묻지 않는다', async () => {
const indexCursor = encodeIndexCursor(
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
'scope',
);
const engineCursor = encodeCursor(
{ anchor: 'm9', position: 20 },
{ text: '계약' },
);
const notReady = setup({ ready: false });
await notReady.service.list(user, {
limit: 20,
q: '계약',
cursor: indexCursor,
});
expect(notReady.searchQuery.search).toHaveBeenCalledTimes(1);
expect(notReady.provider.listMessages).not.toHaveBeenCalled();
const ready = setup({ ready: true });
await ready.service.list(user, {
limit: 20,
q: '계약',
cursor: engineCursor,
});
expect(ready.provider.listMessages).toHaveBeenCalledTimes(1);
expect(ready.searchQuery.search).not.toHaveBeenCalled();
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
expect(ready.searchIndex.isSearchReady).not.toHaveBeenCalled();
});
it('첫 쪽의 색인 찾기가 실패하면(DB 장애) 엔진 방식으로 찾는다 — 경고에 검색 글 · 주소 원문을 남기지 않는다', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockRejectedValue(
Object.assign(new Error('connection terminated'), { code: '57P01' }),
);
provider.listMessages.mockResolvedValue({
items: [summary('m1')],
hasNext: false,
nextCursor: null,
total: 1,
});
const page = await service.list(user, { limit: 20, q: '비밀 계약' });
expect(page.items.map((item) => item.searchHighlight)).toEqual([null]);
expect(provider.listMessages).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
const [line] = warnSpy.mock.calls[0] as [string];
expect(line).toContain('[SYS_001]');
expect(line).toContain('Error/57P01');
expect(line).not.toContain('비밀');
expect(line).not.toContain('계약');
expect(line).not.toContain(user.email);
});
it('코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001)는 그대로 — 엔진 방식으로 다시 찾지 않는다', async () => {
for (const code of ['RES_001', 'MAIL_001'] as const) {
const { service, provider } = setup();
provider.searchScope.mockRejectedValue(new BusinessException(code));
expect(
await codeOf(() =>
service.list(user, { mailboxId: 'zz', limit: 20, q: '계약' }),
),
).toBe(code);
expect(provider.listMessages).not.toHaveBeenCalled();
}
});
it('다음 쪽(색인 커서)의 색인 찾기가 실패하면 그대로 실패한다 — 엔진 커서와 섞지 않는다', async () => {
const { service, provider, searchQuery } = setup();
searchQuery.search.mockRejectedValue(new Error('db down'));
const cursor = encodeIndexCursor(
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
'scope',
);
expect(
await codeOf(() => service.list(user, { limit: 20, q: '계약', cursor })),
).toBe('NOT_BUSINESS');
expect(provider.listMessages).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,212 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import {
MailSearchQueryService,
type IndexHighlightText,
} from '@/modules/mail-search/mail-search-query.service';
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type {
SearchConditions,
SearchCriteria,
SearchScope,
} from '@/modules/mail-search/mail-search.types';
import { cursorMethod } from '@/modules/mail-search/search-cursor';
import {
highlightedSubject,
listHighlight,
} from '@/modules/mail-search/search-highlight';
import {
isSearchCriteria,
planSearch,
} from '@/modules/mail-search/search-plan';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
HAS_ATTACHMENT_ONLY,
type MessageListQueryDto,
} from './dto/message-list-query.dto';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { MessageListItem, MessageSummary } from './mail.types';
/** 앞뒤 공백을 지운 글 — 비면 없는 것 (DTO 가 이미 지우지만 HTTP 를 거치지 않는 호출도 같게) */
function trimmed(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}
/** 검색 쿼리 → 검색 조건 */
export function criteriaOf(query: MessageListQueryDto): SearchCriteria {
return {
text: trimmed(query.q),
from: trimmed(query.from),
to: trimmed(query.to),
subject: trimmed(query.subject),
body: trimmed(query.body),
after: query.after,
before: query.before,
hasAttachment: query.hasAttachment === HAS_ATTACHMENT_ONLY,
};
}
/** 검색어 밖의 조건 — 하나도 없으면 undefined (엔진 방식 검색의 필터 · 커서 지문이 예전과 같다) */
function conditionsOf(criteria: SearchCriteria): SearchConditions | undefined {
const conditions: SearchConditions = {
...(criteria.from ? { from: criteria.from } : {}),
...(criteria.to ? { to: criteria.to } : {}),
...(criteria.subject ? { subject: criteria.subject } : {}),
...(criteria.body ? { body: criteria.body } : {}),
...(criteria.after ? { after: criteria.after } : {}),
...(criteria.before ? { before: criteria.before } : {}),
...(criteria.hasAttachment === true ? { hasAttachment: true } : {}),
};
return Object.keys(conditions).length > 0 ? conditions : undefined;
}
/** 엔진이 지금 그 메일을 범위 안에 두나 — 색인이 몇 초 늦을 수 있어 보이기 전에 엔진의 메일함으로 한 번 더 본다 */
function inScope(summary: MessageSummary, scope: SearchScope): boolean {
if (scope.mailboxId !== null) {
return summary.mailboxIds.includes(scope.mailboxId);
}
const excluded = new Set(scope.excludedMailboxIds);
return summary.mailboxIds.some((mailboxId) => !excluded.has(mailboxId));
}
function errorKind(error: unknown): string {
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name}/${code}` : name;
}
/**
* 메일 목록 · 검색 (GET /api/messages — 계약 2절 "검색", 14단계 설계 C1).
* - mailboxId 와 검색(검색어 q · 조건 하나라도) 가운데 하나는 있어야 한다 — 둘 다 없으면 VAL_001. 검색 글 · 조건은 어떤 로그에도 남기지 않는다
* - 검색 방식: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로), 없으면 색인이 준비됐을 때(ready · 지금 판) 색인,
* 아니면 엔진(낱말 통째 일치 · 조건은 JMAP 필터)
* - 색인 방식: 바로 전 맞추기(PRE_READ_SYNC_WAIT_MS 안 — 넘으면 지금 색인으로) → 엔진에 범위(메일함 · 빼는 메일함) → 색인 찾기 → 찾은 쪽의 요약은 엔진에서
* (목록과 같은 모양 · 지금 상태) → 엔진이 범위 밖으로 둔 메일(그사이 옮김)은 뺀다 → 제목 · 미리 보기 강조. 첫 쪽의 색인 찾기가 DB 장애로 실패하면 엔진 방식으로 찾는다
* 목록 항목은 늘 searchHighlight 를 싣는다 — 색인 방식 검색에서만 값이 있고 그 밖에는 null
*/
@Injectable()
export class MessageListService {
private readonly logger = new Logger(MessageListService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
private readonly searchIndex: MailSearchIndexService,
private readonly searchSync: MailSearchSyncService,
private readonly searchQuery: MailSearchQueryService,
) {}
async list(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageListItem>> {
const criteria = criteriaOf(query);
const searching = isSearchCriteria(criteria);
if (query.mailboxId === undefined && !searching) {
throw new BusinessException('VAL_001', {
context: { reason: 'mailbox-or-search-required' },
});
}
if (!searching) return this.fromEngine(user, query, criteria);
if (query.cursor) {
return cursorMethod(query.cursor) === 'index'
? this.fromIndex(user, query, criteria)
: this.fromEngine(user, query, criteria);
}
if (!(await this.searchIndex.isSearchReady(user.email))) {
return this.fromEngine(user, query, criteria);
}
try {
return await this.fromIndex(user, query, criteria);
} catch (error) {
// 코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001 등)는 그대로 — 색인(DB)이 실패했으면 엔진으로 찾는다
if (error instanceof BusinessException) throw error;
this.logger.warn(
`[SYS_001]${traceTag()} 검색 색인으로 찾지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
);
return this.fromEngine(user, query, criteria);
}
}
/** 엔진 방식 — 목록 · 색인 준비 전의 검색 (강조 없음) */
private async fromEngine(
user: AuthenticatedUser,
query: MessageListQueryDto,
criteria: SearchCriteria,
): Promise<PaginatedResult<MessageListItem>> {
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text: criteria.text,
conditions: conditionsOf(criteria),
});
return toPaginatedResult(
page.items.map((item) => ({ ...item, searchHighlight: null })),
{
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
},
);
}
/** 색인 방식 — 부분 일치 · 조건 · 강조 */
private async fromIndex(
user: AuthenticatedUser,
query: MessageListQueryDto,
criteria: SearchCriteria,
): Promise<PaginatedResult<MessageListItem>> {
// 방금 온 메일도 찾게 — 기다리는 시간을 넘으면 지금 색인으로 답한다 (맞추기는 뒤에서 이어 한다)
await this.searchSync.syncBeforeRead(user.email);
const scope = await this.provider.searchScope(user.email, query.mailboxId);
const plan = planSearch(criteria);
const found = await this.searchQuery.search(
user.email,
scope,
criteria,
plan,
{ limit: query.limit, cursor: query.cursor },
);
const ids = found.hits.map((hit) => hit.id);
const [summaries, texts] =
ids.length > 0
? await Promise.all([
this.provider.getSummaries(user.email, ids),
this.searchQuery.highlightTexts(user.email, ids),
])
: [[], new Map<string, IndexHighlightText>()];
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
const items = ids.flatMap((id): MessageListItem[] => {
const summary = byId.get(id);
if (!summary || !inScope(summary, scope)) return [];
const subject = highlightedSubject(summary.subject);
return [
{
...summary,
subject,
searchHighlight: listHighlight(
{
subject,
preview: summary.preview,
bodyText: texts.get(id)?.bodyText ?? null,
},
plan.highlight,
),
},
];
});
return toPaginatedResult(items, {
hasNext: found.hasNext,
nextCursor: found.nextCursor,
total: found.total,
});
}
}
+18 -9
View File
@@ -68,6 +68,7 @@ import { UpdateMessageDto } from './dto/update-message.dto';
import { MailIdPipe } from './mail-id.pipe';
import { MailLargeFilesService } from './mail-large-files.service';
import { MailService } from './mail.service';
import { MessageListService } from './message-list.service';
import type { AttachmentContent } from './mail.types';
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
@@ -94,24 +95,27 @@ export class MessageController {
constructor(
private readonly mail: MailService,
private readonly largeFiles: MailLargeFilesService,
private readonly messageList: MessageListService,
) {}
@Get()
@ApiOperation({
summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)',
description:
'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
'엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' +
'(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' +
'낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' +
'붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.',
'mailboxId 와 검색(q 나 조건 from · to · subject · body · after · before · hasAttachment 하나라도) 가운데 하나는 있어야 한다. 메일함 없이 검색하면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
'검색 방식(14단계): 그 직원의 색인이 준비됐으면(GET /api/search-index 의 ready) 자체 색인으로 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 · 전각 없이, 줄바꿈으로 갈린 구절도) — 항목에 searchHighlight 가 있다. ' +
'한 글자 낱말뿐이면 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름에서만 찾는다(본문은 훑지 않는다 — 두 글자 넘는 낱말과 함께면 본문도). ' +
'색인 방식은 한 요청이 확인하는 메일 수에 상한이 있어, 쪽이 limit 보다 적거나 비어도 hasNext · nextCursor 가 있으면 이어 찾는다. total 은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). ' +
'준비 중이면 엔진 검색 — 낱말이 통째로 같을 때만 맞고, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다(조건 칸도 같다), searchHighlight 는 null. ' +
'커서에는 방식이 실려 쪽을 넘기는 중에 준비가 끝나도 처음 방식대로 이어 간다. 낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). ' +
'문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 글을 준 칸에 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). 발송 중인 내부 초안은 빠진다.',
})
@ApiStandardResponse(MessagePageDto, {
description: '메일 목록 (total 포함)',
})
@ApiErrorResponse(
'VAL_001',
`mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`,
`mailboxId 와 검색이 둘 다 없음 · 검색 글 칸(q · from · to · subject · body)이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과 · after · before 가 ISO 8601 UTC 가 아님 · hasAttachment 가 true 가 아님 · 다른 메일함·검색어·조건의 커서 · limit 범위 초과`,
)
@ApiErrorResponse(
'RES_001',
@@ -122,7 +126,7 @@ export class MessageController {
@CurrentUser() user: AuthenticatedUser,
@Query() query: MessageListQueryDto,
) {
return this.mail.listMessages(user, query);
return this.messageList.list(user, query);
}
@Patch()
@@ -162,10 +166,15 @@ export class MessageController {
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` +
'images=show 면 외부 이미지(<img> 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' +
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).',
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계). ' +
'검색 쿼리(q · subject · body — 14단계)를 함께 보내면 searchHighlight 에 제목 · 첨부 이름 · textBody 의 맞은 곳을 싣고, htmlBody 의 맞은 글 조각에 <mark> 를 넣는다(목록과 같은 접기 · 부분 일치, 태그를 건너는 말은 강조하지 않는다). ' +
'검색 쿼리의 검사는 목록과 같다 — from · to 는 받기만 하고 강조하지 않는다.',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리')
@ApiErrorResponse(
'VAL_001',
`모르는 images 값 · 모르는 쿼리(cursor · limit · after · before · hasAttachment 포함) · 검색 글 칸이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과`,
)
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
get(
+84
View File
@@ -0,0 +1,84 @@
/**
* 검색어 나누기 (순수 함수 — 계약 2절 "검색") — 엔진 검색(modules/mail/jmap/search-terms.ts)과 자체 색인 찾기(modules/mail-search — 14단계)가 함께 쓴다.
* 낱말 · 구절로 나누고 앞뒤 문장부호를 뗀다. 엔진 쪽은 여기에 조사 형태를 붙이고, 색인 쪽은 접기(search-text.ts) 뒤 부분 일치로 찾는다.
*/
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 */
export const SEARCH_WORD_SEPARATOR = /\s+/u;
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
const QUOTE = '"';
/**
* NUL(U+0000) — PostgreSQL 글에 넣을 수 없다(22021). 색인은 저장할 때 U+FFFD 로 1:1 바꾸므로(index-document.ts 의 storableText)
* 검색 글도 같게 바꿔 찾는다 (계약 2절 "검색 — 14단계") — 엔진 방식 · 강조도 같은 낱말을 쓴다
*/
const NUL = String.fromCharCode(0);
const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd);
/**
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
*/
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
/**
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
*/
const WORD_EDGE_PUNCTUATION =
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
function coreWord(word: string): string {
return word.replace(WORD_EDGE_PUNCTUATION, '');
}
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
export interface SearchTerm {
readonly kind: 'word' | 'phrase';
readonly text: string;
}
function splitWords(text: string): string[] {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
}
/**
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
* - NUL 은 U+FFFD 로 1:1 바꾼다 (색인의 저장과 같다 — 위치 · 길이는 그대로)
*/
export function parseSearchTerms(query: string): SearchTerm[] {
const segments = query
.normalize('NFC')
.replaceAll(NUL, REPLACEMENT_CHARACTER)
.split(QUOTE);
const terms: SearchTerm[] = [];
const seen = new Set<string>();
const add = (kind: SearchTerm['kind'], text: string) => {
const key = `${kind}\n${text}`;
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
seen.add(key);
terms.push({ kind, text });
};
segments.forEach((segment, index) => {
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
const isPhrase = index % 2 === 1 && index < segments.length - 1;
if (isPhrase) {
add('phrase', splitWords(segment).join(' '));
return;
}
for (const word of splitWords(segment)) add('word', coreWord(word));
});
return terms;
}
+29 -7
View File
@@ -4,9 +4,11 @@
* frontend `src/test-utils/search-text-samples.ts`)를 두 쪽 테스트에 둔다. 한쪽을 고치면 둘 다 고친다.
*
* 접기(foldText)는 먼저 NFC 로 맞춘 뒤 **코드 포인트 하나씩** 바꾼다 — 전각 ASCII(! ~ ~) → 반각, 소문자(결과가 코드 포인트 하나이고
* UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로). 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다 →
* 접은 글에서 찾은 위치를 원문에 그대로 쓴다(강조 · 미리 보기). NFKC 는 쓰지 않는다 — ㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다.
* 공백은 바꾸지 않는다(위치를 지키려고) — 낱말 나누기 · 묶음에서만 공백으로 본다.
* UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로), 공백류(줄바꿈 · 탭 · NBSP · U+3000 등 정규식 `\s` 의 글자) → 띄어쓰기(U+0020) 하나.
* 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다 → 접은 글에서 찾은 위치를 원문에 그대로 쓴다(강조 · 미리 보기).
* NFKC 는 쓰지 않는다 — ㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다. 공백은 1:1 로만 바꾼다(이어진 공백을 하나로 줄이지 않는다) —
* 구절 "계약 갱신" 은 줄바꿈으로 갈린 글에도 맞지만, 띄어쓰기 두 칸으로 갈린 글에는 맞지 않는다(알려진 한계).
* ⚠️ 접기 · 묶음 규칙을 바꾸면 색인 판(mail-search.constants.ts 의 INDEX_FORMAT)을 올린다 — 옛 판으로 만든 색인은 다시 채운다
*
* 모든 함수는 입력을 한 번(맞은 곳은 낱말마다 한 번) 훑는다 — 길이에 비례한다.
*/
@@ -57,6 +59,10 @@ function hasNoCase(code: number): boolean {
);
}
/** 공백류를 바꾼 글자 — 띄어쓰기 하나 */
const SPACE = ' ';
const SPACE_CODE = 0x20;
/** ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 */
function foldAsciiCode(code: number): number {
return code >= ASCII_UPPER_A && code <= ASCII_UPPER_Z
@@ -82,7 +88,7 @@ function foldCodePoint(code: number, char: string): string | null {
}
/**
* 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자.
* 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기.
* 결과는 `text.normalize('NFC')` 와 UTF-16 길이 · 위치가 같다
*/
export function foldText(text: string): string {
@@ -102,7 +108,10 @@ export function foldText(text: string): string {
point = source.codePointAt(index) ?? code;
}
let replacement: string | null;
if (code < ASCII_LIMIT) {
if (isSpaceCode(code)) {
// 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1
replacement = code === SPACE_CODE ? null : SPACE;
} else if (code < ASCII_LIMIT) {
const folded = foldAsciiCode(code);
replacement = folded === code ? null : String.fromCharCode(folded);
} else {
@@ -194,9 +203,22 @@ export function matchRanges(
terms: readonly string[],
limit: number = MAX_MATCH_RANGES,
): MatchRange[] {
const haystack = foldText(original);
return matchFoldedRanges(foldText(original), foldTerms(terms), limit);
}
/** 찾는 말들을 접는다 — 같은 말은 한 번, 빈 말은 뺀다 (여러 글에 같은 말을 맞출 때 한 번만 접게) */
export function foldTerms(terms: readonly string[]): string[] {
return [...new Set(terms.map(foldText))].filter((term) => term !== '');
}
/** matchRanges 의 접은 글 판 — `haystack` 은 foldText 한 글, `terms` 는 foldTerms 한 말 */
export function matchFoldedRanges(
haystack: string,
terms: readonly string[],
limit: number = MAX_MATCH_RANGES,
): MatchRange[] {
const found: MatchRange[] = [];
for (const term of new Set(terms.map(foldText))) {
for (const term of terms) {
if (term === '') continue;
let count = 0;
for (