From 9887035bcd3b7a8d2accd2729b30a79c5fadf666 Mon Sep 17 00:00:00 2001
From: ttipo
Date: Thu, 5 Nov 2026 20:51:15 +0900
Subject: [PATCH 01/20] =?UTF-8?q?feat(backend):=20=EB=A9=94=EC=9D=BC=20?=
=?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=83=89=EC=9D=B8=EC=9D=98=20=EB=B0=94?=
=?UTF-8?q?=ED=83=95(=ED=91=9C=20=C2=B7=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20?=
=?UTF-8?q?=C2=B7=20=EC=A7=80=EB=82=9C=20=EB=A9=94=EC=9D=BC=20=EC=B1=84?=
=?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EC=A4=80=EB=B9=84=20=EC=83=81?=
=?UTF-8?q?=ED=83=9C)=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다.
- 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와
mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다)
- 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝)
- 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전,
서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조
- GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘
- 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다)
리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB ·
16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게).
Co-Authored-By: Claude Opus 5.5
---
.../1793873715423-AddMailSearchIndex.ts | 79 ++
backend/src/modules/auth/auth.module.ts | 3 +
backend/src/modules/auth/auth.service.spec.ts | 37 +
backend/src/modules/auth/auth.service.ts | 8 +
.../dto/search-index-status.dto.ts | 32 +
.../mail-search/index-document.spec.ts | 183 ++++
.../src/modules/mail-search/index-document.ts | 205 +++++
.../modules/mail-search/mail-index-source.ts | 132 +++
.../mail-search/mail-search-build.service.ts | 240 +++++
.../mail-search-index.repository.ts | 326 +++++++
.../mail-search-index.service.spec.ts | 66 ++
.../mail-search/mail-search-index.service.ts | 65 ++
.../mail-search/mail-search-message.entity.ts | 143 +++
.../modules/mail-search/mail-search-runs.ts | 71 ++
.../mail-search/mail-search-state.entity.ts | 83 ++
.../mail-search-sync.service.spec.ts | 859 ++++++++++++++++++
.../mail-search/mail-search-sync.service.ts | 478 ++++++++++
.../modules/mail-search/mail-search-timing.ts | 19 +
.../mail-search/mail-search.constants.ts | 107 +++
.../modules/mail-search/mail-search.module.ts | 40 +
.../modules/mail-search/mail-search.types.ts | 22 +
.../mail-search/search-index-schedule.spec.ts | 91 ++
.../mail-search/search-index-schedule.ts | 33 +
.../mail-search/search-index.controller.ts | 38 +
.../mail-user-settings.entity.ts | 7 +
.../src/modules/mail/jmap/jmap-filter-run.ts | 7 +-
.../mail/jmap/jmap-index-source.spec.ts | 434 +++++++++
.../modules/mail/jmap/jmap-index-source.ts | 622 +++++++++++++
.../modules/mail/mail-events.service.spec.ts | 8 +
.../src/modules/mail/mail-events.service.ts | 5 +
backend/src/modules/mail/mail.module.ts | 3 +
backend/src/modules/ops/lock-commands.ts | 2 +
backend/src/modules/ops/ops-commands.spec.ts | 2 +
backend/src/modules/ops/ops-data.service.ts | 15 +-
.../src/shared/config/database.config.spec.ts | 17 +
backend/src/shared/config/database.config.ts | 17 +-
backend/src/shared/utils/kst-date.spec.ts | 11 +
backend/src/shared/utils/kst-date.ts | 10 +
backend/src/shared/utils/mask.spec.ts | 17 +
backend/src/shared/utils/mask.ts | 6 +-
backend/src/shared/utils/search-text.spec.ts | 130 +++
backend/src/shared/utils/search-text.ts | 212 +++++
backend/src/shared/utils/subject-base.spec.ts | 30 +
backend/src/shared/utils/subject-base.ts | 49 +
backend/test/app.e2e-spec.ts | 314 +++++++
backend/test/search-text-samples.ts | 141 +++
46 files changed, 5411 insertions(+), 8 deletions(-)
create mode 100644 backend/src/migrations/1793873715423-AddMailSearchIndex.ts
create mode 100644 backend/src/modules/mail-search/dto/search-index-status.dto.ts
create mode 100644 backend/src/modules/mail-search/index-document.spec.ts
create mode 100644 backend/src/modules/mail-search/index-document.ts
create mode 100644 backend/src/modules/mail-search/mail-index-source.ts
create mode 100644 backend/src/modules/mail-search/mail-search-build.service.ts
create mode 100644 backend/src/modules/mail-search/mail-search-index.repository.ts
create mode 100644 backend/src/modules/mail-search/mail-search-index.service.spec.ts
create mode 100644 backend/src/modules/mail-search/mail-search-index.service.ts
create mode 100644 backend/src/modules/mail-search/mail-search-message.entity.ts
create mode 100644 backend/src/modules/mail-search/mail-search-runs.ts
create mode 100644 backend/src/modules/mail-search/mail-search-state.entity.ts
create mode 100644 backend/src/modules/mail-search/mail-search-sync.service.spec.ts
create mode 100644 backend/src/modules/mail-search/mail-search-sync.service.ts
create mode 100644 backend/src/modules/mail-search/mail-search-timing.ts
create mode 100644 backend/src/modules/mail-search/mail-search.constants.ts
create mode 100644 backend/src/modules/mail-search/mail-search.module.ts
create mode 100644 backend/src/modules/mail-search/mail-search.types.ts
create mode 100644 backend/src/modules/mail-search/search-index-schedule.spec.ts
create mode 100644 backend/src/modules/mail-search/search-index-schedule.ts
create mode 100644 backend/src/modules/mail-search/search-index.controller.ts
create mode 100644 backend/src/modules/mail/jmap/jmap-index-source.spec.ts
create mode 100644 backend/src/modules/mail/jmap/jmap-index-source.ts
create mode 100644 backend/src/shared/config/database.config.spec.ts
create mode 100644 backend/src/shared/utils/search-text.spec.ts
create mode 100644 backend/src/shared/utils/search-text.ts
create mode 100644 backend/src/shared/utils/subject-base.spec.ts
create mode 100644 backend/src/shared/utils/subject-base.ts
create mode 100644 backend/test/search-text-samples.ts
diff --git a/backend/src/migrations/1793873715423-AddMailSearchIndex.ts b/backend/src/migrations/1793873715423-AddMailSearchIndex.ts
new file mode 100644
index 0000000..1213c58
--- /dev/null
+++ b/backend/src/migrations/1793873715423-AddMailSearchIndex.ts
@@ -0,0 +1,79 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * 메일 검색 색인 표 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕, 설계 A).
+ * - mail_search_messages: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계 · 원문 글(NFC) · 접기 글(fold_*) ·
+ * 글자 두 개 묶음(grams) · 대화 키. 색인: PK, (직원, 받은 시각, id) · (직원, Message-ID) · (직원, 대화 키),
+ * GIN 셋(grams · mailbox_ids · refs — TypeORM 이 GIN 을 적지 못해 엔티티에는 synchronize: false 로 이름만 있다)
+ * - mail_search_states: 직원마다 한 줄 — 상태(building · ready) · 엔진 Email state · 채우기 자리 · 멈춘 까닭 · 실패 수
+ * - mail_settings.group_by_conversation: 목록을 대화로 묶어 보기(기본 끔 — API 는 14단계 3차)
+ * 기존 표의 칸 · 행은 지우지 않는다 — 표를 새로 만들고 칸 하나를 더하기만 한다.
+ * (자동 생성: npm run migration:generate — GIN 색인 셋만 손으로 더했다. 엔티티는 modules/mail-search/*.entity.ts ·
+ * modules/mail-user-settings/mail-user-settings.entity.ts)
+ */
+export class AddMailSearchIndex1793873715423 implements MigrationInterface {
+ name = 'AddMailSearchIndex1793873715423';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "mail_search_messages" ("user_email" character varying(254) NOT NULL, "email_id" character varying(64) NOT NULL, "mailbox_ids" text array NOT NULL, "keywords" text array NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, "size" integer NOT NULL, "message_id" text, "refs" text array NOT NULL, "subject" text NOT NULL, "subject_base" text NOT NULL, "from_text" text NOT NULL, "to_text" text NOT NULL, "cc_text" text NOT NULL, "attachment_names" text NOT NULL, "has_attachment" boolean NOT NULL, "preview" text NOT NULL, "body_text" text NOT NULL, "fold_from" text NOT NULL, "fold_to" text NOT NULL, "fold_subject" text NOT NULL, "fold_attachment" text NOT NULL, "fold_body" text NOT NULL, "grams" text array NOT NULL, "thread_key" character varying(64) NOT NULL, "engine_thread_id" character varying(64), "indexed_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_bdeada2a9bbda7dece469d81b6f" PRIMARY KEY ("user_email", "email_id"))`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_thread_key" ON "mail_search_messages" ("user_email", "thread_key") `,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_message_id" ON "mail_search_messages" ("user_email", "message_id") `,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_received" ON "mail_search_messages" ("user_email", "received_at", "email_id") `,
+ );
+ // GIN — 찾는 말의 글자 두 개 묶음이 모두 든 줄(grams @> …) · 메일함 범위(mailbox_ids && …) · 늦게 온 원본이 자식을 찾기(refs @> …)
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_refs" ON "mail_search_messages" USING GIN ("refs")`,
+ );
+ await queryRunner.query(
+ `CREATE TABLE "mail_search_states" ("user_email" character varying(254) NOT NULL, "status" character varying(32) NOT NULL, "email_state" text, "build_anchor" character varying(64), "build_done" integer NOT NULL DEFAULT '0', "build_total" integer, "build_started_at" TIMESTAMP WITH TIME ZONE, "ready_at" TIMESTAMP WITH TIME ZONE, "last_synced_at" TIMESTAMP WITH TIME ZONE, "paused_reason" character varying(32), "failure_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e8aa94c380c49ff05919e5177ab" PRIMARY KEY ("user_email"))`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_states_status" ON "mail_search_states" ("status") `,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "mail_settings" ADD "group_by_conversation" boolean NOT NULL DEFAULT false`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "mail_settings" DROP COLUMN "group_by_conversation"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_states_status"`,
+ );
+ await queryRunner.query(`DROP TABLE "mail_search_states"`);
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_refs"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_grams"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_received"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_message_id"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_thread_key"`,
+ );
+ await queryRunner.query(`DROP TABLE "mail_search_messages"`);
+ }
+}
diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts
index 9ad710a..db0baf7 100644
--- a/backend/src/modules/auth/auth.module.ts
+++ b/backend/src/modules/auth/auth.module.ts
@@ -6,6 +6,7 @@ import { PassportModule } from '@nestjs/passport';
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
import { MailModule } from '@/modules/mail/mail.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
+import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionModule } from './auth-session.module';
import { AuthSessionsController } from './auth-sessions.controller';
@@ -38,6 +39,8 @@ import { LoginAttemptModule } from './login-attempt.module';
MailDirectoryModule,
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
AccountSecurityModule,
+ // 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다)
+ MailSearchModule,
],
controllers: [AuthController, AuthSessionsController],
providers: [
diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts
index 5e40a58..a980653 100644
--- a/backend/src/modules/auth/auth.service.spec.ts
+++ b/backend/src/modules/auth/auth.service.spec.ts
@@ -15,6 +15,7 @@ import {
import type { CredentialCheck } from '@/modules/mail/mail.types';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
+import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import type { AuthSessionEntity } from './auth-session.entity';
import { hashRefreshToken } from './auth-session.policy';
import type {
@@ -259,6 +260,8 @@ describe('AuthService', () => {
sessionState: jest.Mock;
isEnabledInEngine: jest.Mock;
};
+ /** 검색 색인 대역 (14단계) — 로그인한 직원의 상태 줄 */
+ let searchIndex: { enrollAfterLogin: jest.Mock };
let jwt: JwtService;
let service: AuthService;
let logs: string[];
@@ -277,6 +280,7 @@ describe('AuthService', () => {
loginEvents as unknown as LoginEventsService,
appPasswords as unknown as AppPasswordsService,
twoFactorPolicy as unknown as TwoFactorPolicyService,
+ searchIndex as unknown as MailSearchIndexService,
);
const decode = (token: string) =>
@@ -329,6 +333,7 @@ describe('AuthService', () => {
.mockResolvedValue({ restriction: null, deadline: null }),
isEnabledInEngine: jest.fn().mockResolvedValue(false),
};
+ searchIndex = { enrollAfterLogin: jest.fn().mockResolvedValue(undefined) };
jwt = new JwtService({});
service = build();
@@ -395,6 +400,22 @@ describe('AuthService', () => {
expect(directory.recordLogin).not.toHaveBeenCalled();
});
+ it('로그인에 성공한 직원의 검색 색인 상태 줄을 만든다 (14단계) — 실패한 로그인 · 코드를 기다리는 로그인은 만들지 않는다', async () => {
+ await service.login(ARA, PASSWORD, CLIENT_IP);
+ expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
+
+ searchIndex.enrollAfterLogin.mockClear();
+ mail.verifyCredentials.mockResolvedValue(INVALID);
+ expect(
+ (await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
+ ).toBe('AUTH_004');
+ mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
+ expect((await service.login(ARA, PASSWORD, CLIENT_IP)).status).toBe(
+ 'twoFactorRequired',
+ );
+ expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
+ });
+
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
directory.recordLogin.mockRejectedValue(new Error('db down'));
@@ -914,6 +935,20 @@ describe('AuthService', () => {
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
});
+ it('비밀번호가 바뀌면 검색 색인의 멈춤을 풀고 줄을 만든다 (14단계 — 만료로 멈춘 색인) · 바뀌지 않았으면 하지 않는다', async () => {
+ const current = await loginToken();
+ searchIndex.enrollAfterLogin.mockClear();
+
+ mail.changePassword.mockResolvedValueOnce('new-password-rejected');
+ expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
+ 'VAL_001',
+ );
+ expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
+
+ await expect(change(PASSWORD, current)).resolves.toBeUndefined();
+ expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
+ });
+
it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => {
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
.tokens!.refreshToken;
@@ -1804,6 +1839,8 @@ describe('AuthService', () => {
expect(decode(result.tokens.accessToken).rst).toBe('passwordChange');
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
expect(directory.recordLogin).not.toHaveBeenCalled();
+ // 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계)
+ expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect.objectContaining({ email: ARA, isSuccess: true }),
diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts
index 914717a..86470d7 100644
--- a/backend/src/modules/auth/auth.service.ts
+++ b/backend/src/modules/auth/auth.service.ts
@@ -29,6 +29,7 @@ import type {
PasswordChangeOutcome,
} from '@/modules/mail/mail.types';
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
+import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { truncateUserAgent } from '@/shared/utils/user-agent';
@@ -183,6 +184,8 @@ export class AuthService {
private readonly appPasswords: AppPasswordsService,
/** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */
private readonly twoFactorPolicy: TwoFactorPolicyService,
+ /** 14단계 — 로그인한 직원의 검색 색인 상태 줄 (처음이면 만들고, 멈춰 있으면 푼다 — 던지지 않는다) */
+ private readonly searchIndex: MailSearchIndexService,
) {}
/**
@@ -300,6 +303,9 @@ export class AuthService {
const tokens = await this.startSession(profile, client, state);
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
if (!passwordExpired) await this.recordInDirectory(profile);
+ // 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다)
+ if (!passwordExpired)
+ await this.searchIndex.enrollAfterLogin(profile.email);
await this.loginEvents.recordWebmail({
email: profile.email,
isSuccess: true,
@@ -543,6 +549,8 @@ export class AuthService {
refreshToken,
twoFactorEnabled,
);
+ // 검색 색인(14단계) — 비밀번호가 바뀌었으니(만료도 풀렸다) 대리 접근이 다시 된다: 멈춘 줄을 풀고, 없던 줄은 만든다 (던지지 않는다)
+ await this.searchIndex.enrollAfterLogin(address);
} finally {
releaseLock?.();
}
diff --git a/backend/src/modules/mail-search/dto/search-index-status.dto.ts b/backend/src/modules/mail-search/dto/search-index-status.dto.ts
new file mode 100644
index 0000000..a370f54
--- /dev/null
+++ b/backend/src/modules/mail-search/dto/search-index-status.dto.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+import type {
+ SearchIndexStatus,
+ SearchIndexStatusView,
+} from '@/modules/mail-search/mail-search.types';
+
+/** GET /api/search-index 의 응답 (14단계 — 화면의 "검색 준비 중" 줄) */
+export class SearchIndexStatusDto implements SearchIndexStatusView {
+ @ApiProperty({
+ description:
+ '색인 상태 — building: 지난 메일을 채우는 중(그동안 검색은 엔진 방식 — 낱말이 통째로 같은 메일만) · ready: 다 만들었음',
+ enum: ['building', 'ready'],
+ example: 'building',
+ })
+ status!: SearchIndexStatus;
+
+ @ApiProperty({
+ description: '채운 메일 수 — 다 만들었으면 그때 색인에 든 수',
+ example: 1200,
+ })
+ done!: number;
+
+ @ApiProperty({
+ description:
+ '채울 메일 수(채우기를 시작할 때의 메일 수) — 아직 시작하지 않았으면 null. 다 만들었으면 done 과 같다',
+ type: Number,
+ nullable: true,
+ example: 4800,
+ })
+ total!: number | null;
+}
diff --git a/backend/src/modules/mail-search/index-document.spec.ts b/backend/src/modules/mail-search/index-document.spec.ts
new file mode 100644
index 0000000..e6fced8
--- /dev/null
+++ b/backend/src/modules/mail-search/index-document.spec.ts
@@ -0,0 +1,183 @@
+import { startCpuTimer } from '@/../test/cpu-time';
+import {
+ isIndexableId,
+ referencesOf,
+ storableText,
+ takeCodePoints,
+ toIndexRow,
+} from './index-document';
+import type { IndexDocument } from './mail-index-source';
+import {
+ INDEX_ADDRESSES_MAX,
+ INDEX_BODY_MAX_CHARS,
+ INDEX_REFS_MAX,
+ INDEX_SUBJECT_MAX_CHARS,
+} from './mail-search.constants';
+
+function documentOf(overrides: Partial = {}): IndexDocument {
+ return {
+ id: 'e1',
+ threadId: 't1',
+ mailboxIds: ['inbox'],
+ keywords: ['$seen'],
+ receivedAt: '2026-11-05T01:02:03Z',
+ size: 1234,
+ messageIds: ['m1@example.com'],
+ inReplyTo: [],
+ references: [],
+ subject: 'Re: [공지] 11월 계약서',
+ from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ to: [{ name: null, email: 'ara.kim@arasystem.test' }],
+ cc: [{ name: 'Partner', email: 'partner@example.com' }],
+ attachmentNames: ['계약서.PDF'],
+ hasAttachment: true,
+ preview: '계약서를 보내 드립니다',
+ bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
+ ...overrides,
+ };
+}
+
+describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
+ it('원문은 NFC 그대로, 접기 칸은 소문자 · 반각 — 받는 사람 칸은 받는 사람과 참조를 함께', () => {
+ const row = toIndexRow(documentOf());
+ expect(row).toMatchObject({
+ emailId: 'e1',
+ mailboxIds: ['inbox'],
+ keywords: ['$seen'],
+ receivedAt: new Date('2026-11-05T01:02:03Z'),
+ size: 1234,
+ messageId: 'm1@example.com',
+ refs: [],
+ subject: 'Re: [공지] 11월 계약서',
+ subjectBase: '11월 계약서',
+ fromText: '이도윤 ',
+ toText: 'ara.kim@arasystem.test',
+ ccText: 'Partner ',
+ attachmentNames: '계약서.PDF',
+ hasAttachment: true,
+ preview: '계약서를 보내 드립니다',
+ bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
+ foldFrom: '이도윤 ',
+ foldTo: 'ara.kim@arasystem.test\npartner ',
+ foldSubject: 're: [공지] 11월 계약서',
+ foldAttachment: '계약서.pdf',
+ foldBody: '안녕하세요.\n계약서를 보내 드립니다.',
+ threadKey: 'e1',
+ engineThreadId: 't1',
+ });
+ // 묶음은 다섯 칸에서 — 칸 경계를 넘지 않는다 (보낸 사람 끝 + 받는 사람 처음 같은 묶음이 없다)
+ expect(row.grams).toEqual(
+ expect.arrayContaining(['계약', '약서', 're', '공지']),
+ );
+ expect(row.grams).not.toContain('>a');
+ expect(new Set(row.grams).size).toBe(row.grams.length);
+ });
+
+ it('PostgreSQL 에 넣을 수 없는 글자(NUL · 짝 없는 서로게이트)는 U+FFFD — 위치는 그대로', () => {
+ const row = toIndexRow(
+ documentOf({ subject: 'a\u0000b\ud800c', bodyText: '\udc00본문' }),
+ );
+ expect(row.subject).toBe('a\ufffdb\ufffdc');
+ expect(row.bodyText).toBe('\ufffd본문');
+ expect(row.foldSubject.length).toBe(row.subject.length);
+ });
+
+ it('풀어 쓴 한글(NFD)은 NFC 로 모은 원문을 담는다', () => {
+ const row = toIndexRow(
+ documentOf({ subject: '\u1112\u1161\u11ab\u1100\u1173\u11af 메일' }),
+ );
+ expect(row.subject).toBe('한글 메일');
+ });
+
+ it('본문은 앞 INDEX_BODY_MAX_CHARS 코드 포인트 · 제목도 상한까지 · 주소는 INDEX_ADDRESSES_MAX 개까지', () => {
+ const row = toIndexRow(
+ documentOf({
+ bodyText: '가'.repeat(INDEX_BODY_MAX_CHARS + 500),
+ subject: 'S'.repeat(INDEX_SUBJECT_MAX_CHARS * 2),
+ to: Array.from({ length: INDEX_ADDRESSES_MAX + 50 }, (_, index) => ({
+ name: null,
+ email: `p${index}@example.com`,
+ })),
+ }),
+ );
+ expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS);
+ expect(row.subject).toHaveLength(INDEX_SUBJECT_MAX_CHARS);
+ expect(row.toText.split('\n')).toHaveLength(INDEX_ADDRESSES_MAX);
+ });
+
+ it('주소의 이름 · 주소 가운데 있는 것만 · 빈 첨부 이름은 뺀다 · 키워드 · 메일함은 겹치지 않게', () => {
+ const row = toIndexRow(
+ documentOf({
+ from: [
+ { name: ' ', email: 'a@example.com' },
+ { name: '이름만', email: null },
+ { name: null, email: null },
+ ],
+ attachmentNames: ['', ' 보고서.hwp '],
+ keywords: ['$seen', '$seen', '$flagged'],
+ mailboxIds: ['inbox', 'inbox'],
+ }),
+ );
+ expect(row.fromText).toBe('a@example.com\n이름만');
+ expect(row.attachmentNames).toBe('보고서.hwp');
+ expect(row.keywords).toEqual(['$seen', '$flagged']);
+ expect(row.mailboxIds).toEqual(['inbox']);
+ });
+
+ it('받은 시각이 날짜가 아니면 1970-01-01 · 크기는 32비트 정수 안 · Message-ID 가 없으면 null · 긴 대화 id 는 null', () => {
+ const row = toIndexRow(
+ documentOf({
+ receivedAt: 'not-a-date',
+ size: Number.MAX_SAFE_INTEGER,
+ messageIds: [],
+ threadId: 'x'.repeat(100),
+ }),
+ );
+ expect(row.receivedAt.getTime()).toBe(0);
+ expect(row.size).toBe(2_147_483_647);
+ expect(row.messageId).toBeNull();
+ expect(row.engineThreadId).toBeNull();
+ });
+
+ it('1MiB 본문도 길이에 비례해 담는다 (자르기 전에 통째로 NFC 하지 않는다)', () => {
+ const body = '계약서를 보내 드립니다. Project plan '.repeat(40_000);
+ const cpu = startCpuTimer();
+ const row = toIndexRow(documentOf({ bodyText: body }));
+ expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS);
+ expect(cpu()).toBeLessThan(3_000);
+ });
+});
+
+describe('referencesOf (답장 관계)', () => {
+ it('In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게', () => {
+ expect(referencesOf(['c@x'], ['a@x', 'b@x', 'c@x'])).toEqual([
+ 'c@x',
+ 'b@x',
+ 'a@x',
+ ]);
+ });
+
+ it('앞 INDEX_REFS_MAX 개 · 빈 것 · 너무 긴 id 는 뺀다', () => {
+ const many = Array.from({ length: 80 }, (_, index) => `r${index}@x`);
+ const refs = referencesOf([], many);
+ expect(refs).toHaveLength(INDEX_REFS_MAX);
+ expect(refs[0]).toBe('r79@x');
+ expect(referencesOf(['', ' ', 'x'.repeat(300)], ['ok@x'])).toEqual([
+ 'ok@x',
+ ]);
+ });
+});
+
+describe('takeCodePoints · storableText · isIndexableId', () => {
+ it('서로게이트 쌍을 가르지 않는다', () => {
+ expect(takeCodePoints('😀😀😀', 2)).toBe('😀😀');
+ expect(takeCodePoints('ab', 5)).toBe('ab');
+ expect(storableText('😀😀😀', 1)).toBe('😀');
+ });
+
+ it('엔진 id 는 비지 않고 열 길이 안이어야 한다', () => {
+ expect(isIndexableId('abc')).toBe(true);
+ expect(isIndexableId('')).toBe(false);
+ expect(isIndexableId('x'.repeat(65))).toBe(false);
+ });
+});
diff --git a/backend/src/modules/mail-search/index-document.ts b/backend/src/modules/mail-search/index-document.ts
new file mode 100644
index 0000000..96c88f4
--- /dev/null
+++ b/backend/src/modules/mail-search/index-document.ts
@@ -0,0 +1,205 @@
+import { foldText, gramsOf } from '@/shared/utils/search-text';
+import { subjectBase } from '@/shared/utils/subject-base';
+import type { IndexAddress, IndexDocument } from './mail-index-source';
+import {
+ INDEX_ADDRESSES_MAX,
+ INDEX_ADDRESS_LINE_MAX_CHARS,
+ INDEX_ATTACHMENT_NAMES_MAX,
+ INDEX_ATTACHMENT_NAME_MAX_CHARS,
+ INDEX_BODY_MAX_CHARS,
+ INDEX_MESSAGE_ID_MAX_LENGTH,
+ INDEX_PREVIEW_MAX_CHARS,
+ INDEX_REFS_MAX,
+ INDEX_SUBJECT_MAX_CHARS,
+ SEARCH_ID_COLUMN_LENGTH,
+} from './mail-search.constants';
+
+/**
+ * 엔진의 메일 한 통 → 색인 한 줄의 값 (순수 함수 — 14단계 설계 A1 · B1).
+ * - 글 칸은 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)으로 두고, 칸마다 길이 상한(mail-search.constants.ts)에서 자른다 — 코드 포인트로 세고 서로게이트 쌍을 가르지 않는다
+ * - PostgreSQL 의 text 는 NUL(U+0000)을 받지 않고 짝이 없는 서로게이트는 UTF-8 로 보낼 때 U+FFFD 가 된다 — 둘 다 먼저 U+FFFD 로 바꾼다
+ * (한 단위를 한 단위로 — 위치가 바뀌지 않는다. 접은 글 · 묶음이 저장된 원문과 같아지게)
+ * - 접기 글(fold_*)은 foldText — 원문과 위치가 같다. 받는 사람 칸은 받는 사람과 참조를 줄바꿈으로 이어 접는다
+ * - 묶음(grams)은 다섯 접기 칸에서 따로 뽑아 합친다(칸 경계를 넘지 않는다, 겹치지 않게)
+ * - 답장 관계(refs): In-Reply-To 다음 References 를 가까운 것(뒤)부터, 겹치지 않게 앞 INDEX_REFS_MAX 개. 너무 긴 id 는 뺀다
+ * - 대화 키는 14단계 1차에서는 그 메일 id 다 (잇기는 3차 — 설계 B4)
+ */
+
+/** 색인 한 줄의 값 — 표의 칸과 같다 (user_email · indexed_at 은 쓰는 쪽이 붙인다) */
+export interface IndexRowValues {
+ readonly emailId: string;
+ readonly mailboxIds: string[];
+ readonly keywords: string[];
+ readonly receivedAt: Date;
+ readonly size: number;
+ readonly messageId: string | null;
+ readonly refs: string[];
+ readonly subject: string;
+ readonly subjectBase: string;
+ readonly fromText: string;
+ readonly toText: string;
+ readonly ccText: string;
+ readonly attachmentNames: string;
+ readonly hasAttachment: boolean;
+ readonly preview: string;
+ readonly bodyText: string;
+ readonly foldFrom: string;
+ readonly foldTo: string;
+ readonly foldSubject: string;
+ readonly foldAttachment: string;
+ readonly foldBody: string;
+ readonly grams: string[];
+ readonly threadKey: string;
+ readonly engineThreadId: string | null;
+}
+
+/** NUL · 짝이 없는 서로게이트 — PostgreSQL text 에 그대로 넣을 수 없는 글자 */
+const UNSTORABLE =
+ // eslint-disable-next-line no-control-regex
+ /\u0000|[\ud800-\udbff](?![\udc00-\udfff])|(?= 0xd800 &&
+ code <= 0xdbff &&
+ units + 1 < text.length &&
+ (text.charCodeAt(units + 1) & 0xfc00) === 0xdc00
+ ? 2
+ : 1;
+ points += 1;
+ }
+ return text.slice(0, units);
+}
+
+/** 저장할 원문 — 넣을 수 없는 글자를 바꾸고, NFC 로 모으고, 코드 포인트 `max` 개까지 */
+export function storableText(text: string, max: number): string {
+ // 아주 긴 글(본문 1MiB 등)을 통째로 NFC 하지 않게 먼저 넉넉히 자른다
+ const head =
+ text.length > max * NFC_SLACK ? text.slice(0, max * NFC_SLACK) : text;
+ return takeCodePoints(
+ head.replace(UNSTORABLE, REPLACEMENT_CHARACTER).normalize('NFC'),
+ max,
+ );
+}
+
+/** "이름 <주소>" — 이름이 없으면 주소, 주소가 없으면 이름 */
+function addressLine(address: IndexAddress): string {
+ const name = (address.name ?? '').trim();
+ const email = (address.email ?? '').trim();
+ const line = name && email ? `${name} <${email}>` : name || email;
+ return storableText(line, INDEX_ADDRESS_LINE_MAX_CHARS);
+}
+
+function addressText(addresses: readonly IndexAddress[]): string {
+ return addresses
+ .slice(0, INDEX_ADDRESSES_MAX)
+ .map(addressLine)
+ .filter((line) => line !== '')
+ .join('\n');
+}
+
+/** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게 앞 INDEX_REFS_MAX 개 */
+export function referencesOf(
+ inReplyTo: readonly string[],
+ references: readonly string[],
+): string[] {
+ const refs: string[] = [];
+ const seen = new Set();
+ for (const raw of [...inReplyTo, ...[...references].reverse()]) {
+ const ref = storableText(raw.trim(), INDEX_MESSAGE_ID_MAX_LENGTH + 1);
+ if (ref === '' || ref.length > INDEX_MESSAGE_ID_MAX_LENGTH) continue;
+ if (seen.has(ref)) continue;
+ seen.add(ref);
+ refs.push(ref);
+ if (refs.length >= INDEX_REFS_MAX) break;
+ }
+ return refs;
+}
+
+/** 첫 Message-ID — 없거나 너무 길면 null */
+function firstMessageId(messageIds: readonly string[]): string | null {
+ const first = storableText(
+ (messageIds[0] ?? '').trim(),
+ INDEX_MESSAGE_ID_MAX_LENGTH + 1,
+ );
+ return first === '' || first.length > INDEX_MESSAGE_ID_MAX_LENGTH
+ ? null
+ : first;
+}
+
+/** 열 길이 안의 엔진 id 인가 */
+export function isIndexableId(id: string): boolean {
+ return id !== '' && id.length <= SEARCH_ID_COLUMN_LENGTH;
+}
+
+/** 엔진의 메일 한 통 → 색인 한 줄의 값. 받은 시각이 날짜가 아니면 1970-01-01 (목록과 같다) */
+export function toIndexRow(document: IndexDocument): IndexRowValues {
+ const subject = storableText(document.subject, INDEX_SUBJECT_MAX_CHARS);
+ const fromText = addressText(document.from);
+ const toText = addressText(document.to);
+ const ccText = addressText(document.cc);
+ const attachmentNames = document.attachmentNames
+ .slice(0, INDEX_ATTACHMENT_NAMES_MAX)
+ .map((name) => storableText(name.trim(), INDEX_ATTACHMENT_NAME_MAX_CHARS))
+ .filter((name) => name !== '')
+ .join('\n');
+ const bodyText = storableText(document.bodyText, INDEX_BODY_MAX_CHARS);
+
+ const foldFrom = foldText(fromText);
+ const foldTo = foldText([toText, ccText].filter(Boolean).join('\n'));
+ const foldSubject = foldText(subject);
+ const foldAttachment = foldText(attachmentNames);
+ const foldBody = foldText(bodyText);
+ const grams = new Set();
+ for (const fold of [
+ foldFrom,
+ foldTo,
+ foldSubject,
+ foldAttachment,
+ foldBody,
+ ]) {
+ gramsOf(fold, grams);
+ }
+
+ const receivedAt = Date.parse(document.receivedAt);
+ const threadId = document.threadId ?? '';
+ return {
+ emailId: document.id,
+ mailboxIds: [...new Set(document.mailboxIds)],
+ keywords: [...new Set(document.keywords)],
+ receivedAt: new Date(Number.isNaN(receivedAt) ? 0 : receivedAt),
+ size: Math.min(MAX_INT32, Math.max(0, Math.trunc(document.size) || 0)),
+ messageId: firstMessageId(document.messageIds),
+ refs: referencesOf(document.inReplyTo, document.references),
+ subject,
+ subjectBase: subjectBase(subject),
+ fromText,
+ toText,
+ ccText,
+ attachmentNames,
+ hasAttachment: document.hasAttachment,
+ preview: storableText(document.preview, INDEX_PREVIEW_MAX_CHARS),
+ bodyText,
+ foldFrom,
+ foldTo,
+ foldSubject,
+ foldAttachment,
+ foldBody,
+ grams: [...grams],
+ threadKey: document.id,
+ engineThreadId: isIndexableId(threadId) ? threadId : null,
+ };
+}
diff --git a/backend/src/modules/mail-search/mail-index-source.ts b/backend/src/modules/mail-search/mail-index-source.ts
new file mode 100644
index 0000000..75e5960
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-index-source.ts
@@ -0,0 +1,132 @@
+/**
+ * 메일 검색 색인이 메일 엔진에서 읽는 것 — 포트 (14단계 설계 B2). 엔진을 모르는 쪽(맞추기 · 채우기)은 이것만 안다.
+ * 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-index-source.ts`. 엔진을 바꿀 때는 mail-search.module.ts 의 useClass 만 바꾼다.
+ *
+ * 실패는 BusinessException 으로 던진다 — 대리 접근이 막힌 계정은 메일 포트와 같은 예외(PasswordExpiredException · AccountLoginDisabledException ·
+ * AUTH_001), 엔진 장애 MAIL_001, 엔진 한도 REQ_001.
+ */
+export const MAIL_INDEX_SOURCE = Symbol('MAIL_INDEX_SOURCE');
+
+/** 한 번의 엔진 읽기의 끝 시각(epoch ms) — 넘으면 엔진 호출을 그만둔다 (호출마다 남은 시간만큼만 기다린다) */
+export interface IndexReadOptions {
+ readonly deadline: number;
+}
+
+/**
+ * 차례의 끝 시각에 닿았다 — 남은 시간이 INDEX_CALL_MIN_MS 보다 적어 엔진 읽기를 시작하지 않았거나, 남은 시간으로 줄인 기다림이 끝나 끊었다.
+ * **실패가 아니다** — 맞추기 · 채우기는 쓴 데까지를 두고 다음 차례에 이어 한다(실패 수 · 경고 없음). 응답으로 나가지 않는다(배경 일에서만 쓴다)
+ */
+export class IndexDeadlineError extends Error {
+ constructor() {
+ super('검색 색인 차례의 끝 시각');
+ this.name = 'IndexDeadlineError';
+ }
+}
+
+/** 주소 하나 — 이름이 없으면 null */
+export interface IndexAddress {
+ readonly name: string | null;
+ readonly email: string | null;
+}
+
+/** 색인에 넣을 메일 한 통 — 엔진이 준 그대로(자르기 · 접기는 index-document.ts 가 한다) */
+export interface IndexDocument {
+ readonly id: string;
+ /** 엔진의 대화(threadId) — 없으면 null */
+ readonly threadId: string | null;
+ readonly mailboxIds: readonly string[];
+ /** 켜진 키워드 */
+ readonly keywords: readonly string[];
+ /** 받은 시각 (ISO 8601) */
+ readonly receivedAt: string;
+ readonly size: number;
+ /** 머리글 Message-ID 들 (꺾쇠 없이) */
+ readonly messageIds: readonly string[];
+ readonly inReplyTo: readonly string[];
+ readonly references: readonly string[];
+ readonly subject: string;
+ readonly from: readonly IndexAddress[];
+ readonly to: readonly IndexAddress[];
+ readonly cc: readonly IndexAddress[];
+ /** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 빠진다 */
+ readonly attachmentNames: readonly string[];
+ /** 목록과 같은 첨부 판정 */
+ readonly hasAttachment: boolean;
+ readonly preview: string;
+ /** 본문 글 — HTML 본문이면 글자만, 부분은 줄바꿈으로 잇는다. 엔진이 부분마다 앞쪽만 줄 수 있다 */
+ readonly bodyText: string;
+}
+
+/** 바뀔 수 있는 칸만 (JMAP 에서 메일의 글은 바뀌지 않는다 — 메일함 · 키워드뿐) */
+export interface IndexMutable {
+ readonly id: string;
+ readonly mailboxIds: readonly string[];
+ readonly keywords: readonly string[];
+}
+
+/** 엔진 Email state 부터의 변화 — 만들어진 · 바뀐 · 없어진 메일 id */
+export type IndexChanges =
+ | {
+ readonly kind: 'changes';
+ readonly created: readonly string[];
+ readonly updated: readonly string[];
+ readonly destroyed: readonly string[];
+ readonly newState: string;
+ /** 한 번에 다 주지 못했다 — newState 부터 다시 묻는다 */
+ readonly hasMore: boolean;
+ }
+ /** 엔진이 그 state 부터의 변화를 셀 수 없다 — 전체 대조로 */
+ | { readonly kind: 'cannotCalculate' };
+
+/** 받은 차례(최신부터) 메일 id 의 한 쪽 */
+export interface IndexIdPage {
+ readonly ids: readonly string[];
+ /** 이 쪽의 첫 메일 자리 (0 부터) */
+ readonly position: number;
+ /** 엔진이 센 모든 메일 수 — 세지 않았으면 null */
+ readonly total: number | null;
+}
+
+/** 엔진의 모든 메일 id 와 그것을 읽기 바로 전의 Email state (전체 대조) */
+export interface IndexAllIds {
+ readonly state: string;
+ readonly ids: readonly string[];
+}
+
+export interface MailIndexSource {
+ /** 지금 Email state 와 모든 메일 수 (채우기를 시작할 때 — 싼 읽기) */
+ snapshot(
+ user: string,
+ options: IndexReadOptions,
+ ): Promise<{ readonly state: string; readonly total: number | null }>;
+ /** sinceState 부터의 변화 — 한 번에 maxChanges 개까지 */
+ changes(
+ user: string,
+ sinceState: string,
+ maxChanges: number,
+ options: IndexReadOptions,
+ ): Promise;
+ /** 메일 글 — 그사이 없어진 메일은 빠진다 (차례는 ids 를 따르지 않아도 된다) */
+ documents(
+ user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise;
+ /** 메일함 · 키워드만 — 그사이 없어진 메일은 빠진다 */
+ mutable(
+ user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise;
+ /**
+ * 받은 차례(최신부터) 한 쪽 — anchor(지난 쪽의 마지막 메일) 다음부터. anchor 가 없거나 그사이 지워졌으면 `position` 부터
+ */
+ pageIds(
+ user: string,
+ from: { readonly anchor: string | null; readonly position: number },
+ limit: number,
+ options: IndexReadOptions,
+ ): Promise;
+ /** 모든 메일 id — 전체 대조 (그 바로 전의 state 와 함께) */
+ allIds(user: string, options: IndexReadOptions): Promise;
+}
diff --git a/backend/src/modules/mail-search/mail-search-build.service.ts b/backend/src/modules/mail-search/mail-search-build.service.ts
new file mode 100644
index 0000000..e1975a3
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-build.service.ts
@@ -0,0 +1,240 @@
+import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+
+import { maskEmail } from '@/shared/utils/mask';
+import { isIndexableId } from './index-document';
+import {
+ IndexDeadlineError,
+ MAIL_INDEX_SOURCE,
+ type MailIndexSource,
+} from './mail-index-source';
+import { MailSearchIndexRepository } from './mail-search-index.repository';
+import { MailSearchRuns } from './mail-search-runs';
+import { MailSearchSyncService } from './mail-search-sync.service';
+import {
+ MAIL_SEARCH_TIMING,
+ SYSTEM_SEARCH_TIMING,
+ type MailSearchTiming,
+} from './mail-search-timing';
+import {
+ BUILD_BATCH_PAUSE_MS,
+ BUILD_BATCH_SIZE,
+ BUILD_RUN_BUDGET_MS,
+ FINISH_MIN_REMAINING_MS,
+ SEARCH_CRON_TIME_ZONE,
+ STEP_MIN_REMAINING_MS,
+} from './mail-search.constants';
+import { isBuildWindow, isDueAfterFailures } from './search-index-schedule';
+
+/** 밤 채우기 차례에서 한 번에 읽어 볼 직원 수 — 실패가 이어진 직원 · 다른 도메인 줄을 건너뛰고 고를 만큼 */
+const BUILD_CANDIDATES_LIMIT = 20;
+const MS_PER_MINUTE = 60_000;
+
+/**
+ * 한 직원의 채우기 결과 — 다 만들었음 · 이어 하기(끝 시각 · 창이 닫힘 · 마무리를 새 차례로 넘김) · 할 일 없음(줄이 없음 · 이미 ready · 멈춤) ·
+ * 실패(상태 줄에 적었다 — 실패 수 · 멈춤)
+ */
+export type BuildOutcome = 'ready' | 'partial' | 'skipped' | 'failed';
+
+/** 한 묶음의 결과 */
+type StepOutcome = 'continue' | BuildOutcome;
+
+/**
+ * 지난 메일 채우기 (14단계 설계 B3) — 직원이 처음 로그인하면 상태 줄이 `building` 으로 생기고, 밤에 한 사람씩 채운다.
+ *
+ * - 시작: 엔진의 지금 Email state(S0)와 메일 수를 적는다 — 그때부터 새로 온 메일 · 바뀐 메일은 맞추기(B2)가 바로 넣는다
+ * - 받은 차례 최신부터 BUILD_BATCH_SIZE 통씩 글을 읽어 넣고 마지막 메일(anchor) · 넣은 수를 적는다 — 한 묶음마다 직원의 차례(MailSearchRuns)를
+ * 한 번씩 잡고 놓는다(그사이 맞추기가 끼어든다). 묶음 뒤 BUILD_BATCH_PAUSE_MS 쉰다(엔진 메모리 1g)
+ * - anchor 가 지워졌으면 넣은 수의 위치로 이어 간다 — 몇 통 겹치거나 빠질 수 있어 마지막 대조가 채운다
+ * - 다 읽으면: 맞추기로 따라잡고 → 전체 대조(빠진 것 넣기 · 없어진 것 지우기) → `ready`. 이 마무리는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만
+ * (= 새 차례의 처음에) 시작한다 — 시간 안에 끝나지 않으면 이어 하기로 두고 다음 차례에 다시 한다(넣은 것은 남으니 메일함이 커도 끝내 ready 가 된다)
+ * - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸리면
+ * (IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기로 끝낸다(실패 수 · 경고 · 미루기 없음)
+ * - 밤에만: 1분마다 깨어 창(isBuildWindow — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30) 안이면 사내 도메인 직원 한 사람을 BUILD_RUN_BUDGET_MS 동안.
+ * 창이 닫히면 다음 묶음을 시작하지 않는다. 시험에서는 build() 를 바로 불러 창을 건너뛴다
+ * 다 만들기 전 그 직원의 검색 · 대화는 엔진 방식이다(14단계 2차 · 3차가 상태를 본다).
+ *
+ * ⚠️ 1분 차례의 겹침 막기(running)는 프로세스 메모리다 — 인스턴스가 둘이면 두 차례가 같은 직원을 함께 채운다 (nestjs 스킬의 Redis 목록)
+ */
+@Injectable()
+export class MailSearchBuildService {
+ private readonly logger = new Logger(MailSearchBuildService.name);
+ private running = false;
+ private readonly timing: MailSearchTiming;
+
+ constructor(
+ @Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource,
+ private readonly repository: MailSearchIndexRepository,
+ private readonly runs: MailSearchRuns,
+ private readonly sync: MailSearchSyncService,
+ @Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming,
+ ) {
+ this.timing = timing ?? SYSTEM_SEARCH_TIMING;
+ }
+
+ /** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */
+ @Cron('0 * * * * *', {
+ name: 'mail-search-build',
+ timeZone: SEARCH_CRON_TIME_ZONE,
+ })
+ async tick(): Promise {
+ const startedAt = this.timing.now();
+ if (
+ this.running ||
+ this.sync.isStopping ||
+ !isBuildWindow(new Date(startedAt))
+ ) {
+ return null;
+ }
+ this.running = true;
+ try {
+ const candidates = await this.repository.buildCandidates(
+ BUILD_CANDIDATES_LIMIT,
+ );
+ const next = candidates.find(
+ (state) =>
+ this.sync.isIndexedAddress(state.userEmail) &&
+ isDueAfterFailures(state.failureCount, state.updatedAt, startedAt),
+ );
+ if (!next) return null;
+ return await this.build(
+ next.userEmail,
+ startedAt + BUILD_RUN_BUDGET_MS,
+ () => isBuildWindow(new Date(this.timing.now())),
+ );
+ } catch (error) {
+ this.logger.error(
+ `[SYS_001] 검색 색인 채우기 차례를 읽지 못했습니다: ${error instanceof Error ? error.name : typeof error}`,
+ );
+ return null;
+ } finally {
+ this.running = false;
+ }
+ }
+
+ /**
+ * 한 직원의 지난 메일을 끝 시각까지 채운다 — `keepGoing` 이 false 가 되면(밤 창이 닫힘) 다음 묶음을 시작하지 않는다. 던지지 않는다
+ */
+ async build(
+ user: string,
+ deadline: number,
+ keepGoing: () => boolean = () => true,
+ ): Promise {
+ const key = user.trim().toLowerCase();
+ for (;;) {
+ if (!this.hasTime(deadline) || !keepGoing()) return 'partial';
+ const step = await this.runs.run(key, () => this.step(key, deadline));
+ if (step !== 'continue') return step;
+ await this.timing.sleep(BUILD_BATCH_PAUSE_MS);
+ }
+ }
+
+ /** 한 묶음 — 그 직원의 차례 안에서 */
+ private async step(user: string, deadline: number): Promise {
+ try {
+ if (!this.hasTime(deadline)) return 'partial';
+ const state = await this.repository.findState(user);
+ if (
+ !state ||
+ state.status !== 'building' ||
+ state.pausedReason !== null
+ ) {
+ return 'skipped';
+ }
+ const now = new Date(this.timing.now());
+
+ if (state.buildStartedAt === null || state.emailState === null) {
+ // 시작 — 지금 state(S0)부터는 맞추기가 넣는다
+ const snapshot = await this.source.snapshot(user, { deadline });
+ const result = await this.repository.write(
+ user,
+ {
+ state: {
+ emailState: snapshot.state,
+ buildStartedAt: now,
+ buildTotal: snapshot.total,
+ buildDone: 0,
+ buildAnchor: null,
+ },
+ },
+ now,
+ );
+ return result.written ? 'continue' : 'skipped';
+ }
+
+ const page = await this.source.pageIds(
+ user,
+ { anchor: state.buildAnchor, position: state.buildDone },
+ BUILD_BATCH_SIZE,
+ { deadline },
+ );
+ if (page.ids.length === 0) {
+ // 다 읽었다 — 마무리는 새 차례의 처음에만 (남은 시간이 모자라면 다음 차례가 이것부터 한다)
+ if (this.timing.now() > deadline - FINISH_MIN_REMAINING_MS) {
+ return 'partial';
+ }
+ return await this.finish(user, deadline);
+ }
+
+ const indexable = page.ids.filter(isIndexableId);
+ const rows = await this.sync.readRows(user, indexable, deadline);
+ if (rows === null) return 'partial';
+ const result = await this.repository.write(
+ user,
+ {
+ upserts: rows,
+ state: {
+ // 다음 쪽의 자리 — 표에 넣을 수 있는 id 만(열 길이). 하나도 없으면 넣은 수의 위치로 잇는다
+ buildAnchor: indexable[indexable.length - 1] ?? null,
+ buildDone: page.position + page.ids.length,
+ },
+ },
+ new Date(this.timing.now()),
+ );
+ return result.written ? 'continue' : 'skipped';
+ } catch (error) {
+ // 차례의 끝 시각에 걸렸다 — 실패가 아니라 이어 하기 (이 묶음은 쓰지 않았다)
+ if (error instanceof IndexDeadlineError) return 'partial';
+ await this.sync.handleFailure(user, error, 'build');
+ return 'failed';
+ }
+ }
+
+ /** 다 읽었다 — 따라잡기 → 전체 대조(빠진 것 넣기) → ready (던진다 — step 이 받는다) */
+ private async finish(user: string, deadline: number): Promise {
+ if (!(await this.sync.catchUp(user, deadline))) return 'partial';
+ const startedAt = (await this.repository.findState(user))?.buildStartedAt;
+ let count = 0;
+ const done = await this.sync.reconcile(user, deadline, {
+ addMissing: true,
+ refreshMutable: false,
+ finalState: (indexed) => {
+ count = indexed;
+ return {
+ status: 'ready',
+ readyAt: new Date(this.timing.now()),
+ buildAnchor: null,
+ buildDone: indexed,
+ buildTotal: indexed,
+ };
+ },
+ });
+ if (!done) return 'partial';
+ const minutes = startedAt
+ ? Math.round((this.timing.now() - startedAt.getTime()) / MS_PER_MINUTE)
+ : null;
+ // 직원마다 한 번뿐인 상태 변화라 info 로 남긴다 (메일 수 · 걸린 때만 — 글 · 주소 없음)
+ this.logger.log(
+ `검색 색인을 다 만들었습니다: user=${maskEmail(user)}, 메일 ${count}통${minutes === null ? '' : `, 시작부터 ${minutes}분`}`,
+ );
+ return 'ready';
+ }
+
+ /** 다음 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */
+ private hasTime(deadline: number): boolean {
+ return (
+ !this.sync.isStopping &&
+ this.timing.now() <= deadline - STEP_MIN_REMAINING_MS
+ );
+ }
+}
diff --git a/backend/src/modules/mail-search/mail-search-index.repository.ts b/backend/src/modules/mail-search/mail-search-index.repository.ts
new file mode 100644
index 0000000..a1d4fe4
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-index.repository.ts
@@ -0,0 +1,326 @@
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+import type { DataSource, EntityManager } from 'typeorm';
+
+import type { IndexRowValues } from './index-document';
+import type { IndexMutable } from './mail-index-source';
+import { MailSearchMessageEntity } from './mail-search-message.entity';
+import { MailSearchStateEntity } from './mail-search-state.entity';
+import {
+ SEARCH_MESSAGES_TABLE,
+ SEARCH_STATES_TABLE,
+} from './mail-search.constants';
+import type {
+ SearchIndexStatus,
+ SearchPausedReason,
+} from './mail-search.types';
+
+/** 상태 줄에 함께 쓰는 칸 (쓰기가 성공하면 실패 수는 0 으로) */
+export interface SearchStatePatch {
+ readonly status?: SearchIndexStatus;
+ readonly emailState?: string | null;
+ readonly buildAnchor?: string | null;
+ readonly buildDone?: number;
+ readonly buildTotal?: number | null;
+ readonly buildStartedAt?: Date | null;
+ readonly readyAt?: Date | null;
+ readonly lastSyncedAt?: Date | null;
+}
+
+/** 한 트랜잭션에 쓰는 것 — 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */
+export interface SearchIndexWrite {
+ readonly deletes?: readonly string[];
+ readonly upserts?: readonly IndexRowValues[];
+ readonly updates?: readonly IndexMutable[];
+ readonly state?: SearchStatePatch;
+}
+
+/** 쓰기 결과 — 상태 줄이 없으면(퇴사 마무리가 지웠다) 아무것도 쓰지 않고 written false */
+export interface SearchIndexWriteResult {
+ readonly written: boolean;
+ /** 메일함 · 키워드를 바꾼 메일 id — 색인에 없던 메일은 빠진다 */
+ readonly updatedIds: ReadonlySet;
+}
+
+/** 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id) 밖의 모두 */
+const UPSERT_COLUMNS = [
+ 'mailbox_ids',
+ 'keywords',
+ 'received_at',
+ 'size',
+ 'message_id',
+ 'refs',
+ 'subject',
+ 'subject_base',
+ 'from_text',
+ 'to_text',
+ 'cc_text',
+ 'attachment_names',
+ 'has_attachment',
+ 'preview',
+ 'body_text',
+ 'fold_from',
+ 'fold_to',
+ 'fold_subject',
+ 'fold_attachment',
+ 'fold_body',
+ 'grams',
+ 'thread_key',
+ 'engine_thread_id',
+ 'indexed_at',
+];
+
+/**
+ * 메일 검색 색인 표(`mail_search_messages` · `mail_search_states`) 접근 (14단계 설계 A).
+ * 색인 줄을 쓰는 일(write)은 모두 그 직원의 상태 줄을 잠그고(FOR UPDATE) 같은 트랜잭션에서 한다 — 상태 줄이 없으면 쓰지 않는다
+ * (퇴사 마무리가 상태 줄 → 색인 줄 차례로 지운다 — ops-data.service.ts. 그사이 끝난 맞추기가 줄을 되살리지 않게).
+ * 언제나 직원 주소(user_email)로 묻는다.
+ */
+@Injectable()
+export class MailSearchIndexRepository {
+ constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
+
+ findState(userEmail: string): Promise {
+ return this.dataSource.manager.findOneBy(MailSearchStateEntity, {
+ userEmail,
+ });
+ }
+
+ /**
+ * 상태 줄이 없으면 `building` 으로 만들고, 멈춰 있으면 푼다(실패 수도 0) — 직원이 로그인했으니 대리 접근이 다시 될 것이다.
+ * 있고 멈추지 않았으면 쓰지 않는다(로그인 · 상태 묻기마다 줄을 고쳐 쓰지 않게).
+ *
+ * 퇴사 마무리 바로 뒤 남은 액세스 토큰으로 GET /api/search-index 가 오면 빈 상태 줄(building)이 다시 생길 수 있다 — 고치지 않는다:
+ * 엔진 계정이 먼저 지워졌으므로 채우기 · 맞추기는 대리 접근에서 막혀(AUTH_001) 그 줄을 멈출 뿐 메일 줄은 생기지 않고,
+ * 끊은 세션은 15초 안에 막힌다. 같은 주소를 새 사람이 쓰면 그 사람의 로그인이 그 줄로 처음부터 채운다(옛 메일 줄이 없다)
+ */
+ async ensureState(
+ userEmail: string,
+ now: Date,
+ ): Promise {
+ const existing = await this.findState(userEmail);
+ if (existing && existing.pausedReason === null) return existing;
+ if (existing) {
+ await this.dataSource.manager.update(
+ MailSearchStateEntity,
+ { userEmail },
+ { pausedReason: null, failureCount: 0, updatedAt: now },
+ );
+ } else {
+ await this.dataSource.manager
+ .createQueryBuilder()
+ .insert()
+ .into(MailSearchStateEntity)
+ .values({
+ userEmail,
+ status: 'building',
+ emailState: null,
+ buildAnchor: null,
+ buildDone: 0,
+ buildTotal: null,
+ buildStartedAt: null,
+ readyAt: null,
+ lastSyncedAt: null,
+ pausedReason: null,
+ failureCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .orIgnore()
+ .execute();
+ }
+ const saved = await this.findState(userEmail);
+ if (!saved) {
+ // 만든 바로 뒤 다른 곳(퇴사 마무리)이 지웠다 — 처음 상태로 본다
+ const fresh = new MailSearchStateEntity();
+ Object.assign(fresh, {
+ userEmail,
+ status: 'building',
+ emailState: null,
+ buildAnchor: null,
+ buildDone: 0,
+ buildTotal: null,
+ buildStartedAt: null,
+ readyAt: null,
+ lastSyncedAt: null,
+ pausedReason: null,
+ failureCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return fresh;
+ }
+ return saved;
+ }
+
+ /** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */
+ syncCandidates(): Promise {
+ return this.dataSource.manager
+ .createQueryBuilder(MailSearchStateEntity, 'state')
+ .where('state.email_state IS NOT NULL')
+ .andWhere('state.paused_reason IS NULL')
+ .orderBy('state.last_synced_at', 'ASC', 'NULLS FIRST')
+ .addOrderBy('state.user_email', 'ASC')
+ .getMany();
+ }
+
+ /** 밤 채우기의 차례 — building 이고 멈추지 않은 직원: 실패가 적은 · 먼저 시작한 · 먼저 만든 차례로 */
+ buildCandidates(limit: number): Promise {
+ return this.dataSource.manager
+ .createQueryBuilder(MailSearchStateEntity, 'state')
+ .where('state.status = :status', { status: 'building' })
+ .andWhere('state.paused_reason IS NULL')
+ .orderBy('state.failure_count', 'ASC')
+ .addOrderBy('state.build_started_at', 'ASC', 'NULLS LAST')
+ .addOrderBy('state.created_at', 'ASC')
+ .limit(limit)
+ .getMany();
+ }
+
+ /** 실패 한 번 → 이어진 실패 수 (줄이 없으면 0) */
+ async recordFailure(userEmail: string, now: Date): Promise {
+ // UPDATE 의 결과는 [줄들, 바꾼 수] (TypeORM 의 postgres 드라이버)
+ const [rows] = await this.dataSource.query<
+ [{ failure_count: number }[], number]
+ >(
+ `UPDATE "${SEARCH_STATES_TABLE}" SET "failure_count" = "failure_count" + 1, "updated_at" = $2 WHERE "user_email" = $1 RETURNING "failure_count"`,
+ [userEmail, now],
+ );
+ return Number(rows[0]?.failure_count ?? 0);
+ }
+
+ /** 대리 접근이 막혀 멈춘다 (다음 로그인 때 ensureState 가 푼다) */
+ async pause(
+ userEmail: string,
+ reason: SearchPausedReason,
+ now: Date,
+ ): Promise {
+ await this.dataSource.manager.update(
+ MailSearchStateEntity,
+ { userEmail },
+ { pausedReason: reason, updatedAt: now },
+ );
+ }
+
+ /** 그 직원의 색인 줄 수 */
+ async countMessages(userEmail: string): Promise {
+ const rows: { count: number }[] = await this.dataSource.query(
+ `SELECT count(*)::int AS "count" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
+ [userEmail],
+ );
+ return Number(rows[0]?.count ?? 0);
+ }
+
+ /** 주어진 id 가운데 그 직원의 색인에 있는 것 */
+ async existingIds(
+ userEmail: string,
+ ids: readonly string[],
+ ): Promise> {
+ if (ids.length === 0) return new Set();
+ const rows: { email_id: string }[] = await this.dataSource.query(
+ `SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
+ [userEmail, [...ids]],
+ );
+ return new Set(rows.map((row) => row.email_id));
+ }
+
+ /** 그 직원의 색인에 든 메일 id 모두 (전체 대조) */
+ async indexedIds(userEmail: string): Promise {
+ const rows: { email_id: string }[] = await this.dataSource.query(
+ `SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
+ [userEmail],
+ );
+ return rows.map((row) => row.email_id);
+ }
+
+ /**
+ * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0).
+ * `now` 는 넣은 줄의 indexed_at · 상태 줄의 updated_at
+ */
+ async write(
+ userEmail: string,
+ change: SearchIndexWrite,
+ now: Date,
+ ): Promise {
+ return this.dataSource.transaction(async (manager) => {
+ const locked: unknown[] = await manager.query(
+ `SELECT 1 FROM "${SEARCH_STATES_TABLE}" WHERE "user_email" = $1 FOR UPDATE`,
+ [userEmail],
+ );
+ if (locked.length === 0) {
+ return { written: false, updatedIds: new Set() };
+ }
+ const deletes = [...new Set(change.deletes ?? [])];
+ if (deletes.length > 0) {
+ await manager.query(
+ `DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
+ [userEmail, deletes],
+ );
+ }
+ await this.upsertRows(manager, userEmail, change.upserts ?? [], now);
+ const updatedIds = await this.updateMutable(
+ manager,
+ userEmail,
+ change.updates ?? [],
+ );
+ if (change.state) {
+ await manager.update(
+ MailSearchStateEntity,
+ { userEmail },
+ { ...change.state, failureCount: 0, updatedAt: now },
+ );
+ }
+ return { written: true, updatedIds };
+ });
+ }
+
+ /** 넣기(있으면 모든 칸을 바꾸기) — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
+ private async upsertRows(
+ manager: EntityManager,
+ userEmail: string,
+ rows: readonly IndexRowValues[],
+ now: Date,
+ ): Promise {
+ if (rows.length === 0) return;
+ const latest = new Map(rows.map((row) => [row.emailId, row]));
+ await manager
+ .createQueryBuilder()
+ .insert()
+ .into(MailSearchMessageEntity)
+ .values(
+ [...latest.values()].map((row) => ({
+ ...row,
+ userEmail,
+ indexedAt: now,
+ })),
+ )
+ .orUpdate(UPSERT_COLUMNS, ['user_email', 'email_id'])
+ .execute();
+ }
+
+ /** 메일함 · 키워드만 바꾼다 (한 문장) → 바꾼 id */
+ private async updateMutable(
+ manager: EntityManager,
+ userEmail: string,
+ updates: readonly IndexMutable[],
+ ): Promise> {
+ if (updates.length === 0) return new Set();
+ const values: Record = {};
+ for (const update of updates) {
+ values[update.id] = {
+ m: [...new Set(update.mailboxIds)],
+ k: [...new Set(update.keywords)],
+ };
+ }
+ const [rows] = await manager.query<[{ email_id: string }[], number]>(
+ `UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message"
+ SET "mailbox_ids" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'm')),
+ "keywords" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'k'))
+ FROM jsonb_each($2::jsonb) AS "change"("key", "value")
+ WHERE "message"."user_email" = $1 AND "message"."email_id" = "change"."key"
+ RETURNING "message"."email_id"`,
+ [userEmail, JSON.stringify(values)],
+ );
+ return new Set(rows.map((row) => row.email_id));
+ }
+}
diff --git a/backend/src/modules/mail-search/mail-search-index.service.spec.ts b/backend/src/modules/mail-search/mail-search-index.service.spec.ts
new file mode 100644
index 0000000..9214ff5
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-index.service.spec.ts
@@ -0,0 +1,66 @@
+import { Logger } from '@nestjs/common';
+
+import type { MailSearchIndexRepository } from './mail-search-index.repository';
+import {
+ MailSearchIndexService,
+ toStatusView,
+} from './mail-search-index.service';
+
+const ARA = 'ara.kim@arasystem.test';
+
+describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
+ let ensureState: jest.Mock;
+ let service: MailSearchIndexService;
+ let warnings: string[];
+
+ beforeEach(() => {
+ ensureState = jest.fn().mockResolvedValue({
+ status: 'building',
+ buildDone: 0,
+ buildTotal: null,
+ });
+ service = new MailSearchIndexService({
+ ensureState,
+ } as unknown as MailSearchIndexRepository);
+ warnings = [];
+ jest.spyOn(Logger.prototype, 'warn').mockImplementation((message) => {
+ warnings.push(String(message));
+ });
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('상태를 물으면 줄을 만들거나 멈춤을 풀고(소문자 주소로) 준비 상태를 준다', async () => {
+ expect(await service.status(' Ara.Kim@ARASYSTEM.test ')).toEqual({
+ status: 'building',
+ done: 0,
+ total: null,
+ });
+ expect(ensureState).toHaveBeenCalledWith(ARA, expect.any(Date));
+ });
+
+ it('로그인 뒤 줄 만들기가 실패해도 던지지 않는다 — 경고만 (주소 · DB 문장은 가려서)', async () => {
+ ensureState.mockRejectedValue(
+ Object.assign(new Error(`duplicate key (${ARA})`), { code: '23505' }),
+ );
+ await expect(service.enrollAfterLogin(ARA)).resolves.toBeUndefined();
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain('Error/23505');
+ expect(warnings[0]).not.toContain(ARA);
+ });
+
+ it('toStatusView — 다 만들었으면 채운 수 = 채울 수', () => {
+ expect(
+ toStatusView({ status: 'ready', buildDone: 120, buildTotal: 120 }),
+ ).toEqual({ status: 'ready', done: 120, total: 120 });
+ });
+
+ it('toStatusView — 채운 수는 채울 수를 넘지 않는다 (채우는 동안 온 메일 · 위치로 이어 간 겹침) · 시작 전에는 채울 수 null', () => {
+ expect(
+ toStatusView({ status: 'building', buildDone: 130, buildTotal: 120 }),
+ ).toEqual({ status: 'building', done: 120, total: 120 });
+ expect(
+ toStatusView({ status: 'building', buildDone: 0, buildTotal: null }),
+ ).toEqual({ status: 'building', done: 0, total: null });
+ });
+});
diff --git a/backend/src/modules/mail-search/mail-search-index.service.ts b/backend/src/modules/mail-search/mail-search-index.service.ts
new file mode 100644
index 0000000..eca8c5f
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-index.service.ts
@@ -0,0 +1,65 @@
+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 type { MailSearchStateEntity } from './mail-search-state.entity';
+import type { SearchIndexStatusView } from './mail-search.types';
+
+/**
+ * 상태 줄 → 화면의 준비 상태 (GET /api/search-index). 채운 수는 채울 수를 넘지 않게 묶는다 —
+ * 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다
+ */
+export function toStatusView(
+ state: Pick,
+): SearchIndexStatusView {
+ const done = Math.max(0, state.buildDone);
+ const total =
+ state.buildTotal === null ? null : Math.max(0, state.buildTotal);
+ return {
+ status: state.status,
+ done: total === null ? done : Math.min(done, total),
+ total,
+ };
+}
+
+/** DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만 */
+function errorKind(error: unknown): string {
+ const name = error instanceof Error ? error.name : typeof error;
+ const code = (error as { code?: unknown } | null)?.code;
+ return typeof code === 'string' ? `${name}/${code}` : name;
+}
+
+/**
+ * 검색 색인의 직원 상태 (14단계 설계 A2 · C1) — 상태 줄 만들기 · 멈춤 풀기 · 준비 상태 알리기.
+ * 줄은 직원이 웹메일에 로그인할 때(AuthService) · 검색 준비 상태를 물을 때 만든다 — 로그인한 적 없는 계정은 색인하지 않는다.
+ * 대리 접근이 막혀 멈췄던 줄은 이때 푼다(로그인했으니 다시 될 것이다).
+ */
+@Injectable()
+export class MailSearchIndexService {
+ private readonly logger = new Logger(MailSearchIndexService.name);
+
+ constructor(private readonly repository: MailSearchIndexRepository) {}
+
+ /** 로그인에 성공한 직원 — 상태 줄을 만들거나 멈춤을 푼다. 던지지 않는다(못 해도 로그인은 그대로 — 다음 로그인 · 검색 때 다시) */
+ async enrollAfterLogin(user: string): Promise {
+ try {
+ await this.repository.ensureState(normalize(user), new Date());
+ } catch (error) {
+ this.logger.warn(
+ `[SYS_001]${traceTag()} 검색 색인의 상태 줄을 만들지 못했습니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${errorKind(error)}`,
+ );
+ }
+ }
+
+ /** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */
+ async status(user: string): Promise {
+ return toStatusView(
+ await this.repository.ensureState(normalize(user), new Date()),
+ );
+ }
+}
+
+function normalize(user: string): string {
+ return user.trim().toLowerCase();
+}
diff --git a/backend/src/modules/mail-search/mail-search-message.entity.ts b/backend/src/modules/mail-search/mail-search-message.entity.ts
new file mode 100644
index 0000000..43784cd
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-message.entity.ts
@@ -0,0 +1,143 @@
+import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
+
+import {
+ SEARCH_EMAIL_COLUMN_LENGTH,
+ SEARCH_ID_COLUMN_LENGTH,
+ SEARCH_MESSAGES_TABLE,
+} from './mail-search.constants';
+
+/**
+ * 메일 검색 색인의 한 줄 — 직원 주소 + 엔진 메일 id 마다 (14단계 설계 A1).
+ * 언제나 로그인한 직원 주소(user_email)로만 묻는다 — 남의 줄을 읽는 길이 없다.
+ *
+ * 글 칸은 둘씩이다 — 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)과 접기 글(fold_* — shared/utils/search-text.ts 의 foldText, strpos 확인용).
+ * 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고
+ * GIN 으로 후보를 좁힌다.
+ *
+ * GIN 색인 셋(grams · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
+ * (migration:generate 가 지우지 않게).
+ */
+@Entity({ name: SEARCH_MESSAGES_TABLE })
+@Index('IDX_mail_search_messages_received', [
+ 'userEmail',
+ 'receivedAt',
+ 'emailId',
+])
+@Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId'])
+@Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey'])
+@Index('IDX_mail_search_messages_grams', { synchronize: false })
+@Index('IDX_mail_search_messages_mailbox_ids', { synchronize: false })
+@Index('IDX_mail_search_messages_refs', { synchronize: false })
+export class MailSearchMessageEntity {
+ @PrimaryColumn({
+ name: 'user_email',
+ type: 'varchar',
+ length: SEARCH_EMAIL_COLUMN_LENGTH,
+ })
+ userEmail!: string;
+
+ /** 엔진 메일 id */
+ @PrimaryColumn({
+ name: 'email_id',
+ type: 'varchar',
+ length: SEARCH_ID_COLUMN_LENGTH,
+ })
+ emailId!: string;
+
+ /** 든 메일함 id 들 — 메일함 범위 · 휴지통 · 스팸메일함 빼기 */
+ @Column({ name: 'mailbox_ids', type: 'text', array: true })
+ mailboxIds!: string[];
+
+ /** 켜진 키워드(`$seen` · `$flagged` · `$draft` · 우리 내부 키워드) — 엔진이 준 그대로 */
+ @Column({ type: 'text', array: true })
+ keywords!: string[];
+
+ /** 받은 시각 — 차례 · 기간 */
+ @Column({ name: 'received_at', type: 'timestamptz' })
+ receivedAt!: Date;
+
+ @Column({ type: 'integer' })
+ size!: number;
+
+ /** 첫 Message-ID (꺾쇠 없이) — 없으면 null */
+ @Column({ name: 'message_id', type: 'text', nullable: true })
+ messageId!: string | null;
+
+ /** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터(겹치지 않게, 앞 INDEX_REFS_MAX 개) */
+ @Column({ type: 'text', array: true })
+ refs!: string[];
+
+ /** 제목 원문(NFC) */
+ @Column({ type: 'text' })
+ subject!: string;
+
+ /** 말머리를 뗀 제목의 접기 글 — 대화 키를 정할 때 (shared/utils/subject-base.ts) */
+ @Column({ name: 'subject_base', type: 'text' })
+ subjectBase!: string;
+
+ /** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) */
+ @Column({ name: 'from_text', type: 'text' })
+ fromText!: string;
+
+ @Column({ name: 'to_text', type: 'text' })
+ toText!: string;
+
+ @Column({ name: 'cc_text', type: 'text' })
+ ccText!: string;
+
+ /** 진짜 첨부(본문 그림이 아닌 것)의 이름을 줄바꿈으로 */
+ @Column({ name: 'attachment_names', type: 'text' })
+ attachmentNames!: string;
+
+ /** 목록과 같은 판정 (html/inline-images.ts 의 hasRealAttachment) */
+ @Column({ name: 'has_attachment', type: 'boolean' })
+ hasAttachment!: boolean;
+
+ /** 엔진의 미리 보기 */
+ @Column({ type: 'text' })
+ preview!: string;
+
+ /** 본문 글(HTML 이면 글자만) 앞 INDEX_BODY_MAX_CHARS 코드 포인트 */
+ @Column({ name: 'body_text', type: 'text' })
+ bodyText!: string;
+
+ @Column({ name: 'fold_from', type: 'text' })
+ foldFrom!: string;
+
+ /** 받는 사람과 참조를 함께 (조건 검색의 "받는 사람"은 참조를 포함한다) */
+ @Column({ name: 'fold_to', type: 'text' })
+ foldTo!: string;
+
+ @Column({ name: 'fold_subject', type: 'text' })
+ foldSubject!: string;
+
+ @Column({ name: 'fold_attachment', type: 'text' })
+ foldAttachment!: string;
+
+ @Column({ name: 'fold_body', type: 'text' })
+ foldBody!: string;
+
+ /** 다섯 접기 칸의 글자 두 개 묶음 — GIN (`grams @> 찾는 말의 묶음`) */
+ @Column({ type: 'text', array: true })
+ grams!: string[];
+
+ /** 우리 대화 키 — 14단계 1차에서는 그 메일 id (잇기는 3차) */
+ @Column({
+ name: 'thread_key',
+ type: 'varchar',
+ length: SEARCH_ID_COLUMN_LENGTH,
+ })
+ threadKey!: string;
+
+ /** 엔진의 threadId — 색인을 다 만들기 전 대화 · 묶기의 대신 */
+ @Column({
+ name: 'engine_thread_id',
+ type: 'varchar',
+ length: SEARCH_ID_COLUMN_LENGTH,
+ nullable: true,
+ })
+ engineThreadId!: string | null;
+
+ @Column({ name: 'indexed_at', type: 'timestamptz' })
+ indexedAt!: Date;
+}
diff --git a/backend/src/modules/mail-search/mail-search-runs.ts b/backend/src/modules/mail-search/mail-search-runs.ts
new file mode 100644
index 0000000..c4c3166
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-runs.ts
@@ -0,0 +1,71 @@
+import { Injectable } from '@nestjs/common';
+
+import { runOutsideRequestContext } from '@/shared/logger/request-context';
+
+/**
+ * 직원마다 색인 일을 한 번에 하나씩 (14단계 설계 B2 — "같은 직원은 한 번에 하나") — 맞추기 · 채우기 한 묶음 · 전체 대조가 같은 줄에 선다.
+ * 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게.
+ *
+ * ⚠️ 프로세스 안의 차례다 — 인스턴스를 늘리면 두 인스턴스가 같은 직원을 함께 맞춘다(쓰기는 상태 줄을 잠가 차례로 되지만 같은 일을 두 번 한다).
+ * 그때는 잠금(Redis `SET NX PX` 또는 `pg_advisory_lock`)으로 옮긴다 (nestjs 스킬의 Redis 목록)
+ */
+@Injectable()
+export class MailSearchRuns {
+ private readonly tails = new Map>();
+
+ /** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */
+ run(user: string, task: () => Promise): Promise {
+ const key = user.toLowerCase();
+ const previous = this.tails.get(key) ?? Promise.resolve();
+ const start = () => runOutsideRequestContext(task);
+ const result = previous.then(start, start);
+ const tail = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ this.tails.set(key, tail);
+ void tail.then(() => {
+ if (this.tails.get(key) === tail) this.tails.delete(key);
+ });
+ return result;
+ }
+
+ /** 그 직원의 일이 줄에 있거나 도는 중인가 */
+ isBusy(user: string): boolean {
+ return this.tails.has(user.toLowerCase());
+ }
+}
+
+/**
+ * 함께 도는 일의 수 — 넘는 일은 먼저 온 차례로 기다린다 (14단계 — 실시간 소식이 직원 수만큼 한꺼번에 와도 맞추기는 이만큼만 함께).
+ * 앞의 일이 실패해도 자리는 돌려준다.
+ *
+ * ⚠️ 프로세스 안의 셈이다 — 인스턴스를 늘리면 인스턴스마다 따로 센다 (nestjs 스킬의 Redis 목록)
+ */
+export class ConcurrencyGate {
+ private active = 0;
+ private readonly waiting: (() => void)[] = [];
+
+ constructor(private readonly limit: number) {}
+
+ /** 지금 도는 일 · 기다리는 일의 수 (시험용) */
+ get load(): { readonly active: number; readonly waiting: number } {
+ return { active: this.active, waiting: this.waiting.length };
+ }
+
+ async run(task: () => Promise): Promise {
+ if (this.active >= this.limit) {
+ await new Promise((resolve) => this.waiting.push(resolve));
+ } else {
+ this.active += 1;
+ }
+ try {
+ return await task();
+ } finally {
+ // 기다리는 일에 자리를 그대로 넘긴다 (자리 수는 그대로) — 없으면 돌려준다
+ const next = this.waiting.shift();
+ if (next) next();
+ else this.active -= 1;
+ }
+ }
+}
diff --git a/backend/src/modules/mail-search/mail-search-state.entity.ts b/backend/src/modules/mail-search/mail-search-state.entity.ts
new file mode 100644
index 0000000..f97c2f1
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-state.entity.ts
@@ -0,0 +1,83 @@
+import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
+
+import {
+ SEARCH_EMAIL_COLUMN_LENGTH,
+ SEARCH_ID_COLUMN_LENGTH,
+ SEARCH_STATES_TABLE,
+ SEARCH_WORD_COLUMN_LENGTH,
+} from './mail-search.constants';
+import type {
+ SearchIndexStatus,
+ SearchPausedReason,
+} from './mail-search.types';
+
+/**
+ * 직원마다 색인 상태 한 줄 (14단계 설계 A2) — 직원이 웹메일에 처음 로그인하거나 검색 준비 상태를 물을 때 `building` 으로 만든다.
+ * 로그인한 적 없는 계정은 색인하지 않는다.
+ *
+ * 색인 줄을 쓰는 일은 모두 이 줄을 잠그고(SELECT … FOR UPDATE) 같은 트랜잭션에서 한다 — 줄이 없으면(퇴사 마무리가 지웠다) 쓰지 않는다.
+ * 퇴사 마무리는 이 줄을 먼저 지우고 색인 줄을 지운다 (ops-data.service.ts — 그사이 끝난 쓰기가 남지 않게)
+ */
+@Entity({ name: SEARCH_STATES_TABLE })
+@Index('IDX_mail_search_states_status', ['status'])
+export class MailSearchStateEntity {
+ @PrimaryColumn({
+ name: 'user_email',
+ type: 'varchar',
+ length: SEARCH_EMAIL_COLUMN_LENGTH,
+ })
+ userEmail!: string;
+
+ @Column({ type: 'varchar', length: SEARCH_WORD_COLUMN_LENGTH })
+ status!: SearchIndexStatus;
+
+ /** 엔진 Email state — 맞추기(Email/changes)의 기준. 채우기를 시작하기 전에는 null (그때는 맞추지 않는다) */
+ @Column({ name: 'email_state', type: 'text', nullable: true })
+ emailState!: string | null;
+
+ /** 채우기를 이어 할 자리 — 마지막으로 넣은 메일 id (받은 차례 최신부터) */
+ @Column({
+ name: 'build_anchor',
+ type: 'varchar',
+ length: SEARCH_ID_COLUMN_LENGTH,
+ nullable: true,
+ })
+ buildAnchor!: string | null;
+
+ /** 채운 메일 수 (anchor 가 지워졌을 때 이어 갈 위치이기도 하다) */
+ @Column({ name: 'build_done', type: 'integer', default: 0 })
+ buildDone!: number;
+
+ /** 채울 메일 수 — 채우기를 시작할 때의 엔진 메일 수 */
+ @Column({ name: 'build_total', type: 'integer', nullable: true })
+ buildTotal!: number | null;
+
+ @Column({ name: 'build_started_at', type: 'timestamptz', nullable: true })
+ buildStartedAt!: Date | null;
+
+ @Column({ name: 'ready_at', type: 'timestamptz', nullable: true })
+ readyAt!: Date | null;
+
+ /** 마지막으로 맞춘 때 */
+ @Column({ name: 'last_synced_at', type: 'timestamptz', nullable: true })
+ lastSyncedAt!: Date | null;
+
+ /** 대리 접근이 막혀 멈춘 까닭 — 없으면 null (mail-search.types.ts) */
+ @Column({
+ name: 'paused_reason',
+ type: 'varchar',
+ length: SEARCH_WORD_COLUMN_LENGTH,
+ nullable: true,
+ })
+ pausedReason!: SearchPausedReason | null;
+
+ /** 이어진 실패 수 — 성공하면 0. 많을수록 차례에서 뒤로 미룬다 */
+ @Column({ name: 'failure_count', type: 'integer', default: 0 })
+ failureCount!: number;
+
+ @Column({ name: 'created_at', type: 'timestamptz' })
+ createdAt!: Date;
+
+ @Column({ name: 'updated_at', type: 'timestamptz' })
+ updatedAt!: Date;
+}
diff --git a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts
new file mode 100644
index 0000000..9cdcb94
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts
@@ -0,0 +1,859 @@
+import { Logger } from '@nestjs/common';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ AccountLoginDisabledException,
+ PasswordExpiredException,
+} from '@/modules/mail/mail.provider';
+import type { IndexRowValues } from './index-document';
+import {
+ IndexDeadlineError,
+ type IndexChanges,
+ type IndexDocument,
+ type IndexIdPage,
+ type IndexMutable,
+ type IndexReadOptions,
+ type MailIndexSource,
+} from './mail-index-source';
+import { MailSearchBuildService } from './mail-search-build.service';
+import type {
+ MailSearchIndexRepository,
+ SearchIndexWrite,
+} from './mail-search-index.repository';
+import { MailSearchRuns } from './mail-search-runs';
+import type { MailSearchStateEntity } from './mail-search-state.entity';
+import { MailSearchSyncService } from './mail-search-sync.service';
+import type { MailSearchTiming } from './mail-search-timing';
+import {
+ BUILD_BATCH_SIZE,
+ BUILD_RUN_BUDGET_MS,
+ INDEX_CALL_MIN_MS,
+ STEP_MIN_REMAINING_MS,
+ SYNC_CONCURRENCY,
+ SYNC_DEBOUNCE_MS,
+} from './mail-search.constants';
+
+const DOMAIN = 'arasystem.test';
+const ARA = `ara.kim@${DOMAIN}`;
+/** 한국 시각 밤 23시 — 채우기 창 안 */
+const NIGHT = Date.parse('2026-11-05T23:00:00+09:00');
+/** 넉넉한 끝 시각 (시계는 쉬기 · 엔진 읽기 값만큼만 간다) */
+const LONG = 3_600_000;
+
+/** 시험의 시계 — 엔진 대역과 서비스가 함께 본다 */
+interface TestClock {
+ now(): number;
+ advance(ms: number): void;
+}
+
+type ChangeEntry = {
+ created: string[];
+ updated: string[];
+ destroyed: string[];
+};
+
+/**
+ * 대역 엔진 — 메일 · state · 변화 기록. 테스트가 메일을 넣고 · 바꾸고 · 지운다.
+ * 실제 어댑터처럼 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 읽지 않고 IndexDeadlineError, 읽을 때마다 callCostMs 만큼 시계가 간다
+ */
+class FakeEngine implements MailIndexSource {
+ readonly emails = new Map<
+ string,
+ { receivedAt: number; mailboxIds: string[]; keywords: string[] }
+ >();
+ private stateNumber = 0;
+ /** state 번호 → 그 state 가 된 변화 */
+ private readonly log: ChangeEntry[] = [];
+ /** 이보다 오래된 state 는 셀 수 없다 */
+ forgetBefore = 0;
+ /** 다음 부름에 던질 실패 */
+ failNext: Error | null = null;
+ /** 엔진 읽기 한 번에 가는 시간 */
+ callCostMs = 0;
+ /** 있으면 changes 가 답을 만든 뒤 이것이 풀릴 때까지 기다린다 (맞추는 중인 때를 만든다) */
+ changesGate: Promise | null = null;
+ /** 지금 답을 기다리는 changes 수 · 가장 많았던 수 */
+ inFlight = 0;
+ maxInFlight = 0;
+ readonly calls: string[] = [];
+ /** documents 에 실린 id 들 (부름마다) */
+ readonly documentRequests: string[][] = [];
+
+ constructor(private readonly clock: TestClock) {}
+
+ get state(): string {
+ return `s${this.stateNumber}`;
+ }
+
+ add(id: string, receivedAt: number, mailboxIds = ['inbox']): void {
+ this.emails.set(id, { receivedAt, mailboxIds, keywords: [] });
+ this.record({ created: [id], updated: [], destroyed: [] });
+ }
+
+ move(id: string, mailboxIds: string[]): void {
+ const email = this.emails.get(id);
+ if (email) email.mailboxIds = mailboxIds;
+ this.record({ created: [], updated: [id], destroyed: [] });
+ }
+
+ remove(id: string): void {
+ this.emails.delete(id);
+ this.record({ created: [], updated: [], destroyed: [id] });
+ }
+
+ /** 엔진이 같은 쪽에 만든 메일을 바뀐 메일로도 함께 알린다 (RFC 8620 은 둘 다 실을 수 있다) */
+ addReportedTwice(id: string, receivedAt: number): void {
+ this.emails.set(id, { receivedAt, mailboxIds: ['inbox'], keywords: [] });
+ this.record({ created: [id], updated: [id, id], destroyed: [] });
+ }
+
+ /** 변화 기록 없이 넣는다 (지난 메일) */
+ seed(count: number, prefix = 'old'): void {
+ for (let index = 0; index < count; index += 1) {
+ this.emails.set(`${prefix}${index}`, {
+ receivedAt: 1_000 + index,
+ mailboxIds: ['inbox'],
+ keywords: [],
+ });
+ }
+ }
+
+ private record(entry: ChangeEntry): void {
+ this.log.push(entry);
+ this.stateNumber += 1;
+ }
+
+ private check(name: string, options: IndexReadOptions): void {
+ this.calls.push(name);
+ if (this.failNext) {
+ const error = this.failNext;
+ this.failNext = null;
+ throw error;
+ }
+ if (options.deadline - this.clock.now() < INDEX_CALL_MIN_MS) {
+ throw new IndexDeadlineError();
+ }
+ this.clock.advance(this.callCostMs);
+ }
+
+ snapshot(_user: string, options: IndexReadOptions) {
+ this.check('snapshot', options);
+ return Promise.resolve({ state: this.state, total: this.emails.size });
+ }
+
+ async changes(
+ _user: string,
+ sinceState: string,
+ maxChanges: number,
+ options: IndexReadOptions,
+ ): Promise {
+ this.check('changes', options);
+ const since = Number(sinceState.slice(1));
+ let result: IndexChanges;
+ if (!Number.isInteger(since) || since < this.forgetBefore) {
+ result = { kind: 'cannotCalculate' };
+ } else {
+ const entries = this.log.slice(since, since + maxChanges);
+ result = {
+ kind: 'changes',
+ created: entries.flatMap((entry) => entry.created),
+ updated: entries.flatMap((entry) => entry.updated),
+ destroyed: entries.flatMap((entry) => entry.destroyed),
+ newState: `s${since + entries.length}`,
+ hasMore: since + entries.length < this.stateNumber,
+ };
+ }
+ this.inFlight += 1;
+ this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
+ try {
+ if (this.changesGate) await this.changesGate;
+ } finally {
+ this.inFlight -= 1;
+ }
+ return result;
+ }
+
+ documents(
+ _user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise {
+ this.check('documents', options);
+ this.documentRequests.push([...ids]);
+ return Promise.resolve(
+ ids.flatMap((id) => {
+ const email = this.emails.get(id);
+ if (!email) return [];
+ return [
+ {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: email.mailboxIds,
+ keywords: email.keywords,
+ receivedAt: new Date(email.receivedAt).toISOString(),
+ size: 10,
+ messageIds: [`${id}@example.com`],
+ inReplyTo: [],
+ references: [],
+ subject: `제목 ${id}`,
+ from: [{ name: null, email: 'partner@example.com' }],
+ to: [{ name: null, email: ARA }],
+ cc: [],
+ attachmentNames: [],
+ hasAttachment: false,
+ preview: '',
+ bodyText: `본문 ${id}`,
+ },
+ ];
+ }),
+ );
+ }
+
+ mutable(
+ _user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise {
+ this.check('mutable', options);
+ return Promise.resolve(
+ ids.flatMap((id) => {
+ const email = this.emails.get(id);
+ return email
+ ? [{ id, mailboxIds: email.mailboxIds, keywords: email.keywords }]
+ : [];
+ }),
+ );
+ }
+
+ newestFirst(): string[] {
+ return [...this.emails.entries()]
+ .sort((a, b) => b[1].receivedAt - a[1].receivedAt)
+ .map(([id]) => id);
+ }
+
+ pageIds(
+ _user: string,
+ from: { anchor: string | null; position: number },
+ limit: number,
+ options: IndexReadOptions,
+ ): Promise {
+ this.check('pageIds', options);
+ const all = this.newestFirst();
+ // anchor 가 지워졌으면 위치로 (실제 어댑터와 같다)
+ const anchorAt = from.anchor === null ? -1 : all.indexOf(from.anchor);
+ const start = anchorAt >= 0 ? anchorAt + 1 : from.position;
+ return Promise.resolve({
+ ids: all.slice(start, start + limit),
+ position: start,
+ total: all.length,
+ });
+ }
+
+ allIds(_user: string, options: IndexReadOptions) {
+ this.check('allIds', options);
+ return Promise.resolve({ state: this.state, ids: this.newestFirst() });
+ }
+}
+
+/** 대역 표 — 상태 줄 · 색인 줄을 메모리에 (색인 줄은 한 직원 것만 본다) */
+class FakeRepository {
+ readonly states = new Map();
+ readonly rows = new Map();
+ readonly writes: SearchIndexWrite[] = [];
+
+ addState(
+ overrides: Partial = {},
+ ): MailSearchStateEntity {
+ const state: MailSearchStateEntity = {
+ userEmail: ARA,
+ status: 'building',
+ emailState: null,
+ buildAnchor: null,
+ buildDone: 0,
+ buildTotal: null,
+ buildStartedAt: null,
+ readyAt: null,
+ lastSyncedAt: null,
+ pausedReason: null,
+ failureCount: 0,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ ...overrides,
+ };
+ this.states.set(state.userEmail, state);
+ return state;
+ }
+
+ findState(user: string) {
+ return Promise.resolve(this.states.get(user) ?? null);
+ }
+
+ syncCandidates() {
+ return Promise.resolve(
+ [...this.states.values()].filter(
+ (state) => state.emailState !== null && state.pausedReason === null,
+ ),
+ );
+ }
+
+ buildCandidates() {
+ return Promise.resolve(
+ [...this.states.values()].filter(
+ (state) => state.status === 'building' && state.pausedReason === null,
+ ),
+ );
+ }
+
+ recordFailure(user: string, now: Date) {
+ const state = this.states.get(user);
+ if (!state) return Promise.resolve(0);
+ state.failureCount += 1;
+ state.updatedAt = now;
+ return Promise.resolve(state.failureCount);
+ }
+
+ pause(user: string, reason: MailSearchStateEntity['pausedReason']) {
+ const state = this.states.get(user);
+ if (state) state.pausedReason = reason;
+ return Promise.resolve();
+ }
+
+ countMessages() {
+ return Promise.resolve(this.rows.size);
+ }
+
+ existingIds(_user: string, ids: readonly string[]) {
+ return Promise.resolve(new Set(ids.filter((id) => this.rows.has(id))));
+ }
+
+ indexedIds() {
+ return Promise.resolve([...this.rows.keys()]);
+ }
+
+ write(user: string, change: SearchIndexWrite, now: Date) {
+ const state = this.states.get(user);
+ if (!state) {
+ return Promise.resolve({ written: false, updatedIds: new Set() });
+ }
+ this.writes.push(change);
+ 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();
+ for (const update of change.updates ?? []) {
+ const row = this.rows.get(update.id);
+ if (!row) continue;
+ this.rows.set(update.id, {
+ ...row,
+ mailboxIds: [...update.mailboxIds],
+ keywords: [...update.keywords],
+ });
+ updatedIds.add(update.id);
+ }
+ if (change.state) {
+ Object.assign(state, change.state, { failureCount: 0, updatedAt: now });
+ }
+ return Promise.resolve({ written: true, updatedIds });
+ }
+}
+
+describe('검색 색인 맞추기 · 채우기 (MailSearchSyncService · MailSearchBuildService)', () => {
+ let engine: FakeEngine;
+ let repository: FakeRepository;
+ let clock: number;
+ let timing: MailSearchTiming;
+ let sync: MailSearchSyncService;
+ let build: MailSearchBuildService;
+ let logs: string[];
+
+ const changesCalls = () =>
+ engine.calls.filter((call) => call === 'changes').length;
+ const failureLogs = () =>
+ logs.filter((line) => line.includes('못했습니다') || line.includes('실패'));
+
+ beforeEach(() => {
+ clock = NIGHT;
+ engine = new FakeEngine({
+ now: () => clock,
+ advance: (ms) => {
+ clock += ms;
+ },
+ });
+ repository = new FakeRepository();
+ timing = {
+ now: () => clock,
+ // 쉬는 동안 시계만 간다 — 진짜로 기다리지 않는다
+ sleep: (ms) => {
+ clock += ms;
+ return Promise.resolve();
+ },
+ };
+ const runs = new MailSearchRuns();
+ const repo = repository as unknown as MailSearchIndexRepository;
+ sync = new MailSearchSyncService(
+ engine,
+ repo,
+ runs,
+ { domain: DOMAIN },
+ timing,
+ );
+ build = new MailSearchBuildService(engine, repo, runs, sync, timing);
+ logs = [];
+ const record = (message: unknown) => {
+ logs.push(String(message));
+ };
+ jest.spyOn(Logger.prototype, 'log').mockImplementation(record);
+ jest.spyOn(Logger.prototype, 'warn').mockImplementation(record);
+ jest.spyOn(Logger.prototype, 'error').mockImplementation(record);
+ });
+
+ afterEach(() => {
+ sync.beforeApplicationShutdown();
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ describe('채우기 (B3)', () => {
+ it('시작할 때 state(S0) · 메일 수를 적고, 최신부터 묶음씩 넣은 뒤 따라잡기 · 전체 대조 → ready', async () => {
+ engine.seed(BUILD_BATCH_SIZE * 2 + 7);
+ repository.addState();
+
+ const outcome = await build.build(ARA, clock + LONG);
+
+ expect(outcome).toBe('ready');
+ const state = repository.states.get(ARA)!;
+ expect(state).toMatchObject({
+ status: 'ready',
+ emailState: engine.state,
+ buildAnchor: null,
+ buildDone: BUILD_BATCH_SIZE * 2 + 7,
+ buildTotal: BUILD_BATCH_SIZE * 2 + 7,
+ });
+ expect(state.buildStartedAt).not.toBeNull();
+ expect(state.readyAt).not.toBeNull();
+ expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 2 + 7);
+ // 묶음 셋(50 · 50 · 7) + 빈 쪽 — 최신부터
+ expect(engine.calls.filter((call) => call === 'pageIds')).toHaveLength(4);
+ expect(
+ logs.some((line) => line.includes('검색 색인을 다 만들었습니다')),
+ ).toBe(true);
+ expect(logs.join('\n')).not.toContain(ARA);
+ });
+
+ it('채우는 동안 온 메일 · 옮긴 메일 · 지운 메일은 맞추기가 넣는다 — 색인에 없던 옮긴 메일은 채우기에 맡긴다', async () => {
+ engine.seed(BUILD_BATCH_SIZE * 2);
+ repository.addState();
+ // 시작과 첫 묶음까지만 (시계는 묶음 사이 쉬는 동안만 간다 — 끝 시각을 짧게)
+ await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
+ expect(repository.states.get(ARA)?.buildDone).toBe(BUILD_BATCH_SIZE);
+
+ engine.add('new1', 999_999);
+ engine.move('old0', ['archive']); // 아직 넣지 않은 지난 메일 (가장 오래된 것)
+ engine.remove(`old${BUILD_BATCH_SIZE * 2 - 1}`); // 이미 넣은 최신 메일
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+ expect(repository.rows.has('new1')).toBe(true);
+ expect(repository.rows.has('old0')).toBe(false);
+ expect(repository.rows.has(`old${BUILD_BATCH_SIZE * 2 - 1}`)).toBe(false);
+
+ expect(await build.build(ARA, clock + LONG)).toBe('ready');
+ expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']);
+ expect(repository.rows.size).toBe(engine.emails.size);
+ });
+
+ it('anchor 가 지워지면 위치로 이어 가고(몇 통 건너뛸 수 있다) 마지막 전체 대조가 빠진 것을 채운다', async () => {
+ engine.seed(120);
+ repository.addState();
+ await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
+ const first = repository.states.get(ARA)!;
+ expect(first.buildDone).toBe(BUILD_BATCH_SIZE);
+ // 첫 묶음은 old119 ~ old70 — 그 마지막(anchor)을 지운다. 위치 50 은 이제 old68 이라 old69 를 건너뛴다
+ expect(first.buildAnchor).toBe('old70');
+ engine.remove('old70');
+
+ expect(await build.build(ARA, clock + LONG)).toBe('ready');
+ expect(repository.rows.has('old70')).toBe(false);
+ expect(repository.rows.has('old69')).toBe(true);
+ expect([...repository.rows.keys()].sort()).toEqual(
+ engine.newestFirst().sort(),
+ );
+ });
+
+ it('채우는 중 엔진이 변화를 셀 수 없으면 대조하되 빠진 메일은 넣지 않는다(채우기가 넣는다) — 지운 것 · 옮긴 것은 맞춘다', async () => {
+ engine.seed(BUILD_BATCH_SIZE * 2);
+ repository.addState();
+ await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
+ const newest = engine.newestFirst()[0];
+ repository.rows.set('ghost', {
+ ...repository.rows.get(newest)!,
+ emailId: 'ghost',
+ });
+ engine.emails.get(newest)!.mailboxIds = ['trash'];
+ // 그사이 온 메일(가장 오래된 받은 시각 — 채우기의 끝에서 읽힌다) — 대조는 넣지 않고 state 만 앞으로
+ engine.add('late', 5);
+ engine.forgetBefore = Number.MAX_SAFE_INTEGER;
+
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+
+ const state = repository.states.get(ARA)!;
+ expect(state.status).toBe('building');
+ expect(state.emailState).toBe(engine.state);
+ expect(repository.rows.has('ghost')).toBe(false);
+ expect(repository.rows.get(newest)?.mailboxIds).toEqual(['trash']);
+ // 아직 채우지 않은 쪽은 그대로 — 채우기가 넣는다
+ expect(repository.rows.size).toBe(BUILD_BATCH_SIZE);
+
+ engine.forgetBefore = 0;
+ expect(await build.build(ARA, clock + LONG)).toBe('ready');
+ expect(repository.rows.size).toBe(engine.emails.size);
+ expect(repository.rows.has('late')).toBe(true);
+ });
+
+ it('끝 시각 · 창이 닫힘에 닿으면 다음 묶음을 시작하지 않는다 — 다음 차례가 이어 한다', async () => {
+ engine.seed(BUILD_BATCH_SIZE * 3);
+ repository.addState();
+ let open = true;
+ const outcome = await build.build(ARA, clock + LONG, () => {
+ const keep = open;
+ open = false;
+ return keep;
+ });
+ expect(outcome).toBe('partial');
+ expect(repository.states.get(ARA)?.status).toBe('building');
+ expect(await build.build(ARA, clock + LONG)).toBe('ready');
+ expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 3);
+ });
+
+ it('표에 넣을 수 없는 긴 id 는 anchor 로 쓰지 않는다 — 넣을 수 있는 마지막 id', async () => {
+ const longId = 'x'.repeat(65);
+ engine.seed(3);
+ engine.emails.set(longId, {
+ receivedAt: 1,
+ mailboxIds: ['inbox'],
+ keywords: [],
+ });
+ repository.addState();
+ expect(await build.build(ARA, clock + LONG)).toBe('ready');
+ const anchors = repository.writes
+ .map((write) => write.state?.buildAnchor)
+ .filter((anchor): anchor is string => typeof anchor === 'string');
+ expect(anchors).toEqual(['old0']);
+ expect(repository.rows.has(longId)).toBe(false);
+ });
+
+ it('1분 차례는 밤 창 안 · 사내 도메인 주소만 — 낮에는 아무것도 하지 않는다', async () => {
+ engine.seed(3);
+ repository.addState({ userEmail: 'search.index@e2e.arasystem.test' });
+ repository.addState();
+ clock = Date.parse('2026-11-05T12:00:00+09:00');
+ expect(await build.tick()).toBeNull();
+ expect(engine.calls).toEqual([]);
+ clock = NIGHT;
+ expect(await build.tick()).toBe('ready');
+ expect(
+ repository.states.get('search.index@e2e.arasystem.test')
+ ?.buildStartedAt,
+ ).toBeNull();
+ });
+
+ it('상태 줄이 그사이 지워지면(퇴사 마무리) 쓰지 않고 멈춘다', async () => {
+ engine.seed(BUILD_BATCH_SIZE * 2);
+ repository.addState();
+ await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
+ repository.states.delete(ARA);
+ expect(await build.build(ARA, clock + LONG)).toBe('skipped');
+ });
+ });
+
+ describe('시간 끝은 실패가 아니다 (이어 하기)', () => {
+ it('엔진 읽기가 끝 시각에 걸리면(IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기 — 실패 수 · 경고 없음', async () => {
+ engine.seed(BUILD_BATCH_SIZE);
+ repository.addState();
+ engine.failNext = new IndexDeadlineError();
+ expect(await build.build(ARA, clock + LONG)).toBe('partial');
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+
+ repository.states.get(ARA)!.status = 'ready';
+ repository.states.get(ARA)!.emailState = engine.state;
+ engine.failNext = new IndexDeadlineError();
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(false);
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+ expect(failureLogs()).toEqual([]);
+ });
+
+ it('남은 시간이 모자라면 다음 쪽 · 묶음을 시작하지 않는다 — 엔진이 느려도(읽기 4초) 끝 시각 전에 멈추고 쓴 데까지 남긴다', async () => {
+ repository.addState({ status: 'ready', emailState: engine.state });
+ for (let index = 0; index < 120; index += 1)
+ engine.add(`m${index}`, index);
+ engine.callCostMs = 4_000;
+
+ // 20초: changes(4초) → 글 50통 둘(8초) → 다음 쪽 changes(4초) → 그 쪽의 글은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적어 시작하지 않는다
+ expect(await sync.syncUser(ARA, clock + 20_000)).toBe(false);
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+ expect(failureLogs()).toEqual([]);
+ // 끝 시각 전에 멈췄다 — 엔진 읽기가 끝 시각을 넘지 않았다
+ expect(engine.calls.filter((call) => call === 'documents')).toHaveLength(
+ 2,
+ );
+
+ // 다음 차례들이 이어 해서 끝내 따라잡는다
+ let caught = false;
+ for (let round = 0; round < 10 && !caught; round += 1) {
+ caught = await sync.syncUser(ARA, clock + 60_000);
+ }
+ expect(caught).toBe(true);
+ expect(repository.rows.size).toBe(120);
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+ });
+
+ it('마무리(따라잡기 · 전체 대조)는 새 차례의 처음에만 시작하고, 시간 안에 끝나지 않아도 다음 차례에 이어 해 끝내 ready', async () => {
+ engine.seed(300);
+ // 지난 메일을 다 읽었는데(빈 쪽) 색인에는 하나도 없다 — 마무리의 대조가 300통을 넣어야 한다
+ repository.addState({
+ emailState: engine.state,
+ buildStartedAt: new Date(clock),
+ buildAnchor: 'old0',
+ buildDone: 300,
+ buildTotal: 300,
+ });
+ engine.callCostMs = 10_000;
+
+ // 빈 쪽을 읽은 뒤 남은 시간이 마무리에 모자라면(차례의 끝 무렵) 시작하지 않는다
+ expect(await build.build(ARA, clock + 35_000)).toBe('partial');
+ expect(engine.calls).not.toContain('allIds');
+
+ const outcomes: string[] = [];
+ for (let run = 0; run < 8; run += 1) {
+ const outcome = await build.build(ARA, clock + BUILD_RUN_BUDGET_MS);
+ outcomes.push(outcome);
+ if (outcome === 'ready') break;
+ }
+ expect(outcomes[outcomes.length - 1]).toBe('ready');
+ expect(outcomes.length).toBeGreaterThan(1);
+ expect(
+ outcomes.slice(0, -1).every((outcome) => outcome === 'partial'),
+ ).toBe(true);
+ expect(repository.rows.size).toBe(300);
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+ expect(failureLogs()).toEqual([]);
+ });
+ });
+
+ describe('맞추기 (B2)', () => {
+ beforeEach(() => {
+ repository.addState({ status: 'ready', emailState: engine.state });
+ });
+
+ it('만든 · 바꾼 · 지운 메일을 넣고 state 를 함께 적는다 — 여러 쪽이면 이어 묻는다', async () => {
+ for (let index = 0; index < 150; index += 1) {
+ engine.add(`m${index}`, index);
+ }
+ engine.move('m0', ['archive']);
+ engine.remove('m1');
+
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+
+ expect(repository.rows.size).toBe(149);
+ expect(repository.rows.get('m0')?.mailboxIds).toEqual(['archive']);
+ expect(repository.states.get(ARA)?.emailState).toBe(engine.state);
+ expect(changesCalls()).toBeGreaterThan(1);
+ });
+
+ it('한 쪽에 만든 메일이 바뀐 메일로도 함께 오면 글을 한 번만 읽는다', async () => {
+ engine.addReportedTwice('dup', 1);
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+ expect(engine.documentRequests).toEqual([['dup']]);
+ expect(engine.calls).not.toContain('mutable');
+ expect(repository.rows.has('dup')).toBe(true);
+ });
+
+ it('다 만든 직원의 색인에 없던 바뀐 메일은 글째 넣는다', async () => {
+ engine.seed(1);
+ engine.move('old0', ['archive']);
+ await sync.syncUser(ARA, clock + LONG);
+ expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']);
+ });
+
+ it('엔진이 변화를 셀 수 없으면 전체 대조 — 없어진 것은 지우고 빠진 것은 넣고 메일함을 다시 읽는다', async () => {
+ engine.add('a', 1);
+ engine.add('b', 2);
+ await sync.syncUser(ARA, clock + LONG);
+ // 색인에만 있는 줄 · 엔진에서 옮긴 메일 · 색인에 없는 메일
+ repository.rows.set('ghost', {
+ ...repository.rows.get('a')!,
+ emailId: 'ghost',
+ });
+ engine.emails.get('a')!.mailboxIds = ['trash'];
+ engine.seed(2);
+ engine.forgetBefore = Number.MAX_SAFE_INTEGER;
+
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+
+ expect([...repository.rows.keys()].sort()).toEqual([
+ 'a',
+ 'b',
+ 'old0',
+ 'old1',
+ ]);
+ expect(repository.rows.get('a')?.mailboxIds).toEqual(['trash']);
+ expect(repository.states.get(ARA)?.emailState).toBe(engine.state);
+ });
+
+ it('실시간 소식은 모아서 한 번 — 맞추는 중에 온 소식은 한 번 더(그사이 여러 번 와도 한 번), 끝나면 둘 다 들어 있다', async () => {
+ jest.useFakeTimers({
+ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'],
+ });
+ const settle = async () => {
+ for (let round = 0; round < 10; round += 1) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ };
+ let release: () => void = () => undefined;
+ engine.changesGate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ engine.add('a', 1);
+ sync.requestSync(ARA);
+ sync.requestSync(ARA);
+ sync.requestSync(' Ara.Kim@arasystem.test ');
+ await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS);
+ await settle();
+ // 첫 맞추기가 엔진의 답을 기다리는 중 — 그사이 새 메일과 소식 셋
+ expect(changesCalls()).toBe(1);
+ engine.add('b', 2);
+ for (let index = 0; index < 3; index += 1) {
+ sync.requestSync(ARA);
+ await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS);
+ }
+ expect(changesCalls()).toBe(1);
+
+ engine.changesGate = null;
+ release();
+ await settle();
+
+ // 첫 맞추기(a 까지 본 답) 뒤 한 번 더 — b 까지
+ expect(changesCalls()).toBe(2);
+ expect(repository.rows.has('a')).toBe(true);
+ expect(repository.rows.has('b')).toBe(true);
+ });
+
+ it('서버 전체에서 함께 도는 맞추기는 SYNC_CONCURRENCY 개까지 — 나머지는 기다렸다 모두 맞춘다', async () => {
+ const users = Array.from(
+ { length: SYNC_CONCURRENCY + 2 },
+ (_, index) => `user${index}@${DOMAIN}`,
+ );
+ for (const user of users) {
+ repository.addState({
+ userEmail: user,
+ status: 'ready',
+ emailState: engine.state,
+ });
+ }
+ let release: () => void = () => undefined;
+ engine.changesGate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const syncs = users.map((user) => sync.syncBeforeRead(user, LONG));
+ for (let round = 0; round < 10; round += 1) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ expect(engine.inFlight).toBe(SYNC_CONCURRENCY);
+
+ engine.changesGate = null;
+ release();
+ expect(await Promise.all(syncs)).toEqual(users.map(() => true));
+ expect(engine.maxInFlight).toBe(SYNC_CONCURRENCY);
+ expect(changesCalls()).toBe(users.length);
+ });
+
+ it('검색 바로 전 맞추기는 기다리는 만큼만 — 기다리는 맞추기가 있으면 함께 기다린다', async () => {
+ engine.add('a', 1);
+ const [first, second] = await Promise.all([
+ sync.syncBeforeRead(ARA, 5_000),
+ sync.syncBeforeRead(ARA, 5_000),
+ ]);
+ expect(first).toBe(true);
+ expect(second).toBe(true);
+ expect(changesCalls()).toBe(1);
+ });
+
+ it('5분 차례는 맞출 기준이 있는 사내 직원만, 실패가 이어진 직원은 때가 될 때까지 건너뛴다', async () => {
+ repository.addState({ userEmail: `new@${DOMAIN}` }); // 채우기를 시작하지 않음
+ repository.addState({
+ userEmail: `failing@${DOMAIN}`,
+ status: 'ready',
+ emailState: engine.state,
+ failureCount: 3,
+ updatedAt: new Date(clock),
+ });
+ // 같은 DB 의 다른 도메인 줄(e2e 등) — 맞추지 않는다
+ repository.addState({
+ userEmail: 'search.index@e2e.arasystem.test',
+ status: 'ready',
+ emailState: engine.state,
+ });
+ engine.add('a', 1);
+ await sync.syncAll();
+ expect(repository.rows.has('a')).toBe(true);
+ expect(changesCalls()).toBe(1);
+ expect(sync.isIndexedAddress('x@e2e.arasystem.test')).toBe(false);
+ expect(sync.isIndexedAddress(' Ara.Kim@ARASYSTEM.test ')).toBe(true);
+ });
+ });
+
+ describe('실패', () => {
+ beforeEach(() => {
+ repository.addState({ status: 'ready', emailState: engine.state });
+ engine.add('a', 1);
+ });
+
+ it.each([
+ [
+ '비밀번호 만료',
+ new PasswordExpiredException(ARA, 'ara.kim'),
+ 'password-expired',
+ ],
+ ['잠김', new AccountLoginDisabledException(ARA), 'locked'],
+ [
+ '엔진에 없음',
+ new BusinessException('AUTH_001', {
+ context: { reason: 'impersonation-rejected' },
+ }),
+ 'locked',
+ ],
+ ] as const)(
+ '대리 접근이 막히면(%s) 멈춘다 — 맞추기 차례에서 빠진다',
+ async (_name, error, reason) => {
+ engine.failNext = error;
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(false);
+ expect(repository.states.get(ARA)?.pausedReason).toBe(reason);
+ expect(await repository.syncCandidates()).toEqual([]);
+ expect(logs.join('\n')).not.toContain(ARA);
+ },
+ );
+
+ it('엔진 장애는 실패 수를 더하고 처음 한 번만 경고한다 — 성공하면 0 (엔진 설명 문장은 남기지 않는다)', async () => {
+ const outage = () =>
+ new BusinessException('MAIL_001', {
+ context: {
+ reason: 'http-error',
+ jmapType: 'serverFail',
+ description: '비밀 문장',
+ },
+ });
+ engine.failNext = outage();
+ await sync.syncUser(ARA, clock + LONG);
+ engine.failNext = outage();
+ await sync.syncUser(ARA, clock + LONG);
+ expect(repository.states.get(ARA)?.failureCount).toBe(2);
+ const warnings = logs.filter((line) =>
+ line.includes('맞추지 못했습니다'),
+ );
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain('MAIL_001/http-error/serverFail');
+ expect(logs.join('\n')).not.toContain('비밀 문장');
+
+ expect(await sync.syncUser(ARA, clock + LONG)).toBe(true);
+ expect(repository.states.get(ARA)?.failureCount).toBe(0);
+ });
+ });
+});
diff --git a/backend/src/modules/mail-search/mail-search-sync.service.ts b/backend/src/modules/mail-search/mail-search-sync.service.ts
new file mode 100644
index 0000000..15f5160
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-sync.service.ts
@@ -0,0 +1,478 @@
+import {
+ BeforeApplicationShutdown,
+ Inject,
+ Injectable,
+ Logger,
+ Optional,
+} from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ AccountLoginDisabledException,
+ PasswordExpiredException,
+} from '@/modules/mail/mail.provider';
+import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
+import { maskEmail } from '@/shared/utils/mask';
+import { settledWithin } from '@/shared/utils/settle';
+import {
+ isIndexableId,
+ toIndexRow,
+ type IndexRowValues,
+} from './index-document';
+import {
+ IndexDeadlineError,
+ MAIL_INDEX_SOURCE,
+ type IndexChanges,
+ type IndexMutable,
+ type MailIndexSource,
+} from './mail-index-source';
+import {
+ MailSearchIndexRepository,
+ type SearchStatePatch,
+} from './mail-search-index.repository';
+import { ConcurrencyGate, MailSearchRuns } from './mail-search-runs';
+import {
+ MAIL_SEARCH_TIMING,
+ SYSTEM_SEARCH_TIMING,
+ type MailSearchTiming,
+} from './mail-search-timing';
+import {
+ DOCUMENT_READ_BATCH,
+ FAILURE_BACKOFF_STEPS_MAX,
+ MUTABLE_READ_BATCH,
+ PRE_READ_SYNC_WAIT_MS,
+ RECONCILE_BUDGET_MS,
+ RECONCILE_WRITE_BATCH,
+ SEARCH_CRON_TIME_ZONE,
+ STEP_MIN_REMAINING_MS,
+ SYNC_BUDGET_MS,
+ SYNC_CHANGES_PAGE,
+ SYNC_CONCURRENCY,
+ SYNC_DEBOUNCE_MS,
+} from './mail-search.constants';
+import type {
+ SearchIndexStatus,
+ SearchPausedReason,
+} from './mail-search.types';
+import { isDueAfterFailures } from './search-index-schedule';
+
+/** 전체 대조에서 무엇을 하나 */
+export interface ReconcileOptions {
+ /** 엔진에는 있는데 색인에 없는 메일을 넣는다 — 채우는 중(building)이면 채우기가 할 일이라 넣지 않는다 */
+ readonly addMissing: boolean;
+ /** 색인에 있는 메일의 메일함 · 키워드를 다시 읽는다 — 변화를 놓쳤을 때(cannotCalculateChanges) */
+ readonly refreshMutable: boolean;
+ /** 끝에 상태 줄에 함께 쓸 것 (채우기의 마무리 — ready) */
+ readonly finalState?: (indexed: number) => SearchStatePatch;
+}
+
+/** 줄을 나눈다 */
+function chunks(items: readonly T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let index = 0; index < items.length; index += size) {
+ out.push(items.slice(index, index + size));
+ }
+ return out;
+}
+
+/** 로그에 남길 실패 요약 — 코드 · 사유 · 엔진 오류 종류만 (엔진 설명 문장 · 주소 · 글은 넣지 않는다) */
+function failureSummary(error: unknown): string {
+ if (error instanceof BusinessException) {
+ const reason = error.context?.reason;
+ const jmapType = error.context?.jmapType;
+ return [
+ error.code,
+ typeof reason === 'string' ? reason : null,
+ typeof jmapType === 'string' ? jmapType : null,
+ ]
+ .filter(Boolean)
+ .join('/');
+ }
+ // DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만
+ const name = error instanceof Error ? error.name : typeof error;
+ const code = (error as { code?: unknown } | null)?.code;
+ return typeof code === 'string' ? `${name}/${code}` : name;
+}
+
+/** 대리 접근이 막힌 실패인가 → 멈출 까닭 (아니면 null) */
+function pausedReasonOf(error: unknown): SearchPausedReason | null {
+ if (error instanceof PasswordExpiredException) return 'password-expired';
+ if (error instanceof AccountLoginDisabledException) return 'locked';
+ // 엔진에 그 계정이 없다(대리 접근 401) — 로그인할 수 없는 계정이라 멈춘다
+ if (error instanceof BusinessException && error.code === 'AUTH_001') {
+ return 'locked';
+ }
+ return null;
+}
+
+/**
+ * 검색 색인 맞추기 (14단계 설계 B2) — 엔진의 Email state 부터 변화(Email/changes)를 받아 색인에 넣는다. 엔진을 모른다(포트 MAIL_INDEX_SOURCE).
+ *
+ * 부르는 때: ① 실시간 연결의 state 소식(SYNC_DEBOUNCE_MS 모음 — mail-events.service.ts) ② 5분마다 모든 직원(한 사람씩 · SYNC_BUDGET_MS 안)
+ * ③ 검색 · 대화 · 묶은 목록 바로 전 한 번(PRE_READ_SYNC_WAIT_MS 까지 기다린다 — 넘으면 지금 색인으로 답하고 맞추기는 이어 한다 — 14단계 2차부터 부른다).
+ * 같은 직원은 한 번에 하나(MailSearchRuns) — 기다리는 맞추기가 있으면 새로 넣지 않고 그것을 함께 기다린다.
+ * 서버 전체에서는 SYNC_CONCURRENCY 개까지 함께 맞춘다(ConcurrencyGate) — 전 직원에게 메일 한 통이 와도 엔진에 한꺼번에 몰리지 않게.
+ * 맞추는 직원은 사내 도메인(MAIL_DOMAIN) 주소뿐이다 — 로그인할 수 있는 주소만 상태 줄이 생긴다(같은 DB 의 다른 도메인 줄 — e2e 등 — 은 건드리지 않는다).
+ *
+ * - 만든 메일 → 글을 읽어 넣는다. 바뀐 메일 → 메일함 · 키워드만(JMAP 에서 글은 바뀌지 않는다) — 색인에 없던 메일은 다 만든 직원(ready)이면 글을 읽어 넣고,
+ * 채우는 중이면 건너뛴다(채우기가 최신 글로 넣는다). 없어진 메일 → 지운다
+ * - 한 쪽(SYNC_CHANGES_PAGE)마다 그 쪽의 쓰기와 새 state 를 한 트랜잭션에 — 끝 시각에 닿으면 쓴 데까지가 맞춘 곳이다
+ * - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 쪽 · 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸려
+ * 멈추면(IndexDeadlineError) 쓴 데까지 두고 다음 차례에 이어 한다(실패 수 · 경고 · 미루기 없음)
+ * - 엔진이 셀 수 없다(cannotCalculateChanges) → 전체 대조(reconcile — 모든 id 를 견주어 없는 것은 지우고, 메일함 · 키워드를 다시 읽는다)
+ * - 대리 접근이 막혔다(비밀번호 만료 · 잠김 · 계정 없음) → 멈춘다(paused_reason — 다음 로그인 · 비밀번호 바꾸기 때 푼다). 그 밖의 실패는 실패 수를 더하고
+ * 다음 차례를 늦춘다(isDueAfterFailures). 로그는 처음 실패 · 오래 이어질 때만 — 코드 · 사유뿐이다(엔진 설명 문장 · 메일 글 · 주소 없음)
+ *
+ * ⚠️ 차례 · 모음 · 동시 수(debounces · queued · syncingAll · gate)는 프로세스 메모리다 — 인스턴스를 늘리면 옮긴다 (nestjs 스킬의 Redis 목록)
+ */
+@Injectable()
+export class MailSearchSyncService implements BeforeApplicationShutdown {
+ private readonly logger = new Logger(MailSearchSyncService.name);
+ private readonly debounces = new Map();
+ /** 줄에 넣었지만 아직 시작하지 않은 맞추기 — 같은 직원의 새 요청이 함께 기다린다 */
+ private readonly queued = new Map>();
+ /** 서버 전체에서 함께 도는 맞추기 수 */
+ private readonly gate = new ConcurrencyGate(SYNC_CONCURRENCY);
+ private syncingAll = false;
+ private stopping = false;
+ private readonly timing: MailSearchTiming;
+ /** 맞추는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무도 맞추지 않는다) */
+ private readonly addressSuffix: string | null;
+
+ constructor(
+ @Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource,
+ private readonly repository: MailSearchIndexRepository,
+ private readonly runs: MailSearchRuns,
+ @Inject(MAIL_SETTINGS) settings: Pick,
+ @Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming,
+ ) {
+ this.timing = timing ?? SYSTEM_SEARCH_TIMING;
+ const domain = settings.domain?.trim().toLowerCase();
+ this.addressSuffix = domain ? `@${domain}` : null;
+ }
+
+ /** 색인하는 주소인가 — 사내 도메인(MAIL_DOMAIN)의 주소만 (5분 맞추기 · 밤 채우기의 차례가 본다) */
+ isIndexedAddress(user: string): boolean {
+ return (
+ this.addressSuffix !== null &&
+ user.trim().toLowerCase().endsWith(this.addressSuffix)
+ );
+ }
+
+ /** 실시간 소식 — SYNC_DEBOUNCE_MS 동안 모아 맞추기 한 번 (던지지 않는다) */
+ requestSync(user: string): void {
+ const key = user.trim().toLowerCase();
+ if (this.stopping || key === '' || this.debounces.has(key)) return;
+ const timer = setTimeout(() => {
+ this.debounces.delete(key);
+ void this.enqueueSync(key);
+ }, SYNC_DEBOUNCE_MS);
+ timer.unref();
+ this.debounces.set(key, timer);
+ }
+
+ /**
+ * 검색 · 대화 · 묶은 목록 바로 전 — 맞추기를 `waitMs` 까지 기다린다 → 그 안에 다 맞췄으면 true.
+ * 넘으면 false 이고 맞추기는 뒤에서 이어 한다 (부른 쪽은 지금 색인으로 답한다). 던지지 않는다
+ */
+ async syncBeforeRead(
+ user: string,
+ waitMs: number = PRE_READ_SYNC_WAIT_MS,
+ ): Promise {
+ const key = user.trim().toLowerCase();
+ if (this.stopping || key === '') return false;
+ const sync = this.enqueueSync(key);
+ const settled = await settledWithin(sync, waitMs);
+ return settled && (await sync);
+ }
+
+ /** 5분마다 — 맞출 기준이 있는 사내 직원을 한 사람씩 (실패가 이어진 직원은 때가 될 때까지 건너뛴다) */
+ @Cron('0 */5 * * * *', {
+ name: 'mail-search-sync',
+ timeZone: SEARCH_CRON_TIME_ZONE,
+ })
+ async syncAll(): Promise {
+ if (this.syncingAll || this.stopping) return;
+ this.syncingAll = true;
+ try {
+ const candidates = await this.repository.syncCandidates();
+ for (const state of candidates) {
+ if (this.stopping) break;
+ if (
+ !this.isIndexedAddress(state.userEmail) ||
+ !isDueAfterFailures(
+ state.failureCount,
+ state.updatedAt,
+ this.timing.now(),
+ )
+ ) {
+ continue;
+ }
+ await this.enqueueSync(state.userEmail);
+ }
+ } catch (error) {
+ this.logger.error(
+ `[SYS_001] 검색 색인 맞추기 차례를 읽지 못했습니다: ${failureSummary(error)}`,
+ );
+ } finally {
+ this.syncingAll = false;
+ }
+ }
+
+ beforeApplicationShutdown(): void {
+ this.stopping = true;
+ for (const timer of this.debounces.values()) clearTimeout(timer);
+ this.debounces.clear();
+ }
+
+ /** 서버가 닫히는 중인가 — 채우기도 멈춘다 */
+ get isStopping(): boolean {
+ return this.stopping;
+ }
+
+ /**
+ * 한 직원을 끝 시각까지 맞춘다 → 다 따라잡았으면 true. 던지지 않는다 — 실패는 상태 줄(멈춤 · 실패 수)과 로그로,
+ * 시간 끝(IndexDeadlineError)은 실패로 세지 않고 false 다(다음 차례에 이어 한다). 그 직원의 차례(MailSearchRuns) 안에서 부른다
+ */
+ async syncUser(user: string, deadline: number): Promise {
+ try {
+ return await this.catchUp(user, deadline);
+ } catch (error) {
+ if (!(error instanceof IndexDeadlineError)) {
+ await this.handleFailure(user, error, 'sync');
+ }
+ return false;
+ }
+ }
+
+ /**
+ * syncUser 의 던지는 판 — 채우기의 마무리가 쓴다. 상태 줄이 없거나 · 멈췄거나 · 맞출 기준(email_state)이 없으면 false.
+ * 끝 시각 · 서버 종료에 닿아도 false (쓴 데까지가 맞춘 곳이다). 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다
+ */
+ async catchUp(user: string, deadline: number): Promise {
+ const state = await this.repository.findState(user);
+ if (!state || state.pausedReason !== null || state.emailState === null) {
+ return false;
+ }
+ let since = state.emailState;
+ for (;;) {
+ if (!this.hasTime(deadline)) return false;
+ const changes = await this.source.changes(
+ user,
+ since,
+ SYNC_CHANGES_PAGE,
+ { deadline },
+ );
+ if (changes.kind === 'cannotCalculate') {
+ // 변화를 놓쳤다 — 모든 메일을 견준다(더 긴 시간 안에서). 채우는 중이면 없는 메일은 채우기가 넣는다
+ return this.reconcile(
+ user,
+ Math.max(deadline, this.timing.now() + RECONCILE_BUDGET_MS),
+ {
+ addMissing: state.status === 'ready',
+ refreshMutable: true,
+ },
+ );
+ }
+ const applied = await this.applyChanges(
+ user,
+ state.status,
+ changes,
+ deadline,
+ );
+ if (!applied) return false;
+ since = changes.newState;
+ if (!changes.hasMore) return true;
+ }
+ }
+
+ /**
+ * 전체 대조 — 엔진의 모든 메일 id 와 색인을 견주어 색인에만 있는 것을 지우고, 고르면 없는 것을 넣고 · 메일함 · 키워드를 다시 읽는다.
+ * 끝에 그 대조를 시작하기 바로 전의 state 를 맞출 기준으로 적는다(그 뒤의 변화는 다음 맞추기가 다시 한다 — 넣기 · 지우기는 몇 번 해도 같다).
+ * 끝 시각에 닿으면 false 이고 기준은 그대로다 — 지운 것 · 넣은 것은 남으므로 다음 대조는 남은 것만 한다(메일함이 커도 끝내 끝난다). 던진다
+ */
+ async reconcile(
+ user: string,
+ deadline: number,
+ options: ReconcileOptions,
+ ): Promise {
+ const engine = await this.source.allIds(user, { deadline });
+ const engineIds = engine.ids.filter(isIndexableId);
+ const engineSet = new Set(engineIds);
+ const indexed = new Set(await this.repository.indexedIds(user));
+
+ const stale = [...indexed].filter((id) => !engineSet.has(id));
+ for (const batch of chunks(stale, RECONCILE_WRITE_BATCH)) {
+ if (!(await this.write(user, { deletes: batch }))) return false;
+ }
+
+ if (options.addMissing) {
+ const missing = engineIds.filter((id) => !indexed.has(id));
+ for (const batch of chunks(missing, DOCUMENT_READ_BATCH)) {
+ const rows = await this.readRows(user, batch, deadline);
+ if (rows === null) return false;
+ if (!(await this.write(user, { upserts: rows }))) return false;
+ }
+ }
+
+ if (options.refreshMutable) {
+ const kept = engineIds.filter((id) => indexed.has(id));
+ for (const batch of chunks(kept, MUTABLE_READ_BATCH)) {
+ if (!this.hasTime(deadline)) return false;
+ const updates = await this.source.mutable(user, batch, { deadline });
+ if (!(await this.write(user, { updates }))) return false;
+ }
+ }
+
+ const indexedCount = await this.repository.countMessages(user);
+ return this.write(user, {
+ state: {
+ emailState: engine.state,
+ lastSyncedAt: new Date(this.timing.now()),
+ ...(options.finalState ? options.finalState(indexedCount) : {}),
+ },
+ });
+ }
+
+ /**
+ * 실패 하나를 다룬다 — 대리 접근이 막혔으면 멈추고(warn), 아니면 실패 수를 더한다(처음 · FAILURE_BACKOFF_STEPS_MAX 번째에만 로그). 던지지 않는다.
+ * 시간 끝(IndexDeadlineError)은 실패가 아니라 아무것도 하지 않는다
+ */
+ async handleFailure(
+ user: string,
+ error: unknown,
+ phase: 'sync' | 'build',
+ ): Promise {
+ if (error instanceof IndexDeadlineError) return;
+ const now = new Date(this.timing.now());
+ const summary = failureSummary(error);
+ try {
+ const reason = pausedReasonOf(error);
+ if (reason !== null) {
+ await this.repository.pause(user, reason, now);
+ this.logger.warn(
+ `[${error instanceof BusinessException ? error.code : 'MAIL_001'}] 대리 접근이 막혀 검색 색인을 멈춥니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${reason}`,
+ );
+ return;
+ }
+ const failures = await this.repository.recordFailure(user, now);
+ const code = error instanceof BusinessException ? error.code : 'SYS_001';
+ if (failures === 1) {
+ this.logger.warn(
+ `[${code}] 검색 색인을 ${phase === 'sync' ? '맞추지' : '채우지'} 못했습니다(뒤에 다시): user=${maskEmail(user)}, ${summary}`,
+ );
+ } else if (failures === FAILURE_BACKOFF_STEPS_MAX) {
+ this.logger.error(
+ `[${code}] 검색 색인 ${phase === 'sync' ? '맞추기' : '채우기'}가 ${failures}번 이어 실패했습니다: user=${maskEmail(user)}, ${summary}`,
+ );
+ }
+ } catch (recordError) {
+ this.logger.error(
+ `[SYS_001] 검색 색인의 실패를 적지 못했습니다: user=${maskEmail(user)}, ${summary}, record=${failureSummary(recordError)}`,
+ );
+ }
+ }
+
+ /**
+ * 줄에 맞추기를 넣는다 — 기다리는 맞추기(아직 엔진에 묻기 전 — 그 직원의 차례 · 서버 전체 자리를 기다리는 중)가 있으면 그것을 함께 기다린다
+ */
+ private enqueueSync(user: string): Promise {
+ const waiting = this.queued.get(user);
+ if (waiting) return waiting;
+ const sync = this.runs.run(user, () =>
+ this.gate.run(() => {
+ // 시작했다 — 이 뒤의 소식은 맞추기를 하나 더 넣는다 (이 맞추기가 놓칠 수 있다)
+ this.queued.delete(user);
+ if (this.stopping) return Promise.resolve(false);
+ return this.syncUser(user, this.timing.now() + SYNC_BUDGET_MS);
+ }),
+ );
+ this.queued.set(user, sync);
+ void sync.catch(() => this.queued.delete(user));
+ return sync;
+ }
+
+ /** 한 쪽의 변화를 한 트랜잭션에 → 썼으면 true (상태 줄이 없거나 끝 시각이면 false) */
+ private async applyChanges(
+ user: string,
+ status: SearchIndexStatus,
+ changes: Extract,
+ deadline: number,
+ ): Promise {
+ const destroyed = new Set(changes.destroyed);
+ const created = new Set(
+ changes.created.filter((id) => isIndexableId(id) && !destroyed.has(id)),
+ );
+ // 만든 메일은 글째 넣으므로 바뀐 메일에서 뺀다 (한 쪽에 둘 다 오면 Email/get 에 두 번 싣지 않게)
+ const updated = [
+ ...new Set(
+ changes.updated.filter(
+ (id) => isIndexableId(id) && !destroyed.has(id) && !created.has(id),
+ ),
+ ),
+ ];
+
+ const existing = await this.repository.existingIds(user, updated);
+ // 색인에 없던 바뀐 메일 — 다 만든 직원이면 글째 넣는다 (채우는 중이면 채우기가 넣는다)
+ const unknown =
+ status === 'ready' ? updated.filter((id) => !existing.has(id)) : [];
+ const rows = await this.readRows(user, [...created, ...unknown], deadline);
+ if (rows === null) return false;
+
+ let updates: IndexMutable[] = [];
+ const known = updated.filter((id) => existing.has(id));
+ if (known.length > 0) {
+ if (!this.hasTime(deadline)) return false;
+ updates = await this.source.mutable(user, known, { deadline });
+ }
+ return this.write(user, {
+ deletes: [...destroyed],
+ upserts: rows,
+ updates,
+ state: {
+ emailState: changes.newState,
+ lastSyncedAt: new Date(this.timing.now()),
+ },
+ });
+ }
+
+ /**
+ * 메일 글을 DOCUMENT_READ_BATCH 씩 읽어 색인 줄로 (그사이 없어진 메일은 빠진다) — 다음 묶음을 시작할 시간(STEP_MIN_REMAINING_MS)이 없으면 null.
+ * 묶음 안의 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다
+ */
+ async readRows(
+ user: string,
+ ids: readonly string[],
+ deadline: number,
+ ): Promise {
+ const rows: IndexRowValues[] = [];
+ for (const batch of chunks(ids, DOCUMENT_READ_BATCH)) {
+ if (!this.hasTime(deadline)) return null;
+ const documents = await this.source.documents(user, batch, { deadline });
+ for (const document of documents) {
+ if (isIndexableId(document.id)) rows.push(toIndexRow(document));
+ }
+ }
+ return rows;
+ }
+
+ private async write(
+ user: string,
+ change: Parameters[1],
+ ): Promise {
+ const result = await this.repository.write(
+ user,
+ change,
+ new Date(this.timing.now()),
+ );
+ return result.written;
+ }
+
+ /** 다음 쪽 · 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */
+ private hasTime(deadline: number): boolean {
+ return (
+ !this.stopping && this.timing.now() <= deadline - STEP_MIN_REMAINING_MS
+ );
+ }
+}
diff --git a/backend/src/modules/mail-search/mail-search-timing.ts b/backend/src/modules/mail-search/mail-search-timing.ts
new file mode 100644
index 0000000..42b4b24
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-timing.ts
@@ -0,0 +1,19 @@
+/**
+ * 검색 색인의 시계 · 쉬기 (14단계) — 테스트가 바꿔 끼운다(고정 대기 없이 — testing 규칙).
+ * 앱은 Date.now · setTimeout(unref — 종료를 붙잡지 않는다)이다
+ */
+export const MAIL_SEARCH_TIMING = Symbol('MAIL_SEARCH_TIMING');
+
+export interface MailSearchTiming {
+ now(): number;
+ sleep(ms: number): Promise;
+}
+
+export const SYSTEM_SEARCH_TIMING: MailSearchTiming = {
+ now: () => Date.now(),
+ sleep: (ms) =>
+ new Promise((resolve) => {
+ const timer = setTimeout(resolve, ms);
+ timer.unref();
+ }),
+};
diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts
new file mode 100644
index 0000000..b95a2de
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search.constants.ts
@@ -0,0 +1,107 @@
+/**
+ * 메일 검색 색인 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕) 의 상수.
+ * 묶음 크기 · 시간 한도는 개발 엔진에서 잰 뒤 바꾼다 (설계 E 의 2 — 1만 통 재기). 바꿀 때는 이 파일만 고친다.
+ */
+
+/** 표의 이름 — 마이그레이션 `AddMailSearchIndex` 와 같다 */
+export const SEARCH_MESSAGES_TABLE = 'mail_search_messages';
+export const SEARCH_STATES_TABLE = 'mail_search_states';
+
+/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
+export const SEARCH_EMAIL_COLUMN_LENGTH = 254;
+/** 엔진 id(메일 · 대화)의 열 길이 — Stalwart 는 13자 안팎이다. 넘는 id 는 색인하지 않는다 (index-document.ts) */
+export const SEARCH_ID_COLUMN_LENGTH = 64;
+/** 짧은 낱말 열(상태 · 멈춘 까닭) */
+export const SEARCH_WORD_COLUMN_LENGTH = 32;
+
+/**
+ * 본문 글을 색인에 담는 길이 — 처음 이만큼의 코드 포인트 (설계 A1, 처음 값 20,000 — 잰 뒤 정한다).
+ * 그 뒤의 글로는 찾지 못한다 (긴 메일의 뒷부분)
+ */
+export const INDEX_BODY_MAX_CHARS = 20_000;
+/** 제목을 담는 길이 (코드 포인트) — 바깥에서 온 메일의 아주 긴 제목이 줄을 키우지 않게 */
+export const INDEX_SUBJECT_MAX_CHARS = 1_000;
+/** 보낸 사람 · 받는 사람 · 참조 한 칸에 담는 주소 수 — 받는 사람이 수천인 메일이 줄을 키우지 않게 */
+export const INDEX_ADDRESSES_MAX = 200;
+/** 주소 한 줄("이름 <주소>")의 길이 (코드 포인트) */
+export const INDEX_ADDRESS_LINE_MAX_CHARS = 400;
+/** 첨부 이름을 담는 수 · 이름 하나의 길이 (코드 포인트) */
+export const INDEX_ATTACHMENT_NAMES_MAX = 100;
+export const INDEX_ATTACHMENT_NAME_MAX_CHARS = 255;
+/** 미리 보기(엔진 preview)의 길이 (코드 포인트) — 엔진은 256자 안쪽을 준다 */
+export const INDEX_PREVIEW_MAX_CHARS = 512;
+/** 답장 관계(In-Reply-To ∪ References)를 담는 수 · Message-ID 하나의 길이 — 넘는 것은 담지 않는다 */
+export const INDEX_REFS_MAX = 50;
+export const INDEX_MESSAGE_ID_MAX_LENGTH = 255;
+
+/**
+ * 시간 끝 — 차례(맞추기 · 채우기)의 끝 시각에 닿아 멈추는 것은 실패가 아니라 "이어 하기"다 (실패 수 · 경고 · 미루기 없음).
+ * - 엔진 읽기 한 번은 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고(IndexDeadlineError), 한 번에 INDEX_CALL_TIMEOUT_MS 까지만 기다린다
+ * - 맞추기 한 쪽 · 채우기 한 묶음 · 대조의 한 걸음은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 시작하지 않는다 — 시작한 묶음이 짧은 시간에 잘리지 않게
+ * - 채우기의 마무리(따라잡기 · 전체 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만(= 새 차례의 처음) 시작한다
+ */
+export const INDEX_CALL_MIN_MS = 2_000;
+export const INDEX_CALL_TIMEOUT_MS = 15_000;
+export const STEP_MIN_REMAINING_MS = 5_000;
+export const FINISH_MIN_REMAINING_MS = 30_000;
+/** 전체 대조(엔진이 변화를 셀 수 없을 때)의 시간 — 모든 메일의 메일함 · 키워드를 다시 읽는다 */
+export const RECONCILE_BUDGET_MS = 120_000;
+
+/**
+ * 본문 값을 한 번에 읽는 메일의 크기 상한 — 메일 **전체** 크기(엔진의 size)의 합. 엔진은 값을 만들며 원문을 읽으므로,
+ * 큰 첨부가 든 메일 여럿을 한 요청에 묶으면 엔진(메모리 1g)이 한꺼번에 그만큼 읽는다. 이보다 큰 메일 한 통은 혼자 읽는다.
+ * (처음 값 — 큰 첨부 메일로 잰 뒤 다시 정한다)
+ */
+export const BODY_READ_BATCH_MESSAGE_BYTES = 32 * 1024 * 1024;
+export const BODY_READ_SOLO_MESSAGE_BYTES = 16 * 1024 * 1024;
+
+/** 지난 메일 채우기의 한 묶음 — 받은 차례(최신부터)로 이만큼씩 읽어 넣는다 (설계 B3, 잰 뒤 정한다) */
+export const BUILD_BATCH_SIZE = 50;
+/** 한 묶음을 넣은 뒤 쉬는 시간 — 엔진(메모리 1g)이 다른 요청을 받을 틈 */
+export const BUILD_BATCH_PAUSE_MS = 200;
+/** 밤 채우기 한 차례(1분마다 깨어 한 사람) 의 시간 */
+export const BUILD_RUN_BUDGET_MS = 50_000;
+
+/** 맞추기(Email/changes) 한 번에 받는 변화 수 — 만든 메일의 글을 같은 차례에 읽으므로 작게 */
+export const SYNC_CHANGES_PAGE = 100;
+/** 맞추기 한 차례의 시간 — 5분마다 한 사람씩 · 실시간 소식 (설계 B2) */
+export const SYNC_BUDGET_MS = 20_000;
+/** 실시간 소식(state)을 모으는 시간 — 잇달아 온 소식을 맞추기 한 번으로 */
+export const SYNC_DEBOUNCE_MS = 300;
+/** 검색 · 대화 · 묶은 목록 바로 전의 맞추기를 기다리는 시간 — 넘으면 지금 색인으로 답하고 맞추기는 뒤에서 이어 한다 */
+export const PRE_READ_SYNC_WAIT_MS = 1_500;
+/**
+ * 서버 전체에서 함께 도는 맞추기 수 — 전 직원에게 메일 한 통이 오면 실시간 소식이 직원 수만큼 한꺼번에 온다.
+ * 넘는 맞추기는 차례를 기다린다(그동안 같은 직원의 새 소식은 기다리는 맞추기에 합친다)
+ */
+export const SYNC_CONCURRENCY = 4;
+
+/** 메일 글을 한 번에 읽는 수 (Email/get — 엔진의 maxObjectsInGet 500 이하, 본문은 따로 나눠 읽는다) */
+export const DOCUMENT_READ_BATCH = 50;
+/** 표시 · 메일함만 한 번에 읽는 수 (바뀐 메일 — maxObjectsInGet 500) */
+export const MUTABLE_READ_BATCH = 500;
+/** 전체 대조에서 엔진의 메일 id 를 한 번에 묻는 수 — 엔진이 줄이면 받은 만큼 이어 묻는다 */
+export const ALL_IDS_PAGE = 5_000;
+/** 전체 대조에서 우리 표에 한 번에 넣고 지우는 수 */
+export const RECONCILE_WRITE_BATCH = 500;
+
+/** 실패가 이만큼 이어지면 5분 맞추기 · 밤 채우기의 차례에서 뒤로 미룬다 (failure_count — 성공하면 0) */
+export const FAILURE_BACKOFF_STEPS_MAX = 6;
+/** 실패한 직원을 다시 해 보기까지 — 실패 수마다 두 배(5분 · 10분 · … 상한 FAILURE_BACKOFF_STEPS_MAX 단계) */
+export const FAILURE_BACKOFF_BASE_MS = 5 * 60_000;
+
+/**
+ * 밤 채우기의 창 (한국 시간, 하루의 분) — 22:00 ~ 02:45 · 05:00 ~ 07:30.
+ * 03:00 · 04:00 · 04:30 엔진 정리(휴지통 · 스팸메일함 비우기)와 03:30 백업(엔진을 잠깐 멈춘다)을 피한다 (설계 B3)
+ */
+export const BUILD_WINDOWS_KST: readonly (readonly [
+ startMinute: number,
+ endMinute: number,
+])[] = [
+ [22 * 60, 24 * 60],
+ [0, 2 * 60 + 45],
+ [5 * 60, 7 * 60 + 30],
+];
+
+/** 실행 시각 표의 시간대 — 컨테이너는 UTC 로 돈다 */
+export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul';
diff --git a/backend/src/modules/mail-search/mail-search.module.ts b/backend/src/modules/mail-search/mail-search.module.ts
new file mode 100644
index 0000000..e6a0e7a
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search.module.ts
@@ -0,0 +1,40 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { JmapIndexSource } from '@/modules/mail/jmap/jmap-index-source';
+import { MailEngineModule } from '@/modules/mail/mail-engine.module';
+import { 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 { MailSearchMessageEntity } from './mail-search-message.entity';
+import { MailSearchRuns } from './mail-search-runs';
+import { MailSearchStateEntity } from './mail-search-state.entity';
+import { MailSearchSyncService } from './mail-search-sync.service';
+import { SearchIndexController } from './search-index.controller';
+
+/**
+ * 메일 검색 색인 모듈 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕).
+ * 엔진을 바꿀 때는 `MAIL_INDEX_SOURCE` 의 구현(useClass)만 바꾼다 — 맞추기 · 채우기는 포트만 안다.
+ * 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/jmap-index-source.ts` 에 있다.
+ *
+ * 메일 모듈(실시간 소식 → 맞추기)과 인증 모듈(로그인 → 상태 줄)이 가져다 쓴다 — 이 모듈은 둘을 가져오지 않는다 (순환 없음).
+ * 운영 도구(서버와 다른 프로세스)는 이 모듈을 가져오지 않는다 — 퇴사 마무리는 표만 지운다 (ops-data.service.ts)
+ */
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([MailSearchMessageEntity, MailSearchStateEntity]),
+ MailEngineModule,
+ ],
+ controllers: [SearchIndexController],
+ providers: [
+ { provide: MAIL_INDEX_SOURCE, useClass: JmapIndexSource },
+ MailSearchIndexRepository,
+ MailSearchRuns,
+ MailSearchSyncService,
+ MailSearchBuildService,
+ MailSearchIndexService,
+ ],
+ exports: [MailSearchIndexService, MailSearchSyncService],
+})
+export class MailSearchModule {}
diff --git a/backend/src/modules/mail-search/mail-search.types.ts b/backend/src/modules/mail-search/mail-search.types.ts
new file mode 100644
index 0000000..3bd5702
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search.types.ts
@@ -0,0 +1,22 @@
+/**
+ * 메일 검색 색인의 도메인 타입 (14단계) — 엔진을 모른다.
+ */
+
+/** 색인 상태 — 지난 메일을 채우는 중 · 다 만들었음 (설계 A2) */
+export type SearchIndexStatus = 'building' | 'ready';
+
+/**
+ * 맞추기를 멈춘 까닭 — 대리 접근이 막혔다 (설계 B2). 다음 로그인(또는 검색 준비 상태를 물을 때) 푼다.
+ * - password-expired: 비밀번호가 만료됐다 (엔진이 메일 기능을 막는다 — 13단계)
+ * - locked: 로그인할 수 없는 계정 — 잠김(로그인 권한 없음) · 엔진에 없음
+ */
+export type SearchPausedReason = 'password-expired' | 'locked';
+
+/** GET /api/search-index 의 응답 (설계 C1) — 화면의 "검색 준비 중" 줄 */
+export interface SearchIndexStatusView {
+ readonly status: SearchIndexStatus;
+ /** 채운 메일 수 — 다 만들었으면 그때 색인에 든 수 */
+ readonly done: number;
+ /** 채울 메일 수(채우기를 시작할 때의 엔진 메일 수) — 아직 시작하지 않았으면 null */
+ readonly total: number | null;
+}
diff --git a/backend/src/modules/mail-search/search-index-schedule.spec.ts b/backend/src/modules/mail-search/search-index-schedule.spec.ts
new file mode 100644
index 0000000..bb46d60
--- /dev/null
+++ b/backend/src/modules/mail-search/search-index-schedule.spec.ts
@@ -0,0 +1,91 @@
+import { MailSearchRuns } from './mail-search-runs';
+import { FAILURE_BACKOFF_BASE_MS } from './mail-search.constants';
+import { isBuildWindow, isDueAfterFailures } from './search-index-schedule';
+
+/** 한국 시각 'HH:MM' → 그 시각(2026-11-05 한국 날짜)의 Date */
+function kst(time: string): Date {
+ return new Date(`2026-11-05T${time}:00+09:00`);
+}
+
+describe('isBuildWindow (밤 채우기의 창 — 한국 시각)', () => {
+ it.each([
+ ['22:00', true],
+ ['23:59', true],
+ ['00:00', true],
+ ['02:44', true],
+ ['02:45', false],
+ ['03:00', false],
+ ['03:30', false],
+ ['04:30', false],
+ ['04:59', false],
+ ['05:00', true],
+ ['07:29', true],
+ ['07:30', false],
+ ['12:00', false],
+ ['21:59', false],
+ ])('%s → %s', (time, inside) => {
+ expect(isBuildWindow(kst(time))).toBe(inside);
+ });
+});
+
+describe('isDueAfterFailures (실패가 이어진 직원을 다시 해 볼 때)', () => {
+ const at = new Date('2026-11-05T00:00:00Z');
+
+ it('실패가 없으면 늘 된다', () => {
+ expect(isDueAfterFailures(0, at, at.getTime())).toBe(true);
+ });
+
+ it('실패 수마다 기다림이 두 배 — 상한 단계에서 멈춘다', () => {
+ expect(
+ isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS - 1),
+ ).toBe(false);
+ expect(
+ isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS),
+ ).toBe(true);
+ expect(
+ isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 3),
+ ).toBe(false);
+ expect(
+ isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 4),
+ ).toBe(true);
+ // 100번이어도 상한(6단계 — 32배)까지만 기다린다
+ expect(
+ isDueAfterFailures(100, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 32),
+ ).toBe(true);
+ });
+});
+
+describe('MailSearchRuns (직원마다 한 번에 하나)', () => {
+ it('같은 직원의 일은 차례로, 다른 직원은 함께 — 앞 일이 실패해도 다음 일은 한다', async () => {
+ const runs = new MailSearchRuns();
+ const order: string[] = [];
+ let release: () => void = () => undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ const first = runs.run('Ara@x', async () => {
+ order.push('ara-1 시작');
+ await gate;
+ order.push('ara-1 끝');
+ throw new Error('실패');
+ });
+ const second = runs.run('ara@x', () => {
+ order.push('ara-2');
+ return Promise.resolve(2);
+ });
+ const other = runs.run('doyun@x', () => {
+ order.push('doyun');
+ return Promise.resolve(3);
+ });
+
+ await other;
+ expect(runs.isBusy('ARA@x')).toBe(true);
+ release();
+ await expect(first).rejects.toThrow('실패');
+ await expect(second).resolves.toBe(2);
+ expect(order).toEqual(['ara-1 시작', 'doyun', 'ara-1 끝', 'ara-2']);
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(runs.isBusy('ara@x')).toBe(false);
+ });
+});
diff --git a/backend/src/modules/mail-search/search-index-schedule.ts b/backend/src/modules/mail-search/search-index-schedule.ts
new file mode 100644
index 0000000..9236434
--- /dev/null
+++ b/backend/src/modules/mail-search/search-index-schedule.ts
@@ -0,0 +1,33 @@
+import { kstMinuteOfDay } from '@/shared/utils/kst-date';
+import {
+ BUILD_WINDOWS_KST,
+ FAILURE_BACKOFF_BASE_MS,
+ FAILURE_BACKOFF_STEPS_MAX,
+} from './mail-search.constants';
+
+/**
+ * 검색 색인의 때 고르기 (순수 함수 — 14단계 설계 B2 · B3).
+ */
+
+/** 밤 채우기의 창 안인가 — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30 (끝 분은 들지 않는다) */
+export function isBuildWindow(at: Date): boolean {
+ const minute = kstMinuteOfDay(at);
+ return BUILD_WINDOWS_KST.some(
+ ([start, end]) => minute >= start && minute < end,
+ );
+}
+
+/**
+ * 실패가 이어진 직원을 다시 해 볼 때가 됐나 — 실패 수마다 두 배(5분 · 10분 · 20분 …, FAILURE_BACKOFF_STEPS_MAX 단계에서 멈춤).
+ * 실패가 없으면 늘 된다. `lastAttemptAt` 은 마지막 실패를 적은 때(상태 줄의 updated_at)
+ */
+export function isDueAfterFailures(
+ failureCount: number,
+ lastAttemptAt: Date,
+ now: number,
+): boolean {
+ if (failureCount <= 0) return true;
+ const steps = Math.min(failureCount, FAILURE_BACKOFF_STEPS_MAX);
+ const wait = FAILURE_BACKOFF_BASE_MS * 2 ** (steps - 1);
+ return now - lastAttemptAt.getTime() >= wait;
+}
diff --git a/backend/src/modules/mail-search/search-index.controller.ts b/backend/src/modules/mail-search/search-index.controller.ts
new file mode 100644
index 0000000..796e245
--- /dev/null
+++ b/backend/src/modules/mail-search/search-index.controller.ts
@@ -0,0 +1,38 @@
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import {
+ ApiSessionErrors,
+ ApiStandardResponse,
+} from '@/common/decorators/api-standard-response.decorator';
+import {
+ CurrentUser,
+ type AuthenticatedUser,
+} from '@/common/decorators/current-user.decorator';
+import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
+import { SearchIndexStatusDto } from './dto/search-index-status.dto';
+import { MailSearchIndexService } from './mail-search-index.service';
+
+/** 검색 준비 상태 라우트 (`/api/search-index`) — 14단계 */
+export const SEARCH_INDEX_ROUTE = 'search-index';
+
+// 검색 준비 상태 (14단계 — 자체 검색 색인). 줄이 없던 직원은 이때 만든다
+@ApiTags('Mail')
+@ApiCookieAuth()
+@UseGuards(JwtAuthGuard)
+@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
+@Controller(SEARCH_INDEX_ROUTE)
+export class SearchIndexController {
+ constructor(private readonly searchIndex: MailSearchIndexService) {}
+
+ @Get()
+ @ApiOperation({
+ summary: '검색 준비 상태',
+ description:
+ '지난 메일의 검색 색인을 만드는 중(building)이면 그 직원의 검색은 엔진 방식이다 — 화면은 "검색 준비 중" 줄을 보인다. 색인은 밤에 한 사람씩 채운다. 처음 묻는 직원(상태 줄 없음)은 이때 building 으로 만든다.',
+ })
+ @ApiStandardResponse(SearchIndexStatusDto, { description: '지금 상태' })
+ status(@CurrentUser() user: AuthenticatedUser) {
+ return this.searchIndex.status(user.email);
+ }
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts
index 291139d..f57b520 100644
--- a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts
@@ -32,6 +32,13 @@ export class MailUserSettingsEntity {
})
externalImages!: string;
+ /**
+ * 목록을 대화로 묶어 보기 (14단계 — 기본 끔). 열은 14단계 1차의 마이그레이션(AddMailSearchIndex)이 만들고,
+ * 읽고 바꾸는 API(GET · PATCH /api/mail-settings 의 groupByConversation)는 3차에 더한다
+ */
+ @Column({ name: 'group_by_conversation', type: 'boolean', default: false })
+ groupByConversation!: boolean;
+
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts
index 1e2243e..0a5dbb9 100644
--- a/backend/src/modules/mail/jmap/jmap-filter-run.ts
+++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts
@@ -71,8 +71,11 @@ const BODY_ROUND_SIZE = 100;
export const FILTER_RUN_BODY_VALUE_BYTES = 262_144;
export const FILTER_RUN_MESSAGE_BODY_BYTES = 1_048_576;
-/** 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게) */
-const BODY_FETCH_BUDGET_BYTES = 4_000_000;
+/**
+ * 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게).
+ * 검색 색인의 본문 읽기(jmap-index-source.ts — 14단계)도 같은 상한을 쓴다
+ */
+export const BODY_FETCH_BUDGET_BYTES = 4_000_000;
/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
const MAX_IDS_PER_GET = 500;
diff --git a/backend/src/modules/mail/jmap/jmap-index-source.spec.ts b/backend/src/modules/mail/jmap/jmap-index-source.spec.ts
new file mode 100644
index 0000000..78923ed
--- /dev/null
+++ b/backend/src/modules/mail/jmap/jmap-index-source.spec.ts
@@ -0,0 +1,434 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import { IndexDeadlineError } from '@/modules/mail-search/mail-index-source';
+import {
+ BODY_READ_BATCH_MESSAGE_BYTES,
+ BODY_READ_SOLO_MESSAGE_BYTES,
+ INDEX_CALL_MIN_MS,
+ INDEX_CALL_TIMEOUT_MS,
+} from '@/modules/mail-search/mail-search.constants';
+import type { JmapClient, JmapCallOptions } from './jmap.client';
+import {
+ JmapIndexSource,
+ indexAttachmentNames,
+ indexBodyBatches,
+} from './jmap-index-source';
+import type { JmapEmail, JmapInvocation } from './jmap.types';
+
+const ARA = 'ara.kim@arasystem.test';
+
+type Responder = (
+ method: string,
+ args: Record,
+ callId: string,
+) => JmapInvocation;
+
+/** 대역 엔진 — 메서드마다 답을 정한다. 받은 요청을 모은다 */
+class FakeJmap {
+ readonly requests: { calls: JmapInvocation[]; options: JmapCallOptions }[] =
+ [];
+ responders = new Map();
+ /** 요청 전체를 이 예외로 거절한다 (한 번) */
+ rejectNext: Error | null = null;
+
+ readonly client = {
+ call: jest.fn(
+ (
+ _user: string,
+ build: (accountId: string) => JmapInvocation[],
+ options: JmapCallOptions = {},
+ ) => {
+ const calls = build('acc');
+ this.requests.push({ calls, options });
+ if (this.rejectNext) {
+ const error = this.rejectNext;
+ this.rejectNext = null;
+ return Promise.reject(error);
+ }
+ return Promise.resolve(
+ calls.map(([method, args, callId]) => {
+ const responder = this.responders.get(method);
+ if (!responder) throw new Error(`답이 없는 메서드: ${method}`);
+ return responder(method, args, callId);
+ }),
+ );
+ },
+ ),
+ };
+
+ on(method: string, responder: Responder): void {
+ this.responders.set(method, responder);
+ }
+}
+
+describe('JmapIndexSource (검색 색인의 엔진 읽기)', () => {
+ let engine: FakeJmap;
+ let source: JmapIndexSource;
+ const options = () => ({ deadline: Date.now() + 30_000 });
+
+ beforeEach(() => {
+ engine = new FakeJmap();
+ source = new JmapIndexSource(engine.client as unknown as JmapClient);
+ });
+
+ describe('changes', () => {
+ it('만든 · 바꾼 · 없어진 id 와 새 state · 더 있는지', async () => {
+ engine.on('Email/changes', (method, args, callId) => {
+ expect(args).toMatchObject({ sinceState: 's1', maxChanges: 100 });
+ return [
+ method,
+ {
+ newState: 's2',
+ hasMoreChanges: true,
+ created: ['a'],
+ updated: ['b'],
+ destroyed: ['c', 7],
+ },
+ callId,
+ ];
+ });
+ expect(await source.changes(ARA, 's1', 100, options())).toEqual({
+ kind: 'changes',
+ created: ['a'],
+ updated: ['b'],
+ destroyed: ['c'],
+ newState: 's2',
+ hasMore: true,
+ });
+ // 호출마다 남은 시간을 넘긴다 (한 번은 15초까지)
+ expect(engine.requests[0].options.timeoutMs).toBeLessThanOrEqual(15_000);
+ });
+
+ it.each(['cannotCalculateChanges', 'invalidArguments'])(
+ '엔진이 셀 수 없다(%s) → cannotCalculate',
+ async (type) => {
+ engine.on('Email/changes', (_method, _args, callId) => [
+ 'error',
+ { type, description: '엔진 문장' },
+ callId,
+ ]);
+ expect(await source.changes(ARA, 'old', 100, options())).toEqual({
+ kind: 'cannotCalculate',
+ });
+ },
+ );
+
+ it('요청 전체를 거절해도(모르는 state 모양) cannotCalculate — 그 밖의 오류는 코드로 던진다', async () => {
+ engine.rejectNext = new BusinessException('VAL_001', {
+ context: { reason: 'request-rejected', jmapType: 'notRequest' },
+ });
+ expect(await source.changes(ARA, '###', 100, options())).toEqual({
+ kind: 'cannotCalculate',
+ });
+
+ engine.on('Email/changes', (_method, _args, callId) => [
+ 'error',
+ { type: 'serverFail', description: '엔진 문장' },
+ callId,
+ ]);
+ const error = (await source
+ .changes(ARA, 's1', 100, options())
+ .catch((caught: unknown) => caught)) as BusinessException;
+ expect(error.code).toBe('MAIL_001');
+ expect(JSON.stringify(error.context)).not.toContain('엔진 문장');
+ });
+
+ it('끝 시각까지 INDEX_CALL_MIN_MS 보다 적게 남았으면 엔진에 묻지 않고 IndexDeadlineError (실패가 아니라 이어 하기)', async () => {
+ for (const deadline of [
+ Date.now() - 1,
+ Date.now() + INDEX_CALL_MIN_MS - 500,
+ ]) {
+ await expect(
+ source.changes(ARA, 's1', 100, { deadline }),
+ ).rejects.toBeInstanceOf(IndexDeadlineError);
+ }
+ expect(engine.requests).toHaveLength(0);
+ });
+
+ it('남은 시간으로 줄인 기다림이 끝나 끊기면 IndexDeadlineError — 끝 시각이 멀어 줄이지 않았으면 엔진 장애(MAIL_001) 그대로', async () => {
+ const timeout = () =>
+ new BusinessException('MAIL_001', {
+ context: { reason: 'timeout', call: 'api' },
+ });
+ engine.rejectNext = timeout();
+ await expect(
+ source.changes(ARA, 's1', 100, { deadline: Date.now() + 5_000 }),
+ ).rejects.toBeInstanceOf(IndexDeadlineError);
+ expect(engine.requests[0].options.timeoutMs).toBeLessThanOrEqual(5_000);
+
+ engine.rejectNext = timeout();
+ await expect(
+ source.changes(ARA, 's1', 100, {
+ deadline: Date.now() + INDEX_CALL_TIMEOUT_MS * 2,
+ }),
+ ).rejects.toMatchObject({ code: 'MAIL_001' });
+ expect(engine.requests[1].options.timeoutMs).toBe(INDEX_CALL_TIMEOUT_MS);
+ });
+ });
+
+ describe('documents', () => {
+ const emails: JmapEmail[] = [
+ {
+ id: 'e1',
+ threadId: 't1',
+ mailboxIds: { inbox: true, old: false },
+ keywords: { $seen: true, $flagged: false },
+ receivedAt: '2026-11-05T01:00:00Z',
+ size: 900,
+ messageId: ['m1@x'],
+ inReplyTo: ['p@x'],
+ references: ['r@x', 'p@x'],
+ subject: '계약서',
+ from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ to: [{ email: ARA }],
+ cc: null,
+ preview: '미리 보기',
+ textBody: [{ partId: '1', type: 'text/html', size: 100 }],
+ attachments: [
+ // 진짜 첨부 — 이름이 있음 · 이름 없음
+ {
+ partId: '2',
+ type: 'application/pdf',
+ name: ' 계약서.pdf ',
+ size: 10,
+ },
+ { partId: '3', type: 'application/zip', size: 10 },
+ // 본문 그림(서명 로고) — 첨부가 아니다
+ {
+ partId: '4',
+ type: 'image/png',
+ name: 'logo.png',
+ cid: 'logo',
+ disposition: 'inline',
+ size: 1000,
+ },
+ // 텍스트 첨부 — 안의 글은 읽지 않는다
+ { partId: '5', type: 'text/plain', name: '메모.txt', size: 10 },
+ ],
+ },
+ {
+ id: 'e2',
+ mailboxIds: { inbox: true },
+ receivedAt: '2026-11-05T02:00:00Z',
+ subject: null,
+ textBody: [
+ { partId: '1', type: 'text/plain', size: 20 },
+ { partId: '2', type: 'image/jpeg', size: 20 },
+ ],
+ attachments: [],
+ },
+ ];
+
+ it('머리글을 읽고 textBody 의 글자 부분만 값을 읽는다 — HTML 은 글자만, 첨부 이름은 진짜 첨부만', async () => {
+ engine.on('Email/get', (method, args, callId) => {
+ const ids = args.ids as string[];
+ if (args.fetchTextBodyValues === true) {
+ // 몫 — 글자 부분 하나면 256KiB
+ expect(args.maxBodyValueBytes).toBe(262_144);
+ expect(args.fetchAllBodyValues).toBeUndefined();
+ return [
+ method,
+ {
+ list: ids.map((id) =>
+ id === 'e1'
+ ? {
+ id,
+ textBody: [{ partId: '1', type: 'text/html' }],
+ bodyValues: {
+ '1': {
+ value:
+ '계약서를보내 드립니다
',
+ },
+ },
+ }
+ : {
+ id,
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ bodyValues: { '1': { value: '글 본문' } },
+ },
+ ),
+ },
+ callId,
+ ];
+ }
+ return [
+ method,
+ { list: emails.filter((email) => ids.includes(email.id)) },
+ callId,
+ ];
+ });
+
+ const documents = await source.documents(
+ ARA,
+ ['e1', 'e2', 'gone'],
+ options(),
+ );
+
+ expect(documents).toHaveLength(2);
+ expect(documents[0]).toEqual({
+ id: 'e1',
+ threadId: 't1',
+ mailboxIds: ['inbox'],
+ keywords: ['$seen'],
+ receivedAt: '2026-11-05T01:00:00Z',
+ size: 900,
+ messageIds: ['m1@x'],
+ inReplyTo: ['p@x'],
+ references: ['r@x', 'p@x'],
+ subject: '계약서',
+ from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ to: [{ name: null, email: ARA }],
+ cc: [],
+ attachmentNames: ['계약서.pdf', '메모.txt'],
+ hasAttachment: true,
+ preview: '미리 보기',
+ bodyText: '계약서를보내 드립니다\n',
+ });
+ expect(documents[1]).toMatchObject({
+ id: 'e2',
+ subject: '',
+ threadId: null,
+ bodyText: '글 본문',
+ hasAttachment: false,
+ attachmentNames: [],
+ });
+ });
+ });
+
+ it('indexBodyBatches — 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘으면 나누고, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자', () => {
+ const part = { partId: '1', type: 'text/plain', size: 1_000 };
+ const third = Math.floor(BODY_READ_BATCH_MESSAGE_BYTES / 3);
+ const batches = indexBodyBatches([
+ // 큰 첨부가 든 메일 넷 — 크기 합이 상한을 넘기 전에 나눈다
+ { id: 'a', parts: [part], size: third },
+ { id: 'b', parts: [part], size: third },
+ { id: 'c', parts: [part], size: third },
+ { id: 'd', parts: [part], size: third },
+ // 혼자 읽는 메일 — 그 앞뒤 묶음과 섞지 않는다
+ { id: 'huge', parts: [part], size: BODY_READ_SOLO_MESSAGE_BYTES + 1 },
+ { id: 'small', parts: [part], size: 10 },
+ ]);
+ expect(batches.map((batch) => batch.ids)).toEqual([
+ ['a', 'b', 'c'],
+ ['huge'],
+ ['d', 'small'],
+ ]);
+ });
+
+ it('indexBodyBatches — 몫이 같은 메일끼리, 어림 크기가 상한을 넘으면 나눈다 · 글자 부분이 없으면 읽지 않는다', () => {
+ const big = { partId: '1', type: 'text/plain', size: 1_000_000 };
+ const batches = indexBodyBatches([
+ { id: 'a', parts: [big], size: 1_000_000 },
+ { id: 'b', parts: [big], size: 1_000_000 },
+ { id: 'c', parts: [big, { ...big, partId: '2' }], size: 2_000_000 },
+ { id: 'none', parts: [], size: 0 },
+ ]);
+ // 한 부분 몫 256KiB — 어림 256KiB 씩이라 a · b 는 한 묶음, c 는 부분이 둘이라 몫이 같은(256KiB) 묶음에
+ expect(batches.flatMap((batch) => batch.ids).sort()).toEqual([
+ 'a',
+ 'b',
+ 'c',
+ ]);
+ expect(batches.every((batch) => batch.quota === 262_144)).toBe(true);
+ });
+
+ it('indexAttachmentNames — 본문 그림을 빼고 이름이 있는 것만, 경로 · 제어 문자는 다듬는다', () => {
+ expect(
+ indexAttachmentNames([
+ { name: '../보고서.hwp', type: 'application/octet-stream' },
+ { name: 'logo.png', type: 'image/png', cid: 'c1', size: 10 },
+ { name: '', type: 'application/pdf' },
+ ]),
+ ).toEqual(['보고서.hwp']);
+ });
+
+ describe('pageIds · allIds · snapshot', () => {
+ it('anchor 다음부터 최신 차례 — anchor 가 지워졌으면 위치로', async () => {
+ const seen: Record[] = [];
+ engine.on('Email/query', (method, args, callId) => {
+ seen.push(args);
+ if (args.anchor === 'gone') {
+ return ['error', { type: 'anchorNotFound' }, callId];
+ }
+ return [method, { ids: ['x', 'y'], position: 50, total: 120 }, callId];
+ });
+ expect(
+ await source.pageIds(
+ ARA,
+ { anchor: 'gone', position: 50 },
+ 2,
+ options(),
+ ),
+ ).toEqual({ ids: ['x', 'y'], position: 50, total: 120 });
+ expect(seen[0]).toMatchObject({
+ anchor: 'gone',
+ anchorOffset: 1,
+ sort: [{ property: 'receivedAt', isAscending: false }],
+ calculateTotal: true,
+ });
+ expect(seen[1]).toMatchObject({ position: 50, limit: 2 });
+ expect(seen[1]).not.toHaveProperty('anchor');
+ });
+
+ it('전체 대조는 오래된 차례로 anchor 로 이어 묻고 빈 쪽에서 끝난다 — anchor 가 지워지면 처음부터', async () => {
+ const pages = [
+ { anchor: undefined, ids: ['a', 'b'] },
+ { anchor: 'b', error: 'anchorNotFound' },
+ { anchor: undefined, ids: ['a', 'c'] },
+ { anchor: 'c', ids: ['d'] },
+ { anchor: 'd', ids: [] },
+ ];
+ engine.on('Email/get', (method, _args, callId) => [
+ method,
+ { state: 'st', list: [] },
+ callId,
+ ]);
+ engine.on('Email/query', (method, args, callId) => {
+ if (args.limit === 1) return [method, { ids: [], total: 3 }, callId];
+ const page = pages.shift()!;
+ expect(args.anchor).toBe(page.anchor);
+ expect(args.sort).toEqual([
+ { property: 'receivedAt', isAscending: true },
+ ]);
+ if (page.error) return ['error', { type: page.error }, callId];
+ return [method, { ids: page.ids, total: 3 }, callId];
+ });
+ expect(await source.allIds(ARA, options())).toEqual({
+ state: 'st',
+ ids: ['a', 'c', 'd'],
+ });
+ expect(pages).toEqual([]);
+ });
+
+ it('snapshot — 빈 ids 의 Email/get 으로 state, 조회의 total 로 메일 수', async () => {
+ engine.on('Email/get', (method, args, callId) => {
+ expect(args.ids).toEqual([]);
+ return [method, { state: 'st9', list: [] }, callId];
+ });
+ engine.on('Email/query', (method, _args, callId) => [
+ method,
+ { ids: ['z'], total: 42 },
+ callId,
+ ]);
+ expect(await source.snapshot(ARA, options())).toEqual({
+ state: 'st9',
+ total: 42,
+ });
+ });
+
+ it('state 가 없으면 MAIL_001', async () => {
+ engine.on('Email/get', (method, _args, callId) => [
+ method,
+ { list: [] },
+ callId,
+ ]);
+ engine.on('Email/query', (method, _args, callId) => [
+ method,
+ { ids: [] },
+ callId,
+ ]);
+ await expect(source.snapshot(ARA, options())).rejects.toMatchObject({
+ code: 'MAIL_001',
+ });
+ });
+ });
+});
diff --git a/backend/src/modules/mail/jmap/jmap-index-source.ts b/backend/src/modules/mail/jmap/jmap-index-source.ts
new file mode 100644
index 0000000..fcdb222
--- /dev/null
+++ b/backend/src/modules/mail/jmap/jmap-index-source.ts
@@ -0,0 +1,622 @@
+import { Injectable } from '@nestjs/common';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import { sanitizeFileName } from '@/modules/mail/attachment.policy';
+import { htmlToText } from '@/modules/mail/html/html-text';
+import {
+ hasRealAttachment,
+ isBodyImagePart,
+} from '@/modules/mail/html/inline-images';
+import {
+ IndexDeadlineError,
+ type IndexAddress,
+ type IndexAllIds,
+ type IndexChanges,
+ type IndexDocument,
+ type IndexIdPage,
+ type IndexMutable,
+ type IndexReadOptions,
+ type MailIndexSource,
+} from '@/modules/mail-search/mail-index-source';
+import {
+ ALL_IDS_PAGE,
+ BODY_READ_BATCH_MESSAGE_BYTES,
+ BODY_READ_SOLO_MESSAGE_BYTES,
+ INDEX_CALL_MIN_MS,
+ INDEX_CALL_TIMEOUT_MS,
+} from '@/modules/mail-search/mail-search.constants';
+import { BODY_FETCH_BUDGET_BYTES, bodyValueQuota } from './jmap-filter-run';
+import { JmapClient, type JmapCallOptions } from './jmap.client';
+import { jmapFailure } from './jmap.errors';
+import { expectResult, findResult, isState } from './jmap-results';
+import type {
+ JmapBodyPart,
+ JmapChangesResult,
+ JmapEmail,
+ JmapEmailAddress,
+ JmapGetResult,
+ JmapInvocation,
+ JmapQueryResult,
+} from './jmap.types';
+
+/**
+ * 메일 검색 색인이 엔진에서 읽는 것 — Stalwart(JMAP) 어댑터 (14단계 설계 B2, 포트는 modules/mail-search/mail-index-source.ts).
+ * 직원 자격(게이트웨이 대리 접근 — JmapClient.call)으로 읽기만 한다 — 이 파일에는 Email/set 이 없다.
+ *
+ * - 글: 머리글 · 받는 사람 · 첨부 부분은 한 번에(MAX_IDS_PER_GET 씩), 본문 값은 textBody 의 글자 부분만 따로 읽는다(fetchTextBodyValues).
+ * textBody 는 엔진이 고른 글자 쪽 표현(text/plain, 없으면 text/html)이라 같은 글을 두 번 담지 않는다. 첨부 파일 안의 글은 읽지 않는다(설계 — 첨부 이름만 찾는다).
+ * 본문 값은 부분마다 256KiB · 한 통 1MiB 까지(이미 받은 메일에 필터 적용과 같은 몫 — jmap-filter-run.ts 의 bodyValueQuota),
+ * 몫이 같은 메일끼리 어림 크기 BODY_FETCH_BUDGET_BYTES 로 나눠 읽는다 — 바깥 메일 한 통으로 서버 · 엔진 메모리를 크게 쓰지 않게.
+ * 묶음은 메일 **전체** 크기(size)의 합도 BODY_READ_BATCH_MESSAGE_BYTES 까지, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다 —
+ * 엔진은 값을 만들며 원문을 읽으므로 큰 첨부 메일 여럿이 한 요청에 들면 엔진(1g) 메모리가 한꺼번에 튄다
+ * - 시간: 엔진 읽기마다 차례의 끝 시각까지 남은 시간을 본다 — INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고, 남은 시간으로 줄인 기다림이
+ * 끝나 끊긴 것도 IndexDeadlineError(실패가 아니라 이어 하기)로 알린다. 한 번은 INDEX_CALL_TIMEOUT_MS 까지만 기다린다
+ * - 지난 메일 쪽: 받은 시각 최신부터 anchor 다음 — anchor 가 지워졌으면(anchorNotFound) 위치로
+ * - 전체 대조: 받은 시각 오래된 것부터 anchor 로 이어 묻는다(위치로 넘기면 그사이 지운 메일만큼 건너뛴다) — anchor 가 지워지면 처음부터 다시
+ * 엔진 오류의 설명 문장은 남기지 않는다(jmapFailure — type · 속성 이름만)
+ */
+
+/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
+const MAX_IDS_PER_GET = 500;
+
+/** 전체 대조에서 anchor 가 지워져 처음부터 다시 묻는 횟수 */
+const ALL_IDS_RESTARTS = 3;
+
+/** 머리글을 읽을 때의 속성 · 부분 속성 (본문 값은 따로) */
+const DOCUMENT_PROPERTIES = [
+ 'id',
+ 'threadId',
+ 'mailboxIds',
+ 'keywords',
+ 'receivedAt',
+ 'size',
+ 'messageId',
+ 'inReplyTo',
+ 'references',
+ 'subject',
+ 'from',
+ 'to',
+ 'cc',
+ 'preview',
+ 'attachments',
+ 'textBody',
+];
+const DOCUMENT_PART_PROPERTIES = [
+ 'partId',
+ 'type',
+ 'size',
+ 'name',
+ 'disposition',
+ 'cid',
+];
+
+const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }];
+const OLDEST_FIRST = [{ property: 'receivedAt', isAscending: true }];
+
+/** JMAP 메서드 오류 — 그 state 부터의 변화를 셀 수 없다 · 인자가 맞지 않는다(모르는 state 모양) · anchor 가 결과에 없다 */
+const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges';
+const INVALID_ARGUMENTS = 'invalidArguments';
+const ANCHOR_NOT_FOUND = 'anchorNotFound';
+
+const HTML_TYPE = 'text/html';
+
+function chunks(items: readonly T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let index = 0; index < items.length; index += size) {
+ out.push(items.slice(index, index + size));
+ }
+ return out;
+}
+
+function trueKeys(record: Record | undefined): string[] {
+ return Object.entries(record ?? {})
+ .filter(([, value]) => value === true)
+ .map(([key]) => key);
+}
+
+function strings(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : [];
+}
+
+function toAddresses(
+ addresses: JmapEmailAddress[] | null | undefined,
+): IndexAddress[] {
+ return (addresses ?? []).map((address) => ({
+ name: typeof address.name === 'string' ? address.name : null,
+ email: typeof address.email === 'string' ? address.email : null,
+ }));
+}
+
+/** textBody 의 글자 부분 (text/* — 같은 partId 는 한 번) */
+export function indexTextParts(email: JmapEmail): JmapBodyPart[] {
+ const seen = new Set();
+ return (email.textBody ?? []).filter((part) => {
+ const { partId } = part;
+ if (!partId || seen.has(partId)) return false;
+ seen.add(partId);
+ return (part.type ?? '').toLowerCase().startsWith('text/');
+ });
+}
+
+/** 본문 글 — 글자 부분마다(HTML 은 글자만), 줄바꿈으로 잇는다. 값을 받지 못한 부분은 뺀다 */
+export function indexBodyText(
+ parts: readonly JmapBodyPart[],
+ values: JmapEmail['bodyValues'],
+): string {
+ return parts
+ .flatMap((part) => {
+ const value = values?.[part.partId ?? '']?.value;
+ if (typeof value !== 'string') return [];
+ return [
+ (part.type ?? '').toLowerCase() === HTML_TYPE
+ ? htmlToText(value)
+ : value,
+ ];
+ })
+ .join('\n');
+}
+
+/** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 뺀다 (목록 · 읽기 화면과 같은 이름 다듬기) */
+export function indexAttachmentNames(
+ attachments: readonly JmapBodyPart[],
+): string[] {
+ return attachments
+ .filter((part) => !isBodyImagePart(part))
+ .flatMap((part) => {
+ const name = part.name?.trim();
+ return name ? [sanitizeFileName(name)] : [];
+ });
+}
+
+/** 본문 값을 받을 때 응답 크기의 어림 (바이트) — 부분마다 몫까지, UTF-8 로 바꾸며 늘 수 있어 두 배 (크기를 모르면 몫) */
+function estimatedBytes(parts: readonly JmapBodyPart[], quota: number): number {
+ return parts.reduce(
+ (sum, part) =>
+ sum +
+ (typeof part.size === 'number' && part.size >= 0
+ ? Math.min(quota, part.size * 2)
+ : quota),
+ 0,
+ );
+}
+
+/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */
+interface BodyBatch {
+ readonly quota: number;
+ readonly ids: string[];
+}
+
+/** 본문 값을 읽을 메일 한 통 — 글자 부분 · 메일 전체 크기(엔진의 size, 모르면 0) */
+export interface BodyReadCandidate {
+ readonly id: string;
+ readonly parts: readonly JmapBodyPart[];
+ readonly size: number;
+}
+
+/**
+ * 본문 값을 읽을 묶음 — 몫(bodyValueQuota)이 같은 메일끼리. 이럴 때 나눈다: 값의 어림 크기가 BODY_FETCH_BUDGET_BYTES 를 넘는다 ·
+ * 한 번 읽기 한도(MAX_IDS_PER_GET)에 닿는다 · 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘는다.
+ * 전체 크기가 BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다. 글자 부분이 없는 메일은 읽지 않는다
+ */
+export function indexBodyBatches(
+ emails: readonly BodyReadCandidate[],
+): BodyBatch[] {
+ const groups = new Map();
+ for (const email of emails) {
+ if (email.parts.length === 0) continue;
+ const quota = bodyValueQuota(email.parts.length);
+ const group = groups.get(quota);
+ if (group) group.push(email);
+ else groups.set(quota, [email]);
+ }
+ const batches: BodyBatch[] = [];
+ for (const [quota, group] of groups) {
+ let ids: string[] = [];
+ let valueBytes = 0;
+ let messageBytes = 0;
+ for (const email of group) {
+ const size = Number.isFinite(email.size) ? Math.max(0, email.size) : 0;
+ if (size > BODY_READ_SOLO_MESSAGE_BYTES) {
+ batches.push({ quota, ids: [email.id] });
+ continue;
+ }
+ const estimate = estimatedBytes(email.parts, quota);
+ if (
+ ids.length > 0 &&
+ (valueBytes + estimate > BODY_FETCH_BUDGET_BYTES ||
+ messageBytes + size > BODY_READ_BATCH_MESSAGE_BYTES ||
+ ids.length >= MAX_IDS_PER_GET)
+ ) {
+ batches.push({ quota, ids });
+ ids = [];
+ valueBytes = 0;
+ messageBytes = 0;
+ }
+ ids.push(email.id);
+ valueBytes += estimate;
+ messageBytes += size;
+ }
+ if (ids.length > 0) batches.push({ quota, ids });
+ }
+ return batches;
+}
+
+/** 엔진 호출이 기다림 끝(시간 초과)으로 끊겼나 — JmapClient 가 붙이는 사유 */
+function isTimeout(error: unknown): boolean {
+ return (
+ error instanceof BusinessException &&
+ error.code === 'MAIL_001' &&
+ error.context?.reason === 'timeout'
+ );
+}
+
+@Injectable()
+export class JmapIndexSource implements MailIndexSource {
+ constructor(private readonly jmap: JmapClient) {}
+
+ async snapshot(
+ user: string,
+ options: IndexReadOptions,
+ ): Promise<{ state: string; total: number | null }> {
+ const responses = await this.call(
+ user,
+ (accountId) => [
+ ['Email/get', { accountId, ids: [], properties: ['id'] }, 's'],
+ [
+ 'Email/query',
+ { accountId, position: 0, limit: 1, calculateTotal: true },
+ 'q',
+ ],
+ ],
+ options,
+ );
+ const { state } = expectResult>(
+ responses,
+ 's',
+ 'Email/get',
+ );
+ const query = expectResult(responses, 'q', 'Email/query');
+ return {
+ state: this.requireState(state),
+ total: typeof query.total === 'number' ? query.total : null,
+ };
+ }
+
+ async changes(
+ user: string,
+ sinceState: string,
+ maxChanges: number,
+ options: IndexReadOptions,
+ ): Promise {
+ let responses: JmapInvocation[];
+ try {
+ responses = await this.call(
+ user,
+ (accountId) => [
+ ['Email/changes', { accountId, sinceState, maxChanges }, 'c'],
+ ],
+ options,
+ // 엔진이 state 모양을 받지 않아 요청 전체를 거절해도 셀 수 없는 것으로 본다 (아래 catch)
+ { invalidRequestCode: 'VAL_001' },
+ );
+ } catch (error) {
+ if (
+ error instanceof BusinessException &&
+ error.code === 'VAL_001' &&
+ error.context?.reason === 'request-rejected'
+ ) {
+ return { kind: 'cannotCalculate' };
+ }
+ throw error;
+ }
+ const result = findResult(responses, 'c', 'Email/changes');
+ if (!result.ok) {
+ const type = result.error.type;
+ if (type === CANNOT_CALCULATE_CHANGES || type === INVALID_ARGUMENTS) {
+ return { kind: 'cannotCalculate' };
+ }
+ throw jmapFailure(result.error, { method: 'Email/changes' });
+ }
+ const changes = result.args as JmapChangesResult;
+ return {
+ kind: 'changes',
+ created: strings(changes.created),
+ updated: strings(changes.updated),
+ destroyed: strings(changes.destroyed),
+ newState: this.requireState(changes.newState),
+ hasMore: changes.hasMoreChanges === true,
+ };
+ }
+
+ async documents(
+ user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise {
+ const documents: IndexDocument[] = [];
+ for (const chunk of chunks(ids, MAX_IDS_PER_GET)) {
+ const emails = await this.readEmails(
+ user,
+ chunk,
+ DOCUMENT_PROPERTIES,
+ DOCUMENT_PART_PROPERTIES,
+ options,
+ );
+ const withParts = emails.map((email) => ({
+ id: email.id,
+ parts: indexTextParts(email),
+ size: typeof email.size === 'number' ? email.size : 0,
+ }));
+ const bodies = new Map();
+ for (const batch of indexBodyBatches(withParts)) {
+ for (const email of await this.readBodies(user, batch, options)) {
+ bodies.set(
+ email.id,
+ indexBodyText(indexTextParts(email), email.bodyValues),
+ );
+ }
+ }
+ for (const email of emails) {
+ documents.push(this.toDocument(email, bodies.get(email.id) ?? ''));
+ }
+ }
+ return documents;
+ }
+
+ async mutable(
+ user: string,
+ ids: readonly string[],
+ options: IndexReadOptions,
+ ): Promise {
+ const found: IndexMutable[] = [];
+ for (const chunk of chunks(ids, MAX_IDS_PER_GET)) {
+ const emails = await this.readEmails(
+ user,
+ chunk,
+ ['id', 'mailboxIds', 'keywords'],
+ null,
+ options,
+ );
+ for (const email of emails) {
+ found.push({
+ id: email.id,
+ mailboxIds: trueKeys(email.mailboxIds),
+ keywords: trueKeys(email.keywords),
+ });
+ }
+ }
+ return found;
+ }
+
+ async pageIds(
+ user: string,
+ from: { anchor: string | null; position: number },
+ limit: number,
+ options: IndexReadOptions,
+ ): Promise {
+ if (from.anchor !== null) {
+ const anchored = await this.query(
+ user,
+ NEWEST_FIRST,
+ { anchor: from.anchor },
+ limit,
+ options,
+ );
+ if (anchored !== null) return anchored;
+ }
+ // anchor 가 없거나 그사이 지워졌다 — 위치로
+ const page = await this.query(
+ user,
+ NEWEST_FIRST,
+ { position: Math.max(0, from.position) },
+ limit,
+ options,
+ );
+ return page ?? { ids: [], position: from.position, total: null };
+ }
+
+ async allIds(user: string, options: IndexReadOptions): Promise {
+ for (let attempt = 0; attempt <= ALL_IDS_RESTARTS; attempt += 1) {
+ const { state } = await this.snapshot(user, options);
+ const ids: string[] = [];
+ let anchor: string | null = null;
+ let restarted = false;
+ for (;;) {
+ const page = await this.query(
+ user,
+ OLDEST_FIRST,
+ anchor === null ? { position: 0 } : { anchor },
+ ALL_IDS_PAGE,
+ options,
+ );
+ if (page === null) {
+ restarted = true;
+ break;
+ }
+ if (page.ids.length === 0) break;
+ ids.push(...page.ids);
+ anchor = page.ids[page.ids.length - 1];
+ }
+ if (!restarted) return { state, ids };
+ }
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'index-all-ids-anchor-lost' },
+ });
+ }
+
+ /**
+ * 받은 시각 차례로 한 쪽 — anchor 가 결과에 없으면(anchorNotFound) null (부른 쪽이 위치로 · 처음부터).
+ * 위치 · anchor 둘 다 total 을 센다
+ */
+ private async query(
+ user: string,
+ sort: readonly { property: string; isAscending: boolean }[],
+ start: { anchor: string } | { position: number },
+ limit: number,
+ options: IndexReadOptions,
+ ): Promise {
+ const responses = await this.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/query',
+ {
+ accountId,
+ sort,
+ ...('anchor' in start
+ ? { anchor: start.anchor, anchorOffset: 1 }
+ : { position: start.position }),
+ limit,
+ calculateTotal: true,
+ },
+ 'q',
+ ],
+ ],
+ options,
+ );
+ const result = findResult(responses, 'q', 'Email/query');
+ if (!result.ok) {
+ if (result.error.type === ANCHOR_NOT_FOUND && 'anchor' in start) {
+ return null;
+ }
+ throw jmapFailure(result.error, { method: 'Email/query' });
+ }
+ const query = result.args as JmapQueryResult;
+ return {
+ ids: strings(query.ids),
+ position:
+ typeof query.position === 'number'
+ ? query.position
+ : 'position' in start
+ ? start.position
+ : 0,
+ total: typeof query.total === 'number' ? query.total : null,
+ };
+ }
+
+ private async readEmails(
+ user: string,
+ ids: readonly string[],
+ properties: readonly string[],
+ bodyProperties: readonly string[] | null,
+ options: IndexReadOptions,
+ ): Promise {
+ if (ids.length === 0) return [];
+ const responses = await this.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/get',
+ {
+ accountId,
+ ids: [...ids],
+ properties: [...properties],
+ ...(bodyProperties ? { bodyProperties: [...bodyProperties] } : {}),
+ },
+ 'g',
+ ],
+ ],
+ options,
+ );
+ const { list = [] } = expectResult>(
+ responses,
+ 'g',
+ 'Email/get',
+ );
+ return list.filter(
+ (email): email is JmapEmail =>
+ typeof email?.id === 'string' && email.id !== '',
+ );
+ }
+
+ /** 본문 값 — textBody 의 글자 부분, 부분마다 그 몫까지 */
+ private async readBodies(
+ user: string,
+ batch: BodyBatch,
+ options: IndexReadOptions,
+ ): Promise {
+ const responses = await this.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/get',
+ {
+ accountId,
+ ids: batch.ids,
+ properties: ['id', 'textBody', 'bodyValues'],
+ bodyProperties: ['partId', 'type'],
+ fetchTextBodyValues: true,
+ maxBodyValueBytes: batch.quota,
+ },
+ 'b',
+ ],
+ ],
+ options,
+ );
+ const { list = [] } = expectResult>(
+ responses,
+ 'b',
+ 'Email/get',
+ );
+ return list.filter((email) => typeof email?.id === 'string');
+ }
+
+ private toDocument(email: JmapEmail, bodyText: string): IndexDocument {
+ const attachments = email.attachments ?? [];
+ return {
+ id: email.id,
+ threadId: typeof email.threadId === 'string' ? email.threadId : null,
+ mailboxIds: trueKeys(email.mailboxIds),
+ keywords: trueKeys(email.keywords),
+ receivedAt: typeof email.receivedAt === 'string' ? email.receivedAt : '',
+ size: typeof email.size === 'number' ? email.size : 0,
+ messageIds: strings(email.messageId),
+ inReplyTo: strings(email.inReplyTo),
+ references: strings(email.references),
+ subject: typeof email.subject === 'string' ? email.subject : '',
+ from: toAddresses(email.from),
+ to: toAddresses(email.to),
+ cc: toAddresses(email.cc),
+ attachmentNames: indexAttachmentNames(attachments),
+ hasAttachment: hasRealAttachment(attachments),
+ preview: typeof email.preview === 'string' ? email.preview : '',
+ bodyText,
+ };
+ }
+
+ /**
+ * 엔진 읽기 한 번 — 차례의 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않는다(IndexDeadlineError).
+ * 기다림은 남은 시간 · INDEX_CALL_TIMEOUT_MS 가운데 짧은 쪽이다 — 남은 시간으로 줄인 기다림이 끝나 끊겼으면 그것도 IndexDeadlineError
+ * (엔진이 느린 것이 아니라 차례가 끝난 것이다). 그 밖의 실패는 그대로 던진다
+ */
+ private async call(
+ user: string,
+ build: (accountId: string) => JmapInvocation[],
+ options: IndexReadOptions,
+ extra: Omit = {},
+ ): Promise {
+ const remaining = options.deadline - Date.now();
+ if (remaining < INDEX_CALL_MIN_MS) throw new IndexDeadlineError();
+ const cappedByDeadline = remaining < INDEX_CALL_TIMEOUT_MS;
+ try {
+ return await this.jmap.call(user, build, {
+ ...extra,
+ timeoutMs: Math.min(remaining, INDEX_CALL_TIMEOUT_MS),
+ });
+ } catch (error) {
+ if (cappedByDeadline && isTimeout(error)) throw new IndexDeadlineError();
+ throw error;
+ }
+ }
+
+ private requireState(state: unknown): string {
+ if (!isState(state)) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'index-state-missing' },
+ });
+ }
+ return state;
+ }
+}
diff --git a/backend/src/modules/mail/mail-events.service.spec.ts b/backend/src/modules/mail/mail-events.service.spec.ts
index 72fcc91..b306bec 100644
--- a/backend/src/modules/mail/mail-events.service.spec.ts
+++ b/backend/src/modules/mail/mail-events.service.spec.ts
@@ -15,6 +15,7 @@ import {
type MailEventSink,
} from './mail-events.service';
import type { RevokedSessions } from '@/modules/auth/revoked-sessions';
+import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import type { MailProvider } from './mail.provider';
import type {
MailLiveState,
@@ -132,6 +133,8 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
let service: MailEventsService;
/** 끊은 세션 기억 대역 — 테스트가 넣는다 */
let revokedIds: Set;
+ /** 검색 색인 맞추기 대역 (14단계) — 맞춰 달라는 직원을 모은다 */
+ let searchSync: { requestSync: jest.Mock };
const connect = (
user: string,
@@ -171,9 +174,11 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
const revoked = {
isRevoked: (sessionId: string) => revokedIds.has(sessionId),
};
+ searchSync = { requestSync: jest.fn() };
service = new MailEventsService(
fake.provider,
revoked as unknown as RevokedSessions,
+ searchSync as unknown as MailSearchSyncService,
);
});
@@ -207,6 +212,9 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
}
expect(other.events).toHaveLength(1);
+ // 메일이 바뀐 소식 — 그 직원의 검색 색인을 맞춰 달라고 한다 (14단계 — 직원마다 한 번, 모으기는 맞추기 쪽이 한다)
+ expect(searchSync.requestSync.mock.calls).toEqual([[ARA]]);
+
// 나중에 들어온 연결은 마지막으로 알게 된 상태로 시작한다 (엔진에 다시 묻지 않는다)
const late = await connected(ARA);
expect(late.events).toEqual([
diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts
index 484be6c..8a14ae0 100644
--- a/backend/src/modules/mail/mail-events.service.ts
+++ b/backend/src/modules/mail/mail-events.service.ts
@@ -8,6 +8,7 @@ import {
import { BusinessException } from '@/common/errors/business.exception';
import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants';
import { RevokedSessions } from '@/modules/auth/revoked-sessions';
+import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
import {
runOutsideRequestContext,
traceTag,
@@ -149,6 +150,8 @@ export class MailEventsService implements BeforeApplicationShutdown {
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
private readonly revokedSessions: RevokedSessions,
+ /** 14단계 — 엔진의 state 소식이 오면 그 직원의 검색 색인을 맞춘다 (300ms 모아서 — 던지지 않는다) */
+ private readonly searchSync: MailSearchSyncService,
) {}
/**
@@ -312,6 +315,8 @@ export class MailEventsService implements BeforeApplicationShutdown {
state: (live) => {
hub.live = live;
this.broadcast(hub, { type: 'state', data: live });
+ // 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있는 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다)
+ this.searchSync.requestSync(hub.user);
},
newMail: (notice) =>
this.broadcast(hub, { type: 'new-mail', data: notice }),
diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts
index 46f83be..c417109 100644
--- a/backend/src/modules/mail/mail.module.ts
+++ b/backend/src/modules/mail/mail.module.ts
@@ -10,6 +10,7 @@ import { AuthSessionModule } from '@/modules/auth/auth-session.module';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
+import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
import { SystemMailModule } from '@/modules/system-mail/system-mail.module';
@@ -127,6 +128,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
AuthSessionModule,
// 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다
SystemMailModule,
+ // 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만)
+ MailSearchModule,
],
controllers: [
MailboxController,
diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts
index 427ff07..1a8d9bb 100644
--- a/backend/src/modules/ops/lock-commands.ts
+++ b/backend/src/modules/ops/lock-commands.ts
@@ -52,6 +52,8 @@ const ROW_LABELS: Record = {
directory: '사내 목록 기록',
files: '대용량 파일',
shareLinks: '대용량 첨부 링크',
+ searchStates: '검색 색인 상태',
+ searchMessages: '검색 색인(메일)',
};
/** 남은 줄을 보이는 글 */
diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts
index 30091cb..b486169 100644
--- a/backend/src/modules/ops/ops-commands.spec.ts
+++ b/backend/src/modules/ops/ops-commands.spec.ts
@@ -55,6 +55,8 @@ const NO_ROWS: AddressRows = {
directory: 0,
files: 0,
shareLinks: 0,
+ searchStates: 0,
+ searchMessages: 0,
};
function employee(overrides: Partial = {}): EngineEmployee {
diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts
index e52065e..50541d1 100644
--- a/backend/src/modules/ops/ops-data.service.ts
+++ b/backend/src/modules/ops/ops-data.service.ts
@@ -13,6 +13,8 @@ import {
} from '@/modules/file-storage/file-blob-store';
import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity';
import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity';
+import { MailSearchMessageEntity } from '@/modules/mail-search/mail-search-message.entity';
+import { MailSearchStateEntity } from '@/modules/mail-search/mail-search-state.entity';
import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity';
import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity';
import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage';
@@ -45,6 +47,9 @@ export interface AddressRows {
readonly directory: number;
readonly files: number;
readonly shareLinks: number;
+ /** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */
+ readonly searchStates: number;
+ readonly searchMessages: number;
}
/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */
@@ -246,7 +251,7 @@ export class OpsDataService {
/**
* 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다
- * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기).
+ * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기).
* 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다
*/
async purgeAddress(email: string): Promise {
@@ -369,7 +374,9 @@ export class OpsDataService {
| typeof MailUserSettingsEntity
| typeof MailDirectoryEntryEntity
| typeof StoredFileEntity
- | typeof ShareLinkEntity,
+ | typeof ShareLinkEntity
+ | typeof MailSearchStateEntity
+ | typeof MailSearchMessageEntity,
where: Record,
): Promise => {
if (mode === 'count') return manager.countBy(entity, where);
@@ -393,6 +400,10 @@ export class OpsDataService {
files: await run(StoredFileEntity, { ownerEmail: email }),
// 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다
shareLinks: await run(ShareLinkEntity, { ownerEmail: email }),
+ // 검색 색인(14단계) — 상태 줄을 먼저 지운다: 서버의 색인 쓰기는 상태 줄을 잠그고 하므로(없으면 쓰지 않는다) 그사이 끝난 쓰기가
+ // 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다)
+ searchStates: await run(MailSearchStateEntity, { userEmail: email }),
+ searchMessages: await run(MailSearchMessageEntity, { userEmail: email }),
};
}
}
diff --git a/backend/src/shared/config/database.config.spec.ts b/backend/src/shared/config/database.config.spec.ts
new file mode 100644
index 0000000..a55808f
--- /dev/null
+++ b/backend/src/shared/config/database.config.spec.ts
@@ -0,0 +1,17 @@
+import { databaseLogging } from './database.config';
+
+describe('databaseLogging (TypeORM 로그)', () => {
+ it('운영 · 시험은 끈다', () => {
+ expect(databaseLogging(true, 'production')).toBe(false);
+ expect(databaseLogging(true, 'development')).toBe(false);
+ expect(databaseLogging(false, 'test')).toBe(false);
+ });
+
+ it('개발은 오류 · 경고 · 마이그레이션만 — 쿼리(매개변수에 메일 글 · 주소가 든다)는 남기지 않는다 (14단계)', () => {
+ for (const nodeEnv of ['development', undefined]) {
+ const logging = databaseLogging(false, nodeEnv);
+ expect(logging).toEqual(['error', 'warn', 'migration']);
+ expect(logging).not.toContain('query');
+ }
+ });
+});
diff --git a/backend/src/shared/config/database.config.ts b/backend/src/shared/config/database.config.ts
index d72be10..f95b619 100644
--- a/backend/src/shared/config/database.config.ts
+++ b/backend/src/shared/config/database.config.ts
@@ -57,6 +57,19 @@ function resolvePaths(baseDir: string) {
};
}
+/**
+ * TypeORM 로그 — 운영 · 시험은 끄고, 개발은 오류 · 경고 · 마이그레이션만 남긴다(쿼리는 남기지 않는다).
+ * 쿼리 로그는 매개변수째 찍는다 — 14단계부터 DB 에 메일 글 · 제목 · 주소(검색 색인)가 있어 개발 로그에도 남기지 않는다 (.claude/rules/logging.md).
+ * ⚠️ 'error' 는 실패한 쿼리를 매개변수째 찍는다(TypeORM 의 logQueryError) — 실패한 색인 쓰기는 개발 로그에 글이 남을 수 있다
+ */
+export function databaseLogging(
+ isProduction: boolean,
+ nodeEnv: string | undefined,
+): DataSourceOptions['logging'] {
+ if (isProduction || nodeEnv === 'test') return false;
+ return ['error', 'warn', 'migration'];
+}
+
export function buildDatabaseOptions(): DataSourceOptions {
const isProduction = isProductionEnv();
const paths = resolvePaths(__dirname);
@@ -71,8 +84,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
- // SQL 로그는 개발에서만 — 테스트(e2e·CI)는 수천 줄의 쿼리·파라미터(주소 등)가 출력을 덮는다
- logging: !isProduction && process.env.NODE_ENV !== 'test',
+ // 개발도 쿼리는 남기지 않는다(매개변수에 메일 글 · 주소가 든다) — 오류 · 경고 · 마이그레이션만. 운영 · 시험은 끈다
+ logging: databaseLogging(isProduction, process.env.NODE_ENV),
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
diff --git a/backend/src/shared/utils/kst-date.spec.ts b/backend/src/shared/utils/kst-date.spec.ts
index c362734..2fb404a 100644
--- a/backend/src/shared/utils/kst-date.spec.ts
+++ b/backend/src/shared/utils/kst-date.spec.ts
@@ -5,6 +5,7 @@ import {
formatKstDate,
formatKstDateEnglish,
kstEndOfDayAfter,
+ kstMinuteOfDay,
} from './kst-date';
describe('kstEndOfDayAfter — 한국 날짜 + n일의 끝', () => {
@@ -99,3 +100,13 @@ describe('formatKstDateTime — 알림 메일의 시각 (디자인 README 13단
);
});
});
+
+describe('kstMinuteOfDay — 한국 시각의 하루 몇 분째 (14단계 밤 채우기 창)', () => {
+ it('UTC 에 9시간을 더한 시각의 분이다 — 날이 바뀌어도', () => {
+ expect(kstMinuteOfDay(new Date('2026-11-05T13:00:00Z'))).toBe(22 * 60);
+ expect(kstMinuteOfDay(new Date('2026-11-05T15:00:00Z'))).toBe(0);
+ expect(kstMinuteOfDay(new Date('2026-11-05T17:45:30Z'))).toBe(2 * 60 + 45);
+ expect(kstMinuteOfDay(new Date('2026-11-05T14:59:59.999Z'))).toBe(1439);
+ expect(kstMinuteOfDay(new Date('1969-12-31T14:59:00Z'))).toBe(1439);
+ });
+});
diff --git a/backend/src/shared/utils/kst-date.ts b/backend/src/shared/utils/kst-date.ts
index fbd889a..6bc6643 100644
--- a/backend/src/shared/utils/kst-date.ts
+++ b/backend/src/shared/utils/kst-date.ts
@@ -108,3 +108,13 @@ export function formatKstDateTime(at: Date): string {
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
return `${year}년 ${month}월 ${day}일 (${WEEKDAYS_KO[weekday]}) ${period} ${hour12}:${minutes}`;
}
+
+/** 하루의 분 */
+const MINUTES_PER_DAY = 24 * 60;
+const MS_PER_MINUTE = 60 * 1000;
+
+/** `at` 의 한국 시각이 하루의 몇 분째인가 (0 ~ 1439) — 14단계 검색 색인의 밤 채우기 창 */
+export function kstMinuteOfDay(at: Date): number {
+ const minutes = Math.floor((at.getTime() + KST_OFFSET_MS) / MS_PER_MINUTE);
+ return ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
+}
diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts
index 4f2f4e7..9713a07 100644
--- a/backend/src/shared/utils/mask.spec.ts
+++ b/backend/src/shared/utils/mask.spec.ts
@@ -15,6 +15,23 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
).toBe('/api/messages?mailboxId=a&q=<26자>');
});
+ it('조건 검색의 칸(from · to · subject · body — 14단계)도 검색어처럼 길이만 남긴다 — 날짜 · 첨부 조건은 그대로', () => {
+ expect(
+ maskUrlForLog(
+ '/api/messages?in=all&from=%EC%9D%B4%EB%8F%84%EC%9C%A4&to=ara.kim%40example.com&subject=%EA%B2%AC%EC%A0%81&body=%EA%B3%84%EC%95%BD&after=2026-10-01T00%3A00%3A00Z&hasAttachment=true',
+ ),
+ ).toBe(
+ '/api/messages?in=all&from=<3자>&to=<19자>&subject=<2자>&body=<2자>&after=2026-10-01T00%3A00%3A00Z&hasAttachment=true',
+ );
+ // 대소문자 · 배열 표기도 같은 이름 · 비슷한 다른 이름은 그대로
+ expect(
+ maskUrlForLog('/api/x?FROM=a&To[]=bb&Subject%5B0%5D=ccc&BODY=dddd'),
+ ).toBe('/api/x?FROM=<1자>&To[]=<2자>&Subject%5B0%5D=<3자>&BODY=<4자>');
+ expect(
+ maskUrlForLog('/api/x?fromDate=1&toDate=2&subjects=3&bodyHtml=4'),
+ ).toBe('/api/x?fromDate=1&toDate=2&subjects=3&bodyHtml=4');
+ });
+
it('커서는 검색어의 해시(범위 해시)를 담으므로 검색 없는 목록의 커서도 가린다 — 메일함 id 는 그대로', () => {
expect(
maskUrlForLog(
diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts
index 5aa1720..0480d70 100644
--- a/backend/src/shared/utils/mask.ts
+++ b/backend/src/shared/utils/mask.ts
@@ -9,13 +9,15 @@
const MASK = '***';
/**
- * 값을 남기지 않는 쿼리 이름 — 검색어(q)와 목록 커서(cursor).
+ * 값을 남기지 않는 쿼리 이름 — 검색어(q) · 조건 검색의 칸(from · to · subject · body — 14단계)과 목록 커서(cursor).
* - q: 메일 검색어 · 받는 사람 추천의 거르는 글자 — 사람 이름·주소가 들어간다 (계약 2절 "검색" · "로그에는 개수만")
+ * - from · to · subject · body: 조건 검색의 보낸 사람 · 받는 사람 · 제목 · 내용 — 검색어와 같다 (14단계 설계 C1)
* - cursor: 다른 목록의 커서를 막으려고 검색어의 해시(범위 해시)를 담는다 — 로그를 읽는 사람이 후보 검색어를 대입해 맞춰 볼 수 있다
* (modules/mail/jmap/jmap-cursor.ts · recipient-suggestion-cursor.ts)
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
*/
-const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
+const REDACTED_QUERY_NAME =
+ /^(?:q|from|to|subject|body|cursor)(?:\[[^\]]*\])*$/i;
/** UUID 모양의 경로 조각 — 공개 링크의 파일 id 는 남긴다 */
const UUID_SEGMENT =
diff --git a/backend/src/shared/utils/search-text.spec.ts b/backend/src/shared/utils/search-text.spec.ts
new file mode 100644
index 0000000..e444a9d
--- /dev/null
+++ b/backend/src/shared/utils/search-text.spec.ts
@@ -0,0 +1,130 @@
+import { startCpuTimer } from '@/../test/cpu-time';
+import {
+ FOLD_SAMPLES,
+ GRAM_SAMPLES,
+ MATCH_SAMPLES,
+} from '@/../test/search-text-samples';
+import {
+ MAX_MATCH_RANGES,
+ foldText,
+ gramsOf,
+ isSpaceCode,
+ matchRanges,
+} from './search-text';
+
+describe('foldText (검색의 접기)', () => {
+ it.each(FOLD_SAMPLES)('%j → %j', (input, folded) => {
+ expect(foldText(input)).toBe(folded);
+ });
+
+ it('접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 — 위치를 원문에 그대로 쓴다', () => {
+ for (const [input] of FOLD_SAMPLES) {
+ expect(foldText(input).length).toBe(input.normalize('NFC').length);
+ }
+ // 글자 범위를 훑어 본다 — 소문자가 길이를 바꾸는 글자(İ 등)도 그대로라 길이가 같다
+ const changedLength: number[] = [];
+ for (let code = 0; code <= 0xffff; code += 1) {
+ if (code >= 0xd800 && code <= 0xdfff) continue;
+ const char = String.fromCharCode(code).normalize('NFC');
+ if (foldText(char).length !== char.length) changedLength.push(code);
+ }
+ expect(changedLength).toEqual([]);
+ });
+
+ it('두 번 접어도 같다', () => {
+ for (const [input, folded] of FOLD_SAMPLES) {
+ expect(foldText(folded)).toBe(folded);
+ expect(foldText(foldText(input))).toBe(folded);
+ }
+ });
+
+ it('짝이 없는 서로게이트도 그대로 둔다 (길이가 같다)', () => {
+ expect(foldText('a\ud800B')).toBe('a\ud800b');
+ expect(foldText('\udc00A')).toBe('\udc00a');
+ });
+
+ it('긴 글도 길이에 비례해 접는다', () => {
+ const unit = '계약서 Project ABC 회의록 ÀÉÎ ';
+ const small = unit.repeat(2_000);
+ const large = unit.repeat(40_000);
+ const smallCpu = startCpuTimer();
+ foldText(small);
+ const smallMs = smallCpu();
+ const largeCpu = startCpuTimer();
+ expect(foldText(large).length).toBe(large.length);
+ const largeMs = largeCpu();
+ // 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts)
+ expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10);
+ expect(largeMs).toBeLessThan(5_000);
+ });
+});
+
+describe('gramsOf (글자 두 개 묶음)', () => {
+ it.each(GRAM_SAMPLES)('%j → %j', (fold, grams) => {
+ expect([...gramsOf(fold)]).toEqual(grams);
+ });
+
+ it('주어진 모음에 더한다 — 칸 경계를 넘지 않는다', () => {
+ const into = new Set();
+ gramsOf('회의', into);
+ gramsOf('일정', into);
+ expect([...into]).toEqual(['회의', '일정']);
+ expect(into.has('의일')).toBe(false);
+ });
+
+ it('공백은 정규식 \\s 와 같은 글자다 (검색어 낱말 나누기와 같다)', () => {
+ const differ: number[] = [];
+ for (let code = 0; code <= 0xffff; code += 1) {
+ if (isSpaceCode(code) !== /\s/u.test(String.fromCharCode(code))) {
+ differ.push(code);
+ }
+ }
+ expect(differ).toEqual([]);
+ });
+
+ it('20,000자 본문도 길이에 비례해 묶는다', () => {
+ const body = '계약서를 보내 드립니다 Project plan 회의록 '.repeat(800);
+ const cpu = startCpuTimer();
+ const grams = gramsOf(foldText(body));
+ expect(grams.size).toBeGreaterThan(10);
+ expect(cpu()).toBeLessThan(2_000);
+ });
+});
+
+describe('matchRanges (맞은 곳)', () => {
+ it.each(MATCH_SAMPLES)('%j 에서 %j → %j', (original, terms, ranges) => {
+ expect(matchRanges(original, terms)).toEqual(ranges);
+ });
+
+ it('맞은 곳은 limit 개까지만 모은다 (한 글자 낱말이 흔해도)', () => {
+ // 찾은 자리를 말마다 limit 개까지 모은 뒤 합친다 — 이어진 글이면 앞 limit 자리만큼
+ const text = '가'.repeat(5_000);
+ expect(matchRanges(text, ['가'])).toEqual([[0, MAX_MATCH_RANGES]]);
+ const spaced = '가 '.repeat(5_000);
+ const ranges = matchRanges(spaced, ['가']);
+ expect(ranges).toHaveLength(MAX_MATCH_RANGES);
+ expect(ranges[0]).toEqual([0, 1]);
+ expect(matchRanges(spaced, ['가'], 3)).toEqual([
+ [0, 1],
+ [2, 3],
+ [4, 5],
+ ]);
+ });
+
+ it('긴 글 · 낱말 여럿도 오래 걸리지 않는다', () => {
+ const body = '계약서를 보내 드립니다 Project plan 회의록 '.repeat(800);
+ const cpu = startCpuTimer();
+ const ranges = matchRanges(body, [
+ '계약',
+ 'project',
+ '회의',
+ '드립',
+ 'plan',
+ '보내',
+ '없는말',
+ '록',
+ ]);
+ expect(ranges.length).toBeGreaterThan(0);
+ expect(cpu()).toBeLessThan(2_000);
+ });
+});
diff --git a/backend/src/shared/utils/search-text.ts b/backend/src/shared/utils/search-text.ts
new file mode 100644
index 0000000..a911e48
--- /dev/null
+++ b/backend/src/shared/utils/search-text.ts
@@ -0,0 +1,212 @@
+/**
+ * 메일 검색의 접기(정규화) · 글자 두 개 묶음 · 맞은 곳 (순수 함수 — 14단계 설계 B1).
+ * ⚠️ frontend `shared/utils/search-match.ts` 와 **짝**이다 — 같은 접기 · 같은 위치 규칙, 같은 표본 표(backend `test/search-text-samples.ts` ·
+ * frontend `src/test-utils/search-text-samples.ts`)를 두 쪽 테스트에 둔다. 한쪽을 고치면 둘 다 고친다.
+ *
+ * 접기(foldText)는 먼저 NFC 로 맞춘 뒤 **코드 포인트 하나씩** 바꾼다 — 전각 ASCII(! ~ ~) → 반각, 소문자(결과가 코드 포인트 하나이고
+ * UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로). 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다 →
+ * 접은 글에서 찾은 위치를 원문에 그대로 쓴다(강조 · 미리 보기). NFKC 는 쓰지 않는다 — ㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다.
+ * 공백은 바꾸지 않는다(위치를 지키려고) — 낱말 나누기 · 묶음에서만 공백으로 본다.
+ *
+ * 모든 함수는 입력을 한 번(맞은 곳은 낱말마다 한 번) 훑는다 — 길이에 비례한다.
+ */
+
+/** 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) */
+const FULLWIDTH_FIRST = 0xff01;
+const FULLWIDTH_LAST = 0xff5e;
+const FULLWIDTH_OFFSET = 0xfee0;
+
+const ASCII_UPPER_A = 0x41;
+const ASCII_UPPER_Z = 0x5a;
+const ASCII_CASE_OFFSET = 0x20;
+const ASCII_LIMIT = 0x80;
+
+/** 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호 (toLowerCase 를 부르지 않고 건너뛴다) */
+const HANGUL_JAMO_FIRST = 0x1100;
+const HANGUL_JAMO_LAST = 0x11ff;
+const CJK_FIRST = 0x2e80;
+const CJK_LAST = 0x9fff;
+const HANGUL_SYLLABLE_FIRST = 0xac00;
+const HANGUL_SYLLABLE_LAST = 0xd7a3;
+
+const HIGH_SURROGATE_FIRST = 0xd800;
+const HIGH_SURROGATE_LAST = 0xdbff;
+const LOW_SURROGATE_FIRST = 0xdc00;
+const LOW_SURROGATE_LAST = 0xdfff;
+
+/** 맞은 곳을 이만큼까지만 모은다 — 글이 길고 낱말이 흔하면(한 글자) 끝없이 늘지 않게 */
+export const MAX_MATCH_RANGES = 500;
+
+/** 맞은 곳 — [시작, 끝) UTF-16 위치 */
+export type MatchRange = readonly [start: number, end: number];
+
+function isHighSurrogate(code: number): boolean {
+ return code >= HIGH_SURROGATE_FIRST && code <= HIGH_SURROGATE_LAST;
+}
+
+function isLowSurrogate(code: number): boolean {
+ return code >= LOW_SURROGATE_FIRST && code <= LOW_SURROGATE_LAST;
+}
+
+/** 소문자가 따로 없는 글자인가 (빠른 길 — 한글 · 한자) */
+function hasNoCase(code: number): boolean {
+ return (
+ (code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) ||
+ (code >= CJK_FIRST && code <= CJK_LAST) ||
+ (code >= HANGUL_JAMO_FIRST && code <= HANGUL_JAMO_LAST)
+ );
+}
+
+/** ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 */
+function foldAsciiCode(code: number): number {
+ return code >= ASCII_UPPER_A && code <= ASCII_UPPER_Z
+ ? code + ASCII_CASE_OFFSET
+ : code;
+}
+
+/**
+ * ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null.
+ * `char` 는 그 코드 포인트의 글(UTF-16 한두 단위)이다
+ */
+function foldCodePoint(code: number, char: string): string | null {
+ if (code >= FULLWIDTH_FIRST && code <= FULLWIDTH_LAST) {
+ return String.fromCharCode(foldAsciiCode(code - FULLWIDTH_OFFSET));
+ }
+ if (hasNoCase(code)) return null;
+ const lower = char.toLowerCase();
+ if (lower === char || lower.length !== char.length) return null;
+ // 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만
+ const first = lower.codePointAt(0) ?? 0;
+ const firstWidth = first > 0xffff ? 2 : 1;
+ return firstWidth === lower.length ? lower : null;
+}
+
+/**
+ * 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자.
+ * 결과는 `text.normalize('NFC')` 와 UTF-16 길이 · 위치가 같다
+ */
+export function foldText(text: string): string {
+ const source = text.normalize('NFC');
+ let folded = '';
+ let runStart = 0;
+ for (let index = 0; index < source.length; ) {
+ const code = source.charCodeAt(index);
+ let width = 1;
+ let point = code;
+ if (
+ isHighSurrogate(code) &&
+ index + 1 < source.length &&
+ isLowSurrogate(source.charCodeAt(index + 1))
+ ) {
+ width = 2;
+ point = source.codePointAt(index) ?? code;
+ }
+ let replacement: string | null;
+ if (code < ASCII_LIMIT) {
+ const folded = foldAsciiCode(code);
+ replacement = folded === code ? null : String.fromCharCode(folded);
+ } else {
+ replacement = foldCodePoint(point, source.slice(index, index + width));
+ }
+ if (replacement !== null) {
+ folded += source.slice(runStart, index) + replacement;
+ runStart = index + width;
+ }
+ index += width;
+ }
+ return runStart === 0 ? source : folded + source.slice(runStart);
+}
+
+/**
+ * 공백 글자인가 — 낱말 나누기 · 묶음의 경계 (JS 정규식 `\s` 와 같은 글자들: 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등).
+ * 검색어를 낱말로 나누는 기준(SEARCH_WORD_SEPARATOR — `\s+`)과 같다
+ */
+export function isSpaceCode(code: number): boolean {
+ if (code <= 0x20) {
+ return code === 0x20 || (code >= 0x09 && code <= 0x0d);
+ }
+ if (code < 0xa0) return false;
+ return (
+ code === 0xa0 ||
+ code === 0x1680 ||
+ (code >= 0x2000 && code <= 0x200a) ||
+ code === 0x2028 ||
+ code === 0x2029 ||
+ code === 0x202f ||
+ code === 0x205f ||
+ code === 0x3000 ||
+ code === 0xfeff
+ );
+}
+
+/**
+ * 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번).
+ * 접은 글(foldText)에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다.
+ * `into` 를 주면 거기에 더한다 (색인의 다섯 칸을 한 모음으로 — 칸 경계를 넘지 않는다)
+ */
+export function gramsOf(
+ fold: string,
+ into: Set = new Set(),
+): Set {
+ let previous: string | null = null;
+ for (let index = 0; index < fold.length; ) {
+ const code = fold.charCodeAt(index);
+ const width =
+ isHighSurrogate(code) &&
+ index + 1 < fold.length &&
+ isLowSurrogate(fold.charCodeAt(index + 1))
+ ? 2
+ : 1;
+ if (isSpaceCode(code)) {
+ previous = null;
+ } else {
+ const char = width === 1 ? fold[index] : fold.slice(index, index + 2);
+ if (previous !== null) into.add(previous + char);
+ previous = char;
+ }
+ index += width;
+ }
+ return into;
+}
+
+/** 맞은 곳들을 시작 차례로 놓고 겹치거나 맞닿은 것을 합친다 */
+function mergeRanges(ranges: MatchRange[]): MatchRange[] {
+ ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+ const merged: [number, number][] = [];
+ for (const [start, end] of ranges) {
+ const last = merged[merged.length - 1];
+ if (last && start <= last[1]) {
+ if (end > last[1]) last[1] = end;
+ } else {
+ merged.push([start, end]);
+ }
+ }
+ return merged;
+}
+
+/**
+ * 맞은 곳 — 원문(`original` — NFC 여야 위치가 맞는다)에서 찾는 말들(`terms` — 낱말 · 구절, 접기 전 글)이 나오는 자리를 모두 찾아
+ * 겹치거나 맞닿은 것을 합친 [시작, 끝) 목록(UTF-16, 시작 차례). 같은 접기 · 부분 일치다(찾기와 같다 — 'aa' 는 'aaa' 의 [0, 3)).
+ * 빈 말은 건너뛴다. 찾은 자리는 말마다 `limit`(기본 MAX_MATCH_RANGES)개까지 모으고, 합친 목록도 앞쪽부터 그만큼이다
+ */
+export function matchRanges(
+ original: string,
+ terms: readonly string[],
+ limit: number = MAX_MATCH_RANGES,
+): MatchRange[] {
+ const haystack = foldText(original);
+ const found: MatchRange[] = [];
+ for (const term of new Set(terms.map(foldText))) {
+ if (term === '') continue;
+ let count = 0;
+ for (
+ let at = haystack.indexOf(term);
+ at >= 0 && count < limit;
+ at = haystack.indexOf(term, at + 1)
+ ) {
+ found.push([at, at + term.length]);
+ count += 1;
+ }
+ }
+ return mergeRanges(found).slice(0, limit);
+}
diff --git a/backend/src/shared/utils/subject-base.spec.ts b/backend/src/shared/utils/subject-base.spec.ts
new file mode 100644
index 0000000..529c35d
--- /dev/null
+++ b/backend/src/shared/utils/subject-base.spec.ts
@@ -0,0 +1,30 @@
+import { startCpuTimer } from '@/../test/cpu-time';
+import { SUBJECT_BASE_SAMPLES } from '@/../test/search-text-samples';
+import { subjectBase } from './subject-base';
+
+describe('subjectBase (말머리를 뗀 제목)', () => {
+ it.each(SUBJECT_BASE_SAMPLES)('%j → %j', (subject, base) => {
+ expect(subjectBase(subject)).toBe(base);
+ });
+
+ it('답장 · 전달 제목과 원본이 같은 글이 된다', () => {
+ const original = '[공지] 11월 회의 일정';
+ for (const reply of [
+ 'Re: [공지] 11월 회의 일정',
+ '회신: [공지] 11월 회의 일정',
+ 'RE: 전달: [공지] 11월 회의 일정',
+ 'Fwd:[공지] 11월 회의 일정',
+ ]) {
+ expect(subjectBase(reply)).toBe(subjectBase(original));
+ }
+ });
+
+ it('말머리가 아주 많아도 · 닫히지 않은 대괄호가 길어도 길이에 비례한다', () => {
+ const prefixes = 'Re: [a] '.repeat(20_000) + '회의';
+ const open = '['.repeat(100_000);
+ const cpu = startCpuTimer();
+ expect(subjectBase(prefixes)).toBe('회의');
+ expect(subjectBase(open)).toBe(open);
+ expect(cpu()).toBeLessThan(3_000);
+ });
+});
diff --git a/backend/src/shared/utils/subject-base.ts b/backend/src/shared/utils/subject-base.ts
new file mode 100644
index 0000000..d6baeea
--- /dev/null
+++ b/backend/src/shared/utils/subject-base.ts
@@ -0,0 +1,49 @@
+import { foldText, isSpaceCode } from './search-text';
+
+/**
+ * 말머리를 뗀 제목의 접기 글 (순수 함수 — 14단계 설계 B4) — 같은 대화인지 볼 때 제목을 견주는 글이다.
+ * ⚠️ frontend `shared/utils/search-match.ts` 와 짝 — 같은 표본 표(`test/search-text-samples.ts` 의 SUBJECT_BASE_SAMPLES)를 두 쪽에 둔다.
+ *
+ * 차례: 접기(foldText — 소문자 · 전각 → 반각이라 `RE:` · `[공지]` 도 같다) → 공백을 하나로 · 앞뒤 공백 빼기 →
+ * 앞의 말머리를 여러 겹 뗀다 → 다시 공백 정리.
+ * - 답장 · 전달 말머리: `re` · `fw` · `fwd` · `aw` · `wg` · `회신` · `답장` · `전달` (대소문자 없이) — 뒤에 `[2]` · `(2)` 같은 수가 붙어도,
+ * 쌍점 앞에 공백이 있어도 된다. 쌍점은 꼭 있어야 한다(`Reply to` 는 떼지 않는다)
+ * - 대괄호 꼬리표: `[…]` (첫 `]` 까지)
+ * 엔진(Stalwart 0.16.27)은 `회신:` · `답장:` · `전달:` 을 떼지 않아 한국어 Outlook 의 답장이 따로 떨어진다(2026-11-03 실측) — 우리 규칙은 이것도 뗀다.
+ * 다 떼어 빈 글이 되어도 그대로 빈 글이다(빈 제목의 답장과 원본이 같다).
+ *
+ * 한 번 훑는다 — 말머리를 떼는 고리는 늘 앞으로만 간다(길이에 비례).
+ */
+
+/** 앞의 말머리 하나 — 답장 · 전달 말머리 또는 대괄호 꼬리표, 뒤의 공백까지 (접은 글 — 소문자 · 반각) */
+const LEADING_PREFIX =
+ /(?:(?:re|fwd|fw|aw|wg|회신|답장|전달) ?(?:\[\d{1,4}\]|\(\d{1,4}\))? ?:|\[[^\]]*\]) ?/uy;
+
+/** 공백 글자의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (search-text.ts 의 isSpaceCode — 낱말 나누기와 같은 공백) */
+function collapseSpaces(text: string): string {
+ let collapsed = '';
+ let pendingSpace = false;
+ for (let index = 0; index < text.length; index += 1) {
+ if (isSpaceCode(text.charCodeAt(index))) {
+ pendingSpace = collapsed !== '';
+ continue;
+ }
+ if (pendingSpace) collapsed += ' ';
+ pendingSpace = false;
+ collapsed += text[index];
+ }
+ return collapsed;
+}
+
+export function subjectBase(subject: string): string {
+ const text = collapseSpaces(foldText(subject));
+ // 붙은 자리(sticky)에서만 맞춘다 — 뗄 때마다 글을 자르지 않고 자리만 옮긴다
+ let position = 0;
+ for (;;) {
+ LEADING_PREFIX.lastIndex = position;
+ const prefix = LEADING_PREFIX.exec(text);
+ if (!prefix || prefix[0] === '') break;
+ position += prefix[0].length;
+ }
+ return text.slice(position).trim();
+}
diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts
index 8d9bbdb..34c0c28 100644
--- a/backend/test/app.e2e-spec.ts
+++ b/backend/test/app.e2e-spec.ts
@@ -12,6 +12,7 @@ import request from 'supertest';
import type { Response, Test as HttpTest } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
+import { SchedulerRegistry } from '@nestjs/schedule';
import { AppModule } from '@/app.module';
import { configureApp, createHttpAdapter } from '@/app.setup';
@@ -121,6 +122,14 @@ import {
SIGNATURE_IMAGE_MAX_BYTES,
} from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
+import {
+ MAIL_INDEX_SOURCE,
+ type IndexDocument,
+ type MailIndexSource,
+} from '@/modules/mail-search/mail-index-source';
+import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
+import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
+import { toIndexRow } from '@/modules/mail-search/index-document';
import {
SystemSettingsService,
type SystemSettingsPatch,
@@ -491,6 +500,108 @@ const testFileStorageSettings = (dir: string): FileStorageSettings => ({
clamav: null,
});
+/**
+ * 14단계 — 검색 색인의 대역 엔진: 직원마다 메일 글과 state. 실제 엔진 읽기(JMAP)는 jmap-index-source.spec.ts 가 본다.
+ * 넣지 않은 직원은 엔진 장애(MAIL_001)로 답한다 — 개발 DB 를 함께 쓰는 실제 직원의 줄을 대역으로 채우지 않게
+ */
+class FakeIndexSource implements MailIndexSource {
+ readonly mailboxes = new Map<
+ string,
+ { state: string; emails: IndexDocument[] }
+ >();
+
+ private mailbox(user: string) {
+ const found = this.mailboxes.get(user);
+ if (!found) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'e2e-unknown-index-user' },
+ });
+ }
+ return found;
+ }
+
+ snapshot(user: string) {
+ const { state, emails } = this.mailbox(user);
+ return Promise.resolve({ state, total: emails.length });
+ }
+
+ changes(user: string, sinceState: string) {
+ const { state } = this.mailbox(user);
+ // 같은 state 면 변화 없음, 다르면 셀 수 없음(전체 대조로)
+ return Promise.resolve(
+ sinceState === state
+ ? {
+ kind: 'changes' as const,
+ created: [],
+ updated: [],
+ destroyed: [],
+ newState: state,
+ hasMore: false,
+ }
+ : { kind: 'cannotCalculate' as const },
+ );
+ }
+
+ documents(user: string, ids: readonly string[]) {
+ const { emails } = this.mailbox(user);
+ return Promise.resolve(emails.filter((email) => ids.includes(email.id)));
+ }
+
+ mutable(user: string, ids: readonly string[]) {
+ return this.documents(user, ids);
+ }
+
+ pageIds(
+ user: string,
+ from: { anchor: string | null; position: number },
+ limit: number,
+ ) {
+ const ids = this.newestFirst(user);
+ const anchorAt = from.anchor === null ? -1 : ids.indexOf(from.anchor);
+ const start = anchorAt >= 0 ? anchorAt + 1 : from.position;
+ return Promise.resolve({
+ ids: ids.slice(start, start + limit),
+ position: start,
+ total: ids.length,
+ });
+ }
+
+ allIds(user: string) {
+ const { state } = this.mailbox(user);
+ return Promise.resolve({ state, ids: this.newestFirst(user) });
+ }
+
+ private newestFirst(user: string): string[] {
+ return [...this.mailbox(user).emails]
+ .sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))
+ .map((email) => email.id);
+ }
+}
+
+/** 14단계 — 색인에 넣을 메일 한 통 (대역) */
+function indexDocument(overrides: Partial): IndexDocument {
+ return {
+ id: 'e2e-mail',
+ threadId: 'e2e-thread',
+ mailboxIds: ['inbox'],
+ keywords: [],
+ receivedAt: '2026-11-05T01:00:00Z',
+ size: 100,
+ messageIds: [],
+ inReplyTo: [],
+ references: [],
+ subject: '',
+ from: [{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }],
+ to: [],
+ cc: [],
+ attachmentNames: [],
+ hasAttachment: false,
+ preview: '',
+ bodyText: '',
+ ...overrides,
+ };
+}
+
/** 목록 응답의 data — 계약의 { items, pageInfo } */
interface Page {
items: T[];
@@ -925,6 +1036,9 @@ describe('응답 계약 (e2e)', () => {
const rotated = (res: Response) =>
cookieValue(setCookie(res, 'refresh_token'));
+ /** 14단계 — 검색 색인의 대역 엔진 */
+ const fakeIndexSource = new FakeIndexSource();
+
beforeAll(async () => {
fileDir = mkdtempSync(join(tmpdir(), 'arasystem-e2e-files-'));
const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -1557,6 +1671,9 @@ describe('응답 계약 (e2e)', () => {
.useValue(fakeAccountSecurityProvider)
.overrideProvider(ACCOUNT_SECURITY_SETTINGS)
.useValue(TEST_ACCOUNT_SECURITY_SETTINGS)
+ // 14단계 — 검색 색인의 엔진 읽기는 대역으로
+ .overrideProvider(MAIL_INDEX_SOURCE)
+ .useValue(fakeIndexSource)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1572,6 +1689,13 @@ describe('응답 계약 (e2e)', () => {
configureApp(app);
await app.init();
+ // 14단계 — 검색 색인의 5분 맞추기 · 1분 채우기는 돌리지 않는다 — 테스트가 직접 부른다.
+ // 거꾸로 같은 DB 를 쓰는 개발 서버(개발 PC 에서만 — CI 에는 없다)의 cron 은 사내 도메인(arasystem.test) 주소만 맞추고 채우므로
+ // 이 시험의 줄(e2e.arasystem.test)을 건드리지 않는다 (MailSearchSyncService.isIndexedAddress)
+ const scheduler = app.get(SchedulerRegistry);
+ for (const name of ['mail-search-sync', 'mail-search-build']) {
+ void scheduler.getCronJob(name).stop();
+ }
server = app.getHttpServer();
jwt = app.get(JwtService, { strict: false });
authSettings = app.get(AUTH_SETTINGS, { strict: false });
@@ -1603,6 +1727,9 @@ describe('응답 계약 (e2e)', () => {
'DELETE FROM login_events WHERE user_email LIKE $1',
'DELETE FROM app_password_usages WHERE user_email LIKE $1',
'DELETE FROM two_factor_settings WHERE user_email LIKE $1',
+ // 14단계 — 검색 색인 (로그인하면 상태 줄이 생긴다)
+ 'DELETE FROM mail_search_messages WHERE user_email LIKE $1',
+ 'DELETE FROM mail_search_states WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
@@ -7526,6 +7653,193 @@ describe('응답 계약 (e2e)', () => {
});
});
+ describe('14단계 1차 — 검색 색인 바탕 (GET /api/search-index · 색인 표 · 채우기)', () => {
+ const ME = 'search.index@e2e.arasystem.test';
+ const OTHER = 'search.other@e2e.arasystem.test';
+ const FROM_IP = '198.51.100.140';
+
+ const signIn = async (email: string) => {
+ const res = await request(server)
+ .post('/api/auth/login')
+ .set('X-Forwarded-For', FROM_IP)
+ .send({ email, password: CORRECT_PASSWORD })
+ .expect(200);
+ return `access_token=${cookieValue(setCookie(res, 'access_token'))}`;
+ };
+ const statusOf = (cookie: string) =>
+ request(server).get('/api/search-index').set('Cookie', cookie);
+
+ afterEach(async () => {
+ fakeIndexSource.mailboxes.clear();
+ for (const table of ['mail_search_messages', 'mail_search_states']) {
+ await dataSource.query(
+ `DELETE FROM "${table}" WHERE user_email = ANY($1::text[])`,
+ [[ME, OTHER]],
+ );
+ }
+ });
+
+ it('로그인하면 상태 줄이 building 으로 생긴다 — 준비 상태는 { building, 0, null } · 로그인 없이는 401 AUTH_001', async () => {
+ const cookie = await signIn(ME);
+ const rows: { status: string; email_state: string | null }[] =
+ await dataSource.query(
+ 'SELECT status, email_state FROM mail_search_states WHERE user_email = $1',
+ [ME],
+ );
+ expect(rows).toEqual([{ status: 'building', email_state: null }]);
+
+ const res = await statusOf(cookie).expect(200);
+ expect(dataOf(res)).toEqual({ status: 'building', done: 0, total: null });
+
+ const anonymous = await request(server)
+ .get('/api/search-index')
+ .expect(401);
+ expect(body(anonymous).error?.code).toBe('AUTH_001');
+ });
+
+ it('상태 줄이 없던 직원은 준비 상태를 물을 때 만든다 · 멈춘 줄은 로그인하면 푼다', async () => {
+ // 세션 없이 서명만 맞춘 쿠키 — 로그인을 거치지 않았다
+ await statusOf(cookieOf(OTHER)).expect(200);
+ await dataSource.query(
+ `UPDATE mail_search_states SET paused_reason = 'locked', failure_count = 3 WHERE user_email = $1`,
+ [OTHER],
+ );
+ await signIn(OTHER);
+ const rows: { paused_reason: string | null; failure_count: number }[] =
+ await dataSource.query(
+ 'SELECT paused_reason, failure_count FROM mail_search_states WHERE user_email = $1',
+ [OTHER],
+ );
+ expect(rows).toEqual([{ paused_reason: null, failure_count: 0 }]);
+ });
+
+ it('채우기 → ready — 색인 줄의 글자 두 개 묶음(GIN)과 접기 글(strpos)로 부분 일치를 찾는다 (실제 PostgreSQL)', async () => {
+ const cookie = await signIn(ME);
+ fakeIndexSource.mailboxes.set(ME, {
+ state: 's1',
+ emails: [
+ indexDocument({
+ id: 'm-contract',
+ subject: 'Re: [공지] 11월 계약서',
+ bodyText: '계약서를 보내 드립니다. Project PLAN 첨부',
+ attachmentNames: ['계약서.PDF'],
+ hasAttachment: true,
+ mailboxIds: ['inbox', 'archive'],
+ keywords: ['$seen'],
+ receivedAt: '2026-11-04T01:00:00Z',
+ }),
+ indexDocument({
+ id: 'm-meeting',
+ subject: '회의록',
+ bodyText: '내일 회의는 3층 회의실',
+ receivedAt: '2026-11-05T01:00:00Z',
+ }),
+ ],
+ });
+
+ const build = app.get(MailSearchBuildService);
+ // 마무리(따라잡기 · 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만 시작한다 — 넉넉히
+ expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
+ expect(dataOf(await statusOf(cookie).expect(200))).toEqual({
+ status: 'ready',
+ done: 2,
+ total: 2,
+ });
+
+ const find = async (column: string, word: string, grams: string[]) =>
+ (
+ await dataSource.query<{ email_id: string }[]>(
+ `SELECT email_id FROM mail_search_messages WHERE user_email = $1 AND grams @> $2::text[] AND strpos(${column}, $3) > 0 ORDER BY received_at DESC`,
+ [ME, grams, word],
+ )
+ ).map((row) => row.email_id);
+ // 붙여 쓴 말 · 앞부분 · 말머리 · 대소문자
+ expect(await find('fold_body', '계약', ['계약'])).toEqual(['m-contract']);
+ expect(await find('fold_body', 'proj', ['pr', 'ro', 'oj'])).toEqual([
+ 'm-contract',
+ ]);
+ expect(await find('fold_subject', '공지', ['공지'])).toEqual([
+ 'm-contract',
+ ]);
+ expect(await find('fold_attachment', '.pdf', ['.p', 'pd', 'df'])).toEqual(
+ ['m-contract'],
+ );
+ expect(await find('fold_body', '회의', ['회의'])).toEqual(['m-meeting']);
+ // 메일함 범위(GIN) · 배열 칸이 그대로 들어갔다
+ const inArchive: { email_id: string; keywords: string[] }[] =
+ await dataSource.query(
+ `SELECT email_id, keywords FROM mail_search_messages WHERE user_email = $1 AND mailbox_ids && $2::text[]`,
+ [ME, ['archive']],
+ );
+ expect(inArchive).toEqual([
+ { email_id: 'm-contract', keywords: ['$seen'] },
+ ]);
+ });
+
+ it('표 쓰기 — 넣기 · 메일함 · 키워드만 바꾸기(한 문장) · 지우기, 상태 줄이 없으면 아무것도 쓰지 않는다', async () => {
+ await statusOf(cookieOf(OTHER)).expect(200);
+ const repository = app.get(MailSearchIndexRepository);
+ const now = new Date();
+ const row = toIndexRow(indexDocument({ id: 'w1', subject: '견적' }));
+
+ expect(
+ (
+ await repository.write(
+ OTHER,
+ { upserts: [row, { ...row, emailId: 'w2' }] },
+ now,
+ )
+ ).written,
+ ).toBe(true);
+ const updated = await repository.write(
+ OTHER,
+ {
+ updates: [
+ {
+ id: 'w1',
+ mailboxIds: ['trash'],
+ keywords: ['$seen', '$flagged'],
+ },
+ { id: 'not-indexed', mailboxIds: ['inbox'], keywords: [] },
+ ],
+ deletes: ['w2'],
+ state: { emailState: 's9', lastSyncedAt: now },
+ },
+ now,
+ );
+ expect([...updated.updatedIds]).toEqual(['w1']);
+ expect(await repository.indexedIds(OTHER)).toEqual(['w1']);
+ expect([...(await repository.existingIds(OTHER, ['w1', 'w2']))]).toEqual([
+ 'w1',
+ ]);
+ const [stored]: { mailbox_ids: string[]; keywords: string[] }[] =
+ await dataSource.query(
+ 'SELECT mailbox_ids, keywords FROM mail_search_messages WHERE user_email = $1',
+ [OTHER],
+ );
+ expect(stored).toEqual({
+ mailbox_ids: ['trash'],
+ keywords: ['$seen', '$flagged'],
+ });
+ expect((await repository.findState(OTHER))?.emailState).toBe('s9');
+ expect(await repository.recordFailure(OTHER, now)).toBe(1);
+
+ // 퇴사 마무리처럼 상태 줄 → 색인 줄을 지우면, 뒤늦은 쓰기는 아무것도 하지 않는다
+ await dataSource.query(
+ 'DELETE FROM mail_search_states WHERE user_email = $1',
+ [OTHER],
+ );
+ await dataSource.query(
+ 'DELETE FROM mail_search_messages WHERE user_email = $1',
+ [OTHER],
+ );
+ expect(
+ (await repository.write(OTHER, { upserts: [row] }, now)).written,
+ ).toBe(false);
+ expect(await repository.countMessages(OTHER)).toBe(0);
+ });
+ });
+
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
let systemSettings: SystemSettingsService;
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
diff --git a/backend/test/search-text-samples.ts b/backend/test/search-text-samples.ts
new file mode 100644
index 0000000..7d0bf81
--- /dev/null
+++ b/backend/test/search-text-samples.ts
@@ -0,0 +1,141 @@
+/**
+ * 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다).
+ * 서버의 색인 · 찾기(src/shared/utils/search-text.ts · subject-base.ts)와 화면의 미리 확인 · 강조(frontend `shared/utils/search-match.ts`)를 같은 표로 본다.
+ *
+ * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로 — 14단계 frontend 차례에 옮긴다).
+ * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
+ * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다).
+ */
+
+/** 접기 — [넣은 글, 접은 글]. 접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 */
+export const FOLD_SAMPLES: readonly (readonly [
+ input: string,
+ folded: string,
+])[] = [
+ ['계약서', '계약서'],
+ ['Project PLAN', 'project plan'],
+ // 전각 ASCII → 반각 (대문자는 소문자로까지)
+ ['ABC123!', 'abc123!'],
+ ['RE:회의', 're:회의'],
+ ['[공지]', '[공지]'],
+ // 전각 공백(U+3000)은 바꾸지 않는다 — 공백은 위치를 지키려고 그대로 둔다
+ ['회의\u3000일정', '회의\u3000일정'],
+ // 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
+ ['İstanbul', 'İstanbul'],
+ ['ÀÉÎÕÜ', 'àéîõü'],
+ ['ΣΑΣ', 'σασ'],
+ // 풀어 쓴 한글(NFD)은 NFC 로 모은다
+ ['\u1112\u1161\u11ab\u1100\u1173\u11af', '한글'],
+ // ㈜ 는 그대로 (NFKC 가 아니다 — 길이를 바꾸지 않는다)
+ ['㈜아라', '㈜아라'],
+ // BMP 밖 글자 — Deseret 대문자는 소문자로(둘 다 두 단위), 이모지는 그대로
+ ['\u{10400}😀', '\u{10428}😀'],
+ ['', ''],
+];
+
+/** 글자 두 개 묶음 — [접은 글, 묶음(나온 차례, 겹치지 않게)] */
+export const GRAM_SAMPLES: readonly (readonly [
+ fold: string,
+ grams: readonly string[],
+])[] = [
+ ['계약서', ['계약', '약서']],
+ ['회의 일정', ['회의', '일정']],
+ ['계약', ['계약']],
+ ['계', []],
+ ['a b c', []],
+ ['aaaa', ['aa']],
+ ['ab\nab\tab', ['ab']],
+ // 전각 공백 · NBSP 도 공백이다
+ ['회의\u3000일정\u00a0안내', ['회의', '일정', '안내']],
+ // 문장부호는 글자다 — 're:' 의 'e:' 도 묶음
+ ['re:회의', ['re', 'e:', ':회', '회의']],
+ // BMP 밖 글자는 코드 포인트 하나
+ ['😀😁', ['😀😁']],
+ ['', []],
+];
+
+/** 맞은 곳 — [원문(NFC), 찾는 말들, 맞은 곳들 [시작, 끝) UTF-16] — 겹치거나 맞닿으면 합친다 */
+export const MATCH_SAMPLES: readonly (readonly [
+ original: string,
+ terms: readonly string[],
+ ranges: readonly (readonly [number, number])[],
+])[] = [
+ ['계약서를 보냈습니다', ['계약'], [[0, 2]]],
+ ['Project plan', ['PROJ'], [[0, 4]]],
+ ['ABC 주문', ['abc'], [[0, 3]]],
+ [
+ '회의 회의록 회의실',
+ ['회의'],
+ [
+ [0, 2],
+ [3, 5],
+ [7, 9],
+ ],
+ ],
+ // 겹치는 자리도 찾아 합친다
+ ['aaa', ['aa'], [[0, 3]]],
+ // 맞닿은 두 말은 하나로
+ ['계약서', ['계약', '서'], [[0, 3]]],
+ [
+ '회의 일정 안내',
+ ['일정', '회의'],
+ [
+ [0, 2],
+ [3, 5],
+ ],
+ ],
+ // 구절은 띄어쓰기까지 그대로
+ ['회의 일정 안내', ['회의 일정'], [[0, 5]]],
+ ['회의 일정', ['회의 일정'], []],
+ [
+ '[공지] Re: 회의',
+ ['공지', 're'],
+ [
+ [1, 3],
+ [5, 7],
+ ],
+ ],
+ // 빈 말은 건너뛴다 · 없는 말은 맞은 곳 없음
+ ['계약', ['', '없음'], []],
+ // BMP 밖 글자 앞뒤에서도 UTF-16 위치
+ ['😀계약', ['계약'], [[2, 4]]],
+ ['', ['계약'], []],
+];
+
+/** 말머리 떼기 — [제목, 말머리를 뗀 접기 글] */
+export const SUBJECT_BASE_SAMPLES: readonly (readonly [
+ subject: string,
+ base: string,
+])[] = [
+ ['회의 일정 안내', '회의 일정 안내'],
+ ['Re: 회의 일정 안내', '회의 일정 안내'],
+ ['RE: Re: FW: 회의 일정 안내', '회의 일정 안내'],
+ ['Re:회의', '회의'],
+ ['Fwd: 견적서', '견적서'],
+ ['AW: WG: Angebot', 'angebot'],
+ // 한국어 Outlook — 엔진은 떼지 않는다
+ ['회신: 회의 일정 안내', '회의 일정 안내'],
+ ['답장: 회신: 전달: 회의', '회의'],
+ ['회신 : 회의', '회의'],
+ // 수 · 전각 쌍점
+ ['Re[2]: 회의', '회의'],
+ ['RE(3): 회의', '회의'],
+ ['Re:회의', '회의'],
+ ['RE: 회의', '회의'],
+ // 대괄호 꼬리표 (여러 겹 · 말머리와 섞여도)
+ ['[공지] 회의 일정', '회의 일정'],
+ ['Re: [공지] [중요] 회의', '회의'],
+ ['[외부] RE: 견적', '견적'],
+ // 공백은 하나로 · 대소문자 없이
+ [' Re: 회의 일정 ', '회의 일정'],
+ ['Project PLAN', 'project plan'],
+ // 쌍점이 없으면 말머리가 아니다
+ ['Re 회의', 're 회의'],
+ ['Reply to 회의', 'reply to 회의'],
+ ['Regarding: 회의', 'regarding: 회의'],
+ ['회신드립니다', '회신드립니다'],
+ // 다 떼면 빈 글
+ ['Re:', ''],
+ ['[공지]', ''],
+ ['', ''],
+];
From 0fc57359593aa5ca24856c69459c8f9068d1c3a3 Mon Sep 17 00:00:00 2001
From: ttipo
Date: Thu, 5 Nov 2026 20:51:15 +0900
Subject: [PATCH 02/20] =?UTF-8?q?docs:=20=EA=B2=80=EC=83=89=20=EC=83=89?=
=?UTF-8?q?=EC=9D=B8=20=EC=A4=80=EB=B9=84=20=EC=83=81=ED=83=9C=EC=99=80=20?=
=?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EC=BF=BC=EB=A6=AC=20=EB=A1=9C=EA=B7=B8=20?=
=?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EA=B3=84=EC=95=BD=20=C2=B7=20?=
=?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=A0=81?=
=?UTF-8?q?=EB=8A=94=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
계약 2절에 "검색 색인 (GET /api/search-index)" — 색인에 드는 것 · 만드는 때 · 밤 채우기 ·
맞추기 · 조회지만 상태 줄을 만드는 동작(멱등)을 적었다. 로그 규칙에 개발 환경도 DB 쿼리
매개변수를 남기지 않는다는 줄, nestjs 스킬에 mail-search 모듈 줄과 Redis 로 옮길 상태를 더했다.
Co-Authored-By: Claude Opus 5.5
---
.claude/rules/logging.md | 3 ++-
.claude/skills/nestjs/SKILL.md | 2 ++
docs/mail/api-contract.md | 17 +++++++++++++++++
3 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md
index 6e3a4e7..b4baea0 100644
--- a/.claude/rules/logging.md
+++ b/.claude/rules/logging.md
@@ -33,7 +33,8 @@
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
-- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
+- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
+- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · 조건 검색의 `from` · `to` · `subject` · `body`(14단계) · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md
index 0bf49de..fd0e3c5 100644
--- a/.claude/skills/nestjs/SKILL.md
+++ b/.claude/skills/nestjs/SKILL.md
@@ -43,6 +43,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 |
| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 리프레시 쿠키로 지금 세션을 안다) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`. 로그인 기록 `login-events.*`(표 `login_events` — 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` |
| 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM `, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it -backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열한 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 이전 마치기: 앱 비밀번호 → 세션 → 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더(알림 없음) / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) |
+| 메일 검색 색인 (14단계 1차 — 색인 바탕) | `modules/mail-search` · `modules/mail/jmap/jmap-index-source.ts` · `shared/utils/search-text.ts` · `subject-base.ts` | 표 둘(마이그레이션 `AddMailSearchIndex`): `mail_search_messages`(직원 주소 + 엔진 메일 id 마다 한 줄 — 원문(NFC) · 접기 글 `fold_*` · 글자 두 개 묶음 `grams` · 메일함 · 답장 관계(셋은 GIN — 마이그레이션이 손으로 만들고 엔티티에는 `synchronize: false` 로 이름만)) · `mail_search_states`(직원마다 한 줄 — building · ready, 엔진 Email state, 채우기 자리, 멈춘 까닭, 실패 수). 포트 `MAIL_INDEX_SOURCE`(`mail-index-source.ts` — 엔진 교체는 `mail-search.module.ts` 의 useClass 만): 어댑터는 textBody 의 글자 부분만 읽는다(첨부 안의 글은 읽지 않는다) — 본문 값은 부분 256KiB · 한 통 1MiB, 한 요청의 메일 전체 크기 합 32MiB · 16MiB 넘는 메일은 혼자. 접기 `foldText`(NFC 뒤 코드 포인트 하나씩 — 전각 → 반각 · 소문자, 원문과 위치가 같다) · 묶음 `gramsOf` · 맞은 곳 `matchRanges` · 말머리 떼기 `subjectBase` — **짝: frontend `shared/utils/search-match.ts`**, 같은 표본 표 `backend/test/search-text-samples.ts`(한쪽을 고치면 둘 다). 상태 줄은 로그인(`AuthService`) · 비밀번호 바꾸기 · `GET /api/search-index` 가 만들고 멈춤을 푼다(로그인한 적 없는 계정은 색인하지 않는다). 맞추기 `mail-search-sync.service.ts`(Email/changes — 실시간 state 소식 300ms 모음 · 5분 cron · 검색 바로 전 1.5초, 직원마다 하나(`MailSearchRuns`) · 서버 전체 4(`ConcurrencyGate`), cannotCalculate → 전체 대조), 채우기 `mail-search-build.service.ts`(밤 창 KST 22:00~02:45 · 05:00~07:30 — 1분 cron, 최신부터 50통씩 anchor, 다 읽으면 새 차례의 처음에 따라잡기 → 전체 대조 → ready). **시간 끝은 실패가 아니다** — `IndexDeadlineError`(이어 하기 — 실패 수 · 경고 없음). 색인 쓰기는 상태 줄을 `FOR UPDATE` 로 잠근 한 트랜잭션 — 퇴사 마무리(`ops-data.service.ts`)는 상태 줄 → 색인 줄 차례로 지운다. 맞추고 채우는 직원은 사내 도메인(`MAIL_DOMAIN`) 주소뿐이다(같은 DB 의 e2e 줄은 건드리지 않는다). cron 은 `timeZone: 'Asia/Seoul'`. 묶음 크기 · 시간 한도는 `mail-search.constants.ts` 한 곳(잰 뒤 바꾼다). 로그는 코드 · 사유 · 가린 주소만(메일 글 · 제목 · 엔진 설명 문장 없음), 조건 검색 쿼리(`from` · `to` · `subject` · `body`)는 `maskUrlForLog` 가 길이만, 개발의 DB 쿼리 로그도 끈다(`database.config.ts`) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -254,6 +255,7 @@ SwaggerModule.setup('api-docs', app, document);
> 코드 실패 카운터(`login-attempt.service.ts` 의 codeFailures · dailyCodeFailures), 2단계 바꾸기 잠금(`two-factor-locks.ts`) · 앱 비밀번호 만들기 차례(`AppPasswordsService.creating`) —
> 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) —
> 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다.
+ > 검색 색인(14단계)의 직원마다 차례(`mail-search-runs.ts` 의 `MailSearchRuns`) · 맞추기의 소식 모음 · 기다리는 맞추기 · 5분 차례 겹침 막기(`mail-search-sync.service.ts` 의 debounces · queued · syncingAll) · 서버 전체 맞추기 수(`ConcurrencyGate` — 4) · 1분 채우기 겹침 막기(`mail-search-build.service.ts` 의 running) — 그대로면 인스턴스마다 따로 돌아, **두 인스턴스의 1분 차례가 같은 직원을 함께 채우고**(쓰기는 상태 줄 잠금으로 차례가 되지만 같은 묶음을 두 번 읽는다) 같은 직원을 함께 맞추며, 엔진에 가는 맞추기 수가 인스턴스 수만큼 는다 → 직원마다 잠금(`SET NX PX` · `pg_advisory_lock`) · 공유 셈. 표 쓰기 자체는 DB 라 그대로 맞다.
> 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어
> 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) —
> 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다:
diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md
index 8a3681a..8c00458 100644
--- a/docs/mail/api-contract.md
+++ b/docs/mail/api-contract.md
@@ -642,6 +642,23 @@ interface MailState {
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
+### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색 · 대화는 다음 차례에 이 절에 더한다)
+
+14단계는 엔진 검색 대신 **서버의 자체 색인**(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다.
+
+- **색인에 드는 것**: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계(Message-ID · In-Reply-To · References) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름 · 본문 글(앞 2만 자 — 잰 뒤 정한다).
+ 메일 글이 DB 와 매일 백업에 한 벌 더 들어간다(사용자가 받아들였다). 퇴사 마무리는 이 표도 지운다. 언제나 로그인한 직원 주소로만 읽고 쓴다.
+- **만드는 때**: 직원이 웹메일에 처음 로그인할 때(비밀번호가 만료된 로그인은 빼고 — 새 비밀번호를 정하면 만든다) 또는 아래 경로를 처음 물을 때 그 직원의 상태 줄을 `building` 으로 만든다. 로그인한 적 없는 계정은 색인하지 않는다.
+- **지난 메일 채우기**: 밤에만 한 사람씩(KST 22:00 ~ 02:45 · 05:00 ~ 07:30 — 03:00 · 04:00 · 04:30 엔진 정리 · 03:30 백업을 피한다). 다 채우면 `ready`.
+- **새로 온 · 바뀐 · 지운 메일**은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다.
+ 비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다.
+- `GET /api/search-index` → `{ status, done, total }`
+ - `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색은 지금 방식(엔진)이고, 화면은 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`.
+ - `done`: 채운 메일 수(`total` 을 넘지 않는다) · `total`: 채우기를 시작할 때의 메일 수(시작 전이면 `null`, `ready` 면 `done` 과 같다).
+ - **조회지만 상태 줄을 만들고 멈춤을 푼다** — 줄이 없던 직원은 이때 `building` 으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등).
+ - 로그인 필요(401 `AUTH_001`) · 제한된 세션은 403 `AUTH_010`(6절).
+- 로그 가림: 검색 조건의 이름 `from` · `to` · `subject` · `body` 도 `q` · `cursor` 처럼 길이만 남긴다(`maskUrlForLog`). 개발 환경도 DB 쿼리 로그(매개변수)를 남기지 않는다 — 메일 글이 DB 에 있다.
+
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
```ts
From d57e6bfe6ce94f5517357c0e3952d663bac7595d Mon Sep 17 00:00:00 2001
From: ttipo
Date: Thu, 5 Nov 2026 22:00:44 +0900
Subject: [PATCH 03/20] =?UTF-8?q?chore(docker):=20DB=20=EB=A9=94=EB=AA=A8?=
=?UTF-8?q?=EB=A6=AC=20=ED=95=9C=EB=8F=84=EB=A5=BC=201g=20=C2=B7=20?=
=?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8D=BC=EB=A5=BC=20256MB=20?=
=?UTF-8?q?=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4(14=EB=8B=A8=EA=B3=84=20?=
=?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=83=89=EC=9D=B8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1만 통 측정에서 색인 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이었고 캐시가 비면
3~8배 느렸다(사용자 결정 2026-11-05). 8GB 서버의 한도 합은 6.65GB · 백업 때 7.65GB 다.
복원 연습 때는 8.15GB 로 8GB 를 넘는다 — 사실만 적고 다룰 방법은 따로 정한다.
mail/README 에 운영에 올리는 차례(db 만 먼저 up · 한도 · shared_buffers 확인)를 적었다.
docker 스킬의 연습 주기를 기준 문서(README — 한 달에 한 번)에 맞췄다.
Co-Authored-By: Claude Opus 5.5
---
.claude/skills/docker/SKILL.md | 5 ++++-
docker-compose.yml | 10 +++++++++-
mail/README.md | 13 +++++++++----
3 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md
index 3684632..175d86d 100644
--- a/.claude/skills/docker/SKILL.md
+++ b/.claude/skills/docker/SKILL.md
@@ -484,7 +484,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
-- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`.
+- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 한 달에 한 번 `drill`(mail/README "백업 › 복원" 이 기준).
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
@@ -568,6 +568,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
+- **db 메모리 한도는 1g · 공유 버퍼(`shared_buffers`) 256MB** (14단계 — 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있어야 1초 안이다, 캐시가 비면 3~8배).
+ 공유 버퍼는 compose 의 db `command`(`postgres -c …` — 명령줄 값이 데이터 폴더의 설정보다 이긴다)이고 개발도 같은 값이다(dev 오버라이드가 덮어쓰지 않는다).
+ 8GB 서버의 한도 합 · 운영에 올리는 차례는 mail/README.md "메모리 예산".
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
diff --git a/docker-compose.yml b/docker-compose.yml
index 209a2ea..38871d7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -234,6 +234,10 @@ services:
# 판 · digest 까지 고정한다(루트 env 의 DB_IMAGE — docker 스킬 "이미지 버전 고정"). 메이저(15)는 데이터 형식이라 그대로 둔다 —
# 올리려면 pg_dump → 새 메이저에 복원(mail/README.md "백업"의 복원 차례). 아래 watchtower 표시는 13단계에 뺐다(판을 사람이 올린다)
image: ${DB_IMAGE}
+ # 공유 버퍼(shared_buffers) 256MB — 기본 128MB 의 두 배 · 메모리 한도(아래 deploy — 1g)의 1/4. 14단계 메일 검색 색인 때문이다(아래 deploy 의 "메모리").
+ # 공식 이미지의 꼴이다 — entrypoint(docker-entrypoint.sh)가 처음 기동의 initdb 를 하고 이 인자를 postgres 에 그대로 넘긴다.
+ # 명령줄 값은 데이터 폴더의 postgresql.conf · ALTER SYSTEM 보다 이긴다 — 값은 이 한 곳에 둔다(개발도 같다 — docker-compose.dev.yml 은 덮어쓰지 않는다)
+ command: ["postgres", "-c", "shared_buffers=256MB"]
expose:
- "5432"
environment:
@@ -252,11 +256,15 @@ services:
timeout: 5s
retries: 5
start_period: 10s
+ # 메모리 — 1g (14단계에 512m 에서 올렸다 — 2026-11-05 사용자 결정). 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이다 —
+ # 2026-11-05 개발(1만 통 · 색인 121MB)에서 캐시가 비면 3~8배 느렸다. 512m 로도 모자라지는 않았다(채우기 0.16GB 안팎 · 찾기 시험 최대 236MiB) — 늘린 몫은 색인을 메모리에 두는 자리다.
+ # 공유 버퍼(위 command) 밖은 연결마다의 작업 메모리와 표 · 색인의 페이지 캐시다 — 페이지 캐시도 이 한도에 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다).
+ # 8GB 서버의 합은 mail/README.md "메모리 예산". 값을 바꾸면 다음 up 이 db 를 다시 만든다 — 수 초 멈추고 backend 는 다시 붙는다(같은 절의 차례)
deploy:
resources:
limits:
cpus: '0.5'
- memory: 512m
+ memory: 1g
# ----------------------------------------
# 4. Redis — 내부 네트워크 전용 · 기본 비활성
diff --git a/mail/README.md b/mail/README.md
index f6b241d..2cbfdac 100644
--- a/mail/README.md
+++ b/mail/README.md
@@ -826,10 +826,15 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
| clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 |
| mail | 1g | 0.1~0.3GB (큰 메일 뒤 0.4GB 남짓까지 쌓인다 — 2026-11-03) | 메일 한 통을 받을 때 그 크기 × 5.5 쯤 더 — 받는 한도 50MiB(setup)면 +0.3GB 남짓. 운영에서 106.4MB 한 통이 1g 를 넘었다(2026-11-03 — 개발 104MB 는 984/1024MiB · 서버 B 는 이전하는 동안만 2g, [큰 메일](#큰-메일-운영-2026-11-03) · [받는 메일 크기](#엔진-한도)) |
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
-| db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | |
-| **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
-| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.15GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m 를 더 띄운다(한 달에 한 번 — 낮에 하지 않는다) |
+| db | 1g (14단계에 512m 에서) | 0.16GB 안팎(색인을 채우는 동안도) · 찾기 시험 동안 최대 0.24GB (2026-11-05 · 그때는 공유 버퍼 128MB · 한도 512m) | 공유 버퍼(`shared_buffers` 256MB — compose 의 `command`)가 차고 그 위로 표 · 색인의 페이지 캐시(한도 안에서 줄여 쓴다). 메일 검색은 이것이 메모리에 있을 때만 1초 안이다 — 캐시가 비면 3~8배(1만 통 · 색인 121MB) |
+| frontend · mail-resolver | 512m · 128m | 작다 | |
+| **합** | **6.65GB** | 2.5~3GB (14단계 전 — db 는 공유 버퍼가 찬 만큼 는다) | 나머지 1.3GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
+| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.65GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m(스크립트에 고정 — compose 의 db 한도를 따르지 않는다)를 더 띄워 합이 8.15GB 로 8GB 를 넘는다(14단계부터 — 그 전은 7.65GB. 한 달에 한 번 — 낮에 하지 않는다) |
+- **db 1g · 공유 버퍼 256MB 를 운영에 올릴 때**(14단계 배포와 함께 — 한 번): 다음 `up` 이 db 를 다시 만든다 — 수 초 멈추고(데이터는 `DB_DATA_DIR` 그대로) 떠 있는 backend 는 다시 붙는다(그동안의 요청은 실패).
+ 퇴근 뒤 · 백업(03:30) · 복원 연습을 피해 `git pull` → `sudo docker compose --env-file .env.production up -d db`(db 만 먼저) → `sudo docker stats --no-stream "${PROJECT_NAME}-db"` 의 한도 `1GiB` ·
+ `sudo docker exec "${PROJECT_NAME}-db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "show shared_buffers"'` 가 `256MB` → 14단계의 `up -d --build`.
+ mail 이 이전 때의 2g(`docker update`)로 남아 있으면 먼저 되돌린다([큰 메일](#큰-메일-운영-2026-11-03) 3. — 그대로면 합이 7.65GB · 백업 때 8.65GB).
- **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다.
한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다.
- 서명은 해마다 는다 — `docker stats` 의 clamav 가 한도의 80%(1.6GB)를 넘으면 한도를 올리거나(합을 다시 본다) 서버를 키운다.
@@ -1722,7 +1727,7 @@ sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass
`[변경]` · `[유지]` 줄은 저장한 값이고, 엔진에 걸린 값은 리로드 뒤의 이 `[확인]` 줄이다(리로드가 실패하면 엔진은 옛 한도로 받는다).
3. 메모리를 compose 의 값으로 — `sudo docker update --memory 1g --memory-swap 2g -mail`.
- 받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다([엔진 한도](#엔진-한도) "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB, 백업이 도는 동안은 8.15GB 로 8GB 를 넘는다.
+ 받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다([엔진 한도](#엔진-한도) "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.65GB, 백업이 도는 동안은 8.65GB 로 8GB 를 넘는다(14단계의 db 1g 부터 — 그 전은 7.15 · 8.15GB).
#### 개발에서 확인한 것 (imapsync — 2026-11-02)
From f9fbf8a20f84961cae2e47244d63dcdb64bff444 Mon Sep 17 00:00:00 2001
From: ttipo
Date: Fri, 6 Nov 2026 00:18:41 +0900
Subject: [PATCH 04/20] =?UTF-8?q?feat(backend):=20=EB=A9=94=EC=9D=BC=20?=
=?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9E=90=EC=B2=B4=20=EC=83=89?=
=?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20?=
=?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EA=B2=80=EC=83=89=20=C2=B7=20=EA=B0=95?=
=?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼
엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다).
- 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지
- 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면
제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정)
- 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 +
이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초
- 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글
조각에 · 제목 · 첨부 이름 · 글 본문 위치
- 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의
색인을 지우고 밤에 다시 채운다
리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션,
다른 직원 줄 격리 e2e.
Co-Authored-By: Claude Opus 5.5
---
.../1793882273224-SearchIndexUserGrams.ts | 41 ++
.../mail-search/index-document.spec.ts | 5 +-
.../mail-search/mail-search-build.service.ts | 60 +-
.../mail-search-index.repository.ts | 39 +-
.../mail-search-index.service.spec.ts | 75 ++-
.../mail-search/mail-search-index.service.ts | 33 +-
.../mail-search/mail-search-message.entity.ts | 9 +-
.../mail-search-query.service.spec.ts | 551 +++++++++++++++++
.../mail-search/mail-search-query.service.ts | 412 +++++++++++++
.../mail-search/mail-search-state.entity.ts | 7 +
.../mail-search-sync.service.spec.ts | 112 ++++
.../mail-search/mail-search-sync.service.ts | 5 +
.../mail-search/mail-search.constants.ts | 34 ++
.../modules/mail-search/mail-search.module.ts | 8 +-
.../modules/mail-search/mail-search.types.ts | 48 ++
.../modules/mail-search/search-cursor.spec.ts | 137 +++++
.../src/modules/mail-search/search-cursor.ts | 122 ++++
.../mail-search/search-highlight.spec.ts | 178 ++++++
.../modules/mail-search/search-highlight.ts | 163 +++++
.../modules/mail-search/search-plan.spec.ts | 246 ++++++++
.../src/modules/mail-search/search-plan.ts | 196 ++++++
.../modules/mail-search/search-sql.spec.ts | 194 ++++++
backend/src/modules/mail-search/search-sql.ts | 272 +++++++++
.../src/modules/mail/dto/mail-response.dto.ts | 108 +++-
.../mail/dto/message-detail-query.dto.spec.ts | 69 +++
.../mail/dto/message-detail-query.dto.ts | 48 +-
.../mail/dto/message-list-query.dto.spec.ts | 158 ++++-
.../mail/dto/message-list-query.dto.ts | 137 +++--
.../modules/mail/dto/search-query-fields.ts | 121 ++++
.../src/modules/mail/html/html-mark.spec.ts | 176 ++++++
backend/src/modules/mail/html/html-mark.ts | 175 ++++++
.../src/modules/mail/jmap/jmap-cursor.spec.ts | 52 ++
backend/src/modules/mail/jmap/jmap-cursor.ts | 14 +-
.../mail/jmap/jmap-mail.provider.spec.ts | 296 ++++++++-
.../modules/mail/jmap/jmap-mail.provider.ts | 171 +++++-
backend/src/modules/mail/jmap/jmap.types.ts | 10 +
.../modules/mail/jmap/search-terms.spec.ts | 54 ++
backend/src/modules/mail/jmap/search-terms.ts | 112 ++--
backend/src/modules/mail/mail.constants.ts | 4 +-
backend/src/modules/mail/mail.module.ts | 3 +
backend/src/modules/mail/mail.provider.ts | 12 +
backend/src/modules/mail/mail.service.spec.ts | 56 +-
backend/src/modules/mail/mail.service.ts | 52 +-
backend/src/modules/mail/mail.types.ts | 18 +
.../modules/mail/message-highlight.spec.ts | 147 +++++
backend/src/modules/mail/message-highlight.ts | 62 ++
.../modules/mail/message-list.service.spec.ts | 405 +++++++++++++
.../src/modules/mail/message-list.service.ts | 212 +++++++
.../src/modules/mail/message.controller.ts | 27 +-
backend/src/shared/utils/search-query.ts | 84 +++
backend/src/shared/utils/search-text.ts | 36 +-
backend/test/app.e2e-spec.ts | 567 ++++++++++++++++++
backend/test/search-text-samples.ts | 9 +-
53 files changed, 6073 insertions(+), 269 deletions(-)
create mode 100644 backend/src/migrations/1793882273224-SearchIndexUserGrams.ts
create mode 100644 backend/src/modules/mail-search/mail-search-query.service.spec.ts
create mode 100644 backend/src/modules/mail-search/mail-search-query.service.ts
create mode 100644 backend/src/modules/mail-search/search-cursor.spec.ts
create mode 100644 backend/src/modules/mail-search/search-cursor.ts
create mode 100644 backend/src/modules/mail-search/search-highlight.spec.ts
create mode 100644 backend/src/modules/mail-search/search-highlight.ts
create mode 100644 backend/src/modules/mail-search/search-plan.spec.ts
create mode 100644 backend/src/modules/mail-search/search-plan.ts
create mode 100644 backend/src/modules/mail-search/search-sql.spec.ts
create mode 100644 backend/src/modules/mail-search/search-sql.ts
create mode 100644 backend/src/modules/mail/dto/search-query-fields.ts
create mode 100644 backend/src/modules/mail/html/html-mark.spec.ts
create mode 100644 backend/src/modules/mail/html/html-mark.ts
create mode 100644 backend/src/modules/mail/message-highlight.spec.ts
create mode 100644 backend/src/modules/mail/message-highlight.ts
create mode 100644 backend/src/modules/mail/message-list.service.spec.ts
create mode 100644 backend/src/modules/mail/message-list.service.ts
create mode 100644 backend/src/shared/utils/search-query.ts
diff --git a/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts
new file mode 100644
index 0000000..b4af38e
--- /dev/null
+++ b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts
@@ -0,0 +1,41 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤).
+ * - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 —
+ * 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서
+ * 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정).
+ * 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다.
+ * `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로)
+ * - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다
+ * 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다).
+ * (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts)
+ */
+export class SearchIndexUserGrams1793882273224 implements MigrationInterface {
+ name = 'SearchIndexUserGrams1793882273224';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`);
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_grams"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
+ );
+ }
+}
diff --git a/backend/src/modules/mail-search/index-document.spec.ts b/backend/src/modules/mail-search/index-document.spec.ts
index e6fced8..67929f5 100644
--- a/backend/src/modules/mail-search/index-document.spec.ts
+++ b/backend/src/modules/mail-search/index-document.spec.ts
@@ -58,10 +58,11 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
preview: '계약서를 보내 드립니다',
bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
foldFrom: '이도윤 ',
- foldTo: 'ara.kim@arasystem.test\npartner ',
+ // 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2)
+ foldTo: 'ara.kim@arasystem.test partner ',
foldSubject: 're: [공지] 11월 계약서',
foldAttachment: '계약서.pdf',
- foldBody: '안녕하세요.\n계약서를 보내 드립니다.',
+ foldBody: '안녕하세요. 계약서를 보내 드립니다.',
threadKey: 'e1',
engineThreadId: 't1',
});
diff --git a/backend/src/modules/mail-search/mail-search-build.service.ts b/backend/src/modules/mail-search/mail-search-build.service.ts
index e1975a3..71d01fa 100644
--- a/backend/src/modules/mail-search/mail-search-build.service.ts
+++ b/backend/src/modules/mail-search/mail-search-build.service.ts
@@ -1,4 +1,10 @@
-import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
+import {
+ Inject,
+ Injectable,
+ Logger,
+ type OnApplicationBootstrap,
+ Optional,
+} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { maskEmail } from '@/shared/utils/mask';
@@ -21,6 +27,7 @@ import {
BUILD_BATCH_SIZE,
BUILD_RUN_BUDGET_MS,
FINISH_MIN_REMAINING_MS,
+ INDEX_FORMAT,
SEARCH_CRON_TIME_ZONE,
STEP_MIN_REMAINING_MS,
} from './mail-search.constants';
@@ -57,9 +64,11 @@ type StepOutcome = 'continue' | BuildOutcome;
* ⚠️ 1분 차례의 겹침 막기(running)는 프로세스 메모리다 — 인스턴스가 둘이면 두 차례가 같은 직원을 함께 채운다 (nestjs 스킬의 Redis 목록)
*/
@Injectable()
-export class MailSearchBuildService {
+export class MailSearchBuildService implements OnApplicationBootstrap {
private readonly logger = new Logger(MailSearchBuildService.name);
private running = false;
+ /** 판이 다른 줄을 되돌렸나 — 서버가 뜰 때 못 했으면 1분 차례마다 다시 해 본다 (그동안 상태 API · 검색은 판을 보고 building 으로 다룬다) */
+ private formatsReset = false;
private readonly timing: MailSearchTiming;
constructor(
@@ -72,6 +81,39 @@ export class MailSearchBuildService {
this.timing = timing ?? SYSTEM_SEARCH_TIMING;
}
+ /**
+ * 서버가 뜰 때 — 판(INDEX_FORMAT)이 다른 사내 직원의 상태 줄을 building 으로 되돌린다(밤에 다시 채운다 — 그동안 검색은 엔진 방식).
+ * 되돌리지 못해도 서버는 뜬다 — 1분 차례가 다시 해 본다. 그동안 그 직원은 ready 여도 판이 달라 검색은 엔진 방식이고(isSearchReady)
+ * 상태 API 도 building 으로 보인다(toStatusView)
+ */
+ async onApplicationBootstrap(): Promise {
+ await this.resetOutdatedFormats();
+ }
+
+ /** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */
+ private async resetOutdatedFormats(): Promise {
+ const suffix = this.sync.indexedAddressSuffix;
+ if (suffix === null || this.formatsReset) return;
+ try {
+ const reset = await this.repository.resetOutdatedFormats(
+ suffix,
+ INDEX_FORMAT,
+ new Date(this.timing.now()),
+ );
+ this.formatsReset = true;
+ if (reset > 0) {
+ // 배포마다 한 번뿐인 상태 변화 — 수만 (주소 없음)
+ this.logger.log(
+ `검색 색인의 판이 바뀌어 ${reset}명의 색인을 밤에 다시 채웁니다 (판 ${INDEX_FORMAT}).`,
+ );
+ }
+ } catch (error) {
+ this.logger.warn(
+ `[SYS_001] 판이 바뀐 검색 색인을 되돌리지 못했습니다(그 직원의 검색은 엔진 방식 — 1분 뒤 다시): ${error instanceof Error ? error.name : typeof error}`,
+ );
+ }
+ }
+
/** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */
@Cron('0 * * * * *', {
name: 'mail-search-build',
@@ -79,13 +121,10 @@ export class MailSearchBuildService {
})
async tick(): Promise {
const startedAt = this.timing.now();
- if (
- this.running ||
- this.sync.isStopping ||
- !isBuildWindow(new Date(startedAt))
- ) {
- return null;
- }
+ if (this.running || this.sync.isStopping) return null;
+ // 서버가 뜰 때 판을 되돌리지 못했으면 낮에도 다시 해 본다 (한 번 되면 하지 않는다)
+ await this.resetOutdatedFormats();
+ if (!isBuildWindow(new Date(startedAt))) return null;
this.running = true;
try {
const candidates = await this.repository.buildCandidates(
@@ -144,11 +183,12 @@ export class MailSearchBuildService {
const now = new Date(this.timing.now());
if (state.buildStartedAt === null || state.emailState === null) {
- // 시작 — 지금 state(S0)부터는 맞추기가 넣는다
+ // 시작 — 지금 state(S0)부터는 맞추기가 넣는다. 처음 시작이면(판을 되돌린 직원 포함) 남은 색인 줄을 함께 지운다 — 옛 판 줄이 남지 않게
const snapshot = await this.source.snapshot(user, { deadline });
const result = await this.repository.write(
user,
{
+ clearIndex: state.buildStartedAt === null,
state: {
emailState: snapshot.state,
buildStartedAt: now,
diff --git a/backend/src/modules/mail-search/mail-search-index.repository.ts b/backend/src/modules/mail-search/mail-search-index.repository.ts
index a1d4fe4..ded96a1 100644
--- a/backend/src/modules/mail-search/mail-search-index.repository.ts
+++ b/backend/src/modules/mail-search/mail-search-index.repository.ts
@@ -7,6 +7,7 @@ import type { IndexMutable } from './mail-index-source';
import { MailSearchMessageEntity } from './mail-search-message.entity';
import { MailSearchStateEntity } from './mail-search-state.entity';
import {
+ INDEX_FORMAT,
SEARCH_MESSAGES_TABLE,
SEARCH_STATES_TABLE,
} from './mail-search.constants';
@@ -27,8 +28,13 @@ export interface SearchStatePatch {
readonly lastSyncedAt?: Date | null;
}
-/** 한 트랜잭션에 쓰는 것 — 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */
+/** 한 트랜잭션에 쓰는 것 — (그 직원의 색인 줄 모두 지우기) → 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */
export interface SearchIndexWrite {
+ /**
+ * 그 직원의 색인 줄을 모두 지운다 — 채우기를 처음부터 시작할 때(판이 바뀌어 되돌린 직원 포함). 채우기는 위치로 건너뛸 수 있고 마지막 대조는
+ * 없는 줄만 넣으므로, 남겨 두면 옛 판(접기 규칙)의 줄이 그대로 남았다. 그동안(building) 검색은 엔진 방식이다
+ */
+ readonly clearIndex?: boolean;
readonly deletes?: readonly string[];
readonly upserts?: readonly IndexRowValues[];
readonly updates?: readonly IndexMutable[];
@@ -123,6 +129,7 @@ export class MailSearchIndexRepository {
lastSyncedAt: null,
pausedReason: null,
failureCount: 0,
+ indexFormat: INDEX_FORMAT,
createdAt: now,
updatedAt: now,
})
@@ -145,6 +152,7 @@ export class MailSearchIndexRepository {
lastSyncedAt: null,
pausedReason: null,
failureCount: 0,
+ indexFormat: INDEX_FORMAT,
createdAt: now,
updatedAt: now,
});
@@ -153,6 +161,27 @@ export class MailSearchIndexRepository {
return saved;
}
+ /**
+ * 판이 다른 줄을 building 으로 되돌린다 (색인 판 — mail-search.constants.ts 의 INDEX_FORMAT) → 되돌린 줄 수.
+ * 맞출 기준 · 채우기 자리를 지워 밤에 처음부터 다시 채운다 — 옛 색인 줄은 채우기가 시작하는 첫 묶음에서 지운다(SearchIndexWrite 의 clearIndex).
+ * 그동안 그 직원의 검색은 엔진 방식이다. `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 (같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다)
+ */
+ async resetOutdatedFormats(
+ addressSuffix: string,
+ format: number,
+ now: Date,
+ ): Promise {
+ const [rows] = await this.dataSource.query<[unknown[], number]>(
+ `UPDATE "${SEARCH_STATES_TABLE}"
+ SET "status" = 'building', "email_state" = NULL, "build_anchor" = NULL, "build_done" = 0, "build_total" = NULL,
+ "build_started_at" = NULL, "ready_at" = NULL, "failure_count" = 0, "index_format" = $2, "updated_at" = $3
+ WHERE "index_format" <> $2 AND right("user_email", length($1)) = $1
+ RETURNING "user_email"`,
+ [addressSuffix, format, now],
+ );
+ return rows.length;
+ }
+
/** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */
syncCandidates(): Promise {
return this.dataSource.manager
@@ -234,7 +263,7 @@ export class MailSearchIndexRepository {
}
/**
- * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0).
+ * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), (모두 지우기) → 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0).
* `now` 는 넣은 줄의 indexed_at · 상태 줄의 updated_at
*/
async write(
@@ -250,6 +279,12 @@ export class MailSearchIndexRepository {
if (locked.length === 0) {
return { written: false, updatedIds: new Set() };
}
+ if (change.clearIndex) {
+ await manager.query(
+ `DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
+ [userEmail],
+ );
+ }
const deletes = [...new Set(change.deletes ?? [])];
if (deletes.length > 0) {
await manager.query(
diff --git a/backend/src/modules/mail-search/mail-search-index.service.spec.ts b/backend/src/modules/mail-search/mail-search-index.service.spec.ts
index 9214ff5..9c29424 100644
--- a/backend/src/modules/mail-search/mail-search-index.service.spec.ts
+++ b/backend/src/modules/mail-search/mail-search-index.service.spec.ts
@@ -5,11 +5,13 @@ import {
MailSearchIndexService,
toStatusView,
} from './mail-search-index.service';
+import { INDEX_FORMAT } from './mail-search.constants';
const ARA = 'ara.kim@arasystem.test';
describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
let ensureState: jest.Mock;
+ let findState: jest.Mock;
let service: MailSearchIndexService;
let warnings: string[];
@@ -18,9 +20,12 @@ describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
status: 'building',
buildDone: 0,
buildTotal: null,
+ indexFormat: INDEX_FORMAT,
});
+ findState = jest.fn().mockResolvedValue(null);
service = new MailSearchIndexService({
ensureState,
+ findState,
} as unknown as MailSearchIndexRepository);
warnings = [];
jest.spyOn(Logger.prototype, 'warn').mockImplementation((message) => {
@@ -51,16 +56,80 @@ describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
it('toStatusView — 다 만들었으면 채운 수 = 채울 수', () => {
expect(
- toStatusView({ status: 'ready', buildDone: 120, buildTotal: 120 }),
+ toStatusView({
+ status: 'ready',
+ buildDone: 120,
+ buildTotal: 120,
+ indexFormat: INDEX_FORMAT,
+ }),
).toEqual({ status: 'ready', done: 120, total: 120 });
});
it('toStatusView — 채운 수는 채울 수를 넘지 않는다 (채우는 동안 온 메일 · 위치로 이어 간 겹침) · 시작 전에는 채울 수 null', () => {
expect(
- toStatusView({ status: 'building', buildDone: 130, buildTotal: 120 }),
+ toStatusView({
+ status: 'building',
+ buildDone: 130,
+ buildTotal: 120,
+ indexFormat: INDEX_FORMAT,
+ }),
).toEqual({ status: 'building', done: 120, total: 120 });
expect(
- toStatusView({ status: 'building', buildDone: 0, buildTotal: null }),
+ toStatusView({
+ status: 'building',
+ buildDone: 0,
+ buildTotal: null,
+ indexFormat: INDEX_FORMAT,
+ }),
).toEqual({ status: 'building', done: 0, total: null });
});
+
+ it('toStatusView — 판이 다른 줄은 되돌리기 전이어도 building · 채운 0 (검색 방식과 같은 판정)', () => {
+ expect(
+ toStatusView({
+ status: 'ready',
+ buildDone: 120,
+ buildTotal: 120,
+ indexFormat: INDEX_FORMAT - 1,
+ }),
+ ).toEqual({ status: 'building', done: 0, total: null });
+ });
+
+ describe('색인으로 찾을 수 있나 (isSearchReady)', () => {
+ const ready = {
+ status: 'ready',
+ indexFormat: INDEX_FORMAT,
+ pausedReason: null,
+ };
+
+ it('다 만들었고 · 지금 판이고 · 멈추지 않았을 때만 (소문자 주소로 묻는다)', async () => {
+ findState.mockResolvedValue(ready);
+ await expect(
+ service.isSearchReady(' Ara.Kim@ARASYSTEM.test '),
+ ).resolves.toBe(true);
+ expect(findState).toHaveBeenCalledWith(ARA);
+ });
+
+ it.each([
+ ['줄 없음', null],
+ ['채우는 중', { ...ready, status: 'building' }],
+ ['판이 다름(되돌리기 전)', { ...ready, indexFormat: INDEX_FORMAT - 1 }],
+ ['대리 접근이 막혀 멈춤', { ...ready, pausedReason: 'locked' }],
+ ])('%s 이면 엔진 방식', async (_label, state) => {
+ findState.mockResolvedValue(state);
+ await expect(service.isSearchReady(ARA)).resolves.toBe(false);
+ });
+
+ it('상태를 읽지 못하면(DB 장애) 엔진 방식 — 경고만, 주소 · DB 문장은 가린다', async () => {
+ findState.mockRejectedValue(
+ Object.assign(new Error(`connection to ${ARA} lost`), {
+ code: '57P01',
+ }),
+ );
+ await expect(service.isSearchReady(ARA)).resolves.toBe(false);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain('Error/57P01');
+ expect(warnings[0]).not.toContain(ARA);
+ });
+ });
});
diff --git a/backend/src/modules/mail-search/mail-search-index.service.ts b/backend/src/modules/mail-search/mail-search-index.service.ts
index eca8c5f..9107daf 100644
--- a/backend/src/modules/mail-search/mail-search-index.service.ts
+++ b/backend/src/modules/mail-search/mail-search-index.service.ts
@@ -3,16 +3,24 @@ import { Injectable, Logger } from '@nestjs/common';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSearchIndexRepository } from './mail-search-index.repository';
+import { INDEX_FORMAT } from './mail-search.constants';
import type { MailSearchStateEntity } from './mail-search-state.entity';
import type { SearchIndexStatusView } from './mail-search.types';
/**
* 상태 줄 → 화면의 준비 상태 (GET /api/search-index). 채운 수는 채울 수를 넘지 않게 묶는다 —
- * 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다
+ * 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다.
+ * 판(INDEX_FORMAT)이 다른 줄은 아직 되돌리지 못했어도 building · 채운 0 으로 보인다 — 검색 방식(isSearchReady)과 같은 판정이다
*/
export function toStatusView(
- state: Pick,
+ state: Pick<
+ MailSearchStateEntity,
+ 'status' | 'buildDone' | 'buildTotal' | 'indexFormat'
+ >,
): SearchIndexStatusView {
+ if (state.indexFormat !== INDEX_FORMAT) {
+ return { status: 'building', done: 0, total: null };
+ }
const done = Math.max(0, state.buildDone);
const total =
state.buildTotal === null ? null : Math.max(0, state.buildTotal);
@@ -52,6 +60,27 @@ export class MailSearchIndexService {
}
}
+ /**
+ * 색인으로 찾을 수 있나 (14단계 — 검색 방식 고르기) — 다 만들었고(ready) · 지금 판(INDEX_FORMAT)이고 · 멈추지 않았을 때.
+ * 상태를 읽지 못하면(DB 장애) false — 엔진 방식으로 찾는다(검색을 실패로 만들지 않는다)
+ */
+ async isSearchReady(user: string): Promise {
+ try {
+ const state = await this.repository.findState(normalize(user));
+ return (
+ state !== null &&
+ state.status === 'ready' &&
+ state.indexFormat === INDEX_FORMAT &&
+ state.pausedReason === null
+ );
+ } catch (error) {
+ this.logger.warn(
+ `[SYS_001]${traceTag()} 검색 색인 상태를 읽지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user)}, reason=${errorKind(error)}`,
+ );
+ return false;
+ }
+ }
+
/** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */
async status(user: string): Promise {
return toStatusView(
diff --git a/backend/src/modules/mail-search/mail-search-message.entity.ts b/backend/src/modules/mail-search/mail-search-message.entity.ts
index 43784cd..7325133 100644
--- a/backend/src/modules/mail-search/mail-search-message.entity.ts
+++ b/backend/src/modules/mail-search/mail-search-message.entity.ts
@@ -14,8 +14,11 @@ import {
* 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고
* GIN 으로 후보를 좁힌다.
*
- * GIN 색인 셋(grams · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
- * (migration:generate 가 지우지 않게).
+ * GIN 색인 셋((lower(user_email), grams) · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
+ * (migration:generate 가 지우지 않게). 묶음 GIN 은 직원 주소와 함께다(btree_gin — 14단계 2차 `SearchIndexUserGrams`). 다만 GIN 의 posting 은 칸마다 따로라
+ * 흔한 묶음은 **회사 전체** 줄의 posting 을 읽어 그 직원 주소의 posting 과 맞춘다(그 직원의 줄만 읽는 것이 아니다 — 10명 흉내에서 '계약' 7만여 줄이 34쪽 · 수 ms).
+ * 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. mailbox_ids GIN 도 회사 전체다 — 메일함 id 는 계정마다 같은 꼴(a · b …)이다.
+ * 계획기는 그 메일함 id 가 회사 전체에서도 적을 때만 묶음 GIN 과 함께(BitmapAnd) 쓴다(2026-11-06 10명 흉내 — 큰 메일함은 쓰지 않고 13~20ms, 작은 메일함은 함께 써 4~9ms)
*/
@Entity({ name: SEARCH_MESSAGES_TABLE })
@Index('IDX_mail_search_messages_received', [
@@ -25,7 +28,7 @@ import {
])
@Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId'])
@Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey'])
-@Index('IDX_mail_search_messages_grams', { synchronize: false })
+@Index('IDX_mail_search_messages_user_grams', { synchronize: false })
@Index('IDX_mail_search_messages_mailbox_ids', { synchronize: false })
@Index('IDX_mail_search_messages_refs', { synchronize: false })
export class MailSearchMessageEntity {
diff --git a/backend/src/modules/mail-search/mail-search-query.service.spec.ts b/backend/src/modules/mail-search/mail-search-query.service.spec.ts
new file mode 100644
index 0000000..460276d
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-query.service.spec.ts
@@ -0,0 +1,551 @@
+import type { DataSource } from 'typeorm';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ SEARCH_CANDIDATE_COUNT_LIMIT,
+ SEARCH_CHECK_ROWS_GROWTH,
+ SEARCH_CHECK_ROWS_LIMIT,
+ SEARCH_FIRST_CHECK_ROWS,
+ SEARCH_TOTAL_LIMIT,
+} from './mail-search.constants';
+import { MailSearchQueryService } from './mail-search-query.service';
+import type { SearchCriteria, SearchScope } from './mail-search.types';
+import {
+ decodeIndexCursor,
+ encodeIndexCursor,
+ searchScopeKey,
+} from './search-cursor';
+import { planSearch } from './search-plan';
+
+const USER = 'ara.kim@arasystem.test';
+const SCOPE: SearchScope = {
+ mailboxId: null,
+ excludedMailboxIds: ['b', 'c'],
+ excludedKeywords: ['$ara-sending'],
+};
+
+/** 쿼리의 종류 — 쿼리마다 다른 모양으로 가른다 (search-sql.ts) */
+type QueryKind =
+ | 'candidates'
+ | 'check'
+ | 'keyset'
+ | 'frontier'
+ | 'keysetCount'
+ | 'plainPage'
+ | 'plainCount'
+ | 'highlight';
+
+function kindOf(sql: string): QueryKind {
+ if (sql.includes('lower("user_email")')) return 'candidates';
+ if (sql.includes('"body_text"')) return 'highlight';
+ if (sql.includes('AS "hits"')) return 'keysetCount';
+ if (sql.includes('AS "scanned"')) return 'frontier';
+ if (sql.includes('AS "matches"')) return 'plainCount';
+ if (sql.includes('AS "scan"')) return 'keyset';
+ if (sql.includes('= ANY(')) return 'check';
+ return 'plainPage';
+}
+
+interface Recorded {
+ readonly kind: QueryKind;
+ readonly sql: string;
+ readonly params: unknown[];
+ /** 그 쿼리 앞에 건 계획기 설정 (그 트랜잭션의 것) */
+ readonly settings: string[];
+ /** 몇 번째 트랜잭션의 쿼리인가 (트랜잭션 밖이면 null) · 그 격리 수준 */
+ readonly transaction: number | null;
+ readonly isolation: string | null;
+}
+
+interface Row {
+ readonly email_id: string;
+ readonly received_at: Date;
+}
+
+/** 받은 시각이 최신부터인 줄들 — id 는 m1, m2 … */
+function rows(count: number, from = 0): Row[] {
+ return Array.from({ length: count }, (_, index) => ({
+ email_id: `m${from + index + 1}`,
+ received_at: new Date(
+ Date.UTC(2026, 10, 2, 0, 0, 0) - (from + index) * 60_000,
+ ),
+ }));
+}
+
+/** 확인 쿼리의 id 매개변수 */
+const idsOf = (query: Recorded) => query.params[1] as string[];
+
+function fakeDataSource(
+ answers: Partial unknown[]>>,
+) {
+ const recorded: Recorded[] = [];
+ let transactions = 0;
+ const answer = (
+ sql: string,
+ params: unknown[] | undefined,
+ context: {
+ settings: string[];
+ transaction: number | null;
+ isolation: string | null;
+ },
+ ) => {
+ const kind = kindOf(sql);
+ recorded.push({ kind, sql, params: params ?? [], ...context });
+ const respond = answers[kind];
+ if (!respond) throw new Error(`준비하지 않은 쿼리: ${kind}`);
+ return Promise.resolve(respond(params ?? []));
+ };
+ const dataSource = {
+ transaction: jest.fn(
+ (
+ isolation: string,
+ run: (manager: {
+ query: (sql: string, params?: unknown[]) => Promise;
+ }) => Promise,
+ ) => {
+ transactions += 1;
+ const context = {
+ settings: [] as string[],
+ transaction: transactions,
+ isolation,
+ };
+ return run({
+ query: (sql: string, params?: unknown[]) => {
+ if (sql.startsWith('SET LOCAL')) {
+ context.settings.push(sql);
+ return Promise.resolve([]);
+ }
+ return answer(sql, params, context);
+ },
+ });
+ },
+ ),
+ query: jest.fn((sql: string, params?: unknown[]) =>
+ answer(sql, params, { settings: [], transaction: null, isolation: null }),
+ ),
+ };
+ return {
+ recorded,
+ service: new MailSearchQueryService(dataSource as unknown as DataSource),
+ };
+}
+
+/** 확인 — 준 id 가운데 `matches` 를 만족하는 것만 맞았다고 답한다 */
+const answerChecks =
+ (matches: (id: string) => boolean) =>
+ (params: unknown[]): unknown[] =>
+ (params[1] as string[]).filter(matches).map((email_id) => ({ email_id }));
+
+const numberOf = (id: string) => Number(id.slice(1));
+
+const criteria = (overrides: Partial): SearchCriteria => ({
+ hasAttachment: false,
+ ...overrides,
+});
+
+async function search(
+ service: MailSearchQueryService,
+ given: SearchCriteria,
+ page: { limit: number; cursor?: string } = { limit: 20 },
+ user = USER,
+) {
+ return service.search(user, SCOPE, given, planSearch(given), page);
+}
+
+describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', () => {
+ const bitmapHint = [
+ 'SET LOCAL enable_seqscan = off',
+ 'SET LOCAL enable_indexscan = off',
+ ];
+ const keysetHint = [
+ 'SET LOCAL enable_bitmapscan = off',
+ 'SET LOCAL enable_seqscan = off',
+ ];
+
+ describe('묶음이 있는 찾기 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인', () => {
+ it(`후보는 GIN 의 bitmap 으로만 · 받은 시각 최신부터 확인 상한 + 1(${SEARCH_CHECK_ROWS_LIMIT + 1})줄까지 — 두 글자 낱말뿐이면 후보가 곧 결과(확인하지 않는다)`, async () => {
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(25),
+ });
+
+ const page = await search(service, criteria({ text: '계약' }));
+
+ expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
+ expect(recorded[0].settings).toEqual(bitmapHint);
+ expect(recorded[0].params.at(-1)).toBe(SEARCH_CHECK_ROWS_LIMIT + 1);
+ expect(page.hits.map((hit) => hit.id)).toEqual(
+ rows(20).map((row) => row.email_id),
+ );
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBe(25);
+ // 다음 쪽 커서 — 이 쪽의 마지막 줄 다음부터, 같은 검색에서만
+ const key = searchScopeKey(undefined, criteria({ text: '계약' }));
+ expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
+ receivedAt: rows(20)[19].received_at,
+ emailId: 'm20',
+ });
+ });
+
+ it(`두 글자 낱말뿐인 찾기의 total — ${SEARCH_TOTAL_LIMIT}을 넘거나 후보가 상한 밖까지 있으면 null`, async () => {
+ for (const count of [
+ SEARCH_TOTAL_LIMIT + 1,
+ SEARCH_CHECK_ROWS_LIMIT + 1,
+ ]) {
+ const { service } = fakeDataSource({ candidates: () => rows(count) });
+ const page = await search(service, criteria({ text: '계약 확인' }));
+ expect(page.total).toBeNull();
+ expect(page.hits).toHaveLength(20);
+ expect(page.hasNext).toBe(true);
+ }
+ });
+
+ it(`후보가 ${SEARCH_CANDIDATE_COUNT_LIMIT}개 이하면 한 번에 모두 확인한다 — 첫 쪽의 total 은 맞은 수`, async () => {
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(40),
+ check: answerChecks((id) => numberOf(id) % 4 !== 0),
+ });
+
+ const page = await search(service, criteria({ text: '계약서' }));
+
+ expect(recorded.map((query) => query.kind)).toEqual([
+ 'candidates',
+ 'check',
+ ]);
+ expect(idsOf(recorded[1])).toHaveLength(40);
+ // 확인은 기본 키로 그 줄만 — 계획기 설정 없이
+ expect(recorded[1].settings).toEqual([]);
+ expect(page.hits).toHaveLength(20);
+ expect(page.hits.map((hit) => hit.id)).not.toContain('m4');
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBe(30);
+ });
+
+ it(`후보가 많으면 받은 차례로 ${SEARCH_FIRST_CHECK_ROWS}줄부터 ${SEARCH_CHECK_ROWS_GROWTH}배씩 확인하다 쪽이 차면 멈춘다 — total 은 모른다(null)`, async () => {
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(2_500),
+ check: answerChecks((id) => numberOf(id) % 100 === 0),
+ });
+
+ const page = await search(service, criteria({ text: '"계약 갱신"' }));
+
+ const checks = recorded.filter((query) => query.kind === 'check');
+ expect(checks.map((query) => idsOf(query).length)).toEqual([
+ SEARCH_FIRST_CHECK_ROWS,
+ SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH,
+ SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH ** 2,
+ ]);
+ // 확인은 받은 차례 그대로 이어진다
+ expect(idsOf(checks[1])[0]).toBe('m101');
+ expect(page.hits).toHaveLength(20);
+ expect(page.hits[0].id).toBe('m100');
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBeNull();
+ });
+
+ it('후보가 많아도 상한 안이면(상한 밖 후보 없음) 다 확인하고 끝 — 쪽을 못 채워도 hasNext 없음, 맞은 수를 안다', async () => {
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(2_500),
+ check: answerChecks((id) => ['m7', 'm2400'].includes(id)),
+ });
+
+ const page = await search(service, criteria({ text: '"계약 갱신"' }));
+
+ const checked = recorded
+ .filter((query) => query.kind === 'check')
+ .reduce((sum, query) => sum + idsOf(query).length, 0);
+ expect(checked).toBe(2_500);
+ expect(page).toMatchObject({
+ hasNext: false,
+ nextCursor: null,
+ total: 2,
+ });
+ expect(page.hits.map((hit) => hit.id)).toEqual(['m7', 'm2400']);
+ });
+
+ it('상한 밖에도 후보가 있고 상한까지 확인해도 쪽을 못 채우면 찾은 것까지와 이어 찾기 커서 — 쪽이 비어 있어도 hasNext', async () => {
+ const lastChecked = rows(1, SEARCH_CHECK_ROWS_LIMIT - 1)[0];
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
+ check: answerChecks((id) => id === 'm42'),
+ });
+
+ const page = await search(service, criteria({ text: '확인계약' }));
+
+ const checked = recorded
+ .filter((query) => query.kind === 'check')
+ .flatMap(idsOf);
+ expect(checked).toHaveLength(SEARCH_CHECK_ROWS_LIMIT);
+ expect(checked).not.toContain(`m${SEARCH_CHECK_ROWS_LIMIT + 1}`);
+ expect(page.hits.map((hit) => hit.id)).toEqual(['m42']);
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBeNull();
+ const key = searchScopeKey(undefined, criteria({ text: '확인계약' }));
+ // 이어 찾을 자리는 마지막으로 **확인한** 후보 — 맞은 줄이 아니다
+ expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
+ receivedAt: lastChecked.received_at,
+ emailId: lastChecked.email_id,
+ });
+
+ const empty = fakeDataSource({
+ candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
+ check: () => [],
+ });
+ const emptyPage = await search(
+ empty.service,
+ criteria({ text: '확인계약' }),
+ );
+ expect(emptyPage.hits).toEqual([]);
+ expect(emptyPage.hasNext).toBe(true);
+ expect(emptyPage.nextCursor).not.toBeNull();
+ });
+
+ it('후보가 없으면 확인하지 않는다 — 빈 쪽 · total 0', async () => {
+ const { service, recorded } = fakeDataSource({ candidates: () => [] });
+ await expect(
+ search(service, criteria({ text: '뷁뷁뷁' })),
+ ).resolves.toEqual({
+ hits: [],
+ hasNext: false,
+ nextCursor: null,
+ total: 0,
+ });
+ expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
+ });
+
+ it('직원 주소는 소문자로 맞춰 묻는다 (표의 주소는 늘 소문자)', async () => {
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(1),
+ });
+ await search(
+ service,
+ criteria({ text: '계약' }),
+ { limit: 20 },
+ ' Ara.Kim@ArASystem.test ',
+ );
+ expect(recorded[0].params[0]).toBe(USER);
+ });
+ });
+
+ it('묶음이 없으면(한 글자 낱말뿐) 받은 차례로 확인 상한까지 — 쪽이 차면 total 은 창 안에서 센다', async () => {
+ const { service, recorded } = fakeDataSource({
+ keyset: (params) => rows(Number(params.at(-1))),
+ keysetCount: () => [{ count: 120 }],
+ frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
+ });
+
+ const page = await search(service, criteria({ text: '묵' }));
+
+ expect(recorded.map((query) => query.kind)).toEqual([
+ 'keyset',
+ 'keysetCount',
+ 'frontier',
+ ]);
+ expect(recorded[0].settings).toEqual(keysetHint);
+ expect(recorded[0].sql).not.toContain('"grams"');
+ expect(recorded[0].sql).not.toContain('strpos("fold_body"');
+ expect(recorded[0].params.slice(-3)).toEqual([
+ SEARCH_CHECK_ROWS_LIMIT,
+ '묵',
+ 21,
+ ]);
+ expect(page.hits).toHaveLength(20);
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBe(120);
+ });
+
+ it('한 글자 낱말 — 창을 다 훑지 못하고 쪽을 못 채우면 이어 찾기, 다 훑었으면 끝', async () => {
+ const lastScanned = rows(1, 2_999)[0];
+ const unfinished = fakeDataSource({
+ keyset: () => rows(3),
+ frontier: () => [{ ...lastScanned, scanned: SEARCH_CHECK_ROWS_LIMIT }],
+ });
+ const page = await search(unfinished.service, criteria({ text: '뷁' }));
+ expect(page.hits).toHaveLength(3);
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBeNull();
+ const key = searchScopeKey(undefined, criteria({ text: '뷁' }));
+ expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
+ receivedAt: lastScanned.received_at,
+ emailId: lastScanned.email_id,
+ });
+
+ const done = fakeDataSource({
+ keyset: () => rows(7),
+ frontier: () => [{ ...rows(1, 1_499)[0], scanned: 1_500 }],
+ });
+ expect(await search(done.service, criteria({ text: '뷁' }))).toMatchObject({
+ hasNext: false,
+ nextCursor: null,
+ total: 7,
+ });
+
+ const over = fakeDataSource({
+ keyset: () => rows(21),
+ keysetCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
+ });
+ expect(
+ (await search(over.service, criteria({ text: '팀' }))).total,
+ ).toBeNull();
+ });
+
+ it('낱말 확인이 없으면(기간 · 첨부만) 받은 차례로 바로 — total 은 상한 + 1 까지 세어 넘으면 null', async () => {
+ const { service, recorded } = fakeDataSource({
+ plainPage: (params) => rows(Number(params.at(-1))),
+ plainCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
+ });
+
+ const page = await search(service, criteria({ hasAttachment: true }), {
+ limit: 10,
+ });
+
+ expect(recorded.map((query) => query.kind)).toEqual([
+ 'plainPage',
+ 'plainCount',
+ ]);
+ expect(recorded[0].params.at(-1)).toBe(11);
+ expect(recorded[1].params.at(-1)).toBe(SEARCH_TOTAL_LIMIT + 1);
+ expect(
+ recorded.every((query) => query.settings.join() === keysetHint.join()),
+ ).toBe(true);
+ expect(page.hits).toHaveLength(10);
+ expect(page.hasNext).toBe(true);
+ expect(page.total).toBeNull();
+
+ const few = fakeDataSource({
+ plainPage: () => rows(3),
+ plainCount: () => [{ count: 3 }],
+ });
+ expect(
+ await search(few.service, criteria({ hasAttachment: true })),
+ ).toMatchObject({ hasNext: false, nextCursor: null, total: 3 });
+ });
+
+ it('다음 쪽은 커서의 자리부터 · total 은 늘 null — 다른 검색의 커서는 묻기 전에 VAL_001', async () => {
+ const given = criteria({ text: '계약' });
+ const position = {
+ receivedAt: new Date('2026-11-01T00:00:00Z'),
+ emailId: 'm40',
+ };
+ const cursor = encodeIndexCursor(
+ position,
+ searchScopeKey(undefined, given),
+ );
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(3, 40),
+ });
+
+ const page = await search(service, given, { limit: 20, cursor });
+
+ expect(recorded[0].params).toEqual(
+ expect.arrayContaining([position.receivedAt, 'm40']),
+ );
+ expect(page.total).toBeNull();
+ expect(page.hasNext).toBe(false);
+
+ const other = fakeDataSource({});
+ await expect(
+ search(other.service, criteria({ text: '견적' }), { limit: 20, cursor }),
+ ).rejects.toMatchObject({ code: 'VAL_001' });
+ expect(other.recorded).toEqual([]);
+ });
+
+ it('아무것도 맞을 수 없는 계획이면 묻지 않는다 — 빈 쪽 · total 0', async () => {
+ const { service, recorded } = fakeDataSource({});
+ await expect(search(service, criteria({ text: '…' }))).resolves.toEqual({
+ hits: [],
+ hasNext: false,
+ nextCursor: null,
+ total: 0,
+ });
+ await expect(
+ search(service, criteria({ body: '묵' })),
+ ).resolves.toMatchObject({ hits: [], total: 0 });
+ expect(recorded).toEqual([]);
+ });
+
+ it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에, id 가 없으면 묻지 않는다', async () => {
+ const { service, recorded } = fakeDataSource({
+ highlight: () => [
+ { email_id: 'm1', subject: '계약', body_text: '본문', preview: '미리' },
+ ],
+ });
+
+ await expect(service.highlightTexts(USER, [])).resolves.toEqual(new Map());
+ expect(recorded).toEqual([]);
+
+ const texts = await service.highlightTexts(USER, ['m1', 'm2']);
+ expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]);
+ expect([...texts.entries()]).toEqual([
+ ['m1', { subject: '계약', bodyText: '본문', preview: '미리' }],
+ ]);
+ });
+
+ it('DB 실패는 그대로 던진다 — 부르는 쪽(목록 서비스)이 첫 쪽이면 엔진 방식으로 찾는다', async () => {
+ const { service } = fakeDataSource({
+ candidates: () => {
+ throw new Error('db down');
+ },
+ });
+ const error = await search(service, criteria({ text: '계약' })).catch(
+ (caught: unknown) => caught,
+ );
+ expect(error).toBeInstanceOf(Error);
+ expect(error).not.toBeInstanceOf(BusinessException);
+ });
+
+ it('다음 쪽에서는 후보가 적어도 모두 확인하지 않는다 — 쪽이 찰 때까지만 늘려 가며 (total 이 없다 — 리뷰 L3)', async () => {
+ const given = criteria({ text: '계약서' });
+ const cursor = encodeIndexCursor(
+ { receivedAt: new Date('2026-11-01T00:00:00Z'), emailId: 'm9' },
+ searchScopeKey(undefined, given),
+ );
+ const { service, recorded } = fakeDataSource({
+ candidates: () => rows(1_500, 9),
+ check: answerChecks(() => true),
+ });
+
+ const page = await search(service, given, { limit: 20, cursor });
+
+ const checks = recorded.filter((query) => query.kind === 'check');
+ expect(checks.map((query) => idsOf(query).length)).toEqual([
+ SEARCH_FIRST_CHECK_ROWS,
+ ]);
+ expect(page.hits).toHaveLength(20);
+ expect(page.total).toBeNull();
+ });
+
+ it('한 글자 낱말 길의 맞은 줄 · 창의 끝 · 수는 한 스냅숏(REPEATABLE READ 트랜잭션 하나)에서 — 후보 · 낱말 확인 없는 길도 (리뷰 L4)', async () => {
+ const keyset = fakeDataSource({
+ keyset: (params) => rows(Number(params.at(-1))),
+ keysetCount: () => [{ count: 120 }],
+ frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
+ });
+ await search(keyset.service, criteria({ text: '묵' }));
+ expect(new Set(keyset.recorded.map((query) => query.transaction))).toEqual(
+ new Set([1]),
+ );
+ expect(keyset.recorded.map((query) => query.isolation)).toEqual([
+ 'REPEATABLE READ',
+ 'REPEATABLE READ',
+ 'REPEATABLE READ',
+ ]);
+
+ const unfinished = fakeDataSource({
+ keyset: () => rows(3),
+ frontier: () => [
+ { ...rows(1, 2_999)[0], scanned: SEARCH_CHECK_ROWS_LIMIT },
+ ],
+ });
+ await search(unfinished.service, criteria({ text: '뷁' }));
+ expect(unfinished.recorded.map((query) => query.transaction)).toEqual([
+ 1, 1,
+ ]);
+
+ const plain = fakeDataSource({
+ plainPage: () => rows(3),
+ plainCount: () => [{ count: 3 }],
+ });
+ await search(plain.service, criteria({ hasAttachment: true }));
+ expect(plain.recorded.map((query) => query.transaction)).toEqual([1, 1]);
+ });
+});
diff --git a/backend/src/modules/mail-search/mail-search-query.service.ts b/backend/src/modules/mail-search/mail-search-query.service.ts
new file mode 100644
index 0000000..fc98423
--- /dev/null
+++ b/backend/src/modules/mail-search/mail-search-query.service.ts
@@ -0,0 +1,412 @@
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+import type { DataSource } from 'typeorm';
+
+import {
+ SEARCH_CANDIDATE_COUNT_LIMIT,
+ SEARCH_CHECK_ROWS_GROWTH,
+ SEARCH_CHECK_ROWS_LIMIT,
+ SEARCH_FIRST_CHECK_ROWS,
+ SEARCH_TOTAL_LIMIT,
+} from './mail-search.constants';
+import type { SearchCriteria, SearchScope } from './mail-search.types';
+import {
+ decodeIndexCursor,
+ encodeIndexCursor,
+ searchScopeKey,
+} from './search-cursor';
+import type { SearchPlan } from './search-plan';
+import {
+ candidatesSql,
+ checkHitsSql,
+ highlightTextsSql,
+ keysetCountSql,
+ keysetFrontierSql,
+ keysetHitsSql,
+ plainCountSql,
+ plainPageSql,
+ type SearchPosition,
+ type SqlQuery,
+} from './search-sql';
+
+/** 찾은 메일 하나 — 엔진 id 와 받은 시각 */
+export interface IndexHit {
+ readonly id: string;
+ readonly receivedAt: Date;
+}
+
+/** 색인으로 찾은 한 쪽 */
+export interface IndexSearchPage {
+ readonly hits: readonly IndexHit[];
+ readonly hasNext: boolean;
+ readonly nextCursor: string | null;
+ /** 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null, 다음 쪽은 늘 null */
+ readonly total: number | null;
+}
+
+/** 강조에 쓰는 색인의 글 */
+export interface IndexHighlightText {
+ readonly subject: string;
+ readonly bodyText: string;
+ readonly preview: string;
+}
+
+/** 쿼리의 줄 */
+interface HitRow {
+ readonly email_id: string;
+ readonly received_at: Date;
+}
+
+/** 한 쪽을 찾은 결과 — 커서는 search() 가 만든다 */
+interface Found {
+ readonly hits: IndexHit[];
+ readonly hasNext: boolean;
+ /** 이어 찾을 자리 — hasNext 일 때만 */
+ readonly next: SearchPosition | null;
+ readonly total: number | null;
+}
+
+/** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */
+type PlannerHint =
+ /** 후보 — GIN 의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게) */
+ | 'bitmap'
+ /** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */
+ | 'keyset';
+
+/** 한 스냅숏 안의 쿼리 하나 */
+type Ask = (query: SqlQuery) => Promise;
+
+const PLANNER_SETTINGS: Readonly> = {
+ bitmap: [
+ 'SET LOCAL enable_seqscan = off',
+ 'SET LOCAL enable_indexscan = off',
+ ],
+ keyset: [
+ 'SET LOCAL enable_bitmapscan = off',
+ 'SET LOCAL enable_seqscan = off',
+ ],
+};
+
+function toHit(row: HitRow): IndexHit {
+ return {
+ id: row.email_id,
+ receivedAt:
+ row.received_at instanceof Date
+ ? row.received_at
+ : new Date(row.received_at),
+ };
+}
+
+function positionOf(hit: IndexHit): SearchPosition {
+ return { receivedAt: hit.receivedAt, emailId: hit.id };
+}
+
+/** 첫 쪽의 수 — SEARCH_TOTAL_LIMIT 를 넘으면 null */
+function capTotal(count: number): number | null {
+ return count > SEARCH_TOTAL_LIMIT ? null : count;
+}
+
+/** 맞은 줄(받은 시각 최신부터)에서 한 쪽 — 넘치면 다음 쪽이 있고, 이어 찾을 자리는 이 쪽의 마지막 줄 */
+function pageOf(
+ hits: readonly IndexHit[],
+ limit: number,
+ total: number | null,
+): Found {
+ const page = hits.slice(0, limit);
+ const hasNext = hits.length > limit;
+ return {
+ hits: page,
+ hasNext,
+ next: hasNext ? positionOf(page[page.length - 1]) : null,
+ total,
+ };
+}
+
+/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
+function normalize(user: string): string {
+ return user.trim().toLowerCase();
+}
+
+/**
+ * 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다.
+ *
+ * - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로
+ * - 묶음이 있으면: 후보를 주소 + 묶음(GIN)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
+ * - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과
+ * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다
+ * - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다.
+ * SEARCH_CHECK_ROWS_LIMIT 줄을 확인하고도 쪽을 채우지 못하면 찾은 것까지와 마지막으로 확인한 줄의 커서를 준다(hasNext — 쪽이 비어 있을 수도 있다)
+ * - 묶음이 없으면(한 글자 낱말뿐 — 본문은 훑지 않아 확인이 값싸다): 받은 차례로 SEARCH_CHECK_ROWS_LIMIT 줄까지 확인한다(이어 찾기는 위와 같다)
+ * - total 은 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 — 넘거나 이 요청 안에서 다 세지 못하면 null
+ */
+@Injectable()
+export class MailSearchQueryService {
+ constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
+
+ async search(
+ user: string,
+ scope: SearchScope,
+ criteria: SearchCriteria,
+ plan: SearchPlan,
+ page: { readonly limit: number; readonly cursor?: string },
+ ): Promise {
+ const key = normalize(user);
+ const scopeKey = searchScopeKey(scope.mailboxId ?? undefined, criteria);
+ // 커서는 찾기 전에 본다 — 다른 검색의 커서면 VAL_001
+ const after = page.cursor ? decodeIndexCursor(page.cursor, scopeKey) : null;
+ if (plan.matchesNothing) {
+ return { hits: [], hasNext: false, nextCursor: null, total: 0 };
+ }
+ const firstPage = after === null;
+ const found =
+ plan.checks.length === 0
+ ? await this.plain(key, scope, plan, after, page.limit, firstPage)
+ : plan.grams.length === 0
+ ? await this.keyset(key, scope, plan, after, page.limit, firstPage)
+ : await this.byGrams(key, scope, plan, after, page.limit, firstPage);
+ return {
+ hits: found.hits,
+ hasNext: found.hasNext,
+ nextCursor:
+ found.hasNext && found.next
+ ? encodeIndexCursor(found.next, scopeKey)
+ : null,
+ total: firstPage ? found.total : null,
+ };
+ }
+
+ /** 강조에 쓰는 글 — 찾은 쪽의 줄만 (없는 줄은 빠진다) */
+ async highlightTexts(
+ user: string,
+ ids: readonly string[],
+ ): Promise
-
+
+
+
+
+
- {{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
+ {{ loadingMore ? '불러오는 중…' : '메일 더 보기' }}
+
+
diff --git a/frontend/src/components/mail/MailSearchBox.spec.ts b/frontend/src/components/mail/MailSearchBox.spec.ts
index e8d32ae..0ad4bb8 100644
--- a/frontend/src/components/mail/MailSearchBox.spec.ts
+++ b/frontend/src/components/mail/MailSearchBox.spec.ts
@@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
+import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
+import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailSearchBox from './MailSearchBox.vue'
@@ -51,8 +53,10 @@ describe('MailSearchBox (메일 검색)', () => {
expect(input.attributes('aria-label')).toBe('메일 검색')
expect(input.attributes('placeholder')).toBe('메일 검색')
expect(wrapper.get('form').attributes('role')).toBe('search')
- expect(wrapper.find('button').exists()).toBe(false)
+ expect(wrapper.find('[data-search-clear]').exists()).toBe(false)
expect(wrapper.get('form').classes()).not.toContain('is-active')
+ // [조건]은 늘 있다 (14단계)
+ expect(wrapper.get('[data-search-conditions-toggle]').text()).toBe('조건')
})
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
@@ -123,7 +127,7 @@ describe('MailSearchBox (메일 검색)', () => {
expect((input.element as HTMLInputElement).value).toBe('회의')
expect(wrapper.get('form').classes()).toContain('is-active')
- const clear = wrapper.get('button')
+ const clear = wrapper.get('[data-search-clear]')
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
@@ -165,3 +169,311 @@ describe('MailSearchBox (메일 검색)', () => {
expect(push).not.toHaveBeenCalled()
})
})
+
+describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디자인 원본 Search14)', () => {
+ let pinia: Pinia
+ /** 2026년 11월 6일 오전 9시(브라우저 시간대) — 최근 1달은 10월 6일부터 */
+ const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
+ useMailStore().mailboxes = MAILBOXES
+ installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
+ })
+
+ /** 문서에 붙여 그린 칸 — 테스트마다 떼어 낸다 (열린 패널의 문서 pointerdown 처리가 다음 테스트에 남지 않게) */
+ let mounted: VueWrapper[] = []
+
+ afterEach(() => {
+ for (const wrapper of mounted) wrapper.unmount()
+ mounted = []
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+ useToastStore().clear()
+ restoreApiAdapter()
+ document.body.innerHTML = ''
+ })
+
+ async function mountAt(path: string) {
+ const router = createTestRouter()
+ await router.push(path)
+ await router.isReady()
+ const wrapper = mount(MailSearchBox, {
+ attachTo: document.body,
+ global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
+ })
+ mounted.push(wrapper)
+ return { wrapper, router, input: wrapper.get('input[type="search"]') }
+ }
+
+ async function openPanel(wrapper: VueWrapper) {
+ await wrapper.get('[data-search-conditions-toggle]').trigger('click')
+ await flushPromises()
+ return wrapper.get('[data-search-conditions]')
+ }
+
+ const field = (wrapper: VueWrapper, name: string) => wrapper.get(`[data-search-condition="${name}"]`)
+
+ it('[조건]은 칸 안(✕ 앞)에 있고 누르면 칸 아래에 패널이 열린다 — aria-expanded · 청록 바탕, 첫 칸(보낸 사람)으로 초점', async () => {
+ const { wrapper } = await mountAt('/mail/inbox')
+ const toggle = wrapper.get('[data-search-conditions-toggle]')
+ expect(toggle.attributes('aria-expanded')).toBe('false')
+ expect(toggle.classes()).not.toContain('is-on')
+
+ const panel = await openPanel(wrapper)
+
+ expect(toggle.attributes('aria-expanded')).toBe('true')
+ expect(toggle.attributes('aria-controls')).toBe(panel.attributes('id'))
+ expect(toggle.classes()).toContain('is-on')
+ expect(panel.attributes('role')).toBe('group')
+ expect(panel.get('h2').text()).toBe('조건으로 찾기')
+ expect(panel.findAll('label').map((label) => label.text())).toEqual([
+ '보낸 사람',
+ '받는 사람',
+ '제목',
+ '내용',
+ '기간',
+ '첨부 파일이 있는 메일만',
+ ])
+ expect(panel.findAll('option').map((option) => option.text())).toEqual([
+ '전체 기간',
+ '최근 1주',
+ '최근 1달',
+ '최근 3달',
+ '최근 1년',
+ '날짜 고르기',
+ ])
+ expect(panel.text()).toContain(
+ '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
+ )
+ expect(document.activeElement).toBe(field(wrapper, 'from').element)
+ })
+
+ it('검색 칸이 비어도 조건만으로 찾는다 — 최근 1달은 그 날짜로 바꿔 주소에 싣는다, 패널은 닫히고 검색 칸으로', async () => {
+ const { wrapper, router, input } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+ await field(wrapper, 'from').setValue(' 이도윤 ')
+ await field(wrapper, 'period').setValue('month')
+ await field(wrapper, 'attachment').setValue(true)
+
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe(
+ `/mail/search?in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
+ )
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ expect(document.activeElement).toBe(input.element)
+ })
+
+ it('패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같다 — 검색어와 조건을 함께', async () => {
+ const { wrapper, router, input } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+ await field(wrapper, 'subject').setValue('견적')
+ await input.setValue('계약')
+
+ await input.trigger('keydown', { key: 'Enter' })
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe(
+ `/mail/search?q=${encodeURIComponent('계약')}&in=inbox&subject=${encodeURIComponent('견적')}`,
+ )
+ })
+
+ it('검색어와 조건을 합친 낱말이 8개를 넘으면 찾지 않고 VAL_001 사전 문구 — 패널 · 친 글자는 그대로', async () => {
+ // 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 알림과 떨어뜨린다
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(Date.now() + 60_000)
+ const { wrapper, router, input } = await mountAt('/mail/inbox')
+ await input.setValue('하나 둘 셋 넷 다섯')
+ await openPanel(wrapper)
+ await field(wrapper, 'body').setValue('여섯 일곱 여덟 아홉')
+
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
+ expect((field(wrapper, 'body').element as HTMLInputElement).value).toBe('여섯 일곱 여덟 아홉')
+ })
+
+ it('날짜 고르기의 시작이 끝보다 늦으면 찾지 않고 칸 아래에 알린다 — 끝 날짜 칸으로 초점', async () => {
+ const { wrapper, router } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+ await field(wrapper, 'period').setValue('custom')
+ await field(wrapper, 'since').setValue('2026-11-05')
+ await field(wrapper, 'until').setValue('2026-10-01')
+
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ const error = wrapper.get('[role="alert"]')
+ expect(error.text()).toBe('끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.')
+ expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('true')
+ expect(field(wrapper, 'until').attributes('aria-describedby')).toBe(error.attributes('id'))
+ expect(document.activeElement).toBe(field(wrapper, 'until').element)
+
+ // 고쳐서 찾으면 그 날짜로
+ await field(wrapper, 'until').setValue('2026-11-05')
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-05', until: '2026-11-05' })
+ })
+
+ it('날짜는 1970년 1월 1일 ~ 9999년 12월 30일만 고른다 — 그 밖의 날짜는 찾지 않고 칸 아래에 알린다 (그 칸으로 초점)', async () => {
+ const { wrapper, router } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+ await field(wrapper, 'period').setValue('custom')
+ expect(field(wrapper, 'since').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
+ expect(field(wrapper, 'until').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
+
+ await field(wrapper, 'since').setValue('1969-12-31')
+ await field(wrapper, 'until').setValue('2026-11-05')
+ // 고른 끝 날짜가 시작 날짜 칸의 끝이 된다
+ expect(field(wrapper, 'since').attributes('max')).toBe('2026-11-05')
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ const error = wrapper.get('[role="alert"]')
+ expect(error.text()).toBe('날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.')
+ expect(field(wrapper, 'since').attributes('aria-invalid')).toBe('true')
+ expect(field(wrapper, 'since').attributes('aria-describedby')).toBe(error.attributes('id'))
+ expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('false')
+ expect(document.activeElement).toBe(field(wrapper, 'since').element)
+
+ // 날짜를 고치면 알림을 지우고, 고른 시작 날짜가 끝 날짜 칸의 처음이 된다
+ await field(wrapper, 'since').setValue('2026-11-01')
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
+ expect(field(wrapper, 'until').attributes('min')).toBe('2026-11-01')
+ await wrapper.get('[data-search-conditions-apply]').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-01', until: '2026-11-05' })
+ })
+
+ it('미리 정한 기간에서 날짜 고르기로 바꾸면 그 기간의 날짜로 칸을 채워 둔다', async () => {
+ const { wrapper } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+
+ await field(wrapper, 'period').setValue('week')
+ await field(wrapper, 'period').setValue('custom')
+
+ expect((field(wrapper, 'since').element as HTMLInputElement).value).toBe('2026-10-30')
+ expect((field(wrapper, 'until').element as HTMLInputElement).value).toBe('2026-11-06')
+ })
+
+ it('Esc 는 패널만 닫고 [조건]으로 초점을 돌린다 — 바깥을 누르면 닫는다', async () => {
+ const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
+ await openPanel(wrapper)
+
+ await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
+ await flushPromises()
+
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ expect(document.activeElement).toBe(wrapper.get('[data-search-conditions-toggle]').element)
+ // 검색은 그대로
+ expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
+
+ await openPanel(wrapper)
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
+ await flushPromises()
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ })
+
+ it('패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다 — 검색은 그대로', async () => {
+ const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
+ await openPanel(wrapper)
+ input.element.focus()
+ await flushPromises()
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
+
+ await input.trigger('keydown', { key: 'Escape' })
+ await flushPromises()
+
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ expect(document.activeElement).toBe(input.element)
+ expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
+ })
+
+ it('키보드로 검색 칸 밖으로 나가면(focusout) 패널을 닫는다 — 검색 칸 안으로 옮기는 초점은 그대로', async () => {
+ const { wrapper, input } = await mountAt('/mail/inbox')
+ const outside = document.createElement('button')
+ document.body.append(outside)
+ await openPanel(wrapper)
+ const from = field(wrapper, 'from').element
+
+ from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: input.element }))
+ await flushPromises()
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
+
+ from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }))
+ await flushPromises()
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ // 나간 곳의 초점을 [조건]으로 끌어오지 않는다
+ expect(document.activeElement).not.toBe(wrapper.get('[data-search-conditions-toggle]').element)
+ })
+
+ it('다른 화면으로 가면(뒤로 가기 등) 열어 둔 패널을 닫는다', async () => {
+ const { wrapper, router } = await mountAt('/mail/inbox')
+ await openPanel(wrapper)
+
+ await router.push('/mail/trash')
+ await flushPromises()
+
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+ expect(wrapper.get('[data-search-conditions-toggle]').attributes('aria-expanded')).toBe('false')
+ })
+
+ it('조건이 걸린 검색 — [조건]이 청록이고, 열면 걸린 조건으로 칸을 채운다(오늘 기준 최근 1달)', async () => {
+ const { wrapper } = await mountAt(
+ `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
+ )
+ const toggle = wrapper.get('[data-search-conditions-toggle]')
+ expect(toggle.classes()).toContain('is-on')
+ expect(toggle.attributes('aria-expanded')).toBe('false')
+ expect(toggle.text()).toContain('조건이 걸려 있음')
+
+ await openPanel(wrapper)
+
+ expect((field(wrapper, 'from').element as HTMLInputElement).value).toBe('이도윤')
+ expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('month')
+ expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
+ })
+
+ it('패널을 닫은 채 누른 Enter 는 걸린 조건을 그대로 두고 검색어만 바꾼다 (기록을 쌓지 않는다)', async () => {
+ const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all&from=${encodeURIComponent('이도윤')}`)
+ const push = vi.spyOn(router, 'push')
+
+ await input.setValue('견적')
+ await input.trigger('keydown', { key: 'Enter' })
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe(
+ `/mail/search?q=${encodeURIComponent('견적')}&in=all&from=${encodeURIComponent('이도윤')}`,
+ )
+ expect(push).not.toHaveBeenCalled()
+ })
+
+ it('패널의 [조건 지우기] — 걸린 조건을 빼고 다시 찾는다. 검색어도 없으면 검색을 닫는다', async () => {
+ useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
+ const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
+ await openPanel(wrapper)
+
+ await wrapper.get('[data-search-conditions-clear]').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
+ expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
+
+ await router.replace('/mail/search?in=inbox&attachment=1')
+ await flushPromises()
+ await openPanel(wrapper)
+ await wrapper.get('[data-search-conditions-clear]').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ })
+})
diff --git a/frontend/src/components/mail/MailSearchBox.vue b/frontend/src/components/mail/MailSearchBox.vue
index 9e19d69..ad1ead2 100644
--- a/frontend/src/components/mail/MailSearchBox.vue
+++ b/frontend/src/components/mail/MailSearchBox.vue
@@ -1,28 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.attachment }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditionLine.vue b/frontend/src/components/mail/MailSearchConditionLine.vue
new file mode 100644
index 0000000..a29ab51
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditionLine.vue
@@ -0,0 +1,76 @@
+
+
+
+
+ {{ summary }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditions.vue b/frontend/src/components/mail/MailSearchConditions.vue
new file mode 100644
index 0000000..5a39ba4
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditions.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.title }}
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.hint }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditionsToggle.vue b/frontend/src/components/mail/MailSearchConditionsToggle.vue
new file mode 100644
index 0000000..8e8dba1
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditionsToggle.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchEmpty.vue b/frontend/src/components/mail/MailSearchEmpty.vue
index 9049fb4..b70ee5c 100644
--- a/frontend/src/components/mail/MailSearchEmpty.vue
+++ b/frontend/src/components/mail/MailSearchEmpty.vue
@@ -1,30 +1,60 @@
+
+ {{ MAIL_SEARCH_TEXT.wordLimitHint }}
+