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:
2026-11-05 20:51:15 +09:00
parent ba80956d7e
commit 9887035bcd
46 changed files with 5411 additions and 8 deletions
+3
View File
@@ -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 }),
+8
View File
@@ -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 }),
+3
View File
@@ -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,
+2
View File
@@ -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 -2
View File
@@ -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 }),
};
}
}