generated from ttipo/project-base
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:
@@ -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('문자 참조를 풀어 맞추고 다시 적는다 — & · < · > · · 10진 · 16진', () => {
|
||||
expect(marked('<p>A&B 계약 <중요></p>', 'a&b', '<중요>')).toBe(
|
||||
'<p><mark>A&B</mark> 계약 <mark><중요></mark></p>',
|
||||
);
|
||||
// 는 띄어쓰기와 같이 접힌다 — 구절이 맞는다 (다시 적을 때는 글자 그대로)
|
||||
expect(marked('<p>계약 갱신</p>', '계약 갱신')).toBe(
|
||||
'<p><mark>계약\u00a0갱신</mark></p>',
|
||||
);
|
||||
expect(marked('<p>계약 건</p>', '계약')).toBe(
|
||||
'<p><mark>계약</mark> 건</p>',
|
||||
);
|
||||
// 맞은 곳이 없는 조각은 원래 글자 그대로 (참조를 풀어 다시 적지 않는다)
|
||||
expect(marked('<p>계 "x"</p>', '없는말')).toBe(
|
||||
'<p>계 "x"</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('모르는 참조 · 닫히지 않은 & 가 있는 조각은 그대로 둔다 (정화 결과에는 나오지 않는다)', () => {
|
||||
expect(marked('<p>© 계약</p>', '계약')).toBe('<p>© 계약</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 > 계약 "b""><a href="https://example.com/?q=계약&x=1">계약 바로가기</a> 계약&견적 <b>중요</b></div>',
|
||||
);
|
||||
const result = markHtmlText(html, ['계약']);
|
||||
|
||||
expect(result.marks).toBe(2);
|
||||
// 속성은 정화 결과 그대로
|
||||
expect(result.html).toContain('title="a > 계약 "b""');
|
||||
expect(result.html).toContain('href="https://example.com/?q=계약&x=1"');
|
||||
expect(result.html).toContain('<mark>계약</mark> 바로가기');
|
||||
expect(result.html).toContain('<mark>계약</mark>&견적');
|
||||
// <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>이번 분기 계약 갱신과 단가 조정 일정을 공유합니다 & 확인 부탁드립니다.</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>이번 분기 일정을 공유합니다 & 확인 부탁드립니다.<br><b>굵게</b> <a href="https://example.com/a?b=1&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>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
foldTerms,
|
||||
foldText,
|
||||
matchFoldedRanges,
|
||||
} from '@/shared/utils/search-text';
|
||||
|
||||
/**
|
||||
* 정화한 메일 HTML 의 글 조각에 `<mark>` 를 넣는다 (순수 함수 — 14단계 설계 C2, 읽기 화면의 검색 강조).
|
||||
* **정화한 뒤에만** 부른다(sanitize-mail-html.ts 의 결과 — 태그가 닫혀 있고 속성 값의 `>` 는 `>` 다). 다시 정화하지 않는다 — 넣는 것은 속성 없는 `<mark>` 뿐이다
|
||||
* (mark 는 받은 메일 정화의 허용 태그다).
|
||||
*
|
||||
* - 태그 안(이름 · 속성 · style)은 건드리지 않는다 — `<` 부터 `>` 까지는 그대로 옮긴다. 주석(`<!-- … -->`)과 글을 그대로 두어야 하는 요소
|
||||
* (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로
|
||||
* - 글 조각마다 문자 참조를 풀어(& · < · > · " · ' · · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다
|
||||
* (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(& · < · > 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다
|
||||
* - 태그를 건너는 말(`계<b>약</b>`)은 강조하지 않는다 — 글 조각 안에서만
|
||||
* - 넣는 `<mark>` 는 `limit`(MAX_HTML_MARKS)개까지
|
||||
* 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다)
|
||||
*/
|
||||
|
||||
/** 한 본문에 넣는 `<mark>` 의 수 */
|
||||
export const MAX_HTML_MARKS = 1_000;
|
||||
|
||||
/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */
|
||||
const MAX_REFERENCE_LENGTH = 32;
|
||||
|
||||
/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 & · < · > 만 다시 적는다)과 흔한 것 */
|
||||
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 === '&' ? '&' : char === '<' ? '<' : '>',
|
||||
);
|
||||
}
|
||||
|
||||
/** 글 조각 하나 — 맞은 곳에 `<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 최대 개수 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)를 함께 준다
|
||||
*/
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -128,7 +128,9 @@ import {
|
||||
type MailIndexSource,
|
||||
} from '@/modules/mail-search/mail-index-source';
|
||||
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
|
||||
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
|
||||
import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { toIndexRow } from '@/modules/mail-search/index-document';
|
||||
import {
|
||||
SystemSettingsService,
|
||||
@@ -602,6 +604,37 @@ function indexDocument(overrides: Partial<IndexDocument>): IndexDocument {
|
||||
};
|
||||
}
|
||||
|
||||
/** 대역 엔진이 색인 검색에서 빼는 메일함 (휴지통 · 스팸메일함 역할 — 14단계 2차) */
|
||||
const SEARCH_EXCLUDED_MAILBOX = 'idxtrash';
|
||||
|
||||
/** 색인 문서의 주소 → 요약의 주소 (주소가 없는 것은 뺀다) */
|
||||
function mailAddressesOf(
|
||||
addresses: readonly { name: string | null; email: string | null }[],
|
||||
): { name: string | null; email: string }[] {
|
||||
return addresses.flatMap(({ name, email }) =>
|
||||
email === null ? [] : [{ name, email }],
|
||||
);
|
||||
}
|
||||
|
||||
/** 대역 엔진의 요약 — 색인 문서와 같은 메일 (14단계 2차 — 색인으로 찾은 쪽의 요약은 엔진에서 읽는다) */
|
||||
function summaryOfDocument(document: IndexDocument): MessageSummary {
|
||||
return {
|
||||
id: document.id,
|
||||
threadId: document.threadId ?? document.id,
|
||||
mailboxIds: [...document.mailboxIds],
|
||||
from: mailAddressesOf(document.from)[0] ?? null,
|
||||
to: mailAddressesOf(document.to),
|
||||
subject: document.subject,
|
||||
preview: document.preview,
|
||||
receivedAt: new Date(document.receivedAt).toISOString(),
|
||||
isRead: document.keywords.includes('$seen'),
|
||||
isFlagged: document.keywords.includes('$flagged'),
|
||||
hasAttachment: document.hasAttachment,
|
||||
size: document.size,
|
||||
pendingSend: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록 응답의 data — 계약의 { items, pageInfo } */
|
||||
interface Page<T> {
|
||||
items: T[];
|
||||
@@ -713,6 +746,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
|
||||
const listQueries: { user: string; query: MessageListQuery }[] = [];
|
||||
/** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */
|
||||
const summaryRequests: { user: string; ids: readonly string[] }[] = [];
|
||||
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
||||
const bulkUpdates: {
|
||||
user: string;
|
||||
@@ -1235,6 +1270,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
// 14단계 2차 — 색인 검색의 범위 · 찾은 쪽의 요약 (엔진 쪽 판단은 jmap-mail.provider.spec.ts 가 본다)
|
||||
searchScope: (
|
||||
_user: string,
|
||||
mailboxId?: string,
|
||||
): Promise<SearchScope> => {
|
||||
if (mailboxId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-mailbox-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
mailboxId === undefined
|
||||
? {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
}
|
||||
: {
|
||||
mailboxId,
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
},
|
||||
);
|
||||
},
|
||||
getSummaries: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageSummary[]> => {
|
||||
summaryRequests.push({ user, ids });
|
||||
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
|
||||
const byId = new Map(emails.map((email) => [email.id, email]));
|
||||
return Promise.resolve(
|
||||
ids.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [summaryOfDocument(email)] : [];
|
||||
}),
|
||||
);
|
||||
},
|
||||
updateMessages: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
@@ -7840,6 +7915,498 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('14단계 2차 — 색인으로 찾기 · 조건 · 강조 (GET /api/messages · GET /api/messages/{id})', () => {
|
||||
const ME = 'search.query@e2e.arasystem.test';
|
||||
const SEOJUN = { name: '박서준', email: 'seojun.park@e2e.arasystem.test' };
|
||||
const DOCUMENTS = [
|
||||
indexDocument({
|
||||
id: 'm-meeting',
|
||||
subject: '회의 일정',
|
||||
bodyText: '내일 회의는 3층 회의실에서 합니다',
|
||||
preview: '내일 회의는 3층 회의실에서 합니다',
|
||||
from: [SEOJUN],
|
||||
to: [{ name: '김아라', email: ME }],
|
||||
receivedAt: '2026-11-05T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-contract',
|
||||
subject: '[공지] 11월 계약서',
|
||||
bodyText: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||||
preview: '계약서를 보내 드립니다.',
|
||||
attachmentNames: ['계약서.PDF'],
|
||||
hasAttachment: true,
|
||||
cc: [{ name: 'Partner', email: 'partner@example.com' }],
|
||||
receivedAt: '2026-11-04T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-renewal',
|
||||
subject: '회의록',
|
||||
bodyText: '계약 갱신 회의 내용을 정리했습니다',
|
||||
preview: '계약 갱신 회의 내용을 정리했습니다',
|
||||
receivedAt: '2026-11-03T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-floor',
|
||||
subject: '7층 공사 안내',
|
||||
bodyText: '공사 기간 동안 엘리베이터를 쓸 수 없습니다',
|
||||
receivedAt: '2026-11-02T01:00:00Z',
|
||||
}),
|
||||
// 휴지통에만 든 메일 — 메일함을 정하지 않은 검색에서 빠진다
|
||||
indexDocument({
|
||||
id: 'm-trash',
|
||||
subject: '지난 계약 해지',
|
||||
bodyText: '계약 해지 통보',
|
||||
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||||
receivedAt: '2026-11-01T01:00:00Z',
|
||||
}),
|
||||
];
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
subject: string;
|
||||
searchHighlight: {
|
||||
subject: [number, number][];
|
||||
preview: { text: string; ranges: [number, number][] };
|
||||
} | null;
|
||||
}
|
||||
|
||||
const list = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/messages')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
const idsOf = async (params: Record<string, string | number>) =>
|
||||
dataOf<Page<ListItem>>(await list(params).expect(200)).items.map(
|
||||
(item) => item.id,
|
||||
);
|
||||
const setStatus = (status: 'ready' | 'building') =>
|
||||
dataSource.query(
|
||||
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
|
||||
[ME, status],
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
// 상태 줄은 준비 상태를 물을 때 생긴다 — 채우기로 ready (색인 판은 지금 판)
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
fakeIndexSource.mailboxes.set(ME, { state: 'q1', emails: DOCUMENTS });
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
fakeIndexSource.mailboxes.delete(ME);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(`DELETE FROM "${table}" WHERE user_email = $1`, [
|
||||
ME,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('색인이 준비되면 부분 일치로 찾는다 — 휴지통 · 스팸메일함에만 든 메일은 빼고 받은 시각 최신부터, 요약은 엔진에서 · 첫 쪽의 total', async () => {
|
||||
const queriesBefore = listQueries.length;
|
||||
|
||||
const res = await list({ q: '계약' }).expect(200);
|
||||
const page = dataOf<Page<ListItem>>(res);
|
||||
|
||||
expect(page.items.map((item) => item.id)).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 엔진의 목록 검색은 묻지 않았다 — 찾은 쪽의 요약만
|
||||
expect(listQueries.length).toBe(queriesBefore);
|
||||
expect(summaryRequests.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['m-contract', 'm-renewal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳 (UTF-16 [시작, 끝))', async () => {
|
||||
const [contract] = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약 갱신' }).expect(200),
|
||||
).items;
|
||||
|
||||
expect(contract.id).toBe('m-contract');
|
||||
expect(contract.searchHighlight).toEqual({
|
||||
subject: [[9, 11]],
|
||||
preview: {
|
||||
text: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||||
ranges: [
|
||||
[0, 2],
|
||||
[14, 16],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('조건 — 보낸 사람 · 받는 사람(참조 포함) · 제목 · 내용 · 기간 · 첨부, 검색어 없이도 · 검색어와 함께면 모두 맞아야', async () => {
|
||||
expect(await idsOf({ from: '박서준' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ from: 'seojun' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ to: 'partner' })).toEqual(['m-contract']);
|
||||
expect(await idsOf({ subject: '회의' })).toEqual([
|
||||
'm-meeting',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(await idsOf({ body: '회의실' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ hasAttachment: 'true' })).toEqual(['m-contract']);
|
||||
expect(
|
||||
await idsOf({
|
||||
after: '2026-11-03T00:00:00.000Z',
|
||||
before: '2026-11-04T01:00:00.000Z',
|
||||
}),
|
||||
).toEqual(['m-renewal']);
|
||||
expect(await idsOf({ q: '회의', from: '이도윤' })).toEqual(['m-renewal']);
|
||||
expect(await idsOf({ q: '"갱신 일정"' })).toEqual(['m-contract']);
|
||||
// 메일함 범위
|
||||
expect(await idsOf({ mailboxId: 'inbox', q: '계약' })).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(
|
||||
await idsOf({ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }),
|
||||
).toEqual(['m-trash']);
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만 — 본문에만 있는 메일은 빠진다', async () => {
|
||||
// '층' 은 m-floor 의 제목 · m-meeting 의 본문에 있다
|
||||
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
|
||||
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
|
||||
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
|
||||
});
|
||||
|
||||
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
|
||||
const first = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1 }).expect(200),
|
||||
);
|
||||
expect(first.items.map((item) => item.id)).toEqual(['m-contract']);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
||||
const cursor = first.pageInfo.nextCursor as string;
|
||||
|
||||
const second = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(second.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: null,
|
||||
});
|
||||
|
||||
const others: Record<string, string>[] = [
|
||||
{ q: '견적', cursor },
|
||||
{ q: '계약', from: '이도윤', cursor },
|
||||
{ q: '계약', mailboxId: 'inbox', cursor },
|
||||
];
|
||||
for (const params of others) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('쪽을 넘기는 중에는 처음 방식대로 — 색인 커서는 다시 채우기에 들어가도 색인으로, 준비 중의 새 검색은 엔진 방식(조건도 넘긴다 · 강조 없음)', async () => {
|
||||
const cursor = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1 }).expect(200),
|
||||
).pageInfo.nextCursor as string;
|
||||
await setStatus('building');
|
||||
try {
|
||||
const queriesBefore = listQueries.length;
|
||||
const next = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(next.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||||
expect(listQueries.length).toBe(queriesBefore);
|
||||
|
||||
const engine = dataOf<Page<ListItem>>(
|
||||
await list({
|
||||
q: ' 계약 ',
|
||||
from: '이도윤',
|
||||
hasAttachment: 'true',
|
||||
}).expect(200),
|
||||
);
|
||||
expect(engine.items).toEqual([]);
|
||||
expect(listQueries.slice(queriesBefore)).toEqual([
|
||||
{
|
||||
user: ME,
|
||||
query: {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '계약',
|
||||
conditions: { from: '이도윤', hasAttachment: true },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await setStatus('ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 직원의 줄은 섞이지 않는다 — 같은 낱말 · 같은 메일함 id 여도 (두 글자 · 확인 · 한 글자 · 조건만 · 메일함 범위 — 리뷰 L6)', async () => {
|
||||
const OTHER = 'search.neighbor@e2e.arasystem.test';
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(OTHER))
|
||||
.expect(200);
|
||||
// 엔진의 메일함 id 는 계정마다 같은 꼴이다(inbox) — 글도 ME 의 것과 같게
|
||||
fakeIndexSource.mailboxes.set(OTHER, {
|
||||
state: 'n1',
|
||||
emails: DOCUMENTS.map((document) => ({
|
||||
...document,
|
||||
id: `x-${document.id}`,
|
||||
})),
|
||||
});
|
||||
try {
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(OTHER, Date.now() + 120_000)).toBe('ready');
|
||||
|
||||
const searches: Record<string, string>[] = [
|
||||
{ q: '계약' },
|
||||
{ q: '계약서' },
|
||||
{ q: '층' },
|
||||
{ subject: '회의' },
|
||||
{ hasAttachment: 'true' },
|
||||
{ after: '2026-11-01T00:00:00.000Z' },
|
||||
{ mailboxId: 'inbox', q: '회의' },
|
||||
{ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' },
|
||||
];
|
||||
for (const params of searches) {
|
||||
const before = summaryRequests.length;
|
||||
const ids = await idsOf(params);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
// 색인이 찾은 id 그대로(엔진에 요약을 묻기 전) — 다른 직원의 줄이 없다
|
||||
const asked = summaryRequests
|
||||
.slice(before)
|
||||
.flatMap((request) => [...request.ids]);
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.filter((id) => id.startsWith('x-'))).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
fakeIndexSource.mailboxes.delete(OTHER);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||||
[OTHER],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('검색 글의 NUL 은 U+FFFD 로 바꿔 찾는다 — DB 오류(500)가 아니다, 엔진 방식도 (리뷰 L2)', async () => {
|
||||
const NUL = String.fromCharCode(0);
|
||||
expect(await idsOf({ q: `계약${NUL}` })).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(await idsOf({ q: `계${NUL}약`, subject: `회${NUL}의` })).toEqual(
|
||||
[],
|
||||
);
|
||||
await setStatus('building');
|
||||
try {
|
||||
const res = await list({ q: `계${NUL}약` }).expect(200);
|
||||
expect(dataOf<Page<ListItem>>(res).items).toEqual([]);
|
||||
} finally {
|
||||
await setStatus('ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('검색어와 조건 칸 넷의 낱말은 합쳐 8개까지 — 넘으면 400 VAL_001 (읽기 화면도 같다 — 리뷰 M2)', async () => {
|
||||
const words = (prefix: string, count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
|
||||
' ',
|
||||
);
|
||||
await list({ q: words('q', 4), to: words('t', 4) }).expect(200);
|
||||
const overLimit: Record<string, string>[] = [
|
||||
{ q: words('q', 4), to: words('t', 5) },
|
||||
{ from: words('f', 3), subject: words('s', 3), body: words('b', 3) },
|
||||
];
|
||||
for (const params of overLimit) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
const detail = await request(server)
|
||||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||||
.query({ q: words('q', 5), body: words('b', 4) })
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(400);
|
||||
expect(body(detail).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('판을 되돌리면(서버가 뜰 때) building · 엔진 방식이 되고, 다시 채울 때 첫 묶음이 옛 판 줄을 지운다 — 실제 PostgreSQL (리뷰 M1)', async () => {
|
||||
const RESET = 'search.reset@e2e.arasystem.test';
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(RESET))
|
||||
.expect(200);
|
||||
fakeIndexSource.mailboxes.set(RESET, {
|
||||
state: 'r1',
|
||||
emails: DOCUMENTS.map((document) => ({
|
||||
...document,
|
||||
id: `r-${document.id}`,
|
||||
})),
|
||||
});
|
||||
try {
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||||
// 옛 판으로 만든 줄처럼 — 판을 내리고 엔진에 없는 줄도 하나 남긴다
|
||||
await dataSource.query(
|
||||
`UPDATE mail_search_states SET index_format = $2 WHERE user_email = $1`,
|
||||
[RESET, INDEX_FORMAT - 1],
|
||||
);
|
||||
await dataSource.query(
|
||||
`UPDATE mail_search_messages SET fold_body = 'old-format' WHERE user_email = $1`,
|
||||
[RESET],
|
||||
);
|
||||
|
||||
// 판이 다르면 되돌리기 전에도 상태는 building (검색은 엔진 방식)
|
||||
expect(
|
||||
dataOf(
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(RESET))
|
||||
.expect(200),
|
||||
),
|
||||
).toEqual({ status: 'building', done: 0, total: null });
|
||||
|
||||
const repository = app.get(MailSearchIndexRepository);
|
||||
expect(
|
||||
await repository.resetOutdatedFormats(
|
||||
`@${TEST_MAIL_SETTINGS.domain}`,
|
||||
INDEX_FORMAT,
|
||||
new Date(),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const [state]: {
|
||||
status: string;
|
||||
index_format: number;
|
||||
build_started_at: Date | null;
|
||||
}[] = await dataSource.query(
|
||||
'SELECT status, index_format, build_started_at FROM mail_search_states WHERE user_email = $1',
|
||||
[RESET],
|
||||
);
|
||||
expect(state).toEqual({
|
||||
status: 'building',
|
||||
index_format: INDEX_FORMAT,
|
||||
build_started_at: null,
|
||||
});
|
||||
|
||||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||||
const rows: { email_id: string; fold_body: string }[] =
|
||||
await dataSource.query(
|
||||
'SELECT email_id, fold_body FROM mail_search_messages WHERE user_email = $1 ORDER BY email_id',
|
||||
[RESET],
|
||||
);
|
||||
expect(rows.map((row) => row.email_id)).toEqual(
|
||||
DOCUMENTS.map((document) => `r-${document.id}`).sort(),
|
||||
);
|
||||
expect(rows.filter((row) => row.fold_body === 'old-format')).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
fakeIndexSource.mailboxes.delete(RESET);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||||
[RESET],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('검색 칸의 검사 — 길이 · 낱말 수 · 기간 형식 · 첨부 값이 틀리면 400 VAL_001 (엔진 · 색인에 묻지 않는다)', async () => {
|
||||
const before = summaryRequests.length;
|
||||
const rejected: Record<string, string>[] = [
|
||||
{ subject: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{
|
||||
body: Array.from(
|
||||
{ length: MAX_SEARCH_WORDS + 1 },
|
||||
(_, index) => `w${index}`,
|
||||
).join(' '),
|
||||
},
|
||||
{ after: '2026-11-01' },
|
||||
{ before: '2026-11-01T00:00:00+09:00' },
|
||||
{ hasAttachment: 'false' },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(summaryRequests.length).toBe(before);
|
||||
});
|
||||
|
||||
describe('읽기 화면의 강조 (GET /api/messages/{id} 의 q · subject · body)', () => {
|
||||
interface ReadDetail {
|
||||
subject: string;
|
||||
htmlBody: string | null;
|
||||
textBody: string | null;
|
||||
imagesShown: boolean;
|
||||
searchHighlight: {
|
||||
subject: [number, number][];
|
||||
attachments: Record<string, [number, number][]>;
|
||||
textBody: [number, number][];
|
||||
} | null;
|
||||
}
|
||||
const read = (query: Record<string, string>) =>
|
||||
request(server)
|
||||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||||
.query(query)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('검색 쿼리가 없으면 searchHighlight 는 null', async () => {
|
||||
const detail = dataOf<ReadDetail>(await read({}).expect(200));
|
||||
expect(detail.searchHighlight).toBeNull();
|
||||
expect(detail.htmlBody).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('검색어 — 본문의 글에 <mark>(정화한 뒤 — 태그 · 속성은 그대로) · textBody 의 위치, 보낸 사람 조건은 강조하지 않는다', async () => {
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ q: '안녕', from: '바깥' }).expect(200),
|
||||
);
|
||||
expect(detail.searchHighlight).toEqual({
|
||||
subject: [],
|
||||
attachments: {},
|
||||
textBody: [[0, 2]],
|
||||
});
|
||||
expect(detail.htmlBody).toContain('<p><mark>안녕</mark>하세요</p>');
|
||||
expect(detail.htmlBody).not.toContain('<mark>로고');
|
||||
});
|
||||
|
||||
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(
|
||||
200,
|
||||
),
|
||||
);
|
||||
expect(detail.imagesShown).toBe(true);
|
||||
expect(detail.htmlBody).toContain('/api/mail-images/');
|
||||
expect(detail.htmlBody).toContain('<mark>안녕</mark>');
|
||||
expect(detail.searchHighlight?.subject).toEqual([[0, 2]]);
|
||||
});
|
||||
|
||||
it('검색 칸은 목록과 같은 검사 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다 — 400 VAL_001', async () => {
|
||||
const rejected: Record<string, string>[] = [
|
||||
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{ after: '2026-11-01T00:00:00.000Z' },
|
||||
{ before: '2026-11-30T15:00:00.000Z' },
|
||||
{ hasAttachment: 'true' },
|
||||
{ cursor: 'abc' },
|
||||
{ limit: '20' },
|
||||
{ mailboxId: 'inbox' },
|
||||
];
|
||||
for (const query of rejected) {
|
||||
const res = await read(query).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
|
||||
let systemSettings: SystemSettingsService;
|
||||
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
|
||||
|
||||
@@ -18,8 +18,10 @@ export const FOLD_SAMPLES: readonly (readonly [
|
||||
['ABC123!', 'abc123!'],
|
||||
['RE:회의', 're:회의'],
|
||||
['[공지]', '[공지]'],
|
||||
// 전각 공백(U+3000)은 바꾸지 않는다 — 공백은 위치를 지키려고 그대로 둔다
|
||||
['회의\u3000일정', '회의\u3000일정'],
|
||||
// 공백류(전각 공백 · 줄바꿈 · 탭 · NBSP · CR)는 띄어쓰기 하나로 1:1 (색인 판 2) — 이어진 공백을 줄이지는 않는다(위치를 지킨다)
|
||||
['회의\u3000일정', '회의 일정'],
|
||||
['계약\n갱신\t안내\u00a0끝', '계약 갱신 안내 끝'],
|
||||
['a\r\n\nb', 'a b'],
|
||||
// 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
|
||||
['İstanbul', 'İstanbul'],
|
||||
['ÀÉÎÕÜ', 'àéîõü'],
|
||||
@@ -84,6 +86,9 @@ export const MATCH_SAMPLES: readonly (readonly [
|
||||
[3, 5],
|
||||
],
|
||||
],
|
||||
// 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 (공백류 → 띄어쓰기 1:1) — 위치는 원문 그대로
|
||||
['계약\n갱신 안내', ['계약 갱신'], [[0, 5]]],
|
||||
['계약\u00a0갱신', ['계약 갱신'], [[0, 5]]],
|
||||
// 구절은 띄어쓰기까지 그대로
|
||||
['회의 일정 안내', ['회의 일정'], [[0, 5]]],
|
||||
['회의 일정', ['회의 일정'], []],
|
||||
|
||||
Reference in New Issue
Block a user