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:', ''], + ['[공지]', ''], + ['', ''], +];