generated from ttipo/project-base
feat(backend): 메일 검색 색인의 바탕(표 · 맞추기 · 지난 메일 채우기 · 준비 상태)을 더한다
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다. - 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와 mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다) - 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝) - 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전, 서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조 - GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘 - 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다) 리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB · 16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> = {}): 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: '이도윤 <doyun.lee@arasystem.test>',
|
||||
toText: 'ara.kim@arasystem.test',
|
||||
ccText: 'Partner <partner@example.com>',
|
||||
attachmentNames: '계약서.PDF',
|
||||
hasAttachment: true,
|
||||
preview: '계약서를 보내 드립니다',
|
||||
bodyText: '안녕하세요.\n계약서를 보내 드립니다.',
|
||||
foldFrom: '이도윤 <doyun.lee@arasystem.test>',
|
||||
foldTo: 'ara.kim@arasystem.test\npartner <partner@example.com>',
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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])|(?<![\ud800-\udbff])[\udc00-\udfff]/g;
|
||||
const REPLACEMENT_CHARACTER = '\ufffd';
|
||||
|
||||
/** 자르기 전에 볼 원문의 길이 — 풀어 쓴 한글(NFD)은 NFC 로 모으면 셋이 하나가 되므로 넉넉히 */
|
||||
const NFC_SLACK = 4;
|
||||
|
||||
/** 32비트 정수 열(size)의 상한 */
|
||||
const MAX_INT32 = 2_147_483_647;
|
||||
|
||||
/** 앞에서 코드 포인트 `max` 개까지 (서로게이트 쌍을 가르지 않는다) */
|
||||
export function takeCodePoints(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
let units = 0;
|
||||
let points = 0;
|
||||
while (units < text.length && points < max) {
|
||||
const code = text.charCodeAt(units);
|
||||
units +=
|
||||
code >= 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<string>();
|
||||
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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<IndexChanges>;
|
||||
/** 메일 글 — 그사이 없어진 메일은 빠진다 (차례는 ids 를 따르지 않아도 된다) */
|
||||
documents(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
options: IndexReadOptions,
|
||||
): Promise<IndexDocument[]>;
|
||||
/** 메일함 · 키워드만 — 그사이 없어진 메일은 빠진다 */
|
||||
mutable(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
options: IndexReadOptions,
|
||||
): Promise<IndexMutable[]>;
|
||||
/**
|
||||
* 받은 차례(최신부터) 한 쪽 — anchor(지난 쪽의 마지막 메일) 다음부터. anchor 가 없거나 그사이 지워졌으면 `position` 부터
|
||||
*/
|
||||
pageIds(
|
||||
user: string,
|
||||
from: { readonly anchor: string | null; readonly position: number },
|
||||
limit: number,
|
||||
options: IndexReadOptions,
|
||||
): Promise<IndexIdPage>;
|
||||
/** 모든 메일 id — 전체 대조 (그 바로 전의 state 와 함께) */
|
||||
allIds(user: string, options: IndexReadOptions): Promise<IndexAllIds>;
|
||||
}
|
||||
@@ -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<BuildOutcome | null> {
|
||||
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<BuildOutcome> {
|
||||
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<StepOutcome> {
|
||||
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<StepOutcome> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
/** 넣기에서 바꿀 칸 (열 이름) — 키(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<MailSearchStateEntity | null> {
|
||||
return this.dataSource.manager.findOneBy(MailSearchStateEntity, {
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 줄이 없으면 `building` 으로 만들고, 멈춰 있으면 푼다(실패 수도 0) — 직원이 로그인했으니 대리 접근이 다시 될 것이다.
|
||||
* 있고 멈추지 않았으면 쓰지 않는다(로그인 · 상태 묻기마다 줄을 고쳐 쓰지 않게).
|
||||
*
|
||||
* 퇴사 마무리 바로 뒤 남은 액세스 토큰으로 GET /api/search-index 가 오면 빈 상태 줄(building)이 다시 생길 수 있다 — 고치지 않는다:
|
||||
* 엔진 계정이 먼저 지워졌으므로 채우기 · 맞추기는 대리 접근에서 막혀(AUTH_001) 그 줄을 멈출 뿐 메일 줄은 생기지 않고,
|
||||
* 끊은 세션은 15초 안에 막힌다. 같은 주소를 새 사람이 쓰면 그 사람의 로그인이 그 줄로 처음부터 채운다(옛 메일 줄이 없다)
|
||||
*/
|
||||
async ensureState(
|
||||
userEmail: string,
|
||||
now: Date,
|
||||
): Promise<MailSearchStateEntity> {
|
||||
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<MailSearchStateEntity[]> {
|
||||
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<MailSearchStateEntity[]> {
|
||||
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<number> {
|
||||
// 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<void> {
|
||||
await this.dataSource.manager.update(
|
||||
MailSearchStateEntity,
|
||||
{ userEmail },
|
||||
{ pausedReason: reason, updatedAt: now },
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 직원의 색인 줄 수 */
|
||||
async countMessages(userEmail: string): Promise<number> {
|
||||
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<Set<string>> {
|
||||
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<string[]> {
|
||||
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<SearchIndexWriteResult> {
|
||||
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<string>() };
|
||||
}
|
||||
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<void> {
|
||||
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<Set<string>> {
|
||||
if (updates.length === 0) return new Set();
|
||||
const values: Record<string, { m: string[]; k: string[] }> = {};
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<MailSearchStateEntity, 'status' | 'buildDone' | 'buildTotal'>,
|
||||
): 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<void> {
|
||||
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<SearchIndexStatusView> {
|
||||
return toStatusView(
|
||||
await this.repository.ensureState(normalize(user), new Date()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(user: string): string {
|
||||
return user.trim().toLowerCase();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, Promise<void>>();
|
||||
|
||||
/** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */
|
||||
run<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
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<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.limit) {
|
||||
await new Promise<void>((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> | 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<IndexChanges> {
|
||||
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<IndexDocument[]> {
|
||||
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<IndexMutable[]> {
|
||||
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<IndexIdPage> {
|
||||
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<string, MailSearchStateEntity>();
|
||||
readonly rows = new Map<string, IndexRowValues>();
|
||||
readonly writes: SearchIndexWrite[] = [];
|
||||
|
||||
addState(
|
||||
overrides: Partial<MailSearchStateEntity> = {},
|
||||
): 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<string>() });
|
||||
}
|
||||
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<string>();
|
||||
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<void>((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<void>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T>(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<string, NodeJS.Timeout>();
|
||||
/** 줄에 넣었지만 아직 시작하지 않은 맞추기 — 같은 직원의 새 요청이 함께 기다린다 */
|
||||
private readonly queued = new Map<string, Promise<boolean>>();
|
||||
/** 서버 전체에서 함께 도는 맞추기 수 */
|
||||
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<MailSettings, 'domain'>,
|
||||
@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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<IndexChanges, { kind: 'changes' }>,
|
||||
deadline: number,
|
||||
): Promise<boolean> {
|
||||
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<IndexRowValues[] | null> {
|
||||
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<MailSearchIndexRepository['write']>[1],
|
||||
): Promise<boolean> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export const SYSTEM_SEARCH_TIMING: MailSearchTiming = {
|
||||
now: () => Date.now(),
|
||||
sleep: (ms) =>
|
||||
new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
timer.unref();
|
||||
}),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
callId: string,
|
||||
) => JmapInvocation;
|
||||
|
||||
/** 대역 엔진 — 메서드마다 답을 정한다. 받은 요청을 모은다 */
|
||||
class FakeJmap {
|
||||
readonly requests: { calls: JmapInvocation[]; options: JmapCallOptions }[] =
|
||||
[];
|
||||
responders = new Map<string, Responder>();
|
||||
/** 요청 전체를 이 예외로 거절한다 (한 번) */
|
||||
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:
|
||||
'<p>계약서를<b>보내</b> 드립니다</p><style>x{}</style>',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
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<string, unknown>[] = [];
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T>(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<string, boolean> | 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<string>();
|
||||
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<number, BodyReadCandidate[]>();
|
||||
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<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
's',
|
||||
'Email/get',
|
||||
);
|
||||
const query = expectResult<JmapQueryResult>(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<IndexChanges> {
|
||||
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<IndexDocument[]> {
|
||||
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<string, string>();
|
||||
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<IndexMutable[]> {
|
||||
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<IndexIdPage> {
|
||||
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<IndexAllIds> {
|
||||
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<IndexIdPage | null> {
|
||||
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<JmapEmail[]> {
|
||||
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<JmapGetResult<JmapEmail>>(
|
||||
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<JmapEmail[]> {
|
||||
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<JmapGetResult<JmapEmail>>(
|
||||
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<JmapCallOptions, 'timeoutMs'> = {},
|
||||
): Promise<JmapInvocation[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
/** 검색 색인 맞추기 대역 (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([
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,8 @@ const ROW_LABELS: Record<keyof AddressRows, string> = {
|
||||
directory: '사내 목록 기록',
|
||||
files: '대용량 파일',
|
||||
shareLinks: '대용량 첨부 링크',
|
||||
searchStates: '검색 색인 상태',
|
||||
searchMessages: '검색 색인(메일)',
|
||||
};
|
||||
|
||||
/** 남은 줄을 보이는 글 */
|
||||
|
||||
@@ -55,6 +55,8 @@ const NO_ROWS: AddressRows = {
|
||||
directory: 0,
|
||||
files: 0,
|
||||
shareLinks: 0,
|
||||
searchStates: 0,
|
||||
searchMessages: 0,
|
||||
};
|
||||
|
||||
function employee(overrides: Partial<EngineEmployee> = {}): EngineEmployee {
|
||||
|
||||
@@ -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<AddressPurge> {
|
||||
@@ -369,7 +374,9 @@ export class OpsDataService {
|
||||
| typeof MailUserSettingsEntity
|
||||
| typeof MailDirectoryEntryEntity
|
||||
| typeof StoredFileEntity
|
||||
| typeof ShareLinkEntity,
|
||||
| typeof ShareLinkEntity
|
||||
| typeof MailSearchStateEntity
|
||||
| typeof MailSearchMessageEntity,
|
||||
where: Record<string, string>,
|
||||
): Promise<number> => {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = new Set(),
|
||||
): Set<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>): 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<T> {
|
||||
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<AuthSettings>(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 다). 준비가 실패했으면 없다 */
|
||||
|
||||
@@ -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:', ''],
|
||||
['[공지]', ''],
|
||||
['', ''],
|
||||
];
|
||||
Reference in New Issue
Block a user