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> { + if (ids.length === 0) return new Map(); + const query = highlightTextsSql(normalize(user), ids); + const rows = await this.dataSource.query< + { + email_id: string; + subject: string; + body_text: string; + preview: string; + }[] + >(query.sql, query.params); + return new Map( + rows.map((row) => [ + row.email_id, + { + subject: row.subject, + bodyText: row.body_text, + preview: row.preview, + }, + ]), + ); + } + + /** 낱말 확인 없이 — 받은 차례로 (첫 쪽은 SEARCH_TOTAL_LIMIT + 1 까지 센다 — 쪽과 수는 같은 스냅숏에서) */ + private plain( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + plainPageSql(user, scope, plan, after, limit + 1), + ); + let total: number | null = null; + if (firstPage) { + const counted = await ask<{ count: number }[]>( + plainCountSql(user, scope, plan, SEARCH_TOTAL_LIMIT + 1), + ); + total = capTotal(Number(counted[0]?.count ?? 0)); + } + return pageOf(rows.map(toHit), limit, total); + }); + } + + /** 묶음이 있다 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인한다 */ + private async byGrams( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + const candidates = ( + await this.snapshot('bitmap', (ask) => + ask( + candidatesSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT + 1), + ), + ) + ).map(toHit); + // 확인 상한 밖에도 후보가 있다 — 이 요청은 상한까지만 본다 + const more = candidates.length > SEARCH_CHECK_ROWS_LIMIT; + const window = more + ? candidates.slice(0, SEARCH_CHECK_ROWS_LIMIT) + : candidates; + + // 두 글자 낱말뿐 — 후보가 곧 결과다 + if (plan.exactByGrams) { + return pageOf(candidates, limit, more ? null : capTotal(window.length)); + } + + // 첫 쪽이고 후보가 적다 — 모두 확인해 total 을 안다 (다음 쪽은 total 이 없으니 쪽이 찰 때까지만 확인한다) + if (firstPage && !more && window.length <= SEARCH_CANDIDATE_COUNT_LIMIT) { + const matched = await this.check(user, plan, window); + const hits = window.filter((hit) => matched.has(hit.id)); + return pageOf(hits, limit, capTotal(hits.length)); + } + + // 후보가 많다 — 받은 차례로 조금씩 확인하다 쪽이 차면 멈춘다 + const hits: IndexHit[] = []; + let checked = 0; + let size = Math.max(SEARCH_FIRST_CHECK_ROWS, (limit + 1) * 2); + while (hits.length <= limit && checked < window.length) { + const slice = window.slice(checked, checked + size); + const matched = await this.check(user, plan, slice); + for (const hit of slice) if (matched.has(hit.id)) hits.push(hit); + checked += slice.length; + size *= SEARCH_CHECK_ROWS_GROWTH; + } + if (hits.length > limit) return pageOf(hits, limit, null); + // 후보를 다 확인했다 — 끝이고 맞은 수를 안다 + if (!more) { + return { hits, hasNext: false, next: null, total: capTotal(hits.length) }; + } + // 확인 상한에 닿았다 — 마지막으로 확인한 후보 다음부터 이어 찾는다 + return { + hits, + hasNext: true, + next: positionOf(window[window.length - 1]), + total: null, + }; + } + + /** 후보 가운데 낱말이 모두 맞은 줄의 id */ + private async check( + user: string, + plan: SearchPlan, + candidates: readonly IndexHit[], + ): Promise> { + if (candidates.length === 0) return new Set(); + const query = checkHitsSql( + user, + plan, + candidates.map((hit) => hit.id), + ); + const rows = await this.dataSource.query<{ email_id: string }[]>( + query.sql, + query.params, + ); + return new Set(rows.map((row) => row.email_id)); + } + + /** + * 묶음이 없다(한 글자 낱말뿐) — 받은 차례로 확인 상한까지 (확인하는 칸이 작아 값싸다). + * 맞은 줄 · 창의 끝 · 첫 쪽의 수는 **같은 스냅숏**에서 읽는다 — 그사이 온 메일로 창이 밀리면 이어 찾을 자리가 어긋나 같은 메일이 다음 쪽에 또 나왔다 + */ + private keyset( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + keysetHitsSql( + user, + scope, + plan, + after, + SEARCH_CHECK_ROWS_LIMIT, + limit + 1, + ), + ); + if (rows.length > limit) { + return pageOf( + rows.map(toHit), + limit, + firstPage ? await this.keysetTotal(ask, user, scope, plan) : null, + ); + } + // 쪽을 채우지 못했다 — 확인한 창을 다 훑었나(그러면 끝), 아니면 그 창의 끝부터 이어 찾는다 + const hits = rows.map(toHit); + const frontier = await this.frontier(ask, user, scope, plan, after); + if (frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT) { + return { + hits, + hasNext: false, + next: null, + total: capTotal(hits.length), + }; + } + return { hits, hasNext: true, next: frontier.last, total: null }; + }); + } + + /** 받은 차례로 훑는 창의 끝 — 그 창의 마지막 줄 · 줄 수 (창이 비었으면 null) */ + private async frontier( + ask: Ask, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + ): Promise<{ last: SearchPosition; scanned: number } | null> { + const rows = await ask<(HitRow & { scanned: number })[]>( + keysetFrontierSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT), + ); + const row = rows[0]; + if (!row) return null; + return { + last: positionOf(toHit(row)), + scanned: Number(row.scanned), + }; + } + + /** 받은 차례 길의 첫 쪽 total — 창 안에서 SEARCH_TOTAL_LIMIT + 1 까지 센다. 넘거나 창을 다 훑지 못했으면 null */ + private async keysetTotal( + ask: Ask, + user: string, + scope: SearchScope, + plan: SearchPlan, + ): Promise { + const counted = await ask<{ count: number }[]>( + keysetCountSql( + user, + scope, + plan, + SEARCH_CHECK_ROWS_LIMIT, + SEARCH_TOTAL_LIMIT + 1, + ), + ); + const count = Number(counted[0]?.count ?? 0); + if (count > SEARCH_TOTAL_LIMIT) return null; + const frontier = await this.frontier(ask, user, scope, plan, null); + return frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT + ? count + : null; + } + + /** + * 한 스냅숏(REPEATABLE READ — 읽기만 한다)에서 계획기 설정을 건 뒤 쿼리들을 묻는다 (SET LOCAL — 트랜잭션이 끝나면 돌아온다). + * 스냅숏은 설정 다음의 첫 쿼리에서 잡힌다 + */ + private snapshot( + hint: PlannerHint, + work: (ask: Ask) => Promise, + ): Promise { + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + for (const setting of PLANNER_SETTINGS[hint]) { + await manager.query(setting); + } + return work((query: SqlQuery) => + manager.query(query.sql, query.params), + ); + }); + } +} diff --git a/backend/src/modules/mail-search/mail-search-state.entity.ts b/backend/src/modules/mail-search/mail-search-state.entity.ts index f97c2f1..46e43f2 100644 --- a/backend/src/modules/mail-search/mail-search-state.entity.ts +++ b/backend/src/modules/mail-search/mail-search-state.entity.ts @@ -71,6 +71,13 @@ export class MailSearchStateEntity { }) pausedReason!: SearchPausedReason | null; + /** + * 이 줄의 색인을 만든 판 (mail-search.constants.ts 의 INDEX_FORMAT) — 다르면 building 으로 돌려 다시 채운다(그동안 검색은 엔진 방식). + * 판을 적기 전에 만든 줄은 1 + */ + @Column({ name: 'index_format', type: 'smallint', default: 1 }) + indexFormat!: number; + /** 이어진 실패 수 — 성공하면 0. 많을수록 차례에서 뒤로 미룬다 */ @Column({ name: 'failure_count', type: 'integer', default: 0 }) failureCount!: number; diff --git a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts index 9cdcb94..8431f07 100644 --- a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts +++ b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts @@ -28,6 +28,7 @@ import { BUILD_BATCH_SIZE, BUILD_RUN_BUDGET_MS, INDEX_CALL_MIN_MS, + INDEX_FORMAT, STEP_MIN_REMAINING_MS, SYNC_CONCURRENCY, SYNC_DEBOUNCE_MS, @@ -260,6 +261,38 @@ class FakeRepository { readonly states = new Map(); readonly rows = new Map(); readonly writes: SearchIndexWrite[] = []; + /** 판 되돌리기 — 부른 수 · 앞으로 실패할 수(DB 장애 흉내) */ + resetCalls = 0; + failResets = 0; + + /** 실제 SQL(mail-search-index.repository.ts)과 같은 판정 — 판이 다르고 주소가 그 도메인으로 끝나는 줄만 */ + resetOutdatedFormats(suffix: string, format: number, now: Date) { + this.resetCalls += 1; + if (this.failResets > 0) { + this.failResets -= 1; + return Promise.reject(new Error('db down')); + } + let count = 0; + for (const state of this.states.values()) { + if (state.indexFormat === format || !state.userEmail.endsWith(suffix)) { + continue; + } + Object.assign(state, { + status: 'building', + emailState: null, + buildAnchor: null, + buildDone: 0, + buildTotal: null, + buildStartedAt: null, + readyAt: null, + failureCount: 0, + indexFormat: format, + updatedAt: now, + }); + count += 1; + } + return Promise.resolve(count); + } addState( overrides: Partial = {}, @@ -276,6 +309,7 @@ class FakeRepository { lastSyncedAt: null, pausedReason: null, failureCount: 0, + indexFormat: INDEX_FORMAT, createdAt: new Date(0), updatedAt: new Date(0), ...overrides, @@ -336,6 +370,7 @@ class FakeRepository { return Promise.resolve({ written: false, updatedIds: new Set() }); } this.writes.push(change); + if (change.clearIndex) this.rows.clear(); for (const id of change.deletes ?? []) this.rows.delete(id); for (const row of change.upserts ?? []) this.rows.set(row.emailId, row); const updatedIds = new Set(); @@ -477,6 +512,83 @@ describe('검색 색인 맞추기 · 채우기 (MailSearchSyncService · MailSea ); }); + it('판을 되돌리면 채우기의 첫 묶음이 옛 판 줄을 모두 지운다 — 위치로 건너뛴 메일도 마지막 대조가 새로 넣어 옛 판 줄이 남지 않는다 (리뷰 M1)', async () => { + engine.seed(120); + repository.addState(); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + // 옛 판(다른 접기 규칙)으로 만든 줄처럼 꾸민다 + for (const [id, row] of repository.rows) { + repository.rows.set(id, { ...row, foldBody: 'old-format' }); + } + repository.states.get(ARA)!.indexFormat = INDEX_FORMAT - 1; + + await build.onApplicationBootstrap(); + expect(repository.states.get(ARA)).toMatchObject({ + status: 'building', + buildStartedAt: null, + emailState: null, + indexFormat: INDEX_FORMAT, + }); + + // 시작 + 첫 묶음(old119 ~ old70) → 그 anchor 를 지워 위치로 건너뛰게(old69 를 건너뛴다) → 끝까지 + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + expect(repository.writes.some((write) => write.clearIndex)).toBe(true); + expect(repository.states.get(ARA)?.buildAnchor).toBe('old70'); + engine.remove('old70'); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + + const old = [...repository.rows.values()].filter( + (row) => row.foldBody === 'old-format', + ); + expect(old).toEqual([]); + expect([...repository.rows.keys()].sort()).toEqual( + engine.newestFirst().sort(), + ); + // 처음부터 시작할 때만 지운다 — 이어 하는 묶음은 지우지 않는다 + expect( + repository.writes.filter((write) => write.clearIndex), + ).toHaveLength(2); + }); + + it('서버가 뜰 때 판 되돌리기가 실패하면 1분 차례가 다시 해 본다(낮에도) — 한 번 되면 다시 하지 않고, 사내 도메인 줄만 (리뷰 L5)', async () => { + const state = repository.addState({ + status: 'ready', + emailState: 's', + buildStartedAt: new Date(0), + indexFormat: INDEX_FORMAT - 1, + }); + const other = repository.addState({ + userEmail: 'search.index@e2e.arasystem.test', + status: 'ready', + indexFormat: INDEX_FORMAT - 1, + }); + repository.failResets = 1; + + await build.onApplicationBootstrap(); + expect(state.status).toBe('ready'); + expect(logs.some((line) => line.includes('되돌리지 못했습니다'))).toBe( + true, + ); + + clock = Date.parse('2026-11-05T12:00:00+09:00'); + expect(await build.tick()).toBeNull(); + expect(state).toMatchObject({ + status: 'building', + buildStartedAt: null, + indexFormat: INDEX_FORMAT, + }); + // 다른 도메인 줄은 그쪽 서버의 몫 + expect(other).toMatchObject({ + status: 'ready', + indexFormat: INDEX_FORMAT - 1, + }); + expect(repository.resetCalls).toBe(2); + await build.tick(); + expect(repository.resetCalls).toBe(2); + // 낮이라 채우지는 않는다 + expect(engine.calls).toEqual([]); + }); + it('채우는 중 엔진이 변화를 셀 수 없으면 대조하되 빠진 메일은 넣지 않는다(채우기가 넣는다) — 지운 것 · 옮긴 것은 맞춘다', async () => { engine.seed(BUILD_BATCH_SIZE * 2); repository.addState(); diff --git a/backend/src/modules/mail-search/mail-search-sync.service.ts b/backend/src/modules/mail-search/mail-search-sync.service.ts index 15f5160..bae36c8 100644 --- a/backend/src/modules/mail-search/mail-search-sync.service.ts +++ b/backend/src/modules/mail-search/mail-search-sync.service.ts @@ -152,6 +152,11 @@ export class MailSearchSyncService implements BeforeApplicationShutdown { this.addressSuffix = domain ? `@${domain}` : null; } + /** 색인하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null) — 판이 바뀐 줄 되돌리기가 쓴다 */ + get indexedAddressSuffix(): string | null { + return this.addressSuffix; + } + /** 색인하는 주소인가 — 사내 도메인(MAIL_DOMAIN)의 주소만 (5분 맞추기 · 밤 채우기의 차례가 본다) */ isIndexedAddress(user: string): boolean { return ( diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts index b95a2de..68ea781 100644 --- a/backend/src/modules/mail-search/mail-search.constants.ts +++ b/backend/src/modules/mail-search/mail-search.constants.ts @@ -105,3 +105,37 @@ export const BUILD_WINDOWS_KST: readonly (readonly [ /** 실행 시각 표의 시간대 — 컨테이너는 UTC 로 돈다 */ export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul'; + +/** + * 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format). + * 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다. + * 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게) + */ +export const INDEX_FORMAT = 2; + +/** + * 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다: + * - 후보: 주소 + 묶음(GIN — 마이그레이션 SearchIndexUserGrams 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다 + * (줄의 글을 풀지 않는다 — 1만 통에 10ms 안팎) + * - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다 + * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 + * SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초). + * 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext) + * - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null + */ +export const SEARCH_CANDIDATE_COUNT_LIMIT = 2_000; +export const SEARCH_CHECK_ROWS_LIMIT = 3_000; +export const SEARCH_FIRST_CHECK_ROWS = 100; +export const SEARCH_CHECK_ROWS_GROWTH = 4; +export const SEARCH_TOTAL_LIMIT = 1_000; + +/** + * 한 글자 낱말(글자 두 개 묶음이 없는 낱말)만으로 찾을 때 본문은 훑지 않는다 — 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾는다. + * 두 글자 넘는 낱말과 함께면 한 글자 낱말은 확인에만 쓴다(본문 포함). false 로 두면 한 글자 낱말만이어도 본문까지 훑는다(확인 상한 안에서) + */ +export const SINGLE_CHAR_TERMS_SKIP_BODY = true; + +/** 목록의 미리 보기 둘레 — 맞은 말 앞 글자 수 · 모두 글자 수(남짓 — 말 경계에서 자른다) · 말 경계를 찾는 여유 (UTF-16 — 서로게이트 쌍은 가르지 않는다) */ +export const SNIPPET_LEAD_CHARS = 20; +export const SNIPPET_TOTAL_CHARS = 80; +export const SNIPPET_BOUNDARY_SLACK = 10; diff --git a/backend/src/modules/mail-search/mail-search.module.ts b/backend/src/modules/mail-search/mail-search.module.ts index e6a0e7a..5487d08 100644 --- a/backend/src/modules/mail-search/mail-search.module.ts +++ b/backend/src/modules/mail-search/mail-search.module.ts @@ -7,6 +7,7 @@ import { MAIL_INDEX_SOURCE } from './mail-index-source'; import { MailSearchBuildService } from './mail-search-build.service'; import { MailSearchIndexRepository } from './mail-search-index.repository'; import { MailSearchIndexService } from './mail-search-index.service'; +import { MailSearchQueryService } from './mail-search-query.service'; import { MailSearchMessageEntity } from './mail-search-message.entity'; import { MailSearchRuns } from './mail-search-runs'; import { MailSearchStateEntity } from './mail-search-state.entity'; @@ -34,7 +35,12 @@ import { SearchIndexController } from './search-index.controller'; MailSearchSyncService, MailSearchBuildService, MailSearchIndexService, + MailSearchQueryService, + ], + exports: [ + MailSearchIndexService, + MailSearchSyncService, + MailSearchQueryService, ], - exports: [MailSearchIndexService, MailSearchSyncService], }) export class MailSearchModule {} diff --git a/backend/src/modules/mail-search/mail-search.types.ts b/backend/src/modules/mail-search/mail-search.types.ts index 3bd5702..2cceb08 100644 --- a/backend/src/modules/mail-search/mail-search.types.ts +++ b/backend/src/modules/mail-search/mail-search.types.ts @@ -20,3 +20,51 @@ export interface SearchIndexStatusView { /** 채울 메일 수(채우기를 시작할 때의 엔진 메일 수) — 아직 시작하지 않았으면 null */ readonly total: number | null; } + +/** + * 검색 조건 (GET /api/messages — 14단계 설계 C1) — DTO 가 검사한 값. 글 칸은 앞뒤 공백을 뺀 값이고 빈 글은 없는 것이다. + * after · before 는 ISO 8601 시각 — 받은 시각이 after 이상 · before 미만 + */ +export interface SearchCriteria { + /** 검색어(q) — 보낸 사람 · 받는 사람 · 참조 · 제목 · 첨부 이름 · 본문 */ + readonly text?: string; + readonly from?: string; + /** 받는 사람 — 참조도 */ + readonly to?: string; + readonly subject?: string; + readonly body?: string; + readonly after?: string; + readonly before?: string; + /** true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정) */ + readonly hasAttachment?: boolean; +} + +/** 검색어 밖의 조건 — 엔진 방식 검색이 JMAP 필터로 쓴다 */ +export type SearchConditions = Omit; + +/** + * 찾는 범위 (엔진이 알려 준다 — 메일 포트의 searchScope): 메일함 하나, 또는 빼는 메일함(휴지통 · 스팸메일함)에만 있지 않은 메일. + * 목록에서 빼는 키워드(발송 중인 내부 초안)가 있는 메일은 찾지 않는다 + */ +export interface SearchScope { + readonly mailboxId: string | null; + readonly excludedMailboxIds: readonly string[]; + readonly excludedKeywords: readonly string[]; +} + +/** 맞은 곳 — [시작, 끝) UTF-16 */ +export type SearchRange = readonly [start: number, end: number]; + +/** 목록 항목의 강조 (색인으로 찾았을 때만 — 엔진 방식이면 null) */ +export interface MessageListHighlight { + readonly subject: SearchRange[]; + /** 본문에서 맞았으면 맞은 말 둘레, 아니면 엔진의 미리 보기 */ + readonly preview: { readonly text: string; readonly ranges: SearchRange[] }; +} + +/** 읽기 화면의 강조 — 첨부는 맞은 것만(첨부 id → 맞은 곳) */ +export interface MessageDetailHighlight { + readonly subject: SearchRange[]; + readonly attachments: Record; + readonly textBody: SearchRange[]; +} diff --git a/backend/src/modules/mail-search/search-cursor.spec.ts b/backend/src/modules/mail-search/search-cursor.spec.ts new file mode 100644 index 0000000..ab9f27b --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.spec.ts @@ -0,0 +1,137 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor'; +import type { SearchCriteria } from './mail-search.types'; +import { + cursorMethod, + decodeIndexCursor, + encodeIndexCursor, + searchScopeKey, +} from './search-cursor'; + +const criteria = (overrides: Partial): SearchCriteria => ({ + hasAttachment: false, + ...overrides, +}); + +function codeOf(run: () => unknown): string | null { + try { + run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('색인 검색의 커서 (search-cursor — 14단계 설계 C1)', () => { + const position = { + receivedAt: new Date('2026-11-02T01:23:45.678Z'), + emailId: 'm9x', + }; + const key = searchScopeKey(undefined, criteria({ text: '계약' })); + + it('자리(받은 시각 ms · id)를 그대로 되돌린다 — URL 에 그대로 넣을 수 있는 글자만', () => { + const cursor = encodeIndexCursor(position, key); + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeIndexCursor(cursor, key)).toEqual(position); + }); + + it('방식을 커서로 안다 — 색인 커서는 index, 엔진 커서(1판) · 알 수 없는 값은 engine(엔진 쪽이 검사한다)', () => { + expect(cursorMethod(encodeIndexCursor(position, key))).toBe('index'); + expect( + cursorMethod( + encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }), + ), + ).toBe('engine'); + for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600), 'bnVsbA']) { + expect(cursorMethod(cursor)).toBe('engine'); + } + }); + + it('범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 하나라도 다르면 다른 검색의 커서라 VAL_001', () => { + const base = criteria({ + text: '계약', + from: '이도윤', + after: '2026-10-31T15:00:00.000Z', + }); + const cursor = encodeIndexCursor(position, searchScopeKey('a', base)); + + expect(decodeIndexCursor(cursor, searchScopeKey('a', base))).toEqual( + position, + ); + for (const [mailboxId, other] of [ + [undefined, base], + ['b', base], + ['a', { ...base, text: '견적' }], + ['a', { ...base, from: undefined }], + ['a', { ...base, to: '이도윤' }], + ['a', { ...base, subject: '계약' }], + ['a', { ...base, body: '계약' }], + ['a', { ...base, after: '2026-10-30T15:00:00.000Z' }], + ['a', { ...base, before: '2026-11-30T15:00:00.000Z' }], + ['a', { ...base, hasAttachment: true }], + ] as const) { + expect( + codeOf(() => + decodeIndexCursor(cursor, searchScopeKey(mailboxId, other)), + ), + ).toBe('VAL_001'); + } + }); + + it('칸이 달라도 이은 글이 같아지지 않는다 — 같은 글을 다른 칸에 둔 검색은 다른 범위', () => { + expect(searchScopeKey(undefined, criteria({ from: '계약' }))).not.toBe( + searchScopeKey(undefined, criteria({ to: '계약' })), + ); + expect(searchScopeKey('a', criteria({}))).not.toBe( + searchScopeKey(undefined, criteria({})), + ); + }); + + it('모양이 틀린 커서는 VAL_001 — 엔진 커서 · 틀린 판 · 이상한 id · 음수 시각 · 너무 긴 값', () => { + const forge = (payload: object) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + for (const cursor of [ + encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }), + forge({ v: 3, m: 'i', t: 1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'e', t: 1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: -1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: 1.5, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: 1, id: '../x', s: 'x' }), + forge({ v: 2, m: 'i', t: 1, id: 'm1' }), + 'not-json', + 'a'.repeat(600), + ]) { + expect(codeOf(() => decodeIndexCursor(cursor, key))).toBe('VAL_001'); + } + }); + + it('Date 로 읽을 수 없는 시각(안전한 정수지만 ±8.64e15 ms 밖)의 커서는 VAL_001 — 범위 지문이 맞아도 500 이 아니다 (리뷰 L1)', () => { + // 이 검색의 진짜 커서에서 시각만 바꾼다 — 범위 지문(s)은 맞는다 + const real = JSON.parse( + Buffer.from(encodeIndexCursor(position, key), 'base64url').toString( + 'utf8', + ), + ) as Record; + const withTime = (t: number) => + Buffer.from(JSON.stringify({ ...real, t })).toString('base64url'); + expect(Number.isSafeInteger(9e15)).toBe(true); + expect(codeOf(() => decodeIndexCursor(withTime(9e15), key))).toBe( + 'VAL_001', + ); + // 한도 끝(8.64e15)까지는 날짜다 — 그대로 읽는다 + expect(decodeIndexCursor(withTime(8.64e15), key)).toEqual({ + receivedAt: new Date(8.64e15), + emailId: position.emailId, + }); + }); + + it('범위 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸을 흉내 내도 다른 범위 (리뷰 L7)', () => { + const NL = String.fromCharCode(10); + expect(searchScopeKey(undefined, criteria({ from: `a${NL}b` }))).not.toBe( + searchScopeKey(undefined, criteria({ from: 'a', to: 'b' })), + ); + expect(searchScopeKey(undefined, criteria({ text: `계약${NL}` }))).not.toBe( + searchScopeKey(undefined, criteria({ text: '계약', from: '' })), + ); + }); +}); diff --git a/backend/src/modules/mail-search/search-cursor.ts b/backend/src/modules/mail-search/search-cursor.ts new file mode 100644 index 0000000..6d796b7 --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.ts @@ -0,0 +1,122 @@ +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants'; +import type { SearchCriteria } from './mail-search.types'; +import type { SearchPosition } from './search-sql'; + +/** + * 색인으로 찾은 검색의 커서 (14단계 설계 C1 — 클라이언트에게는 불투명 문자열). + * `{ v: 2, m: 'i', t: 받은 시각(ms), id, s: 범위 지문 }` — 그 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 이어 찾는다. + * 다 찾지 못하고 확인 상한에 닿은 쪽(이어 찾기)도 같은 꼴이다 — 마지막으로 **훑은** 줄이 자리다. + * + * 방식(m)이 커서에 실려, 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 그 검색은 처음 방식대로 이어 간다 — + * 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로, 이 커서는 색인으로. + * 범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 다른 검색의 커서를 섞어 쓰면 VAL_001. 지문은 짧은 해시라 글을 담지 않는다 + */ +const CURSOR_VERSION = 2; +const INDEX_METHOD = 'i'; +const MAX_CURSOR_LENGTH = 512; +const SCOPE_HASH_LENGTH = 16; +/** "모든 메일함"(휴지통 · 스팸메일함 제외) 범위 — 메일함 id 형식에 없는 글자 */ +const ALL_MAILBOXES_SCOPE = '*'; + +interface EncodedIndexCursor { + v: number; + m: string; + t: number; + id: string; + s: string; +} + +/** 검색 방식 — 커서를 읽어 본다. 색인 커서가 아니면(엔진 방식 · 모양이 틀림) 'engine' — 엔진 쪽이 검사한다 */ +export function cursorMethod(cursor: string): 'index' | 'engine' { + if (cursor.length > MAX_CURSOR_LENGTH) return 'engine'; + try { + const parsed = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as Partial | null; + return parsed?.v === CURSOR_VERSION && parsed.m === INDEX_METHOD + ? 'index' + : 'engine'; + } catch { + return 'engine'; + } +} + +/** + * 범위 지문의 글 — 메일함 · 검색어 · 조건을 정해진 차례로 (빈 칸은 빈 글). + * 배열을 JSON 으로 잇는다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않게 (from 에 줄바꿈을 넣어 to 칸을 흉내 낼 수 없다) + */ +export function searchScopeKey( + mailboxId: string | undefined, + criteria: SearchCriteria, +): string { + return JSON.stringify([ + mailboxId ?? ALL_MAILBOXES_SCOPE, + criteria.text ?? '', + criteria.from ?? '', + criteria.to ?? '', + criteria.subject ?? '', + criteria.body ?? '', + criteria.after ?? '', + criteria.before ?? '', + criteria.hasAttachment === true, + ]); +} + +function scopeHash(scopeKey: string): string { + return createHash('sha256') + .update(scopeKey) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +function invalidCursor(reason: string): BusinessException { + return new BusinessException('VAL_001', { + context: { reason: `invalid-cursor:${reason}` }, + }); +} + +export function encodeIndexCursor( + position: SearchPosition, + scopeKey: string, +): string { + const payload: EncodedIndexCursor = { + v: CURSOR_VERSION, + m: INDEX_METHOD, + t: position.receivedAt.getTime(), + id: position.emailId, + s: scopeHash(scopeKey), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 읽는다 — 모양이 틀리거나 다른 검색의 커서면 VAL_001 */ +export function decodeIndexCursor( + cursor: string, + scopeKey: string, +): SearchPosition { + if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long'); + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw invalidCursor('not-json'); + } + const { v, m, t, id, s } = (parsed ?? {}) as Partial; + const valid = + v === CURSOR_VERSION && + m === INDEX_METHOD && + typeof t === 'number' && + Number.isSafeInteger(t) && + t >= 0 && + // 날짜로 읽을 수 있는 시각만 — Date 의 한도(±8.64e15 ms) 밖이면 Invalid Date 라 쿼리 값으로 쓰면 500 이었다 + Number.isFinite(new Date(t).getTime()) && + typeof id === 'string' && + MAIL_ID_PATTERN.test(id) && + typeof s === 'string'; + if (!valid) throw invalidCursor('shape'); + if (s !== scopeHash(scopeKey)) throw invalidCursor('other-list'); + return { receivedAt: new Date(t), emailId: id }; +} diff --git a/backend/src/modules/mail-search/search-highlight.spec.ts b/backend/src/modules/mail-search/search-highlight.spec.ts new file mode 100644 index 0000000..e131733 --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.spec.ts @@ -0,0 +1,178 @@ +import { matchRanges } from '@/shared/utils/search-text'; +import { + SNIPPET_BOUNDARY_SLACK, + SNIPPET_LEAD_CHARS, + SNIPPET_TOTAL_CHARS, +} from './mail-search.constants'; +import { + highlightedSubject, + listHighlight, + snippetAround, +} from './search-highlight'; +import type { HighlightTerms } from './search-plan'; + +const terms = (...words: string[]): HighlightTerms => ({ + subject: words, + body: words, + attachment: words, +}); + +/** 글에서 낱말이 처음 맞은 곳 */ +const firstMatch = (body: string, word: string) => + matchRanges(body, [word], 1)[0]; + +/** 범위가 가리키는 글 */ +const slices = (text: string, ranges: readonly (readonly [number, number])[]) => + ranges.map(([start, end]) => text.slice(start, end)); + +describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () => { + describe('맞은 말 둘레 (snippetAround)', () => { + it('짧은 본문은 그대로 — 잘라 낸 글이 없으면 "…" 도 없다', () => { + const body = '이번 달 계약 갱신 일정입니다.'; + expect(snippetAround(body, firstMatch(body, '계약'))).toBe(body); + }); + + it(`맞은 곳 앞 ${SNIPPET_LEAD_CHARS}자 남짓부터 모두 ${SNIPPET_TOTAL_CHARS}자 남짓 — 낱말 경계(공백)에서 자르고 앞뒤에 "…"`, () => { + const before = Array.from({ length: 30 }, (_, i) => `앞말${i}`).join(' '); + const after = Array.from({ length: 30 }, (_, i) => `뒷말${i}`).join(' '); + const body = `${before} 계약서를 보내 드립니다 ${after}`; + + const snippet = snippetAround(body, firstMatch(body, '계약')); + + expect(snippet.startsWith('…')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + const inner = snippet.slice(1, -1); + expect(inner).toContain('계약서를'); + // 낱말 가운데를 자르지 않았다 — 앞 · 뒤 조각이 본문의 낱말 그대로 + const words = inner.split(' '); + for (const word of [words[0], words[words.length - 1]]) { + expect(body.split(' ')).toContain(word); + } + // 맞은 곳 앞은 SNIPPET_LEAD_CHARS + 여유 안, 모두 SNIPPET_TOTAL_CHARS + 여유 안 + expect(inner.indexOf('계약')).toBeLessThanOrEqual( + SNIPPET_LEAD_CHARS + SNIPPET_BOUNDARY_SLACK, + ); + expect(inner.length).toBeLessThanOrEqual( + SNIPPET_TOTAL_CHARS + SNIPPET_BOUNDARY_SLACK * 2, + ); + }); + + it('공백이 없는 긴 글은 그 자리에서 자르고 "…" — 맞은 곳은 늘 담는다', () => { + const body = `${'가'.repeat(100)}계약${'나'.repeat(200)}`; + const snippet = snippetAround(body, firstMatch(body, '계약')); + + expect(snippet).toBe( + `…${'가'.repeat(SNIPPET_LEAD_CHARS)}계약${'나'.repeat(SNIPPET_TOTAL_CHARS - SNIPPET_LEAD_CHARS - 2)}…`, + ); + }); + + it('맞은 말이 쪽보다 길어도 다 담는다', () => { + const long = '계'.repeat(150); + const body = `안내 ${long} 끝`; + const snippet = snippetAround(body, [3, 3 + long.length]); + expect(snippet).toContain(long); + }); + + it('줄바꿈 · 탭 · 여러 칸 공백은 띄어쓰기 하나로 — 잘라 낸 쪽이 공백뿐이면 "…" 를 붙이지 않는다', () => { + const body = `${' '.repeat(30)}계약\n\n\t갱신 일정${'\n'.repeat(5)}`; + expect(snippetAround(body, firstMatch(body, '계약'))).toBe( + '계약 갱신 일정', + ); + }); + + it('서로게이트 쌍(이모지) 가운데를 가르지 않는다', () => { + const body = `${'😀'.repeat(40)}계약${'😀'.repeat(60)}`; + const snippet = snippetAround(body, firstMatch(body, '계약')); + // 짝이 없는 서로게이트가 없다 + expect(snippet).toBe(snippet.normalize()); + expect(() => encodeURIComponent(snippet)).not.toThrow(); + expect(snippet).toContain('계약'); + }); + }); + + describe('항목 하나의 강조 (listHighlight)', () => { + it('제목의 맞은 곳(합친 범위) · 본문에서 처음 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳', () => { + const highlight = listHighlight( + { + subject: '[공지] 계약 갱신 · 계약서', + preview: '엔진의 미리 보기', + bodyText: + '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + }, + terms('계약', '갱신'), + ); + + expect(slices('[공지] 계약 갱신 · 계약서', highlight.subject)).toEqual([ + '계약', + '갱신', + '계약', + ]); + expect(highlight.preview.text).toBe( + '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + ); + expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([ + '계약', + '갱신', + '갱신', + '계약', + ]); + }); + + it('본문에 맞은 곳이 없으면(제목에서만 맞음) 엔진의 미리 보기 — 한 줄로, 맞은 곳이 있으면 그 자리', () => { + const highlight = listHighlight( + { + subject: '계약 안내', + preview: '첨부한 계약서를\n확인해 주세요', + bodyText: null, + }, + terms('계약'), + ); + expect(highlight.preview).toEqual({ + text: '첨부한 계약서를 확인해 주세요', + ranges: [[4, 6]], + }); + + const notInBody = listHighlight( + { subject: '계약 안내', preview: '첨부 확인', bodyText: '첨부 확인' }, + terms('계약'), + ); + expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] }); + expect(notInBody.subject).toEqual([[0, 2]]); + }); + + it('본문에 강조할 말이 없으면(제목 조건만) 미리 보기는 맞은 곳 없이 엔진의 것', () => { + const highlight = listHighlight( + { + subject: '견적 요청', + preview: '견적 부탁드립니다', + bodyText: '견적 부탁드립니다', + }, + { subject: ['견적'], body: [], attachment: [] }, + ); + expect(highlight).toEqual({ + subject: [[0, 2]], + preview: { text: '견적 부탁드립니다', ranges: [] }, + }); + }); + + it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => { + const nfd = '계약 PROJECT'.normalize('NFD'); + const highlight = listHighlight( + { subject: 'PROJECT 안내', preview: nfd, bodyText: null }, + terms('project', '계약'), + ); + expect(highlight.subject).toEqual([[0, 7]]); + expect(highlight.preview.text).toBe('계약 PROJECT'); + expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([ + '계약', + 'PROJECT', + ]); + }); + + it('응답의 제목은 NFC — 강조 위치의 기준', () => { + const nfd = '계약 안내'.normalize('NFD'); + expect(highlightedSubject(nfd)).toBe('계약 안내'); + expect(highlightedSubject(nfd).length).toBe(5); + }); + }); +}); diff --git a/backend/src/modules/mail-search/search-highlight.ts b/backend/src/modules/mail-search/search-highlight.ts new file mode 100644 index 0000000..4d8fcc6 --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.ts @@ -0,0 +1,163 @@ +import { + isSpaceCode, + matchRanges, + type MatchRange, +} from '@/shared/utils/search-text'; +import { + SNIPPET_BOUNDARY_SLACK, + SNIPPET_LEAD_CHARS, + SNIPPET_TOTAL_CHARS, +} from './mail-search.constants'; +import type { MessageListHighlight, SearchRange } from './mail-search.types'; +import type { HighlightTerms } from './search-plan'; + +/** + * 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄. + * - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다) + * - 미리 보기: 본문(색인의 글 — NFC)에서 맞았으면 처음 맞은 말 둘레(앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — + * SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤가 잘렸으면 "…"), 아니면 엔진의 미리 보기. 공백은 하나로 줄인 한 줄이고 맞은 곳은 그 글 기준으로 다시 찾는다 + * 위치는 UTF-16 [시작, 끝) + */ + +const ELLIPSIS = '…'; + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 앞으로 */ +function alignStart(text: string, index: number): number { + if (index <= 0 || index >= text.length) return Math.max(0, index); + return isHighSurrogate(text.charCodeAt(index - 1)) ? index - 1 : index; +} + +/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 뒤로 */ +function alignEnd(text: string, index: number): number { + if (index <= 0 || index >= text.length) return Math.min(text.length, index); + return isHighSurrogate(text.charCodeAt(index - 1)) ? index + 1 : index; +} + +/** `from` 에서 `to` 쪽으로 가며 처음 만나는 공백의 자리 — 없으면 -1 (from · to 를 포함) */ +function findSpace(text: string, from: number, to: number): number { + const step = from <= to ? 1 : -1; + for (let index = from; step > 0 ? index <= to : index >= to; index += step) { + if ( + index >= 0 && + index < text.length && + isSpaceCode(text.charCodeAt(index)) + ) { + return index; + } + } + return -1; +} + +/** [from, to) 에 공백이 아닌 글자가 있나 — 잘라 낸 쪽에 보이는 글이 있을 때만 "…" 를 붙이려고 */ +function hasVisible(text: string, from: number, to: number): boolean { + for (let index = from; index < to; index += 1) { + if (!isSpaceCode(text.charCodeAt(index))) return true; + } + return false; +} + +/** 공백류의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (한 줄로 보이는 미리 보기) */ +function oneLine(text: string): string { + let line = ''; + let pendingSpace = false; + for (let index = 0; index < text.length; index += 1) { + if (isSpaceCode(text.charCodeAt(index))) { + pendingSpace = line !== ''; + continue; + } + if (pendingSpace) line += ' '; + pendingSpace = false; + line += text[index]; + } + return line; +} + +/** + * 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다. + * 앞 · 뒤에 잘라 낸 글(공백만이 아닌)이 있으면 "…" 를 붙이고 한 줄로 만든다 + */ +export function snippetAround(body: string, first: MatchRange): string { + const [matchStart, matchEnd] = first; + // 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리 + const lead = Math.max(0, matchStart - SNIPPET_LEAD_CHARS); + let start = lead; + if (lead > 0) { + const before = findSpace( + body, + lead, + Math.max(0, lead - SNIPPET_BOUNDARY_SLACK), + ); + const after = + before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1; + const space = before >= 0 ? before : after; + if (space >= 0) start = space + 1; + } + start = alignStart(body, start); + // 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다 + const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS); + let end = Math.min(body.length, target); + if (end < body.length) { + const back = findSpace( + body, + end, + Math.max(matchEnd, end - SNIPPET_BOUNDARY_SLACK), + ); + const forward = + back < 0 + ? findSpace( + body, + end, + Math.min(body.length - 1, end + SNIPPET_BOUNDARY_SLACK), + ) + : -1; + const space = back >= 0 ? back : forward; + if (space >= 0) end = space; + } + end = alignEnd(body, end); + const text = oneLine(body.slice(start, end)); + const head = hasVisible(body, 0, start) ? ELLIPSIS : ''; + const tail = hasVisible(body, end, body.length) ? ELLIPSIS : ''; + return `${head}${text}${tail}`; +} + +function toRanges(ranges: readonly MatchRange[]): SearchRange[] { + return ranges.map(([start, end]) => [start, end] as const); +} + +/** + * 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null) + */ +export function listHighlight( + input: { + readonly subject: string; + readonly preview: string; + readonly bodyText: string | null; + }, + terms: HighlightTerms, +): MessageListHighlight { + const subject = toRanges(matchRanges(input.subject, terms.subject)); + if (input.bodyText && terms.body.length > 0) { + const inBody = matchRanges(input.bodyText, terms.body, 1); + if (inBody.length > 0) { + const text = snippetAround(input.bodyText, inBody[0]); + return { + subject, + preview: { text, ranges: toRanges(matchRanges(text, terms.body)) }, + }; + } + } + const text = oneLine(input.preview.normalize('NFC')); + return { + subject, + preview: { text, ranges: toRanges(matchRanges(text, terms.body)) }, + }; +} + +/** 응답에 실을 제목 — 강조 위치의 기준인 NFC (화면에는 같게 보인다) */ +export function highlightedSubject(subject: string): string { + return subject.normalize('NFC'); +} diff --git a/backend/src/modules/mail-search/search-plan.spec.ts b/backend/src/modules/mail-search/search-plan.spec.ts new file mode 100644 index 0000000..8353fc0 --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.spec.ts @@ -0,0 +1,246 @@ +import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants'; +import type { SearchCriteria } from './mail-search.types'; +import { + ALL_FOLD_FIELDS, + highlightTermsOf, + isSearchCriteria, + planSearch, +} from './search-plan'; + +const criteria = (overrides: Partial): SearchCriteria => ({ + hasAttachment: false, + ...overrides, +}); + +describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { + it('검색인가 — 검색어 · 글 조건 · 기간 · 첨부 가운데 하나라도 (빈 글 · 첨부 false 는 없는 것)', () => { + expect(isSearchCriteria(criteria({}))).toBe(false); + expect(isSearchCriteria(criteria({ text: '', subject: '' }))).toBe(false); + for (const overrides of [ + { text: '계약' }, + { from: '이도윤' }, + { to: 'ara' }, + { subject: '견적' }, + { body: '일정' }, + { after: '2026-10-31T15:00:00.000Z' }, + { before: '2026-11-30T15:00:00.000Z' }, + { hasAttachment: true }, + ]) { + expect(isSearchCriteria(criteria(overrides))).toBe(true); + } + }); + + it('검색어의 낱말은 다섯 칸 어디서든 — 접은 낱말로 확인하고 글자 두 개 묶음으로 후보를 좁힌다', () => { + const plan = planSearch(criteria({ text: '계약 PROJECT' })); + + expect(plan.checks).toEqual([ + { term: '계약', fields: ALL_FOLD_FIELDS }, + { term: 'project', fields: ALL_FOLD_FIELDS }, + ]); + expect(plan.grams).toEqual(['계약', 'pr', 'ro', 'oj', 'je', 'ec', 'ct']); + expect(plan.matchesNothing).toBe(false); + expect(plan.bodySkipped).toBe(false); + // 강조는 접기 전 낱말 그대로 (맞추기가 접는다) + expect(plan.highlight).toEqual({ + subject: ['계약', 'PROJECT'], + body: ['계약', 'PROJECT'], + attachment: ['계약', 'PROJECT'], + }); + }); + + it('큰따옴표 구절은 띄어쓰기까지 그대로 하나의 확인 — 묶음은 공백을 넘지 않는다, 앞뒤 문장부호를 뗀 낱말 · 같은 낱말은 한 번', () => { + const plan = planSearch(criteria({ text: '"계약 갱신" (계약), 계약' })); + + expect(plan.checks.map((check) => check.term)).toEqual([ + '계약 갱신', + '계약', + ]); + expect(plan.grams).toEqual(['계약', '갱신']); + }); + + it('조건의 낱말은 그 칸에서만 — 받는 사람(to)은 받는 사람 · 참조를 담은 칸, 보낸 사람 · 받는 사람은 강조하지 않는다', () => { + const plan = planSearch( + criteria({ + from: '이도윤', + to: 'ara.kim', + subject: '견적', + body: '"단가 조정"', + }), + ); + + expect(plan.checks).toEqual([ + { term: '이도윤', fields: ['fold_from'] }, + { term: 'ara.kim', fields: ['fold_to'] }, + { term: '견적', fields: ['fold_subject'] }, + { term: '단가 조정', fields: ['fold_body'] }, + ]); + expect(plan.highlight).toEqual({ + subject: ['견적'], + body: ['단가 조정'], + attachment: [], + }); + }); + + it('같은 낱말이라도 찾는 칸이 다르면 따로 확인한다', () => { + const plan = planSearch(criteria({ text: '견적', subject: '견적' })); + expect(plan.checks).toEqual([ + { term: '견적', fields: ALL_FOLD_FIELDS }, + { term: '견적', fields: ['fold_subject'] }, + ]); + expect(plan.grams).toEqual(['견적']); + }); + + it('한 글자 낱말은 두 글자 넘는 낱말과 함께면 확인에만 쓴다 — 본문에서도 찾는다', () => { + const plan = planSearch(criteria({ text: '묵 계약' })); + + expect(plan.bodySkipped).toBe(false); + expect(plan.grams).toEqual(['계약']); + expect(plan.checks).toEqual([ + { term: '묵', fields: ALL_FOLD_FIELDS }, + { term: '계약', fields: ALL_FOLD_FIELDS }, + ]); + }); + + it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => { + expect(SINGLE_CHAR_TERMS_SKIP_BODY).toBe(true); + const plan = planSearch(criteria({ text: '묵 뷁', from: '김' })); + + expect(plan.bodySkipped).toBe(true); + expect(plan.grams).toEqual([]); + expect(plan.matchesNothing).toBe(false); + expect(plan.checks).toEqual([ + { + term: '묵', + fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'], + }, + { + term: '뷁', + fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'], + }, + { term: '김', fields: ['fold_from'] }, + ]); + // 강조는 본문에도 — 계획이 본문을 훑지 않았을 뿐 맞은 곳은 보인다 + expect(plan.highlight.body).toEqual(['묵', '뷁']); + }); + + it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => { + expect(planSearch(criteria({ body: '묵' })).matchesNothing).toBe(true); + expect( + planSearch(criteria({ text: '묵', body: '뷁' })).matchesNothing, + ).toBe(true); + // 두 글자 넘는 낱말과 함께면 한 글자 내용 조건도 확인한다 + const together = planSearch(criteria({ text: '계약', body: '묵' })); + expect(together.matchesNothing).toBe(false); + expect(together.checks).toContainEqual({ + term: '묵', + fields: ['fold_body'], + }); + }); + + it('글을 준 칸에 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다', () => { + for (const overrides of [ + { text: '…' }, + { text: '""' }, + { from: '-' }, + { text: '계약', subject: '—' }, + ]) { + expect(planSearch(criteria(overrides)).matchesNothing).toBe(true); + } + }); + + it('기간 · 첨부만의 검색은 확인도 묶음도 없다 — 받은 시각 · 첨부 표시로만', () => { + const plan = planSearch( + criteria({ + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }), + ); + + expect(plan.checks).toEqual([]); + expect(plan.grams).toEqual([]); + expect(plan.matchesNothing).toBe(false); + expect(plan.after?.toISOString()).toBe('2026-10-31T15:00:00.000Z'); + expect(plan.before?.toISOString()).toBe('2026-11-30T15:00:00.000Z'); + expect(plan.hasAttachment).toBe(true); + }); + + it('전각 · 대문자 · 공백류는 접어서 확인한다 — 구절 안의 줄바꿈도 띄어쓰기 하나와 같다', () => { + const plan = planSearch(criteria({ text: '"ERP\u3000도입"' })); + expect(plan.checks.map((check) => check.term)).toEqual(['erp 도입']); + expect(plan.grams).toEqual(['er', 'rp', '도입']); + }); + + it('두 글자 낱말뿐인 검색어는 묶음만으로 정해진다(exactByGrams) — 세 글자 · 구절 · 한 글자 · 조건 칸이 섞이면 확인한다', () => { + // 서로게이트 쌍 글자(CJK 확장 B — 글자)도 코드 포인트 하나로 센다 + const astralAndLetter = `${String.fromCodePoint(0x20000)}가`; + for (const text of [ + '계약', + '계약 확인', + 'ab', + 'AB', + astralAndLetter, + '(계약),', + ]) { + expect(planSearch(criteria({ text })).exactByGrams).toBe(true); + } + expect( + planSearch( + criteria({ + text: '계약', + after: '2026-10-31T15:00:00.000Z', + hasAttachment: true, + }), + ).exactByGrams, + ).toBe(true); + for (const overrides of [ + { text: '계약서' }, + { text: '"계약 갱신"' }, + { text: '묵 계약' }, + { text: '계약', subject: '견적' }, + { subject: '계약' }, + { hasAttachment: true }, + ]) { + expect(planSearch(criteria(overrides)).exactByGrams).toBe(false); + } + }); + + describe('읽기 화면의 강조할 말 (highlightTermsOf — 설계 C2)', () => { + it('검색어는 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에', () => { + expect( + highlightTermsOf({ + text: '계약', + subject: '견적', + body: '"단가 조정"', + }), + ).toEqual({ + subject: ['계약', '견적'], + body: ['계약', '단가 조정'], + attachment: ['계약'], + }); + }); + + it('강조할 말이 없으면 null — 검색어 · 제목 · 내용 조건이 없거나 문장부호뿐', () => { + expect(highlightTermsOf({})).toBeNull(); + expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull(); + }); + }); + + it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => { + const NUL = String.fromCharCode(0); + const FFFD = String.fromCharCode(0xfffd); + const plan = planSearch(criteria({ text: `계${NUL}약`, body: `a${NUL}b` })); + expect(plan.checks.map((check) => check.term)).toEqual([ + `계${FFFD}약`, + `a${FFFD}b`, + ]); + expect(plan.grams).toEqual([ + `계${FFFD}`, + `${FFFD}약`, + `a${FFFD}`, + `${FFFD}b`, + ]); + expect(JSON.stringify(plan)).not.toContain(NUL); + expect(plan.highlight.body).toEqual([`계${FFFD}약`, `a${FFFD}b`]); + }); +}); diff --git a/backend/src/modules/mail-search/search-plan.ts b/backend/src/modules/mail-search/search-plan.ts new file mode 100644 index 0000000..9e157ef --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.ts @@ -0,0 +1,196 @@ +import { parseSearchTerms } from '@/shared/utils/search-query'; +import { foldText, gramsOf, isSpaceCode } from '@/shared/utils/search-text'; +import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants'; +import type { SearchCriteria } from './mail-search.types'; + +/** + * 검색 조건 → 색인에서 찾을 계획 (순수 함수 — 14단계 설계 C1 · G). + * + * - 검색어(q)의 낱말 · 구절은 다섯 칸(보낸 사람 · 받는 사람(참조 포함) · 제목 · 첨부 이름 · 본문) 가운데 어디든, 조건 칸(from · to · subject · body)의 + * 낱말 · 구절은 그 칸에서 — 모두 맞아야 한다(AND). 낱말 · 구절 나누기는 엔진 검색과 같다(parseSearchTerms — 큰따옴표 구절 · 앞뒤 문장부호 떼기) + * - 낱말마다 접기(foldText) 뒤 부분 일치(strpos)로 확인하고, 모든 낱말의 글자 두 개 묶음(gramsOf)으로 후보를 좁힌다(GIN `grams @>`) — + * 낱말이 한 칸 안에 이어 있으니 그 묶음은 늘 그 줄의 묶음 안에 있다 + * - 한 글자 낱말(묶음이 없는 낱말): 묶음이 있는 낱말과 함께면 확인에만 쓴다. 한 글자 낱말뿐이면(SINGLE_CHAR_TERMS_SKIP_BODY) 본문은 훑지 않는다 — + * 검색어의 낱말은 제목 · 사람 · 첨부 이름에서만, 내용(body) 조건의 낱말은 찾을 곳이 없어 아무것도 맞지 않는다 + * - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다) + * - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams). + * 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다 + */ + +/** 확인하는 접기 칸 — 표의 열 이름 */ +export type FoldField = + | 'fold_from' + | 'fold_to' + | 'fold_subject' + | 'fold_attachment' + | 'fold_body'; + +/** 검색어(q)의 낱말을 찾는 칸 — 다섯 칸 모두 */ +export const ALL_FOLD_FIELDS: readonly FoldField[] = [ + 'fold_from', + 'fold_to', + 'fold_subject', + 'fold_attachment', + 'fold_body', +]; + +/** 조건 칸 → 그 칸의 접기 열 */ +const CONDITION_FIELDS = { + from: 'fold_from', + to: 'fold_to', + subject: 'fold_subject', + body: 'fold_body', +} as const satisfies Record; + +type ConditionName = keyof typeof CONDITION_FIELDS; +const CONDITION_NAMES = Object.keys(CONDITION_FIELDS) as ConditionName[]; + +/** 낱말 하나의 확인 — 접은 낱말이 그 칸들 가운데 하나에 들어 있어야 한다 */ +export interface TermCheck { + readonly term: string; + readonly fields: readonly FoldField[]; +} + +/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */ +export interface HighlightTerms { + readonly subject: readonly string[]; + readonly body: readonly string[]; + readonly attachment: readonly string[]; +} + +export interface SearchPlan { + /** 낱말 확인 — 모두 맞아야 한다. 비었으면 범위 · 기간 · 첨부만으로 찾는다 */ + readonly checks: readonly TermCheck[]; + /** 후보를 좁히는 글자 두 개 묶음 — 비었으면 GIN 을 쓰지 못한다 */ + readonly grams: readonly string[]; + readonly after: Date | null; + readonly before: Date | null; + readonly hasAttachment: boolean; + /** 아무것도 맞을 수 없다 — 묻지 않고 빈 결과 */ + readonly matchesNothing: boolean; + /** 한 글자 낱말뿐이라 본문을 훑지 않았다 (SINGLE_CHAR_TERMS_SKIP_BODY) */ + readonly bodySkipped: boolean; + /** 묶음만으로 정해진다 — 검색어(다섯 칸)의 두 글자 낱말뿐이라 확인(strpos)이 늘 참이다 */ + readonly exactByGrams: boolean; + readonly highlight: HighlightTerms; +} + +/** 검색인가 — 검색어나 조건이 하나라도 있으면 (빈 글은 없는 것) */ +export function isSearchCriteria(criteria: SearchCriteria): boolean { + return ( + Boolean(criteria.text) || + CONDITION_NAMES.some((name) => Boolean(criteria[name])) || + criteria.after !== undefined || + criteria.before !== undefined || + criteria.hasAttachment === true + ); +} + +/** 글을 낱말 · 구절로 — 접기 전 글(강조)과 접은 글(확인) */ +function termsOf(text: string | undefined): { + readonly given: boolean; + readonly raw: string[]; +} { + if (!text) return { given: false, raw: [] }; + return { given: true, raw: parseSearchTerms(text).map((term) => term.text) }; +} + +/** 두 글자 낱말인가 — 코드 포인트 둘 · 공백 없음 (그 낱말의 묶음 하나가 곧 그 낱말이다) */ +function isGramWord(term: string): boolean { + const points = [...term]; + return ( + points.length === 2 && + points.every((point) => !isSpaceCode(point.charCodeAt(0))) + ); +} + +function dateOf(value: string | undefined): Date | null { + if (value === undefined) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : new Date(time); +} + +export function planSearch(criteria: SearchCriteria): SearchPlan { + const query = termsOf(criteria.text); + const conditions = CONDITION_NAMES.map((name) => ({ + name, + ...termsOf(criteria[name]), + })); + // 글을 준 칸에 찾을 낱말이 없다 — 아무것도 맞지 않는다 + let matchesNothing = + (query.given && query.raw.length === 0) || + conditions.some( + (condition) => condition.given && condition.raw.length === 0, + ); + + const grams = new Set(); + const pending: { term: string; fields: readonly FoldField[] }[] = []; + const add = (raw: string, fields: readonly FoldField[]) => { + const term = foldText(raw); + gramsOf(term, grams); + pending.push({ term, fields }); + }; + for (const raw of query.raw) add(raw, ALL_FOLD_FIELDS); + for (const condition of conditions) { + for (const raw of condition.raw) + add(raw, [CONDITION_FIELDS[condition.name]]); + } + + // 한 글자 낱말뿐 — 본문은 훑지 않는다 (제목 · 사람 · 첨부 이름만) + const bodySkipped = + SINGLE_CHAR_TERMS_SKIP_BODY && pending.length > 0 && grams.size === 0; + const seen = new Set(); + const checks: TermCheck[] = []; + for (const check of pending) { + const fields = bodySkipped + ? check.fields.filter((field) => field !== 'fold_body') + : check.fields; + if (fields.length === 0) { + matchesNothing = true; + continue; + } + const key = `${check.term}\n${fields.join(',')}`; + if (seen.has(key)) continue; + seen.add(key); + checks.push({ term: check.term, fields }); + } + + const exactByGrams = + checks.length > 0 && + checks.every( + (check) => + check.fields.length === ALL_FOLD_FIELDS.length && + isGramWord(check.term), + ); + const subjectTerms = conditions.find((c) => c.name === 'subject')?.raw ?? []; + const bodyTerms = conditions.find((c) => c.name === 'body')?.raw ?? []; + return { + checks, + grams: [...grams], + after: dateOf(criteria.after), + before: dateOf(criteria.before), + hasAttachment: criteria.hasAttachment === true, + matchesNothing, + bodySkipped, + exactByGrams, + highlight: { + subject: [...query.raw, ...subjectTerms], + body: [...query.raw, ...bodyTerms], + attachment: [...query.raw], + }, + }; +} + +/** + * 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에. + * 보낸 사람 · 받는 사람 조건은 강조하지 않는다. 강조할 말이 하나도 없으면 null + */ +export function highlightTermsOf( + criteria: Pick, +): HighlightTerms | null { + const query = termsOf(criteria.text).raw; + const subject = [...query, ...termsOf(criteria.subject).raw]; + const body = [...query, ...termsOf(criteria.body).raw]; + if (subject.length === 0 && body.length === 0) return null; + return { subject, body, attachment: query }; +} diff --git a/backend/src/modules/mail-search/search-sql.spec.ts b/backend/src/modules/mail-search/search-sql.spec.ts new file mode 100644 index 0000000..ee44849 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.spec.ts @@ -0,0 +1,194 @@ +import type { SearchCriteria, SearchScope } from './mail-search.types'; +import { planSearch } from './search-plan'; +import { + candidatesSql, + checkHitsSql, + highlightTextsSql, + keysetCountSql, + keysetFrontierSql, + keysetHitsSql, + plainCountSql, + plainPageSql, +} from './search-sql'; + +const USER = 'ara.kim@arasystem.test'; +const ALL: SearchScope = { + mailboxId: null, + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], +}; +const INBOX: SearchScope = { + mailboxId: 'a', + excludedMailboxIds: [], + excludedKeywords: [], +}; + +const plan = (overrides: Partial) => + planSearch({ hasAttachment: false, ...overrides }); + +/** SQL 의 공백을 하나로 (비교 편하게) */ +const flat = (sql: string) => sql.replace(/\s+/g, ' '); + +describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { + it('후보 — 주소(묶음 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => { + const query = candidatesSql( + USER, + ALL, + plan({ text: '계약 갱신' }), + null, + 3_001, + ); + + expect(flat(query.sql)).toBe( + 'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE lower("user_email") = $1 ' + + 'AND NOT ("mailbox_ids" <@ $2::text[]) AND NOT ("keywords" && $3::text[]) AND "grams" @> $4::text[] ' + + 'ORDER BY "received_at" DESC, "email_id" DESC LIMIT $5', + ); + expect(query.params).toEqual([ + USER, + ['b', 'c'], + ['$ara-sending'], + ['계약', '갱신'], + 3_001, + ]); + // 주소 그대로("user_email" = …)는 btree 로 그 직원의 줄을 다 읽게 둔다 — 후보 쿼리에는 쓰지 않는다 + expect(query.sql).not.toContain('"user_email" ='); + expect(query.sql).not.toContain('strpos'); + }); + + it('메일함 범위 · 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => { + const after = { + receivedAt: new Date('2026-11-01T00:00:00Z'), + emailId: 'm9', + }; + const query = candidatesSql( + USER, + INBOX, + plan({ + text: '계약', + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }), + after, + 10, + ); + + expect(flat(query.sql)).toContain( + 'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' + + 'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]', + ); + expect(query.params).toEqual([ + USER, + ['a'], + new Date('2026-10-31T15:00:00.000Z'), + new Date('2026-11-30T15:00:00.000Z'), + after.receivedAt, + 'm9', + ['계약'], + 10, + ]); + }); + + it('확인 — 후보 id 의 줄만 기본 키로 읽어 낱말마다 그 칸들 가운데 하나의 strpos', () => { + const query = checkHitsSql(USER, plan({ text: '계약', from: '이도윤' }), [ + 'm1', + 'm2', + ]); + + expect(flat(query.sql)).toBe( + 'SELECT "email_id" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[]) ' + + 'AND (strpos("fold_from", $3) > 0 OR strpos("fold_to", $3) > 0 OR strpos("fold_subject", $3) > 0 ' + + 'OR strpos("fold_attachment", $3) > 0 OR strpos("fold_body", $3) > 0) AND (strpos("fold_from", $4) > 0)', + ); + expect(query.params).toEqual([USER, ['m1', 'm2'], '계약', '이도윤']); + }); + + it('묶음이 없는 찾기(한 글자 낱말뿐) — 안쪽에서 확인 상한만큼 받은 차례로 자른 뒤 바깥에서 확인하고 쪽 크기에서 멈춘다, 묶음은 보지 않는다', () => { + const query = keysetHitsSql( + USER, + INBOX, + plan({ text: '묵' }), + null, + 3_000, + 21, + ); + const sql = flat(query.sql); + + expect(sql).toMatch( + /^SELECT "email_id", "received_at" FROM \(SELECT "email_id", "received_at", "fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body" FROM "mail_search_messages" WHERE "user_email" = \$1 AND "mailbox_ids" @> \$2::text\[\] ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$3\) AS "scan" WHERE \(strpos\("fold_from", \$4\) > 0 .+\) ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$5$/, + ); + expect(sql).not.toContain('"grams"'); + // 본문은 훑지 않는다 (SINGLE_CHAR_TERMS_SKIP_BODY) + expect(sql).not.toContain('strpos("fold_body"'); + expect(query.params.slice(2)).toEqual([3_000, '묵', 21]); + }); + + it('훑은 창의 끝 · 창 안의 맞은 수 — 끝은 확인 없이 그 창의 가장 오래된 줄과 줄 수, 수는 countLimit 까지', () => { + const frontier = keysetFrontierSql( + USER, + INBOX, + plan({ text: '묵' }), + null, + 3_000, + ); + expect(flat(frontier.sql)).toMatch( + /^SELECT "email_id", "received_at", count\(\*\) OVER \(\)::int AS "scanned" FROM \(SELECT "email_id", "received_at" FROM .+ LIMIT \$3\) AS "scan" ORDER BY "received_at" ASC, "email_id" ASC LIMIT 1$/, + ); + expect(frontier.sql).not.toContain('strpos'); + + const count = keysetCountSql( + USER, + INBOX, + plan({ text: '묵' }), + 3_000, + 1_001, + ); + expect(flat(count.sql)).toMatch( + /^SELECT count\(\*\)::int AS "count" FROM \(SELECT 1 FROM \(SELECT .+ LIMIT \$3\) AS "scan" WHERE .+strpos.+ LIMIT \$5\) AS "hits"$/, + ); + expect(count.params.slice(2)).toEqual([3_000, '묵', 1_001]); + }); + + it('낱말 확인이 없을 때(기간 · 첨부만) — 묶음 · strpos 없이 받은 차례로 · 수도 같은 조건으로', () => { + const only = plan({ hasAttachment: true }); + const page = plainPageSql(USER, ALL, only, null, 21); + expect(flat(page.sql)).toBe( + 'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE "user_email" = $1 AND NOT ("mailbox_ids" <@ $2::text[]) ' + + 'AND NOT ("keywords" && $3::text[]) AND "has_attachment" ORDER BY "received_at" DESC, "email_id" DESC LIMIT $4', + ); + const count = plainCountSql(USER, ALL, only, 1_001); + expect(flat(count.sql)).toContain( + 'AND "has_attachment" LIMIT $4) AS "matches"', + ); + expect(count.params).toEqual([USER, ['b', 'c'], ['$ara-sending'], 1_001]); + }); + + it('검색 글은 SQL 에 이어 붙이지 않는다 — 따옴표 · 세미콜론이 든 낱말도 매개변수로만', () => { + const hostile = `x'); DROP TABLE users; --`; + const checked = [ + checkHitsSql(USER, plan({ text: hostile }), ['m1']), + keysetHitsSql(USER, ALL, plan({ subject: hostile }), null, 10, 10), + ]; + for (const query of [ + ...checked, + candidatesSql(USER, ALL, plan({ body: hostile }), null, 10), + ]) { + expect(query.sql).not.toMatch(/drop|users|x'/i); + } + // 낱말은 접은 글로 매개변수에만 (앞뒤 문장부호를 뗀 x · drop · table · users) + for (const query of checked) { + expect(query.params).toEqual( + expect.arrayContaining(['x', 'drop', 'table', 'users']), + ); + } + }); + + it('강조에 쓰는 글 — 그 직원의 찾은 줄만', () => { + const query = highlightTextsSql(USER, ['m1', 'm2']); + expect(flat(query.sql)).toBe( + 'SELECT "email_id", "subject", "body_text", "preview" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])', + ); + expect(query.params).toEqual([USER, ['m1', 'm2']]); + }); +}); diff --git a/backend/src/modules/mail-search/search-sql.ts b/backend/src/modules/mail-search/search-sql.ts new file mode 100644 index 0000000..7a16064 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.ts @@ -0,0 +1,272 @@ +import { SEARCH_MESSAGES_TABLE } from './mail-search.constants'; +import type { SearchScope } from './mail-search.types'; +import type { SearchPlan } from './search-plan'; + +/** + * 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다. + * + * 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다: + * - 후보(candidatesSql): 주소 + 묶음을 GIN 에서만 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. + * 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms). + * 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다 + * - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다 + * 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는 + * 받은 차례로 바로 읽는다. + */ + +const TABLE = `"${SEARCH_MESSAGES_TABLE}"`; +/** 받은 시각 최신부터 (같은 시각은 id 로) — 색인 IDX_mail_search_messages_received 의 거꾸로 */ +const NEWEST_FIRST = '"received_at" DESC, "email_id" DESC'; +const OLDEST_FIRST = '"received_at" ASC, "email_id" ASC'; +/** 확인에 쓰는 접기 칸 — 안쪽 쿼리가 바깥으로 넘긴다 */ +const FOLD_COLUMNS = + '"fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body"'; + +/** 이어 찾을 자리 — 이 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 */ +export interface SearchPosition { + readonly receivedAt: Date; + readonly emailId: string; +} + +export interface SqlQuery { + readonly sql: string; + readonly params: unknown[]; +} + +/** 매개변수를 차례로 쌓는다 */ +class Params { + readonly values: unknown[] = []; + + add(value: unknown): string { + this.values.push(value); + return `$${this.values.length}`; + } +} + +/** + * 직원을 거르는 길 — `gin`: 묶음 GIN 의 식(lower)으로 묶음과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키) + * 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다 + */ +type UserMatch = 'gin' | 'btree'; + +/** 값싼 조건 — 직원 · 범위 · 기간 · 첨부 · 빼는 키워드 · 이어 찾을 자리 (`gin` 이면 묶음도) */ +function cheapConditions( + params: Params, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + userMatch: UserMatch, +): string { + const where = [ + userMatch === 'gin' + ? `lower("user_email") = ${params.add(user)}` + : `"user_email" = ${params.add(user)}`, + ]; + if (scope.mailboxId !== null) { + where.push(`"mailbox_ids" @> ${params.add([scope.mailboxId])}::text[]`); + } + if (scope.excludedMailboxIds.length > 0) { + // 빼는 메일함에만 든 메일을 뺀다 — 다른 메일함에도 있으면 찾는다 (엔진의 inMailboxOtherThan 과 같다) + where.push( + `NOT ("mailbox_ids" <@ ${params.add([...scope.excludedMailboxIds])}::text[])`, + ); + } + if (scope.excludedKeywords.length > 0) { + where.push( + `NOT ("keywords" && ${params.add([...scope.excludedKeywords])}::text[])`, + ); + } + if (plan.after !== null) { + where.push(`"received_at" >= ${params.add(plan.after)}::timestamptz`); + } + if (plan.before !== null) { + where.push(`"received_at" < ${params.add(plan.before)}::timestamptz`); + } + if (plan.hasAttachment) where.push('"has_attachment"'); + if (after !== null) { + where.push( + `("received_at", "email_id") < (${params.add(after.receivedAt)}::timestamptz, ${params.add(after.emailId)})`, + ); + } + if (userMatch === 'gin') { + where.push(`"grams" @> ${params.add([...plan.grams])}::text[]`); + } + return where.join(' AND '); +} + +/** 낱말 확인 — 낱말마다 그 칸들 가운데 하나에 들어 있어야 한다 (모두 AND, 확인할 낱말이 없으면 늘 참) */ +function checkConditions(params: Params, plan: SearchPlan): string { + if (plan.checks.length === 0) return 'TRUE'; + return plan.checks + .map((check) => { + const term = params.add(check.term); + return `(${check.fields.map((field) => `strpos("${field}", ${term}) > 0`).join(' OR ')})`; + }) + .join(' AND '); +} + +/** 후보 — 주소 + 묶음(GIN)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */ +export function candidatesSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, after, 'gin'); + return { + sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(limit)}`, + params: params.values, + }; +} + +/** 확인 — 후보 id 가운데 낱말이 모두 맞은 줄의 id (기본 키로 그 줄만 읽는다) */ +export function checkHitsSql( + user: string, + plan: SearchPlan, + ids: readonly string[], +): SqlQuery { + const params = new Params(); + const where = `"user_email" = ${params.add(user)} AND "email_id" = ANY(${params.add([...ids])}::text[])`; + const checks = checkConditions(params, plan); + return { + sql: `SELECT "email_id" FROM ${TABLE} WHERE ${where} AND ${checks}`, + params: params.values, + }; +} + +/** 받은 차례 쿼리의 안쪽 — 값싼 조건을 지난 줄을 최신부터 `scanLimit` 까지 (묶음은 보지 않는다) */ +function scanSubquery( + params: Params, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, + columns: string, +): string { + const where = cheapConditions(params, user, scope, plan, after, 'btree'); + return `SELECT ${columns} FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(scanLimit)}`; +} + +/** + * 묶음이 없는 찾기(한 글자 낱말뿐) — 받은 차례로 `scanLimit` 줄까지 확인하며 맞은 줄을 `pageLimit` 개까지 (차면 멈춘다). + * 묶음 배열을 줄마다 풀지 않게 묶음이 있는 계획에는 쓰지 않는다(candidatesSql) + */ +export function keysetHitsSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, + pageLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + after, + scanLimit, + `"email_id", "received_at", ${FOLD_COLUMNS}`, + ); + const checks = checkConditions(params, plan); + return { + sql: `SELECT "email_id", "received_at" FROM (${scan}) AS "scan" WHERE ${checks} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`, + params: params.values, + }; +} + +/** 받은 차례로 훑은 창의 끝 — 그 창의 마지막 줄과 줄 수 (확인하지 않는다 — 다 훑었는지 · 이어 찾을 자리) */ +export function keysetFrontierSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + after, + scanLimit, + '"email_id", "received_at"', + ); + return { + sql: `SELECT "email_id", "received_at", count(*) OVER ()::int AS "scanned" FROM (${scan}) AS "scan" ORDER BY ${OLDEST_FIRST} LIMIT 1`, + params: params.values, + }; +} + +/** 받은 차례로 훑은 창 안의 맞은 줄 수 — `countLimit` 까지 */ +export function keysetCountSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + scanLimit: number, + countLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + null, + scanLimit, + `"email_id", "received_at", ${FOLD_COLUMNS}`, + ); + const checks = checkConditions(params, plan); + return { + sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM (${scan}) AS "scan" WHERE ${checks} LIMIT ${params.add(countLimit)}) AS "hits"`, + params: params.values, + }; +} + +/** 낱말 확인이 없을 때(기간 · 첨부 · 메일함만) — 받은 차례로 `pageLimit` 개 */ +export function plainPageSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + pageLimit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, after, 'btree'); + return { + sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`, + params: params.values, + }; +} + +/** 낱말 확인이 없을 때의 수 — `countLimit` 까지 */ +export function plainCountSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + countLimit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, null, 'btree'); + return { + sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM ${TABLE} WHERE ${where} LIMIT ${params.add(countLimit)}) AS "matches"`, + params: params.values, + }; +} + +/** 강조에 쓰는 글 — 찾은 쪽의 줄만 */ +export function highlightTextsSql( + user: string, + ids: readonly string[], +): SqlQuery { + return { + sql: `SELECT "email_id", "subject", "body_text", "preview" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, + params: [user, [...ids]], + }; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index e2d67c2..8f78063 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -15,6 +15,11 @@ import { type ContactEmailLabel, } from '@/modules/contacts/contacts.types'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; +import type { + MessageDetailHighlight, + MessageListHighlight, + SearchRange, +} from '@/modules/mail-search/mail-search.types'; import type { Draft, MailAddress, @@ -29,6 +34,7 @@ import type { MessageDeletionResult, MessageDetail, MessageLargeFile, + MessageListItem, MessagesPatchResult, MessageSummary, PendingSend, @@ -311,6 +317,90 @@ export class MessageSummaryDto implements MessageSummary { pendingSend!: PendingSendDto | null; } +/** 맞은 곳 [시작, 끝) — UTF-16, 끝은 들지 않는다 (14단계 검색 강조) */ +const RANGE_LIST_SCHEMA = { + type: 'array', + items: { + type: 'array', + items: { type: 'integer', minimum: 0 }, + minItems: 2, + maxItems: 2, + }, +} as const; + +/** 목록 항목의 미리 보기 강조 */ +export class MessageListPreviewHighlightDto { + @ApiProperty({ + description: + '보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(앞 20자 남짓 · 모두 80자 남짓, 말 경계에서 자르고 잘린 쪽에 "…"), 아니면 엔진의 미리 보기', + example: '…지난주 보내 드린 계약서를 다시 확인해 주세요', + }) + text!: string; + + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: 'text 안의 맞은 곳 [시작, 끝) — UTF-16', + example: [[12, 14]], + }) + ranges!: SearchRange[]; +} + +/** 목록 항목의 검색 강조 (색인으로 찾았을 때만) */ +export class MessageListHighlightDto implements MessageListHighlight { + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + '제목의 맞은 곳 [시작, 끝) — UTF-16. 강조가 있으면 subject 는 NFC 로 맞춘 글이다(위치가 그 글 기준)', + example: [[0, 2]], + }) + subject!: SearchRange[]; + + @ApiProperty({ type: MessageListPreviewHighlightDto }) + preview!: MessageListPreviewHighlightDto; +} + +/** 목록 항목 (GET /api/messages) — 요약 + 검색 강조 */ +export class MessageListItemDto + extends MessageSummaryDto + implements MessageListItem +{ + @ApiProperty({ + description: + '검색 강조 (14단계) — 색인으로 찾은 검색 결과에만, 목록 · 엔진 방식 검색(색인 준비 중)이면 null', + type: MessageListHighlightDto, + nullable: true, + }) + searchHighlight!: MessageListHighlightDto | null; +} + +/** 읽기 화면의 검색 강조 */ +export class MessageDetailHighlightDto implements MessageDetailHighlight { + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + '제목의 맞은 곳 [시작, 끝) — UTF-16 (subject 는 NFC 로 맞춘 글이다)', + example: [[0, 2]], + }) + subject!: SearchRange[]; + + @ApiProperty({ + description: + '첨부 이름의 맞은 곳 — 첨부 id → [시작, 끝) 목록. 맞은 첨부만 든다 (이름은 NFC 로 맞춘 글이다)', + type: 'object', + additionalProperties: RANGE_LIST_SCHEMA, + example: { blob1: [[0, 3]] }, + }) + attachments!: Record; + + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + 'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 가 들어 있다', + example: [[120, 122]], + }) + textBody!: SearchRange[]; +} + export class MessageDetailDto extends MessageSummaryDto implements MessageDetail @@ -406,6 +496,14 @@ export class MessageDetailDto type: () => [MessageLargeFileDto], }) largeFiles!: MessageLargeFileDto[]; + + @ApiProperty({ + description: + '검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 가 있다', + type: MessageDetailHighlightDto, + nullable: true, + }) + searchHighlight!: MessageDetailHighlightDto | null; } const SHARED_FILE_STATUSES: SharedFileStatus[] = [ @@ -475,12 +573,16 @@ export class MessageLargeFileDto implements MessageLargeFile { export class MessagePageDto { @ApiProperty({ - type: [MessageSummaryDto], + type: [MessageListItemDto], description: '받은 시각 내림차순', }) - items!: MessageSummaryDto[]; + items!: MessageListItemDto[]; - @ApiProperty({ type: PageInfoDto, description: 'total 포함' }) + @ApiProperty({ + type: PageInfoDto, + description: + 'total — 목록 · 엔진 방식 검색은 늘, 색인 방식 검색은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). 색인 방식은 쪽이 limit 보다 적거나 비어도 hasNext 일 수 있다(이어 찾기)', + }) pageInfo!: PageInfoDto; } diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts index 0ef15c5..287854b 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts @@ -25,3 +25,72 @@ describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 image expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']); }); }); + +describe('메일 한 통 읽기 쿼리 — 검색 강조 (14단계 q · from · to · subject · body)', () => { + it('목록의 검색 칸과 같은 검사 — 앞뒤 공백을 지우고, 길이 · 낱말 수를 본다', async () => { + for (const field of ['q', 'from', 'to', 'subject', 'body']) { + expect(await constraintsOf({ [field]: ' 계약 ' })).toEqual([]); + expect(await constraintsOf({ [field]: '가'.repeat(201) })).toEqual([ + 'maxCodeUnits', + ]); + expect( + await constraintsOf({ + [field]: Array.from({ length: 9 }, (_, i) => `w${i}`).join(' '), + }), + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); + expect(await constraintsOf({ [field]: ['a', 'b'] })).toContain( + 'isString', + ); + } + }); + + it('images=show 와 함께 받는다 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다', async () => { + expect(await constraintsOf({ images: 'show', q: '계약' })).toEqual([]); + expect(await constraintsOf({ after: '2026-10-31T15:00:00.000Z' })).toEqual([ + 'whitelistValidation', + ]); + expect(await constraintsOf({ hasAttachment: 'true' })).toEqual([ + 'whitelistValidation', + ]); + }); +}); + +describe('메일 한 통 읽기 쿼리 — 받는 칸은 계약과 같다 (14단계 리뷰 M2 · M3)', () => { + it('q · from · to · subject · body 의 낱말은 합쳐 8개까지 — 목록과 같다', async () => { + const words = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`).join( + ' ', + ); + expect( + await constraintsOf({ q: words('q', 4), subject: words('s', 4) }), + ).toEqual([]); + expect( + await constraintsOf({ q: words('q', 4), body: words('b', 5) }), + ).toEqual(['maxCombinedSearchWords']); + }); + + it('images · q · from · to · subject · body 밖의 이름(cursor · limit · mailboxId · after · before · hasAttachment)은 400', async () => { + expect( + await constraintsOf({ + images: 'show', + q: '계약', + from: '이도윤', + to: 'ara', + subject: '견적', + body: '일정', + }), + ).toEqual([]); + for (const name of [ + 'cursor', + 'limit', + 'mailboxId', + 'after', + 'before', + 'hasAttachment', + ]) { + expect(await constraintsOf({ [name]: '1' })).toEqual([ + 'whitelistValidation', + ]); + } + }); +}); diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.ts b/backend/src/modules/mail/dto/message-detail-query.dto.ts index 6c24fb1..aa00ca3 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.ts @@ -1,12 +1,23 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional } from 'class-validator'; +import { + MAX_SEARCH_LENGTH, + MAX_SEARCH_WORDS, +} from '@/modules/mail/mail.constants'; +import { SearchTextField } from './search-query-fields'; + /** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */ export const SHOW_IMAGES = 'show'; +/** 강조에 쓰는 검색 칸의 설명 꼬리 */ +const HIGHLIGHT_RULE = `검색 결과에서 연 메일을 강조할 때 목록과 같은 값을 보낸다 — 검사는 목록의 검색어와 같다(다섯 칸의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지)`; + /** * GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도). - * 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001 + * 없으면 그 직원의 설정(externalImages)을 따른다. 검색 칸(q · from · to · subject · body — 14단계)을 받으면 맞은 곳을 강조한다 + * (searchHighlight · htmlBody 의 ``). from · to 는 받기만 하고 강조하지 않는다. 모르는 값 · 모르는 쿼리는 400 VAL_001 — + * 목록에만 있는 cursor · limit · mailboxId · after · before · hasAttachment 도 받지 않는다(계약 2절 "검색 — 14단계" — 화면은 다섯 칸만 넘긴다) */ export class MessageDetailQueryDto { @ApiPropertyOptional({ @@ -18,4 +29,39 @@ export class MessageDetailQueryDto { @IsOptional() @IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' }) images?: typeof SHOW_IMAGES; + + @ApiPropertyOptional({ + description: `검색어 — 제목 · 본문 · 첨부 이름의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('검색어', '검색어는') + q?: string; + + @ApiPropertyOptional({ + description: `보낸 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('보낸 사람', '보낸 사람은') + from?: string; + + @ApiPropertyOptional({ + description: `받는 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('받는 사람', '받는 사람은') + to?: string; + + @ApiPropertyOptional({ + description: `제목 조건 — 제목의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('제목', '제목은') + subject?: string; + + @ApiPropertyOptional({ + description: `내용 조건 — 본문의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('내용', '내용은') + body?: string; } diff --git a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts index 7bd7b15..21c3524 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts @@ -1,6 +1,9 @@ +import { BadRequestException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { startCpuTimer } from '@/../test/cpu-time'; +import { createValidationPipe } from '@/app.setup'; import { MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, @@ -63,10 +66,10 @@ describe('MessageListQueryDto (GET /api/messages)', () => { ); expect( (await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints, - ).toEqual(['maxSearchWords']); + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); expect( (await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints, - ).toEqual(['maxSearchWords']); + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); }); it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => { @@ -75,3 +78,154 @@ describe('MessageListQueryDto (GET /api/messages)', () => { ); }); }); + +describe('MessageListQueryDto — 조건 검색 (14단계)', () => { + const TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const; + + it('글 조건(from · to · subject · body)은 검색어와 같다 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것', async () => { + for (const field of TEXT_FIELDS) { + const trimmed = await check({ [field]: ' 이도윤 계약 ' }); + expect(trimmed.constraints).toEqual([]); + expect(trimmed.dto[field]).toBe('이도윤 계약'); + + const blank = await check({ [field]: ' \u3000 ' }); + expect(blank.constraints).toEqual([]); + expect(blank.dto[field]).toBeUndefined(); + } + }); + + it(`글 조건도 앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지, 글이 아니면 형식 오류`, async () => { + const words = (count: number) => + Array.from({ length: count }, (_, index) => `w${index}`).join(' '); + for (const field of TEXT_FIELDS) { + expect( + (await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH) })).constraints, + ).toEqual([]); + expect( + (await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH + 1) })) + .constraints, + ).toEqual(['maxCodeUnits']); + expect( + (await check({ [field]: words(MAX_SEARCH_WORDS) })).constraints, + ).toEqual([]); + expect( + (await check({ [field]: words(MAX_SEARCH_WORDS + 1) })).constraints, + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); + expect((await check({ [field]: ['a', 'b'] })).constraints).toContain( + 'isString', + ); + } + }); + + it('기간(after · before)은 ISO 8601 UTC(Z)만 — 없는 날짜 · 시간대 표기 · 날짜만 · 빈 값은 형식 오류', async () => { + expect( + ( + await check({ + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00Z', + }) + ).constraints, + ).toEqual([]); + for (const value of [ + '', + '2026-11-01', + '2026-11-01T00:00:00+09:00', + '2026-02-30T00:00:00.000Z', + '2026-11-01T00:00:00.0000Z', + 'yesterday', + ]) { + expect((await check({ after: value })).constraints).toEqual([ + 'isUtcDateTime', + ]); + expect((await check({ before: value })).constraints).toEqual([ + 'isUtcDateTime', + ]); + } + }); + + it('첨부 조건은 true 만 — false · 1 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => { + expect((await check({ hasAttachment: 'true' })).constraints).toEqual([]); + for (const hasAttachment of ['false', '1', 'TRUE', '']) { + expect((await check({ hasAttachment })).constraints).toEqual(['isIn']); + } + expect((await check({ cc: 'ara' })).constraints).toEqual([ + 'whitelistValidation', + ]); + }); + + it('전역 검사 그대로(stopAtFirstError) — 긴 글은 길이에서 멈춘다: 낱말을 나누지 않고 싸게 400 (CPU 시간)', async () => { + const pipe = createValidationPipe(); + // 낱말 10만 개 · 30만 자 — 길이(MaxCodeUnits — 길이만 본다)에서 걸려 낱말 수 검사(나누기)까지 가지 않는다 + const huge = 'ab '.repeat(100_000); + for (const field of ['q', ...TEXT_FIELDS]) { + const cpuMs = startCpuTimer(); + const error = await pipe + .transform( + { [field]: huge }, + { type: 'query', metatype: MessageListQueryDto }, + ) + .then( + () => null, + (caught: unknown) => caught, + ); + // 지금 수 ms — 부하를 견디게 10배 넘게 + expect(cpuMs()).toBeLessThan(500); + expect(error).toBeInstanceOf(BadRequestException); + const { message } = (error as BadRequestException).getResponse() as { + message: string[]; + }; + expect(message).toHaveLength(1); + expect(message[0]).toContain(`${MAX_SEARCH_LENGTH}자 이하`); + } + }); +}); + +describe('MessageListQueryDto — 검색어와 조건을 합친 낱말 수 (14단계 리뷰 M2)', () => { + const words = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' '); + + it(`q 와 조건 칸 넷(from · to · subject · body)의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지 — 넘으면 한 번만 알린다`, async () => { + expect( + ( + await check({ + q: words('q', 2), + from: words('f', 2), + to: words('t', 2), + subject: words('s', 1), + body: words('b', 1), + }) + ).constraints, + ).toEqual([]); + expect( + (await check({ q: words('q', 4), to: words('t', 5) })).constraints, + ).toEqual(['maxCombinedSearchWords']); + // 검색어 없이 조건만이어도 같다 + expect( + (await check({ from: words('f', 5), body: words('b', 4) })).constraints, + ).toEqual(['maxCombinedSearchWords']); + }); + + it('기간 · 첨부 · 메일함은 세지 않는다 · 따옴표 안의 낱말은 센다', async () => { + expect( + ( + await check({ + q: words('q', MAX_SEARCH_WORDS), + after: '2026-10-31T15:00:00.000Z', + hasAttachment: 'true', + mailboxId: 'a', + }) + ).constraints, + ).toEqual([]); + expect( + (await check({ q: `"${words('q', 5)}"`, subject: words('s', 4) })) + .constraints, + ).toEqual(['maxCombinedSearchWords']); + }); + + it('칸 하나가 길이를 넘으면 그 칸의 검사만 — 합친 수를 세려고 긴 글을 나누지 않는다', async () => { + // (이 검사는 stopAtFirstError 를 뺀 것이라 그 칸의 규칙이 모두 보인다 — 합친 수는 보지 않았다) + expect( + (await check({ q: '계약', to: 'ab '.repeat(100) })).constraints, + ).toEqual(['maxCodeUnits', 'maxSearchWords']); + }); +}); diff --git a/backend/src/modules/mail/dto/message-list-query.dto.ts b/backend/src/modules/mail/dto/message-list-query.dto.ts index 8fa807a..c8e3b54 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.ts @@ -1,61 +1,29 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform, type TransformFnParams } from 'class-transformer'; -import { - IsOptional, - IsString, - Matches, - ValidateBy, - type ValidationOptions, -} from 'class-validator'; +import { IsIn, IsOptional, Matches } from 'class-validator'; -import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; import { MAIL_ID_PATTERN, MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, - SEARCH_WORD_SEPARATOR, } from '@/modules/mail/mail.constants'; +import { IsUtcDateTime } from './mail-vacation.dto'; +import { SearchTextField } from './search-query-fields'; + +/** 첨부 있음 조건의 값 — 이것만 받는다 (없으면 첨부와 상관없이) */ +export const HAS_ATTACHMENT_ONLY = 'true'; + +/** 글 칸의 설명 꼬리 — 검사는 검색어와 같다 */ +const TEXT_FIELD_RULE = `앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지(검색어와 같은 검사 — q 와 조건 넷의 낱말을 합쳐서도 ${MAX_SEARCH_WORDS}개까지), 공백뿐이면 없는 것으로 본다. 큰따옴표 구절 · 앞뒤 문장부호 떼기 · NUL 을 U+FFFD 로 바꾸기도 검색어와 같다`; /** - * 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색"). - * 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다. - */ -function normalizeSearchQuery({ value }: TransformFnParams): unknown { - const raw: unknown = value; - if (typeof raw !== 'string') return raw; - const trimmed = raw.trim(); - return trimmed === '' ? undefined : trimmed; -} - -/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */ -function countSearchWords(text: string): number { - return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length; -} - -/** 최대 낱말 수 */ -function MaxSearchWords(max: number, options: ValidationOptions) { - return ValidateBy( - { - name: 'maxSearchWords', - constraints: [max], - validator: { - validate: (value: unknown) => - typeof value === 'string' && countSearchWords(value) <= max, - }, - }, - options, - ); -} - -/** - * GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어. - * mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages) + * GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 + 조건(14단계). + * mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (message-list.service.ts) */ export class MessageListQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ description: - '메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)', + '메일함 id — 없으면 검색(q 나 조건)이 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (둘 다 없으면 400 VAL_001)', example: 'a', }) @IsOptional() @@ -64,21 +32,76 @@ export class MessageListQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ description: - `검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` + - '보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' + - '한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' + - '낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다', + `검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지(조건 from · to · subject · body 의 낱말과 합쳐서도 ${MAX_SEARCH_WORDS}개까지). 공백뿐이면 없는 것으로 본다. ` + + '보낸 사람·받는 사람·참조·제목·첨부 이름·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' + + '색인이 준비됐으면(GET /api/search-index 의 ready) 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 없이), ' + + '준비 중이면 낱말이 통째로 같을 때만 맞고 한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다. ' + + '낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·조건·범위로만 쓴다', example: '서버 이전', maxLength: MAX_SEARCH_LENGTH, }) - @Transform(normalizeSearchQuery) - @IsOptional() - @IsString({ message: '검색어 형식이 올바르지 않습니다.' }) - @MaxCodeUnits(MAX_SEARCH_LENGTH, { - message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`, - }) - @MaxSearchWords(MAX_SEARCH_WORDS, { - message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, - }) + @SearchTextField('검색어', '검색어는') q?: string; + + @ApiPropertyOptional({ + description: `보낸 사람 조건 — 보낸 사람의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`, + example: '이도윤', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('보낸 사람', '보낸 사람은') + from?: string; + + @ApiPropertyOptional({ + description: `받는 사람 조건 — 받는 사람과 참조의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`, + example: 'ara.kim', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('받는 사람', '받는 사람은') + to?: string; + + @ApiPropertyOptional({ + description: `제목 조건. ${TEXT_FIELD_RULE}`, + example: '견적', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('제목', '제목은') + subject?: string; + + @ApiPropertyOptional({ + description: `내용 조건 — 본문에서 찾는다(첨부 파일 안의 글은 찾지 않는다). ${TEXT_FIELD_RULE}`, + example: '계약', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('내용', '내용은') + body?: string; + + @ApiPropertyOptional({ + description: + '받은 시각이 이 시각 이후(이상)인 메일만 — ISO 8601 UTC(Z). 화면은 브라우저 시간대의 시작 날짜 0시를 UTC 로 바꿔 보낸다', + example: '2026-10-31T15:00:00.000Z', + }) + @IsOptional() + @IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' }) + after?: string; + + @ApiPropertyOptional({ + description: + '받은 시각이 이 시각 앞(미만)인 메일만 — ISO 8601 UTC(Z). 화면은 끝 날짜의 다음 날 0시를 보낸다', + example: '2026-11-30T15:00:00.000Z', + }) + @IsOptional() + @IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' }) + before?: string; + + @ApiPropertyOptional({ + description: + 'true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정 — 본문 그림만 있는 메일은 빠진다). 다른 값은 400 VAL_001', + enum: [HAS_ATTACHMENT_ONLY], + example: HAS_ATTACHMENT_ONLY, + }) + @IsOptional() + @IsIn([HAS_ATTACHMENT_ONLY], { + message: '첨부 조건이 올바르지 않습니다.', + }) + hasAttachment?: typeof HAS_ATTACHMENT_ONLY; } diff --git a/backend/src/modules/mail/dto/search-query-fields.ts b/backend/src/modules/mail/dto/search-query-fields.ts new file mode 100644 index 0000000..2ad2f53 --- /dev/null +++ b/backend/src/modules/mail/dto/search-query-fields.ts @@ -0,0 +1,121 @@ +import { applyDecorators } from '@nestjs/common'; +import { Transform, type TransformFnParams } from 'class-transformer'; +import { + IsOptional, + IsString, + ValidateBy, + type ValidationArguments, + type ValidationOptions, +} from 'class-validator'; + +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + MAX_SEARCH_LENGTH, + MAX_SEARCH_WORDS, + SEARCH_WORD_SEPARATOR, +} from '@/modules/mail/mail.constants'; + +/** + * 검색 글 칸의 검사 (계약 2절 "검색" · "검색 — 14단계" — 검색어 q 와 조건 검색의 from · to · subject · body 가 같이 쓴다). + * 앞뒤 공백을 지우고 공백뿐이면 없는 것으로 본다. 앞뒤 공백을 뺀 1~MAX_SEARCH_LENGTH 자(JS 문자열 길이) · 낱말 MAX_SEARCH_WORDS 개까지, + * 그리고 **다섯 칸을 합친 낱말도 MAX_SEARCH_WORDS 개까지** — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻는다(3단계에서 잰 비용 안에 두려고). + * 검사는 적은 차례대로 돈다(applyDecorators — 8단계 DTO 와 같다): 형식 → 길이 → 낱말 수 → 합친 낱말 수. 길이에서 걸리면 낱말을 나누지 않는다 + */ + +/** 낱말 수를 합쳐 세는 검색 글 칸 — 목록(GET /api/messages)과 읽기(GET /api/messages/{id})의 같은 이름 */ +export const SEARCH_TEXT_FIELDS = [ + 'q', + 'from', + 'to', + 'subject', + 'body', +] as const; + +/** 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 — 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다 */ +function normalizeSearchText({ value }: TransformFnParams): unknown { + const raw: unknown = value; + if (typeof raw !== 'string') return raw; + const trimmed = raw.trim(); + return trimmed === '' ? undefined : trimmed; +} + +/** 검색 글의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */ +export function countSearchWords(text: string): number { + return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length; +} + +/** 최대 낱말 수 */ +function MaxSearchWords(max: number, options: ValidationOptions) { + return ValidateBy( + { + name: 'maxSearchWords', + constraints: [max], + validator: { + validate: (value: unknown) => + typeof value === 'string' && countSearchWords(value) <= max, + }, + }, + options, + ); +} + +/** + * 다섯 칸을 합친 낱말 수 — 값이 있는 첫 칸에서만 센다(한 번만 알린다). 다른 칸이 글이 아니거나 길이를 넘으면 세지 않는다 — + * 그 칸의 검사가 막는다(긴 글을 나누지 않게) + */ +export function combinedSearchWords( + object: Readonly>, +): number | null { + let total = 0; + for (const field of SEARCH_TEXT_FIELDS) { + const value = object[field]; + if (value === undefined) continue; + if (typeof value !== 'string' || value.length > MAX_SEARCH_LENGTH) { + return null; + } + total += countSearchWords(value); + } + return total; +} + +function MaxCombinedSearchWords(max: number, options: ValidationOptions) { + return ValidateBy( + { + name: 'maxCombinedSearchWords', + constraints: [max], + validator: { + validate: (_value: unknown, args?: ValidationArguments) => { + const object = (args?.object ?? {}) as Record; + const first = SEARCH_TEXT_FIELDS.find( + (field) => object[field] !== undefined, + ); + if (first !== args?.property) return true; + const total = combinedSearchWords(object); + return total === null || total <= max; + }, + }, + }, + options, + ); +} + +/** + * 검색 글 칸 하나 — `label` 은 검사 글의 이름(검색어 · 보낸 사람 …), `topic` 은 조사를 붙인 꼴(검색어는 · 보낸 사람은 …). + * 검사 글은 로그 · 응답에 나가지 않는다(응답은 늘 VAL_001 의 사전 문구 — 계약 머리말) + */ +export function SearchTextField(label: string, topic: string) { + return applyDecorators( + Transform(normalizeSearchText), + IsOptional(), + IsString({ message: `${label} 형식이 올바르지 않습니다.` }), + MaxCodeUnits(MAX_SEARCH_LENGTH, { + message: `${topic} ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`, + }), + MaxSearchWords(MAX_SEARCH_WORDS, { + message: `${topic} 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, + }), + MaxCombinedSearchWords(MAX_SEARCH_WORDS, { + message: `검색어와 조건은 낱말을 모두 합쳐 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, + }), + ); +} diff --git a/backend/src/modules/mail/html/html-mark.spec.ts b/backend/src/modules/mail/html/html-mark.spec.ts new file mode 100644 index 0000000..4e22f13 --- /dev/null +++ b/backend/src/modules/mail/html/html-mark.spec.ts @@ -0,0 +1,176 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { MAX_HTML_MARKS, markHtmlText } from './html-mark'; +import { sanitizeMailHtml } from './sanitize-mail-html'; + +const marked = (html: string, ...terms: string[]) => + markHtmlText(html, terms).html; + +const countMarks = (html: string) => html.split('').length - 1; + +describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => { + it('글 조각의 맞은 곳에 — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => { + expect(marked('

계약 갱신 안내

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

계약 갱신 안내

', + ); + expect(marked('

ERP 와 Erp 와 ERP

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

ERP 와 Erp 와 ERP

', + ); + expect(markHtmlText('

계약 계약

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

계약서 갱신

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

계약서 갱신

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

계약

계약

계약

`)과 글을 그대로 두어야 하는 요소 + * (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로 + * - 글 조각마다 문자 참조를 풀어(& · < · > · " · ' ·   · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다 + * (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(& · < · > 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다 + * - 태그를 건너는 말(`계약`)은 강조하지 않는다 — 글 조각 안에서만 + * - 넣는 `` 는 `limit`(MAX_HTML_MARKS)개까지 + * 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다) + */ + +/** 한 본문에 넣는 `` 의 수 */ +export const MAX_HTML_MARKS = 1_000; + +/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */ +const MAX_REFERENCE_LENGTH = 32; + +/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 & · < · > 만 다시 적는다)과 흔한 것 */ +const NAMED_REFERENCES: Readonly> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', +}; + +/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */ +const RAW_TEXT_TAGS: ReadonlySet = new Set([ + 'script', + 'style', + 'textarea', + 'title', + 'xmp', +]); + +const COMMENT_OPEN = ''; +const MAX_CODE_POINT = 0x10ffff; +const REPLACEMENT_CHARACTER = '\ufffd'; + +/** 태그 이름 (` diff --git a/frontend/src/components/mail/ListReadNav.spec.ts b/frontend/src/components/mail/ListReadNav.spec.ts index 1359f17..f5f992b 100644 --- a/frontend/src/components/mail/ListReadNav.spec.ts +++ b/frontend/src/components/mail/ListReadNav.spec.ts @@ -158,3 +158,114 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => { expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`) }) }) + +describe('ListReadNav — 14단계 검색의 빈 쪽', () => { + let pinia: Pinia + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + }) + + afterEach(() => { + useToastStore().clear() + restoreApiAdapter() + }) + + it('[다음]은 빈 쪽에 멈추지 않는다 — 다음 메일이 나올 때까지 커서로 이어 묻고 그 메일로 간다', async () => { + const requests = installMockApi(({ params }) => { + if (params.cursor === 'c2') return ok({ items: [], pageInfo: { hasNext: true, nextCursor: 'c3', total: null } }) + return ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } }) + }) + const store = useMailStore() + store.listSource = { kind: 'search', q: '회의', scope: 'all' } + store.messages = [summary('m1'), summary('m2'), summary('m3')] + store.pageInfo = { hasNext: true, nextCursor: 'c2', total: null } + store.listStatus = 'ready' + const query = `q=${encodeURIComponent('회의')}&in=all` + const router = createTestRouter() + await router.push(`/mail/search?${query}`) + await router.push(`/mail/search/m3?${query}`) + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + + // 전체 수를 모르면 "n / 전체" 를 보이지 않는다 + expect(wrapper.find('.nav-position').exists()).toBe(false) + + await wrapper.get('button[aria-label="다음 메일"]').trigger('click') + await flushPromises() + + expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([ + 'c2', + 'c3', + ]) + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`) + }) + + it('뒤에서 결과를 이어 받는 중에 누른 [다음]은 그 일을 기다렸다가 넘어간다 — 기다리는 동안 꺼지고 aria-busy, 새로 묻지 않는다', async () => { + let resolveSecond: (value: ReturnType) => void = () => undefined + const second = new Promise>((done) => { + resolveSecond = done + }) + const requests = installMockApi(({ url, params }) => { + if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 }) + if (!params.cursor) { + return ok({ items: [summary('m1'), summary('m2'), summary('m3')], pageInfo: { hasNext: true, nextCursor: 'c2', total: null } }) + } + return second + }) + const store = useMailStore() + const opening = store.openSearch('회의', 'all') + await flushPromises() + expect(store.isLoadingMore).toBe(true) + const query = `q=${encodeURIComponent('회의')}&in=all` + const router = createTestRouter() + await router.push(`/mail/search?${query}`) + await router.push(`/mail/search/m3?${query}`) + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + const next = wrapper.get('button[aria-label="다음 메일"]') + // 이어 받는 중이면 끝에 있어도 [다음]을 누를 수 있다 + expect(next.attributes('disabled')).toBeUndefined() + + await next.trigger('click') + await flushPromises() + expect(next.attributes('disabled')).toBeDefined() + expect(next.attributes('aria-busy')).toBe('true') + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`) + + resolveSecond(ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } })) + await opening + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`) + expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([ + undefined, + 'c2', + ]) + }) + + it('목록을 보이는 채로 새로 고치는 동안 받아야 할 [다음]은 꺼지고 aria-busy — 끝나면 다시 누를 수 있다', async () => { + const store = useMailStore() + store.currentMailboxId = 'inbox' + store.messages = [summary('m1'), summary('m2'), summary('m3')] + store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 } + store.listStatus = 'ready' + store.isRefreshing = true + const router = createTestRouter() + await router.push('/mail/inbox') + await router.push('/mail/inbox/m3') + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + const next = wrapper.get('button[aria-label="다음 메일"]') + + expect(next.attributes('disabled')).toBeDefined() + expect(next.attributes('aria-busy')).toBe('true') + + store.isRefreshing = false + await flushPromises() + expect(next.attributes('disabled')).toBeUndefined() + expect(next.attributes('aria-busy')).toBe('false') + }) +}) diff --git a/frontend/src/components/mail/ListReadNav.vue b/frontend/src/components/mail/ListReadNav.vue index 4af15da..599abe3 100644 --- a/frontend/src/components/mail/ListReadNav.vue +++ b/frontend/src/components/mail/ListReadNav.vue @@ -34,21 +34,29 @@ const listHref = computed(() => { }) /** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */ const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId)) -/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */ -const total = computed( - () => - mailStore.pageInfo?.total ?? - (mailStore.search ? null : mailStore.currentMailbox?.totalCount) ?? - mailStore.messages.length, -) +/** + * 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수. + * 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다 + */ +const total = computed(() => { + const known = mailStore.pageInfo?.total ?? null + if (known !== null) return known + if (mailStore.search) return null + return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length +}) const position = computed(() => - index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`, + index.value === -1 || total.value === null ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`, ) const hasPrev = computed(() => index.value > 0) -/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */ +/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */ const hasNext = computed( () => - index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore), + index.value !== -1 && + (index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore), +) +/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */ +const nextWaiting = computed( + () => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing, ) async function goPrev() { @@ -57,13 +65,19 @@ async function goPrev() { if (previous && source) await showSiblingIn(source, previous.id) } +/** + * 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(14단계)이면 그 일이 끝나기를 기다리고, + * 그 안에 다음 메일이 없었으면 이번 누름의 차례로 한 번 더 받는다. 기다리는 동안 단추는 꺼지고 aria-busy + */ async function goNext() { - if (loadingNext.value || index.value === -1) return + if (loadingNext.value || nextWaiting.value || index.value === -1) return const current = index.value - if (!mailStore.messages[current + 1] && mailStore.hasMore) { + if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) { loadingNext.value = true try { + const waited = mailStore.isLoadingMore await mailStore.loadMore() + if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) await mailStore.loadMore() } finally { loadingNext.value = false } @@ -115,8 +129,8 @@ function onBackToList(event: MouseEvent) { type="button" class="nav-button" aria-label="다음 메일" - :disabled="!hasNext || loadingNext" - :aria-busy="loadingNext" + :disabled="!hasNext || loadingNext || nextWaiting" + :aria-busy="loadingNext || nextWaiting" @click="goNext" > /api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts). // 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다 // 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다 +// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 , 글 본문은 textHighlights 의 위치로 (mail-html.ts) import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { SearchRange } from '@/shared/types/mail' import { buildMailDocument } from '@/shared/utils/mail-html' interface Props { @@ -29,6 +31,8 @@ interface Props { title?: string /** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */ foldLargeFiles?: boolean + /** 글 본문의 검색에서 맞은 곳 — 서버의 searchHighlight.textBody (14단계 — 글 본문을 보일 때만 쓴다) */ + textHighlights?: readonly SearchRange[] | null } interface Emits { @@ -47,6 +51,7 @@ const props = withDefaults(defineProps(), { imagesShown: false, title: '메일 본문', foldLargeFiles: false, + textHighlights: null, }) const emit = defineEmits() @@ -63,6 +68,7 @@ const srcdoc = computed(() => textBody: props.textBody, imagesShown: props.imagesShown, foldLargeFiles: props.foldLargeFiles, + textHighlights: props.textHighlights, }), ) diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue index 9f8595f..024affb 100644 --- a/frontend/src/components/mail/MailListFrame.vue +++ b/frontend/src/components/mail/MailListFrame.vue @@ -4,6 +4,8 @@ // 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다), // 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice) // - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다) +// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내). +// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore) // - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다) // - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다 import { computed, nextTick, ref, watch } from 'vue' @@ -14,8 +16,11 @@ import MailListHeader from '@/components/mail/MailListHeader.vue' import MailLoadError from '@/components/mail/MailLoadError.vue' import MailNewNotice from '@/components/mail/MailNewNotice.vue' import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue' +import MailSearchConditionLine from '@/components/mail/MailSearchConditionLine.vue' import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue' import MailSearchHeader from '@/components/mail/MailSearchHeader.vue' +import MailSearchMore from '@/components/mail/MailSearchMore.vue' +import MailSearchNotices from '@/components/mail/MailSearchNotices.vue' import MailSearchScope from '@/components/mail/MailSearchScope.vue' import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue' import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue' @@ -26,7 +31,8 @@ import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation' import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail' import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later' import { isImeComposing } from '@/shared/utils/keyboard-shortcut' -import { isScheduledSource } from '@/shared/utils/mail-list-source' +import { isScheduledSource, isSearchTooLong } from '@/shared/utils/mail-list-source' +import { hasSearchConditions, skipsBodySearch } from '@/shared/utils/mail-search' import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox' import { useMailStore } from '@/stores/mail.store' @@ -71,12 +77,34 @@ const retentionNotice = computed(() => { if (text === null) return null return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text }) +/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */ +const searchConditions = computed(() => + search.value && hasSearchConditions(search.value.conditions) ? (search.value.conditions ?? null) : null, +) +/** 검색어와 조건의 낱말이 한도를 넘는 주소 — 찾지 않았다, 결과 없음 자리에 그 까닭을 보인다 (14단계) */ +const searchTooLong = computed(() => isSearchTooLong(search.value)) +/** 지난 메일의 색인을 만드는 중 — 결과 머리 아래 "검색 준비 중" 줄 · 결과 없음의 한계 안내 (14단계) */ +const indexing = computed(() => mailStore.searchIndexStatus === 'building') +/** 한 글자 낱말뿐이라 본문을 찾지 않았다 — 색인 방식(준비가 끝남)일 때만 알린다 (14단계) */ +const singleChar = computed( + () => + Boolean(search.value) && + !searchTooLong.value && + mailStore.searchIndexStatus === 'ready' && + skipsBodySearch(search.value?.q ?? '', search.value?.conditions), +) +/** 더 받는 중 · 보이는 채로 새로 고치는 중 — [메일 더 보기] · [더 찾기]를 막는다 (새로 고치는 동안 옛 커서로 붙이지 않게) */ +const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing) +/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */ +const searchPending = computed( + () => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore, +) /** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */ const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로')) function retry() { const current = search.value - if (current) void mailStore.openSearch(current.q, current.scope, { force: true }) + if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions }) else if (scheduled.value) void mailStore.openScheduled() else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId) } @@ -107,6 +135,18 @@ function onKeydown(event: KeyboardEvent) { mailStore.clearChecked() } +// 조건 줄의 [조건 지우기] · 결과를 기다리던 [더 찾기]는 누르면 그 자리가 사라진다 — 초점을 잃었으면 목록 칸으로 (14단계) +watch( + [() => searchConditions.value !== null, searchPending], + ([hasConditions, pending], [hadConditions, wasPending]) => { + if ((hadConditions && !hasConditions) || (wasPending && !pending)) { + const active = document.activeElement + if (active === null || active === document.body) focusSection() + } + }, + { flush: 'post' }, +) + // 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로 watch( () => mailStore.checkedCount, @@ -159,7 +199,17 @@ watch( /> {{ retentionNotice }}

- + - {{ 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 @@ + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@